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, ?int $additional = null): ServiceItem { $item = new ServiceItem($section, $name); $item->setSoloDurationMinutes($solo); $item->setAdditionalDurationMinutes($additional); $this->em->persist($item); $this->em->flush(); return $item; } private function resourceType(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; } /** @param array $extra */ private function resource(User $user, DoctorAddress $address, ResourceType $type, string $name, array $extra = []): array { $body = $this->authJson('POST', '/api/v1/resource', $user, $extra + [ 'address_uuid' => $address->getUuid(), 'type_uuid' => $type->getUuid(), 'name' => $name, ]); self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); return $body['data']; } /** @param list> $segments */ private function setSegments(User $user, ServiceItem $service, array $segments): array { $body = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [ 'segments' => $segments, ]); return $body; } /** @param array $extra */ private function preview(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array { return $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, $extra + [ 'service_uuid' => $service->getUuid(), 'branch_uuid' => $address->getUuid(), ]); } /** مثال مرجع مستند، دقیقاً با همان آفست‌ها. */ public function testFourSegmentLaserPlan(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20); $room = $this->resourceType($address, 'room', 'اتاق'); $operator = $this->resourceType($address, 'operator', 'اپراتور'); $device = $this->resourceType($address, 'device', 'دستگاه'); $this->resource($user, $address, $room, 'اتاق ۱'); $this->resource($user, $address, $operator, 'اپراتور ۱'); $this->resource($user, $address, $device, 'لیزر ۱'); $roomReq = ['type_uuid' => $room->getUuid()]; $operatorReq = ['type_uuid' => $operator->getUuid()]; $deviceReq = ['type_uuid' => $device->getUuid()]; $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'بی‌حسی موضعی', 'duration_minutes' => 5, 'requirements' => [$roomReq, $operatorReq]], ['sequence' => 2, 'name' => 'انتظار اثر کرم', 'duration_minutes' => 30, 'requirements' => [$roomReq]], ['sequence' => 3, 'name' => 'خود لیزر', 'duration_source' => 'items', 'requirements' => [$roomReq, $operatorReq, $deviceReq]], ['sequence' => 4, 'name' => 'مراقبت بعد', 'duration_minutes' => 5, 'requirements' => [$roomReq, $operatorReq]], ]); self::assertSame(200, $this->responseCode()); $plan = $this->preview($user, $service, $address); self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE)); $data = $plan['data']; self::assertSame(60, $data['total_minutes']); self::assertCount(4, $data['segments']); self::assertSame([0, 5, 35, 55], array_column($data['segments'], 'offset_minutes')); self::assertSame([5, 30, 20, 5], array_column($data['segments'], 'duration_minutes')); // نکتهٔ اصلی بند ۷: اپراتور در بخش انتظار **نیست**. $waitRoles = array_column($data['segments'][1]['requirements'], 'role'); self::assertSame(['room'], $waitRoles, 'اپراتور در انتظار آزاد است'); $laserRoles = array_column($data['segments'][2]['requirements'], 'role'); sort($laserRoles); self::assertSame(['device', 'operator', 'room'], $laserRoles); } /** بخشی که مدتش از آیتم‌ها می‌آید با دو ناحیه طولانی‌تر می‌شود. */ public function testItemDrivenSegmentUsesTheDurationCalculator(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 0); $face = $this->service($section, 'صورت', 15, 8); $bikini = $this->service($section, 'بیکینی', 12, 8); $room = $this->resourceType($address, 'room', 'اتاق'); $this->resource($user, $address, $room, 'اتاق ۱'); $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], ['sequence' => 2, 'name' => 'خود لیزر', 'duration_source' => 'items', 'requirements' => [['type_uuid' => $room->getUuid()]]], ]); $plan = $this->preview($user, $service, $address, [ 'item_uuids' => [$face->getUuid(), $bikini->getUuid()], ]); self::assertSame(200, $this->responseCode()); // ۵ آماده‌سازی + ۲۳ لیزر (۱۵ + ۸، نه ۲۷) = ۲۸ self::assertSame(28, $plan['data']['total_minutes']); self::assertSame(23, $plan['data']['segments'][1]['duration_minutes']); } /** سرویس بدون الگوی بخش، همان رفتار امروز را می‌گیرد: یک بخش پیوسته با پزشک. */ public function testServiceWithoutTemplatesFallsBackToASingleSegment(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'ویزیت', 20); $doctorType = $this->resourceType($address, ResourceType::CODE_DOCTOR, 'پزشک'); $this->resource($user, $address, $doctorType, 'دکتر یک'); $plan = $this->preview($user, $service, $address); self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE)); self::assertSame(20, $plan['data']['total_minutes']); self::assertCount(1, $plan['data']['segments']); self::assertSame('doctor', $plan['data']['segments'][0]['requirements'][0]['role']); } /** بخش بدون هیچ نیازمندی معتبر است: زمان می‌گیرد، منبعی نمی‌گیرد. */ public function testSegmentWithoutRequirementsIsValid(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'مراقبت خانگی', 10); $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'انتظار در خانه', 'duration_minutes' => 45, 'patient_present' => false, 'requirements' => []], ]); self::assertSame(200, $this->responseCode()); $plan = $this->preview($user, $service, $address); self::assertSame(45, $plan['data']['total_minutes']); self::assertSame([], $plan['data']['segments'][0]['requirements']); self::assertFalse($plan['data']['segments'][0]['patient_present']); } /** مهارتی که هیچ منبعی ندارد → خطای انسانی با نام نقش و مهارت (بند ۱۰). */ public function testMissingEligibleResourceGivesAHumanError(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20); $operator = $this->resourceType($address, 'operator', 'اپراتور'); $skill = $this->authJson('POST', '/api/v1/skills', $user, ['name' => 'لیزر آلکساندرایت']); self::assertSame(201, $this->responseCode()); // اپراتور هست ولی مهارت را ندارد. $this->resource($user, $address, $operator, 'اپراتور بی‌مهارت'); $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [ ['type_uuid' => $operator->getUuid(), 'skill_uuid' => $skill['data']['uuid']], ]], ]); self::assertSame(200, $this->responseCode()); $body = $this->preview($user, $service, $address); self::assertSame(422, $this->responseCode()); self::assertSame('ERR_NO_ELIGIBLE_RESOURCE', $body['errors'][0]['code']); self::assertStringContainsString('اپراتور', $body['errors'][0]['message']); self::assertStringContainsString('لیزر آلکساندرایت', $body['errors'][0]['message']); self::assertStringContainsString('شعبهٔ مرکزی', $body['errors'][0]['message']); } /** * قید جنسیت وقتی جنسیت بیمار نامشخص است نادیده گرفته **نمی‌شود** — رد کردن بی‌صدا * یعنی بیمار به منبعی می‌رسد که قرار نبود. */ public function testSameGenderConstraintRequiresAKnownPatientGender(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20); $operator = $this->resourceType($address, 'operator', 'اپراتور'); $this->resource($user, $address, $operator, 'اپراتور خانم', ['attributes' => ['gender' => 'female']]); $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [ ['type_uuid' => $operator->getUuid(), 'constraints' => [SegmentRequirement::CONSTRAINT_SAME_GENDER]], ]], ]); self::assertSame(200, $this->responseCode()); $unknown = $this->preview($user, $service, $address); self::assertSame(422, $this->responseCode()); self::assertStringContainsString('جنسیت بیمار', $unknown['errors'][0]['message']); $female = $this->preview($user, $service, $address, ['patient_gender' => 'female']); self::assertSame(200, $this->responseCode()); self::assertSame(1, $female['data']['segments'][0]['requirements'][0]['candidates']); $male = $this->preview($user, $service, $address, ['patient_gender' => 'male']); self::assertSame(422, $this->responseCode(), 'هیچ اپراتور آقایی نیست'); self::assertStringContainsString('آقایی', $male['errors'][0]['message']); } /** `setup/cleanup` در نمای کاربر نمی‌آید ولی برای تسک بعدی در پاسخ هست. */ public function testOccupancyOffsetCarriesResourceSetupAndCleanup(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20); $device = $this->resourceType($address, 'device', 'دستگاه'); $this->resource($user, $address, $device, 'لیزر ۱', ['setup_minutes' => 5, 'cleanup_minutes' => 10]); $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $device->getUuid()]]], ]); $plan = $this->preview($user, $service, $address); $offset = $plan['data']['segments'][0]['requirements'][0]['occupancy_offset']; self::assertSame(5, $offset['setup_minutes']); self::assertSame(10, $offset['cleanup_minutes']); } public function testTotalBeyondTheDailyCapIsRejected(): void { [$user, $section] = $this->clinicWithBranch(); $service = $this->service($section, 'ماراتن', 20); $body = $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'بخش بلند', 'duration_minutes' => 400], ['sequence' => 2, 'name' => 'بخش بلند دوم', 'duration_minutes' => 200], ]); self::assertSame(422, $this->responseCode()); self::assertStringContainsString('سقف', $body['errors'][0]['message']); } public function testFixedSegmentNeedsAPositiveDuration(): void { [$user, $section] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20); $body = $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'بخش بی‌مدت', 'duration_minutes' => 0], ]); self::assertSame(422, $this->responseCode()); self::assertSame('duration_minutes', $body['errors'][0]['field']); } /** بخش‌های `mergeable` هم‌نام یک بار می‌آیند. */ public function testMergeableSegmentsAppearOnce(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20); $room = $this->resourceType($address, 'room', 'اتاق'); $this->resource($user, $address, $room, 'اتاق ۱'); $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], ['sequence' => 2, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], ['sequence' => 3, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], ]); $plan = $this->preview($user, $service, $address); self::assertCount(2, $plan['data']['segments'], 'آماده‌سازی یک بار'); self::assertSame(25, $plan['data']['total_minutes']); } public function testForeignServiceIsNotFound(): void { [$user, , $address] = $this->clinicWithBranch(); [, $otherSection] = $this->clinicWithBranch(); $foreign = $this->service($otherSection, 'سرویس بیگانه', 20); $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, [ 'service_uuid' => $foreign->getUuid(), 'branch_uuid' => $address->getUuid(), ]); self::assertSame(404, $this->responseCode()); } }