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); + } +}