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,122 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* حالت نوبت‌دهی سرویسی: پاسخ booking-services باید بخش هر سرویس را بدهد؛ اسلات‌ها و
* ثبت نوبت باید مدتِ override منشی را (فقط برای همان نوبت) لحاظ کنند بدون تغییر پیش‌فرضِ سرویس.
*/
class ServiceModeSectionDurationTest extends ApiTestCase
{
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:string} */
private function serviceDoctor(int $serviceMinutes = 30): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر سرویس');
$this->em->persist($doctor);
$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' => '19:00',
'duration_per_patient' => 20, 'location_id' => 1,
]]],
]);
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => 0]);
$this->em->persist($schedule);
$this->em->flush();
return [$owner, $doctor, $date];
}
private function service(Doctor $doctor, string $name, int $minutes): ServiceItem
{
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
$this->em->persist($section);
$item = new ServiceItem($section, $name, 0);
$item->setDurationMinutes($minutes)->setBookable(true);
$this->em->persist($item);
$this->em->flush();
return $item;
}
public function testBookingServicesReturnsSection(): void
{
[$owner, $doctor] = $this->serviceDoctor();
$svc = $this->service($doctor, 'بوتاکس', 30);
$res = $this->authJson('GET', '/api/v1/appointment-booking-services/' . $doctor->getUuid(), $owner);
self::assertSame(200, $this->responseCode());
$row = $res['data']['services'][0];
self::assertSame($svc->getUuid(), $row['uuid']);
self::assertArrayHasKey('service_section', $row);
self::assertSame($svc->getSection()->getUuid(), $row['service_section']['uuid']);
self::assertSame($svc->getSection()->getName(), $row['service_section']['name']);
}
public function testServiceSlotsHonorsDurationOverride(): void
{
[$owner, $doctor, $date] = $this->serviceDoctor();
$svc = $this->service($doctor, 'فیلر', 30);
// بدون override: مدت کل = ۳۰
$base = $this->authJson('GET', sprintf(
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
$doctor->getUuid(), $date, $svc->getUuid()
), $owner);
self::assertSame(30, $base['data']['total_duration_minutes']);
// با override = ۹۰
$over = $this->authJson('GET', sprintf(
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s&durations[%s]=90',
$doctor->getUuid(), $date, $svc->getUuid(), $svc->getUuid()
), $owner);
self::assertSame(90, $over['data']['total_duration_minutes']);
// پیش‌فرضِ سرویس در DB تغییر نکرده
$this->em->clear();
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]);
self::assertSame(30, $reloaded->getDurationMinutes());
}
public function testCreateAppliesDurationOverrideToSlotEnd(): void
{
[$owner, $doctor] = $this->serviceDoctor();
$svc = $this->service($doctor, 'لیزر', 30);
$start = time() + 86_400 + random_int(0, 3_600) * 100;
$nc = '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'slot_start' => $start,
'slot_end' => $start + 60, // نادیده گرفته می‌شود (بازمحاسبه)
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
'patient_name' => 'بیمار تست',
'patient_national_code' => $nc,
'service_item_uuids' => [$svc->getUuid()],
'duration_from_services' => true,
'service_durations' => [$svc->getUuid() => 75],
]);
self::assertSame(201, $this->responseCode());
$this->em->clear();
$appt = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]);
// slot_end = start + 75 دقیقه (override)، نه ۳۰ پیش‌فرض
self::assertSame($start + 75 * 60, $appt->getSlotEnd());
// پیش‌فرضِ سرویس دست‌نخورده
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]);
self::assertSame(30, $reloaded->getDurationMinutes());
}
}