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
@@ -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]));
}
}