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> $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 $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); } }