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
+2
View File
@@ -81,6 +81,7 @@ import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage';
import BranchRoomsPage from './pages/BranchRoomsPage';
import ResourcesPage from './pages/ResourcesPage';
import ResourceTypesPage from './pages/ResourceTypesPage';
import CatalogCategoriesPage from './pages/CatalogCategoriesPage';
import SkillsPage from './pages/SkillsPage';
import ResourcePoolsPage from './pages/ResourcePoolsPage';
import ResourceCalendarPage from './pages/ResourceCalendarPage';
@@ -286,6 +287,7 @@ export default function App() {
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ClinicServicesPage /></RoleRoute>} />
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
<Route path="branches" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchesPage /></RoleRoute>} />
<Route path="branches/:branchUuid/working-hours" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchWorkingHoursPage /></RoleRoute>} />
<Route path="branches/:branchUuid/rooms" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchRoomsPage /></RoleRoute>} />
@@ -31,6 +31,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
{ key: 'branches', label: 'شعبه‌ها و اتاق‌ها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'service-categories', label: 'دسته‌بندی‌ها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'price-lists', label: 'لیست‌های قیمت', icon: BanknotesIcon, to: '/admin/price-lists', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
@@ -0,0 +1,87 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { CatalogCategory } from '../types';
/**
* دستهٔ **سراسری** کلینیک — یک بار در تنظیمات تعریف می‌شود و سرویس و منبع از همان
* استفاده می‌کنند.
*
* ⚠️ با `useServiceCategories` اشتباه نشود: آن یکی enum نوع خدمت (سرپایی/بستری) است و
* هم‌نام بودنشان تصادفی است.
*/
const KEY = ['catalog-categories'];
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function useCatalogCategories() {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: KEY });
const query = useQuery({
queryKey: KEY,
queryFn: () => api.get<ApiResponse<CatalogCategory[]>>('/api/v1/service-categories/tree'),
staleTime: 30_000,
});
const create = useMutation({
mutationFn: (d: { name: string; parent_uuid?: string | null }) =>
api.post<ApiResponse<CatalogCategory>>('/api/v1/service-category', d),
onSuccess: () => { toast.success('دسته‌بندی افزوده شد'); invalidate(); },
onError: (e) => fail(e, 'افزودن دسته‌بندی ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; sort_order?: number; active?: boolean } }) =>
api.patch<ApiResponse<CatalogCategory>>(`/api/v1/service-category/${uuid}`, d),
onSuccess: () => { toast.success('دسته‌بندی به‌روزرسانی شد'); invalidate(); },
onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/service-category/${uuid}`),
// پیام سرور دقیق است («ابتدا زیردسته‌ها را حذف کنید»)، پس همان نشان داده می‌شود.
onSuccess: () => { toast.success('دسته‌بندی حذف شد'); invalidate(); },
onError: (e) => fail(e, 'حذف دسته‌بندی ناموفق بود'),
});
return { tree: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
}
/**
* یال‌های «این دسته شامل آن دسته است».
*
* جدا از `parent` است: آن سلسله‌مراتب نمایشیِ منوست، این می‌گوید انتخاب هم‌زمان دو
* سرویس از این دو دسته تعارض دارد («تمام بدن» و «دست»).
*/
export function useCategoryIncludes(categoryUuid?: string) {
const qc = useQueryClient();
const key = ['category-includes', categoryUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<CatalogCategory[]>>(`/api/v1/service-category/${categoryUuid}/includes`),
enabled: !!categoryUuid,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['category-includes'] });
const add = useMutation({
mutationFn: ({ uuid, childUuid }: { uuid: string; childUuid: string }) =>
api.post<ApiResponse<CatalogCategory>>(`/api/v1/service-category/${uuid}/includes`, { child_category_uuid: childUuid }),
onSuccess: () => { toast.success('زیرمجموعه افزوده شد'); invalidate(); },
// پیام سرور می‌گوید کدام دو دسته حلقه می‌سازند.
onError: (e) => fail(e, 'افزودن زیرمجموعه ناموفق بود'),
});
const remove = useMutation({
mutationFn: ({ uuid, childUuid }: { uuid: string; childUuid: string }) =>
api.delete<ApiResponse<null>>(`/api/v1/service-category/${uuid}/includes/${childUuid}`),
onSuccess: () => { toast.success('زیرمجموعه حذف شد'); invalidate(); },
onError: (e) => fail(e, 'حذف زیرمجموعه ناموفق بود'),
});
return { includes: query.data?.data ?? [], loading: query.isLoading, add, remove };
}
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) }));
const create = { mutate: vi.fn(), isPending: false };
const update = { mutate: vi.fn(), isPending: false };
const remove = { mutate: vi.fn(), isPending: false };
const addEdge = { mutate: vi.fn(), isPending: false };
const removeEdge = { mutate: vi.fn(), isPending: false };
const tree = [
{
uuid: 'c-whole',
name: 'تمام بدن',
sort_order: 0,
active: true,
children: [{ uuid: 'c-hand', name: 'دست', sort_order: 1, active: true, children: [] }],
},
];
let includes: Array<{ uuid: string; name: string }> = [];
vi.mock('../hooks/useCatalogCategories', () => ({
useCatalogCategories: () => ({ tree, loading: false, create, update, remove }),
useCategoryIncludes: () => ({ includes, loading: false, add: addEdge, remove: removeEdge }),
}));
import CatalogCategoriesPage from './CatalogCategoriesPage';
describe('CatalogCategoriesPage', () => {
beforeEach(() => {
includes = [];
vi.clearAllMocks();
});
it('درخت را تخت نشان می‌دهد — زیردسته هم ردیف خودش را دارد', () => {
renderWithProviders(<CatalogCategoriesPage />);
expect(screen.getByText('تمام بدن')).toBeInTheDocument();
expect(screen.getByText('دست')).toBeInTheDocument();
});
it('ساخت دسته، نام را به API می‌دهد', async () => {
const user = userEvent.setup();
renderWithProviders(<CatalogCategoriesPage />);
await user.click(screen.getByRole('button', { name: /افزودن دسته‌بندی/ }));
await user.type(screen.getByLabelText('نام دسته‌بندی'), 'پا');
await user.click(screen.getByRole('button', { name: 'ذخیره' }));
expect(create.mutate).toHaveBeenCalledWith(
expect.objectContaining({ name: 'پا' }),
expect.anything(),
);
});
it('دکمهٔ ذخیره با نام خالی غیرفعال است', async () => {
const user = userEvent.setup();
renderWithProviders(<CatalogCategoriesPage />);
await user.click(screen.getByRole('button', { name: /افزودن دسته‌بندی/ }));
expect(screen.getByRole('button', { name: 'ذخیره' })).toBeDisabled();
});
it('یال «شامل بودن» را با دکمهٔ حذف برمی‌دارد', async () => {
includes = [{ uuid: 'c-hand', name: 'دست' }];
const user = userEvent.setup();
renderWithProviders(<CatalogCategoriesPage />);
await user.click(screen.getAllByRole('button', { name: 'شامل' })[0]);
await user.click(screen.getByRole('button', { name: 'حذف دست' }));
expect(removeEdge.mutate).toHaveBeenCalledWith({ uuid: 'c-whole', childUuid: 'c-hand' });
});
});
@@ -0,0 +1,292 @@
import React, { useMemo, useState } from 'react';
import { PlusIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
import type { CatalogCategory } from '../types';
/** یک ردیف از درخت، صاف‌شده — با عمق، تا تورفتگی نشان دهد کجای درخت است. */
type Row = CatalogCategory & { depth: number };
function flatten(nodes: CatalogCategory[], depth = 0): Row[] {
return nodes.flatMap((n) => [{ ...n, depth }, ...flatten(n.children ?? [], depth + 1)]);
}
/**
* دسته‌بندی سراسری کلینیک — تنها جایی که دسته ساخته، ویرایش و حذف می‌شود.
*
* صفحهٔ سرویس و منبع فقط از همین‌ها **انتخاب** می‌کنند؛ ساختن دسته از آن‌جا عمداً ممکن
* نیست، وگرنه هر کاربر دستهٔ تکراری با املای خودش می‌سازد و «تمام بدن» دو بار وجود
* خواهد داشت.
*/
export default function CatalogCategoriesPage() {
const { tree, loading, create, update, remove } = useCatalogCategories();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [editing, setEditing] = useState<{ open: boolean; category: CatalogCategory | null }>({ open: false, category: null });
const [includesFor, setIncludesFor] = useState<CatalogCategory | null>(null);
const [toDelete, setToDelete] = useState<CatalogCategory | null>(null);
const all = useMemo(() => flatten(tree), [tree]);
const rows = useMemo(() => {
const q = urlState.search.trim();
return q === '' ? all : all.filter((c) => c.name.includes(q));
}, [all, urlState.search]);
const columns: Column<Row>[] = [
{
key: 'name',
header: 'دسته‌بندی',
render: (c) => (
<span style={{ paddingRight: c.depth * 18, fontWeight: c.depth === 0 ? 600 : 400 }}>
{c.depth > 0 && <span style={{ color: 'var(--text-3)' }}> </span>}
{c.name}
</span>
),
},
{ key: 'sort_order', header: 'ترتیب', render: (c) => <span style={{ fontSize: 13 }}>{c.sort_order ?? 0}</span> },
{ key: 'active', header: 'وضعیت', render: (c) => <ActiveBadge active={c.active} /> },
];
return (
<div className="fade-in">
<PageHeader
title="دسته‌بندی‌ها"
description="دسته‌بندی سراسری کلینیک؛ یک بار تعریف می‌شود و سرویس‌ها و منابع از همین‌ها انتخاب می‌کنند."
backTo="/admin/settings-menu"
action={
canUpdate ? (
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, category: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن دستهبندی
</button>
) : undefined
}
/>
<DataTable<Row>
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در دسته‌بندی‌ها..."
emptyMessage="هنوز دسته‌بندی‌ای تعریف نشده است"
actions={
canUpdate
? (c) => (
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, category: c })}>
ویرایش
</button>
<button type="button" className="btn secondary sm" onClick={() => setIncludesFor(c)}>
شامل
</button>
<button type="button" className="btn secondary sm" onClick={() => setToDelete(c)}>
حذف
</button>
</div>
)
: undefined
}
/>
<CategoryFormModal
open={editing.open}
category={editing.category}
parents={all}
saving={create.isPending || update.isPending}
onClose={() => setEditing({ open: false, category: null })}
onSave={(payload) => {
const done = { onSuccess: () => setEditing({ open: false, category: null }) };
if (editing.category) {
update.mutate({ uuid: editing.category.uuid, d: { name: payload.name, sort_order: payload.sort_order, active: payload.active } }, done);
} else {
create.mutate({ name: payload.name, parent_uuid: payload.parent_uuid }, done);
}
}}
/>
<CategoryIncludesModal
category={includesFor}
all={all}
onClose={() => setIncludesFor(null)}
/>
<ConfirmDialog
open={!!toDelete}
title="حذف دسته‌بندی"
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟ سرویس‌ها و منابعی که این دسته را دارند بدون دسته می‌مانند.`}
confirmLabel="حذف"
danger
loading={remove.isPending}
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
onCancel={() => setToDelete(null)}
/>
</div>
);
}
function CategoryFormModal({
open, category, parents, saving, onClose, onSave,
}: {
open: boolean;
category: CatalogCategory | null;
parents: Row[];
saving: boolean;
onClose: () => void;
onSave: (payload: { name: string; parent_uuid: string | null; sort_order: number; active: boolean }) => void;
}) {
const [name, setName] = useState('');
const [parent, setParent] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState('0');
const [active, setActive] = useState(true);
React.useEffect(() => {
if (!open) return;
setName(category?.name ?? '');
setParent(null);
setSortOrder(String(category?.sort_order ?? 0));
setActive(category?.active ?? true);
}, [open, category]);
return (
<Modal open={open} onClose={onClose} title={category ? `ویرایش «${category.name}»` : 'افزودن دسته‌بندی'}>
<div style={{ display: 'grid', gap: 14 }}>
<div className="field-block">
<label htmlFor="category-name">نام دستهبندی</label>
<input
id="category-name"
className="field"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً: تمام بدن"
/>
</div>
{!category && (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>زیرمجموعهٔ (اختیاری)</label>
<SearchableSelect
options={parents.map((p) => ({ value: p.uuid, label: '— '.repeat(p.depth) + p.name }))}
value={parent}
onChange={(v) => setParent(v ? String(v) : null)}
placeholder="بدون والد — دستهٔ سطح اول"
height={38}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
این فقط جای دسته در منوست. «شامل بودن» مثل «تمام بدن شامل دست» از دکمهٔ «شامل» تعریف میشود.
</span>
</div>
)}
<div style={{ display: 'flex', gap: 10 }}>
<div className="field-block" style={{ flex: 1 }}>
<label htmlFor="category-sort">ترتیب نمایش</label>
<input
id="category-sort"
className="field"
inputMode="numeric"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--text-2)' }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
فعال
</label>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button
type="button"
className="btn primary"
disabled={saving || name.trim() === ''}
onClick={() => onSave({
name: name.trim(),
parent_uuid: parent,
sort_order: Number(sortOrder) || 0,
active,
})}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</Modal>
);
}
/** «این دسته شامل کدام دسته‌هاست» — گراف، جدا از درختِ منو. */
function CategoryIncludesModal({
category, all, onClose,
}: {
category: CatalogCategory | null;
all: Row[];
onClose: () => void;
}) {
const { includes, add, remove } = useCategoryIncludes(category?.uuid);
const chosen = new Set(includes.map((c) => c.uuid));
const available = all.filter((c) => c.uuid !== category?.uuid && !chosen.has(c.uuid));
return (
<Modal open={category !== null} onClose={onClose} title={`«${category?.name ?? ''}» شامل چه دسته‌هایی است`}>
<div style={{ display: 'grid', gap: 14 }}>
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0, lineHeight: 1.9 }}>
انتخاب همزمان دو سرویس که دستهٔ یکی دیگری را در بر میگیرد، هنگام رزرو رد میشود
مثلاً «لیزر تمام بدن» با «لیزر دست».
</p>
{includes.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>این دسته زیرمجموعهای ندارد.</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{includes.map((child) => (
<div key={child.uuid} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{child.name}</span>
<button
type="button"
className="btn secondary sm"
disabled={remove.isPending}
onClick={() => category && remove.mutate({ uuid: category.uuid, childUuid: child.uuid })}
aria-label={`حذف ${child.name}`}
>
</button>
</div>
))}
</div>
)}
{available.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن زیرمجموعه</label>
<SearchableSelect
options={available.map((c) => ({ value: c.uuid, label: '— '.repeat(c.depth) + c.name }))}
value={null}
onChange={(v) => v && category && add.mutate({ uuid: category.uuid, childUuid: String(v) })}
placeholder="یک دسته انتخاب کنید"
height={38}
/>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button type="button" className="btn secondary" onClick={onClose}>بستن</button>
</div>
</div>
</Modal>
);
}
+9
View File
@@ -1260,3 +1260,12 @@ export interface ServiceItemOption {
duration_minutes?: number | null;
price_rials?: number | null;
}
/** دستهٔ سراسری کاتالوگ — یک بار در تنظیمات تعریف می‌شود و همه‌جا انتخاب می‌شود. */
export interface CatalogCategory {
uuid: string;
name: string;
sort_order?: number;
active: boolean;
children?: CatalogCategory[];
}
+56
View File
@@ -499,6 +499,62 @@ override فقط وقتی اعمال می‌شود که `branch_uuid` به `valid
است (`outpatient`/`inpatient`) که روی خودِ `ServiceItem` هم نشسته. با `ServiceSection` هم
فرق دارد — آن «بخش کلینیک» است، این تاکسونومی کاتالوگ. عمق حداکثر ۴ سطح.
دسته **سراسری** است: یک بار در «تنظیمات ← دسته‌بندی‌ها» (`/admin/service-categories`) ساخته
می‌شود و سرویس و منبع فقط از همان انتخاب می‌کنند. صفحهٔ سرویس و منبع عمداً امکان ساخت دسته
ندارند، وگرنه هر کاربر «تمام بدن» خودش را با املای خودش می‌سازد.
### یال «شامل بودن» — `CatalogCategoryInclude`
جدا از `parent`. `parent` سلسله‌مراتب نمایشی است و هر دسته فقط یک والد دارد؛ یال شامل‌بودن یک
**DAG** است، چون «دست» هم زیر «تمام بدن» است و هم زیر «اندام فوقانی». موتور انتخاب سرویس از
همین یال‌ها استفاده می‌کند تا رزرو هم‌زمان «لیزر تمام بدن» و «لیزر دست» را رد کند.
| متد | مسیر | مجوز |
|---|---|---|
| GET | `/api/v1/service-category/{uuid}/includes` | `appointment_settings.view` |
| POST | `/api/v1/service-category/{uuid}/includes` | `appointment_settings.update` |
| DELETE | `/api/v1/service-category/{uuid}/includes/{childUuid}` | `appointment_settings.update` |
**POST body:** `{ "child_category_uuid": "<uuid>" }` — الزامی.
```jsonc
// POST /api/v1/service-category/{whole}/includes → 201 (تکرار همان یال → 200، نه خطا)
{
"success": true,
"data": {
"uuid": "f06c1e09-00d8-4f6f-b63b-c41efd5291f1",
"parent_uuid": null,
"name": "دست (doc)",
"sort_order": 0,
"active": true,
"children": []
}
}
// GET → آرایه‌ای از همان شکل
// DELETE → { "success": true, "data": null }
```
حلقه رد می‌شود — «تمام بدن شامل دست» و بعد «دست شامل تمام بدن» → **422**:
```json
{
"success": false,
"data": null,
"errors": [{
"code": "ERR_VALIDATION_001",
"message": "«دست (doc)» از قبل زیرمجموعهٔ «تمام بدن (doc)» است؛ این دو نمی‌توانند شامل هم باشند",
"field": "child_category_uuid"
}]
}
```
خطاها: `404` دستهٔ نامعتبر · `422` نبودِ `child_category_uuid`، حلقه، یا دستهٔ محیط دیگر.
### دستهٔ منبع — `PUT /api/v1/resource/{uuid}/categories`
مستند کامل در [`resource.md`](resource.md).
## تست‌ها
```bash
+33
View File
@@ -335,6 +335,39 @@
> **اتمی است.** اعتبارسنجی کل فهرست پیش از هر حذفی انجام می‌شود، پس یک ردیف نامعتبر
> در انتهای فهرست، مهارت‌های درستِ قبلی را پاک نمی‌کند و بعد ۴۲۲ برگرداند.
### `PUT /api/v1/resource/{uuid}/categories`
مجوز: `appointment_settings.update`.
دستهٔ منبع از **کاتالوگ سراسری** انتخاب می‌شود (`CatalogCategory`) — همان دسته‌هایی که سرویس
هم از آن‌ها استفاده می‌کند. ساخت دسته اینجا ممکن نیست؛ فقط در «تنظیمات ← دسته‌بندی‌ها»
([`clinic-services.md`](clinic-services.md)).
جایگزینی **کامل**، مثل `skills`: `{"category_uuids":[]}` همه را پاک می‌کند.
```json
{ "category_uuids": ["8ae755b5-5f27-404e-9b63-1b29693a9039"] }
```
پاسخ ۲۰۰ کلِ منبع است؛ بخش `categories` آن (خروجی واقعی):
```json
{
"success": true,
"data": {
"uuid": "ce070910-7038-4f50-9f7a-1b35ec1a67f7",
"name": "اتاق ۱",
"categories": [
{ "uuid": "8ae755b5-5f27-404e-9b63-1b29693a9039", "name": "دست (doc)" }
]
}
}
```
**۴۲۲:** نبودِ `category_uuids` (`{"code":"ERR_VALIDATION_002","message":"فیلد category_uuids الزامی است","field":"category_uuids"}`)
· دستهٔ محیط دیگر. uuid از بدنهٔ درخواست می‌آید و `TenantFilter` رویش اعمال نمی‌شود، پس محیطِ
هر دسته صریحاً با محیط منبع مقایسه می‌شود.
---
## استخر منابع
@@ -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
{
@@ -0,0 +1,170 @@
<?php
namespace App\Tests\ClinicService;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\CatalogCategory;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* مدیریت دستهٔ **سراسری** از تنظیمات: ساخت، یال «شامل بودن»، و تخصیص به منبع.
*
* دسته یک بار در سطح کلینیک تعریف می‌شود و همه‌جا — سرویس و منبع — از همان استفاده
* می‌کنند. صفحهٔ سرویس و منبع فقط **انتخاب** می‌کنند.
*/
class CategoryManagementEndpointTest extends ApiTestCase
{
/** @return array{0: User, 1: Clinic, 2: DoctorAddress} */
private function clinic(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک دسته‌بندی');
$this->em->persist($clinic);
$this->em->flush();
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return [$user, $clinic, $address];
}
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 testACategoryCanIncludeAnother(): void
{
[$user] = $this->clinic();
$whole = $this->category($user, 'تمام بدن');
$hand = $this->category($user, 'دست');
$this->authJson('POST', "/api/v1/service-category/{$whole}/includes", $user, [
'child_category_uuid' => $hand,
]);
self::assertSame(201, $this->responseCode());
$body = $this->authJson('GET', "/api/v1/service-category/{$whole}/includes", $user);
self::assertCount(1, $body['data']);
self::assertSame('دست', $body['data'][0]['name']);
}
public function testTheSameEdgeTwiceIsNotAnError(): void
{
[$user] = $this->clinic();
$whole = $this->category($user, 'تمام بدن');
$hand = $this->category($user, 'دست');
$this->authJson('POST', "/api/v1/service-category/{$whole}/includes", $user, ['child_category_uuid' => $hand]);
// تکرار همان یال idempotent است: کاربر دوبار کلیک می‌کند، خطا معنایی ندارد.
$this->authJson('POST', "/api/v1/service-category/{$whole}/includes", $user, ['child_category_uuid' => $hand]);
self::assertSame(200, $this->responseCode());
self::assertCount(1, $this->authJson('GET', "/api/v1/service-category/{$whole}/includes", $user)['data']);
}
public function testAnEdgeCanBeRemoved(): void
{
[$user] = $this->clinic();
$whole = $this->category($user, 'تمام بدن');
$hand = $this->category($user, 'دست');
$this->authJson('POST', "/api/v1/service-category/{$whole}/includes", $user, ['child_category_uuid' => $hand]);
$this->authJson('DELETE', "/api/v1/service-category/{$whole}/includes/{$hand}", $user);
self::assertSame(200, $this->responseCode());
self::assertSame([], $this->authJson('GET', "/api/v1/service-category/{$whole}/includes", $user)['data']);
}
public function testAResourceTakesTheGlobalCategories(): void
{
[$user, $clinic, $address] = $this->clinic();
$type = new ResourceType('clinic', (int) $clinic->getId(), 'device', 'دستگاه');
$this->em->persist($type);
$this->em->flush();
$resource = new ClinicResource($address, $type, 'لیزر دایود');
$this->em->persist($resource);
$this->em->flush();
$hand = $this->category($user, 'دست');
$foot = $this->category($user, 'پا');
$body = $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/categories", $user, [
'category_uuids' => [$hand, $foot],
]);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertCount(2, $body['data']['categories']);
// جایگزینی کامل: فهرست خالی یعنی هیچ دسته‌ای.
$emptied = $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/categories", $user, ['category_uuids' => []]);
self::assertSame([], $emptied['data']['categories']);
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
public function testACycleIsRefusedByTheEndpoint(): void
{
[$user] = $this->clinic();
$whole = $this->category($user, 'تمام بدن');
$hand = $this->category($user, 'دست');
$this->authJson('POST', "/api/v1/service-category/{$whole}/includes", $user, ['child_category_uuid' => $hand]);
// «دست شامل تمام بدن» حلقه می‌بندد.
$body = $this->authJson('POST', "/api/v1/service-category/{$hand}/includes", $user, ['child_category_uuid' => $whole]);
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
}
public function testACategoryFromAnotherEnvironmentCannotBeAssigned(): void
{
[$user, $clinic, $address] = $this->clinic();
[$otherUser] = $this->clinic();
$type = new ResourceType('clinic', (int) $clinic->getId(), 'device', 'دستگاه');
$this->em->persist($type);
$this->em->flush();
$resource = new ClinicResource($address, $type, 'لیزر');
$this->em->persist($resource);
$this->em->flush();
$foreign = $this->category($otherUser, 'دستهٔ کلینیک دیگر');
$this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/categories", $user, [
'category_uuids' => [$foreign],
]);
self::assertSame(422, $this->responseCode());
}
public function testTheBodyMustCarryTheField(): void
{
[$user] = $this->clinic();
$whole = $this->category($user, 'تمام بدن');
$this->authJson('POST', "/api/v1/service-category/{$whole}/includes", $user, ['wrong' => 'x']);
self::assertSame(422, $this->responseCode());
}
}