feat: section-based service picker + per-appointment duration override (service mode)

Service-booking mode now selects services by section like slot mode:
appointment-booking-services returns service_section per item; ServiceSlotPicker
groups by section (SearchableSelect), accumulates picks across sections into a
removable 'section -> service' chip list.

Secretaries can override a service's duration for a single appointment without
changing the service default: appointment-service-slots accepts durations[uuid]
and both create endpoints accept service_durations; the override drives total
duration and slot_end. Online (patient) booking is unaffected — it never sends
overrides. Backend + frontend tests and docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 10:41:15 +03:30
co-authored by Claude Opus 4.8
parent af6da197dc
commit e00fe997f9
12 changed files with 598 additions and 71 deletions
@@ -0,0 +1,192 @@
# انتخاب سرویس بر اساس بخش + ویرایش مدت توسط منشی — حالت نوبت‌دهی سرویسی
## زمینه
صفحهٔ ایجاد نوبت پنل ادمین (`assets/admin/pages/AppointmentCreatePage.tsx`) دو حالت دارد که با `booking_mode` پزشک تعیین می‌شود (`useDoctorBookingServices`):
- **اسلاتی (`slot`)**: کاربر بخش را انتخاب می‌کند، سپس سرویس‌های همان بخش به‌صورت چک‌باکس نشان داده می‌شوند، انتخاب‌ها در یک لیستِ انباشته (chip قابل حذف) جمع می‌شوند و بین چند بخش انباشته می‌مانند. تاریخ/ساعت شروع/پایان دستی است. (این الگو **قبلاً پیاده شده** — state `selectedServices: {uuid,name}[]`، endpointهای `GET /api/v1/service-sections` و `GET /api/v1/service-items/{sectionUuid}`.)
- **سرویسی (`service`)**: از کامپوننت `assets/admin/components/appointments/ServiceSlotPicker.tsx` استفاده می‌شود که سرویس‌ها را **تخت** (بدون بخش) از `GET /api/v1/appointment-booking-services/{doctorUuid}` می‌گیرد؛ کاربر یک/چند سرویس را تیک می‌زند، مدت کل = مجموع `duration_minutes` سرویس‌ها، و زمان‌های خالیِ پیشنهادی از `GET /api/v1/appointment-service-slots` می‌آید.
پزشک نمونه: `ab747d75-2114-42b8-9e6d-abdaa338edbe` (سرویس‌ها در بخش‌های «زیبایی»، «لیزر»).
## مشکل / هدف
۱. در حالت **سرویسی**، انتخاب سرویس هم باید مثل حالت اسلاتی «بخش → سرویس» شود (نه لیست تخت):
- انتخاب بخش (Select/Autocomplete) → نمایش فقط سرویس‌های همان بخش → افزودن به لیست انباشته → انباشت بین چند بخش → حذف هر سرویس.
- در chip سرویس انتخاب‌شده، **نام بخش کنار نام سرویس** نشان داده شود (مثل «زیبایی → بوتاکس»).
- محاسبهٔ مدت/اسلات سرویسی باید **حفظ** شود (`appointment-service-slots`).
۲. **زمان متوسط سرویس (duration) قابل ویرایش توسط منشی، فقط برای همان نوبت**:
- هر سرویس `duration_minutes` پیش‌فرض از تنظیمات سرویس دارد.
- نوبت‌دهی آنلاین (سایت عمومی، بیمار): غیرقابل تغییر.
- نوبت‌دهی پنل (منشی/کلینیک/پزشک): منشی بتواند مدت هر سرویس را **فقط برای این نوبت** ویرایش کند؛ مقدار پیش‌فرض سرویس در تنظیمات (`ServiceItem.durationMinutes`) **نباید** تغییر کند.
- کنار هر سرویس انتخاب‌شده مدتش نمایش داده شود و در پنل قابل ویرایش باشد. مجموع مدت (و در نتیجه اسلات‌های پیشنهادی + `slot_end` نهایی) باید بر اساس مقدارِ override محاسبه شود.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Appointment/Controller/AppointmentController.php` | `bookingServices` (خط ۲۰۲) — افزودن `service_section` به هر سرویس؛ `serviceSlots` (خط ۱۴۵) — پذیرش override مدت |
| `src/Appointment/Controller/MyAppointmentsController.php` | `createAppointment` (`POST /api/v1/my/appointment`) — پذیرش مدتِ override هنگام محاسبهٔ `slot_end` سرویسی |
| `src/Admin/Controller/AdminApiController.php` | `createAppointment` (`POST /api/v1/admin/appointment`) — همان منطق override |
| `assets/admin/hooks/useDoctorBookingServices.ts` | type `BookingService` + استخراج `section` |
| `assets/admin/components/appointments/ServiceSlotPicker.tsx` | بازطراحی UI انتخاب سرویس به «بخش → سرویس + مدتِ قابل‌ویرایش + chip» |
| `assets/admin/pages/AppointmentCreatePage.tsx` | اتصال payload (مدت override) در `create` mutation |
| `docs/api/appointment.md` | مستند تغییرات `appointment-booking-services`، `appointment-service-slots`، `my/appointment` |
| `tests/Appointment/*` | تست backend (section در پاسخ، override مدت در اسلات و ثبت) |
| `assets/admin/components/appointments/ServiceSlotPicker.test.tsx` + `assets/admin/pages/AppointmentCreatePage.test.tsx` | تست frontend |
## وضعیت فعلی
### backend: `bookingServices` — سرویس تخت، بدون بخش
```php
// src/Appointment/Controller/AppointmentController.php:213
$services = array_map(fn(\App\ClinicService\Entity\ServiceItem $i) => [
'uuid' => $i->getUuid(),
'name' => $i->getName(),
'duration_minutes' => $i->getDurationMinutes(),
'price_rials' => $i->getPriceRials(),
], $this->itemRepo->findBookableByEntity('doctor', $doctor->getId()));
```
`ServiceItem::getSection(): ServiceSection` موجود است (`getUuid()`, `getName()`).
### backend: `serviceSlots` — مدت کل فقط از duration پیش‌فرض
```php
// src/Appointment/Controller/AppointmentController.php:170
$totalMinutes = 0;
foreach ($uuids as $u) {
$item = $this->itemRepo->findByUuid($u);
// ... اعتبارسنجی bookable/duration ...
$totalMinutes += (int) $item->getDurationMinutes();
}
// ...
'total_duration_minutes' => $totalMinutes,
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes),
```
### backend: `my/appointment` — بازمحاسبهٔ slot_end از duration پیش‌فرض
```php
// src/Appointment/Controller/MyAppointmentsController.php (createAppointment)
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
if (!empty($serviceUuids) && !$isReserve) {
$totalMinutes = 0;
foreach ($serviceUuids as $u) {
$item = $this->itemRepo->findByUuid($u);
if ($computeDuration) {
// ... اعتبارسنجی ...
$totalMinutes += (int) $item->getDurationMinutes();
}
$serviceItems[] = $item;
}
if ($computeDuration) { $slotEnd = $slotStart + $totalMinutes * 60; }
}
```
### frontend: `BookingService` type — بدون section
```ts
// assets/admin/hooks/useDoctorBookingServices.ts
export interface BookingService {
uuid: string;
name: string;
duration_minutes: number | null;
price_rials: number;
}
```
### frontend: `ServiceSlotPicker` — لیست تخت با تیک، بدون بخش، مدت غیرقابل‌ویرایش
```tsx
// assets/admin/components/appointments/ServiceSlotPicker.tsx (خلاصه)
const [serviceUuids, setServiceUuids] = useState<string[]>([]);
// slotsQ: GET /api/v1/appointment-service-slots?doctor_uuid=..&date=..&service_item_uuids[]=..
// services.map(...) → دکمهٔ تیک‌دار؛ چیدنِ start_times؛ onSelect({serviceUuids, slot})
```
## وظایف
### ۱. backend — افزودن بخش به پاسخ `appointment-booking-services`
در `bookingServices`، هر سرویس `service_section` بگیرد:
```php
$services = array_map(function (\App\ClinicService\Entity\ServiceItem $i) {
$section = $i->getSection();
return [
'uuid' => $i->getUuid(),
'name' => $i->getName(),
'duration_minutes' => $i->getDurationMinutes(),
'price_rials' => $i->getPriceRials(),
'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()],
];
}, $this->itemRepo->findBookableByEntity('doctor', $doctor->getId()));
```
- سازگاری عقب‌رو: افزودنِ فیلد است، مصرف‌کنندهٔ سایت عمومی (`nobat724_front`) نمی‌شکند.
### ۲. backend — پذیرش override مدت در `serviceSlots`
`serviceSlots` باید علاوه بر مدت پیش‌فرض، یک override اختیاری بپذیرد تا اسلات‌ها بر اساس مدتِ ویرایش‌شدهٔ منشی چیده شوند. الگوی پیشنهادی: پارامتر `durations[<service_uuid>]=<minutes>` (map) یا `total_duration_minutes` مستقیم.
```php
// اگر durations[uuid] آمده و > 0 بود، به‌جای getDurationMinutes همان استفاده شود
$overrides = (array) $request->query->all('durations'); // uuid => minutes
// در حلقه:
$dur = isset($overrides[$u]) && (int)$overrides[$u] > 0
? (int) $overrides[$u]
: (int) $item->getDurationMinutes();
if ($dur <= 0) { /* 422 مدت تعریف نشده */ }
$totalMinutes += $dur;
```
- اعتبارسنجی: override باید عدد مثبت باشد؛ مقدار نامعتبر ⇒ `422`.
- **مقدار پیش‌فرض سرویس تغییر نکند** — override فقط در محاسبهٔ همین درخواست استفاده شود (هیچ `set`/`save` روی `ServiceItem`).
### ۳. backend — اعمال override مدت هنگام ثبت نوبت
در `MyAppointmentsController::createAppointment` و `AdminApiController::createAppointment`، وقتی `duration_from_services=true`، بازمحاسبهٔ `slot_end` باید مدتِ override را لحاظ کند تا با اسلاتی که منشی انتخاب کرده هم‌خوان بماند. یک فیلد جدید در payload، مثلاً `service_durations: { "<uuid>": <minutes> }`:
```php
$durations = (array) ($data['service_durations'] ?? []); // uuid => minutes
// در حلقهٔ محاسبهٔ مدت:
$dur = isset($durations[$u]) && (int)$durations[$u] > 0
? (int) $durations[$u]
: (int) $item->getDurationMinutes();
$totalMinutes += $dur;
```
- منطق پیوستِ چند سرویس (`addServiceItem`) و پرچم `duration_from_services` که قبلاً پیاده شده، حفظ شود.
- **مهم — سازگاری قیمت/گزارش**: بررسی شود آیا مدتِ override باید روی خودِ نوبت ذخیره شود (برای نمایش/گزارش بعدی). اگر بله، به Entity `Appointment` یک ستون/فیلد برای مدتِ مؤثر یا map مدت‌ها اضافه شود (⇒ **migration**). اگر ذخیره لازم نیست و فقط `slot_end` کافی است، ذخیرهٔ اضافه لازم نیست — این تصمیم را در زمان اجرا بر اساس نیاز گزارش‌گیری مشخص کن و در پرامپت‌کننده تأیید بگیر.
### ۴. frontend — type و hook
`BookingService` را با بخش گسترش بده:
```ts
export interface BookingService {
uuid: string;
name: string;
duration_minutes: number | null;
price_rials: number;
service_section: { uuid: string; name: string };
}
```
### ۵. frontend — بازطراحی `ServiceSlotPicker` به «بخش → سرویس»
منطق slot/مدت را نگه دار، فقط UIِ انتخاب سرویس را عوض کن — از همان الگوی حالت اسلاتیِ `AppointmentCreatePage.tsx` تقلید کن:
- گروه‌بندی `services` بر اساس `service_section.uuid` (client-side؛ نیازی به endpoint جدید نیست چون همهٔ سرویس‌های bookable یکجا آمده‌اند).
- Select/Autocomplete بخش (`SearchableSelect`) → نمایش سرویس‌های همان بخش به‌صورت چک‌باکس → افزودن به `selected: { uuid; name; section: string; duration: number }[]` (انباشته، بین چند بخش).
- chip قابل حذف با نمایش «بخش → سرویس» و مدت؛ در حالت پنل (منشی) مدت با `DigitInput`/عدد قابل ویرایش.
- مجموع مدت از `selected` (با override) محاسبه و در query `appointment-service-slots` به‌صورت `durations[uuid]=minutes` ارسال شود تا `start_times` هماهنگ بماند.
- `onSelect` باید `serviceUuids` + `durations` map + `slot` را بالا بفرستد.
### ۶. frontend — payload در `AppointmentCreatePage`
در `create` mutation، حالت سرویسی علاوه بر `service_item_uuids` و `duration_from_services:true`، در صورت override منشی `service_durations: { uuid: minutes }` هم بفرستد.
- تشخیص «منشی/پنل بودن» برای فعال‌کردن ویرایش مدت: از نقش کاربر (`useAuthStore().primaryRole`) — همهٔ نقش‌های پنل (admin/clinic/doctor/secretary) مجازند؛ این صفحه اصلاً پنل است، پس ویرایش مدت همیشه در این صفحه فعال است (محدودیت «غیرقابل‌تغییر» فقط مربوط به سایت عمومی `nobat724_front` است، نه این صفحه).
## نکات مهم
- **عدم تغییر پیش‌فرض سرویس**: override مدت هرگز نباید `ServiceItem.durationMinutes` را در دیتابیس تغییر دهد — نه در `serviceSlots`، نه در ثبت نوبت. فقط در محاسبهٔ همان درخواست/نوبت.
- **حفظ منطق موجود**: پرچم `duration_from_services`, تابع `addServiceItem` (چند سرویس)، و جریان اسلاتیِ فعلی نباید بشکنند. حالت اسلاتی دست‌نخورده بماند.
- **سازگاری مصرف‌کننده‌ها**: `appointment-booking-services` و `appointment-service-slots` توسط `nobat724_front` هم مصرف می‌شوند (`services/response.js`). افزودن فیلد (`service_section`) و پارامتر اختیاری (`durations`) عقب‌رو-سازگار است؛ سایت عمومی نباید override را فعال کند (بیمار مجاز به تغییر مدت نیست).
- **پاسخ‌ها**: با `$this->success(...)` / `$this->error(ErrorCodes::..., msg, status, field)` مطابق `BaseController`.
- **الگوی frontend**: `SearchableSelect` (نه `<select>` خام)، `TanStack Query` برای دیتا، state لوکال React برای انتخاب‌ها، `DigitInput` برای ورودی عددی مدت. chipها با توکن‌های `--primary-soft`/`--primary` مطابق UIِ فعلی.
- **edge caseها**: سرویس بدون `duration_minutes` (⇒ 422 یا فیلترشدن)؛ بخشِ بدون سرویس bookable؛ override صفر/منفی/غیرعدد (رد شود، به پیش‌فرض برگردد)؛ حذف همهٔ سرویس‌ها (اسلات خالی، دکمهٔ ثبت غیرفعال)؛ انتخاب سرویس از دو بخش با مدت‌های override متفاوت (مجموع درست).
- **تست (اجباری، موفق + خطا + مرزی)**:
- backend: `appointment-booking-services` فیلد `service_section` را برمی‌گرداند؛ `appointment-service-slots` با `durations[uuid]` مدت کل و `start_times` را بر اساس override می‌دهد؛ ثبت نوبت با `service_durations` مقدار `slot_end` را بر اساس override می‌سازد و پیش‌فرض سرویس در DB تغییر نمی‌کند.
- frontend: انتخاب بخش → نمایش سرویس‌های همان بخش؛ انباشت بین دو بخش؛ chip «بخش → سرویس»؛ ویرایش مدت یک سرویس و بازتاب در payload؛ حذف سرویس.
- **مستندات**: `docs/api/appointment.md` برای هر سه endpoint به‌روز شود (فیلد `service_section`، پارامتر `durations`، فیلد `service_durations` در body ثبت).
- **بعد از اتمام**: `npx tsc --noEmit`، `ddev exec bin/console lint:container`, `ddev exec bin/phpunit tests/Appointment`, `npx vitest run` تست‌های مربوطه، `ddev exec yarn dev` — همه سبز.