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:
hamed
2026-07-23 19:20:40 +03:30
co-authored by Claude Opus 4.8
parent 48b0f684f5
commit aa842b883d
4 changed files with 58 additions and 93 deletions
+5 -36
View File
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon, PlusIcon, PencilIcon, WrenchScrewdriverIcon, BanknotesIcon,
ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon, ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon,
EllipsisHorizontalIcon, CheckCircleIcon, XCircleIcon, ChevronRightIcon, EllipsisHorizontalIcon, CheckCircleIcon, XCircleIcon, ChevronRightIcon,
XMarkIcon, UsersIcon, ClockIcon, XMarkIcon, UsersIcon, ClockIcon,
@@ -48,12 +48,10 @@ function ClinicServicesPageInner() {
const { can } = usePermissions(); const { can } = usePermissions();
const canCreate = can('services', 'create'); const canCreate = can('services', 'create');
const canUpdate = can('services', 'update'); const canUpdate = can('services', 'update');
const canDelete = can('services', 'delete');
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedSection, setSelectedSection] = useState<ServiceSection | null>(null); const [selectedSection, setSelectedSection] = useState<ServiceSection | null>(null);
const [sectionModal, setSectionModal] = useState<'create' | 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 [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null);
const [toggleItem, setToggleItem] = useState<ServiceItem | null>(null); const [toggleItem, setToggleItem] = useState<ServiceItem | null>(null);
const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null); const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null);
@@ -98,17 +96,6 @@ function ClinicServicesPageInner() {
onError: (e: any) => toast.error(e.message), 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({ const toggleSection = useMutation({
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) => mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
api.patch(`/api/v1/service-section/${uuid}`, { active }), api.patch(`/api/v1/service-section/${uuid}`, { active }),
@@ -188,18 +175,11 @@ function ClinicServicesPageInner() {
</span> </span>
</div> </div>
{(canUpdate || canDelete) && ( {canUpdate && (
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 8 }} onClick={(e) => e.stopPropagation()}> <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)}>
<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 }} />
<PencilIcon style={{ width: 15 }} /> </button>
</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>
)}
</div> </div>
)} )}
</div> </div>
@@ -385,17 +365,6 @@ function ClinicServicesPageInner() {
<ServiceInsuranceModal item={insuranceItem} onClose={() => setInsuranceItem(null)} /> <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 فعال/غیرفعال سرویس */} {/* Confirm فعال/غیرفعال سرویس */}
<ConfirmDialog <ConfirmDialog
open={!!toggleItem} open={!!toggleItem}
+10 -9
View File
@@ -72,9 +72,11 @@
## DELETE /api/v1/service-section/{uuid} ## 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} ## DELETE /api/v1/service-item/{uuid}
حذف سرویس. **غیرفعال — همیشه `409` برمی‌گرداند.** سرویس‌ها هرگز حذف نمی‌شوند: نوبت‌ها، جلسات،
فاکتورها و سوابق پرداخت به سرویس ارجاع دارند و حذف آن‌ها را ناسازگار می‌کرد. برای
اگر سرویس در پرونده بیماری استفاده شده باشد، خطا برمی‌گرداند: برداشتنِ سرویس از پذیرشِ جدید، آن را غیرفعال کنید: `PATCH /api/v1/service-item/{uuid}`
با `{"active": false}` — سرویسِ غیرفعال در پذیرشِ جدید نمایش داده نمی‌شود ولی سوابق حفظ می‌مانند.
```json ```json
{ {
"success": false, "success": false,
"errors": [{ "code": "ERR_SERVICE_ITEM_IN_USE", "message": "این سرویس در پرونده بیمار ثبت شده است" }] "errors": [{ "code": "ERR_SERVICE_ITEM_IN_USE", "message": "حذف سرویس ممکن نیست؛ برای حفظ سوابق پرداخت فقط می‌توانید آن را غیرفعال کنید." }]
} }
``` ```
**Errors:** **Errors:**
| Code | HTTP | توضیح | | Code | HTTP | توضیح |
|------|------|-------| |------|------|-------|
| ERR_SUBSCRIPTION_REQUIRED | 403 | نیاز به پنل Basic+ | | ERR_SERVICE_ITEM_IN_USE | 409 | حذف مجاز نیست — فقط غیرفعال‌کردن ممکن است |
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| ERR_SERVICE_ITEM_IN_USE | 409 | سرویس در پرونده بیمار استفاده شده |
--- ---
@@ -212,18 +212,14 @@ class ClinicServiceController extends BaseController
#[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])] #[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])]
public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse
{ {
$this->denyServices($user, 'delete'); // حذف بخش مجاز نیست: حذفِ آن سرویس‌های زیرمجموعه را هم پاک می‌کرد و سوابق
[$entityType, $entityId] = $this->resolveEntity($user); // پرداخت/فاکتور به همان سرویس‌ها ارجاع دارند. فقط غیرفعال‌کردن مجاز است
$this->assertServicesGate($entityType, $entityId); // (PATCH active=false).
return $this->error(
$section = $this->sectionRepo->findByUuid($uuid); ErrorCodes::ERR_SERVICE_ITEM_IN_USE,
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) { 'حذف بخش ممکن نیست؛ برای حفظ سوابق پرداخت فقط می‌توانید آن را غیرفعال کنید.',
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404); 409
} );
$this->sectionRepo->remove($section);
return $this->success(['message' => 'بخش حذف شد']);
} }
// ── Service Items ──────────────────────────────────────────────────────── // ── Service Items ────────────────────────────────────────────────────────
@@ -407,29 +403,15 @@ class ClinicServiceController extends BaseController
#[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])] #[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])]
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
{ {
$this->denyServices($user, 'delete'); // حذف سرویس مجاز نیست: نوبت‌ها، جلسات، فاکتورها و سوابق پرداخت به سرویس
[$entityType, $entityId] = $this->resolveEntity($user); // ارجاع دارند و حذف آن‌ها را یتیم/ناسازگار می‌کرد. فقط غیرفعال‌کردن مجاز است
// (PATCH active=false) — سرویسِ غیرفعال در پذیرش جدید نمایش داده نمی‌شود ولی
$item = $this->itemRepo->findByUuid($uuid); // سوابق حفظ می‌شوند.
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) { return $this->error(
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404); ErrorCodes::ERR_SERVICE_ITEM_IN_USE,
} 'حذف سرویس ممکن نیست؛ برای حفظ سوابق پرداخت فقط می‌توانید آن را غیرفعال کنید.',
409
// 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' => 'سرویس حذف شد']);
} }
// ── Tariffs (تعرفه‌ی نسخه‌دار سالانه) ────────────────────────────────────── // ── Tariffs (تعرفه‌ی نسخه‌دار سالانه) ──────────────────────────────────────
@@ -4,19 +4,17 @@ namespace App\Tests\ClinicService;
use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection; use App\ClinicService\Entity\ServiceSection;
use App\ClinicService\Entity\Tariff;
use App\Doctor\Entity\Doctor; use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\TenantServiceCoverage;
use App\Tests\ApiTestCase; 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 * دارند. DELETE با ۴۰۹ رد می‌شود و سرویس دست‌نخورده می‌ماند؛ فقط غیرفعال‌کردن
* otherwise orphan. * (PATCH active=false) مجاز است.
*/ */
class ServiceItemDeleteCleanupTest extends ApiTestCase class ServiceItemDeleteCleanupTest extends ApiTestCase
{ {
public function testDeletingItemPurgesTariffAndCoverage(): void public function testDeletingItemIsRejected(): void
{ {
$owner = $this->createUser(['ROLE_DOCTOR']); $owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر'); $doctor = new Doctor($owner, 'دکتر');
@@ -28,17 +26,32 @@ class ServiceItemDeleteCleanupTest extends ApiTestCase
$this->em->persist($section); $this->em->persist($section);
$this->em->persist($item); $this->em->persist($item);
$this->em->flush(); $this->em->flush();
$itemId = $item->getId(); $itemUuid = $item->getUuid();
$this->em->persist(new Tariff($itemId, 1404, 1000)); $this->authJson('DELETE', '/api/v1/service-item/' . $itemUuid, $owner);
$this->em->persist(new TenantServiceCoverage(999_999, $itemId)); $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->em->flush();
$this->authJson('DELETE', '/api/v1/service-item/' . $item->getUuid(), $owner); $section = new ServiceSection('doctor', $doctor->getId(), 'بخش');
$this->assertSame(200, $this->responseCode()); $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->em->clear();
$this->assertCount(0, $this->em->getRepository(Tariff::class)->findBy(['serviceItemId' => $itemId])); $this->assertNotNull($this->em->getRepository(ServiceSection::class)->findOneBy(['uuid' => $sectionUuid]));
$this->assertCount(0, $this->em->getRepository(TenantServiceCoverage::class)->findBy(['serviceItemId' => $itemId]));
} }
} }