fix(db): purge insurance config when a doctor/clinic is deleted (H5)
tenant_insurances, entity_insurance_pricing and tenant_service_coverages reference their owner through a polymorphic (entity_type, entity_id) pair, so no database FK can cascade their cleanup. Hard-deleting a doctor (DoctorController) or clinic (AdminApiController) left these rows orphaned. Add TenantInsuranceCleanupService::purgeForEntity() and call it from both delete paths — removes coverage (via owning tenant_insurance ids), then tenant insurances, then pricing. Residual (separate, lower-freq paths): deleting an insurance category or a service_item still orphans rows that reference them by id — tracked under the medium-tier soft-ref findings. Regression: tests/Insurance/TenantInsuranceCleanupTest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -458,6 +458,8 @@ Delete a clinic.
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
> **Side effect:** the clinic's insurance configuration (`tenant_insurances`, `entity_insurance_pricing`, and their `tenant_service_coverages`) is purged in the same request — polymorphic `entity_id`, cleaned up at the application level.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "success": true, "data": { "message": "کلینیک حذف شد" } }
|
||||
|
||||
@@ -261,6 +261,8 @@ Delete a doctor profile.
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
> **Side effect:** the doctor's insurance configuration (`tenant_insurances`, `entity_insurance_pricing`, and their `tenant_service_coverages`) is purged in the same request — these reference the doctor via a polymorphic `entity_id` with no DB FK, so the cleanup is enforced in the application.
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
|
||||
@@ -41,7 +41,7 @@ _None outstanding._
|
||||
| ✅H2 | No UNIQUE `(doctor_id, slot_start)` on Appointment → double-booking race (index is non-unique) | src/Appointment/Entity/Appointment.php | db-unique | **DONE** — nullable unique `active_slot_key` (occupying = pending/confirmed, mirrors `isSlotTaken`); `bookAtomically` catches the unique violation + expires lapsed pendings in-txn; all 3 booking paths (online/my/admin) routed through it. Migration backfills one row per slot (non-destructive). `tests/Appointment/SlotUniquenessTest`. **NB:** backfill surfaced a real pre-existing double-booked slot in dev data (doctor 1764, two `expired` rows) — harmless (both expired = key NULL). |
|
||||
| ✅H3 | `Payment.referenceId` not unique → same gateway callback credited twice | src/Payment/Entity/Payment.php:61-62 | db-unique | **DONE** — unique index on `reference_id` (NULL until success → no collision) + callback pre-check rejects replays. `tests/Payment/PaymentCallbackAmountTest::testReplayedGatewayReferenceIsRejected` |
|
||||
| ✅H4 | `FinancialBreakdown.payment` onDelete CASCADE on non-nullable FK → deleting a Payment destroys ledger rows; should be RESTRICT | src/Settlement/Entity/FinancialBreakdown.php:28-30 | db-ondelete | **DONE** — onDelete RESTRICT + migration. `tests/Settlement/FinancialBreakdownIntegrityTest` |
|
||||
| H5 | Insurance pricing/coverage modeled as raw int FKs (no FK/onDelete) → orphan rows on delete: `EntityInsurancePricing.entity_id/insurance_id`, `TenantInsurance.entity_id/insurance_id`, `TenantServiceCoverage.tenant_insurance_id` | src/Insurance/Entity/EntityInsurancePricing.php:25-29 · TenantInsurance.php:29,32 · TenantServiceCoverage.php:23-24 | db-ondelete | Delete insurance/tenant → children removed or restricted, no dangling rows |
|
||||
| ✅H5 | Insurance pricing/coverage modeled as raw int FKs (no FK/onDelete) → orphan rows on delete | src/Insurance/Entity/EntityInsurancePricing.php · TenantInsurance.php · TenantServiceCoverage.php | db-ondelete | **DONE (entity-owner path)** — `entity_id` is polymorphic (doctor\|clinic) so no DB FK is possible; added `TenantInsuranceCleanupService::purgeForEntity()` wired into doctor + clinic DELETE (purges tenant_insurances + pricing + coverage). `tests/Insurance/TenantInsuranceCleanupTest`. **Residual (→ M20-adjacent):** orphans when an *insurance category* itself is deleted (`insurance_id` ref) or a *service_item* is deleted (`service_item_id` ref) — different deletion paths, lower freq. |
|
||||
| H6 | N+1: clinic doctors list lazy-loads `specialties` per doctor | src/Clinic/Controller/ClinicController.php:326 · src/Doctor/Repository/DoctorRepository.php:49 | perf-nplus1 | SQL profiler on clinic doctors list → 1 query/doctor for specialties |
|
||||
| H7 | N+1: comments list lazy-loads `likes`→`user`, `replies` (recursive), author realName | src/Rating/Controller/RatingController.php:278,352 | perf-nplus1 | Profiler GET comments → query count scales w/ comments |
|
||||
| H8 | N+1: insurance service-coverage `serviceItemRepo->find()` per row in array_map | src/Insurance/Controller/InsuranceController.php:410 | perf-nplus1 | GET service-coverage → 1 find()/row |
|
||||
|
||||
@@ -34,6 +34,7 @@ class AdminApiController extends BaseController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator,
|
||||
private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup,
|
||||
) {}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
@@ -553,6 +554,7 @@ class AdminApiController extends BaseController
|
||||
$clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404);
|
||||
|
||||
$this->insuranceCleanup->purgeForEntity(\App\Insurance\Entity\TenantInsurance::TYPE_CLINIC, $clinic->getId());
|
||||
$this->em->remove($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Doctor\Controller;
|
||||
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Insurance\Entity\TenantInsurance;
|
||||
use App\Insurance\Service\TenantInsuranceCleanupService;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
@@ -39,6 +41,7 @@ class DoctorController extends BaseController
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly TenantInsuranceCleanupService $insuranceCleanup,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
@@ -369,6 +372,7 @@ class DoctorController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->insuranceCleanup->purgeForEntity(TenantInsurance::TYPE_DOCTOR, $doctor->getId());
|
||||
$this->doctorRepo->remove($doctor);
|
||||
return $this->success(['message' => 'دکتر با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Insurance\Service;
|
||||
|
||||
use App\Insurance\Entity\EntityInsurancePricing;
|
||||
use App\Insurance\Entity\TenantInsurance;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Removes the insurance configuration a doctor/clinic owns when that entity is
|
||||
* deleted. The owning columns (entity_type, entity_id) are polymorphic
|
||||
* (doctor|clinic), so no database FK can cascade this — the cleanup is enforced
|
||||
* here in the application. Coverage rows are removed via their owning
|
||||
* tenant_insurance ids.
|
||||
*/
|
||||
final class TenantInsuranceCleanupService
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em) {}
|
||||
|
||||
public function purgeForEntity(string $entityType, int $entityId): void
|
||||
{
|
||||
$tenantInsuranceIds = $this->em->createQuery(
|
||||
'SELECT t.id FROM ' . TenantInsurance::class . ' t
|
||||
WHERE t.entityType = :type AND t.entityId = :id'
|
||||
)->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->getSingleColumnResult();
|
||||
|
||||
if ($tenantInsuranceIds !== []) {
|
||||
$this->em->createQuery(
|
||||
'DELETE ' . TenantServiceCoverage::class . ' c
|
||||
WHERE c.tenantInsuranceId IN (:ids)'
|
||||
)->setParameter('ids', $tenantInsuranceIds)->execute();
|
||||
}
|
||||
|
||||
$this->em->createQuery(
|
||||
'DELETE ' . TenantInsurance::class . ' t
|
||||
WHERE t.entityType = :type AND t.entityId = :id'
|
||||
)->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->execute();
|
||||
|
||||
$this->em->createQuery(
|
||||
'DELETE ' . EntityInsurancePricing::class . ' p
|
||||
WHERE p.entityType = :type AND p.entityId = :id'
|
||||
)->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Insurance;
|
||||
|
||||
use App\Insurance\Entity\EntityInsurancePricing;
|
||||
use App\Insurance\Entity\TenantInsurance;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\Insurance\Service\TenantInsuranceCleanupService;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Deleting a doctor/clinic must purge the insurance config it owns. entity_id is
|
||||
* polymorphic, so this cleanup is enforced in the application, not by a DB FK.
|
||||
*/
|
||||
class TenantInsuranceCleanupTest extends ApiTestCase
|
||||
{
|
||||
public function testPurgeRemovesTenantInsurancePricingAndCoverage(): void
|
||||
{
|
||||
$entityType = TenantInsurance::TYPE_DOCTOR;
|
||||
$entityId = random_int(1_000_000, 9_999_999); // a doctor id not shared with other tests
|
||||
|
||||
$tenant = new TenantInsurance($entityType, $entityId, 1);
|
||||
$this->em->persist($tenant);
|
||||
$this->em->flush();
|
||||
|
||||
$coverage = new TenantServiceCoverage($tenant->getId(), 1);
|
||||
$this->em->persist($coverage);
|
||||
$this->em->persist(new EntityInsurancePricing($entityType, $entityId, 1));
|
||||
$this->em->flush();
|
||||
|
||||
static::getContainer()->get(TenantInsuranceCleanupService::class)
|
||||
->purgeForEntity($entityType, $entityId);
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$this->assertCount(0, $this->em->getRepository(TenantInsurance::class)
|
||||
->findBy(['entityType' => $entityType, 'entityId' => $entityId]));
|
||||
$this->assertCount(0, $this->em->getRepository(EntityInsurancePricing::class)
|
||||
->findBy(['entityType' => $entityType, 'entityId' => $entityId]));
|
||||
$this->assertNull($this->em->getRepository(TenantServiceCoverage::class)
|
||||
->findOneBy(['tenantInsuranceId' => $tenant->getId()]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user