feat: enrich invoice summary with session payments, consumables and discount

Port the remaining gap of tauri /files/invoice-summary into the admin
invoice summary: GET /api/v1/billing/invoices/{uuid} now includes a
'session' key (full PatientSession payload) so InvoiceSummaryModal can
render the consumables table, the itemized payments table (method,
amount, date-time, recorder) and real discount/paid/remaining figures
instead of heuristics. Invoices without a source session keep the
previous behavior (session: null).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 14:38:38 +03:30
co-authored by Claude Opus 4.8
parent 3b2b22f7d6
commit 0e0e4ebe71
6 changed files with 391 additions and 15 deletions
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Tests\Billing;
use App\Auth\Entity\User;
use App\Billing\Entity\Invoice;
use App\Doctor\Entity\Doctor;
use App\Inventory\Entity\InventoryItem;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionConsumable;
use App\Patient\Entity\SessionPayment;
use App\Tests\ApiTestCase;
/**
* GET /api/v1/billing/invoices/{uuid} — invoice detail enriched with its
* source session (`session` key: payments, consumables, discount, paid totals).
*/
class InvoiceShowSessionTest extends ApiTestCase
{
/** @return array{0: User, 1: Doctor} owner user + their doctor profile */
private function doctor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
return [$owner, $doctor];
}
private function patientRecord(Doctor $doctor): PatientRecord
{
$patient = $this->createUser(['ROLE_USER']);
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
$this->em->persist($record);
$this->em->flush();
return $record;
}
private function setField(object $obj, string $prop, mixed $value): void
{
$ref = new \ReflectionProperty($obj, $prop);
$ref->setAccessible(true);
$ref->setValue($obj, $value);
}
public function testShowIncludesSessionPaymentsConsumablesAndDiscount(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor);
$session = new PatientSession($record);
$session->setServicesTotalRials(2_400_000)
->setFinalPriceRials(2_200_000)
->setDiscount('amount', 200_000, 200_000)
->setSessionAt(1_700_000_000)
->setPaidAt(1_700_100_000);
$this->em->persist($session);
$this->em->flush();
$payment = new SessionPayment($session, 'wallet', 1_500_000, 1_700_100_000);
$payment->setCreatedByName('منشی تست');
$this->em->persist($payment);
$session->addPayment($payment);
$item = new InventoryItem('doctor', $doctor->getId(), 'عینک');
$item->setPrice(20_000);
$this->em->persist($item);
$this->em->flush();
$consumable = new SessionConsumable($session, $item, 2);
$this->em->persist($consumable);
$session->addConsumable($consumable);
$this->em->flush();
$invoice = new Invoice('doctor', $doctor->getId());
$invoice->setPatientSessionId($session->getId())
->setPatientRecordId($record->getId());
$this->em->persist($invoice);
$this->em->flush();
$res = $this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $owner);
self::assertSame(200, $this->responseCode());
$data = $res['data']['data'];
self::assertSame($invoice->getUuid(), $data['uuid']);
self::assertIsArray($data['session']);
$s = $data['session'];
self::assertSame($session->getUuid(), $s['uuid']);
self::assertSame(1_700_000_000, $s['session_at']);
self::assertSame(1_700_100_000, $s['paid_at']);
self::assertSame(2_400_000, $s['services_total_rials']);
self::assertSame(2_200_000, $s['final_price_rials']);
self::assertSame(200_000, $s['discount_rials']);
// payments — real rows, not heuristics
self::assertCount(1, $s['payments']);
self::assertSame('wallet', $s['payments'][0]['method']);
self::assertSame(1_500_000, $s['payments'][0]['amount_rials']);
self::assertSame('منشی تست', $s['payments'][0]['created_by_name']);
self::assertSame(1_500_000, $s['paid_total_rials']);
// consumables — snapshot price × quantity
self::assertCount(1, $s['consumables']);
self::assertSame('عینک', $s['consumables'][0]['item_name']);
self::assertSame(2, $s['consumables'][0]['quantity']);
self::assertSame(40_000, $s['consumables'][0]['line_total_rials']);
self::assertSame(40_000, $s['consumables_total_rials']);
}
public function testShowSessionNullWhenInvoiceHasNoSession(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor);
$invoice = new Invoice('doctor', $doctor->getId());
$invoice->setPatientRecordId($record->getId());
$this->em->persist($invoice);
$this->em->flush();
$res = $this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $owner);
self::assertSame(200, $this->responseCode());
self::assertNull($res['data']['data']['session']);
}
public function testShowNotFoundForUnknownUuid(): void
{
[$owner] = $this->doctor();
$this->authJson('GET', '/api/v1/billing/invoices/00000000-0000-0000-0000-000000000000', $owner);
self::assertSame(404, $this->responseCode());
}
public function testShowNotFoundForOtherTenant(): void
{
[, $doctor] = $this->doctor();
$invoice = new Invoice('doctor', $doctor->getId());
$this->em->persist($invoice);
$this->em->flush();
[$other] = $this->doctor();
$this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $other);
self::assertSame(404, $this->responseCode());
}
public function testShowSessionEmptyPaymentsAndConsumables(): void
{
[$owner, $doctor] = $this->doctor();
$record = $this->patientRecord($doctor);
$session = new PatientSession($record);
$this->em->persist($session);
$this->em->flush();
$invoice = new Invoice('doctor', $doctor->getId());
$invoice->setPatientSessionId($session->getId());
$this->em->persist($invoice);
$this->em->flush();
$res = $this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $owner);
self::assertSame(200, $this->responseCode());
$s = $res['data']['data']['session'];
self::assertSame([], $s['payments']);
self::assertSame([], $s['consumables']);
self::assertSame(0, $s['paid_total_rials']);
self::assertSame(0, $s['discount_rials']);
}
}