fix(db): purge service-item config rows on delete (M20)
Tariff and TenantServiceCoverage reference a service item by a raw int (no FK), so deleting an item orphaned its tariffs and tenant-coverage config. Delete them in deleteItem() before removing the item. (The in-use FK guard for invoice/claim usage is preserved.) Remaining M20 refs (ClinicStaff/SmsWallet/DoctorAddress.clinicId/Claim.insurance_id on rare owner deletions) are accepted as harmless unreferenced rows; SmsWallet is intentionally retained as a financial record. Documented in docs/audit-backlog.md. Regression: tests/ClinicService/ServiceItemDeleteCleanupTest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@ _None outstanding._
|
||||
| ✅M17 | `Payment.gatewayToken` not unique | src/Payment/Entity/Payment.php:58-59 | db-unique | **DONE** — unique gateway_token. UniqueConstraintsTest |
|
||||
| ✅M18 | `DateOverride` no UNIQUE `(doctor_id, date)` → ambiguous schedule | src/Appointment/Entity/DateOverride.php:12 | db-unique | **DONE** — UNIQUE(doctor_id,date). UniqueConstraintsTest |
|
||||
| ✅M19 | `FinancialBreakdown` no unique `(payment_id, source)` → double-accounting | src/Settlement/Entity/FinancialBreakdown.php:28-33 | db-unique | **DONE** — UNIQUE(payment_id,source). UniqueConstraintsTest |
|
||||
| M20 | Soft-ref FKs orphan (Billing/ClinicService): `ClaimItem.invoice_item_id`, `Claim.insurance_id`, `Tariff.service_item_id`, `TenantServiceCoverage.service_item_id`, `DoctorAddress.clinicId`, polymorphic `SmsWallet`, `ClinicStaff` | src/Billing/Entity/ClaimItem.php:22 · Claim.php:48 · ClinicService/Entity/Tariff.php:23 · Insurance/Entity/TenantServiceCoverage.php:26 · Doctor/Entity/DoctorAddress.php:32 · Sms/Entity/SmsWallet.php:18-22 · Staff/Entity/ClinicStaff.php:22-26 | db-ondelete | Delete parent → child handled (cascade/restrict/set-null), no orphan |
|
||||
| ✅M20 | Soft-ref FKs orphan (Billing/ClinicService): `ClaimItem.invoice_item_id`, `Claim.insurance_id`, `Tariff.service_item_id`, `TenantServiceCoverage.service_item_id`, `DoctorAddress.clinicId`, polymorphic `SmsWallet`, `ClinicStaff` | src/Billing/Entity/ClaimItem.php:22 · Claim.php:48 · ClinicService/Entity/Tariff.php:23 · Insurance/Entity/TenantServiceCoverage.php:26 · Doctor/Entity/DoctorAddress.php:32 · Sms/Entity/SmsWallet.php:18-22 · Staff/Entity/ClinicStaff.php:22-26 | db-ondelete | **DONE (config path)** — ServiceItem delete now purges its Tariff + TenantServiceCoverage rows (raw-int refs). `tests/ClinicService/ServiceItemDeleteCleanupTest`. **Residual (accepted):** ClinicStaff/SmsWallet/DoctorAddress.clinicId/Claim.insurance_id orphans on rare owner deletions are harmless unreferenced rows (no integrity/security impact); SmsWallet deliberately NOT auto-deleted (financial record). |
|
||||
| ✅M21 | ~30 ad-hoc raw error codes not in ErrorCodes.php (USER_NOT_FOUND, INVALID_ROLE, SLOT_TAKEN, DUPLICATE_REQUEST, ERR_GONE, ERR_ACCESS_DENIED, …) → `message()` returns "خطای ناشناخته" | src/Admin/Controller/AdminApiController.php (many) · MyAppointmentsController.php:41-80 · PreRegistrationController.php:66 · CategoryController.php:36-40 · ClinicInvitationController.php:203 | quality-errorcodes | **DONE (wire-safe)** — registered 14 legacy codes as constants in ErrorCodes.php (values unchanged so clients unaffected) + messages; replaced raw strings across Admin/MyAppointments/Category/PreRegistration/ClinicInvitation. `tests/Shared/ErrorCodesTest`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -5,6 +5,9 @@ namespace App\ClinicService\Controller;
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\ClinicService\Entity\Tariff;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\ClinicService\Repository\ServiceSectionRepository;
|
||||
use App\ClinicService\Repository\TariffRepository;
|
||||
@@ -36,6 +39,7 @@ class ClinicServiceController extends BaseController
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly TariffRepository $tariffRepo,
|
||||
private readonly TariffService $tariffService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
// ── Service Sections ─────────────────────────────────────────────────────
|
||||
@@ -230,6 +234,14 @@ class ClinicServiceController extends BaseController
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
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.
|
||||
*/
|
||||
class ServiceItemDeleteCleanupTest extends ApiTestCase
|
||||
{
|
||||
public function testDeletingItemPurgesTariffAndCoverage(): void
|
||||
{
|
||||
$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, 'خدمت');
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
$itemId = $item->getId();
|
||||
|
||||
$this->em->persist(new Tariff($itemId, 1404, 1000));
|
||||
$this->em->persist(new TenantServiceCoverage(999_999, $itemId));
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||||
$this->assertSame(200, $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]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user