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,
);
}
@@ -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;
}
}
}
+7 -30
View File
@@ -5,9 +5,7 @@ namespace App\Patient\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Billing\Repository\InvoiceRepository;
use App\Billing\Service\ClaimService;
use App\Billing\Service\InvoiceService;
use Psr\Log\LoggerInterface;
use App\Billing\Service\SessionBillingService;
use App\Patient\Entity\PatientRecord;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
@@ -40,8 +38,7 @@ class PatientController extends BaseController
private readonly PatientRecordScopeResolver $scopeResolver,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo,
private readonly InvoiceService $invoiceService,
private readonly ClaimService $claimService,
private readonly SessionBillingService $sessionBilling,
private readonly InvoiceRepository $invoiceRepo,
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
@@ -60,7 +57,6 @@ class PatientController extends BaseController
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly LoggerInterface $logger,
) {}
// ── Financials (مالی: پرداخت / تراکنش / کیف‌پول) ────────────────────────────
@@ -1058,34 +1054,11 @@ class PatientController extends BaseController
$data = json_decode($request->getContent(), true) ?? [];
$session = $this->patientService->createSession($record, $data, $entityType, $entityId);
$this->autoCreateClaim($session, $entityType, $entityId);
$this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId);
return $this->success($this->sessionWithBilling($session), 201);
}
/**
* اتصال خودکار مطالبه‌ی بیمه: اگر session بیمه‌ی پایه یا مکمل داشته باشد،
* صورتحساب ساخته و نهایی می‌شود و مطالبه (در وضعیت pending) ایجاد می‌گردد.
* ارسال مطالبه دستی از صفحه‌ی مطالبات انجام می‌شود.
* خطا در این مرحله نباید ثبت session را خراب کند.
*/
private function autoCreateClaim(\App\Patient\Entity\PatientSession $session, string $entityType, int $entityId): void
{
if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) {
return;
}
try {
$invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId);
$this->invoiceService->finalize($invoice);
$this->claimService->createFromInvoice($invoice);
} catch (\App\Shared\Exception\AppException $e) {
// سهم بیمه‌ای صفر بود یا صورتحساب از قبل وجود داشت — قابل چشم‌پوشی.
$this->logger->info('auto claim skipped', ['session' => $session->getUuid(), 'reason' => $e->getMessage()]);
} catch (\Throwable $e) {
$this->logger->error('auto claim failed', ['session' => $session->getUuid(), 'error' => $e->getMessage()]);
}
}
#[Route('/api/v1/session/{uuid}', methods: ['PATCH'])]
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
@@ -1112,6 +1085,10 @@ class PatientController extends BaseController
|| array_key_exists('visit_price_rials', $data) || array_key_exists('insurance_base_id', $data)
|| array_key_exists('base_insurance_discount_percent', $data)) {
$this->patientService->updateSessionServices($session, $data, $entityType, $entityId, $user);
// بیمه ممکن است همین حالا به مراجعه اضافه شده باشد؛ بدون این، مراجعه‌ای که
// بیمه‌اش بعداً ثبت می‌شود هرگز صورتحساب و مطالبه نمی‌گیرد.
$this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId);
}
// تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع.
+10 -1
View File
@@ -52,6 +52,7 @@ class PatientService
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly AppointmentInsuranceService $appointmentInsurance,
private readonly BillingCalculator $billingCalculator,
private readonly \App\Billing\Service\SessionBillingService $sessionBilling,
private readonly WalletService $walletService,
private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly \App\Discount\Service\DiscountEngine $discountEngine,
@@ -238,9 +239,13 @@ class PatientService
$category = $this->appointmentInsurance->effectiveCategory($appointment);
$session->setInsuranceServiceCategory($category);
$session->setInsuranceBaseId($appointment->getInsuranceBaseId());
$session->setInsuranceSupplementaryId($appointment->getInsuranceSupplementaryId());
$session->setBaseInsuranceDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category)
);
$session->setSupplementaryDiscountPercent(
$this->contractPercent($entityType, $entityId, $appointment->getInsuranceSupplementaryId(), $category)
);
$shares = $this->calculateFinalPrice(
$visitPrice,
@@ -251,7 +256,7 @@ class PatientService
$entityType,
$entityId,
$appointment->getInsuranceBaseId(),
null,
$appointment->getInsuranceSupplementaryId(),
$category,
);
@@ -269,6 +274,10 @@ class PatientService
$session->addService($line);
}
// مراجعهٔ بیمه‌دارِ آمده از نوبت هم باید صورتحساب و مطالبه بگیرد؛ بدون این،
// نوبت‌های قطعی‌شده هرگز به داشبورد مطالبات نمی‌رسیدند.
$this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId);
return $session;
}