feat(invoice): synchronize invoice totals with patient session updates and add resync command
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Billing\Command;
|
||||
|
||||
use App\Billing\Entity\Invoice;
|
||||
use App\Billing\Repository\InvoiceRepository;
|
||||
use App\Billing\Service\InvoiceService;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
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;
|
||||
|
||||
/**
|
||||
* صورتحسابهایی که با مراجعهٔ خودشان همتراز نیستند را دوباره میسازد.
|
||||
*
|
||||
* صورتحساب عکسِ لحظهٔ ساخت بود و ویرایشِ بعدیِ مراجعه به آن نمیرسید؛ نتیجهاش
|
||||
* صورتحسابی با مبلغِ قدیمی بود که چون پرداختیها از آن بیشتر بودند «پرداختشده» دیده
|
||||
* میشد، و آمار فهرست پرداختها (مجموع/تسویهنشده) هم از همان ستونها غلط درمیآمد.
|
||||
* از این پس هر ویرایشِ مراجعه صورتحسابش را همتراز میکند؛ این دستور فقط برای جبرانِ
|
||||
* انحرافِ گذشته است.
|
||||
*
|
||||
* ddev exec php bin/console app:billing:resync-invoices --dry-run
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:billing:resync-invoices',
|
||||
description: 'Rebuild invoices whose totals drifted from their patient session',
|
||||
)]
|
||||
class ResyncSessionInvoicesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InvoiceRepository $invoiceRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly InvoiceService $invoiceService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
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');
|
||||
|
||||
$drifted = 0;
|
||||
$rows = [];
|
||||
|
||||
foreach ($this->invoiceRepo->findAll() as $invoice) {
|
||||
if ($invoice->getStatus() === Invoice::STATUS_VOID || $invoice->getPatientSessionId() === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$session = $this->sessionRepo->find($invoice->getPatientSessionId());
|
||||
if ($session === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$before = $invoice->getTotalRials();
|
||||
// مبلغِ درست از خودِ منطقِ ساخت میآید، نه از یک محاسبهٔ موازی در این دستور.
|
||||
$after = $dryRun
|
||||
? $this->previewTotal($session, $invoice)
|
||||
: $this->invoiceService->syncFromSession($session, $invoice->getEntityType(), $invoice->getEntityId())?->getTotalRials() ?? $before;
|
||||
|
||||
if ($before === $after) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$drifted++;
|
||||
$rows[] = [$invoice->getUuid(), $before, $after];
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
$io->table(['invoice', 'before (rials)', 'after (rials)'], $rows);
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
$dryRun ? '%d صورتحساب منحرف است (چیزی نوشته نشد)' : '%d صورتحساب همتراز شد',
|
||||
$drifted,
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* مبلغِ درستِ همین صورتحساب بدون ذخیره — برای `--dry-run`.
|
||||
* روی همان entity کار میکند و در پایان چیزی flush نمیشود.
|
||||
*/
|
||||
private function previewTotal(\App\Patient\Entity\PatientSession $session, Invoice $invoice): int
|
||||
{
|
||||
$this->em->beginTransaction();
|
||||
|
||||
try {
|
||||
$synced = $this->invoiceService->syncFromSession($session, $invoice->getEntityType(), $invoice->getEntityId());
|
||||
|
||||
return $synced?->getTotalRials() ?? $invoice->getTotalRials();
|
||||
} finally {
|
||||
$this->em->rollback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,18 @@ class Invoice
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* خالیکردن خطوط برای بازسازی از روی مراجعه.
|
||||
* `orphanRemoval` ردیفهای جداشده را حذف میکند، پس نیازی به حذف دستی نیست.
|
||||
*/
|
||||
public function clearItems(): self
|
||||
{
|
||||
$this->items->clear();
|
||||
$this->updatedAt = time();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function recalculateTotals(): void
|
||||
{
|
||||
$total = $base = $supp = $patient = 0;
|
||||
|
||||
@@ -185,34 +185,47 @@ class InvoiceRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum `$field` over a prepared invoice query, split by paid vs. still unsettled.
|
||||
* Sum `$field` over a prepared invoice query, split by collected vs. still owed.
|
||||
*
|
||||
* «پرداختشده» **مبلغِ وصولشده** است، نه مجموعِ صورتحسابهای کاملاً تسویهشده:
|
||||
* با شمارشِ صورتحسابمحور، فاکتوری که نیمی از آن وصول شده بود صفر حساب میشد و کلِ
|
||||
* مبلغش در «تسویهنشده» مینشست — یعنی پولِ گرفتهشده نامرئی و بدهی بزرگتر از
|
||||
* واقعیت نشان داده میشد.
|
||||
*
|
||||
* جمع per-invoice در PHP بسته میشود چون DQL نه `LEAST` دارد و نه اجازهٔ `SUM`
|
||||
* روی زیرکوئریِ همبسته؛ هر ردیف فقط دو عدد است.
|
||||
*
|
||||
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
|
||||
*/
|
||||
private function summarize(QueryBuilder $qb, string $field): array
|
||||
{
|
||||
$row = (clone $qb)
|
||||
$rows = (clone $qb)
|
||||
->select(
|
||||
sprintf('COALESCE(SUM(i.%s), 0) AS total_rials', $field),
|
||||
'COUNT(i.id) AS invoices_count',
|
||||
sprintf('i.%s AS amount_rials', $field),
|
||||
'i.patientRials AS due_rials',
|
||||
sprintf('%s AS paid_rials', self::paidSumDql('sp_sum')),
|
||||
)
|
||||
->getQuery()
|
||||
->getSingleResult();
|
||||
->getArrayResult();
|
||||
|
||||
// «پرداختشده» = مجموع صورتحسابهایی که سهم بیمارشان کامل وصول شده
|
||||
$paid = (int) (clone $qb)
|
||||
->select(sprintf('COALESCE(SUM(i.%s), 0)', $field))
|
||||
->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_sum')))
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
$total = $paid = $unsettled = 0;
|
||||
foreach ($rows as $row) {
|
||||
$total += (int) $row['amount_rials'];
|
||||
|
||||
$total = (int) $row['total_rials'];
|
||||
// بدهی و وصولی همیشه روی **سهم بیمار** سنجیده میشوند، حتی وقتی ستونِ
|
||||
// «مجموع» جمعِ کلِ صورتحساب است: بیمار سهم بیمه را بدهکار نیست.
|
||||
$due = (int) $row['due_rials'];
|
||||
$part = min((int) $row['paid_rials'], $due);
|
||||
|
||||
$paid += $part;
|
||||
$unsettled += $due - $part;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_rials' => $total,
|
||||
'paid_rials' => $paid,
|
||||
'unsettled_rials' => $total - $paid,
|
||||
'invoices_count' => (int) $row['invoices_count'],
|
||||
'unsettled_rials' => $unsettled,
|
||||
'invoices_count' => count($rows),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,51 @@ class InvoiceService
|
||||
|
||||
$invoice = new Invoice($entityType, $entityId);
|
||||
$invoice->setPatientSessionId($session->getId())
|
||||
->setPatientRecordId($session->getRecord()->getId())
|
||||
->setBaseInsuranceId($session->getInsuranceBaseId())
|
||||
->setPatientRecordId($session->getRecord()->getId());
|
||||
|
||||
$this->fillFromSession($invoice, $session, $entityType, $entityId);
|
||||
$this->invoiceRepo->save($invoice);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* همترازکردن صورتحسابِ موجود با مراجعهاش — پس از ویرایش سرویس/کالا/ویزیت/بیمه.
|
||||
*
|
||||
* صورتحساب عکسِ لحظهٔ ساخت بود و ویرایشِ بعدیِ مراجعه هرگز به آن نمیرسید: مراجعهای
|
||||
* که بعداً سرویس گرفت، صورتحسابش روی مبلغ قدیمی میماند و چون پرداختیها از همان
|
||||
* مبلغِ کوچک بیشتر بودند، «پرداختشده» دیده میشد. فهرست پرداختها هم مجموع و
|
||||
* تسویهنشده را از همین ستونها میسازد، پس خطا تا آمار بالای صفحه میرفت.
|
||||
*
|
||||
* `null` یعنی این مراجعه هنوز صورتحسابی ندارد؛ ساختش سیاست جای دیگری است.
|
||||
*/
|
||||
public function syncFromSession(PatientSession $session, string $entityType, int $entityId): ?Invoice
|
||||
{
|
||||
$invoice = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null;
|
||||
if ($invoice === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// صورتحساب باطلشده دیگر دنبال مراجعه نمیآید؛ بازنویسیاش یعنی زندهکردن سندی
|
||||
// که عمداً کنار گذاشته شده.
|
||||
if ($invoice->getStatus() === Invoice::STATUS_VOID) {
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
$invoice->clearItems();
|
||||
$this->fillFromSession($invoice, $session, $entityType, $entityId);
|
||||
$this->invoiceRepo->save($invoice);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* خطوطِ صورتحساب از روی مراجعه: ویزیت + هر سرویس، با قاعدهٔ پوشش بیمهٔ همان محیط.
|
||||
* تنها جای ساختِ خطوط است تا «ساخت» و «همترازسازی» از هم واگرا نشوند.
|
||||
*/
|
||||
private function fillFromSession(Invoice $invoice, PatientSession $session, string $entityType, int $entityId): void
|
||||
{
|
||||
$invoice->setBaseInsuranceId($session->getInsuranceBaseId())
|
||||
->setSupplementaryInsuranceId($session->getInsuranceSupplementaryId());
|
||||
|
||||
$baseId = $session->getInsuranceBaseId();
|
||||
@@ -87,9 +130,6 @@ class InvoiceService
|
||||
}
|
||||
|
||||
$invoice->recalculateTotals();
|
||||
$this->invoiceRepo->save($invoice);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
/** نهاییسازی، و اعلامش به مصرفکنندههای اثر جانبی (ساخت مطالبهٔ بیمه). */
|
||||
|
||||
@@ -24,9 +24,28 @@ class SessionBillingService
|
||||
/**
|
||||
* صورتحساب مراجعه را میسازد و نهایی میکند. مراجعهٔ بدون بیمه دستنخورده میماند.
|
||||
* شکست اینجا نباید ثبت مراجعه یا قطعیکردن نوبت را برگرداند.
|
||||
*
|
||||
* اگر صورتحسابی از قبل هست — چه بیمهدار، چه ساختهشده از صفحهٔ پرداخت — با محتوای
|
||||
* فعلیِ مراجعه همتراز میشود؛ وگرنه ویرایشِ سرویسها روی مبلغِ قدیمیِ صورتحساب
|
||||
* مینشست و «تسویهشده/تسویهنشده» و آمار فهرست پرداختها را غلط نشان میداد.
|
||||
*/
|
||||
public function ensureFinalizedInvoice(PatientSession $session, string $entityType, int $entityId): ?Invoice
|
||||
{
|
||||
try {
|
||||
$synced = $this->invoiceService->syncFromSession($session, $entityType, $entityId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('syncing the session invoice failed', [
|
||||
'session' => $session->getUuid(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($synced !== null) {
|
||||
return $synced;
|
||||
}
|
||||
|
||||
if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user