# اصلاح تشخیص روز کاری در پنل + حذف محل‌های جعلی از 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>({ 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 (
این روز تعطیل است
هیچ برنامه زمانبندی برای این روز تنظیم نشده است
); ``` ### محل بدون آدرس فیلتر نمی‌شود `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>({ 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 صحیح.