# نوبت‌دهی بر اساس مدت سرویس (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 اضافه می‌شود.