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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user