diff --git a/.claude/prompt/service-based-booking.md b/.claude/prompt/service-based-booking.md new file mode 100644 index 00000000..42e73965 --- /dev/null +++ b/.claude/prompt/service-based-booking.md @@ -0,0 +1,217 @@ +# نوبت‌دهی بر اساس مدت سرویس (Service-based booking) — Backend + Admin + +## پروژه + +`clinicpro` (Backend Symfony + پنل ادمین React). +**Cross-repo:** بخش نوبت‌دهی آنلاین در `nobat724_front` است → پرامپت همتا: `nobat724_front/.claude/prompt/service-based-online-booking.md` (این پرامپت اول اجرا شود؛ قرارداد endpointها را همان‌جا مصرف می‌کنند). + +## زمینه + +الان نوبت‌دهی «اسلاتی» است: در `WeeklySchedule.setting` (JSON) برای هر روز یک یا چند `session` تعریف می‌شود و `SlotCalculatorService::buildSessionSlots()` بازهٔ session را با گام ثابت `duration_per_patient` به اسلات‌های هم‌اندازه می‌شکند. مدت هر نوبت مستقل از نوع خدمت است. + +هدف: افزودن حالت دوم «نوبت‌دهی بر اساس سرویس»، به‌طوری‌که مدت هر نوبت از `ServiceItem.durationMinutes` (که **الان هم در Entity هست ولی در محاسبهٔ نوبت استفاده نمی‌شود**) بیاید، نه از گام ثابت. حالت اسلاتی باید دست‌نخورده بماند و حالت جدید فقط یک گزینهٔ قابل‌انتخاب باشد. + +خبر خوب: بیشتر زیرساخت موجود است و نباید بازساخته شود: +- `ServiceItem.durationMinutes` (`service_items.duration_minutes`, nullable) — مدت هر سرویس. +- `Appointment.serviceItem` / `serviceSection` / `staff` (ManyToOne) — از قبل روی نوبت هست. +- `Appointment.isReserve` (bool) — **همان «نوبت آزاد»** است (در سایت «نوبت رزرو»). day-level، اسلات اشغال نمی‌کند، فقط منشی ثبت می‌کند. **بازسازی نکن؛ از همین استفاده کن.** +- `Holiday` و `DateOverride` entities — تعطیلات و استثناها از قبل هستند. +- `AppointmentRepository::isSlotTaken()` **از قبل overlap واقعیِ بازه‌ای می‌زند** (`a.slotStart < :slotEnd AND a.slotEnd > :slotStart`) — برای نوبت‌های متغیرالطول هم درست کار می‌کند. + +## هدف / spec انگلیسی + +Add a per-doctor booking mode `slot | service` stored in `WeeklySchedule` meta. In `service` mode: +- Working hours per weekday come from the existing `sessions` windows (`start_time`/`end_time`), but `duration_per_patient` is ignored; appointment length = sum of selected services' `durationMinutes` + optional `buffer_minutes`. +- A new endpoint returns candidate start times: first-fit free gaps inside each session window that fit the requested duration, treating existing bookings (interval-overlap) as busy. +- Booking accepts service items, derives `slot_end = slot_start + Σ durationMinutes + buffer`, and inserts atomically without overlap. + +## فایل‌های مرتبط + +| فایل | نقش | تغییر | +|------|-----|-------| +| `src/Appointment/Entity/WeeklySchedule.php` | متای برنامهٔ هفتگی | افزودن `booking_mode` + `buffer_minutes` به `DEFAULT_META` و `setMeta()` | +| `src/Appointment/Service/SlotCalculatorService.php` | محاسبهٔ زمان | افزودن مسیر service-based (متد جدید `getServiceStartTimes`) | +| `src/Appointment/Repository/AppointmentRepository.php` | `isSlotTaken` / `bookAtomically` | افزودن قفلِ per-doctor برای حالت سرویس (توضیح در نکات) | +| `src/Appointment/Controller/AppointmentController.php` | endpoint اسلات + book | endpoint جدید سرویس + پذیرش سرویس در `book()` | +| `src/Appointment/Controller/MyAppointmentsController.php` | ثبت توسط منشی | پذیرش سرویس/مدت در ایجاد نوبت منشی | +| `src/ClinicService/Entity/ServiceItem.php` | مدت + نمایش در نوبت‌دهی | افزودن فیلد `bookable` (bool) — **migration لازم** — `durationMinutes` از قبل هست | +| `src/ClinicService/Controller/ClinicServiceController.php` (createItem L143, updateItem L188) | POST/PATCH سرویس | پذیرش `bookable` کنار `duration_minutes` موجود | +| `src/ClinicService/Repository/ServiceItemRepository.php` | کوئری سرویس | افزودن `findBookableByEntity`/شمارش سرویس‌های bookable برای enforcement | +| `docs/api/appointment.md`, `docs/api/appointment-settings.md` | مستندات | به‌روزرسانی هم‌زمان (Standing Rule) | +| `assets/admin/pages/DoctorDetailPage.tsx` (`WeeklyScheduleTab`, ~L1231؛ SessionConfig L92, defaults L304) | ویرایشگر برنامهٔ هفتگی | افزودن سوییچ حالت + فیلد بافر؛ در حالت سرویس مخفی‌کردن `duration_per_patient` | +| `assets/admin/pages/AppointmentSettingsPage.tsx` | «مدیریت نوبت دهی» | همان `WeeklyScheduleTab` را render می‌کند — خودکار سوییچ را می‌گیرد | +| `assets/admin/components/NewAppointmentDrawer.tsx` | فرم ثبت نوبتِ منشی | در حالت سرویس: پیشنهاد زمان‌های خالی به‌جای ورود دستی ساعت | +| `assets/admin/pages/ClinicServicesPage.tsx` (617 خط) | مدیریت سرویس‌ها | مطمئن شو فیلد «مدت (دقیقه)» برای هر ServiceItem قابل‌ویرایش است | + +## وضعیت فعلی (کد واقعی) + +### مدت خدمت — هست ولی استفاده نمی‌شود +```php +// src/ClinicService/Entity/ServiceItem.php:58 +#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)] +private ?int $durationMinutes = null; // getter L87, setter L124, در toArray L153 +``` + +### متای برنامهٔ هفتگی +```php +// src/Appointment/Entity/WeeklySchedule.php:18 +public const DEFAULT_META = [ + 'online_booking_enabled' => true, + 'booking_window_value' => 1, + 'booking_window_unit' => 'month', +]; +// setMeta() (L76) فقط سه کلید بالا را whitelist می‌کند +``` + +### ساخت اسلاتِ ثابت (حالت فعلی = slot mode) +```php +// src/Appointment/Service/SlotCalculatorService.php:225 buildSessionSlots() +$dur = (int)($session['duration_per_patient'] ?? 20) * 60; // گام ثابت +while ($currentSec + $dur <= $endSec) { ... $currentSec += $dur; } +``` + +### overlap واقعی از قبل درست است +```php +// src/Appointment/Repository/AppointmentRepository.php:91 isSlotTaken() +->andWhere('a.slotStart < :slotEnd') +->andWhere('a.slotEnd > :slotStart') // interval overlap — نه exact key +``` + +### book() فعلی فقط slot_start/slot_end می‌گیرد +```php +// src/Appointment/Controller/AppointmentController.php:224 +$slotStart = (int)($data['slot_start'] ?? 0); +$slotEnd = (int)($data['slot_end'] ?? 0); +// ... new Appointment($doctor, $user, $slotStart, $slotEnd) +``` + +## وظایف + +### ۱. متای WeeklySchedule: افزودن `booking_mode` و `buffer_minutes` + +در `WeeklySchedule.php`: +```php +public const MODE_SLOT = 'slot'; +public const MODE_SERVICE = 'service'; + +public const DEFAULT_META = [ + 'online_booking_enabled' => true, + 'booking_window_value' => 1, + 'booking_window_unit' => 'month', + 'booking_mode' => self::MODE_SLOT, // پیش‌فرض = رفتار فعلی + 'buffer_minutes' => 0, +]; +``` +در `setMeta()` این دو کلید را هم whitelist کن (validate: `booking_mode ∈ {slot,service}`، `buffer_minutes` = `max(0, (int))`). چون Entity تغییر نمی‌کند (فقط محتوای JSON)، **migration لازم نیست**؛ ولی `getMeta()` با `array_merge(DEFAULT_META, ...)` مقدار پیش‌فرض را به رکوردهای قدیمی می‌دهد — این backward-compat را حفظ می‌کند. + +### ۲. SlotCalculatorService: مسیر service-based + +متد جدید که برای یک مدت مشخص (به دقیقه) زمان‌های شروعِ ممکن را برمی‌گرداند. از `buildAllSessions()` موجود استفاده کن تا window/holiday/override/booking-window همه رعایت شوند، ولی به‌جای اسلاتِ ثابت، gap-packing کن: + +```php +/** + * زمان‌های شروعِ ممکن برای نوبتی به طول $durationMinutes (+ بافر) در یک روز. + * first-fit: داخل هر session، از ابتدای window شروع می‌کند، بازه‌های اشغال‌شده + * (نوبت‌های موجود) را رد می‌کند و اولین جای پیوستهٔ کافی را پیشنهاد می‌دهد. + * + * @return array[] [{start, end, start_time, end_time, location_id}] + */ +public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes): array +{ + $buffer = (int)($this->getBookingMeta($doctor)['buffer_minutes'] ?? 0); + $needSec = ($durationMinutes + $buffer) * 60; + if ($needSec <= 0) return []; + + $sessions = $this->buildAllSessions($doctor, $date); // window/holiday/override رعایت می‌شود + $now = time(); + $result = []; + + foreach ($sessions as $session) { + // مرزهای واقعی window از start_time/end_time همان session + // (نه از اسلات‌های ثابتِ ساخته‌شده) + $winStart = $dayStart + parseTime(session.start_time); + $winEnd = $dayStart + parseTime(session.end_time); + $busy = بازه‌های اشغال‌شدهٔ [winStart, winEnd) از AppointmentRepository (فقط SLOT_BLOCKING + pending زنده)؛ + // پیمایش با گام مناسب (مثلاً بافر یا ۵ دقیقه) و بررسی عدم تداخل با $busy: + for ($t = $winStart; $t + $needSec <= $winEnd; ) { + $end = $t + $needSec; + if ($t >= $now && !overlapsAny($t, $end, $busy)) { + $result[] = ['start'=>$t, 'end'=>$t + $durationMinutes*60, /* بافر جزو نمایش نیست */ + 'start_time'=>gmdate('H:i',...), 'location_id'=>session.location_id]; + $t = $end; // بعد از این نوبت + بافر ادامه بده + } else { + $t = پرش به انتهای بازهٔ اشغال‌شدهٔ متداخل، یا + گام کوچک; + } + } + } + return $result; +} +``` + +نکات پیاده‌سازی: +- برای گرفتن نوبت‌های موجودِ یک روز، یک متد repository اضافه کن (مثلاً `findBusyIntervals(Doctor, int $dayStart, int $dayEnd): array` که `[slotStart, slotEnd]` نوبت‌های blocking + pendingِ زنده و **غیر-reserve** را برمی‌گرداند). `isReserve=true` هیچ بازه‌ای اشغال نمی‌کند. +- `slot_end` ذخیره‌شده = `start + durationMinutes*60` (بدون بافر)؛ بافر فقط فاصلهٔ بین نوبت‌ها را در پیشنهاد ایجاد می‌کند (تا نوبت بعدی زودتر از `end+buffer` پیشنهاد نشود). این تصمیم را در docstring بنویس تا edge سازگار بماند. +- اگر هیچ جای کافی نبود، آرایهٔ خالی برگردان (کنترلر پیام مناسب می‌دهد). + +### ۳. Endpoint جدید: زمان‌های خالی بر اساس سرویس + +در `AppointmentController` (عمومی، مثل `/appointment-slots`): +``` +GET /api/v1/appointment-service-slots?doctor_uuid=..&date=YYYY-MM-DD&service_item_uuids[]=..&service_item_uuids[]=.. +``` +- مدت = مجموع `durationMinutes` سرویس‌های داده‌شده (اگر سرویسی `durationMinutes` نداشت → خطای ۴۲۲ «مدت سرویس تعریف نشده»). +- خروجی با envelope استاندارد: +```json +{ "success": true, "data": { + "doctor_uuid": "...", "date": "YYYY-MM-DD", + "total_duration_minutes": 45, "buffer_minutes": 5, + "start_times": [ { "start": 1750000000, "end": 1750002700, "start_time": "15:00", "location_id": 12 } ] +} } +``` +- اگر پزشک در حالت `slot` است، این endpoint می‌تواند خطای ۴۲۲ «این پزشک در حالت نوبت‌دهی سرویس نیست» بدهد یا خالی برگرداند — تصمیم را مستند کن. +- `ServiceItem` repository از قبل هست (`ServiceItemRepository::findByUuid`). + +### ۴. book() و MyAppointmentsController: پذیرش سرویس + +در `AppointmentController::book()` و `MyAppointmentsController` (POST `/api/v1/my/appointment`): +- ورودی جدید اختیاری: `service_item_uuids: string[]` (و/یا `service_item_uuid` تکی که الان هم پذیرفته می‌شود). +- اگر پزشک `service` mode است و سرویس داده شده: `slot_end` را از `slot_start + Σ durationMinutes*60` **در سمت سرور** محاسبه کن (به `slot_end` کلاینت اعتماد نکن) و همان serviceItem را روی نوبت set کن. +- حالت `slot` دقیقاً مثل الان بماند (از `slot_end` کلاینت استفاده کن). +- قبل از insert، در همان تراکنش `isSlotTaken` (که overlap واقعی می‌زند) کافی است برای صحت منطقی؛ ولی **race concurrency** را ببین نکتهٔ زیر. + +### ۴.۵ نشان «نمایش در نوبت‌دهی» روی سرویس + اجبار در حالت سرویس + +پزشک ممکن است نخواهد همهٔ سرویس‌ها در نوبت‌دهی نمایش داده شوند. پس: + +- **`ServiceItem`:** فیلد جدید `bookable` (bool, default `false`, ستون `bookable`) = «نمایش در نوبت‌دهی». getter/setter + در `toArray()`. **migration بساز و اجرا کن** (این تنها Entity change است). +- **`ClinicServiceController` (createItem L143, updateItem L188):** `bookable` را مثل `duration_minutes` بپذیر (`if (array_key_exists('bookable', $data)) $item->setBookable((bool)$data['bookable']);`). +- **`ServiceItemRepository`:** متد `countBookableByEntity($entityType, $entityId): int` (یا `findBookable...`) برای enforcement. +- **فیلتر نوبت‌دهی:** endpoint `appointment-service-slots` و `book()`/منشی فقط سرویس‌های `bookable=true` را بپذیرند؛ سرویس غیر-bookable → ۴۲۲ «این سرویس برای نوبت‌دهی فعال نیست». +- **اجبار حالت سرویس:** در `AppointmentSettingsController::createSchedule`/`updateSchedule`، وقتی `meta.booking_mode === service` و هیچ سرویسِ `bookable` برای آن پزشک/کلینیک وجود ندارد → ۴۲۲ «برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است». (سرویس‌ها به entity کلینیک/پزشک وصل‌اند از طریق `ServiceSection.entityType/entityId` — همان resolve موجود در ClinicServiceController.) + +### ۵. پنل ادمین + +- **`WeeklyScheduleTab` (DoctorDetailPage.tsx):** بالای ویرایشگر یک سوییچ «نوبت‌دهی اسلاتی / بر اساس سرویس» + فیلد «بافر بین نوبت‌ها (دقیقه)» اضافه کن که به `meta.booking_mode` و `meta.buffer_minutes` map شود (همراه schedule در همان POST/PATCH `weekly-schedule` ذخیره می‌شود؛ `meta` از قبل پشتیبانی می‌شود). در حالت سرویس، فیلد `duration_per_patient` هر session را مخفی/غیرفعال کن (چون بی‌اثر است) و فقط ساعت شروع/پایان window و آدرس بماند. +- **`ClinicServicesPage.tsx`:** برای هر ServiceItem دو کنترل: فیلد «مدت (دقیقه)» → `duration_minutes` و سوییچ «نمایش در نوبت‌دهی» → `bookable`. هر دو در POST/PATCH `/service-item` ارسال شوند. +- **`WeeklyScheduleTab`:** وقتی حالت «سرویس» انتخاب شد و پزشک هیچ سرویسِ bookable ندارد، پیام/لینک به صفحهٔ سرویس‌ها نشان بده و اجازهٔ ذخیره نده (backend هم ۴۲۲ می‌دهد). +- **`NewAppointmentDrawer.tsx`:** الان منشی دستی `duration` + ساعت شروع/پایان وارد می‌کند (L70-74, L194-208). در حالت سرویس پزشک: + - بعد از انتخاب یک/چند سرویس، `service_item_uuids[]` را به endpoint جدید بفرست و لیست «زمان‌های خالی پیشنهادی» را نمایش بده؛ منشی یکی را انتخاب می‌کند (به‌جای ورود دستی ساعت). `slot_start/slot_end` از انتخاب پر می‌شود. + - اگر هیچ زمانی نبود پیام «امروز جای خالی برای این سرویس نیست» + امکان رفتن به روز بعد. + - مسیر «نوبت آزاد» (`isReserve=true`, L28/L92) دست‌نخورده بماند — بدون زمان، فقط منشی. + - در حالت اسلاتی، همان رفتار فعلی (ورود دستی/اسلات) حفظ شود. + +### ۶. مستندات و تست + +- `docs/api/appointment.md`: endpoint `GET /appointment-service-slots` + پارامترهای جدید `book`. +- `docs/api/appointment-settings.md`: کلیدهای متای جدید `booking_mode`, `buffer_minutes`. +- تست‌های PHPUnit (موفق + خطا + مرزی): `getServiceStartTimes` (پر شدن، gap بین دو نوبت، عدم جای کافی)، محاسبهٔ `slot_end` سمت سرور، عدم تداخل، حفظ رفتار slot mode. تست Vitest برای سوییچ حالت و جریان جدید Drawer. + +## نکات مهم + +- **⚠️ race در حالت سرویس (مهم‌ترین edge):** unique constraint روی `active_slot_key = "doctorId:slotStart"` است — یعنی فقط دو نوبت با **شروع دقیقاً یکسان** را در سطح DB می‌گیرد. در حالت اسلاتی چون شروع‌ها روی گرید ثابت‌اند، هر تداخل ⇒ شروع یکسان ⇒ constraint می‌گیرد. اما در حالت سرویس، دو درخواست هم‌زمانِ «۱۵:۰۰ به مدت ۳۰د» و «۱۵:۲۰ به مدت ۳۰د» شروعِ متفاوت دارند، پس `activeSlotKey` متفاوت است و constraint نمی‌گیرد؛ هر دو `isSlotTaken` را خالی می‌بینند و هر دو insert می‌شوند → **تداخل**. راه‌حل: در `bookAtomically` **در حالت سرویس** قبل از `isSlotTaken`، یک قفلِ per-doctor بگیر تا رزروهای یک پزشک سریالایز شوند — یا pessimistic lock روی ردیف `Doctor` (`$em->lock($doctor, LockMode::PESSIMISTIC_WRITE)`) یا MySQL `GET_LOCK("appt:doctor:{id}")`/`RELEASE_LOCK`. حالت اسلاتی را تغییر نده (همان unique-key کافی است). +- **حفظ حالت اسلاتی:** هیچ رفتار موجودی نباید تغییر کند وقتی `booking_mode = slot`. مسیر جدید فقط شاخهٔ `service`. +- **نوبت آزاد = `isReserve` موجود، نه type جدید.** بازسازی نکن. در تقویم روز از قبل با پرچم متمایز است (`ReserveAppointmentsPage.tsx` + فیلتر `?reserve=1` در `my/appointments`). فقط مطمئن شو بازه‌ای اشغال نمی‌کند (`refreshActiveSlotKey` وقتی `isReserve` → key null است). +- **تغییر حالت نباید نوبت‌های قبلی را خراب کند:** نوبت‌های ثبت‌شده `slot_start/slot_end` مطلق (Unix) دارند و مستقل از حالت‌اند؛ سوییچ حالت فقط روی محاسبهٔ نوبت‌های جدید اثر دارد. این را در docstring/تست تثبیت کن. +- **ویرایش/لغو و آزادسازی زمان:** از قبل کار می‌کند — لغو → `transitionTo(cancelled_*)` → `refreshActiveSlotKey` → key null → `isSlotTaken` دیگر آن بازه را busy نمی‌بیند. `update`/`rescheduleTo` هم موجود است. فقط مطمئن شو مسیر service اینها را نمی‌شکند. +- **الگوهای پروژه:** کنترلرها از `BaseController` ارث می‌برند؛ پاسخ با `$this->success()/error()`؛ timestampها Unix `int`؛ رشته‌های UI فارسی؛ کد/کامیت انگلیسی. هر session فعال در schedule باید `location_id` داشته باشد (`validateSessionsHaveLocation`) — در حالت سرویس هم حفظ شود. +- **قاعدهٔ ۲ (اول بگرد بعد بساز):** `durationMinutes`، `serviceItem`، `isReserve`، `Holiday`، `DateOverride`، overlapِ `isSlotTaken` همه موجودند؛ فقط متای mode/buffer + یک متد محاسبه + یک endpoint + وصل‌کردن UI اضافه می‌شود. diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx index c46575fe..e48b0989 100644 --- a/assets/admin/components/NewAppointmentDrawer.tsx +++ b/assets/admin/components/NewAppointmentDrawer.tsx @@ -54,6 +54,17 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey const [itemUuid, setItemUuid] = useState(''); const [staffUuid, setStaffUuid] = useState(''); + // روش نوبت‌دهی پزشک: در حالت «سرویس» زمان از مدت سرویس محاسبه و پیشنهاد می‌شود. + const scheduleQ = useQuery>({ + queryKey: ['drawer-schedule', doctorUuid], + queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`), + enabled: !!doctorUuid, + }); + const bookingMode: 'slot' | 'service' = + ((scheduleQ.data?.data as any)?.data?.meta ?? (scheduleQ.data?.data as any)?.meta)?.booking_mode === 'service' + ? 'service' : 'slot'; + const serviceMode = bookingMode === 'service' && !isReserve; + const sectionsQ = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'), }); @@ -73,6 +84,23 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey const [end, setEnd] = useState(addMinutes('15:00', 40)); useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]); + // ── service-mode: چند سرویس + زمان‌های خالیِ پیشنهادی ───────────────────────── + const [serviceUuids, setServiceUuids] = useState([]); + const [svcNames, setSvcNames] = useState>({}); + const [pickedSlot, setPickedSlot] = useState<{ start: number; end: number } | null>(null); + useEffect(() => { setPickedSlot(null); }, [serviceUuids, date]); + + const svcSlotsQ = useQuery>({ + queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids], + queryFn: () => api.get( + `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}` + + serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('') + ), + enabled: serviceMode && !!date && serviceUuids.length > 0, + }); + const svcSlots = ((svcSlotsQ.data?.data as any)?.start_times ?? []) as Array<{ start: number; end: number; start_time: string }>; + const totalMinutes = (svcSlotsQ.data?.data as any)?.total_duration_minutes as number | undefined; + // ── deposit / status / notes ─────────────────────────────────────────────── const [depositRequired, setDepositRequired] = useState(false); const [depositRials, setDepositRials] = useState(0); @@ -82,21 +110,29 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey const effectiveName = pickedPatient?.user_name || name.trim(); const effectiveMobile = pickedPatient?.user_mobile || mobile.trim(); const effectiveNationalCode = (pickedPatient?.user_national_code || nationalCode).replace(/\D/g, ''); + const timingValid = isReserve + ? true + : serviceMode + ? (serviceUuids.length > 0 && !!pickedSlot) + : (!!start && !!end); const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 - && effectiveNationalCode.length === 10 && (isReserve || (!!start && !!end)); + && effectiveNationalCode.length === 10 && timingValid; const create = useMutation({ mutationFn: async () => { + const slotStart = isReserve ? toEpoch(date, '00:00') : serviceMode ? pickedSlot!.start : toEpoch(date, start); + const slotEnd = isReserve ? toEpoch(date, '00:00') : serviceMode ? pickedSlot!.end : toEpoch(date, end); const payload: Record = { doctor_uuid: doctorUuid, - slot_start: isReserve ? toEpoch(date, '00:00') : toEpoch(date, start), - slot_end: isReserve ? toEpoch(date, '00:00') : toEpoch(date, end), + slot_start: slotStart, + slot_end: slotEnd, patient_name: effectiveName, patient_mobile: effectiveMobile, patient_national_code: effectiveNationalCode, is_reserve: isReserve, ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), - ...(itemUuid ? { service_item_uuid: itemUuid } : {}), + // حالت سرویس: چند سرویس؛ حالت اسلاتی: تک سرویسِ workflow (اختیاری). + ...(serviceMode ? { service_item_uuids: serviceUuids } : itemUuid ? { service_item_uuid: itemUuid } : {}), ...(staffUuid ? { staff_uuid: staffUuid } : {}), ...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}), ...(note.trim() ? { note: note.trim() } : {}), @@ -175,13 +211,41 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
- - { + const uuid = e.target.value; + if (!uuid) return; + if (serviceMode) { + const name = (itemsQ.data?.data ?? []).find(o => o.uuid === uuid)?.name ?? ''; + setServiceUuids(prev => prev.includes(uuid) ? prev : [...prev, uuid]); + setSvcNames(prev => ({ ...prev, [uuid]: name })); + } else { + setItemUuid(uuid); + } + }} + > + {(itemsQ.data?.data ?? []).map(o => )}
+ {serviceMode && serviceUuids.length > 0 && ( +
+ {serviceUuids.map(uuid => ( + + {svcNames[uuid] ?? uuid} + + + ))} + {totalMinutes != null && مدت کل: {totalMinutes} دقیقه} +
+ )} setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" /> - - {!isReserve && ( -
-
- -
setStart(e.target.value)} dir="ltr" />
-
-
- -
setEnd(e.target.value)} dir="ltr" />
-
+ + {serviceMode ? ( +
+ + {serviceUuids.length === 0 ? ( +
ابتدا سرویس را انتخاب کنید.
+ ) : svcSlotsQ.isLoading ? ( +
در حال محاسبه...
+ ) : svcSlots.length === 0 ? ( +
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
+ ) : ( +
+ {svcSlots.map(s => { + const active = pickedSlot?.start === s.start; + return ( + + ); + })} +
+ )}
+ ) : ( + <> + +
+ setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" /> +
+ {!isReserve && ( +
+
+ +
setStart(e.target.value)} dir="ltr" />
+
+
+ +
setEnd(e.target.value)} dir="ltr" />
+
+
+ )} + )} {!isReserve && ( diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx index b21c3863..61b47486 100644 --- a/assets/admin/pages/ClinicServicesPage.tsx +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -30,6 +30,7 @@ const itemSchema = z.object({ insurance_covered: z.boolean().optional(), insurance_price_rials: z.coerce.number().min(0).optional(), duration_minutes: z.coerce.number().min(0).optional(), + bookable: z.boolean().optional(), }); type SectionForm = z.infer; type ItemForm = z.infer; @@ -207,12 +208,13 @@ function ClinicServicesPageInner() { insurance_covered: item.insurance_covered ?? false, insurance_price_rials: rialToToman(item.insurance_price_rials ?? 0), duration_minutes: item.duration_minutes ?? undefined, + bookable: item.bookable ?? false, }); setItemModal(item); }; const openCreateItem = () => { - itemForm.reset({ name: '', price_rials: 0, staff_uuids: [], insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined }); + itemForm.reset({ name: '', price_rials: 0, staff_uuids: [], insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined, bookable: false }); setItemModal('create'); }; @@ -376,9 +378,12 @@ function ClinicServicesPageInner() { زمان متوسط: - {item.duration_minutes - ? {formatNumber(Number(item.duration_minutes))} دقیقه - : } + + {item.bookable && در نوبت‌دهی} + {item.duration_minutes + ? {formatNumber(Number(item.duration_minutes))} دقیقه + : } +
{item.insurance_covered && item.insurance_price_rials != null && ( @@ -517,13 +522,24 @@ function ClinicServicesPageInner() { -
+
+
diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 85e8d5fd..a98b2c7c 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -101,11 +101,15 @@ interface BookingMeta { online_booking_enabled: boolean; booking_window_value: number; booking_window_unit: 'week' | 'month'; + booking_mode: 'slot' | 'service'; + buffer_minutes: number; } const DEFAULT_BOOKING_META: BookingMeta = { online_booking_enabled: true, booking_window_value: 1, booking_window_unit: 'month', + booking_mode: 'slot', + buffer_minutes: 0, }; interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; } @@ -1384,6 +1388,53 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { )} + {/* ─ روش نوبت‌دهی */} +
+
+ روش نوبت‌دهی +
+
+
+ {([['slot', 'اسلاتی (مدت ثابت)'], ['service', 'بر اساس سرویس']] as const).map(([val, lbl]) => ( + + ))} +
+ {meta.booking_mode === 'service' ? ( + <> +
+ فاصله بین نوبت‌ها + setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(e.target.value) || 0) }))} + className="w-16 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 focus:outline-none focus:ring-0" + /> + دقیقه +
+

+ مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود. لازم است حداقل یک سرویس با «نمایش در نوبت‌دهی» در بخش سرویس‌ها تعریف کنید، وگرنه ذخیره نمی‌شود. +

+ + ) : ( +

+ مدت هر نوبت از «زمان هر نوبت» در شیفت‌های زیر تعیین می‌شود. +

+ )} +
+
+ {/* ─ نوبت‌دهی آنلاین */}
{/* header + toggle */} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 3a8091d6..700aee55 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -463,6 +463,7 @@ export interface ServiceItem { insurance_covered?: boolean; insurance_price_rials?: number | null; duration_minutes?: number | null; + bookable?: boolean; } export interface SmsWalletBalance { diff --git a/migrations/Version20260715192758.php b/migrations/Version20260715192758.php new file mode 100644 index 00000000..9f4b1feb --- /dev/null +++ b/migrations/Version20260715192758.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE service_items ADD bookable TINYINT DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE service_items DROP bookable'); + } +} diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 1660cae9..03248c14 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -136,6 +136,63 @@ class AppointmentController extends BaseController ]); } + /** + * حالت نوبت‌دهی سرویسی: زمان‌های خالیِ کافی برای مجموعِ مدت سرویس‌های انتخاب‌شده. + * فقط سرویس‌های «نمایش در نوبت‌دهی» (bookable) و دارای مدت پذیرفته می‌شوند. + * + * GET /api/v1/appointment-service-slots?doctor_uuid=..&date=Y-m-d&service_item_uuids[]=.. + */ + #[Route('/api/v1/appointment-service-slots', methods: ['GET'])] + public function serviceSlots(Request $request): JsonResponse + { + $doctorUuid = trim($request->query->get('doctor_uuid', '')); + $date = trim($request->query->get('date', '')); + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); + } + + $schedule = $this->scheduleRepo->findByDoctor($doctor); + $mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT; + if ($mode !== WeeklySchedule::MODE_SERVICE) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422); + } + + $uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids')))); + if (empty($uuids)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids'); + } + + $totalMinutes = 0; + foreach ($uuids as $u) { + $item = $this->itemRepo->findByUuid($u); + if ($item === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids'); + } + if (!$item->isBookable()) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); + } + if (($item->getDurationMinutes() ?? 0) <= 0) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); + } + $totalMinutes += (int) $item->getDurationMinutes(); + } + + $meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META; + + return $this->success([ + 'doctor_uuid' => $doctorUuid, + 'date' => $date, + 'total_duration_minutes' => $totalMinutes, + 'buffer_minutes' => (int) $meta['buffer_minutes'], + 'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes), + ]); + } + #[Route('/api/v1/appointment-settings/month-availability/{doctorUuid}', methods: ['GET'])] public function monthAvailability(string $doctorUuid, Request $request): JsonResponse { @@ -224,6 +281,29 @@ class AppointmentController extends BaseController $slotStart = (int) ($data['slot_start'] ?? 0); $slotEnd = (int) ($data['slot_end'] ?? 0); + // حالت نوبت‌دهی سرویسی: مدت نوبت = مجموع مدت سرویس‌های bookableِ انتخاب‌شده، + // و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود). + $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? [])))); + $serviceItem = null; + if (!empty($serviceUuids)) { + $totalMinutes = 0; + foreach ($serviceUuids as $u) { + $item = $this->itemRepo->findByUuid($u); + if ($item === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids'); + } + if (!$item->isBookable()) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); + } + if (($item->getDurationMinutes() ?? 0) <= 0) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); + } + $totalMinutes += (int) $item->getDurationMinutes(); + $serviceItem ??= $item; + } + $slotEnd = $slotStart + $totalMinutes * 60; + } + if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422); } @@ -261,6 +341,7 @@ class AppointmentController extends BaseController $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); $appointment->setPatientNationalCode($nationalCode); $appointment->setPatientGender($gender); + if ($serviceItem !== null) $appointment->setServiceItem($serviceItem); if (isset($data['note'])) $appointment->setNote($data['note']); // نماینده‌ی دامنه‌ی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظه‌ی diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index bcb22539..694291d4 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -34,8 +34,19 @@ class AppointmentSettingsController extends BaseController private readonly DoctorRepository $doctorRepo, private readonly DoctorAddressRepository $addressRepo, private readonly ClinicRepository $clinicRepo, + private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo, ) {} + /** + * در حالت نوبت‌دهی سرویسی، پزشک باید حداقل یک سرویسِ «نمایش در نوبت‌دهی» + * (bookable) داشته باشد؛ وگرنه هیچ نوبتی قابل‌محاسبه نیست. + */ + private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool + { + return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE + && $this->itemRepo->countBookableByEntity('doctor', $doctor->getId()) === 0; + } + // ── Weekly Schedule ─────────────────────────────────────────────────────── #[Route('/api/v1/appointment-settings/weekly-schedule', methods: ['POST'])] @@ -69,6 +80,10 @@ class AppointmentSettingsController extends BaseController $schedule->setMeta($data['meta']); } + if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode'); + } + $this->scheduleRepo->save($schedule); return $this->success(['data' => $schedule->toArray()], 201); @@ -103,6 +118,10 @@ class AppointmentSettingsController extends BaseController $schedule->setMeta($data['meta']); } + if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor())) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode'); + } + $this->scheduleRepo->save($schedule); return $this->success(['data' => $schedule->toArray()]); diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 5fcfc891..f5fbe9d3 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -69,6 +69,29 @@ class MyAppointmentsController extends BaseController $slotEnd = $slotStart; } + // حالت نوبت‌دهی سرویسی: مدت نوبت از مجموعِ مدت سرویس‌های انتخاب‌شده تعیین + // و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود). + $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? [])))); + $serviceItems = []; + if (!empty($serviceUuids) && !$isReserve) { + $totalMinutes = 0; + foreach ($serviceUuids as $u) { + $item = $this->itemRepo->findByUuid($u); + if ($item === null) { + return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids'); + } + if (!$item->isBookable()) { + return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); + } + if (($item->getDurationMinutes() ?? 0) <= 0) { + return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); + } + $totalMinutes += (int) $item->getDurationMinutes(); + $serviceItems[] = $item; + } + $slotEnd = $slotStart + $totalMinutes * 60; + } + if (empty($doctorUuid) || $slotStart <= 0 || (!$isReserve && $slotEnd <= $slotStart) || empty($mobile) || empty($patientName)) { return $this->error(ErrorCodes::VALIDATION, 'همه فیلدها الزامی است', 422); } @@ -117,6 +140,10 @@ class MyAppointmentsController extends BaseController } $appointment->$setter($entity); } + // در حالت سرویسی، سرویسِ اصلیِ نوبت = اولین سرویسِ انتخاب‌شده. + if (!empty($serviceItems)) { + $appointment->setServiceItem($serviceItems[0]); + } if (!empty($data['deposit_required'])) { $appointment->setDepositRequired(true); } diff --git a/src/Appointment/Entity/WeeklySchedule.php b/src/Appointment/Entity/WeeklySchedule.php index 1f83bb9b..21821eaf 100644 --- a/src/Appointment/Entity/WeeklySchedule.php +++ b/src/Appointment/Entity/WeeklySchedule.php @@ -15,10 +15,16 @@ class WeeklySchedule public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday']; public const META_KEY = 'meta'; + + public const MODE_SLOT = 'slot'; // نوبت‌دهی اسلاتی (رفتار پیش‌فرض) + public const MODE_SERVICE = 'service'; // نوبت‌دهی بر اساس مدت سرویس + public const DEFAULT_META = [ 'online_booking_enabled' => true, 'booking_window_value' => 1, 'booking_window_unit' => 'month', + 'booking_mode' => self::MODE_SLOT, + 'buffer_minutes' => 0, ]; #[ORM\Id] @@ -82,6 +88,10 @@ class WeeklySchedule 'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, ['week', 'month'], true) ? $meta['booking_window_unit'] : $current['booking_window_unit'], + 'booking_mode' => in_array($meta['booking_mode'] ?? null, [self::MODE_SLOT, self::MODE_SERVICE], true) + ? $meta['booking_mode'] + : $current['booking_mode'], + 'buffer_minutes' => max(0, (int)($meta['buffer_minutes'] ?? $current['buffer_minutes'])), ]; $this->updatedAt = time(); return $this; diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index e7eff04e..ab438650 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -7,6 +7,7 @@ use App\Auth\Entity\User; use App\Doctor\Entity\Doctor; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use Doctrine\DBAL\LockMode; use Doctrine\ORM\OptimisticLockException; use Doctrine\Persistence\ManagerRegistry; @@ -37,6 +38,13 @@ class AppointmentRepository extends ServiceEntityRepository $start = $appointment->getSlotStart(); $end = $appointment->getSlotEnd(); + // قفلِ per-doctor (SELECT ... FOR UPDATE روی ردیف پزشک): رزروهای + // هم‌زمانِ یک پزشک را سریالایز می‌کند. در حالت نوبت‌دهی سرویسی که + // نوبت‌ها طول متغیر و شروعِ متفاوت دارند، unique-keyِ (doctor,slot_start) + // تداخلِ بازه‌ایِ دو رزروِ هم‌زمان را نمی‌گیرد؛ این قفل تضمین می‌کند + // بررسیِ isSlotTaken و insert به‌صورت اتمیک نسبت به سایر رزروها انجام شود. + $em->lock($doctor, LockMode::PESSIMISTIC_WRITE); + if ($this->isSlotTaken($doctor, $start, $end)) { throw new SlotTakenException(); } @@ -87,6 +95,37 @@ class AppointmentRepository extends ServiceEntityRepository $this->getEntityManager()->flush(); } + /** + * بازه‌های اشغال‌شدهٔ یک پزشک در پنجرهٔ [$from, $to) — برای محاسبهٔ زمانِ خالی + * در حالت نوبت‌دهی سرویسی. همان معیارِ isSlotTaken (blocking یا pendingِ زنده)، + * ولی نوبت‌های «آزاد» (is_reserve) هیچ بازه‌ای اشغال نمی‌کنند. + * + * @return array مرتب‌شده بر اساس start + */ + public function findBusyIntervals(Doctor $doctor, int $from, int $to): array + { + $rows = $this->createQueryBuilder('a') + ->select('a.slotStart AS start, a.slotEnd AS end') + ->where('a.doctor = :doctor') + ->andWhere('a.isReserve = false') + ->andWhere('a.slotStart < :to') + ->andWhere('a.slotEnd > :from') + ->andWhere( + 'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))' + ) + ->setParameter('doctor', $doctor) + ->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES) + ->setParameter('pending', Appointment::STATUS_PENDING) + ->setParameter('now', time()) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('a.slotStart', 'ASC') + ->getQuery() + ->getScalarResult(); + + return array_map(fn($r) => ['start' => (int) $r['start'], 'end' => (int) $r['end']], $rows); + } + /** Check if a slot is already taken (confirmed or pending) */ public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool { diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index 8cdfe526..34b4a65f 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -80,6 +80,74 @@ class SlotCalculatorService return !empty($this->buildAllSessions($doctor, $date)); } + /** + * حالت نوبت‌دهی سرویسی: زمان‌های شروعِ ممکن برای نوبتی به طول $durationMinutes + * در یک روز. برخلاف اسلاتِ ثابت، فضای خالی داخل هر session را با توجه به مدت + * سرویس (+ بافر) پُر می‌کند: از ابتدای window شروع، بازه‌های اشغال‌شده را رد + * می‌کند و اولین جای پیوستهٔ کافی را برمی‌گرداند، سپس نوبت‌های بعدی را پشت‌سرهم + * (با فاصلهٔ بافر) می‌چیند. + * + * زمان پایانِ ذخیره‌شدهٔ نوبت = start + duration (بدون بافر)؛ بافر فقط فاصلهٔ + * بین دو نوبت است، پس candidate بعدی از start + duration + buffer شروع می‌شود. + * + * @return array + */ + public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes): array + { + if ($durationMinutes <= 0) return []; + + $buffer = (int)($this->getBookingMeta($doctor)['buffer_minutes'] ?? 0); + $durSec = $durationMinutes * 60; + $needSec = $durSec + $buffer * 60; // فضای لازم شامل بافر + + $sessions = $this->buildAllSessions($doctor, $date); // window/holiday/override/booking-window رعایت می‌شود + if (empty($sessions)) return []; + + $dayStart = (int) strtotime($date . ' 00:00:00'); + $busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400); + $now = time(); + + $result = []; + foreach ($sessions as $session) { + $winStart = $dayStart + $this->parseTime($session['start_time'] ?? '00:00'); + $winEnd = $dayStart + $this->parseTime($session['end_time'] ?? '00:00'); + $locationId = $session['slots'][0]['location_id'] ?? null; + + $t = max($winStart, $now); + while ($t + $durSec <= $winEnd) { + $end = $t + $durSec; + $conflict = $this->firstOverlap($t, $t + $needSec, $busy); + if ($conflict !== null) { + $t = $conflict; // به انتهای بازهٔ اشغال‌شدهٔ متداخل بپر + continue; + } + $result[] = [ + 'start' => $t, + 'end' => $end, + 'start_time' => date('H:i', $t), + 'end_time' => date('H:i', $end), + 'location_id' => $locationId !== null ? (int) $locationId : null, + ]; + $t += $needSec; // نوبت بعدی پس از این نوبت + بافر + } + } + return $result; + } + + /** + * انتهای اولین بازهٔ اشغال‌شده‌ای که با [$start, $end) تداخل دارد، یا null. + * @param array $busy + */ + private function firstOverlap(int $start, int $end, array $busy): ?int + { + foreach ($busy as $b) { + if ($b['start'] < $end && $b['end'] > $start) { + return $b['end']; + } + } + return null; + } + /** * Booking is allowed only when online booking is enabled and the date is * today..(today + window). Past dates are always rejected. diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index 23ea14ec..b79565b1 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -176,6 +176,9 @@ class ClinicServiceController extends BaseController $dm = $data['duration_minutes']; $item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm); } + if (array_key_exists('bookable', $data)) { + $item->setBookable((bool) $data['bookable']); + } $this->itemRepo->save($item); @@ -217,6 +220,9 @@ class ClinicServiceController extends BaseController $dm = $data['duration_minutes']; $item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm); } + if (array_key_exists('bookable', $data)) { + $item->setBookable((bool) $data['bookable']); + } $this->itemRepo->save($item); diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index f5c31922..f17ebefe 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -58,6 +58,10 @@ class ServiceItem #[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)] private ?int $durationMinutes = null; + /** نمایش این سرویس در نوبت‌دهی (پزشک ممکن است همهٔ سرویس‌ها را ارائه ندهد). */ + #[ORM\Column(type: 'boolean', options: ['default' => false])] + private bool $bookable = false; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -85,6 +89,7 @@ class ServiceItem public function isInsuranceCovered(): bool { return $this->insuranceCovered; } public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; } public function getDurationMinutes(): ?int { return $this->durationMinutes; } + public function isBookable(): bool { return $this->bookable; } public function getCreatedAt(): int { return $this->createdAt; } public function getUpdatedAt(): int { return $this->updatedAt; } @@ -122,6 +127,7 @@ class ServiceItem public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; } public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; } public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; } + public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; } public function toArray(): array { @@ -151,6 +157,7 @@ class ServiceItem 'insurance_covered' => $this->insuranceCovered, 'insurance_price_rials' => $this->insurancePriceRials, 'duration_minutes' => $this->durationMinutes, + 'bookable' => $this->bookable, 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, ]; diff --git a/src/ClinicService/Repository/ServiceItemRepository.php b/src/ClinicService/Repository/ServiceItemRepository.php index 3f7f1574..d9af5b57 100644 --- a/src/ClinicService/Repository/ServiceItemRepository.php +++ b/src/ClinicService/Repository/ServiceItemRepository.php @@ -57,6 +57,25 @@ class ServiceItemRepository extends ServiceEntityRepository return $counts; } + /** + * تعداد سرویس‌های فعالِ «نمایش در نوبت‌دهی» (bookable) متعلق به یک entity + * (پزشک/کلینیک) — از طریق section.entityType/entityId. برای اجبارِ حالت سرویس. + */ + public function countBookableByEntity(string $entityType, int $entityId): int + { + return (int) $this->createQueryBuilder('i') + ->select('COUNT(i.id)') + ->join('i.section', 's') + ->where('s.entityType = :type') + ->andWhere('s.entityId = :id') + ->andWhere('i.bookable = true') + ->andWhere('i.active = true') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->getQuery() + ->getSingleScalarResult(); + } + public function save(ServiceItem $item): void { $this->getEntityManager()->persist($item); diff --git a/tests/Appointment/ServiceBasedSlotsTest.php b/tests/Appointment/ServiceBasedSlotsTest.php new file mode 100644 index 00000000..194f5ff8 --- /dev/null +++ b/tests/Appointment/ServiceBasedSlotsTest.php @@ -0,0 +1,105 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر سرویس'); + $this->em->persist($doctor); + + // فردا در بازهٔ booking-window قرار دارد و روز گذشته نیست. + $date = date('Y-m-d', strtotime('tomorrow')); + $dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7); + + $schedule = new WeeklySchedule($doctor, [ + $dayKey => ['sessions' => [[ + 'active' => true, + 'start_time' => '15:00', + 'end_time' => '17:00', + 'duration_per_patient' => 20, + 'location_id' => 1, + ]]], + ]); + $schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]); + $this->em->persist($schedule); + $this->em->flush(); + + return [$doctor, $date]; + } + + public function testGapPackingWithBuffer(): void + { + [$doctor, $date] = $this->makeDoctorWithServiceSchedule(5); + + $calc = static::getContainer()->get(SlotCalculatorService::class); + $slots = $calc->getServiceStartTimes($doctor, $date, 30); + + // پنجره 15:00–17:00، سرویس 30 + بافر 5 → گام 35 دقیقه: 15:00, 15:35, 16:10 + $times = array_column($slots, 'start_time'); + $this->assertSame(['15:00', '15:35', '16:10'], $times); + + // زمان پایانِ ذخیره‌شده بدون بافر است. + $this->assertSame(strtotime($date . ' 15:00') + 30 * 60, $slots[0]['end']); + } + + public function testBookedIntervalIsSkipped(): void + { + [$doctor, $date] = $this->makeDoctorWithServiceSchedule(5); + + $patient = $this->createUser(['ROLE_USER']); + $start = strtotime($date . ' 15:00'); + $appt = new Appointment($doctor, $patient, $start, $start + 30 * 60); + $appt->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appt); + $this->em->flush(); + + $calc = static::getContainer()->get(SlotCalculatorService::class); + $times = array_column($calc->getServiceStartTimes($doctor, $date, 30), 'start_time'); + + // 15:00 اشغال است → از 15:30 شروع می‌شود. + $this->assertNotContains('15:00', $times); + $this->assertContains('15:30', $times); + } + + public function testNoRoomReturnsEmpty(): void + { + [$doctor, $date] = $this->makeDoctorWithServiceSchedule(0); + + // سرویس 200 دقیقه در پنجرهٔ 120 دقیقه‌ای جا نمی‌شود. + $calc = static::getContainer()->get(SlotCalculatorService::class); + $this->assertSame([], $calc->getServiceStartTimes($doctor, $date, 200)); + } + + public function testMetaDefaultsAndWhitelist(): void + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر متا'); + $schedule = new WeeklySchedule($doctor, []); + + // پیش‌فرض = اسلاتی + $this->assertSame(WeeklySchedule::MODE_SLOT, $schedule->getMeta()['booking_mode']); + $this->assertSame(0, $schedule->getMeta()['buffer_minutes']); + + $schedule->setMeta(['booking_mode' => 'service', 'buffer_minutes' => 7]); + $this->assertSame('service', $schedule->getMeta()['booking_mode']); + $this->assertSame(7, $schedule->getMeta()['buffer_minutes']); + + // مقدار نامعتبر mode نادیده گرفته می‌شود (whitelist)، بافر منفی → صفر. + $schedule->setMeta(['booking_mode' => 'bogus', 'buffer_minutes' => -3]); + $this->assertSame('service', $schedule->getMeta()['booking_mode']); + $this->assertSame(0, $schedule->getMeta()['buffer_minutes']); + } +}