feat: Enhance insurance billing system to support supplementary insurance

- Updated CoverageRule and related entities to include franchise_percent instead of franchise_rials.
- Modified Appointment entity to carry supplementary insurance ID alongside base insurance.
- Implemented SessionBillingService to ensure finalized invoices for insured patient sessions.
- Created InvoiceFinalized event to trigger claims creation upon invoice finalization.
- Added BackfillMissingClaimsCommand to generate claims for finalized invoices without existing claims.
- Developed tests to validate the new functionality for supplementary insurance handling in appointments and claims.
This commit is contained in:
hamed
2026-07-29 19:57:02 +03:30
parent 9b05c6d1ff
commit e6267080b2
25 changed files with 876 additions and 126 deletions
@@ -1059,6 +1059,7 @@ class AppointmentController extends BaseController
// تفکیک بیمه — مودالِ قطعی‌کردن همان مبلغی را نشان می‌دهد که ثبت شده.
'insurance_service_category' => $session->getInsuranceServiceCategory()?->value,
'insurance_base_id' => $session->getInsuranceBaseId(),
'insurance_supplementary_id' => $session->getInsuranceSupplementaryId(),
'gross_total_rials' => $session->getGrossTotalRials(),
'base_insurance_rials' => $session->getBaseInsuranceRials(),
'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(),
+7
View File
@@ -195,6 +195,10 @@ class Appointment
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
private ?int $insuranceBaseId = null;
/** بیمهٔ تکمیلی روی باقیماندهٔ بعد از بیمهٔ پایه محاسبه می‌شود، نه روی کل مبلغ. */
#[ORM\Column(name: 'insurance_supplementary_id', type: 'integer', nullable: true)]
private ?int $insuranceSupplementaryId = null;
/**
* Reserve-list entry (نوبت رزرو): booked for a day, not a time slot.
* slotStart/slotEnd hold that day's midnight so date queries keep working.
@@ -291,6 +295,7 @@ class Appointment
public function getVisitPriceRials(): ?int { return $this->visitPriceRials; }
public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; }
public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; }
public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; }
public function isReserve(): bool { return $this->isReserve; }
public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; }
@@ -301,6 +306,7 @@ class Appointment
public function setVisitPriceRials(?int $v): self { $this->visitPriceRials = $v; return $this; }
public function setInsuranceServiceCategory(?ServiceCategory $v): self { $this->insuranceServiceCategory = $v; $this->updatedAt = time(); return $this; }
public function setInsuranceBaseId(?int $v): self { $this->insuranceBaseId = $v; $this->updatedAt = time(); return $this; }
public function setInsuranceSupplementaryId(?int $v): self { $this->insuranceSupplementaryId = $v; $this->updatedAt = time(); return $this; }
/**
* Move the appointment to a new slot (جا به جایی نوبت) and/or flip its
@@ -422,6 +428,7 @@ class Appointment
'insurance_service_category' => $this->insuranceServiceCategory?->value,
'insurance_service_category_label' => $this->insuranceServiceCategory?->label(),
'insurance_base_id' => $this->insuranceBaseId,
'insurance_supplementary_id' => $this->insuranceSupplementaryId,
'is_reserve' => $this->isReserve,
'version' => $this->version,
'created_at' => $this->createdAt,
@@ -73,7 +73,13 @@ class AppointmentInsuranceService
if (array_key_exists('insurance_base_id', $data)) {
$appointment->setInsuranceBaseId(
$this->resolveBaseInsuranceId($data['insurance_base_id'], $entityType, $entityId)
$this->resolveInsuranceId($data['insurance_base_id'], $entityType, $entityId, false)
);
}
if (array_key_exists('insurance_supplementary_id', $data)) {
$appointment->setInsuranceSupplementaryId(
$this->resolveInsuranceId($data['insurance_supplementary_id'], $entityType, $entityId, true)
);
}
}
@@ -106,8 +112,15 @@ class AppointmentInsuranceService
return $category;
}
private function resolveBaseInsuranceId(mixed $raw, string $entityType, int $entityId): ?int
/**
* شناسهٔ بیمهٔ معتبر برای این نوبت، یا null وقتی انتخاب پاک شده است.
* قرارداد باید فعال باشد و نوعش با جایگاهی که در آن انتخاب شده بخواند —
* بیمهٔ تکمیلی نمی‌تواند جای بیمهٔ پایه بنشیند و برعکس.
*/
private function resolveInsuranceId(mixed $raw, string $entityType, int $entityId, bool $supplementary): ?int
{
$field = $supplementary ? 'insurance_supplementary_id' : 'insurance_base_id';
if ($raw === null || $raw === '' || (int) $raw <= 0) {
return null;
}
@@ -119,18 +132,20 @@ class AppointmentInsuranceService
ErrorCodes::ERR_VALIDATION_001,
'این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد',
422,
'insurance_base_id',
$field,
);
}
// نوعِ قرارداد بر نوع کاتالوگ اولویت دارد — همان قاعدهٔ TenantInsuranceService.
$kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value;
if ($kind === InsuranceType::Supplementary->value) {
$kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value;
$isSupp = $kind === InsuranceType::Supplementary->value;
if ($isSupp !== $supplementary) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'برای نوبت فقط بیمهٔ پایه قابل انتخاب است',
$supplementary ? 'اینجا فقط بیمهٔ تکمیلی قابل انتخاب است' : 'اینجا فقط بیمهٔ پایه قابل انتخاب است',
422,
'insurance_base_id',
$field,
);
}