diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index e046a1b7..81444b40 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 174c2cac..5497d383 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -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'] }, diff --git a/assets/admin/hooks/useCatalogCategories.ts b/assets/admin/hooks/useCatalogCategories.ts new file mode 100644 index 00000000..5d2290ae --- /dev/null +++ b/assets/admin/hooks/useCatalogCategories.ts @@ -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>('/api/v1/service-categories/tree'), + staleTime: 30_000, + }); + + const create = useMutation({ + mutationFn: (d: { name: string; parent_uuid?: string | null }) => + api.post>('/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>(`/api/v1/service-category/${uuid}`, d), + onSuccess: () => { toast.success('دسته‌بندی به‌روزرسانی شد'); invalidate(); }, + onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/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>(`/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>(`/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>(`/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 }; +} diff --git a/assets/admin/pages/CatalogCategoriesPage.test.tsx b/assets/admin/pages/CatalogCategoriesPage.test.tsx new file mode 100644 index 00000000..7076f7d9 --- /dev/null +++ b/assets/admin/pages/CatalogCategoriesPage.test.tsx @@ -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(); + + expect(screen.getByText('تمام بدن')).toBeInTheDocument(); + expect(screen.getByText('دست')).toBeInTheDocument(); + }); + + it('ساخت دسته، نام را به API می‌دهد', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + 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(); + + 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(); + + 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' }); + }); +}); diff --git a/assets/admin/pages/CatalogCategoriesPage.tsx b/assets/admin/pages/CatalogCategoriesPage.tsx new file mode 100644 index 00000000..f50268b4 --- /dev/null +++ b/assets/admin/pages/CatalogCategoriesPage.tsx @@ -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(null); + const [toDelete, setToDelete] = useState(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[] = [ + { + key: 'name', + header: 'دسته‌بندی', + render: (c) => ( + + {c.depth > 0 && } + {c.name} + + ), + }, + { key: 'sort_order', header: 'ترتیب', render: (c) => {c.sort_order ?? 0} }, + { key: 'active', header: 'وضعیت', render: (c) => }, + ]; + + return ( +
+ setEditing({ open: true, category: null })}> + افزودن دسته‌بندی + + ) : undefined + } + /> + + + columns={columns} + data={rows} + loading={loading} + searchValue={urlState.search} + onSearchChange={(v) => setUrlState({ search: v })} + searchPlaceholder="جستجو در دسته‌بندی‌ها..." + emptyMessage="هنوز دسته‌بندی‌ای تعریف نشده است" + actions={ + canUpdate + ? (c) => ( +
+ + + +
+ ) + : undefined + } + /> + + 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); + } + }} + /> + + setIncludesFor(null)} + /> + + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} + +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(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 ( + +
+
+ + setName(e.target.value)} + placeholder="مثلاً: تمام بدن" + /> +
+ + {!category && ( +
+ + ({ value: p.uuid, label: '— '.repeat(p.depth) + p.name }))} + value={parent} + onChange={(v) => setParent(v ? String(v) : null)} + placeholder="بدون والد — دستهٔ سطح اول" + height={38} + /> + + این فقط جای دسته در منوست. «شامل بودن» — مثل «تمام بدن شامل دست» — از دکمهٔ «شامل» تعریف می‌شود. + +
+ )} + +
+
+ + setSortOrder(e.target.value)} + /> +
+ + +
+ +
+ + +
+
+
+ ); +} + +/** «این دسته شامل کدام دسته‌هاست» — گراف، جدا از درختِ منو. */ +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 ( + +
+

+ انتخاب هم‌زمان دو سرویس که دستهٔ یکی دیگری را در بر می‌گیرد، هنگام رزرو رد می‌شود — + مثلاً «لیزر تمام بدن» با «لیزر دست». +

+ + {includes.length === 0 ? ( +

این دسته زیرمجموعه‌ای ندارد.

+ ) : ( +
+ {includes.map((child) => ( +
+ {child.name} + +
+ ))} +
+ )} + + {available.length > 0 && ( +
+ + ({ 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} + /> +
+ )} + +
+ +
+
+
+ ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 94dc3d95..04a5c092 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -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[]; +} diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index acbcb71d..161b5a53 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.md @@ -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": "" }` — الزامی. + +```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 diff --git a/docs/api/resource.md b/docs/api/resource.md index e9a44c3f..ca5bebfe 100644 --- a/docs/api/resource.md +++ b/docs/api/resource.md @@ -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` رویش اعمال نمی‌شود، پس محیطِ +هر دسته صریحاً با محیط منبع مقایسه می‌شود. + --- ## استخر منابع diff --git a/src/ClinicService/Controller/ServiceCatalogController.php b/src/ClinicService/Controller/ServiceCatalogController.php index da64bb2a..5609afc8 100644 --- a/src/ClinicService/Controller/ServiceCatalogController.php +++ b/src/ClinicService/Controller/ServiceCatalogController.php @@ -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'])] diff --git a/src/Resource/Controller/ResourceController.php b/src/Resource/Controller/ResourceController.php index dbf1b53f..93c8924b 100644 --- a/src/Resource/Controller/ResourceController.php +++ b/src/Resource/Controller/ResourceController.php @@ -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 diff --git a/src/Resource/Entity/ClinicResource.php b/src/Resource/Entity/ClinicResource.php index 4c5565ca..d46fea3c 100644 --- a/src/Resource/Entity/ClinicResource.php +++ b/src/Resource/Entity/ClinicResource.php @@ -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, diff --git a/src/Resource/Service/ResourceService.php b/src/Resource/Service/ResourceService.php index d877fc38..02fabfad 100644 --- a/src/Resource/Service/ResourceService.php +++ b/src/Resource/Service/ResourceService.php @@ -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 $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 $data */ public function create(DoctorAddress $address, ResourceType $type, array $data): ClinicResource { diff --git a/tests/ClinicService/CategoryManagementEndpointTest.php b/tests/ClinicService/CategoryManagementEndpointTest.php new file mode 100644 index 00000000..1dd73c05 --- /dev/null +++ b/tests/ClinicService/CategoryManagementEndpointTest.php @@ -0,0 +1,170 @@ +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()); + } +}