From 021d9f82a2f38675c8189d6691c7d971aefbd140 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 21:26:24 +0330 Subject: [PATCH] Pick candidate resources by whether they actually offer the service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findEligible matched on address, type and skills, so two devices of the same type were interchangeable even when only one of them performed the service. It now also consults the offering table. The filter is conditional on purpose: it only applies once the clinic has registered at least one resource for that service. Applying it unconditionally would leave every environment that has not filled the links in yet without a single free slot overnight — a silent outage caused by a feature they never opted into. When rows do exist but all are inactive the result is empty, which is the honest answer: nobody performs this right now. The service comes from the segment template rather than the root service. One appointment's plan can carry segments from several items, and "who can do this" is a per-item question. Five tests: the filter picking one of two identical devices, the no-rows passthrough, the all-inactive empty, the no-service-argument path still untouched, and the filter stacking with the skill filter. Suite 1277 green — including the 27 existing plan and availability tests, which is what proves the backward-compatible path. Co-Authored-By: Claude Opus 5 --- .../task-15-resource-first-model/checklist.md | 10 +- .../Plan/Service/AppointmentPlanBuilder.php | 14 +- .../Repository/ClinicResourceRepository.php | 24 ++- tests/Resource/OfferingEligibilityTest.php | 142 ++++++++++++++++++ 4 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 tests/Resource/OfferingEligibilityTest.php 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()); + } +}