feat: support multiple services per appointment (checkbox selection)
Appointments could only reference a single service (ManyToOne). Add an appointment_service_items join table (ManyToMany) so an appointment can carry several services; the first stays the primary service_item for backward compatibility, and toArray now also returns service_items[]. Both create endpoints (my/appointment, admin/appointment) accept service_item_uuids[] and attach all of them. A new duration_from_services flag gates the slot_end recompute: service-booking mode sends it true (slot_end = start + Σ durations); slot mode omits it so the manual end time is preserved. The admin endpoint previously ignored services entirely. Frontend: in slot mode the single service dropdown becomes a checkbox list filtered by the selected section (multi-select); service mode sends the duration flag. Migration + backend/entity tests + docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* چند سرویس روی یک نوبت: در حالت اسلاتی سرویسها فقط پیوست میشوند و ساعت پایانِ
|
||||
* دستی حفظ میماند؛ در حالت سرویسی (duration_from_services) slot_end از مجموع مدت
|
||||
* سرویسها بازمحاسبه میشود.
|
||||
*/
|
||||
class AppointmentMultiServiceTest extends ApiTestCase
|
||||
{
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function serviceItem(Doctor $doctor, string $name, int $minutes): ServiceItem
|
||||
{
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
|
||||
$this->em->persist($section);
|
||||
$item = new ServiceItem($section, $name, 500_000);
|
||||
$item->setDurationMinutes($minutes)->setBookable(true);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function nationalCode(): string
|
||||
{
|
||||
return '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function body(Doctor $doctor, int $start, int $end, array $extra): array
|
||||
{
|
||||
return array_merge([
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $end,
|
||||
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'patient_name' => 'بیمار تست',
|
||||
'patient_national_code' => $this->nationalCode(),
|
||||
], $extra);
|
||||
}
|
||||
|
||||
private function reload(string $uuid): Appointment
|
||||
{
|
||||
$this->em->clear();
|
||||
return $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function testSlotModeAttachesMultipleServicesAndKeepsManualEnd(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$a = $this->serviceItem($doctor, 'تزریق ژل', 30);
|
||||
$b = $this->serviceItem($doctor, 'کندلا', 20);
|
||||
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
$end = $start + 3_600; // ساعت پایانِ دستی: ۶۰ دقیقه
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $end, [
|
||||
'service_item_uuids' => [$a->getUuid(), $b->getUuid()],
|
||||
]));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$appt = $this->reload($res['data']['uuid']);
|
||||
self::assertCount(2, $appt->getServiceItems());
|
||||
// ساعت پایان دستنخورده (بدون بازمحاسبه از مدت سرویسها)
|
||||
self::assertSame($end, $appt->getSlotEnd());
|
||||
// سرویسِ اصلی = اولین سرویس
|
||||
self::assertSame($a->getUuid(), $appt->getServiceItem()?->getUuid());
|
||||
}
|
||||
|
||||
public function testServiceModeRecomputesEndFromDurations(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$a = $this->serviceItem($doctor, 'تزریق ژل', 30);
|
||||
$b = $this->serviceItem($doctor, 'کندلا', 20);
|
||||
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $start + 60, [
|
||||
'service_item_uuids' => [$a->getUuid(), $b->getUuid()],
|
||||
'duration_from_services' => true,
|
||||
]));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$appt = $this->reload($res['data']['uuid']);
|
||||
self::assertCount(2, $appt->getServiceItems());
|
||||
// مجموع مدت ۳۰+۲۰=۵۰ دقیقه → slot_end بازمحاسبهشده
|
||||
self::assertSame($start + 50 * 60, $appt->getSlotEnd());
|
||||
}
|
||||
|
||||
public function testUnknownServiceUuidIsRejected(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $start + 1_800, [
|
||||
'service_item_uuids' => ['no-such-uuid'],
|
||||
]));
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user