From 4f76cff503b2555b79dda18067fdcb8250899fb7 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 18 Aug 2026 17:37:44 +0330 Subject: [PATCH] fix(billing): bill the insurance share on clinic appointments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invoice and the encounter belong to the clinic, but the contract sits on the doctor, so every coverage lookup asked the clinic, got nothing, and resolved to zero percent. Three failures followed from that one wrong tenant: the encounter charged the patient the full amount, the payment page showed a balance the insurer owed, and — because no line carried an insurance share — no claim was ever built, leaving the claims page empty. Coverage now resolves through the same doctor-first-then-clinic rule the rest of the insurance settings use, both when the encounter is created and when its invoice lines are built. Changing the insurance on an appointment also re-runs the encounter's shares. Reception routinely confirms first and corrects the insurance afterwards, and until now those shares stayed frozen at whatever the first calculation said. Recorded payments are untouched; only the payable amount moves, so an overpayment simply clamps the remainder at zero. The PATCH response also returns the appointment's real venue now, matching GET. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/appointment.md | 2 + .../Controller/AppointmentController.php | 8 +- src/Billing/Service/InvoiceService.php | 36 ++++- src/Patient/Service/PatientService.php | 76 +++++++++- ...linicAppointmentUsesDoctorContractTest.php | 141 ++++++++++++++++++ 5 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 tests/Billing/ClinicAppointmentUsesDoctorContractTest.php diff --git a/docs/api/appointment.md b/docs/api/appointment.md index f33bd01f..9556068c 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -460,6 +460,8 @@ Get appointment detail. } ``` +> ℹ️ تغییر بیمهٔ نوبت با `PATCH` سهم‌های مراجعهٔ همان نوبت را هم دوباره حساب می‌کند: پذیرش گاهی اول نوبت را قطعی می‌کند و بعد بیمه را اصلاح می‌کند، و بدون این، مراجعه روی محاسبهٔ اول می‌ماند و صفحهٔ پرداخت سهمِ بیمه را از بیمار می‌خواهد. پرداخت‌های ثبت‌شده دست نمی‌خورند؛ فقط مبلغ قابل‌پرداخت اصلاح می‌شود. پاسخ `PATCH` هم مثل `GET` نشانیِ واقعیِ نوبت را برمی‌گرداند. + > ℹ️ `address` is the venue recorded on this appointment (`address_id`) — the doctor's own > office or the clinic branch, whichever the booking was made at — and it is the only place > `telephone` is returned. When that record carries no number, the clinic's own number takes diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index d26aa68a..fc9e92e8 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -56,6 +56,7 @@ class AppointmentController extends BaseController private readonly \App\Resource\Service\PublicResourceBookingService $publicResources, private readonly \App\Treatment\Service\SessionBookingLink $sessionLink, private readonly \App\Shared\Tenant\TenantFilterScope $tenantScope, + private readonly \App\Patient\Service\PatientService $patients, private readonly \Psr\Log\LoggerInterface $logger, ) {} @@ -1482,7 +1483,12 @@ class AppointmentController extends BaseController if ($completed) { } - return $this->success(['data' => $appointment->toArray()]); + // انتخاب بیمه می‌تواند بعد از ساخته‌شدنِ مراجعه عوض شود — پذیرش اول نوبت را + // قطعی می‌کند و بعد بیمه را اصلاح می‌کند. بدون این، سهم‌های مراجعه روی همان + // محاسبهٔ اول می‌مانند و صفحهٔ پرداخت سهمِ بیمه را از بیمار می‌خواهد. + $this->patients->resyncInsuranceShares($appointment); + + return $this->success(['data' => $this->appointmentWithVenue($appointment)]); } /** diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php index 69d0633d..25c552f6 100644 --- a/src/Billing/Service/InvoiceService.php +++ b/src/Billing/Service/InvoiceService.php @@ -25,6 +25,7 @@ class InvoiceService private readonly InsuranceRepository $insuranceRepo, private readonly EventDispatcherInterface $events, private readonly ActorIdentityResolver $actorIdentity, + private readonly \App\Insurance\Service\InsuranceScopeResolver $insuranceScope, ) {} /** @var array نام بیمه‌ها، یک‌بار در هر درخواست. */ @@ -107,10 +108,16 @@ class InvoiceService $visitCategory = $session->getInsuranceServiceCategory() ?? ServiceCategory::Outpatient; $invoice->setServiceCategory($session->getInsuranceServiceCategory()); + // قرارداد بیمه جای دیگری غیر از محیطِ صورتحساب نشسته است: صورتحسابِ نوبتِ + // کلینیک به کلینیک تعلق دارد، ولی قرارداد معمولاً روی خودِ پزشک ثبت شده. با + // محیط صورتحساب، هر سهم بیمه صفر درمی‌آمد — بیمار کل مبلغ را بدهکار می‌شد و + // مطالبه‌ای هم برای بیمه ساخته نمی‌شد. {@see InsuranceScopeResolver} + [$coverageType, $coverageId] = $this->coverageScope($session, $entityType, $entityId); + $visitPrice = $session->getVisitPriceRials(); if ($visitPrice > 0) { - $baseRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseId, $visitCategory); - $suppRule = $this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppId, $visitCategory); + $baseRule = $this->tenantInsuranceService->coverageRule($coverageType, $coverageId, $baseId, $visitCategory); + $suppRule = $this->tenantInsuranceService->coverageRule($coverageType, $coverageId, $suppId, $visitCategory); $breakdown = $this->calculator->calculateItem(new Money($visitPrice), $baseRule, $suppRule); $invoice->addItem(new InvoiceItem($invoice, 'ویزیت', $visitPrice, 1, $breakdown, null)); } @@ -122,8 +129,8 @@ class InvoiceService $unitPrice = $item->getPriceRials(); $total = new Money($unitPrice * $qty); - $baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseId, $item->getId()); - $suppRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppId, $item->getId()); + $baseRule = $this->tenantInsuranceService->coverageRuleForService($coverageType, $coverageId, $baseId, $item->getId()); + $suppRule = $this->tenantInsuranceService->coverageRuleForService($coverageType, $coverageId, $suppId, $item->getId()); $breakdown = $this->calculator->calculateItem($total, $baseRule, $suppRule); $invoice->addItem(new InvoiceItem($invoice, $item->getName(), $unitPrice, $qty, $breakdown, $item->getId())); @@ -132,6 +139,27 @@ class InvoiceService $invoice->recalculateTotals(); } + /** + * محیطی که قرارداد بیمهٔ این مراجعه در آن ثبت شده — اول پزشکِ نوبت، بعد کلینیکش. + * + * مراجعهٔ دستی نوبتی ندارد، پس پزشکی هم برای پرسیدن نیست و همان محیط صورتحساب + * می‌ماند؛ آنجا قرارداد در همان محیط ثبت می‌شود. + * + * @return array{0: string, 1: int} + */ + private function coverageScope(PatientSession $session, string $entityType, int $entityId): array + { + $appointment = $session->getAppointment(); + if ($appointment === null) { + return [$entityType, $entityId]; + } + + return $this->insuranceScope->forContracts( + (int) $appointment->getDoctor()->getId(), + $appointment->getClinic()?->getId() !== null ? (int) $appointment->getClinic()->getId() : null, + ); + } + /** نهایی‌سازی، و اعلامش به مصرف‌کننده‌های اثر جانبی (ساخت مطالبهٔ بیمه). */ public function finalize(Invoice $invoice): void { diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index f0904c73..6e6f41bc 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -165,6 +165,67 @@ class PatientService ->coveragePercent; } + /** + * سهم‌های مراجعهٔ یک نوبت را دوباره حساب می‌کند — برای وقتی بیمهٔ نوبت *بعد از* + * ساخته‌شدنِ مراجعه عوض می‌شود. + * + * بدون این، سهم‌ها همان لحظهٔ ساخت منجمد می‌شدند: نوبتی که بعداً بیمه گرفت، + * مراجعه‌اش همچنان صددرصد به گردن بیمار بود و صفحهٔ پرداخت مانده‌ای نشان می‌داد + * که بیمه باید می‌پرداخت. + * + * پرداخت‌های ثبت‌شده دست‌نخورده می‌مانند؛ فقط مبلغِ قابل‌پرداخت اصلاح می‌شود، پس + * مانده خودبه‌خود درست می‌شود و اضافه‌پرداخت در صفر کلمپ می‌شود. + */ + public function resyncInsuranceShares(Appointment $appointment): ?PatientSession + { + [$entityType, $entityId] = $this->appointmentInsurance->tenantOf($appointment); + + $session = $this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId); + if ($session === null) { + return null; + } + + [$coverageType, $coverageId] = $this->appointmentInsurance->contractScopeOf($appointment); + $category = $this->appointmentInsurance->effectiveCategory($appointment); + + $session->setInsuranceServiceCategory($category); + $session->setInsuranceBaseId($appointment->getInsuranceBaseId()); + $session->setInsuranceSupplementaryId($appointment->getInsuranceSupplementaryId()); + $session->setBaseInsuranceDiscountPercent( + $this->contractPercent($coverageType, $coverageId, $appointment->getInsuranceBaseId(), $category) + ); + $session->setSupplementaryDiscountPercent( + $this->contractPercent($coverageType, $coverageId, $appointment->getInsuranceSupplementaryId(), $category) + ); + + $shares = $this->calculateFinalPrice( + $session->getVisitPriceRials(), + array_map( + static fn (SessionService $line): array => [ + 'item_id' => $line->getServiceItem()->getId(), + 'price_rials' => $line->getLineTotalRials(), + ], + $session->getServices()->toArray(), + ), + $coverageType, + $coverageId, + $appointment->getInsuranceBaseId(), + $appointment->getInsuranceSupplementaryId(), + $category, + ); + + $session->applyShares( + $shares['gross_total_rials'], + $shares['base_insurance_rials'], + $shares['supplementary_insurance_rials'], + $shares['patient_share_rials'], + ); + + $this->sessionRepo->save($session); + + return $session; + } + /** * پرونده و مراجعهٔ خودکار برای یک نوبت قطعی‌شده. * @@ -246,15 +307,22 @@ class PatientService // بیمهٔ انتخاب‌شده روی نوبت مبنای محاسبه است؛ نوبتِ بدون بیمه مثل قبل کاملاً // سهم بیمار می‌ماند (coverageRule برای insuranceId=null، notCovered می‌دهد). + // + // درصد پوشش از همان محیطی خوانده می‌شود که خودِ قرارداد آنجاست — اول پزشک، + // بعد کلینیک. با محیطِ پرونده (کلینیک) قراردادِ ثبت‌شده روی پزشک دیده نمی‌شد و + // نتیجه‌اش صفر درصد پوشش بود: کل مبلغ به گردن بیمار می‌افتاد در حالی که + // نوبت بیمه داشت. {@see InsuranceScopeResolver} + [$coverageType, $coverageId] = $this->appointmentInsurance->contractScopeOf($appointment); + $category = $this->appointmentInsurance->effectiveCategory($appointment); $session->setInsuranceServiceCategory($category); $session->setInsuranceBaseId($appointment->getInsuranceBaseId()); $session->setInsuranceSupplementaryId($appointment->getInsuranceSupplementaryId()); $session->setBaseInsuranceDiscountPercent( - $this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category) + $this->contractPercent($coverageType, $coverageId, $appointment->getInsuranceBaseId(), $category) ); $session->setSupplementaryDiscountPercent( - $this->contractPercent($entityType, $entityId, $appointment->getInsuranceSupplementaryId(), $category) + $this->contractPercent($coverageType, $coverageId, $appointment->getInsuranceSupplementaryId(), $category) ); $shares = $this->calculateFinalPrice( @@ -263,8 +331,8 @@ class PatientService fn(SessionService $line) => ['item_id' => $line->getServiceItem()->getId(), 'price_rials' => $line->getLineTotalRials()], $lines, ), - $entityType, - $entityId, + $coverageType, + $coverageId, $appointment->getInsuranceBaseId(), $appointment->getInsuranceSupplementaryId(), $category, diff --git a/tests/Billing/ClinicAppointmentUsesDoctorContractTest.php b/tests/Billing/ClinicAppointmentUsesDoctorContractTest.php new file mode 100644 index 00000000..1a3a8b78 --- /dev/null +++ b/tests/Billing/ClinicAppointmentUsesDoctorContractTest.php @@ -0,0 +1,141 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر قرارداد شخصی'); + $doctor->setMobileNumber($doctorUser->getMobileNumber()); + $this->em->persist($doctor); + + $owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($owner); + $clinic->setName('کلینیک مطالبه'); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + + $insurance = new Insurance('بیمه مطالبه ' . random_int(1000, 9999), InsuranceType::Basic); + $this->em->persist($insurance); + $this->em->flush(); + + // قرارداد روی خودِ پزشک — همان کاری که پنل کلینیک می‌کند. + $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => $coveragePercent, + ]); + self::assertSame(201, $this->responseCode()); + + return [$owner, $doctor, $clinic, $insurance]; + } + + private function appointment(Doctor $doctor, Clinic $clinic, Insurance $insurance): Appointment + { + $appointment = $this->newAppointment( + $doctor, + $this->createUser(['ROLE_USER']), + time() + 3600, + time() + 5400, + $clinic, + ); + $appointment->setVisitPriceRials(self::VISIT_PRICE); + $appointment->setInsuranceBaseId($insurance->getId()); + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appointment); + $this->em->flush(); + + return $appointment; + } + + public function testTheInsuranceShareIsDeductedFromWhatThePatientOwes(): void + { + [, $doctor, $clinic, $insurance] = $this->scenario(30); + $appointment = $this->appointment($doctor, $clinic, $insurance); + + $session = static::getContainer()->get(PatientService::class) + ->autoCreateOnAppointmentConfirm($appointment); + + self::assertNotNull($session); + self::assertSame(990_000, $session->getBaseInsuranceRials(), 'سهم بیمه ۳۰٪ ویزیت است'); + self::assertSame(2_310_000, $session->getPatientShareRials()); + self::assertSame(2_310_000, $session->getRemainingRials(), 'مانده نباید شامل سهم بیمه باشد'); + } + + public function testTheInvoiceCarriesTheInsuranceShareSoAClaimCanBeBuilt(): void + { + [, $doctor, $clinic, $insurance] = $this->scenario(30); + $appointment = $this->appointment($doctor, $clinic, $insurance); + + $session = static::getContainer()->get(PatientService::class) + ->autoCreateOnAppointmentConfirm($appointment); + + $invoice = static::getContainer()->get(\App\Billing\Service\InvoiceService::class) + ->createFromSession($session, 'clinic', (int) $clinic->getId()); + + $shares = array_map( + static fn ($item): int => $item->getBaseInsuranceRials(), + $invoice->getItems()->toArray(), + ); + + self::assertContains(990_000, $shares, 'خط ویزیت باید سهم بیمه داشته باشد'); + + // صورتحساب همان‌جا نهایی می‌شود و مطالبهٔ بیمه از روی همین سهم ساخته می‌شود؛ + // با سهمِ صفر هیچ مطالبه‌ای ساخته نمی‌شد و صفحهٔ مطالبات خالی می‌ماند. + $claims = $this->em->getRepository(\App\Billing\Entity\Claim::class) + ->findBy(['entityType' => 'clinic', 'entityId' => (int) $clinic->getId()]); + + self::assertNotSame([], $claims, 'باید برای سهم بیمه مطالبه ساخته شود'); + self::assertSame(990_000, $claims[0]->getTotalClaimedRials()); + } + + /** نوبتِ بدون قرارداد، مثل قبل کاملاً سهم بیمار می‌ماند. */ + public function testAnAppointmentWithoutAnyContractStillBillsThePatientInFull(): void + { + [, $doctor, $clinic, ] = $this->scenario(30); + + $other = new Insurance('بیمه بی‌قرارداد ' . random_int(1000, 9999), InsuranceType::Basic); + $this->em->persist($other); + $this->em->flush(); + + $appointment = $this->newAppointment( + $doctor, + $this->createUser(['ROLE_USER']), + time() + 7200, + time() + 9000, + $clinic, + ); + $appointment->setVisitPriceRials(self::VISIT_PRICE); + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appointment); + $this->em->flush(); + + $session = static::getContainer()->get(PatientService::class) + ->autoCreateOnAppointmentConfirm($appointment); + + self::assertSame(0, $session->getBaseInsuranceRials()); + self::assertSame(self::VISIT_PRICE, $session->getPatientShareRials()); + } +}