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