feat(services): pick a global category from the service page

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 13:16:53 +03:30
co-authored by Claude Opus 5
parent e08876f7b4
commit f11514950e
6 changed files with 274 additions and 0 deletions
@@ -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<string | null>(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<ApiResponse<unknown>>(`/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 (
<div className="card card-pad" style={{ display: 'grid', gap: 14 }}>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
دستهبندی سراسری کلینیک است و اینجا فقط انتخاب میشود. ساخت، ویرایش و حذف فقط در{' '}
<Link to="/admin/service-categories" style={{ color: 'var(--primary)' }}>تنظیمات دستهبندیها</Link>{' '}
انجام میشود.
</p>
{loading ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
) : all.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
هنوز هیچ دستهبندیای تعریف نشده است.
</p>
) : (
<div style={{ display: 'grid', gap: 6, maxWidth: 380 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>دستهبندی این سرویس</label>
<SearchableSelect
options={all.map((c) => ({ value: c.uuid, label: '— '.repeat(c.depth) + c.name }))}
value={selected}
onChange={(v) => setSelected(v ? String(v) : null)}
placeholder="بدون دسته‌بندی"
isDisabled={!canEdit}
isClearable
height={38}
/>
</div>
)}
{selected && includes.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>این دسته شامل:</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{includes.map((c) => (
<span key={c.uuid} className="badge gray" style={{ fontSize: 11 }}>{c.name}</span>
))}
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
انتخاب همزمان این سرویس با سرویسی از این زیرمجموعهها هنگام رزرو رد میشود.
</span>
</div>
)}
{canEdit && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn primary"
disabled={save.isPending || selected === categoryUuid}
onClick={() => save.mutate(selected)}
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
)}
</div>
);
}
+9
View File
@@ -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' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} canUpdate={canUpdate} />}
{tab === 'groups' && <ServiceGroupsTab serviceUuid={item.uuid} canEdit={canUpdate} />}
{tab === 'segments' && <ServiceSegmentsTab serviceUuid={item.uuid} canEdit={canUpdate} />}
{tab === 'categories' && (
<ServiceCategoryTab
serviceUuid={item.uuid}
categoryUuid={item.catalog_category_uuid ?? null}
canEdit={canUpdate}
/>
)}
{tab === 'goods' && <GoodsTab item={item} onEdit={() => setEditOpen(true)} canUpdate={canUpdate} />}
{tab === 'history' && <HistoryTab item={item} />}
+2
View File
@@ -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;
}
+17
View File
@@ -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` سرویس این فیلد را می‌پذیرد.
| مقدار | اثر |
|---|---|
| `"<uuid>"` | دسته ست می‌شود |
| `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).
@@ -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;
@@ -0,0 +1,101 @@
<?php
namespace App\Tests\ClinicService;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* تب «دسته‌بندی‌ها»ی صفحهٔ سرویس فقط **انتخاب** می‌کند و همین `PATCH` پشتش است.
*
* دسته ساخته نمی‌شود؛ ساختش فقط از «تنظیمات ← دسته‌بندی‌ها» است. اینجا اثبات می‌شود که
* انتخاب می‌نشیند، پاک می‌شود، و دستهٔ محیط دیگر رد می‌شود.
*/
class ServiceItemCatalogCategoryTest extends ApiTestCase
{
/** @return array{0: User, 1: ServiceItem} */
private function doctorWithItem(): array
{
$owner = $this->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());
}
}