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'); + } +}