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:
hamed
2026-07-15 23:15:45 +03:30
parent 6904361e32
commit 5937f7e176
16 changed files with 817 additions and 26 deletions
+217
View File
@@ -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 اضافه می‌شود.
+115 -21
View File
@@ -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 && (
+21 -5
View File
@@ -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>
+51
View File
@@ -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 */}
+1
View File
@@ -463,6 +463,7 @@ export interface ServiceItem {
insurance_covered?: boolean;
insurance_price_rials?: number | null;
duration_minutes?: number | null;
bookable?: boolean;
}
export interface SmsWalletBalance {
+31
View File
@@ -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);
}
+10
View File
@@ -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);
+7
View File
@@ -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);
+105
View File
@@ -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:0017: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']);
}
}