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
@@ -0,0 +1,74 @@
<?php
namespace App\Billing\Command;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\ClaimService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* صورتحساب‌های نهایی‌شده‌ی بیمه‌داری که مطالبه‌شان ساخته نشده را جبران می‌کند.
*
* تا پیش از رویداد InvoiceFinalized، مطالبه فقط در مسیر «ثبت مراجعه» ساخته می‌شد؛
* صورتحسابی که از صفحه‌ی صورتحساب یا با ویرایش بعدی نهایی شده بود بدون مطالبه می‌ماند
* و در داشبورد مطالبات اصلاً دیده نمی‌شد.
*
* ddev exec php bin/console app:billing:backfill-claims --dry-run
*/
#[AsCommand(
name: 'app:billing:backfill-claims',
description: 'Create the missing claims of already finalized, insured invoices',
)]
class BackfillMissingClaimsCommand extends Command
{
public function __construct(
private readonly InvoiceRepository $invoiceRepo,
private readonly ClaimService $claimService,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی نساز');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$invoices = $this->invoiceRepo->findFinalizedInsuredWithoutClaims();
if ($invoices === []) {
$io->success('همه‌ی صورتحساب‌های بیمه‌دار مطالبه دارند.');
return Command::SUCCESS;
}
$rows = [];
$created = 0;
foreach ($invoices as $invoice) {
$claims = $dryRun ? [] : $this->claimService->syncFromInvoice($invoice);
$created += count($claims);
$rows[] = [
$invoice->getUuid(),
$invoice->getEntityType() . '#' . $invoice->getEntityId(),
number_format($invoice->getTotalRials()),
number_format($invoice->getTotalRials() - $invoice->getPatientRials()),
$dryRun ? '—' : count($claims),
];
}
$io->table(['صورتحساب', 'محیط', 'کل', 'سهم بیمه', 'مطالبهٔ ساخته‌شده'], $rows);
$io->success($dryRun
? sprintf('%d صورتحساب بدون مطالبه پیدا شد (dry-run).', count($invoices))
: sprintf('%d مطالبه برای %d صورتحساب ساخته شد.', $created, count($invoices)));
return Command::SUCCESS;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Billing\Event;
use App\Billing\Entity\Invoice;
/**
* صورتحساب نهایی شد. مصرف‌کننده‌ها اثرات جانبیِ نهایی‌شدن (مثل ساخت مطالبهٔ بیمه) را
* از اینجا می‌گیرند تا هر مسیری که صورتحساب را نهایی می‌کند خودش مجبور نباشد آن‌ها را
* تکرار کند — همان چیزی که نبودش باعث می‌شد صورتحساب‌های بیمه‌دار بدون مطالبه بمانند.
*/
final readonly class InvoiceFinalized
{
public function __construct(public Invoice $invoice) {}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Billing\EventSubscriber;
use App\Billing\Event\InvoiceFinalized;
use App\Billing\Service\ClaimService;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
/**
* هر صورتحساب نهایی‌شده‌ی بیمه‌دار باید مطالبه داشته باشد، از هر مسیری که نهایی شده باشد.
* خطا در این مرحله نباید نهایی‌شدن صورتحساب را برگرداند؛ فقط لاگ می‌شود.
*/
#[AsEventListener(event: InvoiceFinalized::class)]
class CreateClaimsOnInvoiceFinalized
{
public function __construct(
private readonly ClaimService $claimService,
private readonly LoggerInterface $logger,
) {}
public function __invoke(InvoiceFinalized $event): void
{
try {
$this->claimService->syncFromInvoice($event->invoice);
} catch (\Throwable $e) {
$this->logger->error('claim sync failed', [
'invoice' => $event->invoice->getUuid(),
'error' => $e->getMessage(),
]);
}
}
}
@@ -390,6 +390,26 @@ class ClaimRepository extends ServiceEntityRepository
return $count > 0;
}
/**
* نوع مطالبه‌هایی که برای این صورتحساب از قبل ثبت شده‌اند (`base` / `supplementary`).
* مبنای idempotent بودنِ ساخت خودکار مطالبه است.
*
* @return list<string>
*/
public function kindsForInvoice(int $invoiceId): array
{
$rows = $this->createQueryBuilder('c')
->select('DISTINCT c.insuranceKind AS kind')
->join('c.items', 'ci')
->join('App\Billing\Entity\InvoiceItem', 'ii', 'WITH', 'ii.id = ci.invoiceItemId')
->where('IDENTITY(ii.invoice) = :invoiceId')
->setParameter('invoiceId', $invoiceId)
->getQuery()
->getArrayResult();
return array_column($rows, 'kind');
}
public function save(Claim $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
@@ -29,6 +29,28 @@ class InvoiceRepository extends ServiceEntityRepository
return $this->findOneBy(['patientSessionId' => $patientSessionId]);
}
/**
* صورتحساب‌های نهایی‌شده‌ای که بیمه‌ای روی خودشان دارند ولی هیچ مطالبه‌ای برایشان
* ثبت نشده — بازمانده‌های دورانی که مطالبه فقط در یک مسیر ساخته می‌شد.
*
* @return Invoice[]
*/
public function findFinalizedInsuredWithoutClaims(): array
{
return $this->createQueryBuilder('i')
->where('i.status = :finalized')
->andWhere('i.baseInsuranceId IS NOT NULL OR i.supplementaryInsuranceId IS NOT NULL')
->andWhere('NOT EXISTS (
SELECT 1 FROM App\Billing\Entity\ClaimItem ci
JOIN App\Billing\Entity\InvoiceItem ii WITH ii.id = ci.invoiceItemId
WHERE IDENTITY(ii.invoice) = i.id
)')
->setParameter('finalized', Invoice::STATUS_FINALIZED)
->orderBy('i.id', 'ASC')
->getQuery()
->getResult();
}
/**
* A flat, newest-first page of a tenant's recorded (finalized/paid) invoices,
* one row per invoice with the patient's name and national code joined in.
+47
View File
@@ -72,6 +72,53 @@ class ClaimService
return $claims;
}
/**
* مطالبات جاافتاده‌ی یک صورتحساب نهایی‌شده را می‌سازد و مطالبات موجود را دست نمی‌زند.
* برخلاف createFromInvoice که یک عملِ کاربر است و با خطا جواب می‌دهد، این متد
* روی رویدادِ نهایی‌شدن صدا زده می‌شود و «کاری برای انجام نبود» حالت عادی‌اش است.
*
* @return Claim[] مطالبات تازه‌ساخته‌شده
*/
public function syncFromInvoice(Invoice $invoice): array
{
if ($invoice->getStatus() !== Invoice::STATUS_FINALIZED || $invoice->getId() === null) {
return [];
}
$existingKinds = $this->claimRepo->kindsForInvoice($invoice->getId());
$claims = [];
foreach ([
Claim::KIND_BASE => $invoice->getBaseInsuranceId(),
Claim::KIND_SUPPLEMENTARY => $invoice->getSupplementaryInsuranceId(),
] as $kind => $insuranceId) {
if ($insuranceId === null || in_array($kind, $existingKinds, true)) {
continue;
}
$claim = $this->buildClaim($invoice, $insuranceId, $kind);
if ($claim !== null) {
$claims[] = $claim;
}
}
if ($claims === []) {
return [];
}
foreach ($claims as $claim) {
$this->claimRepo->save($claim, false);
}
$this->claimRepo->getEntityManager()->flush();
foreach ($claims as $claim) {
$this->logTransition($claim, null, $claim->getStatus(), 'ایجاد مطالبه', null);
}
$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);
+6
View File
@@ -4,6 +4,7 @@ namespace App\Billing\Service;
use App\Billing\Entity\Invoice;
use App\Billing\Entity\InvoiceItem;
use App\Billing\Event\InvoiceFinalized;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\ValueObject\Money;
use App\ClinicService\Service\TariffService;
@@ -12,6 +13,7 @@ use App\Insurance\Repository\InsuranceRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Patient\Entity\PatientSession;
use App\Patient\Repository\PatientSessionRepository;
use Psr\EventDispatcher\EventDispatcherInterface;
class InvoiceService
{
@@ -22,6 +24,7 @@ class InvoiceService
private readonly BillingCalculator $calculator,
private readonly PatientSessionRepository $sessionRepo,
private readonly InsuranceRepository $insuranceRepo,
private readonly EventDispatcherInterface $events,
) {}
/** @var array<int, string|null> نام بیمه‌ها، یک‌بار در هر درخواست. */
@@ -89,10 +92,13 @@ class InvoiceService
return $invoice;
}
/** نهایی‌سازی، و اعلامش به مصرف‌کننده‌های اثر جانبی (ساخت مطالبهٔ بیمه). */
public function finalize(Invoice $invoice): void
{
$invoice->finalize();
$this->invoiceRepo->save($invoice);
$this->events->dispatch(new InvoiceFinalized($invoice));
}
/**
@@ -0,0 +1,48 @@
<?php
namespace App\Billing\Service;
use App\Billing\Entity\Invoice;
use App\Patient\Entity\PatientSession;
use Psr\Log\LoggerInterface;
/**
* مراجعهٔ بیمه‌دار باید صورتحساب نهایی‌شده داشته باشد — چون مطالبهٔ بیمه از دل همان
* صورتحساب بیرون می‌آید و بدونش، مراجعه در داشبورد مطالبات اصلاً دیده نمی‌شود.
*
* تنها جای این قاعده همین‌جاست تا هر سه مسیرِ ساختِ مراجعه (ثبت دستی، ویرایش، و
* قطعی‌کردن نوبت) یک رفتار داشته باشند؛ پراکندگیِ قبلیِ همین قاعده باعث شده بود
* مراجعه‌های قطعی‌شده از نوبت هیچ‌وقت مطالبه نگیرند.
*/
class SessionBillingService
{
public function __construct(
private readonly InvoiceService $invoiceService,
private readonly LoggerInterface $logger,
) {}
/**
* صورتحساب مراجعه را می‌سازد و نهایی می‌کند. مراجعهٔ بدون بیمه دست‌نخورده می‌ماند.
* شکست اینجا نباید ثبت مراجعه یا قطعی‌کردن نوبت را برگرداند.
*/
public function ensureFinalizedInvoice(PatientSession $session, string $entityType, int $entityId): ?Invoice
{
if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) {
return null;
}
try {
$invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId);
$this->invoiceService->finalize($invoice);
return $invoice;
} catch (\Throwable $e) {
$this->logger->error('finalizing the insured session invoice failed', [
'session' => $session->getUuid(),
'error' => $e->getMessage(),
]);
return null;
}
}
}