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
@@ -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,