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
@@ -167,6 +167,9 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
}
// مدتِ override منشی (فقط برای همین محاسبه؛ پیش‌فرض سرویس تغییر نمی‌کند). durations[uuid]=minutes
$overrides = (array) $request->query->all('durations');
$totalMinutes = 0;
foreach ($uuids as $u) {
$item = $this->itemRepo->findByUuid($u);
@@ -176,10 +179,13 @@ class AppointmentController extends BaseController
if (!$item->isBookable()) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids');
}
if (($item->getDurationMinutes() ?? 0) <= 0) {
$duration = isset($overrides[$u]) && (int) $overrides[$u] > 0
? (int) $overrides[$u]
: (int) ($item->getDurationMinutes() ?? 0);
if ($duration <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
}
$totalMinutes += (int) $item->getDurationMinutes();
$totalMinutes += $duration;
}
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
@@ -210,12 +216,16 @@ class AppointmentController extends BaseController
$schedule = $this->scheduleRepo->findByDoctor($doctor);
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
$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()));
$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()));
return $this->success([
'doctor_uuid' => $doctorUuid,