From 0e0e4ebe71a5fbd47d72d980fdb8399abe48a876 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 14:38:38 +0330 Subject: [PATCH] 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) --- .../components/InvoiceSummaryModal.test.tsx | 101 +++++++++++ .../admin/components/InvoiceSummaryModal.tsx | 79 ++++++-- docs/api/billing.md | 35 +++- src/Billing/Controller/BillingController.php | 2 +- src/Billing/Service/InvoiceService.php | 18 ++ tests/Billing/InvoiceShowSessionTest.php | 171 ++++++++++++++++++ 6 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 assets/admin/components/InvoiceSummaryModal.test.tsx create mode 100644 tests/Billing/InvoiceShowSessionTest.php diff --git a/assets/admin/components/InvoiceSummaryModal.test.tsx b/assets/admin/components/InvoiceSummaryModal.test.tsx new file mode 100644 index 00000000..a9d1e71b --- /dev/null +++ b/assets/admin/components/InvoiceSummaryModal.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import InvoiceSummaryModal from './InvoiceSummaryModal'; + +const get = api.get as ReturnType; + +const baseInvoice = { + uuid: 'iv1', status: 'finalized', issued_at: 1700000000, total_rials: 2_400_000, + base_insurance_rials: 0, supplementary_rials: 0, patient_rials: 900_000, + items: [{ uuid: 'it1', title: 'فول بادی', quantity: 1, total_rials: 2_400_000, patient_rials: 900_000 }], +}; + +const fullSession = { + session_at: 1700000000, paid_at: 1700100000, + services_total_rials: 2_400_000, consumables_total_rials: 40_000, + discount_rials: 200_000, final_price_rials: 2_240_000, paid_total_rials: 1_500_000, + payments: [ + { uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000, created_by_name: 'منشی تست' }, + ], + consumables: [ + { uuid: 'c1', item_name: 'عینک', quantity: 2, line_total_rials: 40_000 }, + ], +}; + +beforeEach(() => { + get.mockReset(); +}); + +function mockInvoice(invoice: object) { + get.mockResolvedValue({ success: true, data: { data: invoice } }); +} + +describe('InvoiceSummaryModal', () => { + it('با session کامل: جدول کالای مصرفی، پرداختی‌ها و تخفیف را نشان می‌دهد', async () => { + mockInvoice({ ...baseInvoice, session: fullSession }); + renderWithProviders( {}} />); + + await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument()); + + // کالای مصرفی + expect(screen.getByText('اطلاعات کالای مصرفی')).toBeInTheDocument(); + expect(screen.getByText('عینک')).toBeInTheDocument(); + + // پرداختی‌ها با label فارسی روش و ثبت‌کننده + expect(screen.getByText('پرداختی ها')).toBeInTheDocument(); + expect(screen.getByText('پرداخت از کیف پول')).toBeInTheDocument(); + expect(screen.getByText('منشی تست')).toBeInTheDocument(); + + // خلاصه مالی با ستون تخفیف و جمع کالا + expect(screen.getByText('تخفیف')).toBeInTheDocument(); + expect(screen.getByText('جمع مبلغ کالا')).toBeInTheDocument(); + + // وضعیت — مبالغ واقعی (نمایش تومان = ریال ÷ ۱۰): + // پرداخت‌شده ۱۵۰٬۰۰۰ (هم در جدول پرداختی‌ها هم وضعیت) و باقی‌مانده ۷۴٬۰۰۰ + expect(screen.getAllByText(/۱۵۰٬۰۰۰/).length).toBeGreaterThanOrEqual(2); + expect(screen.getByText(/۷۴٬۰۰۰/)).toBeInTheDocument(); + }); + + it('بدون session (فاکتور قدیمی): رفتار قبلی حفظ می‌شود', async () => { + mockInvoice({ ...baseInvoice, session: null }); + renderWithProviders( {}} />); + + await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument()); + + // جدول‌های session-محور رندر نمی‌شوند + expect(screen.queryByText('اطلاعات کالای مصرفی')).not.toBeInTheDocument(); + expect(screen.queryByText('پرداختی ها')).not.toBeInTheDocument(); + + // خلاصه مالی قدیمی با سهم بیمار + expect(screen.getByText('سهم بیمار')).toBeInTheDocument(); + }); + + it('session با payments/consumables خالی: ردیف خط تیره', async () => { + mockInvoice({ + ...baseInvoice, + session: { ...fullSession, payments: [], consumables: [], paid_total_rials: 0, discount_rials: 0 }, + }); + renderWithProviders( {}} />); + + await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument()); + + expect(screen.getByText('اطلاعات کالای مصرفی')).toBeInTheDocument(); + expect(screen.getByText('پرداختی ها')).toBeInTheDocument(); + // ردیف‌های '-' برای هر دو جدول خالی + ستون تخفیف صفر + expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(8); + }); + + it('invoiceUuid=null: مودال بسته و بدون fetch', () => { + renderWithProviders( {}} />); + expect(get).not.toHaveBeenCalled(); + expect(screen.queryByText('خلاصه فاکتور')).not.toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/InvoiceSummaryModal.tsx b/assets/admin/components/InvoiceSummaryModal.tsx index 7aa43130..0a5b24ed 100644 --- a/assets/admin/components/InvoiceSummaryModal.tsx +++ b/assets/admin/components/InvoiceSummaryModal.tsx @@ -2,13 +2,23 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import Modal from './ui/Modal'; -import { formatDate, formatRial } from '../lib/utils'; +import { formatDate, formatDateTime, formatRial } from '../lib/utils'; +import { METHOD_LABELS } from './session/PaymentStep'; interface InvoiceItem { uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number } +interface SessionPayment { uuid: string; method: string; amount_rials: number; paid_at: number; created_by_name: string | null } +interface SessionConsumable { uuid: string; item_name: string; quantity: number; line_total_rials: number } +interface SessionData { + session_at: number | null; paid_at: number | null; + services_total_rials: number; consumables_total_rials: number; + discount_rials: number; final_price_rials: number; paid_total_rials: number; + payments: SessionPayment[]; consumables: SessionConsumable[]; +} interface Invoice { uuid: string; status: string; issued_at: number; total_rials: number; base_insurance_rials: number; supplementary_rials: number; patient_rials: number; items: InvoiceItem[]; + session?: SessionData | null; } const STATUS_LABEL: Record = { paid: 'پرداخت شده', finalized: 'بدهکار', draft: 'پیش‌نویس', void: 'باطل' }; @@ -51,10 +61,14 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU }); // billing show wraps as { data: { data: invoice } } const inv = ((data?.data as any)?.data ?? data?.data ?? null) as Invoice | null; + const session = inv?.session ?? null; const paid = inv?.status === 'paid'; - const remaining = inv ? (paid ? 0 : inv.patient_rials) : 0; - const paidAmount = inv ? inv.total_rials - remaining : 0; + // با session: مبالغ واقعی پرداخت؛ بدون آن (فاکتور قدیمی): heuristic قبلی. + const remaining = session + ? session.final_price_rials - session.paid_total_rials + : inv ? (paid ? 0 : inv.patient_rials) : 0; + const paidAmount = session ? session.paid_total_rials : inv ? inv.total_rials - remaining : 0; return ( @@ -65,21 +79,62 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU [it.title, it.quantity, formatRial(it.total_rials)]) : [['—', '—', '—']]} /> - + {session && ( + [c.item_name, c.quantity, formatRial(c.line_total_rials)]) + : [['-', '-', '-']]} + /> + )} + {session ? ( + 0 ? formatRial(session.discount_rials) : '-', + formatRial(inv.base_insurance_rials), formatRial(inv.supplementary_rials), + formatRial(session.final_price_rials), + ]]} + /> + ) : ( + + )} + {session && ( + [ + i + 1, + METHOD_LABELS[p.method] ?? p.method, + formatRial(p.amount_rials), + p.paid_at ? formatDateTime(p.paid_at) : '-', + p.created_by_name ?? '-', + ]) + : [['-', '-', '-', '-', '-']]} + /> + )} error(ErrorCodes::ERR_NOT_FOUND_001, 'صورتحساب یافت نشد', 404); } - return $this->success(['data' => $invoice->toArray()]); + return $this->success(['data' => $this->invoiceService->detailWithSession($invoice)]); } #[Route('/api/v1/billing/invoices/{uuid}/finalize', methods: ['POST'])] diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php index 56428748..45465aeb 100644 --- a/src/Billing/Service/InvoiceService.php +++ b/src/Billing/Service/InvoiceService.php @@ -9,6 +9,7 @@ use App\Billing\ValueObject\Money; use App\ClinicService\Service\TariffService; use App\Insurance\Service\TenantInsuranceService; use App\Patient\Entity\PatientSession; +use App\Patient\Repository\PatientSessionRepository; class InvoiceService { @@ -17,6 +18,7 @@ class InvoiceService private readonly TariffService $tariffService, private readonly TenantInsuranceService $tenantInsuranceService, private readonly BillingCalculator $calculator, + private readonly PatientSessionRepository $sessionRepo, ) {} /** @@ -75,6 +77,22 @@ class InvoiceService $this->invoiceRepo->save($invoice); } + /** + * Invoice detail enriched with its source encounter (`session` key): + * payments, consumables, discount and paid totals for the invoice summary + * view. `session` is null for invoices not created from a session. + */ + public function detailWithSession(Invoice $invoice): array + { + $data = $invoice->toArray(); + + $sessionId = $data['patient_session_id']; + $session = $sessionId !== null ? $this->sessionRepo->find($sessionId) : null; + $data['session'] = $session?->toArray(); + + return $data; + } + /** * Paginated flat list of a tenant's recorded (finalized/paid) invoices for * the payments list (node 1). Rows arrive ready-shaped from the repository; diff --git a/tests/Billing/InvoiceShowSessionTest.php b/tests/Billing/InvoiceShowSessionTest.php new file mode 100644 index 00000000..6df00322 --- /dev/null +++ b/tests/Billing/InvoiceShowSessionTest.php @@ -0,0 +1,171 @@ +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']); + } +}