# وضعیت نوبت‌دهی عمومی از همهٔ برنامه‌ها + 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 صحیح؛ رشته‌های جدید فارسی.
