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

20 KiB
Raw Blame History

نوبت‌دهی بر اساس مدت سرویس (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 قابل‌ویرایش است

وضعیت فعلی (کد واقعی)

مدت خدمت — هست ولی استفاده نمی‌شود

// 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

متای برنامهٔ هفتگی

// 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)

// src/Appointment/Service/SlotCalculatorService.php:225 buildSessionSlots()
$dur = (int)($session['duration_per_patient'] ?? 20) * 60;   // گام ثابت
while ($currentSec + $dur <= $endSec) { ... $currentSec += $dur; }

overlap واقعی از قبل درست است

// src/Appointment/Repository/AppointmentRepository.php:91 isSlotTaken()
->andWhere('a.slotStart < :slotEnd')
->andWhere('a.slotEnd > :slotStart')   // interval overlap — نه exact key

book() فعلی فقط slot_start/slot_end می‌گیرد

// 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:

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 کن:

/**
 * زمان‌های شروعِ ممکن برای نوبتی به طول $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 استاندارد:
{ "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 اضافه می‌شود.