Files
clinicpro/src/ClinicService/Service/CategoryClosureResolver.php
T
hamedandClaude Opus 5 6d7c54508c 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>
2026-08-01 21:43:12 +03:30

97 lines
3.7 KiB
PHP

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