Files
clinicpro/.claude/prompt/service-based-booking.md
T
hamed 5937f7e176 feat: add service-based booking mode to appointment scheduling
- Introduced a new booking mode in WeeklySchedule to support service-based appointments.
- Updated SlotCalculatorService to calculate available start times based on selected service durations and buffer times.
- Enhanced AppointmentController to handle service items during booking, calculating slot_end on the server side.
- Implemented validation to ensure at least one bookable service exists for doctors in service mode.
- Added new API endpoint to retrieve available appointment slots based on selected services.
- Updated MyAppointmentsController to accept service items during appointment creation.
- Modified ServiceItem entity to include a bookable flag, allowing services to be marked for scheduling.
- Created migration to add bookable column to service_items table.
- Added tests for service-based slot calculations and validation logic.
2026-07-15 23:15:45 +03:30

218 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# نوبت‌دهی بر اساس مدت سرویس (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 اضافه می‌شود.