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.
This commit is contained in:
@@ -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 اضافه میشود.
|
||||
@@ -54,6 +54,17 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
const [itemUuid, setItemUuid] = useState('');
|
||||
const [staffUuid, setStaffUuid] = useState('');
|
||||
|
||||
// روش نوبتدهی پزشک: در حالت «سرویس» زمان از مدت سرویس محاسبه و پیشنهاد میشود.
|
||||
const scheduleQ = useQuery<ApiResponse<any>>({
|
||||
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<ApiResponse<Option[]>>({
|
||||
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<string[]>([]);
|
||||
const [svcNames, setSvcNames] = useState<Record<string, string>>({});
|
||||
const [pickedSlot, setPickedSlot] = useState<{ start: number; end: number } | null>(null);
|
||||
useEffect(() => { setPickedSlot(null); }, [serviceUuids, date]);
|
||||
|
||||
const svcSlotsQ = useQuery<ApiResponse<any>>({
|
||||
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<string, unknown> = {
|
||||
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
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>سرویس</label>
|
||||
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
|
||||
<option value="">انتخاب سرویس</option>
|
||||
<label style={label}>سرویس{serviceMode ? ' (یک یا چند)' : ''}</label>
|
||||
<select
|
||||
aria-label="سرویس"
|
||||
style={{ ...sel, marginTop: 6 }}
|
||||
value={serviceMode ? '' : itemUuid}
|
||||
disabled={!sectionUuid}
|
||||
onChange={e => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">{serviceMode ? 'افزودن سرویس' : 'انتخاب سرویس'}</option>
|
||||
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{serviceMode && serviceUuids.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
|
||||
{serviceUuids.map(uuid => (
|
||||
<span key={uuid} className="badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '4px 8px' }}>
|
||||
{svcNames[uuid] ?? uuid}
|
||||
<button type="button" aria-label="حذف سرویس" onClick={() => setServiceUuids(prev => prev.filter(u => u !== uuid))}
|
||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-3)', fontSize: 14, lineHeight: 1 }}>×</button>
|
||||
</span>
|
||||
))}
|
||||
{totalMinutes != null && <span style={{ fontSize: 12, color: 'var(--text-3)', alignSelf: 'center' }}>مدت کل: {totalMinutes} دقیقه</span>}
|
||||
</div>
|
||||
)}
|
||||
<label style={label}>انتخاب پرسنل</label>
|
||||
<select aria-label="پرسنل" style={{ ...sel, margin: '6px 0 12px' }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
|
||||
<option value="">انتخاب...</option>
|
||||
@@ -191,21 +255,51 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>زمان نوبت:</div>
|
||||
<label style={label}>انتخاب تاریخ</label>
|
||||
<div style={{ margin: '6px 0 10px' }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
||||
<div className="field" style={{ margin: '6px 0 10px' }}>
|
||||
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
|
||||
</div>
|
||||
{!isReserve && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={label}>ساعت شروع</label>
|
||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت پایان</label>
|
||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
|
||||
{serviceMode ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={label}>زمانهای خالی پیشنهادی</label>
|
||||
{serviceUuids.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 6 }}>ابتدا سرویس را انتخاب کنید.</div>
|
||||
) : svcSlotsQ.isLoading ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 6 }}>در حال محاسبه...</div>
|
||||
) : svcSlots.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--danger)', marginTop: 6 }}>برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{svcSlots.map(s => {
|
||||
const active = pickedSlot?.start === s.start;
|
||||
return (
|
||||
<button key={s.start} type="button" dir="ltr" onClick={() => setPickedSlot({ start: s.start, end: s.end })}
|
||||
style={{ fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? '#fff' : 'var(--text)' }}>
|
||||
{s.start_time}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
||||
<div className="field" style={{ margin: '6px 0 10px' }}>
|
||||
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
|
||||
</div>
|
||||
{!isReserve && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={label}>ساعت شروع</label>
|
||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت پایان</label>
|
||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isReserve && (
|
||||
|
||||
@@ -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<typeof sectionSchema>;
|
||||
type ItemForm = z.infer<typeof itemSchema>;
|
||||
@@ -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() {
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<ClockIcon style={{ width: 14, color: 'var(--text-3)' }} /> زمان متوسط:
|
||||
</span>
|
||||
{item.duration_minutes
|
||||
? <span className="badge blue" style={{ fontSize: 11 }}>{formatNumber(Number(item.duration_minutes))} دقیقه</span>
|
||||
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}>—</span>}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{item.bookable && <span className="badge green" style={{ fontSize: 11 }}>در نوبتدهی</span>}
|
||||
{item.duration_minutes
|
||||
? <span className="badge blue" style={{ fontSize: 11 }}>{formatNumber(Number(item.duration_minutes))} دقیقه</span>
|
||||
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}>—</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.insurance_covered && item.insurance_price_rials != null && (
|
||||
@@ -517,13 +522,24 @@ function ClinicServicesPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, alignItems: 'end' }}>
|
||||
<div>
|
||||
<label className="field-label">زمان متوسط (دقیقه)</label>
|
||||
<div className="field">
|
||||
<input type="number" min={0} {...itemForm.register('duration_minutes')} placeholder="مثلاً: ۵۰" />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
|
||||
<span className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={itemForm.watch('bookable') ?? false}
|
||||
onChange={(e) => itemForm.setValue('bookable', e.target.checked)}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13 }}>نمایش در نوبتدهی</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─ روش نوبتدهی */}
|
||||
<div className="mb-3 rounded-xl border border-slate-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-gray-800/50">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">روش نوبتدهی</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{([['slot', 'اسلاتی (مدت ثابت)'], ['service', 'بر اساس سرویس']] as const).map(([val, lbl]) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => setMeta(m => ({ ...m, booking_mode: val }))}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
|
||||
meta.booking_mode === val
|
||||
? 'bg-[var(--primary)] text-white border-[var(--primary)]'
|
||||
: 'bg-white dark:bg-gray-900 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{meta.booking_mode === 'service' ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">فاصله بین نوبتها</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={meta.buffer_minutes}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">دقیقه</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 leading-relaxed">
|
||||
مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود. لازم است حداقل یک سرویس با «نمایش در نوبتدهی» در بخش <span className="font-medium">سرویسها</span> تعریف کنید، وگرنه ذخیره نمیشود.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 leading-relaxed">
|
||||
مدت هر نوبت از «زمان هر نوبت» در شیفتهای زیر تعیین میشود.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─ نوبتدهی آنلاین */}
|
||||
<div className="mb-3 rounded-xl border border-slate-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* header + toggle */}
|
||||
|
||||
@@ -463,6 +463,7 @@ export interface ServiceItem {
|
||||
insurance_covered?: boolean;
|
||||
insurance_price_rials?: number | null;
|
||||
duration_minutes?: number | null;
|
||||
bookable?: boolean;
|
||||
}
|
||||
|
||||
export interface SmsWalletBalance {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260715192758 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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 مرورگر)؛ گاردِ نهایی پورسانت در لحظهی
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<array{start:int,end:int}> مرتبشده بر اساس 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
|
||||
{
|
||||
|
||||
@@ -80,6 +80,74 @@ class SlotCalculatorService
|
||||
return !empty($this->buildAllSessions($doctor, $date));
|
||||
}
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی سرویسی: زمانهای شروعِ ممکن برای نوبتی به طول $durationMinutes
|
||||
* در یک روز. برخلاف اسلاتِ ثابت، فضای خالی داخل هر session را با توجه به مدت
|
||||
* سرویس (+ بافر) پُر میکند: از ابتدای window شروع، بازههای اشغالشده را رد
|
||||
* میکند و اولین جای پیوستهٔ کافی را برمیگرداند، سپس نوبتهای بعدی را پشتسرهم
|
||||
* (با فاصلهٔ بافر) میچیند.
|
||||
*
|
||||
* زمان پایانِ ذخیرهشدهٔ نوبت = start + duration (بدون بافر)؛ بافر فقط فاصلهٔ
|
||||
* بین دو نوبت است، پس candidate بعدی از start + duration + buffer شروع میشود.
|
||||
*
|
||||
* @return array<array{start:int,end:int,start_time:string,end_time:string,location_id:?int}>
|
||||
*/
|
||||
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<array{start:int,end:int}> $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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Appointment\Service\SlotCalculatorService;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی سرویسی: getServiceStartTimes باید فضای خالیِ داخل شیفت را با توجه
|
||||
* به مدت سرویس (+ بافر) بچیند و بازههای اشغالشده را رد کند. همچنین متای mode/buffer.
|
||||
*/
|
||||
class ServiceBasedSlotsTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctorWithServiceSchedule(int $buffer = 5): array
|
||||
{
|
||||
$owner = $this->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user