From a70a98769ba3bab900aec0bf60e433aface464be Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 21:17:29 +0330 Subject: [PATCH] Resolve a service's duration and price from the resource that performs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chain the spec asks for, plus the branch level that already has data: resource+option, resource+service, branch override, then the item's own value. Duration and price resolve independently. If they resolved together the first override would silently swallow the other value — a resource that only differs in how long it takes would also drop the branch's tariff. Each resolved value carries where it came from. Without that, the panel cannot label a number "from the branch" or "service default", and "why this number?" becomes a four-table investigation. Two rules worth stating: null means inherit while zero is a real value, so a free service keeps its zero instead of inheriting the parent's price; and an inactive offering is skipped whole, since "this resource does not perform this right now" is not the same as "I have no opinion on the numbers". The parent service is passed in rather than looked up from the item's group. The booking flow already holds both, and a reverse query would be a second way to answer a question that already has an answer in hand. Eight tests: one per level with the other levels populated so the winner is provable, plus independent resolution, the inactive skip, zero, and resolving the service itself without a parent. Suite 1272 green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 --- .../task-15-resource-first-model/checklist.md | 16 +- .../Service/ResourceServiceResolver.php | 98 +++++++++ .../ValueObject/ResolvedServiceSpec.php | 44 ++++ .../ResourceServiceResolverTest.php | 196 ++++++++++++++++++ 4 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 src/ClinicService/Service/ResourceServiceResolver.php create mode 100644 src/ClinicService/ValueObject/ResolvedServiceSpec.php create mode 100644 tests/ClinicService/ResourceServiceResolverTest.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 bed72930..8eee2daa 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 @@ -38,14 +38,14 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | `ResourceServiceResolver` با زنجیرهٔ چهارسطحی | ⏳ | | -| ۲.۲ | `ResolvedServiceSpec` منبعِ هر مقدار را می‌گوید | ⏳ | | -| ۲.۳ | مدت و قیمت **جدا** حل می‌شوند | ⏳ | | -| ۲.۴ | تست سطح ۱ (منبع+گزینه) | ⏳ | | -| ۲.۵ | تست سطح ۲ (منبع+سرویس) | ⏳ | | -| ۲.۶ | تست سطح ۳ (شعبه) | ⏳ | | -| ۲.۷ | تست سطح ۴ (پیش‌فرض آیتم) | ⏳ | | -| ۲.۸ | تست مرزی: مدت از سطح ۱، قیمت از سطح ۳ | ⏳ | | +| ۲.۱ | `ResourceServiceResolver` با زنجیرهٔ چهارسطحی | ✅ | `ResourceServiceResolver` — منبع+گزینه → منبع+سرویس → شعبه → پیش‌فرض آیتم | +| ۲.۲ | `ResolvedServiceSpec` منبعِ هر مقدار را می‌گوید | ✅ | `ResolvedServiceSpec` با `durationSource` و `priceSource` | +| ۲.۳ | مدت و قیمت **جدا** حل می‌شوند | ✅ | `testDurationAndPriceResolveIndependently` — مدت از سطح ۱، قیمت از سطح ۳ | +| ۲.۴ | تست سطح ۱ (منبع+گزینه) | ✅ | `testLevelOneResourcePlusOptionWins` سبز | +| ۲.۵ | تست سطح ۲ (منبع+سرویس) | ✅ | `testLevelTwoResourcePlusServiceWinsWhenTheOptionHasNothing` سبز | +| ۲.۶ | تست سطح ۳ (شعبه) | ✅ | `testLevelThreeBranchWinsWhenTheResourceHasNothing` سبز | +| ۲.۷ | تست سطح ۴ (پیش‌فرض آیتم) | ✅ | `testLevelFourFallsBackToTheItemItself` سبز | +| ۲.۸ | تست مرزی: مدت از سطح ۱، قیمت از سطح ۳ | ✅ | به‌علاوهٔ سه مرزی: ردیف غیرفعال رد می‌شود · قیمت صفر ارث نمی‌گیرد · حل خودِ سرویس بدون والد | ## ۳. فیلتر کاندیدها بر اساس سرویس diff --git a/src/ClinicService/Service/ResourceServiceResolver.php b/src/ClinicService/Service/ResourceServiceResolver.php new file mode 100644 index 00000000..24fd887d --- /dev/null +++ b/src/ClinicService/Service/ResourceServiceResolver.php @@ -0,0 +1,98 @@ +offerings->findOneFor($resource, $item); + + $parentOffering = $parentService !== null && $parentService->getId() !== $item->getId() + ? $this->offerings->findOneFor($resource, $parentService) + : null; + + // ردیف غیرفعال یعنی «این منبع فعلاً این را نمی‌دهد»، نه «مقدارش را نمی‌دانم» — + // پس اعدادش هم خوانده نمی‌شوند و زنجیره از رویش رد می‌شود. + $optionOffering = $optionOffering?->isActive() === true ? $optionOffering : null; + $parentOffering = $parentOffering?->isActive() === true ? $parentOffering : null; + + $branch = $this->branchOverrides->findOneBy(['item' => $item, 'address' => $address]); + + [$duration, $durationSource] = $this->first([ + [$optionOffering?->getDurationMinutes(), ResolvedServiceSpec::SOURCE_RESOURCE_OPTION], + [$parentOffering?->getDurationMinutes(), ResolvedServiceSpec::SOURCE_RESOURCE_SERVICE], + [$branch?->getSoloDurationMinutes(), ResolvedServiceSpec::SOURCE_BRANCH], + [$item->getSoloDurationMinutes(), ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT], + ]); + + [$price, $priceSource] = $this->first([ + [$optionOffering?->getPriceRials(), ResolvedServiceSpec::SOURCE_RESOURCE_OPTION], + [$parentOffering?->getPriceRials(), ResolvedServiceSpec::SOURCE_RESOURCE_SERVICE], + [$branch?->getPriceRials(), ResolvedServiceSpec::SOURCE_BRANCH], + [$item->getPriceRials(), ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT], + ]); + + return new ResolvedServiceSpec( + durationMinutes: $duration, + priceRials: (int) ($price ?? 0), + durationSource: $duration === null ? null : $durationSource, + priceSource: $priceSource ?? ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, + ); + } + + /** + * اولین سطحی که مقدار دارد. `null` یعنی «حرفی برای گفتن ندارم» و رد می‌شود؛ + * صفر یک مقدارِ واقعی است و **رد نمی‌شود** — سرویسِ رایگان قیمتش صفر است، نه ارث‌بر. + * + * @param list $levels + * @return array{0: int|null, 1: string|null} + */ + private function first(array $levels): array + { + foreach ($levels as [$value, $source]) { + if ($value !== null) { + return [$value, $source]; + } + } + + return [null, null]; + } +} diff --git a/src/ClinicService/ValueObject/ResolvedServiceSpec.php b/src/ClinicService/ValueObject/ResolvedServiceSpec.php new file mode 100644 index 00000000..420b69a4 --- /dev/null +++ b/src/ClinicService/ValueObject/ResolvedServiceSpec.php @@ -0,0 +1,44 @@ + */ + public function toArray(): array + { + return [ + 'duration_minutes' => $this->durationMinutes, + 'price_rials' => $this->priceRials, + 'duration_source' => $this->durationSource, + 'price_source' => $this->priceSource, + ]; + } +} diff --git a/tests/ClinicService/ResourceServiceResolverTest.php b/tests/ClinicService/ResourceServiceResolverTest.php new file mode 100644 index 00000000..fb14e5b6 --- /dev/null +++ b/tests/ClinicService/ResourceServiceResolverTest.php @@ -0,0 +1,196 @@ +resolver = new ResourceServiceResolver( + $this->em->getRepository(ResourceServiceOffering::class), + $this->em->getRepository(ServiceBranchOverride::class), + ); + + $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک زنجیره'); + $this->em->persist($clinic); + $this->em->flush(); + + $this->address = DoctorAddress::forClinic($clinic->getId()); + $this->address->setName('شعبهٔ مرکزی'); + $this->em->persist($this->address); + + $type = new ResourceType('clinic', (int) $clinic->getId(), 'device', 'دستگاه'); + $this->em->persist($type); + $this->em->flush(); + + $this->resource = new ClinicResource($this->address, $type, 'لیزر دایود'); + $this->em->persist($this->resource); + + $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); + $this->em->persist($section); + $this->em->flush(); + + // سرویسِ والد «لیزر» و گزینهٔ «پا» — هر دو ServiceItem اند، همان مدل کاتالوگ. + $this->service = new ServiceItem($section, 'لیزر', 10_000_000); + $this->service->setDurationMinutes(60); + $this->em->persist($this->service); + + $this->option = new ServiceItem($section, 'لیزر پا', 8_000_000); + $this->option->setDurationMinutes(30); + $this->em->persist($this->option); + $this->em->flush(); + } + + private function offer(ServiceItem $item, ?int $minutes, ?int $price, bool $active = true): ResourceServiceOffering + { + $offering = new ResourceServiceOffering($this->resource, $item); + $offering->setDurationMinutes($minutes)->setPriceRials($price)->setActive($active); + $this->em->persist($offering); + $this->em->flush(); + + return $offering; + } + + private function branchOverride(ServiceItem $item, ?int $minutes, ?int $price): void + { + $override = new ServiceBranchOverride($item, $this->address); + $override->setSoloDurationMinutes($minutes)->setPriceRials($price); + $this->em->persist($override); + $this->em->flush(); + } + + private function resolve(): ResolvedServiceSpec + { + return $this->resolver->resolve($this->resource, $this->option, $this->address, $this->service); + } + + // ── ✅ هر سطح، تنها ────────────────────────────────────────────────────── + + public function testLevelOneResourcePlusOptionWins(): void + { + $this->offer($this->option, 15, 9_500_000); + $this->offer($this->service, 40, 12_000_000); + $this->branchOverride($this->option, 50, 11_000_000); + + $spec = $this->resolve(); + + self::assertSame(15, $spec->durationMinutes); + self::assertSame(9_500_000, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->durationSource); + self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->priceSource); + } + + public function testLevelTwoResourcePlusServiceWinsWhenTheOptionHasNothing(): void + { + $this->offer($this->service, 40, 12_000_000); + $this->branchOverride($this->option, 50, 11_000_000); + + $spec = $this->resolve(); + + self::assertSame(40, $spec->durationMinutes); + self::assertSame(12_000_000, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_SERVICE, $spec->durationSource); + } + + public function testLevelThreeBranchWinsWhenTheResourceHasNothing(): void + { + $this->branchOverride($this->option, 50, 11_000_000); + + $spec = $this->resolve(); + + self::assertSame(50, $spec->durationMinutes); + self::assertSame(11_000_000, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_BRANCH, $spec->durationSource); + } + + public function testLevelFourFallsBackToTheItemItself(): void + { + $spec = $this->resolve(); + + self::assertSame(30, $spec->durationMinutes); + self::assertSame(8_000_000, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->durationSource); + self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->priceSource); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testDurationAndPriceResolveIndependently(): void + { + // منبع فقط مدت را می‌گوید؛ قیمت باید تا سطح شعبه پایین برود. + $this->offer($this->option, 15, null); + $this->branchOverride($this->option, null, 11_000_000); + + $spec = $this->resolve(); + + self::assertSame(15, $spec->durationMinutes); + self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->durationSource); + + self::assertSame(11_000_000, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_BRANCH, $spec->priceSource); + } + + public function testAnInactiveOfferingIsSkippedEntirely(): void + { + // ردیف هست ولی خاموش: «این منبع فعلاً این را نمی‌دهد» یعنی اعدادش هم خوانده نمی‌شوند. + $this->offer($this->option, 15, 9_500_000, active: false); + + $spec = $this->resolve(); + + self::assertSame(30, $spec->durationMinutes); + self::assertSame(8_000_000, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->durationSource); + } + + public function testAFreeServiceKeepsItsZeroInsteadOfInheriting(): void + { + // صفر مقدار واقعی است، نه «حرفی ندارم» — وگرنه سرویس رایگان قیمت سرویس والد را می‌گرفت. + $this->offer($this->option, null, 0); + + $spec = $this->resolve(); + + self::assertSame(0, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->priceSource); + } + + public function testResolvingTheServiceItselfNeedsNoParent(): void + { + $this->offer($this->service, 45, 13_000_000); + + $spec = $this->resolver->resolve($this->resource, $this->service, $this->address); + + self::assertSame(45, $spec->durationMinutes); + self::assertSame(13_000_000, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->durationSource); + } +}