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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user