diff --git a/docs/api/admin.md b/docs/api/admin.md index fe0a0f4e..9f75d02d 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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": "کلینیک حذف شد" } } diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 92b51f24..44bd122c 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -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 | |-------|------|-------------| diff --git a/docs/audit-backlog.md b/docs/audit-backlog.md index 739505ff..51e5a216 100644 --- a/docs/audit-backlog.md +++ b/docs/audit-backlog.md @@ -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 | diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 7324afff..b98159de 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -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(); diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index e9c8960d..da818025 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -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' => 'دکتر با موفقیت حذف شد']); } diff --git a/src/Insurance/Service/TenantInsuranceCleanupService.php b/src/Insurance/Service/TenantInsuranceCleanupService.php new file mode 100644 index 00000000..73cc8b1e --- /dev/null +++ b/src/Insurance/Service/TenantInsuranceCleanupService.php @@ -0,0 +1,51 @@ +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(); + } +} diff --git a/tests/Insurance/TenantInsuranceCleanupTest.php b/tests/Insurance/TenantInsuranceCleanupTest.php new file mode 100644 index 00000000..e6b958c6 --- /dev/null +++ b/tests/Insurance/TenantInsuranceCleanupTest.php @@ -0,0 +1,43 @@ +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()])); + } +}