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>
14 KiB
وضعیت نوبتدهی عمومی از همهٔ برنامهها + 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:00–13: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/:
- پزشک با برنامهٔ شخصی غیرفعال + برنامهٔ کلینیکی فعال →
GET /api/v1/doctor/{uuid}بایدactive=trueوfree_turnغیرتهی بدهد. (بازتولید مستقیم علامت ۱) - پزشک با هر دو برنامه، هر دو
online_booking_enabled=false→free_turn=APPOINTMENT_DISABLED_LABEL. /api/v1/doctors: پزشک چندبرنامهای — نتیجه مستقل از ترتیب ردیفهایfindByDoctors.- (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(uuidbcabb3a8-cae3-45ec-876c-548f9c1e1569)، مالک کلینیک09024206041(دو-نقشی)، کلینیک41e325c4-e825-4067-8438-5d828ecaee09. کد OTP در dev همیشه12345. - تست دستی پس از build (
yarn build): سه سناریوی گزارششده — (۱)/doctor/bcabb3a8…در سایت، (۲)/admin/appointmentsبا ادمین برای ۱۴۰۵/۰۴/۲۷، (۳) همان صفحه با دکتر تست در محیط کلینیک برای ۱۴۰۵/۰۴/۳۰. - پاسخها طبق
BaseController؛ تاریخها timestamp صحیح؛ رشتههای جدید فارسی.