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(), 'supervisor_doctor_uuid' => $this->supervisorFor($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']); } /** * ⭐ ادغام واقعی: دو ناحیه که هرکدام «آماده‌سازی» خودشان را دارند. * * پیش از این، الگوهای آیتم‌های انتخاب‌شده اصلاً خوانده نمی‌شدند و پرچم `mergeable` * هیچ کاری نمی‌کرد — در یک سرویس، دو بخشِ هم‌نام معنا ندارد. */ public function testSegmentsOfSelectedItemsAreMergedByName(): void { [$user, $section, $address] = $this->clinicWithBranch(); $face = $this->service($section, 'لیزر صورت', 20); $bikini = $this->service($section, 'لیزر بیکینی', 30); $room = $this->resourceType($address, 'room', 'اتاق'); $this->resource($user, $address, $room, 'اتاق ۱'); // آماده‌سازیِ بیکینی طولانی‌تر است؛ ادغام باید طولانی‌ترین را نگه دارد. $this->setSegments($user, $face, [ ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], ['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 0, 'duration_source' => 'items', 'requirements' => [['type_uuid' => $room->getUuid()]]], ]); $this->setSegments($user, $bikini, [ ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 12, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], ]); $plan = $this->preview($user, $face, $address, [ 'item_uuids' => [$face->getUuid(), $bikini->getUuid()], ]); $segments = $plan['data']['segments']; $names = array_column($segments, 'name'); self::assertSame(['آماده‌سازی', 'کار اصلی'], $names, 'آماده‌سازی یک بار می‌آید'); self::assertSame(12, $segments[0]['duration_minutes'], 'طولانی‌ترین آماده‌سازی می‌ماند'); // کار اصلی از `DurationCalculator` می‌آید: ۲۰ + ۳۰. self::assertSame(50, $segments[1]['duration_minutes']); self::assertSame(62, $plan['data']['total_minutes']); } /** * ⭐ تعداد منبع پس از ادغام **بیشینه** است، نه جمع و نه اولی. * * دو ناحیه با هم دو اتاق نمی‌خواهند؛ ولی اگر یکی دو نفر لازم داشت، ادغام نباید آن * را به یک تنزل بدهد — نوبتی که نیروی کافی ندارد بدتر از نوبتِ نگرفته است. */ public function testMergingTakesTheLargestRequiredCount(): void { [$user, $section, $address] = $this->clinicWithBranch(); $face = $this->service($section, 'ناحیهٔ یک', 20); $bikini = $this->service($section, 'ناحیهٔ دو', 20); $operator = $this->resourceType($address, 'operator', 'اپراتور'); $this->resource($user, $address, $operator, 'اپراتور ۱'); $this->resource($user, $address, $operator, 'اپراتور ۲'); $this->setSegments($user, $face, [ ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 10, 'mergeable' => true, 'requirements' => [['type_uuid' => $operator->getUuid(), 'count' => 1]]], ]); $this->setSegments($user, $bikini, [ ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 8, 'mergeable' => true, 'requirements' => [['type_uuid' => $operator->getUuid(), 'count' => 2]]], ]); $plan = $this->preview($user, $face, $address, [ 'item_uuids' => [$face->getUuid(), $bikini->getUuid()], ]); $segments = $plan['data']['segments']; self::assertCount(1, $segments); self::assertSame(2, $segments[0]['requirements'][0]['count'], 'بیشینه، نه اولی'); } /** * ⭐ قطعیت: دو build با همان ورودی باید **بایت‌به‌بایت** یکی باشند. * * ترتیب منابع و بخش‌ها از کوئری می‌آید و کوئریِ بدون `ORDER BY` قطعی نیست. برنامه‌ای * که بین پیش‌نمایش و رزرو جابه‌جا شود، یعنی کاربر چیزی را تأیید کرده که رزرو نشد. */ public function testTwoIdenticalBuildsProduceTheSamePlan(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20); $room = $this->resourceType($address, 'room', 'اتاق'); $operator = $this->resourceType($address, 'operator', 'اپراتور'); foreach (['اتاق ۱', 'اتاق ۲', 'اتاق ۳'] as $name) { $this->resource($user, $address, $room, $name); } foreach (['اپراتور ۱', 'اپراتور ۲'] as $name) { $this->resource($user, $address, $operator, $name); } $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]], ['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], ]); $first = $this->preview($user, $service, $address)['data']; $second = $this->preview($user, $service, $address)['data']; self::assertSame( json_encode($first, JSON_UNESCAPED_UNICODE), json_encode($second, JSON_UNESCAPED_UNICODE), ); } /** * ⭐ الگوی نمونه نقطهٔ شروع است، نه پیکربندی نهایی — و **بازنویسی خاموش نمی‌کند**. */ public function testSeedingCreatesAStarterSetAndRefusesToOverwrite(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20); $room = $this->resourceType($address, 'room', 'اتاق'); $operator = $this->resourceType($address, 'operator', 'اپراتور'); $this->resource($user, $address, $room, 'اتاق ۱'); $this->resource($user, $address, $operator, 'اپراتور ۱'); $seed = static::getContainer()->get(\App\Appointment\Plan\Command\SeedSegmentTemplatesCommand::class); $run = static function (array $input) use ($seed): array { $tester = new \Symfony\Component\Console\Tester\CommandTester($seed); $tester->execute($input); return [$tester->getStatusCode(), $tester->getDisplay()]; }; [$code] = $run(['--service' => $service->getUuid(), '--preset' => 'beauty']); self::assertSame(0, $code); $plan = $this->preview($user, $service, $address); self::assertCount(5, $plan['data']['segments']); self::assertSame('آماده‌سازی', $plan['data']['segments'][0]['name']); // بخشِ تمیزکاری بدون حضور بیمار است — همان چیزی که گزارش بهره‌وری با آن کار می‌کند. $cleanup = end($plan['data']['segments']); self::assertFalse($cleanup['patient_present']); // اجرای دوباره بدون `--force` دست به چیزی نمی‌زند. [, $display] = $run(['--service' => $service->getUuid(), '--preset' => 'dental']); self::assertStringContainsString('--force', $display); $unchanged = $this->preview($user, $service, $address); self::assertCount(5, $unchanged['data']['segments'], 'بدون --force بازنویسی نمی‌شود'); [, $forced] = $run(['--service' => $service->getUuid(), '--preset' => 'dental', '--force' => true]); self::assertStringNotContainsString('--force', $forced); $replaced = $this->preview($user, $service, $address); self::assertCount(3, $replaced['data']['segments']); } /** * ⭐⭐ جایگزینی بخش‌ها **حذف‌کن-و-بنویس** است. اگر اعتبارسنجی بعد از حذف بیفتد، * سرویس بدون هیچ بخشی می‌ماند و نوبت‌دهی‌اش بی‌صدا به «یک بخش پیوسته» برمی‌گردد — * یعنی مدت و منابع همهٔ نوبت‌های بعدی عوض می‌شود، بدون اینکه کسی چیزی خواسته باشد. */ public function testARejectedReplaceLeavesTheExistingSegmentsIntact(): 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' => 10, 'requirements' => [['type_uuid' => $room->getUuid()]]], ['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], ]); self::assertCount(2, $this->preview($user, $service, $address)['data']['segments']); // قیدِ ناشناخته: باید ۴۲۲ بگیرد **و** چیزی را خراب نکند. $this->setSegments($user, $service, [ ['sequence' => 1, 'name' => 'بخش تازه', 'duration_minutes' => 15, 'requirements' => [ ['type_uuid' => $room->getUuid(), 'constraints' => ['same_blood_type']], ]], ]); self::assertSame(422, $this->responseCode()); $after = $this->preview($user, $service, $address); self::assertCount(2, $after['data']['segments'], 'بخش‌های قبلی باید دست‌نخورده مانده باشند'); self::assertSame('آماده‌سازی', $after['data']['segments'][0]['name']); } public function testTooManySegmentsIsRejected(): void { [$user, $section, $address] = $this->clinicWithBranch(); $service = $this->service($section, 'لیزر', 20); $segments = []; for ($i = 1; $i <= 21; $i++) { $segments[] = ['sequence' => $i, 'name' => sprintf('بخش %d', $i), 'duration_minutes' => 5]; } $this->setSegments($user, $service, $segments); self::assertSame(422, $this->responseCode()); } /** * ⭐ «نود دقیقه وقت بگذارید» برای نوبتی که چهل دقیقه‌اش انتظار است، حرفِ درستی نیست. * محاسبه یک‌جا در بک‌اند است تا هر کلاینت خودش جمع نزند. */ public function testThePlanReportsHowLongThePatientIsActuallyPresent(): 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' => 20, 'patient_present' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], ['sequence' => 2, 'name' => 'تمیزکاری', 'duration_minutes' => 15, 'patient_present' => false, 'requirements' => [['type_uuid' => $room->getUuid()]]], ]); $plan = $this->preview($user, $service, $address)['data']; self::assertSame(35, $plan['total_minutes']); self::assertSame(20, $plan['patient_facing_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()); } }