From 6d7c54508c47a1bba83f1443641b19e72d735dfe Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 21:43:12 +0330 Subject: [PATCH] Let categories contain other categories, and share them with resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps against the spec. Resources could not be categorised at all — only services carried a catalog category — so "this device is for hands and feet" was unsayable. And CatalogCategory::$parent is a tree built for menu ordering: one parent per category. Laser areas overlap, so "hand" belongs under both "whole body" and "upper limb" at once, which a tree cannot express. Containment is therefore a separate directed acyclic graph (catalog_category_includes) sitting beside the display hierarchy, and resources join the existing clinic-wide categories through a many-to-many rather than growing a parallel list of their own. CategoryClosureResolver walks it transitively: whole body includes lower body includes foot, so whole body includes foot without anyone writing that pair down. The walk reads every edge of the environment in one query and traverses in memory — a query per level would tie round-trips to graph depth. The visited set doubles as the cycle guard, so even data that already contains a loop cannot hang the traversal, and assertNoCycle refuses to create one. Selection now rejects picking an area together with a category that contains it: "whole body laser" and "hand laser" in one appointment is a 422 with a Persian message naming both. This replaces hand-written incompatible_with pairs for the area case — defined once on the category instead of per item pair — while that relation stays for incompatibilities that have nothing to do with areas. Nine tests, including the two-parents case a tree could not hold, the cycle refusal, the self-edge, and the empty-graph boundary. TenantSchemaCoverageTest caught the new edge entity as unclassified; it is registered as an aggregate child of the parent category, which is what the constructor already enforces. Suite 1286 green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 --- .../task-15-resource-first-model/checklist.md | 18 +- migrations/Version20260801175928.php | 35 +++ migrations/Version20260801180102.php | 35 +++ .../Entity/CatalogCategoryInclude.php | 62 ++++++ .../CatalogCategoryIncludeRepository.php | 65 ++++++ .../Service/CategoryClosureResolver.php | 96 +++++++++ .../Service/ServiceSelectionValidator.php | 52 +++++ src/Resource/Entity/ClinicResource.php | 18 ++ src/Shared/Tenant/GlobalTables.php | 3 + tests/ClinicService/CategoryClosureTest.php | 200 ++++++++++++++++++ 10 files changed, 575 insertions(+), 9 deletions(-) create mode 100644 migrations/Version20260801175928.php create mode 100644 migrations/Version20260801180102.php create mode 100644 src/ClinicService/Entity/CatalogCategoryInclude.php create mode 100644 src/ClinicService/Repository/CatalogCategoryIncludeRepository.php create mode 100644 src/ClinicService/Service/CategoryClosureResolver.php create mode 100644 tests/ClinicService/CategoryClosureTest.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 117fc268..f0433421 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 @@ -101,15 +101,15 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۷.۱ | `resource_catalog_categories` (m2m منبع↔دسته) | ⏳ | | -| ۷.۲ | `catalog_category_includes` (یال DAG) | ⏳ | | -| ۷.۳ | `CategoryClosureResolver::descendants()` با محافظ دور | ⏳ | | -| ۷.۴ | تعارض انتخاب در `ServiceSelectionValidator` → ۴۲۲ فارسی | ⏳ | | -| ۷.۵ | تقدم منابعِ پوشش‌دهندهٔ دسته در `findEligible` | ⏳ | | -| ۷.۶ | تست: «تمام بدن» → دست/پا | ⏳ | | -| ۷.۷ | تست: بستار گذرا سه‌سطحی | ⏳ | | -| ۷.۸ | تست: یال دوری → ۴۲۲ | ⏳ | | -| ۷.۹ | تست: دستهٔ بی‌یال → آرایهٔ خالی | ⏳ | | +| ۷.۱ | `resource_catalog_categories` (m2m منبع↔دسته) | ✅ | `resource_catalog_categories` — m2m، همان دسته‌بندی سراسری که سرویس‌ها هم دارند | +| ۷.۲ | `catalog_category_includes` (یال DAG) | ✅ | `catalog_category_includes` — یال DAG، جدا از `parent` درختی | +| ۷.۳ | `CategoryClosureResolver::descendants()` با محافظ دور | ✅ | `CategoryClosureResolver::descendants()` با BFS و `$seen` به‌عنوان محافظ دور | +| ۷.۴ | تعارض انتخاب در `ServiceSelectionValidator` → ۴۲۲ فارسی | ✅ | `categoryOverlapErrors()` با کد `category_overlap` و پیام فارسی | +| ۷.۵ | تقدم منابعِ پوشش‌دهندهٔ دسته در `findEligible` | ⏳ | ⏳ باقی مانده — در وظیفهٔ ۵ کنار endpoint منبع بسته می‌شود | +| ۷.۶ | تست: «تمام بدن» → دست/پا | ✅ | `testAWholeBodyCategoryIncludesTheAreasUnderIt` سبز | +| ۷.۷ | تست: بستار گذرا سه‌سطحی | ✅ | `testContainmentIsTransitive` — تمام بدن → نیم‌تنه → پا | +| ۷.۸ | تست: یال دوری → ۴۲۲ | ✅ | `testACycleIsRefused` → `AppException` ۴۲۲؛ به‌علاوه یال به خود → `InvalidArgumentException` | +| ۷.۹ | تست: دستهٔ بی‌یال → آرایهٔ خالی | ✅ | `testACategoryWithNoEdgesHasNoDescendants` → آرایهٔ خالی | | ۷.۱۰ | curl: «تمام بدن + دست» با هم → ۴۲۲ | ⏳ | | ## ۸. حذف زیرسیستم‌های خارج از مدل diff --git a/migrations/Version20260801175928.php b/migrations/Version20260801175928.php new file mode 100644 index 00000000..73f6b16e --- /dev/null +++ b/migrations/Version20260801175928.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE catalog_category_includes (id INT AUTO_INCREMENT NOT NULL, created_at INT NOT NULL, parent_category_id INT NOT NULL, child_category_id INT NOT NULL, INDEX IDX_862DDA14796A8F92 (parent_category_id), INDEX idx_include_child (child_category_id), UNIQUE INDEX uniq_category_include (parent_category_id, child_category_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE catalog_category_includes ADD CONSTRAINT FK_862DDA14796A8F92 FOREIGN KEY (parent_category_id) REFERENCES service_catalog_categories (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE catalog_category_includes ADD CONSTRAINT FK_862DDA14C8C2FACC FOREIGN KEY (child_category_id) REFERENCES service_catalog_categories (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE catalog_category_includes DROP FOREIGN KEY FK_862DDA14796A8F92'); + $this->addSql('ALTER TABLE catalog_category_includes DROP FOREIGN KEY FK_862DDA14C8C2FACC'); + $this->addSql('DROP TABLE catalog_category_includes'); + } +} diff --git a/migrations/Version20260801180102.php b/migrations/Version20260801180102.php new file mode 100644 index 00000000..282e9142 --- /dev/null +++ b/migrations/Version20260801180102.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE resource_catalog_categories (resource_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_ED9684A289329D25 (resource_id), INDEX IDX_ED9684A212469DE2 (category_id), PRIMARY KEY (resource_id, category_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE resource_catalog_categories ADD CONSTRAINT FK_ED9684A289329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_catalog_categories ADD CONSTRAINT FK_ED9684A212469DE2 FOREIGN KEY (category_id) REFERENCES service_catalog_categories (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE resource_catalog_categories DROP FOREIGN KEY FK_ED9684A289329D25'); + $this->addSql('ALTER TABLE resource_catalog_categories DROP FOREIGN KEY FK_ED9684A212469DE2'); + $this->addSql('DROP TABLE resource_catalog_categories'); + } +} diff --git a/src/ClinicService/Entity/CatalogCategoryInclude.php b/src/ClinicService/Entity/CatalogCategoryInclude.php new file mode 100644 index 00000000..43f3145e --- /dev/null +++ b/src/ClinicService/Entity/CatalogCategoryInclude.php @@ -0,0 +1,62 @@ +getId() !== null && $parent->getId() === $child->getId()) { + throw new \InvalidArgumentException('A category cannot include itself.'); + } + + if ($parent->getEntityType() !== $child->getEntityType() + || $parent->getEntityId() !== $child->getEntityId()) { + throw new \InvalidArgumentException('An include edge cannot cross environments.'); + } + + $this->parent = $parent; + $this->child = $child; + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getParent(): CatalogCategory { return $this->parent; } + public function getChild(): CatalogCategory { return $this->child; } + public function getCreatedAt(): int { return $this->createdAt; } +} diff --git a/src/ClinicService/Repository/CatalogCategoryIncludeRepository.php b/src/ClinicService/Repository/CatalogCategoryIncludeRepository.php new file mode 100644 index 00000000..eb8207e5 --- /dev/null +++ b/src/ClinicService/Repository/CatalogCategoryIncludeRepository.php @@ -0,0 +1,65 @@ + + */ +class CatalogCategoryIncludeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CatalogCategoryInclude::class); + } + + /** + * یال‌های مستقیمِ یک محیط، به‌صورت نگاشت والد => فرزندان. + * + * همهٔ یال‌های محیط با **یک** کوئری خوانده می‌شوند و پیمایش در حافظه انجام می‌شود: + * بستار گذرا با یک کوئری per سطح یعنی تعداد رفت‌وبرگشت به عمق گراف وابسته شود. + * + * @return array> + */ + public function edgeMapFor(string $entityType, int $entityId): array + { + $rows = $this->createQueryBuilder('e') + ->select('IDENTITY(e.parent) AS parent_id, IDENTITY(e.child) AS child_id') + ->join('e.parent', 'p') + ->where('p.entityType = :type') + ->andWhere('p.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->getQuery() + ->getArrayResult(); + + $map = []; + foreach ($rows as $row) { + $map[(int) $row['parent_id']][] = (int) $row['child_id']; + } + + return $map; + } + + public function findEdge(CatalogCategory $parent, CatalogCategory $child): ?CatalogCategoryInclude + { + return $this->findOneBy(['parent' => $parent, 'child' => $child]); + } + + /** @return CatalogCategoryInclude[] */ + public function findForParent(CatalogCategory $parent): array + { + return $this->createQueryBuilder('e') + ->join('e.child', 'c') + ->addSelect('c') + ->where('e.parent = :parent') + ->setParameter('parent', $parent) + ->orderBy('c.name', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/ClinicService/Service/CategoryClosureResolver.php b/src/ClinicService/Service/CategoryClosureResolver.php new file mode 100644 index 00000000..a2857874 --- /dev/null +++ b/src/ClinicService/Service/CategoryClosureResolver.php @@ -0,0 +1,96 @@ + + */ + public function descendants(CatalogCategory $category): array + { + $map = $this->includes->edgeMapFor($category->getEntityType(), $category->getEntityId()); + + return $this->walk((int) $category->getId(), $map); + } + + /** آیا یکی از این دو، دیگری را در بر می‌گیرد؟ (رابطه متقارن نیست، ولی تعارض هست) */ + public function overlaps(CatalogCategory $a, CatalogCategory $b): bool + { + if ($a->getId() === $b->getId()) { + return true; + } + + $map = $this->includes->edgeMapFor($a->getEntityType(), $a->getEntityId()); + + return in_array((int) $b->getId(), $this->walk((int) $a->getId(), $map), true) + || in_array((int) $a->getId(), $this->walk((int) $b->getId(), $map), true); + } + + /** + * افزودن یال، پیش از ذخیره. + * + * @throws AppException ۴۲۲ اگر یال دور بسازد + */ + public function assertNoCycle(CatalogCategory $parent, CatalogCategory $child): void + { + // اگر والدِ آینده از قبل زیرمجموعهٔ فرزند باشد، این یال حلقه می‌بندد و + // `descendants()` تا سرریز استک می‌رود. + if ($parent->getId() === $child->getId() + || in_array((int) $parent->getId(), $this->descendants($child), true)) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('«%s» از قبل زیرمجموعهٔ «%s» است؛ این دو نمی‌توانند شامل هم باشند', $parent->getName(), $child->getName()), + 422, + 'child_category_uuid', + ); + } + } + + /** + * @param array> $map + * @return list + */ + private function walk(int $rootId, array $map): array + { + $seen = []; + $queue = $map[$rootId] ?? []; + + while ($queue !== []) { + $id = array_shift($queue); + + // `$seen` هم نتیجه است هم محافظ دور: گرافی که با داده‌های قدیمی حلقه دارد + // نباید حلقهٔ بی‌پایان بسازد، حتی اگر ساختش امروز ممنوع است. + if (isset($seen[$id])) { + continue; + } + + $seen[$id] = true; + + foreach ($map[$id] ?? [] as $next) { + $queue[] = $next; + } + } + + return array_map('intval', array_keys($seen)); + } +} diff --git a/src/ClinicService/Service/ServiceSelectionValidator.php b/src/ClinicService/Service/ServiceSelectionValidator.php index b92ead61..b92aa2c2 100644 --- a/src/ClinicService/Service/ServiceSelectionValidator.php +++ b/src/ClinicService/Service/ServiceSelectionValidator.php @@ -28,6 +28,7 @@ final class ServiceSelectionValidator private readonly ServiceItemRelationRepository $relations, private readonly ServiceBranchOverrideRepository $overrides, private readonly DurationCalculator $durations, + private readonly CategoryClosureResolver $categories, ) {} /** @@ -40,6 +41,7 @@ final class ServiceSelectionValidator $errors = [ ...$this->groupErrors($selected, $groups), ...$this->relationErrors($selected), + ...$this->categoryOverlapErrors($selected), ]; $overrides = $address === null @@ -58,6 +60,56 @@ final class ServiceSelectionValidator ]; } + /** + * دو آیتمی که دسته‌شان یکی دیگری را در بر می‌گیرد، با هم انتخاب نمی‌شوند — + * «لیزر تمام بدن» و «لیزر دست». + * + * جای رابطهٔ دستیِ `incompatible_with` را برای حالت ناحیه‌ای می‌گیرد: یک بار روی + * دسته تعریف می‌شود، نه به‌ازای هر جفت آیتم. آن رابطه برای ناسازگاری‌هایی که ربطی + * به ناحیه ندارند سرِ جایش می‌ماند. + * + * @param ServiceItem[] $selected + * @return list> + */ + private function categoryOverlapErrors(array $selected): array + { + $withCategory = array_values(array_filter( + $selected, + static fn (ServiceItem $i): bool => $i->getCatalogCategory() !== null, + )); + + $errors = []; + + for ($i = 0; $i < count($withCategory); $i++) { + for ($j = $i + 1; $j < count($withCategory); $j++) { + $a = $withCategory[$i]; + $b = $withCategory[$j]; + + $categoryA = $a->getCatalogCategory(); + $categoryB = $b->getCatalogCategory(); + + if ($categoryA->getId() === $categoryB->getId() + || !$this->categories->overlaps($categoryA, $categoryB)) { + continue; + } + + $errors[] = [ + 'code' => 'category_overlap', + 'items' => [$a->getUuid(), $b->getUuid()], + 'message' => sprintf( + '«%s» شامل «%s» است؛ «%s» و «%s» با هم انتخاب نمی‌شوند', + $categoryA->getName(), + $categoryB->getName(), + $a->getName(), + $b->getName(), + ), + ]; + } + } + + return $errors; + } + /** * @param ServiceItem[] $selected * @param ItemGroup[] $groups diff --git a/src/Resource/Entity/ClinicResource.php b/src/Resource/Entity/ClinicResource.php index be49350b..4c5565ca 100644 --- a/src/Resource/Entity/ClinicResource.php +++ b/src/Resource/Entity/ClinicResource.php @@ -103,6 +103,20 @@ class ClinicResource #[ORM\OneToMany(targetEntity: ResourceSkill::class, mappedBy: 'resource', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $skills; + /** + * دسته‌های کاتالوگ که این منبع پوشش می‌دهد — «این دستگاه برای دست و پا است». + * + * m2m چون یک دستگاه چند ناحیه را می‌گیرد، و همان دسته‌بندیِ سراسری کلینیک است که + * سرویس‌ها هم از آن استفاده می‌کنند؛ نه فهرست موازیِ مخصوص منابع. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: \App\ClinicService\Entity\CatalogCategory::class)] + #[ORM\JoinTable(name: 'resource_catalog_categories')] + #[ORM\JoinColumn(name: 'resource_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + private Collection $categories; + /** @var Collection سرویس‌هایی که این منبع ارائه می‌دهد */ #[ORM\OneToMany(targetEntity: ResourceServiceOffering::class, mappedBy: 'resource', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $serviceOfferings; @@ -117,6 +131,7 @@ class ClinicResource $this->updatedAt = time(); $this->skills = new ArrayCollection(); $this->serviceOfferings = new ArrayCollection(); + $this->categories = new ArrayCollection(); // جفت از آدرس مشتق می‌شود، نه از بدنهٔ درخواست — پس هیچ نقطهٔ ساختی // نمی‌تواند فراموشش کند و کلاینت هم نمی‌تواند منبع را به محیط دیگری بچسباند. @@ -145,6 +160,9 @@ class ClinicResource /** @return Collection */ public function getServiceOfferings(): Collection { return $this->serviceOfferings; } + /** @return Collection */ + public function getCategories(): Collection { return $this->categories; } + public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } public function setSetupMinutes(int $v): self { $this->setupMinutes = $this->assertMinutes($v, 'setup_minutes'); $this->touch(); return $this; } public function setCleanupMinutes(int $v): self { $this->cleanupMinutes = $this->assertMinutes($v, 'cleanup_minutes'); $this->touch(); return $this; } diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index ce8c2ac6..a65cf544 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -111,6 +111,9 @@ final class GlobalTables \App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class, + // یال «این دسته شامل آن دسته است» جزئی از تعریف دستهٔ والد است؛ هر دو سرِ یال + // در یک محیط‌اند و سازندهٔ یال همین را اجبار می‌کند. + \App\ClinicService\Entity\CatalogCategoryInclude::class => \App\ClinicService\Entity\CatalogCategory::class, \App\Pricing\Entity\PriceListItem::class => \App\Pricing\Entity\PriceList::class, \App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class, diff --git a/tests/ClinicService/CategoryClosureTest.php b/tests/ClinicService/CategoryClosureTest.php new file mode 100644 index 00000000..43f10e9c --- /dev/null +++ b/tests/ClinicService/CategoryClosureTest.php @@ -0,0 +1,200 @@ +closure = new CategoryClosureResolver( + $this->em->getRepository(CatalogCategoryInclude::class), + ); + + $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $this->clinic = new Clinic($user); + $this->clinic->setName('کلینیک دسته‌بندی'); + $this->em->persist($this->clinic); + $this->em->flush(); + + $this->section = new ServiceSection('clinic', (int) $this->clinic->getId(), 'لیزر'); + $this->em->persist($this->section); + $this->em->flush(); + } + + private function category(string $name): CatalogCategory + { + $category = new CatalogCategory('clinic', (int) $this->clinic->getId(), $name); + $this->em->persist($category); + $this->em->flush(); + + return $category; + } + + private function includes(CatalogCategory $parent, CatalogCategory $child): void + { + $this->closure->assertNoCycle($parent, $child); + $this->em->persist(new CatalogCategoryInclude($parent, $child)); + $this->em->flush(); + } + + private function item(string $name, ?CatalogCategory $category = null): ServiceItem + { + $item = new ServiceItem($this->section, $name, 5_000_000); + $item->setDurationMinutes(30)->setCatalogCategory($category); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testAWholeBodyCategoryIncludesTheAreasUnderIt(): void + { + $whole = $this->category('تمام بدن'); + $hand = $this->category('دست'); + $foot = $this->category('پا'); + + $this->includes($whole, $hand); + $this->includes($whole, $foot); + + $descendants = $this->closure->descendants($whole); + + self::assertContains((int) $hand->getId(), $descendants); + self::assertContains((int) $foot->getId(), $descendants); + self::assertCount(2, $descendants); + } + + public function testContainmentIsTransitive(): void + { + $whole = $this->category('تمام بدن'); + $lower = $this->category('نیم‌تنهٔ پایین'); + $foot = $this->category('پا'); + + $this->includes($whole, $lower); + $this->includes($lower, $foot); + + // بدون بستار گذرا، کلینیک مجبور بود همهٔ جفت‌ها را دستی بنویسد. + self::assertContains((int) $foot->getId(), $this->closure->descendants($whole)); + self::assertTrue($this->closure->overlaps($whole, $foot)); + } + + public function testACategoryCanSitUnderTwoParentsWhichATreeCouldNotExpress(): void + { + $whole = $this->category('تمام بدن'); + $upper = $this->category('اندام فوقانی'); + $hand = $this->category('دست'); + + $this->includes($whole, $hand); + $this->includes($upper, $hand); + + self::assertContains((int) $hand->getId(), $this->closure->descendants($whole)); + self::assertContains((int) $hand->getId(), $this->closure->descendants($upper)); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testACycleIsRefused(): void + { + $whole = $this->category('تمام بدن'); + $hand = $this->category('دست'); + + $this->includes($whole, $hand); + + // «دست شامل تمام بدن» حلقه می‌بندد و پیمایش را تا سرریز استک می‌برد. + $this->expectException(AppException::class); + $this->closure->assertNoCycle($hand, $whole); + } + + public function testACategoryCannotIncludeItself(): void + { + $whole = $this->category('تمام بدن'); + + $this->expectException(\InvalidArgumentException::class); + new CatalogCategoryInclude($whole, $whole); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testACategoryWithNoEdgesHasNoDescendants(): void + { + self::assertSame([], $this->closure->descendants($this->category('تک‌افتاده'))); + } + + // ── تعارض انتخاب ───────────────────────────────────────────────────────── + + public function testSelectingAnAreaTogetherWithTheWholeBodyIsRejected(): void + { + $whole = $this->category('تمام بدن'); + $hand = $this->category('دست'); + $this->includes($whole, $hand); + + $wholeItem = $this->item('لیزر تمام بدن', $whole); + $handItem = $this->item('لیزر دست', $hand); + + $result = $this->validator()->validate([$wholeItem, $handItem], []); + + self::assertFalse($result['valid']); + self::assertSame('category_overlap', $result['errors'][0]['code']); + self::assertStringContainsString('تمام بدن', $result['errors'][0]['message']); + self::assertStringContainsString('دست', $result['errors'][0]['message']); + } + + public function testTwoUnrelatedAreasAreFine(): void + { + $hand = $this->category('دست'); + $foot = $this->category('پا'); + + // هیچ‌کدام دیگری را در بر نمی‌گیرد، پس با هم انتخاب می‌شوند. + $result = $this->validator()->validate( + [$this->item('لیزر دست', $hand), $this->item('لیزر پا', $foot)], + [], + ); + + self::assertTrue($result['valid'], json_encode($result['errors'], JSON_UNESCAPED_UNICODE)); + } + + public function testItemsWithoutACategoryAreLeftAlone(): void + { + $result = $this->validator()->validate( + [$this->item('مشاوره'), $this->item('ویزیت')], + [], + ); + + self::assertTrue($result['valid']); + } + + private function validator(): ServiceSelectionValidator + { + return new ServiceSelectionValidator( + $this->em->getRepository(\App\ClinicService\Entity\ItemGroupMember::class), + $this->em->getRepository(\App\ClinicService\Entity\ServiceItemRelation::class), + $this->em->getRepository(\App\ClinicService\Entity\ServiceBranchOverride::class), + new \App\ClinicService\Service\DurationCalculator(), + $this->closure, + ); + } +}