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
+12 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { PlusIcon, MinusIcon } from '@heroicons/react/24/outline';
import { api } from '../../lib/api';
@@ -57,6 +57,7 @@ interface Props {
* موجودِ NewSessionPage (نمایش شرطی: سرویسِ تحت پوشش بیمه یا بیمه در پروفایل بیمار).
*/
export default function CreateStep({ recordUuid, profile, onCreated, onCancel, editSession }: Props) {
const qc = useQueryClient();
const userName = useAuthStore((s) => s.userName);
const isEdit = !!editSession;
@@ -307,6 +308,16 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
? api.patch(`/api/v1/session/${editSession!.uuid}`, body)
: api.post(`/api/v1/patient/${recordUuid}/session`, body),
onSuccess: (res: any) => {
// بدون این، صفحهٔ پرونده و فهرست پرداخت‌ها نسخهٔ کش‌شده را نشان می‌دادند و
// وضعیت مراجعه تا رفرشِ دستی عوض نمی‌شد. صورتحساب هم سمت سرور با همین ویرایش
// هم‌تراز می‌شود، پس آمار پرداخت‌ها هم کهنه می‌ماند.
qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] });
qc.invalidateQueries({ queryKey: ['patient-detail', recordUuid] });
qc.invalidateQueries({ queryKey: ['patient', recordUuid] });
qc.invalidateQueries({ queryKey: ['patient-invoices', recordUuid] });
qc.invalidateQueries({ queryKey: ['payments'] });
qc.invalidateQueries({ queryKey: ['payments-summary'] });
toast.success(isEdit ? 'مراجعه ویرایش شد' : 'مراجعه ثبت شد');
onCreated((isEdit ? editSession!.uuid : res?.data?.uuid) as string);
},
@@ -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;
}
@@ -0,0 +1,151 @@
<?php
namespace App\Tests\Billing;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Billing\Entity\Invoice;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Tests\ApiTestCase;
/**
* صورتحساب باید با محتوای فعلیِ مراجعه هم‌تراز بماند.
*
* پیش از این عکسِ لحظهٔ ساخت بود: مراجعه‌ای که بعداً سرویس می‌گرفت، صورتحسابش روی
* مبلغ قدیمی می‌ماند، «پرداخت‌شده» دیده می‌شد و آمار فهرست پرداخت‌ها (مجموع/
* تسویه‌نشده) که از همان ستون‌ها ساخته می‌شود غلط بود.
*/
class InvoiceSyncOnSessionEditTest extends ApiTestCase
{
/** @return array{0: User, 1: Clinic, 2: PatientRecord, 3: ServiceSection} */
private function scenario(): array
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic'));
$patient = $this->createUser(['ROLE_USER']);
$patient->setRealName('بیمار تست');
$record = new PatientRecord('clinic', $clinic->getId(), $patient, 'clinic', $clinic->getId());
$this->em->persist($record);
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$this->em->flush();
return [$owner, $clinic, $record, $section];
}
private function service(ServiceSection $section, string $name, int $priceRials): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setPriceRials($priceRials);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function invoiceOf(PatientSession $session): ?Invoice
{
$this->em->clear();
return $this->em->getRepository(Invoice::class)->findOneBy(['patientSessionId' => $session->getId()]);
}
/** ✅ موفق: افزودن سرویس به مراجعه، مبلغ صورتحساب را هم بالا می‌برد. */
public function testEditingSessionServicesUpdatesTheInvoiceTotal(): void
{
[$owner, , $record, $section] = $this->scenario();
$first = $this->service($section, 'لیزر ناحیه‌ای', 10_000_000);
$second = $this->service($section, 'لیزر توتال', 13_000_000);
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'services' => [['service_item_uuid' => $first->getUuid(), 'quantity' => 1]],
]);
self::assertSame(201, $this->responseCode());
$sessionUuid = $created['data']['uuid'];
// صورتحساب از مسیر پرداخت ساخته می‌شود (مراجعهٔ بدون بیمه).
$this->authJson('POST', '/api/v1/billing/invoices', $owner, ['session_uuid' => $sessionUuid]);
self::assertSame(201, $this->responseCode());
$session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]);
self::assertSame(10_000_000, $this->invoiceOf($session)->getTotalRials());
// ویرایش: سرویس دوم اضافه می‌شود.
$this->authJson('PATCH', '/api/v1/session/' . $sessionUuid, $owner, [
'services' => [
['service_item_uuid' => $first->getUuid(), 'quantity' => 1],
['service_item_uuid' => $second->getUuid(), 'quantity' => 1],
],
]);
self::assertSame(200, $this->responseCode());
$session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]);
$invoice = $this->invoiceOf($session);
self::assertSame(23_000_000, $invoice->getTotalRials());
self::assertSame(23_000_000, $invoice->getPatientRials());
self::assertCount(2, $invoice->getItems());
}
/** ⚠️ مرزی: حذف سرویس هم باید مبلغ را پایین بیاورد، نه اینکه خط یتیم بماند. */
public function testRemovingAServiceShrinksTheInvoice(): void
{
[$owner, , $record, $section] = $this->scenario();
$first = $this->service($section, 'سرویس اول', 5_000_000);
$second = $this->service($section, 'سرویس دوم', 7_000_000);
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'services' => [
['service_item_uuid' => $first->getUuid(), 'quantity' => 1],
['service_item_uuid' => $second->getUuid(), 'quantity' => 1],
],
]);
$sessionUuid = $created['data']['uuid'];
$this->authJson('POST', '/api/v1/billing/invoices', $owner, ['session_uuid' => $sessionUuid]);
$this->authJson('PATCH', '/api/v1/session/' . $sessionUuid, $owner, [
'services' => [['service_item_uuid' => $first->getUuid(), 'quantity' => 1]],
]);
self::assertSame(200, $this->responseCode());
$session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]);
$invoice = $this->invoiceOf($session);
self::assertSame(5_000_000, $invoice->getTotalRials());
self::assertCount(1, $invoice->getItems());
}
/** ⚠️ مرزی: مراجعهٔ بدونِ صورتحساب نباید با ویرایش، صورتحساب بگیرد. */
public function testSessionWithoutInvoiceStaysWithoutOne(): void
{
[$owner, , $record, $section] = $this->scenario();
$item = $this->service($section, 'سرویس', 4_000_000);
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
]);
$sessionUuid = $created['data']['uuid'];
$this->authJson('PATCH', '/api/v1/session/' . $sessionUuid, $owner, [
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 2]],
]);
self::assertSame(200, $this->responseCode());
$session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]);
self::assertNull($this->invoiceOf($session));
}
}
+37 -3
View File
@@ -108,6 +108,39 @@ class PaymentsSummaryTest extends ApiTestCase
self::assertSame(2, $data['invoices_count']);
}
/**
* ⚠️ مرزی: فاکتورِ نیمه‌پرداخت. پیش از این، «پرداخت‌شده» فقط فاکتورهای کاملاً
* تسویه‌شده را می‌شمرد، پس پولِ گرفته‌شده نامرئی می‌شد و بدهی بزرگ‌تر از واقعیت.
*/
public function testPartiallyPaidInvoiceCountsItsCollectedAmount(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor);
// سهم بیمار ۱٬۰۰۰٬۰۰۰ و فقط ۴۰۰٬۰۰۰ وصول شده.
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 400_000);
$data = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner)['data'];
self::assertSame(1_000_000, $data['total_rials']);
self::assertSame(400_000, $data['paid_rials']);
self::assertSame(600_000, $data['unsettled_rials']);
}
/** ⚠️ مرزی: اضافه‌پرداخت نباید «تسویه‌نشده» را منفی کند. */
public function testOverpaymentIsCappedAtTheInvoiceShare(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor);
$this->invoice($doctor, $record, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 900_000);
$data = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner)['data'];
self::assertSame(500_000, $data['paid_rials']);
self::assertSame(0, $data['unsettled_rials']);
}
public function testSummaryHonoursStatusAndDateFilters(): void
{
[$owner, $doctor] = $this->doctor();
@@ -168,11 +201,12 @@ class PaymentsSummaryTest extends ApiTestCase
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
self::assertSame(200, $this->responseCode());
// the patient view sums total_rials (invoice grand total), not the patient share
// ستون «مجموع» جمعِ کلِ صورتحساب است، ولی وصولی و بدهی روی سهم بیمار سنجیده
// می‌شوند: فاکتور اول (سهم ۱٬۰۰۰٬۰۰۰) تسویه شده و فاکتور دوم (۵۰۰٬۰۰۰) نه.
$summary = $res['data']['summary'];
self::assertSame(3_000_000, $summary['total_rials']);
self::assertSame(2_000_000, $summary['paid_rials']);
self::assertSame(1_000_000, $summary['unsettled_rials']);
self::assertSame(1_000_000, $summary['paid_rials']);
self::assertSame(500_000, $summary['unsettled_rials']);
self::assertSame(2, $summary['invoices_count']);
self::assertSame(2, $res['data']['meta']['totalRecords']);
}