feat(invoice): synchronize invoice totals with patient session updates and add resync command

This commit is contained in:
hamed
2026-08-04 13:12:24 +03:30
parent 4653cd0e4a
commit 0dca245246
8 changed files with 411 additions and 23 deletions
@@ -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();
}
}
}
+12
View File
@@ -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;
+27 -14
View File
@@ -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),
];
}
+45 -5
View File
@@ -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;
}