fix(services): forbid deleting service items/sections — deactivate only
Services are referenced by appointments, sessions, invoices and payment history,
so deleting one orphans/corrupts those records (deleting a section cascaded to
its services too). Make deletion impossible:
- Backend: DELETE /service-item/{uuid} and DELETE /service-section/{uuid} now
always return 409 (ERR_SERVICE_ITEM_IN_USE) with a message pointing to
deactivate; no rows are touched. Deactivate stays via PATCH active=false.
- Frontend: removed the section delete button, its confirm dialog, the delete
mutation, and the now-unused delete state/flag/icon from ClinicServicesPage.
Section and item deactivate toggles are unchanged.
Tests: ServiceItemDeleteCleanupTest rewritten — delete of item and section both
rejected (409) and the row survives. docs/api/clinic-services.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon,
|
||||
PlusIcon, PencilIcon, WrenchScrewdriverIcon, BanknotesIcon,
|
||||
ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon,
|
||||
EllipsisHorizontalIcon, CheckCircleIcon, XCircleIcon, ChevronRightIcon,
|
||||
XMarkIcon, UsersIcon, ClockIcon,
|
||||
@@ -48,12 +48,10 @@ function ClinicServicesPageInner() {
|
||||
const { can } = usePermissions();
|
||||
const canCreate = can('services', 'create');
|
||||
const canUpdate = can('services', 'update');
|
||||
const canDelete = can('services', 'delete');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedSection, setSelectedSection] = useState<ServiceSection | null>(null);
|
||||
const [sectionModal, setSectionModal] = useState<'create' | ServiceSection | null>(null);
|
||||
const [deleteSection, setDeleteSection] = useState<ServiceSection | null>(null);
|
||||
const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null);
|
||||
const [toggleItem, setToggleItem] = useState<ServiceItem | null>(null);
|
||||
const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null);
|
||||
@@ -98,17 +96,6 @@ function ClinicServicesPageInner() {
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const delSection = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete(`/api/v1/service-section/${uuid}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['service-sections'] });
|
||||
if (selectedSection?.uuid === deleteSection?.uuid) setSelectedSection(null);
|
||||
setDeleteSection(null);
|
||||
toast.success('بخش حذف شد');
|
||||
},
|
||||
onError: (e: any) => { toast.error(e.message); setDeleteSection(null); },
|
||||
});
|
||||
|
||||
const toggleSection = useMutation({
|
||||
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
|
||||
api.patch(`/api/v1/service-section/${uuid}`, { active }),
|
||||
@@ -188,18 +175,11 @@ function ClinicServicesPageInner() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(canUpdate || canDelete) && (
|
||||
{canUpdate && (
|
||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
|
||||
{canUpdate && (
|
||||
<button className="btn sm ghost" aria-label="ویرایش" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
|
||||
<PencilIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--danger)' }} onClick={() => setDeleteSection(s)}>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn sm ghost" aria-label="ویرایش" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
|
||||
<PencilIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -385,17 +365,6 @@ function ClinicServicesPageInner() {
|
||||
|
||||
<ServiceInsuranceModal item={insuranceItem} onClose={() => setInsuranceItem(null)} />
|
||||
|
||||
{/* Confirm حذف بخش */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteSection}
|
||||
title="حذف بخش"
|
||||
message={`آیا مطمئن هستید که میخواهید بخش «${deleteSection?.name}» و همهی سرویسهای آن را حذف کنید؟`}
|
||||
confirmLabel="حذف"
|
||||
onConfirm={() => deleteSection && delSection.mutate(deleteSection.uuid)}
|
||||
onCancel={() => setDeleteSection(null)}
|
||||
loading={delSection.isPending}
|
||||
/>
|
||||
|
||||
{/* Confirm فعال/غیرفعال سرویس */}
|
||||
<ConfirmDialog
|
||||
open={!!toggleItem}
|
||||
|
||||
@@ -72,9 +72,11 @@
|
||||
|
||||
## DELETE /api/v1/service-section/{uuid}
|
||||
|
||||
حذف بخش (cascade — همه ServiceItem های آن حذف میشوند).
|
||||
**غیرفعال — همیشه `409` برمیگرداند.** حذفِ بخش، سرویسهای زیرمجموعه را هم پاک میکرد و
|
||||
سوابق پرداخت/فاکتور به همان سرویسها ارجاع دارند. برای برداشتنِ بخش از پذیرشِ جدید،
|
||||
آن را غیرفعال کنید: `PATCH /api/v1/service-section/{uuid}` با `{"active": false}`.
|
||||
|
||||
**Permission:** owner
|
||||
پاسخ: `409 ERR_SERVICE_ITEM_IN_USE` — «حذف بخش ممکن نیست؛ برای حفظ سوابق پرداخت فقط میتوانید آن را غیرفعال کنید.»
|
||||
|
||||
---
|
||||
|
||||
@@ -267,23 +269,22 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
|
||||
## DELETE /api/v1/service-item/{uuid}
|
||||
|
||||
حذف سرویس.
|
||||
|
||||
اگر سرویس در پرونده بیماری استفاده شده باشد، خطا برمیگرداند:
|
||||
**غیرفعال — همیشه `409` برمیگرداند.** سرویسها هرگز حذف نمیشوند: نوبتها، جلسات،
|
||||
فاکتورها و سوابق پرداخت به سرویس ارجاع دارند و حذف آنها را ناسازگار میکرد. برای
|
||||
برداشتنِ سرویس از پذیرشِ جدید، آن را غیرفعال کنید: `PATCH /api/v1/service-item/{uuid}`
|
||||
با `{"active": false}` — سرویسِ غیرفعال در پذیرشِ جدید نمایش داده نمیشود ولی سوابق حفظ میمانند.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_SERVICE_ITEM_IN_USE", "message": "این سرویس در پرونده بیمار ثبت شده است" }]
|
||||
"errors": [{ "code": "ERR_SERVICE_ITEM_IN_USE", "message": "حذف سرویس ممکن نیست؛ برای حفظ سوابق پرداخت فقط میتوانید آن را غیرفعال کنید." }]
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
| Code | HTTP | توضیح |
|
||||
|------|------|-------|
|
||||
| ERR_SUBSCRIPTION_REQUIRED | 403 | نیاز به پنل Basic+ |
|
||||
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
|
||||
| ERR_SERVICE_ITEM_IN_USE | 409 | سرویس در پرونده بیمار استفاده شده |
|
||||
| ERR_SERVICE_ITEM_IN_USE | 409 | حذف مجاز نیست — فقط غیرفعالکردن ممکن است |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -212,18 +212,14 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->denyServices($user, 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
$section = $this->sectionRepo->findByUuid($uuid);
|
||||
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$this->sectionRepo->remove($section);
|
||||
|
||||
return $this->success(['message' => 'بخش حذف شد']);
|
||||
// حذف بخش مجاز نیست: حذفِ آن سرویسهای زیرمجموعه را هم پاک میکرد و سوابق
|
||||
// پرداخت/فاکتور به همان سرویسها ارجاع دارند. فقط غیرفعالکردن مجاز است
|
||||
// (PATCH active=false).
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_SERVICE_ITEM_IN_USE,
|
||||
'حذف بخش ممکن نیست؛ برای حفظ سوابق پرداخت فقط میتوانید آن را غیرفعال کنید.',
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
// ── Service Items ────────────────────────────────────────────────────────
|
||||
@@ -407,29 +403,15 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->denyServices($user, 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
// Tariff and tenant-coverage rows reference the item by a raw int (no FK),
|
||||
// so they would orphan on delete. Remove the item's config rows first.
|
||||
$itemId = $item->getId();
|
||||
$this->em->createQuery('DELETE FROM ' . Tariff::class . ' t WHERE t.serviceItemId = :id')
|
||||
->setParameter('id', $itemId)->execute();
|
||||
$this->em->createQuery('DELETE FROM ' . TenantServiceCoverage::class . ' c WHERE c.serviceItemId = :id')
|
||||
->setParameter('id', $itemId)->execute();
|
||||
|
||||
try {
|
||||
$this->itemRepo->remove($item);
|
||||
} catch (\Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_ITEM_IN_USE, ErrorCodes::message(ErrorCodes::ERR_SERVICE_ITEM_IN_USE), 409);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'سرویس حذف شد']);
|
||||
// حذف سرویس مجاز نیست: نوبتها، جلسات، فاکتورها و سوابق پرداخت به سرویس
|
||||
// ارجاع دارند و حذف آنها را یتیم/ناسازگار میکرد. فقط غیرفعالکردن مجاز است
|
||||
// (PATCH active=false) — سرویسِ غیرفعال در پذیرش جدید نمایش داده نمیشود ولی
|
||||
// سوابق حفظ میشوند.
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_SERVICE_ITEM_IN_USE,
|
||||
'حذف سرویس ممکن نیست؛ برای حفظ سوابق پرداخت فقط میتوانید آن را غیرفعال کنید.',
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tariffs (تعرفهی نسخهدار سالانه) ──────────────────────────────────────
|
||||
|
||||
@@ -4,19 +4,17 @@ namespace App\Tests\ClinicService;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\ClinicService\Entity\Tariff;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Deleting a service item must also remove its config rows (tariffs and
|
||||
* tenant-coverage), which reference it by a raw int with no FK and would
|
||||
* otherwise orphan.
|
||||
* سرویسها اصلاً حذف نمیشوند — نوبت/جلسه/فاکتور/سوابق پرداخت به سرویس ارجاع
|
||||
* دارند. DELETE با ۴۰۹ رد میشود و سرویس دستنخورده میماند؛ فقط غیرفعالکردن
|
||||
* (PATCH active=false) مجاز است.
|
||||
*/
|
||||
class ServiceItemDeleteCleanupTest extends ApiTestCase
|
||||
{
|
||||
public function testDeletingItemPurgesTariffAndCoverage(): void
|
||||
public function testDeletingItemIsRejected(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
@@ -28,17 +26,32 @@ class ServiceItemDeleteCleanupTest extends ApiTestCase
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
$itemId = $item->getId();
|
||||
$itemUuid = $item->getUuid();
|
||||
|
||||
$this->em->persist(new Tariff($itemId, 1404, 1000));
|
||||
$this->em->persist(new TenantServiceCoverage(999_999, $itemId));
|
||||
$this->authJson('DELETE', '/api/v1/service-item/' . $itemUuid, $owner);
|
||||
$this->assertSame(409, $this->responseCode());
|
||||
|
||||
// سرویس باید همچنان وجود داشته باشد.
|
||||
$this->em->clear();
|
||||
$this->assertNotNull($this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $itemUuid]));
|
||||
}
|
||||
|
||||
public function testDeletingSectionIsRejected(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش');
|
||||
$this->em->persist($section);
|
||||
$this->em->flush();
|
||||
$sectionUuid = $section->getUuid();
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/service-section/' . $sectionUuid, $owner);
|
||||
$this->assertSame(409, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$this->assertCount(0, $this->em->getRepository(Tariff::class)->findBy(['serviceItemId' => $itemId]));
|
||||
$this->assertCount(0, $this->em->getRepository(TenantServiceCoverage::class)->findBy(['serviceItemId' => $itemId]));
|
||||
$this->assertNotNull($this->em->getRepository(ServiceSection::class)->findOneBy(['uuid' => $sectionUuid]));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user