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:
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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): مقدار از خود قانون، با ثبت منبع.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user