feat: insurance & medical billing system (6 phases)

Multi-tenant insurance contracts, service coverage, versioned tariffs,
invoice calculation, and insurance claims with debt reporting.

- TenantInsurance: per-tenant insurance contracts (coverage/franchise/ceiling,
  versioning, soft-deactivate) + active guard
- ServiceItem.insuranceCovered + TenantServiceCoverage per-service overrides
- Tariff: versioned yearly tariffs with fallback to ServiceItem price
- Billing domain: Money/ShareBreakdown VOs, BillingCalculator (unit-tested),
  Invoice/InvoiceItem aggregate, InvoiceService.createFromSession
- Claim/ClaimItem with state machine (pending->submitted->approved/rejected->paid),
  ClaimService, insurance-debt report
- ClaimSubmitterInterface + ManualClaimSubmitter (future insurance API ready)
- Admin UI: insurance-pricing page, claims page, service tariff modal,
  service insurance toggle; routes + sidebar entries
- Architecture doc + billing/insurance/clinic-services API docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-23 15:05:24 +03:30
co-authored by Claude Opus 4.8
parent 5b1dfe9b40
commit 89191eee57
54 changed files with 4233 additions and 10 deletions
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Billing\Service;
use App\Billing\Contract\ClaimSubmitterInterface;
use App\Billing\Entity\Claim;
use App\Billing\Entity\ClaimItem;
use App\Billing\Entity\Invoice;
use App\Billing\Repository\ClaimRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
class ClaimService
{
public function __construct(
private readonly ClaimRepository $claimRepo,
private readonly ClaimSubmitterInterface $submitter,
) {}
/**
* ساخت مطالبات از یک صورتحساب نهایی‌شده.
* یک Claim برای بیمه‌ی پایه و یک Claim برای بیمه‌ی مکمل (در صورت وجود سهم).
* @return Claim[]
*/
public function createFromInvoice(Invoice $invoice): array
{
if ($invoice->getStatus() !== Invoice::STATUS_FINALIZED) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فقط از صورتحساب نهایی‌شده می‌توان مطالبه ساخت', 422);
}
$claims = [];
$baseId = $invoice->getBaseInsuranceId();
if ($baseId !== null) {
$claim = $this->buildClaim($invoice, $baseId, Claim::KIND_BASE);
if ($claim !== null) {
$claims[] = $claim;
}
}
$suppId = $invoice->getSupplementaryInsuranceId();
if ($suppId !== null) {
$claim = $this->buildClaim($invoice, $suppId, Claim::KIND_SUPPLEMENTARY);
if ($claim !== null) {
$claims[] = $claim;
}
}
if ($claims === []) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'سهم بیمه‌ای برای این صورتحساب وجود ندارد', 422);
}
foreach ($claims as $claim) {
$this->claimRepo->save($claim, false);
}
$this->claimRepo->getEntityManager()->flush();
return $claims;
}
private function buildClaim(Invoice $invoice, int $insuranceId, string $kind): ?Claim
{
$claim = new Claim($invoice->getEntityType(), $invoice->getEntityId(), $insuranceId, $kind);
$hasShare = false;
foreach ($invoice->getItems() as $item) {
$share = $kind === Claim::KIND_BASE
? $item->getBaseInsuranceRials()
: $item->getSupplementaryRials();
if ($share > 0) {
$claim->addItem(new ClaimItem($claim, $item->getId(), $share));
$hasShare = true;
}
}
return $hasShare ? $claim : null;
}
public function transition(Claim $claim, string $target, array $opts = []): void
{
if (!$claim->canTransitionTo($target)) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('انتقال از «%s» به «%s» مجاز نیست', $claim->getStatus(), $target),
422
);
}
match ($target) {
Claim::STATUS_SUBMITTED => $this->doSubmit($claim),
Claim::STATUS_APPROVED => $claim->approve($opts['approved_rials'] ?? null),
Claim::STATUS_REJECTED => $claim->reject($opts['reason'] ?? ''),
Claim::STATUS_PAID => $claim->pay($opts['paid_rials'] ?? null),
default => throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'وضعیت نامعتبر', 422),
};
$this->claimRepo->save($claim);
}
private function doSubmit(Claim $claim): void
{
$result = $this->submitter->submit($claim);
if (!$result->success) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, $result->errorMessage ?? 'ارسال مطالبه ناموفق بود', 422);
}
$claim->submit();
}
}