From aa842b883d22db8ad2e7c81946a40417c88abf3b Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 23 Jul 2026 19:20:40 +0330 Subject: [PATCH] =?UTF-8?q?fix(services):=20forbid=20deleting=20service=20?= =?UTF-8?q?items/sections=20=E2=80=94=20deactivate=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- assets/admin/pages/ClinicServicesPage.tsx | 41 ++------------- docs/api/clinic-services.md | 19 +++---- .../Controller/ClinicServiceController.php | 52 ++++++------------- .../ServiceItemDeleteCleanupTest.php | 39 +++++++++----- 4 files changed, 58 insertions(+), 93 deletions(-) diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx index e19689b8..67c87766 100644 --- a/assets/admin/pages/ClinicServicesPage.tsx +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -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(null); const [sectionModal, setSectionModal] = useState<'create' | ServiceSection | null>(null); - const [deleteSection, setDeleteSection] = useState(null); const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null); const [toggleItem, setToggleItem] = useState(null); const [tariffItem, setTariffItem] = useState(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() { - {(canUpdate || canDelete) && ( + {canUpdate && (
e.stopPropagation()}> - {canUpdate && ( - - )} - {canDelete && ( - - )} +
)} @@ -385,17 +365,6 @@ function ClinicServicesPageInner() { setInsuranceItem(null)} /> - {/* Confirm حذف بخش */} - deleteSection && delSection.mutate(deleteSection.uuid)} - onCancel={() => setDeleteSection(null)} - loading={delSection.isPending} - /> - {/* Confirm فعال/غیرفعال سرویس */} 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 (تعرفه‌ی نسخه‌دار سالانه) ────────────────────────────────────── diff --git a/tests/ClinicService/ServiceItemDeleteCleanupTest.php b/tests/ClinicService/ServiceItemDeleteCleanupTest.php index d878c763..eb39e114 100644 --- a/tests/ClinicService/ServiceItemDeleteCleanupTest.php +++ b/tests/ClinicService/ServiceItemDeleteCleanupTest.php @@ -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])); } }