diff --git a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md index 8eee2daa..117fc268 100644 --- a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md +++ b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md @@ -51,11 +51,11 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `findEligible()` آرگومان `?ServiceItem` گرفت | ⏳ | | -| ۳.۲ | سازگاری عقب‌رو: بدون ردیف = بدون فیلتر | ⏳ | | -| ۳.۳ | `AppointmentPlanBuilder` سرویس را پاس می‌دهد | ⏳ | | -| ۳.۴ | تست: دو منبع، یکی وصل → assignment همان یکی | ⏳ | | -| ۳.۵ | تست: غیرفعال‌کردن ردیف → slots خالی با reason | ⏳ | | +| ۳.۱ | `findEligible()` آرگومان `?ServiceItem` گرفت | ✅ | `findEligible($address, $type, $skillIds, ?ServiceItem $service)` | +| ۳.۲ | سازگاری عقب‌رو: بدون ردیف = بدون فیلتر | ✅ | `hasAnyFor()` دروازه است: بدون ردیف فیلتری اعمال نمی‌شود — `testWithoutAnyOfferingTheFilterIsNotAppliedAtAll` | +| ۳.۳ | `AppointmentPlanBuilder` سرویس را پاس می‌دهد | ✅ | سرویس از `$template->getService()` می‌آید نه سرویس ریشه، چون یک برنامه می‌تواند بخش‌هایی از چند آیتم داشته باشد | +| ۳.۴ | تست: دو منبع، یکی وصل → assignment همان یکی | ✅ | `testOnlyTheResourceThatOffersTheServiceIsEligible` — از دو دستگاه هم‌نوع، فقط وصل‌شده | +| ۳.۵ | تست: غیرفعال‌کردن ردیف → slots خالی با reason | ✅ | `testDeactivatingEveryOfferingLeavesNoCandidate` → آرایهٔ خالی؛ به‌علاوه ترکیب با فیلتر مهارت و مسیر بدون سرویس | ## ۴. منبع و snapshot روی نوبت diff --git a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php index 49ab80f8..4b0fb774 100644 --- a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php +++ b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php @@ -222,10 +222,6 @@ final class AppointmentPlanBuilder return $counts; } - /** - * قانونی که نقشِ ناشناخته یا بی‌منبع می‌خواهد **خطاست، نه بی‌اثر**: در سکوت رد - * کردنش یعنی کلینیک فکر کند قانونش اجرا می‌شود در حالی که هیچ‌وقت نشده. - */ /** @return list */ private function planRequirements( SegmentTemplate $template, @@ -278,7 +274,15 @@ final class AppointmentPlanBuilder SegmentTemplate $template, ): array { $skillIds = $requirement->getSkill() === null ? [] : [(int) $requirement->getSkill()->getId()]; - $eligible = $this->resources->findEligible($address, $requirement->getResourceType(), $skillIds); + + // سرویس از خودِ الگو می‌آید نه از سرویس ریشه: برنامهٔ یک نوبت می‌تواند بخش‌هایی + // از چند آیتم داشته باشد، و «کدام منبع این را می‌دهد» برای هر آیتم جداست. + $eligible = $this->resources->findEligible( + $address, + $requirement->getResourceType(), + $skillIds, + $template->getService(), + ); if (!$requirement->requiresSameGender()) { return array_values($eligible); diff --git a/src/Resource/Repository/ClinicResourceRepository.php b/src/Resource/Repository/ClinicResourceRepository.php index 9d49638e..6df50ebd 100644 --- a/src/Resource/Repository/ClinicResourceRepository.php +++ b/src/Resource/Repository/ClinicResourceRepository.php @@ -2,6 +2,7 @@ namespace App\Resource\Repository; +use App\ClinicService\Entity\ServiceItem; use App\Doctor\Entity\Doctor; use App\Doctor\Entity\DoctorAddress; use App\Branch\Entity\Room; @@ -16,8 +17,10 @@ use Doctrine\Persistence\ManagerRegistry; */ class ClinicResourceRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) - { + public function __construct( + ManagerRegistry $registry, + private readonly ResourceServiceOfferingRepository $offerings, + ) { parent::__construct($registry, ClinicResource::class); } @@ -105,7 +108,7 @@ class ClinicResourceRepository extends ServiceEntityRepository * @param int[] $skillIds خالی یعنی بدون شرط مهارت * @return ClinicResource[] */ - public function findEligible(DoctorAddress $address, ResourceType $type, array $skillIds = []): array + public function findEligible(DoctorAddress $address, ResourceType $type, array $skillIds = [], ?ServiceItem $service = null): array { $qb = $this->createQueryBuilder('r') ->where('r.address = :address') @@ -114,6 +117,21 @@ class ClinicResourceRepository extends ServiceEntityRepository ->setParameter('address', $address) ->setParameter('type', $type); + /** + * «کدام منبع این سرویس را می‌دهد» فقط وقتی فیلتر است که کلینیک جوابش را داده + * باشد. اگر برای این سرویس هیچ ردیفی نباشد، فیلتر اعمال نمی‌شود — وگرنه هر + * محیطی که هنوز رابطه‌ها را پر نکرده، یک‌شبه بدون وقت آزاد می‌شد. + */ + if ($service !== null && $this->offerings->hasAnyFor($service)) { + $resourceIds = $this->offerings->activeResourceIdsFor($service); + + if ($resourceIds === []) { + return []; // همه غیرفعال‌اند: «هیچ‌کس این سرویس را نمی‌دهد»، نه «فیلتری نیست» + } + + $qb->andWhere('r.id IN (:offered)')->setParameter('offered', $resourceIds); + } + if ($skillIds !== []) { $qb->join('r.skills', 'rs') ->andWhere('rs.skill IN (:skills)') diff --git a/tests/Resource/OfferingEligibilityTest.php b/tests/Resource/OfferingEligibilityTest.php new file mode 100644 index 00000000..9d6537ae --- /dev/null +++ b/tests/Resource/OfferingEligibilityTest.php @@ -0,0 +1,142 @@ +tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name); + $this->em->persist($section); + $this->em->flush(); + + $item = new ServiceItem($section, $name, 5_000_000); + $item->setDurationMinutes(30); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function resource(DoctorAddress $address, ResourceType $type, string $name): ClinicResource + { + $resource = new ClinicResource($address, $type, $name); + $this->em->persist($resource); + $this->em->flush(); + + return $resource; + } + + private function repo(): ClinicResourceRepository + { + return $this->em->getRepository(ClinicResource::class); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testOnlyTheResourceThatOffersTheServiceIsEligible(): void + { + [, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + $service = $this->service($address, 'لیزر CO2'); + + $offering = $this->resource($address, $type, 'دستگاه شمارهٔ ۱'); + $this->resource($address, $type, 'دستگاه شمارهٔ ۲'); // این یکی وصل نیست + + $this->em->persist(new ResourceServiceOffering($offering, $service)); + $this->em->flush(); + + $eligible = $this->repo()->findEligible($address, $type, [], $service); + + self::assertCount(1, $eligible); + self::assertSame('دستگاه شمارهٔ ۱', $eligible[0]->getName()); + } + + // ── ⚠️ مرزی: سازگاری عقب‌رو ────────────────────────────────────────────── + + public function testWithoutAnyOfferingTheFilterIsNotAppliedAtAll(): void + { + [, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + $service = $this->service($address, 'سرویس بدون رابطه'); + + $this->resource($address, $type, 'دستگاه الف'); + $this->resource($address, $type, 'دستگاه ب'); + + // هیچ ردیفی برای این سرویس نیست → همان رفتار قبلی، هر دو کاندیدند. + self::assertCount(2, $this->repo()->findEligible($address, $type, [], $service)); + } + + public function testDeactivatingEveryOfferingLeavesNoCandidate(): void + { + [, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + $service = $this->service($address, 'لیزر خاموش'); + + $first = $this->resource($address, $type, 'دستگاه الف'); + $this->resource($address, $type, 'دستگاه ب'); + + $offering = new ResourceServiceOffering($first, $service); + $offering->setActive(false); + $this->em->persist($offering); + $this->em->flush(); + + // ردیف هست ولی خاموش: یعنی «کسی این را نمی‌دهد»، نه «فیلتری در کار نیست». + self::assertSame([], $this->repo()->findEligible($address, $type, [], $service)); + } + + public function testWithoutAServiceTheOldBehaviourIsUntouched(): void + { + [, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + $service = $this->service($address, 'لیزر'); + + $only = $this->resource($address, $type, 'دستگاه تنها'); + $this->resource($address, $type, 'دستگاه دیگر'); + $this->em->persist(new ResourceServiceOffering($only, $service)); + $this->em->flush(); + + // بدون سرویس، هیچ فیلتری اعمال نمی‌شود — مسیرهایی که سرویس ندارند نباید بشکنند. + self::assertCount(2, $this->repo()->findEligible($address, $type)); + } + + public function testTheFilterStacksWithTheSkillFilter(): void + { + [, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + $service = $this->service($address, 'لیزر تخصصی'); + + $skilled = $this->resource($address, $type, 'دستگاه با مهارت'); + $plain = $this->resource($address, $type, 'دستگاه بی‌مهارت'); + + $skill = new \App\Resource\Entity\Skill($address->tenantEntityType(), $address->tenantEntityId(), 'کار با لیزر'); + $this->em->persist($skill); + $this->em->flush(); + $this->em->persist(new \App\Resource\Entity\ResourceSkill($skilled, $skill, 3)); + + // هر دو سرویس را می‌دهند، ولی فقط یکی مهارتش را دارد. + $this->em->persist(new ResourceServiceOffering($skilled, $service)); + $this->em->persist(new ResourceServiceOffering($plain, $service)); + $this->em->flush(); + + $eligible = $this->repo()->findEligible($address, $type, [(int) $skill->getId()], $service); + + self::assertCount(1, $eligible); + self::assertSame('دستگاه با مهارت', $eligible[0]->getName()); + } +}