# اصلاح تشخیص روز کاری در پنل + حذف محل‌های جعلی از API عمومی

## پروژه

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

پرامپت همتا در سایت عمومی: `nobat724_front/.claude/prompt/booking-locations-day-aware.md`
(**backend اول اجرا شود** — قرارداد API تغییر می‌کند.)

## زمینه

در تغییرات قبلی، تنظیمات نوبت‌دهی per-context شد: هر پزشک یک برنامه برای مطب شخصی و یکی به ازای
هر کلینیک دارد (`weekly_schedules.clinic_id`، `NULL` = شخصی). همهٔ endpointهای اسلات پارامتر
اختیاری `clinic_uuid` گرفتند و **نبودِ آن یعنی «مطب شخصی»** — نه «هر برنامه‌ای که پیدا شد».

`ScheduleSection` (تنظیمات نوبت‌دهی) به‌درستی `clinic_uuid` را می‌فرستد، اما **بقیهٔ پنل ادمین
به‌روزرسانی نشد**. این یک رگرسیون است، نه یک قابلیت ناقص.

## مشکل / هدف

### مشکل ۱ — «این روز تعطیل است» در پنل

پزشک `09100652121` در محیط کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`، و مدیر همان کلینیک در
`/admin/appointments`، هر دو پیام «این روز تعطیل است» می‌بینند در حالی که برنامهٔ آن روز در محیط
کلینیک فعال است.

علت: صفحهٔ نوبت‌ها اسلات‌ها را **بدون `clinic_uuid`** می‌گیرد، پس backend برنامهٔ **مطب شخصی** را
می‌خواند. آن پزشک برنامهٔ شخصیِ تقریباً خالی دارد → صفر اسلات → پیام تعطیلی.

پیام هم گمراه‌کننده است: `TurnsTimeline` هیچ‌وقت تعطیلی را بررسی نمی‌کند، فقط
`slots.length === 0` را به «تعطیل» ترجمه می‌کند.

### مشکل ۲ — «مطب شخصی» جعلی در API عمومی

`GET /api/v1/appointment-booking-locations/{doctorUuid}` برای «دکتر تست» یک محل
`type: "personal"` برمی‌گرداند، در حالی که در دیتابیس:

```sql
-- برنامه‌های این پزشک: (id, clinic_id, تعداد روز)
2505  NULL  1      -- برنامهٔ شخصی
2509  1003  8      -- برنامهٔ کلینیک

-- آدرس‌های شخصی این پزشک:
(هیچ ردیفی)

-- location_id شیفت‌های برنامهٔ شخصی:
NULL
```

یعنی محل «مطب شخصی» **هیچ آدرسی ندارد** و شیفتش هم به هیچ آدرسی وصل نیست، ولی در سایت نمایش داده
می‌شود و حتی `opening_hours` تولید می‌کند. `openingHours()` فقط `active` را چک می‌کند و
`location_id` را نادیده می‌گیرد.

قاعدهٔ درست: محل نوبت‌دهی فقط وقتی وجود دارد که **هم آدرس ثبت شده باشد، هم آن آدرس در شیفت‌های
همان برنامه انتخاب شده باشد**.

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

| فایل | نقش |
|------|-----|
| `assets/admin/pages/AppointmentsPage.tsx:326` | صفحهٔ `/admin/appointments` |
| `assets/admin/pages/AppointmentsPage.tsx:422-428` | فراخوانی اسلات‌ها — بدون `clinic_uuid` |
| `assets/admin/components/appointments/TurnsTimeline.tsx:166-171` | پیام «این روز تعطیل است» |
| `assets/admin/hooks/useDoctorBookingServices.ts:24-28` | حالت نوبت‌دهی — بدون `clinic_uuid` |
| `assets/admin/components/appointments/ServiceSlotPicker.tsx:58-59` | اسلات سرویسی — بدون `clinic_uuid` |
| `assets/admin/components/NewAppointmentDrawer.tsx:61` | برنامهٔ هفتگی — بدون `clinic_uuid` |
| `assets/admin/pages/ClinicAppointmentSettingsPage.tsx:20-27` | الگوی درستِ استخراج `clinicUuid` |
| `src/Appointment/Controller/AppointmentController.php:270-305` | `bookingLocations()` |
| `src/Appointment/Controller/AppointmentController.php:~700` | `openingHours()` |
| `src/Appointment/Controller/MyAppointmentsController.php:144` | `resolveSlotLocationId` بدون context |
| `src/Admin/Controller/AdminApiController.php:933` | `resolveSlotLocationId` بدون context |
| `src/Appointment/Service/SlotCalculatorService.php` | منبع واحد تولید اسلات |

## وضعیت فعلی

### پنل: context حمل نمی‌شود

`assets/admin/pages/AppointmentsPage.tsx:422-428`:

```tsx
  const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate];
  const slotsQuery = useQuery<ApiResponse<any>>({
    queryKey: slotsQueryKey,
    queryFn: () => api.get(`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}`),
    enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
  });
```

`dbUuid` (uuid کلینیک) فقط برای گرفتن فهرست پزشکان استفاده می‌شود
(`/api/v1/clinic/doctor-list/${dbUuid}` در `:384`) و هرگز به‌عنوان `clinic_uuid` ارسال نمی‌شود.

### پیام گمراه‌کننده

`assets/admin/components/appointments/TurnsTimeline.tsx:166-171`:

```tsx
  if (!slots.length) return (
    <div style={{ padding: 40, textAlign: 'center' }}>
      <div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
      <div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>هیچ برنامه زمانبندی برای این روز تنظیم نشده است</div>
    </div>
  );
```

### محل بدون آدرس فیلتر نمی‌شود

`src/Appointment/Controller/AppointmentController.php:277-296`:

```php
        $locations = [];
        foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
            $clinic  = $schedule->getClinic();
            $meta    = $schedule->getMeta();
            $address = $this->addressRepo->findForContext($doctor, $clinic?->getId())[0] ?? null;

            $locations[] = [
                'location_uuid'     => $address?->getUuid(),
                'type'              => $clinic === null ? 'personal' : 'clinic',
                'title'             => $clinic?->getName() ?? ($address?->getName() ?: 'مطب شخصی'),
                ...
```

`$address` می‌تواند `null` باشد و همچنان محل ساخته می‌شود.

## وظایف

### ۱. حمل context در پنل ادمین

یک hook مشترک بساز تا منطق در چهار جا تکرار نشود — `assets/admin/hooks/useClinicContext.ts`:

```ts
/**
 * uuid کلینیکِ محیط جاری، یا null برای محیط شخصی پزشک. همان قاعده‌ای که
 * ClinicAppointmentSettingsPage استفاده می‌کند.
 */
export function useClinicContext(): string | null {
  const dbUuid            = useAuthStore(s => s.dbUuid);
  const context           = useAuthStore(s => s.context);
  const availableContexts = useAuthStore(s => s.availableContexts);

  return useMemo(() => {
    if (context?.type === 'clinic') return dbUuid;
    return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null;
  }, [context, dbUuid, availableContexts]);
}
```

سپس در این چهار جا مصرفش کن و `clinic_uuid` را به query اضافه کن:

- `AppointmentsPage.tsx:422-428` (اسلات‌ها) — **حتماً `clinicUuid` را در `queryKey` هم بگذار**،
  وگرنه cache بین دو محیط نشت می‌کند.
- `useDoctorBookingServices.ts:24-28`
- `ServiceSlotPicker.tsx:58-59`
- `NewAppointmentDrawer.tsx:61`

نمونه:

```tsx
const clinicUuid = useClinicContext();
const slotsQuery = useQuery<ApiResponse<any>>({
  queryKey: ['appt-slots', selectedDoctorUuid, selectedDate, clinicUuid],
  queryFn: () => api.get(
    `/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` +
    (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
  ),
  enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
});
```

**نکتهٔ مهم:** پزشکی که هم مطب شخصی دارد هم در کلینیک است، در محیط شخصی نباید `clinic_uuid`
بفرستد. `useClinicContext` وقتی `context.type === 'doctor'` است باید `null` برگرداند — قاعدهٔ
fallback به `availableContexts` فقط برای مدیر کلینیک است. اگر این تفکیک را رعایت نکنی، پزشک در
محیط شخصی برنامهٔ کلینیک را می‌بیند و مشکل قبلی وارونه تکرار می‌شود.

### ۲. پیام دقیق به‌جای «تعطیل»

`TurnsTimeline.tsx:166-171` نمی‌داند چرا اسلاتی نیست. `GET /api/v1/appointment-slots` را طوری
تغییر بده که دلیل خالی‌بودن را برگرداند:

```php
return $this->success([
    'doctor_uuid' => $doctorUuid,
    'clinic_uuid' => $clinic?->getUuid(),
    'date'        => $date,
    'sessions'    => $sessions,
    // چرا خالی است — تا پنل پیام درست بدهد
    'empty_reason' => $sessions === [] ? $this->emptySlotsReason($doctor, $date, $clinic) : null,
]);
```

`emptySlotsReason()` یکی از این‌ها را برگرداند:

| مقدار | معنی | پیام پنل |
|---|---|---|
| `no_schedule` | برنامه‌ای برای این context ثبت نشده | «برای این محل برنامهٔ نوبت‌دهی ثبت نشده است» |
| `holiday` | تعطیلی فعال این روز را پوشش می‌دهد | «این روز تعطیل است» |
| `day_off` | برنامه هست ولی این روز شیفت فعال ندارد | «این روز در برنامهٔ کاری تعریف نشده است» |
| `outside_window` | خارج از بازهٔ نوبت‌دهی یا نوبت‌دهی آنلاین خاموش | «این تاریخ خارج از بازهٔ نوبت‌دهی است» |

منطقش از همان دادهٔ `SlotCalculatorService` بیرون می‌آید؛ متد کمکی عمومی به آن اضافه کن تا کنترلر
دوباره کوئری نزند.

### ۳. فیلترکردن محل‌های بدون آدرس در API عمومی

`bookingLocations()` فقط محلی را برگرداند که **هر دو شرط** را دارد:

1. حداقل یک آدرس در آن context ثبت شده باشد.
2. حداقل یک شیفت فعال داشته باشد که `location_id`اش یکی از همان آدرس‌ها باشد.

```php
foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
    $clinic    = $schedule->getClinic();
    $addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId());
    if ($addresses === []) {
        continue;   // محلی که آدرس ندارد، محل نیست
    }

    $byId    = [];
    foreach ($addresses as $a) { $byId[(int) $a->getId()] = $a; }

    $hours   = $this->openingHours($schedule, $byId);
    if ($hours === []) {
        continue;   // هیچ شیفت فعالی روی آدرس‌های این محل نشسته
    }

    $address = $byId[$hours[0]['location_id']] ?? $addresses[0];
    ...
}
```

و `openingHours()` باید `location_id` را هم بررسی و برگرداند:

```php
private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array
{
    ...
            $locationId = (int) ($session['location_id'] ?? 0);
            if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) {
                continue;   // شیفت بدون آدرس معتبر = قابل رزرو نیست
            }
    ...
            $hours[] = [
                'day'         => ucfirst($dayName),
                'day_index'   => (int) $dayIndex,
                'location_id' => $locationId,
                'opens'       => $opens,
                'closes'      => $closes,
            ];
}
```

**این تغییر رفتار عمومی است و باید در `docs/api/appointment.md` صریح ثبت شود:** محلی که آدرس ندارد
یا شیفتی روی آدرسش تعریف نشده، دیگر در `booking_locations` نمی‌آید.

### ۴. فیلتر بر اساس روز — پارامتر `date`

سایت باید بتواند بپرسد «در این تاریخ کدام محل‌ها باز است». به `bookingLocations` پارامتر اختیاری
`?date=Y-m-d` اضافه کن:

- بدون `date` → رفتار فعلی (همهٔ محل‌های معتبر).
- با `date` → فقط محل‌هایی که در آن روز حداقل یک اسلات آزاد دارند
  (`$this->slotCalculator->getAvailableSlots($doctor, $date, $clinic)` غیرخالی).

هر آیتم یک فیلد `available_on_date` (بولین) هم بگیرد تا کلاینت بتواند به‌جای حذف، آن را غیرفعال
نشان دهد.

پاسخ در حالت `date` باید `date` را echo کند.

### ۵. دو call site بدون context

`src/Appointment/Controller/MyAppointmentsController.php:144` و
`src/Admin/Controller/AdminApiController.php:933` هر دو
`resolveSlotLocationId($doctor, $slotStart)` را بدون کلینیک صدا می‌زنند، پس آدرس نوبتِ ثبت‌شده در
کلینیک را `null` یا اشتباه حل می‌کنند.

هر دو باید context را از همان مسیری که نوبت ساخته می‌شود بگیرند (بدنهٔ درخواست یا
`EntityContextResolver`). اگر context در دسترس نبود، به‌جای حدس‌زدن، آدرس را `null` بگذار و در
لاگ ثبت کن — **حدس‌زدن یعنی ثبت نوبت با آدرس اشتباه**.

### ۶. پاک‌سازی دادهٔ ناسازگار

برنامهٔ شخصیِ `id=2505` شیفت فعال با `location_id = NULL` دارد؛ چنین ردیفی امروز از طریق API
ساخته نمی‌شود چون `validateSessions()` جلویش را می‌گیرد، ولی ردیف‌های قدیمی مانده‌اند.

یک console command بنویس — `app:schedule:audit-locations`:

- برنامه‌هایی که شیفت فعال با `location_id` تهی یا اشاره به آدرسی خارج از context دارند را
  فهرست کند.
- با `--fix` آن شیفت‌ها را `active = false` کند (حذف نکن — دادهٔ کاربر است).
- خروجی: uuid پزشک، context، روز، و `location_id` مشکل‌دار.

### ۷. تست و مستندات

تست‌های لازم در `tests/Appointment/`:

1. اسلات‌های پزشکِ عضو کلینیک با `clinic_uuid` → غیرخالی؛ بدون آن → خالی با
   `empty_reason = 'no_schedule'`.
2. `booking_locations` محلی که آدرس ندارد را برنمی‌گرداند.
3. `booking_locations` محلی که آدرس دارد ولی هیچ شیفتی روی آن آدرس نیست را برنمی‌گرداند.
4. `?date=` روزی که فقط کلینیک باز است → فقط یک محل.
5. `empty_reason` برای هر چهار حالت (`no_schedule` / `holiday` / `day_off` / `outside_window`).

مستندات: `docs/api/appointment.md` — پارامتر `date`، فیلدهای `available_on_date`، `location_id`
داخل `opening_hours`، فیلد `empty_reason` روی `appointment-slots`، و قاعدهٔ جدید فیلترشدن محل‌ها.

## نکات مهم

- **این رگرسیون از تغییر per-context قبلی آمده.** هر جای دیگری از پنل که اسلات یا برنامه می‌خواند
  و در فهرست بالا نیست را هم بگرد: `grep -rn "appointment-slots\|appointment-service-slots\|month-availability\|weekly-schedule" assets/admin`.
- `SlotCalculatorService` تنها منبع تولید اسلات است و درست کار می‌کند — مشکل در **ورودی**اش است،
  نه در خودش. هیچ منطق موازی تولید اسلات نساز.
- Timezone: همهٔ محاسبات با `strtotime`/`date` و timestamp صحیح انجام می‌شود و `SlotCalculator`
  فرض می‌کند تاریخ `Y-m-d` محلی است. اگر مشکل روزِ اشتباه دیدی، اول `date_default_timezone`
  کانتینر را با `Asia/Tehran` بسنج؛ ولی علتِ گزارش‌شدهٔ فعلی timezone **نیست** — نبودِ
  `clinic_uuid` است.
- تعطیلی سراسری پزشک (`holidays.clinic_id IS NULL`) عمداً روی همهٔ محیط‌ها اثر می‌گذارد؛ این
  رفتار درست است و نباید تغییر کند.
- کاربر تست: پزشک `09100652121` (uuid `bcabb3a8-cae3-45ec-876c-548f9c1e1569`) در کلینیک
  `41e325c4-e825-4067-8438-5d828ecaee09`. مدیر کلینیک برای بازتولید مشکل دوم.
- پاسخ‌ها طبق `BaseController` با `$this->success()` / `$this->error()`؛ تاریخ‌ها timestamp صحیح.
