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>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
# وضعیت نوبتدهی عمومی از همهٔ برنامهها + 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):
|
||||
|
||||
```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 صحیح؛ رشتههای جدید فارسی.
|
||||
Reference in New Issue
Block a user