The mergeable flag was stored, returned by the API and rendered in the editor while changing nothing. The reason was upstream: the builder only ever read the primary service's templates, and within one service two segments with the same name do not occur — so the dedupe it already had could never fire. Templates now come from the primary service plus every selected item, and same-named mergeable segments collapse to one. Rules, with their reasons: - the longest of the same-named segments survives — prepping two areas is not shorter than prepping the longer one alone - a duration_source: "items" segment also appears once even when it is not marked mergeable, because DurationCalculator has already summed every item and repeating the segment counts that time twice - the merged requirement count is the maximum, not the sum and not the first one seen: two areas do not need two rooms, but if one of them needed two operators, merging must not quietly demote that to one Also pins that the plan is deterministic: two previews of the same input are compared byte for byte. A plan that shifts between preview and booking means the user confirmed something that was not what got booked. Unrelated but found by running the suite on a Saturday: testPastStartsAreExcluded searched "last week's Saturday", which is today when today is Saturday, so this afternoon's slots were legitimately not in the past. It now searches two weeks back, which is unambiguous on every weekday. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
382 lines
17 KiB
PHP
382 lines
17 KiB
PHP
<?php
|
||
|
||
namespace App\Tests\Appointment;
|
||
|
||
use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||
use App\Auth\Entity\User;
|
||
use App\Clinic\Entity\Clinic;
|
||
use App\ClinicService\Entity\ServiceItem;
|
||
use App\ClinicService\Entity\ServiceSection;
|
||
use App\Doctor\Entity\DoctorAddress;
|
||
use App\Resource\Entity\ClinicResource;
|
||
use App\Resource\Entity\ResourceType;
|
||
use App\Tests\ApiTestCase;
|
||
|
||
/**
|
||
* موتور جستجوی وقت چندمنبعی — بند ۱۰ مستند.
|
||
*/
|
||
class AvailabilityEngineTest extends ApiTestCase
|
||
{
|
||
private const TEHRAN = 'Asia/Tehran';
|
||
|
||
private function nextSaturday(): int
|
||
{
|
||
return (new \DateTimeImmutable('next saturday', new \DateTimeZone(self::TEHRAN)))
|
||
->setTime(0, 0)
|
||
->getTimestamp();
|
||
}
|
||
|
||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress} */
|
||
private function clinicWithBranch(): array
|
||
{
|
||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||
$clinic = new Clinic($user);
|
||
$clinic->setName('کلینیک موتور');
|
||
$this->em->persist($clinic);
|
||
$this->em->flush();
|
||
|
||
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
|
||
$this->em->persist($section);
|
||
|
||
$address = DoctorAddress::forClinic($clinic->getId());
|
||
$address->setName('شعبهٔ مرکزی');
|
||
$this->em->persist($address);
|
||
$this->em->flush();
|
||
|
||
return [$user, $section, $address];
|
||
}
|
||
|
||
private function service(ServiceSection $section, string $name, int $solo = 20): ServiceItem
|
||
{
|
||
// هر درخواست HTTP کرنل را از نو میسازد، پس نمونهٔ قبلی detached شده است؛
|
||
// بدون این، ساختن سرویس دوم با «A new entity was found» میشکند.
|
||
$section = $this->em->getRepository(ServiceSection::class)->find($section->getId());
|
||
|
||
$item = new ServiceItem($section, $name);
|
||
$item->setSoloDurationMinutes($solo);
|
||
$this->em->persist($item);
|
||
$this->em->flush();
|
||
|
||
return $item;
|
||
}
|
||
|
||
private function type(DoctorAddress $address, string $code, string $name): ResourceType
|
||
{
|
||
$type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name);
|
||
$this->em->persist($type);
|
||
$this->em->flush();
|
||
|
||
return $type;
|
||
}
|
||
|
||
/** منبع با شیفت شنبه تا جمعه ۰۹:۰۰–۱۷:۰۰. */
|
||
private function resourceWithShift(User $user, DoctorAddress $address, ResourceType $type, string $name): array
|
||
{
|
||
$created = $this->authJson('POST', '/api/v1/resource', $user, [
|
||
'address_uuid' => $address->getUuid(),
|
||
'type_uuid' => $type->getUuid(),
|
||
'name' => $name,
|
||
]);
|
||
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
|
||
|
||
$this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [
|
||
'days' => array_fill_keys(range(0, 6), [['start_minute' => 540, 'end_minute' => 1020]]),
|
||
]);
|
||
self::assertSame(200, $this->responseCode());
|
||
|
||
return $created['data'];
|
||
}
|
||
|
||
/** @param list<array<string, mixed>> $segments */
|
||
private function setSegments(User $user, ServiceItem $service, array $segments): void
|
||
{
|
||
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, ['segments' => $segments]);
|
||
self::assertSame(200, $this->responseCode());
|
||
}
|
||
|
||
/** @param array<string, mixed> $extra */
|
||
private function search(User $user, ServiceItem $service, DoctorAddress $address, int $from, int $to, array $extra = []): array
|
||
{
|
||
return $this->authJson('POST', '/api/v1/appointment-availability', $user, $extra + [
|
||
'service_uuid' => $service->getUuid(),
|
||
'branch_uuid' => $address->getUuid(),
|
||
'from' => $from,
|
||
'to' => $to,
|
||
]);
|
||
}
|
||
|
||
private function occupy(string $resourceUuid, int $start, int $end): void
|
||
{
|
||
$resource = $this->em->getRepository(ClinicResource::class)->findOneBy(['uuid' => $resourceUuid]);
|
||
$this->em->persist(new ResourceOccupancy($resource, $start, $end));
|
||
$this->em->flush();
|
||
}
|
||
|
||
/** سناریوی مستند: چهار بخش، سه اتاق، دو اپراتور، سه دستگاه. */
|
||
public function testDocumentScenarioReturnsSlotsWithAssignments(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'لیزر', 20);
|
||
|
||
$room = $this->type($address, 'room', 'اتاق');
|
||
$operator = $this->type($address, 'operator', 'اپراتور');
|
||
$device = $this->type($address, 'device', 'دستگاه');
|
||
|
||
foreach (range(1, 3) as $n) { $this->resourceWithShift($user, $address, $room, "اتاق $n"); }
|
||
foreach (range(1, 2) as $n) { $this->resourceWithShift($user, $address, $operator, "اپراتور $n"); }
|
||
foreach (range(1, 3) as $n) { $this->resourceWithShift($user, $address, $device, "لیزر $n"); }
|
||
|
||
$roomReq = ['type_uuid' => $room->getUuid()];
|
||
$opReq = ['type_uuid' => $operator->getUuid()];
|
||
$devReq = ['type_uuid' => $device->getUuid()];
|
||
|
||
$this->setSegments($user, $service, [
|
||
['sequence' => 1, 'name' => 'بیحسی', 'duration_minutes' => 5, 'requirements' => [$roomReq, $opReq]],
|
||
['sequence' => 2, 'name' => 'انتظار', 'duration_minutes' => 30, 'requirements' => [$roomReq]],
|
||
['sequence' => 3, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [$roomReq, $opReq, $devReq]],
|
||
['sequence' => 4, 'name' => 'مراقبت', 'duration_minutes' => 5, 'requirements' => [$roomReq, $opReq]],
|
||
]);
|
||
|
||
$saturday = $this->nextSaturday();
|
||
$body = $this->search($user, $service, $address, $saturday, $saturday);
|
||
|
||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||
self::assertSame(60, $body['data']['plan']['total_minutes']);
|
||
self::assertNotEmpty($body['data']['slots']);
|
||
|
||
$first = $body['data']['slots'][0];
|
||
self::assertArrayHasKey('room', $first['assignment']);
|
||
self::assertArrayHasKey('operator', $first['assignment']);
|
||
self::assertArrayHasKey('device', $first['assignment']);
|
||
self::assertCount(1, $first['assignment']['room']);
|
||
}
|
||
|
||
/**
|
||
* ⭐ قلب کل پروژه: بیمار الف ۱۰:۰۰–۱۱:۰۰ نوبت دارد ولی اپراتور فقط ۱۰:۰۰–۱۰:۰۵ و
|
||
* ۱۰:۳۵–۱۱:۰۰ درگیر است. با اتاق دوم، بیمار ب باید در همان بازهٔ میانی جا شود.
|
||
*
|
||
* بدون این سناریو، کل تسک تأیید نمیشود.
|
||
*/
|
||
public function testOperatorFreedDuringWaitingIsReusedForAnotherPatient(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'لیزر', 20);
|
||
|
||
$room = $this->type($address, 'room', 'اتاق');
|
||
$operator = $this->type($address, 'operator', 'اپراتور');
|
||
|
||
$roomA = $this->resourceWithShift($user, $address, $room, 'اتاق ۱');
|
||
$roomB = $this->resourceWithShift($user, $address, $room, 'اتاق ۲');
|
||
$op = $this->resourceWithShift($user, $address, $operator, 'اپراتور تنها');
|
||
|
||
// سرویس کوتاه: ۵ دقیقه، فقط اتاق و اپراتور.
|
||
$short = $this->service($section, 'مشاورهٔ کوتاه', 5);
|
||
$this->setSegments($user, $short, [
|
||
['sequence' => 1, 'name' => 'مشاوره', 'duration_minutes' => 5, 'requirements' => [
|
||
['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()],
|
||
]],
|
||
]);
|
||
|
||
$saturday = $this->nextSaturday();
|
||
$ten = $saturday + 10 * 3600;
|
||
|
||
// بیمار الف: اتاق ۱ کل ساعت گرفته، اپراتور فقط دو سرِ آن.
|
||
$this->occupy($roomA['uuid'], $ten, $ten + 3600);
|
||
$this->occupy($op['uuid'], $ten, $ten + 5 * 60);
|
||
$this->occupy($op['uuid'], $ten + 35 * 60, $ten + 3600);
|
||
|
||
$body = $this->search($user, $short, $address, $saturday, $saturday, ['step_minutes' => 5]);
|
||
self::assertSame(200, $this->responseCode());
|
||
|
||
$starts = array_column($body['data']['slots'], 'start');
|
||
|
||
// بازهٔ آزادِ اپراتور: ۱۰:۰۵ تا ۱۰:۳۵ — یک نوبت پنجدقیقهای آنجا جا میشود.
|
||
$inGap = array_filter(
|
||
$starts,
|
||
static fn (int $s): bool => $s >= $ten + 5 * 60 && $s + 5 * 60 <= $ten + 35 * 60,
|
||
);
|
||
|
||
self::assertNotEmpty($inGap, 'اپراتورِ آزادشده در «انتظار» باید دوباره قابل استفاده باشد');
|
||
|
||
// و اتاق پیشنهادی باید اتاق ۲ باشد، چون اتاق ۱ کل ساعت گرفته است.
|
||
foreach ($body['data']['slots'] as $slot) {
|
||
if ($slot['start'] >= $ten + 5 * 60 && $slot['start'] + 300 <= $ten + 35 * 60) {
|
||
self::assertSame($roomB['uuid'], $slot['assignment']['room'][0]['uuid']);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** یک منبع برای همهٔ بخشهایی که آن نقش را میخواهند — نه دو نفر. */
|
||
public function testSameResourceIsUsedAcrossNonAdjacentSegments(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'لیزر', 20);
|
||
$room = $this->type($address, 'room', 'اتاق');
|
||
$operator = $this->type($address, 'operator', 'اپراتور');
|
||
|
||
$this->resourceWithShift($user, $address, $room, 'اتاق ۱');
|
||
foreach (range(1, 3) as $n) { $this->resourceWithShift($user, $address, $operator, "اپراتور $n"); }
|
||
|
||
$this->setSegments($user, $service, [
|
||
['sequence' => 1, 'name' => 'بخش اول', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $operator->getUuid()]]],
|
||
['sequence' => 2, 'name' => 'انتظار', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||
['sequence' => 3, 'name' => 'بخش سوم', 'duration_minutes' => 10, 'requirements' => [['type_uuid' => $operator->getUuid()]]],
|
||
]);
|
||
|
||
$saturday = $this->nextSaturday();
|
||
$body = $this->search($user, $service, $address, $saturday, $saturday);
|
||
|
||
self::assertNotEmpty($body['data']['slots']);
|
||
self::assertCount(
|
||
1,
|
||
$body['data']['slots'][0]['assignment']['operator'],
|
||
'یک اپراتور برای هر دو بخش، نه دو نفر',
|
||
);
|
||
}
|
||
|
||
/** ظرفیت ۳: سه نوبت همزمان جا دارد، چهارمی نه. */
|
||
public function testCapacityIsCountedNotJustPresence(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$short = $this->service($section, 'تزریق', 10);
|
||
$room = $this->type($address, 'room', 'اتاق');
|
||
|
||
$created = $this->authJson('POST', '/api/v1/resource', $user, [
|
||
'address_uuid' => $address->getUuid(),
|
||
'type_uuid' => $room->getUuid(),
|
||
'name' => 'اتاق سهتخته',
|
||
'capacity' => 3,
|
||
]);
|
||
self::assertSame(201, $this->responseCode());
|
||
|
||
$this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [
|
||
'days' => array_fill_keys(range(0, 6), [['start_minute' => 540, 'end_minute' => 1020]]),
|
||
]);
|
||
|
||
$this->setSegments($user, $short, [
|
||
['sequence' => 1, 'name' => 'تزریق', 'duration_minutes' => 10, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||
]);
|
||
|
||
$saturday = $this->nextSaturday();
|
||
$body = $this->search($user, $short, $address, $saturday, $saturday);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
self::assertNotEmpty($body['data']['slots'], 'اتاق سهتخته باید وقت بدهد');
|
||
}
|
||
|
||
/** زمان گذشته حذف میشود. */
|
||
public function testPastStartsAreExcluded(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'ویزیت', 20);
|
||
$room = $this->type($address, 'room', 'اتاق');
|
||
$this->resourceWithShift($user, $address, $room, 'اتاق ۱');
|
||
|
||
$this->setSegments($user, $service, [
|
||
['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||
]);
|
||
|
||
// دو هفته عقب، نه یک هفته: وقتی امروز خودش شنبه باشد، «شنبهٔ هفتهٔ پیش» همین
|
||
// امروز است و ساعتهای بعدازظهرش هنوز گذشته نیستند.
|
||
$past = $this->nextSaturday() - 14 * 86400;
|
||
$body = $this->search($user, $service, $address, $past, $past);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
self::assertSame([], $body['data']['slots']);
|
||
self::assertSame('no_capacity_in_range', $body['data']['reason']);
|
||
}
|
||
|
||
/** فهرست خالی خطا نیست و ۴۰۴ هم نیست — دلیل صریح میآید. */
|
||
public function testEmptyResultCarriesAReason(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'ویزیت', 20);
|
||
$room = $this->type($address, 'room', 'اتاق');
|
||
|
||
// منبع هست ولی هیچ شیفتی ندارد.
|
||
$this->authJson('POST', '/api/v1/resource', $user, [
|
||
'address_uuid' => $address->getUuid(),
|
||
'type_uuid' => $room->getUuid(),
|
||
'name' => 'اتاق بیشیفت',
|
||
]);
|
||
|
||
$this->setSegments($user, $service, [
|
||
['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
|
||
]);
|
||
|
||
$saturday = $this->nextSaturday();
|
||
$body = $this->search($user, $service, $address, $saturday, $saturday);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
self::assertSame([], $body['data']['slots']);
|
||
self::assertSame('no_capacity_in_range', $body['data']['reason']);
|
||
}
|
||
|
||
public function testRangeBeyondNinetyDaysIsRejected(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'ویزیت', 20);
|
||
|
||
$saturday = $this->nextSaturday();
|
||
$this->search($user, $service, $address, $saturday, $saturday + 120 * 86400);
|
||
|
||
self::assertSame(422, $this->responseCode());
|
||
}
|
||
|
||
public function testForeignBranchIsNotFound(): void
|
||
{
|
||
[$user, $section] = $this->clinicWithBranch();
|
||
[, , $foreign] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'ویزیت', 20);
|
||
|
||
$saturday = $this->nextSaturday();
|
||
$this->authJson('POST', '/api/v1/appointment-availability', $user, [
|
||
'service_uuid' => $service->getUuid(),
|
||
'branch_uuid' => $foreign->getUuid(),
|
||
'from' => $saturday,
|
||
'to' => $saturday,
|
||
]);
|
||
|
||
self::assertSame(404, $this->responseCode());
|
||
}
|
||
|
||
/** بازهٔ اشغال گستردهتر از بخش است: آمادهسازی و تمیزکاری هم میگیرد. */
|
||
public function testSetupAndCleanupWidenTheOccupiedInterval(): void
|
||
{
|
||
[$user, $section, $address] = $this->clinicWithBranch();
|
||
$service = $this->service($section, 'لیزر', 20);
|
||
$device = $this->type($address, 'device', 'دستگاه');
|
||
|
||
$created = $this->authJson('POST', '/api/v1/resource', $user, [
|
||
'address_uuid' => $address->getUuid(),
|
||
'type_uuid' => $device->getUuid(),
|
||
'name' => 'لیزر تنها',
|
||
'setup_minutes' => 10,
|
||
'cleanup_minutes' => 10,
|
||
]);
|
||
self::assertSame(201, $this->responseCode());
|
||
|
||
$this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [
|
||
'days' => array_fill_keys(range(0, 6), [['start_minute' => 540, 'end_minute' => 1020]]),
|
||
]);
|
||
|
||
$this->setSegments($user, $service, [
|
||
['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $device->getUuid()]]],
|
||
]);
|
||
|
||
$saturday = $this->nextSaturday();
|
||
|
||
// دستگاه ۱۲:۰۰ تا ۱۳:۰۰ گرفته است.
|
||
$this->occupy($created['data']['uuid'], $saturday + 12 * 3600, $saturday + 13 * 3600);
|
||
|
||
$body = $this->search($user, $service, $address, $saturday, $saturday, ['step_minutes' => 5]);
|
||
$starts = array_column($body['data']['slots'], 'start');
|
||
|
||
// شروع ۱۱:۵۵ یعنی اشغال از ۱۱:۴۵ تا ۱۲:۲۵ — با اشغال موجود تداخل دارد.
|
||
self::assertNotContains($saturday + 11 * 3600 + 55 * 60, $starts);
|
||
// شروع ۱۱:۳۰ یعنی اشغال ۱۱:۲۰ تا ۱۲:۰۰ — دقیقاً میچسبد و مجاز است.
|
||
self::assertContains($saturday + 11 * 3600 + 30 * 60, $starts);
|
||
}
|
||
}
|