Refactor insurance share calculation logic in PatientService

- Consolidated the calculation of patient and insurance shares into a single method using BillingCalculator.
- Introduced new fields in PatientSession to store breakdown of insurance shares and patient share.
- Updated the API responses to include the new fields for consistency across payment, invoice, and claims dashboard.
- Added migration to backfill existing sessions with appropriate values for the new fields.
- Implemented tests to ensure the correctness of the new logic and verify that the breakdown sums to the gross total.
- Redesigned the claims dashboard to provide a more user-friendly overview of patient claims and their statuses.
This commit is contained in:
hamed
2026-07-18 22:56:46 +03:30
parent 466b649988
commit b3a5cda808
16 changed files with 842 additions and 110 deletions
+65 -28
View File
@@ -86,44 +86,69 @@ class PatientService
}
/**
* محاسبه‌ی سهم بیمار.
* ویزیت با درصد تخفیف انتخاب‌شده در فرم؛ هر خدمت با قاعده‌ی پوشش همان بیمه‌گر برای همان خدمت
* (TenantServiceCoverage از طریق BillingCalculator). خدمتی که آن بیمه را پوشش نمی‌دهد، کامل بر عهده‌ی بیمار است.
* تفکیک سهم بیمه‌ها و سهم بیمار برای یک مراجعه.
*
* ویزیت با قاعده‌ی قرارداد بیمه (coverageRule) و هر خدمت با قاعده‌ی پوشش همان خدمت
* (coverageRuleForService) محاسبه می‌شود — هر دو از طریق BillingCalculator، همان مسیری
* که InvoiceService برای صدور فاکتور استفاده می‌کند. تنها منبع محاسبه همین است تا مبلغ
* صفحه‌ی پرداخت و فاکتور نتوانند از هم واگرا شوند.
*
* @param array<array{item_id: int, price_rials: int}> $serviceItems قیمت کل هر ردیف (با احتساب تعداد)
* @return array{services_total_rials: int, gross_total_rials: int, base_insurance_rials: int, supplementary_insurance_rials: int, patient_share_rials: int, final_price_rials: int}
*/
public function calculateFinalPrice(
int $visitPrice,
float $baseDiscount,
float $suppDiscount,
array $serviceItems,
string $entityType = 'doctor',
int $entityId = 0,
?int $baseInsuranceId = null,
?int $suppInsuranceId = null,
): array {
$afterBase = $visitPrice * (1 - $baseDiscount / 100);
$afterSupp = $afterBase * (1 - $suppDiscount / 100);
$visitShare = (int) round($afterSupp);
$baseShare = 0;
$suppShare = 0;
$patientShare = 0;
$servicesTotal = 0;
$servicesPatient = 0;
if ($visitPrice > 0) {
$visit = $this->billingCalculator->calculateItem(
new Money($visitPrice),
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $baseInsuranceId),
$this->tenantInsuranceService->coverageRule($entityType, $entityId, $suppInsuranceId),
);
$baseShare += $visit->baseInsuranceRials;
$suppShare += $visit->supplementaryRials;
$patientShare += $visit->patientRials;
}
$servicesTotal = 0;
foreach ($serviceItems as $svc) {
$servicesTotal += $svc['price_rials'];
$baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseInsuranceId, $svc['item_id']);
$suppRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppInsuranceId, $svc['item_id']);
$breakdown = $this->billingCalculator->calculateItem(new Money($svc['price_rials']), $baseRule, $suppRule);
$servicesPatient += $breakdown->patientRials;
$line = $this->billingCalculator->calculateItem(
new Money($svc['price_rials']),
$this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseInsuranceId, $svc['item_id']),
$this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $suppInsuranceId, $svc['item_id']),
);
$baseShare += $line->baseInsuranceRials;
$suppShare += $line->supplementaryRials;
$patientShare += $line->patientRials;
}
return [
'services_total_rials' => $servicesTotal,
'final_price_rials' => $visitShare + $servicesPatient,
'services_total_rials' => $servicesTotal,
'gross_total_rials' => $visitPrice + $servicesTotal,
'base_insurance_rials' => $baseShare,
'supplementary_insurance_rials' => $suppShare,
'patient_share_rials' => $patientShare,
'final_price_rials' => $patientShare,
];
}
/** درصد پوشش قرارداد فعال — snapshot روی مراجعه، نه ورودی محاسبه. */
private function contractPercent(string $entityType, int $entityId, ?int $insuranceId): float
{
return $this->tenantInsuranceService->coverageRule($entityType, $entityId, $insuranceId)->coveragePercent;
}
/**
* پرونده و مراجعهٔ خودکار برای یک نوبت قطعی‌شده.
*
@@ -190,7 +215,9 @@ class PatientService
}
$session->setServicesTotalRials($servicesTotal);
$session->setFinalPriceRials($servicesTotal + $visitPrice);
// پذیرش خودکار بیمه‌ای ندارد: تمام مبلغ سهم بیمار است.
$gross = $servicesTotal + $visitPrice;
$session->applyShares($gross, 0, 0, $gross);
$this->sessionRepo->save($session);
@@ -222,8 +249,8 @@ class PatientService
$session->setInsuranceBaseId(isset($data['insurance_base_id']) ? (int) $data['insurance_base_id'] : null);
$session->setInsuranceSupplementaryId(isset($data['insurance_supplementary_id']) ? (int) $data['insurance_supplementary_id'] : null);
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
$session->setBaseInsuranceDiscountPercent((float) ($data['base_insurance_discount_percent'] ?? 0));
$session->setSupplementaryDiscountPercent((float) ($data['supplementary_discount_percent'] ?? 0));
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId()));
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId()));
$session->setPaymentMethod($data['payment_method'] ?? 'pending');
$session->setNotes($data['notes'] ?? null);
@@ -252,8 +279,6 @@ class PatientService
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
$session->getBaseInsuranceDiscountPercent(),
$session->getSupplementaryDiscountPercent(),
$serviceItemsData,
$entityType,
$entityId,
@@ -277,7 +302,13 @@ class PatientService
$consumablesTotal += $item->getPrice() * $qty;
}
$session->setFinalPriceRials($priceCalc['final_price_rials'] + $consumablesTotal);
// کالاهای مصرفی پوشش بیمه ندارند: مستقیم به سهم بیمار و به جمع کل اضافه می‌شوند.
$session->applyShares(
$priceCalc['gross_total_rials'] + $consumablesTotal,
$priceCalc['base_insurance_rials'],
$priceCalc['supplementary_insurance_rials'],
$priceCalc['patient_share_rials'] + $consumablesTotal,
);
$this->sessionRepo->save($session);
@@ -365,18 +396,25 @@ class PatientService
fn(SessionService $s) => ['item_id' => $s->getServiceItem()->getId(), 'price_rials' => $s->getLineTotalRials()],
$session->getServices()->toArray(),
);
$session->setBaseInsuranceDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceBaseId()));
$session->setSupplementaryDiscountPercent($this->contractPercent($entityType, $entityId, $session->getInsuranceSupplementaryId()));
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
$session->getBaseInsuranceDiscountPercent(),
$session->getSupplementaryDiscountPercent(),
$serviceItemsData,
$entityType,
$entityId,
$session->getInsuranceBaseId(),
$session->getInsuranceSupplementaryId(),
);
$consumablesTotal = $session->getConsumablesTotalRials();
$session->setServicesTotalRials($priceCalc['services_total_rials']);
$session->setFinalPriceRials($priceCalc['final_price_rials'] + $session->getConsumablesTotalRials());
$session->applyShares(
$priceCalc['gross_total_rials'] + $consumablesTotal,
$priceCalc['base_insurance_rials'],
$priceCalc['supplementary_insurance_rials'],
$priceCalc['patient_share_rials'] + $consumablesTotal,
);
$this->sessionRepo->save($session);
// پس از تغییر مبلغ، وضعیت تسویه بازمحاسبه شود (افزودن سرویس/پکیج → بدهکار).
@@ -417,8 +455,7 @@ class PatientService
}
// مجموع پرداخت‌ها (با مقدار جدید) نباید از مبلغِ پس از تخفیف بیشتر شود.
$othersTotal = $session->getPaidTotalRials() - $oldAmount;
$payable = $session->getFinalPriceRials() - $session->getDiscountRials();
if ($othersTotal + $newAmount > $payable) {
if ($othersTotal + $newAmount > $session->getPayableRials()) {
throw new AppException(ErrorCodes::ERR_SESSION_PAYMENT_EXCEEDS, null, 422, 'amount_rials');
}