From 7a0654f8ba50d245adc0eebd9f30b7bbdd4ad4bc Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 14:35:31 +0330 Subject: [PATCH] fix(booking): carry the clinic context through the panel and drop phantom locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults, one root: the per-context booking work updated ScheduleSection but left the rest of the panel calling slot endpoints without clinic_uuid. Absent clinic_uuid means the personal practice, so the panel asked about a schedule the doctor barely uses and got nothing back. - useClinicContext() resolves the current environment once and is used by the appointments page, useDoctorBookingServices, ServiceSlotPicker and both queries in NewAppointmentDrawer (a fifth call site a sweep turned up). It returns null in a doctor's personal environment so the mirror-image bug — a doctor seeing the clinic's schedule at their own practice — cannot appear. clinicUuid is part of every query key; without it the cache leaks across environments. - appointment-slots returns empty_reason (no_schedule | holiday | day_off | outside_window). TurnsTimeline rendered «این روز تعطیل است» for any empty day, which is what the bug report actually saw; it now says which of the four it is. - booking-locations lists a location only when the context has an address and an active shift points at it. The dev data had three "personal" schedules whose shifts referenced the clinic's address, so the public site advertised a personal practice that could never be booked. - ?date= adds available_on_date per location, validated as a real calendar date. - MyAppointmentsController and AdminApiController resolved the appointment address with no context and could store the wrong one. Both now go through the new BookingContextResolver, which also replaces AppointmentController's private copy of the same membership check. - app:schedule:audit-locations reports shifts pointing at a missing or foreign address; --fix deactivates them rather than deleting. Verified against the reported doctor: same date, no clinic_uuid -> 0 sessions, with it -> 1 session; a full week matches the configured Sat/Tue/Wed/Thu. Suite: 417 tests, 2 failures — both pre-existing and unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ing-context-slots-and-phantom-locations.md | 320 ++++++++++++++++++ .../admin/components/NewAppointmentDrawer.tsx | 12 +- .../appointments/ServiceSlotPicker.tsx | 7 +- .../components/appointments/TurnsTimeline.tsx | 29 +- assets/admin/hooks/useClinicContext.ts | 28 ++ .../admin/hooks/useDoctorBookingServices.ts | 10 +- assets/admin/pages/AppointmentsPage.tsx | 11 +- docs/api/appointment.md | 47 ++- src/Admin/Controller/AdminApiController.php | 4 +- .../Command/AuditScheduleLocationsCommand.php | 111 ++++++ .../Controller/AppointmentController.php | 92 +++-- .../Controller/MyAppointmentsController.php | 6 +- .../Service/BookingContextResolver.php | 36 ++ .../Service/SlotCalculatorService.php | 37 ++ .../BookingLocationValidityTest.php | 198 +++++++++++ 15 files changed, 906 insertions(+), 42 deletions(-) create mode 100644 .claude/prompt/fix-booking-context-slots-and-phantom-locations.md create mode 100644 assets/admin/hooks/useClinicContext.ts create mode 100644 src/Appointment/Command/AuditScheduleLocationsCommand.php create mode 100644 src/Appointment/Service/BookingContextResolver.php create mode 100644 tests/Appointment/BookingLocationValidityTest.php diff --git a/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md b/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md new file mode 100644 index 00000000..4dc6cb2f --- /dev/null +++ b/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md @@ -0,0 +1,320 @@ +# اصلاح تشخیص روز کاری در پنل + حذف محل‌های جعلی از 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 صحیح. diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx index 8d0d8d47..eb90596b 100644 --- a/assets/admin/components/NewAppointmentDrawer.tsx +++ b/assets/admin/components/NewAppointmentDrawer.tsx @@ -4,6 +4,7 @@ import { PlusIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; +import { useClinicContext } from '../hooks/useClinicContext'; import Modal from './ui/Modal'; import PersianDateInput from './ui/PersianDateInput'; import PriceInput from './ui/PriceInput'; @@ -36,6 +37,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey isReserve?: boolean; }) { const qc = useQueryClient(); + const clinicUuid = useClinicContext(); // ── patient: pick an existing record or enter a new person ──────────────── const [patientSearch, setPatientSearch] = useState(''); @@ -57,8 +59,11 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey // روش نوبت‌دهی پزشک: در حالت «سرویس» زمان از مدت سرویس محاسبه و پیشنهاد می‌شود. const scheduleQ = useQuery>({ - queryKey: ['drawer-schedule', doctorUuid], - queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`), + queryKey: ['drawer-schedule', doctorUuid, clinicUuid], + queryFn: () => api.get( + `/api/v1/appointment-settings/weekly-schedule/${doctorUuid}` + + (clinicUuid ? `?clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') + ), enabled: !!doctorUuid, }); const bookingMode: 'slot' | 'service' = @@ -92,10 +97,11 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey useEffect(() => { setPickedSlot(null); }, [serviceUuids, date]); const svcSlotsQ = useQuery>({ - queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids], + queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids, clinicUuid], queryFn: () => api.get( `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}` + serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('') + + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') ), enabled: serviceMode && !!date && serviceUuids.length > 0, }); diff --git a/assets/admin/components/appointments/ServiceSlotPicker.tsx b/assets/admin/components/appointments/ServiceSlotPicker.tsx index fd059c00..18e291da 100644 --- a/assets/admin/components/appointments/ServiceSlotPicker.tsx +++ b/assets/admin/components/appointments/ServiceSlotPicker.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import type { BookingService } from '../../hooks/useDoctorBookingServices'; +import { useClinicContext } from '../../hooks/useClinicContext'; import SearchableSelect from '../ui/SearchableSelect'; import DigitInput from '../ui/DigitInput'; @@ -25,6 +26,7 @@ export default function ServiceSlotPicker({ onSelect: (v: ServicePick) => void; editableDuration?: boolean; }) { + const clinicUuid = useClinicContext(); const [sectionUuid, setSectionUuid] = useState(''); const [selected, setSelected] = useState([]); const [pickedSlot, setPickedSlot] = useState(null); @@ -54,11 +56,12 @@ export default function ServiceSlotPicker({ const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join(''); const slotsQ = useQuery>({ - queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations], + queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid], queryFn: () => api.get( `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}` + serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('') - + durationsQs, + + durationsQs + + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''), ), enabled: !!doctorUuid && !!date && serviceUuids.length > 0, }); diff --git a/assets/admin/components/appointments/TurnsTimeline.tsx b/assets/admin/components/appointments/TurnsTimeline.tsx index ca7e0a71..f153b8d7 100644 --- a/assets/admin/components/appointments/TurnsTimeline.tsx +++ b/assets/admin/components/appointments/TurnsTimeline.tsx @@ -142,10 +142,22 @@ function OccupiedCard({ ); } +/** + * چرا این روز اسلاتی ندارد — از `empty_reason` پاسخ appointment-slots. + * خالی‌بودن لزوماً تعطیلی نیست. + */ +const EMPTY_REASON_TEXT: Record = { + no_schedule: { title: 'برنامهٔ نوبت‌دهی ثبت نشده', hint: 'برای این محل هنوز برنامهٔ کاری تعریف نشده است' }, + holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات، این روز برای پزشک تعطیل ثبت شده' }, + day_off: { title: 'این روز شیفت کاری ندارد', hint: 'در برنامهٔ هفتگی، برای این روز شیفتی تعریف نشده است' }, + outside_window: { title: 'خارج از بازهٔ نوبت‌دهی', hint: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبت‌دهی آنلاین خاموش است' }, +}; + export default function TurnsTimeline({ - slots, loading, queryKey, onView, onBook, + slots, loading, queryKey, onView, onBook, emptyReason, }: { slots: TimelineSlot[]; + emptyReason?: string | null; loading: boolean; queryKey: unknown[]; onView: (a: Appointment) => void; @@ -163,12 +175,15 @@ export default function TurnsTimeline({ }, [activeIndex]); if (loading) return
در حال بارگذاری...
; - if (!slots.length) return ( -
-
این روز تعطیل است
-
هیچ برنامه زمانبندی برای این روز تنظیم نشده است
-
- ); + if (!slots.length) { + const reason = EMPTY_REASON_TEXT[emptyReason ?? ''] ?? EMPTY_REASON_TEXT.day_off; + return ( +
+
{reason.title}
+
{reason.hint}
+
+ ); + } return (
diff --git a/assets/admin/hooks/useClinicContext.ts b/assets/admin/hooks/useClinicContext.ts new file mode 100644 index 00000000..7ddb755a --- /dev/null +++ b/assets/admin/hooks/useClinicContext.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { useAuthStore } from '../stores/authStore'; + +/** + * uuid کلینیکِ محیط جاری، یا null وقتی کاربر در محیط شخصی خودش است. + * + * تنظیمات نوبت‌دهی per-context است و نبودِ clinic_uuid در درخواست یعنی «مطب شخصی»، + * نه «هر برنامه‌ای که پیدا شد». پس این تفکیک باید دقیق باشد: پزشکی که هم مطب شخصی + * دارد هم عضو کلینیک است، در محیط شخصی نباید برنامهٔ کلینیک را ببیند و برعکس. + * + * fallback به availableContexts فقط برای مالک کلینیک است — قبل از اولین + * switch-context، هنوز context پر نشده ولی نقش کاربر تکلیف را روشن می‌کند. + */ +export function useClinicContext(): string | null { + const dbUuid = useAuthStore(s => s.dbUuid); + const context = useAuthStore(s => s.context); + const primaryRole = useAuthStore(s => s.primaryRole); + const availableContexts = useAuthStore(s => s.availableContexts); + + return useMemo(() => { + if (context?.type === 'clinic') return dbUuid; + if (context?.type === 'doctor') return null; + + return primaryRole === 'clinic' + ? availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? dbUuid + : null; + }, [context, dbUuid, primaryRole, availableContexts]); +} diff --git a/assets/admin/hooks/useDoctorBookingServices.ts b/assets/admin/hooks/useDoctorBookingServices.ts index 5d25e433..9852296e 100644 --- a/assets/admin/hooks/useDoctorBookingServices.ts +++ b/assets/admin/hooks/useDoctorBookingServices.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; +import { useClinicContext } from './useClinicContext'; export interface BookingService { uuid: string; @@ -21,9 +22,14 @@ interface BookingServicesData { * `appointment-booking-services`. برای سرویس‌محور کردن فرم‌های ثبت نوبت پنل. */ export function useDoctorBookingServices(doctorUuid: string | null | undefined) { + const clinicUuid = useClinicContext(); + const q = useQuery>({ - queryKey: ['booking-services', doctorUuid], - queryFn: () => api.get(`/api/v1/appointment-booking-services/${doctorUuid}`), + queryKey: ['booking-services', doctorUuid, clinicUuid], + queryFn: () => api.get( + `/api/v1/appointment-booking-services/${doctorUuid}` + + (clinicUuid ? `?clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') + ), enabled: !!doctorUuid, }); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 72a9addf..ad18d061 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -12,6 +12,7 @@ import type { Appointment } from '../types'; import { formatDate, toGregorianDate, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils'; import PriceInput from '../components/ui/PriceInput'; import { useAuthStore } from '../stores/authStore'; +import { useClinicContext } from '../hooks/useClinicContext'; import Pagination from '../components/ui/Pagination'; import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal'; import type { AppointmentFilters } from '../components/AppointmentFiltersModal'; @@ -327,6 +328,7 @@ export default function AppointmentsPage() { const navigate = useNavigate(); const primaryRole = useAuthStore(s => s.primaryRole); const dbUuid = useAuthStore(s => s.dbUuid); + const clinicUuid = useClinicContext(); const isAdmin = primaryRole === 'admin'; const isClinic = primaryRole === 'clinic'; const isDoctor = primaryRole === 'doctor'; @@ -420,10 +422,14 @@ export default function AppointmentsPage() { }, [isClinic, selectedDoctorUuid, doctors]); // ── Slots query (timeline) - const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate]; + // clinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود. + const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, clinicUuid]; const slotsQuery = useQuery>({ queryKey: slotsQueryKey, - queryFn: () => api.get(`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}`), + queryFn: () => api.get( + `/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` + + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') + ), enabled: viewMode === 'timeline' && !!selectedDoctorUuid, }); @@ -640,6 +646,7 @@ export default function AppointmentsPage() { queryKey={apptQueryKey} onView={openDetail} onBook={handleSlotClick} + emptyReason={(slotsQuery.data?.data as any)?.empty_reason ?? null} /> )} diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 1617e276..9022ad25 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -701,6 +701,8 @@ Every public booking endpoint therefore accepts an optional **`clinic_uuid`**: | `GET /api/v1/appointment-booking-services/{doctorUuid}` | query | | `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | query | | `POST /api/v1/appointment` | body | +| `POST /api/v1/my/appointment` | body | +| admin booking (`src/Admin/`) | body | Omitting it means the **personal practice** — it is never a wildcard. If the doctor is not a member of the given clinic → `404 ERR_VALIDATION_002` («محل نوبت‌دهی یافت نشد»). All four `GET`s echo back @@ -771,7 +773,8 @@ location — picking one and hiding the rest removes real capacity from the doct | `location_uuid` | `string\|null` | the `DoctorAddress` uuid; `null` when the context has no address yet | | `type` | `"personal" \| "clinic"` | | | `booking_mode` | `"slot" \| "service"` | per-context — the same doctor can differ between locations | -| `opening_hours` | `array` | active weekly shifts of that context, flattened; `day` is the English weekday name so it maps straight onto schema.org `openingHoursSpecification` | +| `opening_hours` | `array` | active weekly shifts of that context, flattened; each entry is `{day, day_index, location_id, opens, closes}`. `day` is the English weekday name so it maps straight onto schema.org `openingHoursSpecification`; `day_index` is the schedule key (0=Saturday) | +| `available_on_date` | `bool\|null` | only when `?date=` is supplied — whether that location has a free slot that day. `null` without `date` | | `services` | `array` | populated only in `service` mode, scoped to that context's owner | | `next_available_at` | `int\|null` | Unix timestamp of the earliest free slot within 30 days, capped by the context's booking window | @@ -786,6 +789,29 @@ selection; locations with no capacity sort last. Deep links should carry the cho **Status codes:** `200`, `404 ERR_VALIDATION_002` (doctor not found). +#### A location must be bookable to be listed + +An entry is returned only when **both** hold: + +1. the context has at least one registered address, **and** +2. at least one active shift points at one of those addresses. + +A schedule whose shifts carry no `location_id`, or point at an address belonging to a different +context (a personal schedule referencing a clinic address, say), is not a place a patient can go — +it is omitted entirely, and its shifts never appear in `opening_hours`. + +This filters out rows that predate the `validateSessions` rule, which now rejects such shifts at +write time. Use `php bin/console app:schedule:audit-locations` to list the offenders; `--fix` +deactivates them (it never deletes — the hours are user data). If the schedule was in truth a +clinic's, move it instead with `app:schedule:assign-clinic`. + +#### `?date=YYYY-MM-DD` + +Adds `available_on_date` to every entry and echoes `date` in the response. Use it to grey out +locations that cannot be booked on the day the patient picked, rather than showing an empty slot +list. An impossible date (`2026-13-99`) → `422 ERR_VALIDATION_001` on field `date`; the check is a +real calendar check, not just a regex. + `opening_hours` lists one entry per active shift, so a day with a morning and an evening shift appears twice. Days with no active shift are absent. Times are local `HH:MM` strings, and the per-shift `location_id` is not repeated here — every shift in an entry already belongs to that @@ -794,3 +820,22 @@ location's context. **Consumer:** `nobat724_front` — the doctor page must render one booking block per entry, pass the matching `clinic_uuid` into the slot and booking calls, and feed `opening_hours` into the `openingHoursSpecification` of each `MedicalClinic` in the Physician JSON-LD. + + +--- + +## Why a day has no slots — `empty_reason` (2026-07) + +`GET /api/v1/appointment-slots` now returns `empty_reason` alongside `sessions`. It is `null` when +sessions exist, otherwise one of: + +| Value | Meaning | +|---|---| +| `no_schedule` | no weekly schedule exists for that **context** — the commonest cause is a caller that forgot `clinic_uuid` and so asked about the personal practice | +| `holiday` | an active holiday covers the day (the doctor's own global holiday, or one the clinic set) | +| `day_off` | a schedule exists but that weekday has no active shift | +| `outside_window` | the date is past, beyond the booking window, or online booking is switched off | + +Clients must not translate an empty `sessions` array into "closed". The admin panel used to do +exactly that and reported «این روز تعطیل است» for a doctor whose clinic schedule was perfectly +active — the request simply carried no `clinic_uuid`. diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index b1c4206f..10df0608 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -38,6 +38,7 @@ class AdminApiController extends BaseController public function __construct( private readonly EntityManagerInterface $em, private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator, + private readonly \App\Appointment\Service\BookingContextResolver $bookingContext, private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup, private readonly \App\Payment\Service\PaymentManager $paymentManager, private readonly \App\Payment\Repository\PaymentRepository $paymentRepo, @@ -930,7 +931,8 @@ class AdminApiController extends BaseController foreach ($serviceItems as $si) { $appointment->addServiceItem($si); } - $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); + $bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null); + $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic); if ($locationId !== null) $appointment->setAddressId($locationId); try { diff --git a/src/Appointment/Command/AuditScheduleLocationsCommand.php b/src/Appointment/Command/AuditScheduleLocationsCommand.php new file mode 100644 index 00000000..00309dc7 --- /dev/null +++ b/src/Appointment/Command/AuditScheduleLocationsCommand.php @@ -0,0 +1,111 @@ +addOption('fix', null, InputOption::VALUE_NONE, 'Deactivate the offending shifts instead of only reporting them'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $fix = (bool) $input->getOption('fix'); + + $rows = []; + $touched = 0; + + foreach ($this->scheduleRepo->findAll() as $schedule) { + $clinic = $schedule->getClinic(); + $allowed = []; + foreach ($this->addressRepo->findForContext($schedule->getDoctor(), $clinic?->getId()) as $address) { + $allowed[(int) $address->getId()] = true; + } + + $setting = $schedule->getSetting(); + $changed = false; + + foreach ($setting as $dayKey => $day) { + foreach (($day['sessions'] ?? []) as $index => $session) { + if (!($session['active'] ?? false)) { + continue; + } + + $locationId = (int) ($session['location_id'] ?? 0); + if ($locationId !== 0 && isset($allowed[$locationId])) { + continue; + } + + $rows[] = [ + $schedule->getDoctor()->getUuid(), + $clinic === null ? 'personal' : ($clinic->getName() ?? 'clinic'), + WeeklySchedule::DAYS[(int) $dayKey] ?? (string) $dayKey, + $locationId === 0 ? '—' : (string) $locationId, + $locationId === 0 ? 'no address' : 'address outside context', + ]; + + if ($fix) { + $setting[$dayKey]['sessions'][$index]['active'] = false; + $changed = true; + } + } + } + + if ($changed) { + // داده‌ی کاربر حذف نمی‌شود؛ فقط غیرفعال می‌شود تا قابل بازیابی بماند. + $schedule->setSetting($setting); + $touched++; + } + } + + if ($rows === []) { + $io->success('No schedule shift points at a missing or foreign address.'); + + return Command::SUCCESS; + } + + $io->table(['doctor', 'context', 'day', 'location_id', 'problem'], $rows); + + if (!$fix) { + $io->warning(sprintf('%d offending shift(s). Re-run with --fix to deactivate them.', count($rows))); + + return Command::SUCCESS; + } + + $this->em->flush(); + $io->success(sprintf('Deactivated %d shift(s) across %d schedule(s).', count($rows), $touched)); + + return Command::SUCCESS; + } +} diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 0a7292bf..22a87d16 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -33,7 +33,7 @@ class AppointmentController extends BaseController private readonly SlotCalculatorService $slotCalculator, private readonly PatientService $patientService, private readonly WeeklyScheduleRepository $scheduleRepo, - private readonly \App\Clinic\Repository\ClinicRepository $clinicRepo, + private readonly \App\Appointment\Service\BookingContextResolver $bookingContext, private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo, private readonly \App\Representation\Service\DomainContextResolver $domainResolver, private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo, @@ -164,6 +164,10 @@ class AppointmentController extends BaseController 'clinic_uuid' => $clinic?->getUuid(), 'date' => $date, 'sessions' => $sessions, + // خالی‌بودن دلایل مختلفی دارد؛ کلاینت نباید همه را «تعطیل» بنامد. + 'empty_reason' => $sessions === [] + ? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic) + : null, ]); } @@ -267,32 +271,58 @@ class AppointmentController extends BaseController * GET /api/v1/appointment-booking-locations/{doctorUuid} */ #[Route('/api/v1/appointment-booking-locations/{doctorUuid}', methods: ['GET'])] - public function bookingLocations(string $doctorUuid): JsonResponse + public function bookingLocations(string $doctorUuid, Request $request): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } + $date = trim((string) $request->query->get('date', '')); + if ($date !== '' && !$this->isCalendarDate($date)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); + } + $locations = []; foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) { - $clinic = $schedule->getClinic(); + $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; + } + $meta = $schedule->getMeta(); - $address = $this->addressRepo->findForContext($doctor, $clinic?->getId())[0] ?? null; + $address = $byId[$hours[0]['location_id']] ?? $addresses[0]; $locations[] = [ - 'location_uuid' => $address?->getUuid(), + 'location_uuid' => $address->getUuid(), 'type' => $clinic === null ? 'personal' : 'clinic', - 'title' => $clinic?->getName() ?? ($address?->getName() ?: 'مطب شخصی'), - 'address' => $address?->getAddress(), + 'title' => $clinic?->getName() ?? ($address->getName() ?: 'مطب شخصی'), + 'address' => $address->getAddress(), 'clinic_uuid' => $clinic?->getUuid(), 'booking_mode' => $meta['booking_mode'], 'buffer_minutes' => (int) $meta['buffer_minutes'], - 'opening_hours' => $this->openingHours($schedule), + 'opening_hours' => $hours, 'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE ? $this->bookableServices($doctor, $clinic) : [], 'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic), + 'available_on_date' => $date === '' + ? null + : $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic) !== [], ]; } @@ -301,6 +331,7 @@ class AppointmentController extends BaseController return $this->success([ 'doctor_uuid' => $doctorUuid, + 'date' => $date !== '' ? $date : null, 'booking_locations' => $locations, ]); } @@ -672,16 +703,7 @@ class AppointmentController extends BaseController */ private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic { - if ($clinicUuid === null || trim($clinicUuid) === '') { - return null; - } - - $clinic = $this->clinicRepo->findByUuid(trim($clinicUuid)); - if ($clinic === null || !$clinic->hasDoctor($doctor)) { - throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'محل نوبت‌دهی یافت نشد', 404); - } - - return $clinic; + return $this->bookingContext->resolve($doctor, $clinicUuid); } private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse @@ -700,6 +722,17 @@ class AppointmentController extends BaseController return null; } + /** + * تاریخ Y-m-d که واقعاً روی تقویم وجود دارد. regex تنها کافی نیست: «2026-13-99» + * الگو را پاس می‌کند ولی روزی نیست. + */ + private function isCalendarDate(string $date): bool + { + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date); + + return $parsed !== false && $parsed->format('Y-m-d') === $date; + } + /** @return array> */ private function bookableServices(Doctor $doctor, ?Clinic $clinic): array { @@ -723,9 +756,13 @@ class AppointmentController extends BaseController * شیفت‌های فعال هفته به‌صورت تخت، با نام انگلیسی روز — آمادهٔ نگاشت به * openingHoursSpecification در schema.org. کلیدهای برنامه 0..6 هستند و 0 شنبه است. * - * @return array + * فقط شیفت‌هایی برمی‌گردند که آدرسشان در $allowedAddressIds باشد. + * + * @param array $allowedAddressIds + * + * @return array */ - private function openingHours(WeeklySchedule $schedule): array + private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array { $hours = []; @@ -740,6 +777,13 @@ class AppointmentController extends BaseController continue; } + // شیفتی که آدرس ندارد یا به آدرسی خارج از این محیط اشاره می‌کند، + // قابل رزرو نیست و نباید ساعت کاری تولید کند. + $locationId = (int) ($session['location_id'] ?? 0); + if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) { + continue; + } + $opens = $session['start_time'] ?? null; $closes = $session['end_time'] ?? null; if ($opens === null || $closes === null) { @@ -747,9 +791,11 @@ class AppointmentController extends BaseController } $hours[] = [ - 'day' => ucfirst($dayName), - 'opens' => $opens, - 'closes' => $closes, + 'day' => ucfirst($dayName), + 'day_index' => (int) $dayIndex, + 'location_id' => $locationId, + 'opens' => $opens, + 'closes' => $closes, ]; } } diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index c769f6ee..92dc3499 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -37,6 +37,7 @@ class MyAppointmentsController extends BaseController private readonly DoctorSecretaryRepository $secretaryRepo, private readonly UserActiveContextRepository $contextRepo, private readonly SlotCalculatorService $slotCalculator, + private readonly \App\Appointment\Service\BookingContextResolver $bookingContext, private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo, private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo, private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo, @@ -141,7 +142,10 @@ class MyAppointmentsController extends BaseController $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); $appointment->setPatientNationalCode($nationalCode); if (!empty($data['note'])) $appointment->setNote($data['note']); - $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); + // محل نوبت باید از همان محیطی بیاید که نوبت در آن ثبت می‌شود؛ بدون clinic_uuid + // یعنی مطب شخصی، نه «هر برنامه‌ای که پیدا شد». + $bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null); + $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic); if ($locationId !== null) $appointment->setAddressId($locationId); // Optional clinic-workflow fields (بخش/سرویس/پرسنل/بیعانه) — unknown uuid → 422. diff --git a/src/Appointment/Service/BookingContextResolver.php b/src/Appointment/Service/BookingContextResolver.php new file mode 100644 index 00000000..6b03f523 --- /dev/null +++ b/src/Appointment/Service/BookingContextResolver.php @@ -0,0 +1,36 @@ +clinicRepo->findByUuid(trim($clinicUuid)); + if ($clinic === null || !$clinic->hasDoctor($doctor)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'محل نوبت‌دهی یافت نشد', 404); + } + + return $clinic; + } +} diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index 0b34c295..215df6ae 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -13,6 +13,12 @@ use App\Doctor\Entity\Doctor; class SlotCalculatorService { + /** دلایل خالی‌بودن یک روز — برای پیام دقیق در پنل. */ + public const EMPTY_NO_SCHEDULE = 'no_schedule'; + public const EMPTY_HOLIDAY = 'holiday'; + public const EMPTY_DAY_OFF = 'day_off'; + public const EMPTY_OUTSIDE_WINDOW = 'outside_window'; + public function __construct( private readonly WeeklyScheduleRepository $scheduleRepo, private readonly DateOverrideRepository $overrideRepo, @@ -135,6 +141,37 @@ class SlotCalculatorService return $result; } + /** + * چرا این روز اسلاتی ندارد. null یعنی اسلات دارد. + * + * پنل نمی‌تواند خالی‌بودن را به «تعطیل» ترجمه کند: نبودِ برنامه، تعطیلی، روزِ + * بدون شیفت و خارج‌بودن از بازهٔ نوبت‌دهی چهار چیز متفاوت‌اند و کاربر باید + * بداند کدام‌یک رخ داده تا بداند چه کاری باید بکند. + */ + public function explainEmptyDay(Doctor $doctor, string $date, ?Clinic $clinic = null): ?string + { + if ($this->buildAllSessions($doctor, $date, $clinic) !== []) { + return null; + } + + $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); + if ($schedule === null) { + return self::EMPTY_NO_SCHEDULE; + } + + $dayStart = (int) strtotime($date . ' 00:00:00'); + + if ($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayStart + 86399, $clinic) !== []) { + return self::EMPTY_HOLIDAY; + } + + if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) { + return self::EMPTY_OUTSIDE_WINDOW; + } + + return self::EMPTY_DAY_OFF; + } + /** * زودترین اسلات آزاد در $daysAhead روز آینده، یا null اگر ظرفیتی نباشد. * diff --git a/tests/Appointment/BookingLocationValidityTest.php b/tests/Appointment/BookingLocationValidityTest.php new file mode 100644 index 00000000..b3f5d802 --- /dev/null +++ b/tests/Appointment/BookingLocationValidityTest.php @@ -0,0 +1,198 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر محل'); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function makeClinicWith(Doctor $doctor): Clinic + { + $clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC'])); + $clinic->setName('کلینیک محل'); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + return $clinic; + } + + /** برنامه‌ای که همهٔ روزها یک شیفت روی $locationId دارد. */ + private function scheduleFor(Doctor $doctor, ?Clinic $clinic, ?int $locationId): WeeklySchedule + { + $day = ['sessions' => [array_filter([ + 'active' => true, + 'location_id' => $locationId, + 'start_time' => '09:00', + 'end_time' => '13:00', + 'duration_per_patient' => 20, + ], fn($v) => $v !== null)]]; + + $schedule = new WeeklySchedule( + $doctor, + array_fill_keys(array_map('strval', range(0, 6)), $day), + $clinic + ); + $this->em->persist($schedule); + $this->em->flush(); + + return $schedule; + } + + private function locations(Doctor $doctor, string $query = ''): array + { + $body = $this->authJson( + 'GET', + '/api/v1/appointment-booking-locations/' . $doctor->getUuid() . $query, + $doctor->getUser() + ); + + return $body['data']['booking_locations'] ?? []; + } + + public function testLocationWithoutAnyAddressIsNotReturned(): void + { + $doctor = $this->makeDoctor(); + $this->scheduleFor($doctor, null, null); + + self::assertSame([], $this->locations($doctor), 'محلی که آدرس ندارد نباید محل به حساب بیاید'); + } + + public function testLocationWhoseShiftsPointOutsideItsContextIsNotReturned(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinicWith($doctor); + + $clinicAddress = DoctorAddress::forClinic($clinic->getId()); + $personal = DoctorAddress::forDoctor($doctor); + $this->em->persist($clinicAddress); + $this->em->persist($personal); + $this->em->flush(); + + // برنامهٔ شخصی که شیفتش روی آدرس کلینیک نشسته — دقیقاً حالتی که در dev دیده شد. + $this->scheduleFor($doctor, null, $clinicAddress->getId()); + + self::assertSame([], $this->locations($doctor)); + } + + public function testValidLocationIsReturnedWithItsOpeningHours(): void + { + $doctor = $this->makeDoctor(); + $address = DoctorAddress::forDoctor($doctor); + $this->em->persist($address); + $this->em->flush(); + + $this->scheduleFor($doctor, null, $address->getId()); + + $locations = $this->locations($doctor); + self::assertCount(1, $locations); + self::assertSame('personal', $locations[0]['type']); + self::assertSame($address->getUuid(), $locations[0]['location_uuid']); + self::assertCount(7, $locations[0]['opening_hours']); + self::assertSame($address->getId(), $locations[0]['opening_hours'][0]['location_id']); + } + + public function testDateParameterReportsPerDayAvailability(): void + { + $doctor = $this->makeDoctor(); + $address = DoctorAddress::forDoctor($doctor); + $this->em->persist($address); + $this->em->flush(); + + // فقط شنبه (اندیس 0) شیفت فعال دارد. + $active = ['sessions' => [[ + 'active' => true, 'location_id' => $address->getId(), + 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20, + ]]]; + $setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]); + $setting['0'] = $active; + $this->em->persist(new WeeklySchedule($doctor, $setting)); + $this->em->flush(); + + $saturday = $this->nextWeekday(6); // date('w'): 6 = Saturday + $sunday = $this->nextWeekday(0); + + $onSaturday = $this->locations($doctor, '?date=' . $saturday); + $onSunday = $this->locations($doctor, '?date=' . $sunday); + + self::assertTrue($onSaturday[0]['available_on_date'] ?? null); + self::assertFalse($onSunday[0]['available_on_date'] ?? null); + } + + public function testInvalidDateIsRejected(): void + { + $doctor = $this->makeDoctor(); + + $this->authJson('GET', '/api/v1/appointment-booking-locations/' . $doctor->getUuid() . '?date=2026-13-99', $doctor->getUser()); + + self::assertSame(422, $this->responseCode()); + } + + public function testEmptyReasonDistinguishesTheCauses(): void + { + $doctor = $this->makeDoctor(); + $today = date('Y-m-d'); + + // بدون هیچ برنامه‌ای + $body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser()); + self::assertSame('no_schedule', $body['data']['empty_reason'] ?? null); + + // برنامه هست ولی این روز شیفت ندارد + $address = DoctorAddress::forDoctor($doctor); + $this->em->persist($address); + $this->em->flush(); + + $setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]); + $this->em->persist(new WeeklySchedule($doctor, $setting)); + $this->em->flush(); + + $body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser()); + self::assertSame('day_off', $body['data']['empty_reason'] ?? null); + + // تاریخ خارج از بازهٔ نوبت‌دهی + $farFuture = date('Y-m-d', strtotime('+2 years')); + $body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$farFuture}", $doctor->getUser()); + self::assertSame('outside_window', $body['data']['empty_reason'] ?? null); + } + + /** + * اولین وقوعِ آن روز هفته در آینده. از فردا شروع می‌شود چون شیفت‌های امروز + * ممکن است گذشته باشند و getAvailableSlots اسلات گذشته را برنمی‌گرداند — + * وگرنه تست بسته به ساعت اجرا نتیجهٔ متفاوت می‌دهد. + * + * @param int $phpDow خروجی date('w') — 0=یکشنبه ... 6=شنبه + */ + private function nextWeekday(int $phpDow): string + { + for ($i = 1; $i <= 7; $i++) { + $ts = strtotime("+{$i} day"); + if ((int) date('w', $ts) === $phpDow) { + return date('Y-m-d', $ts); + } + } + + return date('Y-m-d'); + } +}