Files
clinicpro/.claude/prompt/fix-public-booking-state-and-admin-context-slots.md
hamedandClaude Fable 5 7baa4df3d4 fix(booking): aggregate public booking state across all schedules
The public doctor payload built `active`/`free_turn`/`hours_of_work` from the
personal schedule alone, so a doctor bookable only at a clinic was reported as
"نوبت‌دهی غیرفعال". Aggregate over every schedule instead: any schedule with
online booking on and an active day makes the doctor bookable, and the disabled
label only appears when all of them are off.

Three admin-panel fixes for the same class of bug:

- AppointmentsPage took the selected doctor from `dbUuid`, which is the clinic's
  uuid inside a clinic context — the slots request 404'd. Use `doctorUuid`.
- TurnsTimeline rendered any error or unknown empty_reason as "این روز شیفت کاری
  ندارد". Errors now surface as errors and unknown reasons get a neutral message;
  the day-off wording is reserved for an explicit day_off from the backend.
- Admins have no clinic context, so slots fell back to the personal schedule.
  They now pick a location from `appointment-booking-locations` and that choice
  drives the slot, service and create-appointment requests.

Adds `app:schedule:normalize-format` for legacy rows stored as a bare JSON list
covering only Saturday, which read as day-off for the rest of the week.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:25:24 +03:30

14 KiB
Raw Permalink Blame History

وضعیت نوبت‌دهی عمومی از همهٔ برنامه‌ها + context درست اسلات‌ها در پنل

پروژه

clinicpro (Backend + پنل ادمین React)

پرامپت همتا در سایت عمومی: nobat724_front/.claude/prompt/doctor-profile-booking-state-from-locations.md (backend اول اجرا شود — فیلدهای active / free_turn پاسخ عمومی تغییر می‌کنند.)

زمینه — نتیجهٔ عیب‌یابی واقعی (curl + دیتابیس)

سه علامت گزارش‌شده دوباره بررسی شد. موتور اسلات سالم است — هر سه علامت از این است که «چه کسی، با چه contextی می‌پرسد». شواهد:

# دادهٔ دیتابیس — دکتر تست (doctor_id=3341, uuid bcabb3a8-…)، کلینیک 1003 (41e325c4-…):
weekly_schedules:
  2505  clinic_id=NULL  setting=[{"sessions":[{"active":false,…}]}]   ← شخصی، غیرفعال، فرمت لیستِ legacy
  2509  clinic_id=1003  روزهای 0..4 فعال 09:0013:00، location_id=2631، meta.online_booking_enabled=true

doctor_addresses: 2631 → type=clinic, clinic_id=1003 ✓

# تست مستقیم API (1405/04/27 = 2026-07-18):
GET /api/v1/appointment-slots?doctor_uuid=bcabb3a8…&date=2026-07-18&clinic_uuid=41e325c4…
  → sessions پر ✓
GET /api/v1/appointment-slots?doctor_uuid=bcabb3a8…&date=2026-07-18        (بدون clinic_uuid)
  → sessions=[] , empty_reason="day_off"      ← برنامهٔ شخصیِ 2505 خوانده می‌شود
GET /api/v1/appointment-booking-locations/bcabb3a8…
  → یک محل کلینیکی معتبر با opening_hours و next_available_at ✓
GET /api/v1/appointment-slots?doctor_uuid=<CLINIC-uuid>&…
  → 404 «دکتر یافت نشد»

مشکل / هدف

علامت ۱ — سایت عمومی: «نوبت‌دهی غیرفعال است» برای پزشکی که نوبت‌دهی فعال دارد

GET /api/v1/doctor/{uuid} فیلدهای active / free_turn / hours_of_work را از scheduleRepo->findByDoctor($doctor) می‌سازد که فقط برنامهٔ شخصی (clinic_id IS NULL) است. دکتر تست برنامهٔ شخصیِ غیرفعال دارد و برنامهٔ کلینیکش دیده نمی‌شود → active=false → سایت «نوبت‌دهی غیرفعال است» نشان می‌دهد.

علامت ۲ — پنل با کاربر ادمین: «این روز شیفت کاری ندارد»

useClinicContext() برای primaryRole === 'admin' مقدار null برمی‌گرداند (ادمین context کلینیکی ندارد) → اسلات‌ها بدون clinic_uuid گرفته می‌شوند → برنامهٔ شخصیِ 2505 → day_off.

علامت ۳ — پزشک دعوت‌شده در محیط کلینیک: همان پیام

AppointmentsPage.tsx:341 مقدار اولیهٔ پزشکِ انتخاب‌شده را از dbUuid می‌گیرد؛ برای پزشک دعوت‌شده در محیط کلینیک، dbUuid uuid کلینیک است نه پزشک → درخواست appointment-slots?doctor_uuid=<clinic-uuid> → 404 «دکتر یافت نشد» → و چون TurnsTimeline هر حالت ناشناخته/خطا را به day_off ترجمه می‌کند، پیام «این روز شیفت کاری ندارد» دیده می‌شود.

فایل‌های مرتبط

فایل نقش
src/Doctor/Entity/Doctor.php:417-432 computeScheduleFields(?WeeklySchedule) — تک‌برنامه‌ای
src/Doctor/Entity/Doctor.php:512-533 toListArray / toDetailArray مصرف‌کننده
src/Doctor/Controller/DoctorController.php:126,179,204,351 findByDoctor (فقط شخصی)
src/Doctor/Controller/DoctorController.php:259-265 /api/v1/doctors — map با overwrite دلبخواهی
src/Appointment/Repository/WeeklyScheduleRepository.php:40 findAllByDoctor (همهٔ contextها)
src/Appointment/Service/SlotCalculatorService.php:182 findNextAvailableStart per-context
assets/admin/pages/AppointmentsPage.tsx:341 selectedDoctorUuid از dbUuid
assets/admin/pages/AppointmentsPage.tsx:425-433 slots query (خودش درست است)
assets/admin/hooks/useClinicContext.ts برای admin مقدار null
assets/admin/components/appointments/TurnsTimeline.tsx:149-185 fallback به day_off
assets/admin/stores/authStore.ts doctorUuid (از context.doctor_uuid پر می‌شود)

وضعیت فعلی

۱. فیلدهای عمومی فقط از برنامهٔ شخصی

src/Doctor/Controller/DoctorController.php:179 (GET عمومی) و :351 (PATCH):

$schedule = $this->scheduleRepo->findByDoctor($doctor);   // @deprecated — فقط clinic IS NULL
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), [...

/api/v1/doctors (:259-265) — findByDoctors همهٔ برنامه‌ها (شخصی + کلینیک) را برمی‌گرداند و map با overwrite، برنامهٔ «آخری» را نگه می‌دارد — نتیجه دلبخواهی است:

$scheduleMap = [];
foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) {
    $scheduleMap[$schedule->getDoctor()->getId()] = $schedule;   // آخری برنده می‌شود
}

۲. انتخاب پزشک در پنل از dbUuid

assets/admin/pages/AppointmentsPage.tsx:341:

const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');

برای پزشک دعوت‌شده در محیط کلینیک (context = {type:'clinic', role:'doctor', scope:'clinic'}primaryRole='doctor' و dbUuid = uuid کلینیک است. authStore.doctorUuid (از context.doctor_uuid) uuid درستِ پزشک را دارد و استفاده نمی‌شود.

۳. TurnsTimeline خطا را «روز بدون شیفت» نشان می‌دهد

assets/admin/components/appointments/TurnsTimeline.tsx:180:

if (!slots.length) {
    const reason = EMPTY_REASON_TEXT[emptyReason ?? ''] ?? EMPTY_REASON_TEXT.day_off;

پاسخ 404، خطای شبکه، یا هر empty_reason ناشناخته → همیشه «این روز شیفت کاری ندارد».

وظایف

۱. تجمیع وضعیت نوبت‌دهی عمومی از همهٔ برنامه‌ها

Doctor::computeScheduleFields آرایه‌ای از برنامه‌ها بگیرد (امضای جدید: computeScheduleFields(WeeklySchedule[] $schedules)؛ null-tolerant برای سازگاری):

قواعد تجمیع:

  • has_schedule / active: حداقل یک برنامه (در هر context) که هم روز فعال دارد و هم meta.online_booking_enabled === true → true. برنامهٔ شخصیِ خاموش نباید برنامهٔ کلینیکی روشن را بپوشاند.
  • free_turn: نزدیک‌ترین روز/ساعت در بین همهٔ برنامه‌های فعال (همان حلقهٔ فعلی computeScheduleParts، اجراشده روی هر برنامه، سپس min بر اساس فاصلهٔ روز ایرانی).
  • hours_of_work: از همان برنامه‌ای که free_turn را داد ساخته شود (ترکیب ساعت‌های دو محل در یک رشته گمراه‌کننده است). اگر تصمیم دیگری گرفتی در PR توضیح بده.

سپس چهار call site در DoctorController (:126، :179، :204، :351) از findAllByDoctor($doctor) استفاده کنند و /api/v1/doctors (:259-265) map را به array<doctorId, WeeklySchedule[]> تبدیل کند (findByDoctors از قبل همه را می‌آورد — فقط دیگر overwrite نکن).

نکته: APPOINTMENT_DISABLED_LABEL وقتی برگردد که همهٔ برنامه‌ها online_booking_enabled=false باشند، نه فقط اولین برنامه (Doctor.php:423).

۲. uuid درست پزشک در AppointmentsPage

const doctorUuid = useAuthStore(s => s.doctorUuid);   // از context.doctor_uuid
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(
  isDoctor ? (doctorUuid ?? '') : ''
);

dbUuid فقط وقتی uuid پزشک است که context.type === 'doctor'؛ به آن اتکا نکن. بررسی کن NewAppointmentDrawer و بقیهٔ مصرف‌کننده‌های selectedDoctorUuid هم از همین مقدار تغذیه می‌شوند (prop می‌گیرند، پس با همین فیکس درست می‌شوند).

۳. TurnsTimeline: خطا ≠ روز بدون شیفت

  • AppointmentsPage باید slotsQuery.isError و پیام خطای API (errors[0].message) را به TurnsTimeline بدهد (prop جدید errorMessage?: string | null).
  • در TurnsTimeline: اول خطا (errorMessage → همان پیام + ظاهر خطا)، بعد EMPTY_REASON_TEXT[emptyReason]، و برای reason ناشناخته/غایب یک پیام خنثی: «برنامهٔ این روز در دسترس نیست» — هرگز پیش‌فرض day_off نگذار؛ آن پیام یعنی «backend صریحاً گفت این روز شیفت ندارد».
  • دقت: پاسخ خطای API با success:false می‌آید؛ lib/api.ts را ببین که آیا آن را throw می‌کند یا resolve — مسیر درست را بر همان اساس بنویس.

۴. انتخاب محل برای ادمین (و هر بیننده‌ای بدون context کلینیک)

ادمین context کلینیکی ندارد و نباید هم useClinicContext برایش چیزی جعل کند. راه درست: همان منبع سایت عمومی — GET /api/v1/appointment-booking-locations/{doctorUuid}:

  • در AppointmentsPage، وقتی isAdmin و پزشکی انتخاب شده، این endpoint را بگیر (query key شامل selectedDoctorUuid).
  • اگر بیش از یک محل بود، یک SearchableSelect (قانون پروژه — نه <select> بومی) برای انتخاب محل نمایش بده؛ پیش‌فرض = اولین آیتم (آرایه بر اساس next_available_at مرتب است).
  • clinic_uuid مؤثر برای slots/service-slots/booking-services در حالت ادمین از محل انتخاب‌شده بیاید (selected.clinic_uuid، که برای مطب شخصی null است)، نه از useClinicContext. برای نقش‌های clinic/doctor رفتار فعلی useClinicContext بماند.
  • endpoint عمومی است؛ نیازی به endpoint جدید نیست (قانون «اول توسعه، بعد ساخت»).

۵. عادی‌سازی فرمت legacy برنامهٔ شخصی

ردیف 2505 فرمت لیست دارد: [{"sessions":[…]}] — فقط ایندکس 0 (شنبه) تعریف است و meta ندارد. کد فعلی خطا نمی‌دهد ولی روزهای 1..6 برایش null است و meta از DEFAULT_META می‌آید. به console command موجود app:schedule:audit-locations (یا command جدید app:schedule:normalize-format اگر تفکیک مسئولیت تمیزتر است) حالت زیر را اضافه کن:

  • شناسایی ردیف‌هایی که setting آن‌ها آرایهٔ لیستی است یا کلیدهای "0".."6" کامل نیست.
  • با --fix به فرمت canonical ({"0":…,"6":…,"meta":…}) تبدیل کند؛ روزهای غایب {"sessions":[]} و meta غایب = DEFAULT_META. دادهٔ session موجود دست نخورد.

۶. تست و مستندات

تست‌ها در tests/Doctor/ و tests/Appointment/:

  1. پزشک با برنامهٔ شخصی غیرفعال + برنامهٔ کلینیکی فعال → GET /api/v1/doctor/{uuid} باید active=true و free_turn غیرتهی بدهد. (بازتولید مستقیم علامت ۱)
  2. پزشک با هر دو برنامه، هر دو online_booking_enabled=falsefree_turn = APPOINTMENT_DISABLED_LABEL.
  3. /api/v1/doctors: پزشک چندبرنامه‌ای — نتیجه مستقل از ترتیب ردیف‌های findByDoctors.
  4. (frontend) TurnsTimeline با errorMessage → پیام خطا؛ با emptyReason ناشناخته → پیام خنثی، نه day_off. (vitest موجود در assets/admin)

مستندات: docs/api/doctor.md — معنای جدید active / free_turn (تجمیع همهٔ محل‌ها) صریح ثبت شود؛ قانون ثابت پروژه.

نکات مهم

  • هیچ منطق موازی اسلات نسازSlotCalculatorService سالم است (با curl تأیید شد)؛ مشکل فقط انتخاب schedule/context در ورودی‌هاست.
  • findByDoctor از قبل @deprecated است؛ این تسک چهار مصرف‌کنندهٔ باقی‌مانده در DoctorController را حذف می‌کند — بعدش اگر مصرف‌کنندهٔ دیگری نماند، خود متد را حذف کن.
  • free_turn رشتهٔ فارسی نمایشی است (مثل «شنبه 09:00») — قراردادش را عوض نکن؛ nobat724_front همین رشته را خام نمایش می‌دهد.
  • تجمیع باید ارزان بماند: /api/v1/doctors صفحه‌ای ۱۰+ پزشک دارد؛ findByDoctors همین حالا همهٔ برنامه‌ها را در یک کوئری می‌آورد — کوئری اضافه per-doctor نزن.
  • کاربران تست: ادمین 09390039833، دکتر تست 09100652121 (uuid bcabb3a8-cae3-45ec-876c-548f9c1e1569)، مالک کلینیک 09024206041 (دو-نقشی)، کلینیک 41e325c4-e825-4067-8438-5d828ecaee09. کد OTP در dev همیشه 12345.
  • تست دستی پس از build (yarn build): سه سناریوی گزارش‌شده — (۱) /doctor/bcabb3a8… در سایت، (۲) /admin/appointments با ادمین برای ۱۴۰۵/۰۴/۲۷، (۳) همان صفحه با دکتر تست در محیط کلینیک برای ۱۴۰۵/۰۴/۳۰.
  • پاسخ‌ها طبق BaseController؛ تاریخ‌ها timestamp صحیح؛ رشته‌های جدید فارسی.