From f11514950e641dd08cac88f410c41d5556a8c6a7 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 2 Aug 2026 13:16:53 +0330 Subject: [PATCH] feat(services): pick a global category from the service page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service page could not say which category a service belongs to, so the containment edges defined in settings had nothing to match against. A Categories tab now selects one — and only selects. Creating, renaming and deleting stay in Settings > Categories: if every page could create one, "whole body" would exist three times with three spellings and the includes edge would stop catching anything. PATCH /api/v1/service-item/{uuid} carries the choice as catalog_category_uuid. Absent field leaves the current category alone, null clears it, and a category from another environment is refused with 422 — the uuid arrives in the request body where TenantFilter does not reach. Co-Authored-By: Claude Opus 5 --- .../admin/components/ServiceCategoryTab.tsx | 107 ++++++++++++++++++ assets/admin/pages/ServiceDetailPage.tsx | 9 ++ assets/admin/types/index.ts | 2 + docs/api/clinic-services.md | 17 +++ .../Controller/ClinicServiceController.php | 38 +++++++ .../ServiceItemCatalogCategoryTest.php | 101 +++++++++++++++++ 6 files changed, 274 insertions(+) create mode 100644 assets/admin/components/ServiceCategoryTab.tsx create mode 100644 tests/ClinicService/ServiceItemCatalogCategoryTest.php diff --git a/assets/admin/components/ServiceCategoryTab.tsx b/assets/admin/components/ServiceCategoryTab.tsx new file mode 100644 index 00000000..b04f0ac7 --- /dev/null +++ b/assets/admin/components/ServiceCategoryTab.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import SearchableSelect from './ui/SearchableSelect'; +import { api, ApiError, type ApiResponse } from '../lib/api'; +import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories'; +import type { CatalogCategory } from '../types'; + +type Flat = { uuid: string; name: string; depth: number }; + +function flatten(nodes: CatalogCategory[], depth = 0): Flat[] { + return nodes.flatMap((n) => [ + { uuid: n.uuid, name: n.name, depth }, + ...flatten(n.children ?? [], depth + 1), + ]); +} + +/** + * دستهٔ یک سرویس — فقط **انتخاب** از کاتالوگ سراسری. + * + * ساخت، ویرایش و حذف دسته عمداً اینجا نیست؛ فقط در «تنظیمات ← دسته‌بندی‌ها». اگر هر + * صفحه‌ای بتواند دسته بسازد، «تمام بدن» چند بار با املاهای مختلف ساخته می‌شود و یال + * «شامل بودن» دیگر چیزی را نمی‌گیرد. + */ +export default function ServiceCategoryTab({ serviceUuid, categoryUuid, canEdit }: { + serviceUuid: string; + categoryUuid: string | null; + canEdit: boolean; +}) { + const qc = useQueryClient(); + const { tree, loading } = useCatalogCategories(); + const [selected, setSelected] = useState(categoryUuid); + + useEffect(() => setSelected(categoryUuid), [categoryUuid]); + + const all = useMemo(() => flatten(tree), [tree]); + const { includes } = useCategoryIncludes(selected ?? undefined); + + const save = useMutation({ + mutationFn: (uuid: string | null) => + api.patch>(`/api/v1/service-item/${serviceUuid}`, { catalog_category_uuid: uuid }), + onSuccess: () => { + toast.success('دسته‌بندی سرویس ذخیره شد'); + qc.invalidateQueries({ queryKey: ['service-item', serviceUuid] }); + }, + onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ دسته‌بندی ناموفق بود'), + }); + + return ( +
+

+ دسته‌بندی سراسری کلینیک است و اینجا فقط انتخاب می‌شود. ساخت، ویرایش و حذف فقط در{' '} + تنظیمات ← دسته‌بندی‌ها{' '} + انجام می‌شود. +

+ + {loading ? ( + در حال بارگذاری... + ) : all.length === 0 ? ( +

+ هنوز هیچ دسته‌بندی‌ای تعریف نشده است. +

+ ) : ( +
+ + ({ value: c.uuid, label: '— '.repeat(c.depth) + c.name }))} + value={selected} + onChange={(v) => setSelected(v ? String(v) : null)} + placeholder="بدون دسته‌بندی" + isDisabled={!canEdit} + isClearable + height={38} + /> +
+ )} + + {selected && includes.length > 0 && ( +
+ این دسته شامل: +
+ {includes.map((c) => ( + {c.name} + ))} +
+ + انتخاب هم‌زمان این سرویس با سرویسی از این زیرمجموعه‌ها هنگام رزرو رد می‌شود. + +
+ )} + + {canEdit && ( +
+ +
+ )} +
+ ); +} diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index 8cf7640e..269ca232 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -20,6 +20,7 @@ import ServiceInsuranceModal from '../components/ServiceInsuranceModal'; import ServiceItemFormModal from '../components/ServiceItemFormModal'; import ServiceGroupsTab from '../components/ServiceGroupsTab'; import ServiceSegmentsTab from '../components/ServiceSegmentsTab'; +import ServiceCategoryTab from '../components/ServiceCategoryTab'; interface Tariff { uuid: string; @@ -55,6 +56,7 @@ const TABS = [ { id: 'insurance', label: 'بیمه‌ها' }, { id: 'groups', label: 'گروه‌ها و آیتم‌ها' }, { id: 'segments', label: 'بخش‌های نوبت' }, + { id: 'categories',label: 'دسته‌بندی‌ها' }, { id: 'goods', label: 'کالاهای مرتبط' }, { id: 'history', label: 'لاگ تغییرات' }, ] as const; @@ -543,6 +545,13 @@ function ServiceDetailPageInner() { {tab === 'insurance' && setInsuranceOpen(true)} canUpdate={canUpdate} />} {tab === 'groups' && } {tab === 'segments' && } + {tab === 'categories' && ( + + )} {tab === 'goods' && setEditOpen(true)} canUpdate={canUpdate} />} {tab === 'history' && } diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 97b477bf..dd2911a1 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -719,6 +719,8 @@ export interface ServiceItem { /** نوع خدمت (سرپایی/بستری) — درصد پوشش بیمه بر همین اساس انتخاب می‌شود. */ service_category?: string; service_category_label?: string; + /** دستهٔ کاتالوگ سراسری — با `service_category` (enum بیمه‌ای) اشتباه نشود. */ + catalog_category_uuid?: string | null; duration_minutes?: number | null; bookable?: boolean; } diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index 161b5a53..cceefd22 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.md @@ -551,6 +551,23 @@ override فقط وقتی اعمال می‌شود که `branch_uuid` به `valid خطاها: `404` دستهٔ نامعتبر · `422` نبودِ `child_category_uuid`، حلقه، یا دستهٔ محیط دیگر. +### دستهٔ سرویس — `catalog_category_uuid` روی `POST/PATCH /api/v1/service-item[/{uuid}]` + +سرویس **یک** دسته دارد (`ManyToOne`)، برخلاف منبع که چند دسته می‌گیرد؛ اندپوینت جدایی +ندارد و همان `PATCH` سرویس این فیلد را می‌پذیرد. + +| مقدار | اثر | +|---|---| +| `""` | دسته ست می‌شود | +| `null` یا `""` | دسته پاک می‌شود | +| فیلد در بدنه نباشد | دستهٔ فعلی دست‌نخورده می‌ماند | + +پاسخ ۲۰۰ کلِ سرویس است و `catalog_category_uuid` را برمی‌گرداند. **۴۲۲:** دستهٔ محیط دیگر +(`{"code":"ERR_VALIDATION_002","message":"دسته‌بندی یافت نشد","field":"catalog_category_uuid"}`). + +در پنل، تب «دسته‌بندی‌ها»ی `/admin/service/{uuid}` فقط همین انتخاب را انجام می‌دهد؛ دکمهٔ +ساخت دسته عمداً آنجا نیست. + ### دستهٔ منبع — `PUT /api/v1/resource/{uuid}/categories` مستند کامل در [`resource.md`](resource.md). diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index acce02a6..4f8dd3c1 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -12,6 +12,7 @@ use App\ClinicService\Entity\Tariff; use App\Clinic\Security\ClinicDoctorAccessChecker; use App\Secretary\Security\SecretaryAccessChecker; use Doctrine\ORM\EntityManagerInterface; +use App\ClinicService\Repository\CatalogCategoryRepository; use App\ClinicService\Repository\ServiceItemAuditLogRepository; use App\ClinicService\Repository\ServiceItemRepository; use App\ClinicService\Repository\ServiceSectionRepository; @@ -55,6 +56,7 @@ class ClinicServiceController extends BaseController private readonly RequestStack $requestStack, private readonly SecretaryAccessChecker $secretaryAccess, private readonly ClinicDoctorAccessChecker $clinicDoctorAccess, + private readonly CatalogCategoryRepository $catalogCategoryRepo, ) {} /** گِیتِ ترکیبی: منشی + پزشکِ عضوِ کلینیک (هرکدام فقط نقشِ خودش را محدود می‌کند). */ @@ -120,6 +122,36 @@ class ClinicServiceController extends BaseController return null; } + /** + * دستهٔ کاتالوگ سرویس — از دستهٔ **سراسری** محیط انتخاب می‌شود. + * + * uuid از بدنهٔ درخواست می‌آید و `TenantFilter` رویش اعمال نمی‌شود، پس محیط دسته صریحاً + * با محیط کاربر مقایسه می‌شود. `null` یعنی «بدون دسته»، پس با `array_key_exists` + * تشخیص داده می‌شود نه `isset`. + */ + private function applyCatalogCategory(ServiceItem $item, array $data, string $entityType, ?int $entityId): ?JsonResponse + { + if (!array_key_exists('catalog_category_uuid', $data)) { + return null; + } + + $uuid = $data['catalog_category_uuid']; + if ($uuid === null || $uuid === '') { + $item->setCatalogCategory(null); + + return null; + } + + $category = $this->catalogCategoryRepo->findByUuid((string) $uuid); + if ($category === null || $category->getEntityType() !== $entityType || $category->getEntityId() !== $entityId) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دسته‌بندی یافت نشد', 422, 'catalog_category_uuid'); + } + + $item->setCatalogCategory($category); + + return null; + } + /** نوع خدمت (سرپایی/بستری) را ست می‌کند؛ مقدار نامعتبر ۴۲۲ می‌دهد. */ private function applyServiceCategory(ServiceItem $item, array $data): ?JsonResponse { @@ -380,6 +412,9 @@ class ClinicServiceController extends BaseController if (($categoryError = $this->applyServiceCategory($item, $data)) !== null) { return $categoryError; } + if (($catalogError = $this->applyCatalogCategory($item, $data, $entityType, $entityId)) !== null) { + return $catalogError; + } $packageError = $this->applyInventoryPackage($item, $data, $entityType, $entityId); if ($packageError !== null) { return $packageError; @@ -435,6 +470,9 @@ class ClinicServiceController extends BaseController if (($categoryError = $this->applyServiceCategory($item, $data)) !== null) { return $categoryError; } + if (($catalogError = $this->applyCatalogCategory($item, $data, $entityType, $entityId)) !== null) { + return $catalogError; + } $packageError = $this->applyInventoryPackage($item, $data, $entityType, $entityId); if ($packageError !== null) { return $packageError; diff --git a/tests/ClinicService/ServiceItemCatalogCategoryTest.php b/tests/ClinicService/ServiceItemCatalogCategoryTest.php new file mode 100644 index 00000000..49309965 --- /dev/null +++ b/tests/ClinicService/ServiceItemCatalogCategoryTest.php @@ -0,0 +1,101 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر دسته‌بندی'); + $this->em->persist($doctor); + $this->em->flush(); + + $section = new ServiceSection('doctor', $doctor->getId(), 'لیزر'); + $item = new ServiceItem($section, 'لیزر دست'); + $item->setPriceRials(5_000_000); + $this->em->persist($section); + $this->em->persist($item); + $this->em->flush(); + + return [$owner, $item]; + } + + private function category(User $user, string $name): string + { + $body = $this->authJson('POST', '/api/v1/service-category', $user, ['name' => $name]); + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + return $body['data']['uuid']; + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testAServiceTakesAGlobalCategory(): void + { + [$owner, $item] = $this->doctorWithItem(); + $hand = $this->category($owner, 'دست'); + + $body = $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'catalog_category_uuid' => $hand, + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame($hand, $body['data']['catalog_category_uuid']); + } + + public function testNullClearsTheCategory(): void + { + [$owner, $item] = $this->doctorWithItem(); + $hand = $this->category($owner, 'دست'); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, ['catalog_category_uuid' => $hand]); + $body = $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, ['catalog_category_uuid' => null]); + + self::assertSame(200, $this->responseCode()); + self::assertNull($body['data']['catalog_category_uuid']); + } + + // ── ⚠️ مرزی ───────────────────────────────────────────────────────────── + + public function testNotSendingTheFieldLeavesTheCategoryAlone(): void + { + [$owner, $item] = $this->doctorWithItem(); + $hand = $this->category($owner, 'دست'); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, ['catalog_category_uuid' => $hand]); + // فیلد در بدنه نیست: ویرایش نام نباید دسته را پاک کند. + $body = $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, ['name' => 'لیزر دست کامل']); + + self::assertSame($hand, $body['data']['catalog_category_uuid']); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testACategoryFromAnotherEnvironmentIsRefused(): void + { + [$owner, $item] = $this->doctorWithItem(); + [$stranger] = $this->doctorWithItem(); + + $foreign = $this->category($stranger, 'دستهٔ پزشک دیگر'); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'catalog_category_uuid' => $foreign, + ]); + + self::assertSame(422, $this->responseCode()); + } +}