- 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.
20 KiB
نوبتدهی بر اساس مدت سرویس (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وDateOverrideentities — تعطیلات و استثناها از قبل هستند.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
sessionswindows (start_time/end_time), butduration_per_patientis ignored; appointment length = sum of selected services'durationMinutes+ optionalbuffer_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 میتواند خطای ۴۲۲ «این پزشک در حالت نوبتدهی سرویس نیست» بدهد یا خالی برگرداند — تصمیم را مستند کن. ServiceItemrepository از قبل هست (ServiceItemRepository::findByUuid).
۴. book() و MyAppointmentsController: پذیرش سرویس
در AppointmentController::book() و MyAppointmentsController (POST /api/v1/my/appointment):
- ورودی جدید اختیاری:
service_item_uuids: string[](و/یاservice_item_uuidتکی که الان هم پذیرفته میشود). - اگر پزشک
servicemode است و سرویس داده شده:slot_endرا ازslot_start + Σ durationMinutes*60در سمت سرور محاسبه کن (بهslot_endکلاینت اعتماد نکن) و همان serviceItem را روی نوبت set کن. - حالت
slotدقیقاً مثل الان بماند (ازslot_endکلاینت استفاده کن). - قبل از insert، در همان تراکنش
isSlotTaken(که overlap واقعی میزند) کافی است برای صحت منطقی؛ ولی race concurrency را ببین نکتهٔ زیر.
۴.۵ نشان «نمایش در نوبتدهی» روی سرویس + اجبار در حالت سرویس
پزشک ممکن است نخواهد همهٔ سرویسها در نوبتدهی نمایش داده شوند. پس:
ServiceItem: فیلد جدیدbookable(bool, defaultfalse, ستون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_minutesmap شود (همراه schedule در همان POST/PATCHweekly-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: endpointGET /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)) یا MySQLGET_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ها Unixint؛ رشتههای UI فارسی؛ کد/کامیت انگلیسی. هر session فعال در schedule بایدlocation_idداشته باشد (validateSessionsHaveLocation) — در حالت سرویس هم حفظ شود. - قاعدهٔ ۲ (اول بگرد بعد بساز):
durationMinutes،serviceItem،isReserve،Holiday،DateOverride، overlapِisSlotTakenهمه موجودند؛ فقط متای mode/buffer + یک متد محاسبه + یک endpoint + وصلکردن UI اضافه میشود.