Files
clinicpro/.claude/prompt/fix-public-booking-state-and-admin-context-slots.md
T
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

221 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# وضعیت نوبت‌دهی عمومی از همهٔ برنامه‌ها + 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):
```php
$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، برنامهٔ «آخری» را نگه می‌دارد — نتیجه دلبخواهی است:
```php
$scheduleMap = [];
foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) {
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule; // آخری برنده می‌شود
}
```
### ۲. انتخاب پزشک در پنل از dbUuid
`assets/admin/pages/AppointmentsPage.tsx:341`:
```tsx
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`:
```tsx
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
```tsx
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=false``free_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 صحیح؛ رشته‌های جدید فارسی.