Let categories contain other categories, and share them with resources

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 21:43:12 +03:30
co-authored by Claude Opus 5
parent 021d9f82a2
commit 6d7c54508c
10 changed files with 575 additions and 9 deletions
@@ -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: «تمام بدن + دست» با هم → ۴۲۲ | ⏳ | |
## ۸. حذف زیرسیستم‌های خارج از مدل
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260801175928 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260801180102 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\CatalogCategoryIncludeRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* «این دسته شامل آن دسته است» — «تمام بدن» شامل «دست» و «پا».
*
* چرا جدا از `CatalogCategory::$parent`: آن یک **درخت** است و برای چیدمان منو ساخته
* شده، پس هر دسته یک والد بیشتر ندارد. ولی در لیزر مجموعه‌ها روی هم می‌افتند — «دست»
* هم زیر «تمام بدن» است هم زیر «اندام فوقانی» — و درخت این را نمی‌تواند بگوید.
* پس «شامل بودن» یک **گراف جهت‌دار بدون دور** است، جدا از سلسله‌مراتب نمایشی.
*
* فرزند aggregate با ریشهٔ دستهٔ والد است: ستون محیط ندارد چون هر دو سرِ یال در یک
* محیط‌اند و سازنده همین را اجبار می‌کند.
*/
#[ORM\Entity(repositoryClass: CatalogCategoryIncludeRepository::class)]
#[ORM\Table(name: 'catalog_category_includes')]
#[ORM\UniqueConstraint(name: 'uniq_category_include', columns: ['parent_category_id', 'child_category_id'])]
#[ORM\Index(columns: ['child_category_id'], name: 'idx_include_child')]
class CatalogCategoryInclude
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: CatalogCategory::class)]
#[ORM\JoinColumn(name: 'parent_category_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private CatalogCategory $parent;
#[ORM\ManyToOne(targetEntity: CatalogCategory::class)]
#[ORM\JoinColumn(name: 'child_category_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private CatalogCategory $child;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
/** @throws \InvalidArgumentException روی یال به خود یا دو محیط متفاوت */
public function __construct(CatalogCategory $parent, CatalogCategory $child)
{
if ($parent->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; }
}
@@ -0,0 +1,65 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\CatalogCategoryInclude;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CatalogCategoryInclude>
*/
class CatalogCategoryIncludeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CatalogCategoryInclude::class);
}
/**
* یال‌های مستقیمِ یک محیط، به‌صورت نگاشت والد => فرزندان.
*
* همهٔ یال‌های محیط با **یک** کوئری خوانده می‌شوند و پیمایش در حافظه انجام می‌شود:
* بستار گذرا با یک کوئری per سطح یعنی تعداد رفت‌وبرگشت به عمق گراف وابسته شود.
*
* @return array<int, list<int>>
*/
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();
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\ClinicService\Service;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Repository\CatalogCategoryIncludeRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* «کدام دسته‌ها زیرمجموعهٔ این دسته‌اند» — با احتساب زنجیره.
*
* «تمام بدن» شامل «نیم‌تنهٔ پایین» و آن شامل «پا» است، پس «تمام بدن» شامل «پا» هم هست.
* بدون بستار گذرا، کلینیک مجبور می‌شد همهٔ جفت‌ها را دستی بنویسد و اولین دسته‌ای که
* فراموش می‌شد یک تعارضِ ندیده می‌ساخت.
*
* پیمایش روی نگاشتی که **یک بار** از دیتابیس خوانده می‌شود انجام می‌گیرد، نه با یک
* کوئری per سطح.
*/
final class CategoryClosureResolver
{
public function __construct(private readonly CatalogCategoryIncludeRepository $includes) {}
/**
* شناسهٔ همهٔ دسته‌های زیرمجموعه (بدون خودِ دسته).
*
* @return list<int>
*/
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<int, list<int>> $map
* @return list<int>
*/
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));
}
}
@@ -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<array<string, mixed>>
*/
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
+18
View File
@@ -103,6 +103,20 @@ class ClinicResource
#[ORM\OneToMany(targetEntity: ResourceSkill::class, mappedBy: 'resource', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $skills;
/**
* دسته‌های کاتالوگ که این منبع پوشش می‌دهد — «این دستگاه برای دست و پا است».
*
* m2m چون یک دستگاه چند ناحیه را می‌گیرد، و همان دسته‌بندیِ سراسری کلینیک است که
* سرویس‌ها هم از آن استفاده می‌کنند؛ نه فهرست موازیِ مخصوص منابع.
*
* @var Collection<int, \App\ClinicService\Entity\CatalogCategory>
*/
#[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<int, ResourceServiceOffering> سرویس‌هایی که این منبع ارائه می‌دهد */
#[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<int, ResourceServiceOffering> */
public function getServiceOfferings(): Collection { return $this->serviceOfferings; }
/** @return Collection<int, \App\ClinicService\Entity\CatalogCategory> */
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; }
+3
View File
@@ -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,
+200
View File
@@ -0,0 +1,200 @@
<?php
namespace App\Tests\ClinicService;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\CatalogCategoryInclude;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\ClinicService\Service\CategoryClosureResolver;
use App\ClinicService\Service\ServiceSelectionValidator;
use App\Shared\Exception\AppException;
use App\Tests\ApiTestCase;
/**
* دسته‌بندی سراسری کلینیک و رابطهٔ «شامل بودن».
*
* «تمام بدن» شامل «دست» و «پا» است — و این با `parent` درختی گفتنی نیست، چون «دست»
* می‌تواند هم‌زمان زیر «تمام بدن» و زیر «اندام فوقانی» باشد. پس گراف است، نه درخت.
*/
class CategoryClosureTest extends ApiTestCase
{
private CategoryClosureResolver $closure;
private Clinic $clinic;
private ServiceSection $section;
protected function setUp(): void
{
parent::setUp();
$this->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,
);
}
}