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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user