my/clinic-doctors now reports has_schedule per doctor, and the appointments page builds tabs from it. A doctor with no working days had a tab that could only ever show an empty timeline. The flag is resolved with one query for the whole list rather than one per doctor. Clinic owners now read this authenticated endpoint too instead of the public clinic doctor-list, which is where the flag lives; admin keeps the public list and, with no flag present, hides nobody. Also repairs fallout from making the resource supervisor mandatory: four test classes build resources through their own helpers and were failing with 422. The supervisorFor helper moved to ApiTestCase so all domains share one, rather than copying it per suite. Full backend suite is green again (1306 tests) — the previous commit only ran tests/Resource and missed this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
386 lines
18 KiB
PHP
386 lines
18 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(),
|
||
'supervisor_doctor_uuid' => $this->supervisorFor($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(),
|
||
'supervisor_doctor_uuid' => $this->supervisorFor($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(),
|
||
'supervisor_doctor_uuid' => $this->supervisorFor($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(),
|
||
'supervisor_doctor_uuid' => $this->supervisorFor($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);
|
||
}
|
||
}
|