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>
193 lines
15 KiB
Markdown
193 lines
15 KiB
Markdown
# انتخاب سرویس بر اساس بخش + ویرایش مدت توسط منشی — حالت نوبتدهی سرویسی
|
||
|
||
## زمینه
|
||
|
||
صفحهٔ ایجاد نوبت پنل ادمین (`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` — همه سبز.
|