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()); + } +}