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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user