feat(invoice): synchronize invoice totals with patient session updates and add resync command
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user