feat(invoice): synchronize invoice totals with patient session updates and add resync command
This commit is contained in:
@@ -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