feat(catalog): manage global categories from settings

Categories are the taxonomy both services and resources select from, but
until now they could only be reached through the service catalog, so every
environment ended up with its own spelling of "whole body".

- Settings > Categories page: global CRUD plus the "includes" edge
- POST/GET/DELETE /api/v1/service-category/{uuid}/includes — a DAG, kept
  separate from `parent` because "hand" sits under both "whole body" and
  "upper limb"; a cycle is refused with 422
- PUT /api/v1/resource/{uuid}/categories — full replacement, and a category
  from another environment is rejected explicitly since the uuid arrives in
  the request body where TenantFilter does not reach

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 12:58:26 +03:30
co-authored by Claude Opus 5
parent f8e8a63ae8
commit 03d8d7d68a
13 changed files with 865 additions and 0 deletions
@@ -5,6 +5,9 @@ namespace App\ClinicService\Controller;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\CatalogCategoryInclude;
use App\ClinicService\Repository\CatalogCategoryIncludeRepository;
use App\ClinicService\Service\CategoryClosureResolver;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ItemGroupMember;
use App\ClinicService\Entity\ServiceItem;
@@ -49,6 +52,8 @@ class ServiceCatalogController extends BaseController
private readonly ServiceSelectionValidator $validator,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly CatalogCategoryIncludeRepository $includes,
private readonly CategoryClosureResolver $closure,
private readonly EntityManagerInterface $em,
) {}
@@ -160,6 +165,67 @@ class ServiceCatalogController extends BaseController
return $this->success(null);
}
// ── «شامل بودن» بین دسته‌ها ─────────────────────────────────────────────
/**
* یال‌های «این دسته شامل آن دسته است» — گراف، نه درخت.
*
* جدا از `parent` است: آن سلسله‌مراتب نمایشیِ منو است و تک‌والدی، ولی «دست» باید
* هم‌زمان زیر «تمام بدن» و «اندام فوقانی» باشد.
*/
#[Route('/api/v1/service-category/{uuid}/includes', name: 'service_category_includes', methods: ['GET'])]
public function listIncludes(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$category = $this->requireCategory($user, $uuid);
return $this->success(array_map(
static fn (CatalogCategoryInclude $edge): array => $edge->getChild()->toArray(),
$this->includes->findForParent($category),
));
}
#[Route('/api/v1/service-category/{uuid}/includes', name: 'service_category_include_add', methods: ['POST'])]
public function addInclude(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$childUuid = is_array($data) && is_string($data['child_category_uuid'] ?? null) ? trim($data['child_category_uuid']) : '';
if ($childUuid === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد child_category_uuid الزامی است', 422, 'child_category_uuid');
}
$parent = $this->requireCategory($user, $uuid);
$child = $this->requireCategory($user, $childUuid);
if ($this->includes->findEdge($parent, $child) !== null) {
return $this->success($child->toArray()); // idempotent: یال تکراری خطا نیست
}
// حلقه ممنوع است، وگرنه پیمایش بستار تا سرریز استک می‌رود.
$this->closure->assertNoCycle($parent, $child);
$this->em->persist(new CatalogCategoryInclude($parent, $child));
$this->em->flush();
return $this->success($child->toArray(), 201);
}
#[Route('/api/v1/service-category/{uuid}/includes/{childUuid}', name: 'service_category_include_remove', methods: ['DELETE'])]
public function removeInclude(#[CurrentUser] User $user, string $uuid, string $childUuid): JsonResponse
{
$edge = $this->includes->findEdge(
$this->requireCategory($user, $uuid),
$this->requireCategory($user, $childUuid),
);
if ($edge !== null) {
$this->em->remove($edge);
$this->em->flush();
}
return $this->success(null);
}
// ── گروه آیتم ───────────────────────────────────────────────────────────
#[Route('/api/v1/service-item/{uuid}/groups', name: 'service_item_groups', methods: ['GET'])]
@@ -154,6 +154,29 @@ class ResourceController extends BaseController
return $this->success($this->serviceOfferings->listFor($resource));
}
/**
* دسته‌های کاتالوگ که این منبع پوشش می‌دهد — «این دستگاه برای دست و پا است».
*
* همان دسته‌بندی سراسری کلینیک است که سرویس‌ها هم از آن استفاده می‌کنند؛ اینجا فقط
* انتخاب می‌شود، ساخته نمی‌شود. جایگزینی کامل، مثل مهارت‌ها.
*/
#[Route('/api/v1/resource/{uuid}/categories', name: 'resource_categories_replace', methods: ['PUT'])]
public function replaceCategories(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['category_uuids'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد category_uuids الزامی است', 422, 'category_uuids');
}
$resource = $this->context->resource($user, $uuid);
$this->service->replaceCategories($resource, $data['category_uuids']);
return $this->success($resource->toArray());
}
/** جایگزینی کامل مهارت‌های منبع: مهارتی که در بدنه نیست، برداشته می‌شود. */
#[Route('/api/v1/resource/{uuid}/skills', name: 'resource_skills_replace', methods: ['PUT'])]
public function replaceSkills(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
+8
View File
@@ -265,6 +265,14 @@ class ClinicResource
static fn (ResourceSkill $rs): array => $rs->toArray(),
$this->skills->toArray(),
),
// دسته‌ها فقط انتخاب می‌شوند؛ ساختشان کار صفحهٔ «تنظیمات ← دسته‌بندی‌ها» است.
'categories' => array_map(
static fn (\App\ClinicService\Entity\CatalogCategory $c): array => [
'uuid' => $c->getUuid(),
'name' => $c->getName(),
],
$this->categories->toArray(),
),
'active' => $this->active,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
+38
View File
@@ -16,8 +16,46 @@ final class ResourceService
public function __construct(
private readonly EntityManagerInterface $em,
private readonly \App\ClinicService\Repository\CatalogCategoryRepository $categories,
) {}
/**
* دسته‌های این منبع — جایگزینی کامل.
*
* دسته فقط **انتخاب** می‌شود؛ ساختش کار صفحهٔ «تنظیمات ← دسته‌بندی‌ها» است. دستهٔ
* محیط دیگر رد می‌شود چون uuid از بدنهٔ درخواست می‌آید و `TenantFilter` پوششش نمی‌دهد.
*
* @param list<mixed> $categoryUuids
* @throws AppException ۴۲۲ روی دستهٔ ناموجود یا دستهٔ محیط دیگر
*/
public function replaceCategories(ClinicResource $resource, array $categoryUuids): void
{
$chosen = [];
foreach ($categoryUuids as $uuid) {
if (!is_string($uuid) || trim($uuid) === '') {
continue;
}
$category = $this->categories->findOneBy(['uuid' => trim($uuid)]);
if ($category === null
|| $category->getEntityType() !== $resource->getEntityType()
|| $category->getEntityId() !== $resource->getEntityId()) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'دسته‌بندی یافت نشد', 422, 'category_uuids');
}
$chosen[(int) $category->getId()] = $category;
}
$resource->getCategories()->clear();
foreach ($chosen as $category) {
$resource->getCategories()->add($category);
}
$this->em->flush();
}
/** @param array<string, mixed> $data */
public function create(DoctorAddress $address, ResourceType $type, array $data): ClinicResource
{