diff --git a/assets/admin/components/SessionPaymentAccordion.tsx b/assets/admin/components/SessionPaymentAccordion.tsx index b453b9ca..ebb06b75 100644 --- a/assets/admin/components/SessionPaymentAccordion.tsx +++ b/assets/admin/components/SessionPaymentAccordion.tsx @@ -1,5 +1,6 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { formatDate, formatRial } from '../lib/utils'; +import { PAYMENT_METHOD_LABELS } from '../lib/paymentMethods'; import { FilesServicePaymentsCheck } from './icons/FilesServiceIcons'; export interface SessionPaymentData { @@ -14,10 +15,7 @@ export interface SessionPaymentData { updated_at?: number; } -const PAYMENT_LABELS: Record = { - cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار', - wallet: 'کیف پول', pos: 'کارتخوان', -}; +const PAYMENT_LABELS = PAYMENT_METHOD_LABELS; /** vertical hairline divider between meta columns (tauri MUI vertical Divider). */ function VDivider({ h = 24 }: { h?: number }) { diff --git a/assets/admin/components/ui/DataTable.tsx b/assets/admin/components/ui/DataTable.tsx index aded24cb..d98c04f7 100644 --- a/assets/admin/components/ui/DataTable.tsx +++ b/assets/admin/components/ui/DataTable.tsx @@ -23,6 +23,8 @@ interface Props { sortKey?: string | null; sortDir?: 'asc' | 'desc'; onSort?: (key: string) => void; + /** محتوای ردیف بازشونده‌ی زیر هر ردیف؛ `null`/`undefined` یعنی بسته است. */ + renderExpanded?: (row: T) => React.ReactNode; } function SkeletonRow({ cols }: { cols: number }) { @@ -51,6 +53,7 @@ export default function DataTable({ sortKey, sortDir, onSort, + renderExpanded, }: Props) { const allColumns = actions ? [...columns, { key: '__actions', header: 'اقدامات', className: 'w-[120px]' }] @@ -129,24 +132,36 @@ export default function DataTable({ ) : ( - data.map((row, rowIdx) => ( - - {columns.map((col) => ( - - {col.render - ? col.render(row) - : ((row as Record)[col.key] as React.ReactNode) ?? '—'} - - ))} - {actions && ( - -
- {actions(row)} -
- - )} - - )) + data.map((row, rowIdx) => { + const expanded = renderExpanded?.(row); + return ( + + + {columns.map((col) => ( + + {col.render + ? col.render(row) + : ((row as Record)[col.key] as React.ReactNode) ?? '—'} + + ))} + {actions && ( + +
+ {actions(row)} +
+ + )} + + {expanded && ( + + + {expanded} + + + )} +
+ ); + }) )} diff --git a/assets/admin/components/ui/StatusBadge.tsx b/assets/admin/components/ui/StatusBadge.tsx index 0d7d2fc0..e2709d64 100644 --- a/assets/admin/components/ui/StatusBadge.tsx +++ b/assets/admin/components/ui/StatusBadge.tsx @@ -47,6 +47,7 @@ const claimMap: Record = { const invoiceMap: Record = { paid: { color: 'green', label: 'پرداخت شده' }, + partial: { color: 'blue', label: 'پرداخت ناقص' }, unsettled: { color: 'amber', label: 'تسویه نشده' }, }; diff --git a/assets/admin/hooks/useMyPayments.ts b/assets/admin/hooks/useMyPayments.ts index 9b2dd27c..1020b2cb 100644 --- a/assets/admin/hooks/useMyPayments.ts +++ b/assets/admin/hooks/useMyPayments.ts @@ -2,8 +2,11 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; -/** Two-state invoice status shown on both the list (node 1) and detail (node 2). */ -export type PaymentRowStatus = 'paid' | 'unsettled'; +/** + * وضعیت پرداخت صورتحساب — مشتق از مجموع پرداخت‌های مراجعه در برابر سهم بیمار، + * نه ستون `invoices.status` (که فقط چرخه‌ی حیات را نگه می‌دارد). + */ +export type PaymentRowStatus = 'paid' | 'partial' | 'unsettled'; /** One recorded invoice on the flat payments list (node 1). */ export interface PaymentRow { @@ -13,33 +16,46 @@ export interface PaymentRow { national_code: string | null; issued_at: number; amount_rials: number; + paid_rials: number; status: PaymentRowStatus; } export interface PaymentFilters { page: number; national_code?: string; - status?: string; // paid | unsettled + status?: string; // paid | partial | unsettled from?: number; // unix seconds to?: number; // unix seconds } -/** A single invoice status on the detail table (node 2) — two-state. */ -export type InvoiceRowStatus = 'paid' | 'unsettled'; +/** همان وضعیت مشتق‌شده، روی جدول جزئیات بیمار (node 2). */ +export type InvoiceRowStatus = PaymentRowStatus; export interface PatientInvoiceRow { uuid: string; number: number; issued_at: number; total_rials: number; + patient_rials: number; + paid_rials: number; status: InvoiceRowStatus; service_title: string | null; items: Array<{ uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }>; + payments: InvoicePaymentRow[]; +} + +/** یک پرداخت ثبت‌شده روی مراجعه‌ی همان صورتحساب. */ +export interface InvoicePaymentRow { + method: string; + amount_rials: number; + paid_at: number; + created_by_name: string | null; } export interface PatientInvoicesPayload { patient: { uuid: string; name: string | null; national_code: string | null }; data: PatientInvoiceRow[]; + summary: PaymentsSummary; meta: { totalRecords: number; totalPages: number; currentPage: number }; } diff --git a/assets/admin/lib/paymentMethods.ts b/assets/admin/lib/paymentMethods.ts new file mode 100644 index 00000000..c199906a --- /dev/null +++ b/assets/admin/lib/paymentMethods.ts @@ -0,0 +1,13 @@ +/** برچسب فارسی روش‌های پرداخت — تنها مرجع؛ در اکاردئون مراجعه و صفحه‌ی پرداخت‌ها مشترک است. */ +export const PAYMENT_METHOD_LABELS: Record = { + cash: 'نقدی', + card: 'کارت', + pos: 'کارتخوان', + wallet: 'کیف پول', + insurance: 'بیمه', + online: 'آنلاین', + pending: 'در انتظار', +}; + +export const paymentMethodLabel = (method: string): string => + PAYMENT_METHOD_LABELS[method] ?? method; diff --git a/assets/admin/pages/MyPaymentDetailPage.test.tsx b/assets/admin/pages/MyPaymentDetailPage.test.tsx index 6d52c5fe..875bcf62 100644 --- a/assets/admin/pages/MyPaymentDetailPage.test.tsx +++ b/assets/admin/pages/MyPaymentDetailPage.test.tsx @@ -14,12 +14,20 @@ const get = api.get as ReturnType; const PAYLOAD = { patient: { uuid: 'abc', name: 'دنیا خلیلی', national_code: '1744023654' }, data: [ - { uuid: 'i1', number: 12345, issued_at: 1717000000, total_rials: 2350000, status: 'paid', - service_title: 'روکش دندان', - items: [{ uuid: 'it1', title: 'روکش دندان', quantity: 1, total_rials: 2350000, patient_rials: 2350000 }] }, - { uuid: 'i2', number: 52614, issued_at: 1718000000, total_rials: 6000000, status: 'unsettled', - service_title: 'طرح لبخند', items: [] }, + { uuid: 'i1', number: 12345, issued_at: 1717000000, total_rials: 2350000, patient_rials: 2350000, + paid_rials: 2350000, status: 'paid', service_title: 'روکش دندان', + items: [{ uuid: 'it1', title: 'روکش دندان', quantity: 1, total_rials: 2350000, patient_rials: 2350000 }], + payments: [ + { method: 'cash', amount_rials: 350000, paid_at: 1717000000, created_by_name: 'منشی' }, + { method: 'pos', amount_rials: 2000000, paid_at: 1717000500, created_by_name: null }, + ] }, + { uuid: 'i2', number: 52614, issued_at: 1718000000, total_rials: 6000000, patient_rials: 6000000, + paid_rials: 0, status: 'unsettled', service_title: 'طرح لبخند', items: [], payments: [] }, + { uuid: 'i3', number: 52615, issued_at: 1719000000, total_rials: 1000000, patient_rials: 1000000, + paid_rials: 400000, status: 'partial', service_title: 'جرم‌گیری', items: [], + payments: [{ method: 'wallet', amount_rials: 400000, paid_at: 1719000000, created_by_name: null }] }, ], + summary: { total_rials: 8350000, paid_rials: 2350000, unsettled_rials: 6000000, invoices_count: 2 }, meta: { totalRecords: 2, totalPages: 1, currentPage: 1 }, }; @@ -40,31 +48,62 @@ beforeEach(() => { describe('MyPaymentDetailPage (پرداخت‌های ثبت‌شده)', () => { it('renders the patient header and invoice rows', async () => { renderPage(); - expect(await screen.findByText('دنیا خلیلی')).toBeInTheDocument(); + // the name shows twice: breadcrumb + identity card + expect(await screen.findAllByText('دنیا خلیلی')).toHaveLength(2); expect(screen.getByText(/1744023654/)).toBeInTheDocument(); expect(screen.getByText('روکش دندان')).toBeInTheDocument(); expect(screen.getByText('طرح لبخند')).toBeInTheDocument(); expect(screen.getByText('پرداخت شده')).toBeInTheDocument(); expect(screen.getByText('تسویه نشده')).toBeInTheDocument(); + expect(screen.getByText('پرداخت ناقص')).toBeInTheDocument(); + expect(screen.getByText(/پرداخت‌شده:/)).toBeInTheDocument(); + }); + + it('renders the summary stat cards', async () => { + renderPage(); + expect(await screen.findByText('مجموع صورتحساب‌ها')).toBeInTheDocument(); + expect(screen.getByText('پرداخت‌شده')).toBeInTheDocument(); + expect(screen.getByText('تسویه‌نشده')).toBeInTheDocument(); + expect(screen.getByText('تعداد صورتحساب')).toBeInTheDocument(); }); it('fetches invoices for the patient uuid from the route', async () => { renderPage(); - await screen.findByText('دنیا خلیلی'); + await screen.findAllByText('دنیا خلیلی'); expect(get.mock.calls[0][0]).toContain('/api/v1/my/billing/patients/abc/invoices'); }); - it('expands a row to show its line items on بیشتر', async () => { + it('expands a row to show its line items and payment methods on بیشتر', async () => { renderPage(); await screen.findByText('روکش دندان'); fireEvent.click(screen.getAllByRole('button', { name: /بیشتر/ })[0]); + // the item breakdown appears (title repeated inside the expanded sub-row) expect(await screen.findAllByText('روکش دندان')).toHaveLength(2); + // and each payment shows its method in Persian + expect(screen.getByText('روش‌های پرداخت')).toBeInTheDocument(); + expect(screen.getByText('نقدی')).toBeInTheDocument(); + expect(screen.getByText('کارتخوان')).toBeInTheDocument(); + }); + + it('says so when an invoice has no recorded payment', async () => { + renderPage(); + await screen.findByText('طرح لبخند'); + fireEvent.click(screen.getAllByRole('button', { name: /بیشتر/ })[1]); + expect(await screen.findByText('پرداختی ثبت نشده است.')).toBeInTheDocument(); }); it('shows an empty state when the patient has no invoices', async () => { - get.mockResolvedValue({ success: true, data: { patient: PAYLOAD.patient, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } } }); + get.mockResolvedValue({ + success: true, + data: { + patient: PAYLOAD.patient, + data: [], + summary: { total_rials: 0, paid_rials: 0, unsettled_rials: 0, invoices_count: 0 }, + meta: { totalRecords: 0, totalPages: 0, currentPage: 1 }, + }, + }); renderPage(); - expect(await screen.findByText('صورتحسابی ثبت نشده است')).toBeInTheDocument(); + expect(await screen.findByText('صورتحسابی ثبت نشده است.')).toBeInTheDocument(); }); }); diff --git a/assets/admin/pages/MyPaymentDetailPage.tsx b/assets/admin/pages/MyPaymentDetailPage.tsx index 4dbd74cb..320021a7 100644 --- a/assets/admin/pages/MyPaymentDetailPage.tsx +++ b/assets/admin/pages/MyPaymentDetailPage.tsx @@ -1,20 +1,20 @@ -import { Fragment, useState } from 'react'; +import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ChevronRightIcon, ChevronDownIcon, PlusIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import PageHeader from '../components/ui/PageHeader'; import Pagination from '../components/ui/Pagination'; +import StatCard from '../components/ui/StatCard'; +import StatusBadge from '../components/ui/StatusBadge'; +import DataTable, { type Column } from '../components/ui/DataTable'; import { formatRial, formatDate, formatNumber } from '../lib/utils'; +import { paymentMethodLabel } from '../lib/paymentMethods'; import { usePatientInvoices, MY_PAYMENTS_LIMIT, type PatientInvoiceRow, - type InvoiceRowStatus, } from '../hooks/useMyPayments'; -const STATUS_LABEL: Record = { paid: 'پرداخت شده', unsettled: 'تسویه نشده' }; -const STATUS_BADGE: Record = { paid: 'green', unsettled: 'amber' }; - const EMPTY: PatientInvoiceRow[] = []; /** HH:MM (Persian digits) from a unix timestamp. */ @@ -22,7 +22,73 @@ function formatTime(unix: number): string { return new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date(unix * 1000)); } -/** پرداخت‌های ثبت‌شده — a single patient's recorded invoices (node 2). */ +function Avatar({ name }: { name: string | null }) { + return ( +
+ {(name ?? '؟').charAt(0)} +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +/** آیتم‌های صورتحساب و پرداخت‌های ثبت‌شده‌ی آن، کنار هم در ردیف بازشونده. */ +function InvoiceBreakdown({ row }: { row: PatientInvoiceRow }) { + return ( +
+
+ {row.items.length === 0 ? ( + آیتمی ثبت نشده است. + ) : ( +
+ {row.items.map((it) => ( +
+ + {it.title}{it.quantity > 1 ? ` × ${formatNumber(it.quantity)}` : ''} + + {formatRial(it.total_rials)} +
+ ))} +
+ )} +
+ +
+ {row.payments.length === 0 ? ( + پرداختی ثبت نشده است. + ) : ( +
+ {row.payments.map((p, i) => ( +
+ + {paymentMethodLabel(p.method)} + {formatDate(p.paid_at)} + + {formatRial(p.amount_rials)} +
+ ))} +
+ )} +
+
+ ); +} + +/** + * پرداخت‌های ثبت‌شده‌ی یک بیمار — سربرگ بیمار، کارت‌های آمار و جدول صورتحساب‌ها + * با ردیف بازشونده‌ی آیتم‌ها و روش‌های پرداخت. + */ export default function MyPaymentDetailPage() { const { patientUuid } = useParams<{ patientUuid: string }>(); const navigate = useNavigate(); @@ -33,15 +99,41 @@ export default function MyPaymentDetailPage() { const payload = data?.data; const patient = payload?.patient; const rows = payload?.data ?? EMPTY; + const summary = payload?.summary; const total = payload?.meta?.totalRecords ?? 0; - const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 }; - const td: React.CSSProperties = { padding: '12px 16px' }; + const columns: Column[] = [ + { key: 'number', header: 'شماره صورتحساب', render: (r) => #{formatNumber(r.number)} }, + { key: 'issued_at', header: 'تاریخ', render: (r) => formatDate(r.issued_at) }, + { key: 'time', header: 'ساعت', render: (r) => {formatTime(r.issued_at)} }, + { key: 'service_title', header: 'نوع خدمت', render: (r) => {r.service_title ?? '—'} }, + { + key: 'total_rials', + header: 'مبلغ کل', + render: (r) => ( +
+
{formatRial(r.total_rials)}
+ {r.status === 'partial' && ( +
+ پرداخت‌شده: {formatRial(r.paid_rials)} +
+ )} +
+ ), + }, + { key: 'status', header: 'وضعیت', render: (r) => }, + ]; return ( <> + )} + renderExpanded={(row) => (expanded === row.uuid ? : null)} + /> + + {total > MY_PAYMENTS_LIMIT && ( + + )} + ); } diff --git a/assets/admin/pages/MyPaymentsPage.test.tsx b/assets/admin/pages/MyPaymentsPage.test.tsx index 1da21fc9..366c64fb 100644 --- a/assets/admin/pages/MyPaymentsPage.test.tsx +++ b/assets/admin/pages/MyPaymentsPage.test.tsx @@ -16,9 +16,11 @@ const get = api.get as ReturnType; const ROWS = [ { invoice_uuid: 'iv1', patient_uuid: 'p1', patient_name: 'دنیا خلیلی', national_code: '1744023654', - issued_at: 1717000000, amount_rials: 2350000, status: 'paid' }, + issued_at: 1717000000, amount_rials: 2350000, paid_rials: 2350000, status: 'paid' }, { invoice_uuid: 'iv2', patient_uuid: 'p2', patient_name: 'علی بدیعی', national_code: '2200112233', - issued_at: 1718000000, amount_rials: 6000000, status: 'unsettled' }, + issued_at: 1718000000, amount_rials: 6000000, paid_rials: 0, status: 'unsettled' }, + { invoice_uuid: 'iv3', patient_uuid: 'p3', patient_name: 'مریم رضایی', national_code: '3300112233', + issued_at: 1719000000, amount_rials: 1000000, paid_rials: 400000, status: 'partial' }, ]; const SUMMARY = { total_rials: 8350000, paid_rials: 2350000, unsettled_rials: 6000000, invoices_count: 2 }; @@ -47,10 +49,16 @@ describe('MyPaymentsPage (لیست پرداخت‌ها)', () => { expect(screen.getByText('2200112233')).toBeInTheDocument(); }); - it('renders the two-state invoice status badge', async () => { + it('renders the derived payment status badge for each state', async () => { renderWithProviders(, { route: '/admin/my-payments' }); expect(await screen.findByText('پرداخت شده')).toBeInTheDocument(); expect(screen.getByText('تسویه نشده')).toBeInTheDocument(); + expect(screen.getByText('پرداخت ناقص')).toBeInTheDocument(); + }); + + it('shows how much was collected on a partially paid invoice', async () => { + renderWithProviders(, { route: '/admin/my-payments' }); + expect(await screen.findByText(/پرداخت‌شده:/)).toBeInTheDocument(); }); it('renders the summary stat cards', async () => { diff --git a/assets/admin/pages/MyPaymentsPage.tsx b/assets/admin/pages/MyPaymentsPage.tsx index 13d51193..d5c143f5 100644 --- a/assets/admin/pages/MyPaymentsPage.tsx +++ b/assets/admin/pages/MyPaymentsPage.tsx @@ -18,6 +18,7 @@ import { const STATUS_OPTIONS = [ { value: '', label: 'همه وضعیت‌ها' }, { value: 'paid', label: 'پرداخت شده' }, + { value: 'partial', label: 'پرداخت ناقص' }, { value: 'unsettled', label: 'تسویه نشده' }, ]; @@ -108,7 +109,20 @@ export default function MyPaymentsPage() { header: 'تاریخ', render: (r) => {formatDate(r.issued_at)} - {formatTime(r.issued_at)}, }, - { key: 'amount_rials', header: 'مبلغ', render: (r) => {formatRial(r.amount_rials)} }, + { + key: 'amount_rials', + header: 'مبلغ', + render: (r) => ( +
+
{formatRial(r.amount_rials)}
+ {r.status === 'partial' && ( +
+ پرداخت‌شده: {formatRial(r.paid_rials)} +
+ )} +
+ ), + }, { key: 'status', header: 'وضعیت', render: (r) => }, ]; diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index c1a396e6..c112b66d 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -204,8 +204,11 @@ export type SettlementStatus = "pending" | "approved" | "rejected"; /** `mixed` فقط در نمای تجمیعی بیمار معنا دارد: مطالبات آن بیمار وضعیت یکسان ندارند. */ export type ClaimStatus = "pending" | "submitted" | "approved" | "rejected" | "paid" | "mixed"; -/** وضعیت دوحالته‌ی صورتحساب در فهرست پرداخت‌ها — با `PaymentStatus` درگاه فرق دارد. */ -export type InvoiceListStatus = "paid" | "unsettled"; +/** + * وضعیت پرداخت صورتحساب — از مجموع پرداخت‌های مراجعه در برابر سهم بیمار مشتق + * می‌شود (ستون `invoices.status` فقط چرخه‌ی حیات است). با `PaymentStatus` درگاه فرق دارد. + */ +export type InvoiceListStatus = "paid" | "partial" | "unsettled"; export interface ClaimPatientRow { patient_uuid: string; diff --git a/docs/api/billing.md b/docs/api/billing.md index 0076838b..40c6fa14 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -325,7 +325,7 @@ | param | توضیح | |-------|-------| | `national_code` | جست‌وجوی جزئی روی کد ملی بیمار (`LIKE`) | -| `status` | `paid` (پرداخت‌شده) · `unsettled` (تسویه‌نشده = `finalized`) | +| `status` | وضعیت **مشتق‌شده‌ی** پرداخت: `paid` · `partial` · `unsettled` | | `from` / `to` | بازه‌ی `issued_at` بر حسب ثانیه‌ی Unix | | `page` / `limit` | صفحه‌بندی (پیش‌فرض ۱ / ۲۰، سقف ۱۰۰) | @@ -336,12 +336,21 @@ "data": [ { "invoice_uuid": "…", "patient_uuid": "…", "patient_name": "دنیا خلیلی", "national_code": "1744023654", "issued_at": 1717000000, - "amount_rials": 2350000, "status": "paid" } + "amount_rials": 2350000, "paid_rials": 2350000, "status": "paid" } ], "meta": { "totalRecords": 12, "totalPages": 1, "currentPage": 1 } } ``` -> `amount_rials` = سهم بیمار (`patient_rials`) همان صورتحساب. `status` دو حالته: `paid` یا `unsettled`. +> `amount_rials` = سهم بیمار (`patient_rials`) همان صورتحساب · `paid_rials` = مجموع پرداخت‌های ثبت‌شده‌ی مراجعه‌ی متناظر. +> +> **وضعیت پرداخت مشتق است، ذخیره نمی‌شود.** ستون `invoices.status` فقط چرخه‌ی حیات صورتحساب را نگه می‌دارد (`draft` → `finalized` → `void`) و هرگز `paid` نمی‌شود؛ پول واقعی در `session_payments` ثبت می‌شود. قاعده در `App\Billing\Service\InvoicePaymentStatus` است: +> | حالت | شرط | +> |------|-----| +> | `paid` | `paid_rials >= amount_rials` (شامل صورتحساب صفرریالی) | +> | `partial` | `0 < paid_rials < amount_rials` | +> | `unsettled` | `paid_rials = 0` و `amount_rials > 0` | +> +> صورتحساب بدون مراجعه (`patient_session_id = null`) هیچ پرداختی ندارد، پس `unsettled` می‌ماند. **Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد. @@ -362,7 +371,7 @@ } } ``` -> مبالغ جمع `patient_rials` هستند. `unsettled_rials = total_rials - paid_rials`. با فیلتر `status=paid` مقدار `unsettled_rials` صفر می‌شود (رفتار درست، نه باگ). وقتی هیچ رکوردی مطابقت ندارد، همه‌ی مقادیر `0` برمی‌گردند. +> مبالغ جمع `patient_rials` هستند. `paid_rials` = جمع صورتحساب‌هایی که **کاملاً** وصول شده‌اند؛ صورتحساب نیمه‌پرداخت (`partial`) کامل در `unsettled_rials` می‌نشیند. `unsettled_rials = total_rials - paid_rials`. با فیلتر `status=paid` مقدار `unsettled_rials` صفر می‌شود (رفتار درست، نه باگ). وقتی هیچ رکوردی مطابقت ندارد، همه‌ی مقادیر `0` برمی‌گردند. **Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد. @@ -377,14 +386,29 @@ "patient": { "uuid": "…", "name": "دنیا خلیلی", "national_code": "1744023654" }, "data": [ { "uuid": "…", "number": 12345, "issued_at": 1717000000, "total_rials": 2350000, + "patient_rials": 2350000, "paid_rials": 2350000, "status": "paid", "service_title": "روکش دندان", - "items": [ { "uuid": "…", "title": "روکش دندان", "quantity": 1, "total_rials": 2350000, "patient_rials": 2350000 } ] } + "items": [ { "uuid": "…", "title": "روکش دندان", "quantity": 1, "total_rials": 2350000, "patient_rials": 2350000 } ], + "payments": [ + { "method": "cash", "amount_rials": 350000, "paid_at": 1717000000, "created_by_name": "منشی" }, + { "method": "pos", "amount_rials": 2000000, "paid_at": 1717000500, "created_by_name": null } + ] } ], + "summary": { + "total_rials": 8350000, + "paid_rials": 2350000, + "unsettled_rials": 6000000, + "invoices_count": 3 + }, "meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 } } } ``` -> `status` دو حالته: `paid` (پرداخت‌شده) یا `unsettled` (تسویه‌نشده = `finalized`). `service_title` عنوان اولین آیتم است (+ «و موارد دیگر» اگر بیش از یک آیتم باشد). +> `payments` پرداخت‌های ثبت‌شده‌ی مراجعه‌ی همان صورتحساب است (قدیمی‌ترین اول)؛ `method` یکی از `wallet` · `pos` · `cash` · `card` — برچسب فارسی سمت کلاینت در `assets/admin/lib/paymentMethods.ts`. صورتحساب بدون مراجعه آرایه‌ی خالی می‌گیرد. +> +> `status` همان وضعیت مشتق‌شده‌ی بالاست (`paid` · `partial` · `unsettled`) و همیشه بر مبنای `patient_rials` سنجیده می‌شود، حتی در این نما که ستون مبلغش `total_rials` است. `service_title` عنوان اولین آیتم است (+ «و موارد دیگر» اگر بیش از یک آیتم باشد). +> +> `summary` روی **همه‌ی** صورتحساب‌های همان بیمار محاسبه می‌شود (نه فقط صفحه‌ی جاری) و جمع `total_rials` صورتحساب‌هاست — بر خلاف `summary` اندپوینت لیست پرداخت‌ها که سهم بیمار (`patient_rials`) را جمع می‌زند. `invoices_count` همان `meta.totalRecords` است. **Errors:** `403` پروفایل tenant یافت نشد · `404` (`ERR_NOT_FOUND_001`) بیمار متعلق به این tenant نیست/یافت نشد. diff --git a/src/Billing/Controller/BillingController.php b/src/Billing/Controller/BillingController.php index 166e2085..7a16767e 100644 --- a/src/Billing/Controller/BillingController.php +++ b/src/Billing/Controller/BillingController.php @@ -217,7 +217,7 @@ class BillingController extends BaseController /** * پرداخت‌های ثبت‌شده‌ی یک بیمار — سربرگ بیمار + فهرست صفحه‌بندی‌شده‌ی صورتحساب‌ها. * فقط مالک رکورد (همان tenant) اجازه دارد؛ در غیر این صورت ۴۰۴. - * پاسخ: { patient:{uuid,name,national_code}, data:[صورتحساب‌ها], meta:{...} }. + * پاسخ: { patient:{uuid,name,national_code}, data:[صورتحساب‌ها], summary:{...}, meta:{...} }. */ #[Route('/api/v1/my/billing/patients/{patientUuid}/invoices', methods: ['GET'])] public function listPatientInvoices(string $patientUuid, Request $request, #[CurrentUser] User $user): JsonResponse @@ -244,7 +244,8 @@ class BillingController extends BaseController 'name' => $patient->getRealName(), 'national_code' => $patient->getNationalCode(), ], - 'data' => $result['items'], + 'data' => $result['items'], + 'summary' => $result['summary'], 'meta' => [ 'totalRecords' => $result['total'], 'totalPages' => (int) ceil($result['total'] / $limit), diff --git a/src/Billing/Entity/Invoice.php b/src/Billing/Entity/Invoice.php index dd8ff409..200884ca 100644 --- a/src/Billing/Entity/Invoice.php +++ b/src/Billing/Entity/Invoice.php @@ -93,6 +93,7 @@ class Invoice public function getTotalRials(): int { return $this->totalRials; } public function getPatientRials(): int { return $this->patientRials; } public function getIssuedAt(): int { return $this->issuedAt; } + public function getPatientSessionId(): ?int { return $this->patientSessionId; } /** @return Collection */ public function getItems(): Collection { return $this->items; } diff --git a/src/Billing/Repository/InvoiceRepository.php b/src/Billing/Repository/InvoiceRepository.php index c8f90b44..8c5bad55 100644 --- a/src/Billing/Repository/InvoiceRepository.php +++ b/src/Billing/Repository/InvoiceRepository.php @@ -3,7 +3,9 @@ namespace App\Billing\Repository; use App\Billing\Entity\Invoice; +use App\Billing\Service\InvoicePaymentStatus; use App\Patient\Entity\PatientRecord; +use App\Patient\Entity\SessionPayment; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -40,7 +42,8 @@ class InvoiceRepository extends ServiceEntityRepository ->select( 'i.uuid AS invoice_uuid', 'r.uuid AS patient_uuid', 'u.realName AS patient_name', 'u.nationalCode AS national_code', 'i.issuedAt AS issued_at', - 'i.patientRials AS amount_rials', 'i.status AS status', + 'i.patientRials AS amount_rials', + sprintf('%s AS paid_rials', self::paidSumDql('sp_row')), ) ->orderBy('i.issuedAt', 'DESC') ->setFirstResult(($page - 1) * $limit) @@ -55,10 +58,78 @@ class InvoiceRepository extends ServiceEntityRepository 'national_code' => $r['national_code'], 'issued_at' => (int) $r['issued_at'], 'amount_rials' => (int) $r['amount_rials'], - 'status' => $r['status'] === Invoice::STATUS_PAID ? 'paid' : 'unsettled', + 'paid_rials' => (int) $r['paid_rials'], + 'status' => InvoicePaymentStatus::resolve((int) $r['amount_rials'], (int) $r['paid_rials']), ], $rows); } + /** + * زیرپرس‌وجوی مجموع پرداخت‌های مراجعه‌ی همان صورتحساب. صورتحساب بدون مراجعه + * هیچ پرداختی ندارد، پس `COALESCE` صفر می‌دهد و «تسویه‌نشده» می‌ماند. + * + * @param string $alias نام یکتا؛ استفاده‌ی دوبار از یک alias در یک query خطای semantical می‌دهد. + */ + private static function paidSumDql(string $alias): string + { + return sprintf( + '(SELECT COALESCE(SUM(%1$s.amountRials), 0) FROM %2$s %1$s WHERE IDENTITY(%1$s.session) = i.patientSessionId)', + $alias, + SessionPayment::class, + ); + } + + /** + * پرداخت‌های ثبت‌شده‌ی هر صورتحساب (از راه مراجعه‌اش)، در یک کوئری برای کل صفحه. + * مبلغ وصول‌شده هم از همین ردیف‌ها جمع می‌شود تا کوئری دوم لازم نباشد. + * + * @param list $invoices + * @return array> + * کلید = شناسه‌ی صورتحساب؛ قدیمی‌ترین پرداخت اول. + */ + public function paymentsForInvoices(array $invoices): array + { + $sessionIds = []; + foreach ($invoices as $invoice) { + $sessionId = $invoice->getPatientSessionId(); + if ($sessionId !== null) { + $sessionIds[$invoice->getId()] = $sessionId; + } + } + if ($sessionIds === []) { + return []; + } + + $rows = $this->getEntityManager()->createQueryBuilder() + ->select( + 'IDENTITY(sp.session) AS session_id', 'sp.method AS method', + 'sp.amountRials AS amount_rials', 'sp.paidAt AS paid_at', + 'sp.createdByName AS created_by_name', + ) + ->from(SessionPayment::class, 'sp') + ->where('IDENTITY(sp.session) IN (:sessions)') + ->setParameter('sessions', array_values($sessionIds)) + ->orderBy('sp.paidAt', 'ASC') + ->getQuery() + ->getArrayResult(); + + $bySession = []; + foreach ($rows as $row) { + $bySession[(int) $row['session_id']][] = [ + 'method' => $row['method'], + 'amount_rials' => (int) $row['amount_rials'], + 'paid_at' => (int) $row['paid_at'], + 'created_by_name' => $row['created_by_name'], + ]; + } + + $byInvoice = []; + foreach ($sessionIds as $invoiceId => $sessionId) { + $byInvoice[$invoiceId] = $bySession[$sessionId] ?? []; + } + + return $byInvoice; + } + public function countTenantInvoices(string $entityType, int $entityId, array $filters): int { return (int) $this->tenantInvoicesQuery($entityType, $entityId, $filters) @@ -76,18 +147,43 @@ class InvoiceRepository extends ServiceEntityRepository */ public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array { - $row = $this->tenantInvoicesQuery($entityType, $entityId, $filters) + return $this->summarize($this->tenantInvoicesQuery($entityType, $entityId, $filters), 'patientRials'); + } + + /** + * Aggregate totals over one patient's invoices — the same set {@see invoicesForPatient} + * pages through, so the detail page's cards match its table. + * + * @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int} + */ + public function patientInvoiceSummary(string $entityType, int $entityId, int $recordId): array + { + return $this->summarize($this->patientInvoicesQuery($entityType, $entityId, $recordId), 'totalRials'); + } + + /** + * Sum `$field` over a prepared invoice query, split by paid vs. still unsettled. + * + * @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) ->select( - 'COALESCE(SUM(i.patientRials), 0) AS total_rials', - 'COALESCE(SUM(CASE WHEN i.status = :paidStatus THEN i.patientRials ELSE 0 END), 0) AS paid_rials', + sprintf('COALESCE(SUM(i.%s), 0) AS total_rials', $field), 'COUNT(i.id) AS invoices_count', ) - ->setParameter('paidStatus', Invoice::STATUS_PAID) ->getQuery() ->getSingleResult(); + // «پرداخت‌شده» = مجموع صورتحساب‌هایی که سهم بیمارشان کامل وصول شده + $paid = (int) (clone $qb) + ->select(sprintf('COALESCE(SUM(i.%s), 0)', $field)) + ->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_sum'))) + ->getQuery() + ->getSingleScalarResult(); + $total = (int) $row['total_rials']; - $paid = (int) $row['paid_rials']; return [ 'total_rials' => $total, @@ -118,11 +214,17 @@ class InvoiceRepository extends ServiceEntityRepository if (!empty($filters['to'])) { $qb->andWhere('i.issuedAt <= :to')->setParameter('to', (int) $filters['to']); } - if (($filters['status'] ?? null) === 'paid') { - $qb->andWhere('i.status = :st')->setParameter('st', Invoice::STATUS_PAID); - } elseif (($filters['status'] ?? null) === 'unsettled') { - $qb->andWhere('i.status = :st')->setParameter('st', Invoice::STATUS_FINALIZED); - } + // وضعیت پرداخت مشتق است (پرداخت‌های مراجعه در برابر سهم بیمار)، نه ستون status + match ($filters['status'] ?? null) { + InvoicePaymentStatus::PAID => $qb->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_f1'))), + InvoicePaymentStatus::PARTIAL => $qb->andWhere(sprintf( + '%s > 0 AND %s < i.patientRials', + self::paidSumDql('sp_f1'), + self::paidSumDql('sp_f2'), + )), + InvoicePaymentStatus::UNSETTLED => $qb->andWhere(sprintf('%s <= 0 AND i.patientRials > 0', self::paidSumDql('sp_f1'))), + default => null, + }; return $qb; } @@ -141,14 +243,6 @@ class InvoiceRepository extends ServiceEntityRepository ->getResult(); } - public function countInvoicesForPatient(string $entityType, int $entityId, int $recordId): int - { - return (int) $this->patientInvoicesQuery($entityType, $entityId, $recordId) - ->select('COUNT(i.id)') - ->getQuery() - ->getSingleScalarResult(); - } - private function patientInvoicesQuery(string $entityType, int $entityId, int $recordId): QueryBuilder { return $this->createQueryBuilder('i') diff --git a/src/Billing/Service/InvoicePaymentStatus.php b/src/Billing/Service/InvoicePaymentStatus.php new file mode 100644 index 00000000..5ef8efb8 --- /dev/null +++ b/src/Billing/Service/InvoicePaymentStatus.php @@ -0,0 +1,28 @@ += $dueRials) { + return self::PAID; + } + + return $paidRials > 0 ? self::PARTIAL : self::UNSETTLED; + } +} diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php index ec6ecd8c..6b41cc2f 100644 --- a/src/Billing/Service/InvoiceService.php +++ b/src/Billing/Service/InvoiceService.php @@ -126,11 +126,14 @@ class InvoiceService * time, a single service title (first item, "+ more" when several), total, * a two-state status (paid|unsettled), and the full item breakdown. * - * @return array{items: list>, total: int} + * @return array{items: list>, total: int, summary: array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}} */ public function patientInvoiceList(string $entityType, int $entityId, int $recordId, int $page, int $limit): array { - $items = array_map(function (Invoice $invoice): array { + $invoices = $this->invoiceRepo->invoicesForPatient($entityType, $entityId, $recordId, $page, $limit); + $paymentsById = $this->invoiceRepo->paymentsForInvoices($invoices); + + $items = array_map(function (Invoice $invoice) use ($paymentsById): array { $lineItems = array_map(static fn(InvoiceItem $i) => $i->toArray(), $invoice->getItems()->toArray()); $title = match (count($lineItems)) { 0 => null, @@ -138,20 +141,29 @@ class InvoiceService default => $lineItems[0]['title'] . ' و موارد دیگر', }; + $payments = $paymentsById[$invoice->getId()] ?? []; + $paid = array_sum(array_column($payments, 'amount_rials')); + return [ 'uuid' => $invoice->getUuid(), 'number' => $invoice->getId(), 'issued_at' => $invoice->getIssuedAt(), 'total_rials' => $invoice->getTotalRials(), - 'status' => $invoice->getStatus() === Invoice::STATUS_PAID ? 'paid' : 'unsettled', + 'patient_rials' => $invoice->getPatientRials(), + 'paid_rials' => $paid, + 'status' => InvoicePaymentStatus::resolve($invoice->getPatientRials(), $paid), 'service_title' => $title, 'items' => $lineItems, + 'payments' => $payments, ]; - }, $this->invoiceRepo->invoicesForPatient($entityType, $entityId, $recordId, $page, $limit)); + }, $invoices); + + $summary = $this->invoiceRepo->patientInvoiceSummary($entityType, $entityId, $recordId); return [ - 'items' => $items, - 'total' => $this->invoiceRepo->countInvoicesForPatient($entityType, $entityId, $recordId), + 'items' => $items, + 'total' => $summary['invoices_count'], + 'summary' => $summary, ]; } } diff --git a/tests/Billing/PatientPaymentsTest.php b/tests/Billing/PatientPaymentsTest.php index ba73734e..c196acd9 100644 --- a/tests/Billing/PatientPaymentsTest.php +++ b/tests/Billing/PatientPaymentsTest.php @@ -8,6 +8,8 @@ use App\Billing\Entity\InvoiceItem; use App\Billing\ValueObject\ShareBreakdown; use App\Doctor\Entity\Doctor; use App\Patient\Entity\PatientRecord; +use App\Patient\Entity\PatientSession; +use App\Patient\Entity\SessionPayment; use App\Tests\ApiTestCase; /** @@ -48,6 +50,10 @@ class PatientPaymentsTest extends ApiTestCase return $record->getUser()->getNationalCode(); } + /** + * `$status` چرخه‌ی حیات صورتحساب است (draft/finalized/void). وضعیت پرداختِ + * نمایش‌داده‌شده از `$paidRials` مشتق می‌شود — پرداخت واقعی روی مراجعه. + */ private function invoice( Doctor $doctor, PatientRecord $record, @@ -56,9 +62,13 @@ class PatientPaymentsTest extends ApiTestCase ?int $issuedAt = null, ?string $itemTitle = null, int $totalRials = 0, + int $paidRials = 0, ): Invoice { $invoice = new Invoice('doctor', $doctor->getId()); $invoice->setPatientRecordId($record->getId()); + if ($paidRials > 0) { + $invoice->setPatientSessionId($this->paidSession($record, $paidRials)->getId()); + } if ($itemTitle !== null) { // Item added only for its title (service_title); totals set below by hand. $invoice->addItem(new InvoiceItem($invoice, $itemTitle, $totalRials, 1, new ShareBreakdown($totalRials, 0, 0, $patientRials), null)); @@ -75,6 +85,20 @@ class PatientPaymentsTest extends ApiTestCase return $invoice; } + /** مراجعه‌ای با یک پرداخت نقدی ثبت‌شده — منبع واقعیِ «چقدر وصول شده». */ + private function paidSession(PatientRecord $record, int $paidRials): PatientSession + { + $session = new PatientSession($record); + $this->em->persist($session); + $this->em->flush(); + + $payment = new SessionPayment($session, 'cash', $paidRials, time()); + $this->em->persist($payment); + $this->em->flush(); + + return $session; + } + private function setField(object $obj, string $prop, mixed $value): void { $ref = new \ReflectionProperty($obj, $prop); @@ -87,8 +111,8 @@ class PatientPaymentsTest extends ApiTestCase [$owner, $doctor] = $this->doctor(); $a = $this->patientRecord($doctor, 'دنیا خلیلی'); - $this->invoice($doctor, $a, Invoice::STATUS_PAID, 100000, 2000); - $this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 50000, 1000); + $this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 100000, 2000, null, 0, 100000); // fully paid + $this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 50000, 1000); // nothing paid $this->invoice($doctor, $a, Invoice::STATUS_DRAFT, 999999, 3000); // excluded $this->invoice($doctor, $a, Invoice::STATUS_VOID, 999999, 3000); // excluded @@ -111,10 +135,12 @@ class PatientPaymentsTest extends ApiTestCase public function testFiltersByNationalCodeAndStatus(): void { [$owner, $doctor] = $this->doctor(); - $paid = $this->patientRecord($doctor, 'بیمار پرداخت'); - $unpaid = $this->patientRecord($doctor, 'بیمار بدهکار'); - $this->invoice($doctor, $paid, Invoice::STATUS_PAID, 100000); + $paid = $this->patientRecord($doctor, 'بیمار پرداخت'); + $unpaid = $this->patientRecord($doctor, 'بیمار بدهکار'); + $partial = $this->patientRecord($doctor, 'بیمار نیمه‌پرداخت'); + $this->invoice($doctor, $paid, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 100000); $this->invoice($doctor, $unpaid, Invoice::STATUS_FINALIZED, 100000); + $this->invoice($doctor, $partial, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 40000); $byCode = $this->authJson('GET', '/api/v1/my/billing/payments?national_code=' . $this->nationalCodeOf($paid), $owner); self::assertSame(1, $byCode['meta']['totalRecords']); @@ -127,6 +153,12 @@ class PatientPaymentsTest extends ApiTestCase $onlyUnsettled = $this->authJson('GET', '/api/v1/my/billing/payments?status=unsettled', $owner); self::assertSame(1, $onlyUnsettled['meta']['totalRecords']); self::assertSame($unpaid->getUuid(), $onlyUnsettled['data'][0]['patient_uuid']); + + $onlyPartial = $this->authJson('GET', '/api/v1/my/billing/payments?status=partial', $owner); + self::assertSame(1, $onlyPartial['meta']['totalRecords']); + self::assertSame($partial->getUuid(), $onlyPartial['data'][0]['patient_uuid']); + self::assertSame('partial', $onlyPartial['data'][0]['status']); + self::assertSame(40000, $onlyPartial['data'][0]['paid_rials']); } public function testEmptyWhenNoInvoices(): void @@ -152,7 +184,7 @@ class PatientPaymentsTest extends ApiTestCase [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor, 'دنیا خلیلی'); - $this->invoice($doctor, $record, Invoice::STATUS_PAID, 235000, 1000, 'روکش دندان', 235000); + $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 235000, 1000, 'روکش دندان', 235000, 235000); $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 600000, 2000, 'طرح لبخند', 600000); $this->invoice($doctor, $record, Invoice::STATUS_DRAFT, 111, 3000, 'پیش‌نویس', 111); // excluded @@ -174,6 +206,47 @@ class PatientPaymentsTest extends ApiTestCase self::assertSame('paid', $rows[1]['status']); } + public function testPatientInvoiceRowsCarryTheirPaymentMethods(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor, 'دنیا خلیلی'); + $session = $this->paidSession($record, 60000); + + $extra = new SessionPayment($session, 'pos', 40000, time() + 60); + $this->em->persist($extra); + $this->em->flush(); + + $invoice = $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'روکش دندان', 100000); + $invoice->setPatientSessionId($session->getId()); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner); + $row = $res['data']['data'][0]; + + self::assertSame('paid', $row['status']); + self::assertSame(100000, $row['paid_rials']); + self::assertCount(2, $row['payments']); + // oldest payment first + self::assertSame('cash', $row['payments'][0]['method']); + self::assertSame(60000, $row['payments'][0]['amount_rials']); + self::assertSame('pos', $row['payments'][1]['method']); + self::assertSame(40000, $row['payments'][1]['amount_rials']); + } + + public function testPatientInvoiceWithoutSessionHasNoPayments(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor, 'بدون مراجعه'); + $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'ویزیت', 100000); + + $res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner); + $row = $res['data']['data'][0]; + + self::assertSame([], $row['payments']); + self::assertSame(0, $row['paid_rials']); + self::assertSame('unsettled', $row['status']); + } + public function testPatientInvoicesNotFoundForOtherTenant(): void { [, $doctor] = $this->doctor(); diff --git a/tests/Billing/PaymentsSummaryTest.php b/tests/Billing/PaymentsSummaryTest.php index a951984d..995898b4 100644 --- a/tests/Billing/PaymentsSummaryTest.php +++ b/tests/Billing/PaymentsSummaryTest.php @@ -6,6 +6,8 @@ use App\Auth\Entity\User; use App\Billing\Entity\Invoice; use App\Doctor\Entity\Doctor; use App\Patient\Entity\PatientRecord; +use App\Patient\Entity\PatientSession; +use App\Patient\Entity\SessionPayment; use App\Tests\ApiTestCase; /** @@ -45,10 +47,38 @@ class PaymentsSummaryTest extends ApiTestCase $ref->setValue($obj, $value); } - private function invoice(Doctor $doctor, PatientRecord $record, int $patientRials, string $status, int $issuedAt): Invoice + /** مراجعه‌ای با یک پرداخت نقدی ثبت‌شده — منبع واقعیِ «چقدر وصول شده». */ + private function paidSession(PatientRecord $record, int $paidRials): PatientSession { + $session = new PatientSession($record); + $this->em->persist($session); + $this->em->flush(); + + $payment = new SessionPayment($session, 'cash', $paidRials, 1_700_000_000); + $this->em->persist($payment); + $this->em->flush(); + + return $session; + } + + /** + * `totalRials` دو برابر سهم بیمار است تا دو خلاصه از هم قابل‌تفکیک بمانند. + * `$paidRials` پرداخت واقعی روی مراجعه است؛ وضعیت پرداخت از همین مشتق می‌شود. + */ + private function invoice( + Doctor $doctor, + PatientRecord $record, + int $patientRials, + string $status, + int $issuedAt, + int $paidRials = 0, + ): Invoice { $invoice = new Invoice('doctor', $doctor->getId()); $invoice->setPatientRecordId($record->getId()); + if ($paidRials > 0) { + $invoice->setPatientSessionId($this->paidSession($record, $paidRials)->getId()); + } + $this->setField($invoice, 'totalRials', $patientRials * 2); $this->setField($invoice, 'patientRials', $patientRials); $this->setField($invoice, 'status', $status); $this->setField($invoice, 'issuedAt', $issuedAt); @@ -63,10 +93,10 @@ class PaymentsSummaryTest extends ApiTestCase [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor); - $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000); + $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000); $this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_100); // draft invoices are not part of the payments list, so they must not count - $this->invoice($doctor, $record, 999_000, Invoice::STATUS_DRAFT, 1_700_000_200); + $this->invoice($doctor, $record, 999_000, Invoice::STATUS_DRAFT, 1_700_000_200, 999_000); $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner); self::assertSame(200, $this->responseCode()); @@ -83,9 +113,9 @@ class PaymentsSummaryTest extends ApiTestCase [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor); - $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_PAID, 1_700_000_000); + $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000); $this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_000); - $this->invoice($doctor, $record, 700_000, Invoice::STATUS_PAID, 1_800_000_000); + $this->invoice($doctor, $record, 700_000, Invoice::STATUS_FINALIZED, 1_800_000_000, 700_000); $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?status=paid', $owner); self::assertSame(1_700_000, $res['data']['total_rials']); @@ -106,8 +136,8 @@ class PaymentsSummaryTest extends ApiTestCase $mine = $this->patientRecord($doctor, $code); $other = $this->patientRecord($doctor, (string) random_int(1_000_000_000, 9_999_999_999)); - $this->invoice($doctor, $mine, 500_000, Invoice::STATUS_PAID, 1_700_000_000); - $this->invoice($doctor, $other, 800_000, Invoice::STATUS_PAID, 1_700_000_000); + $this->invoice($doctor, $mine, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 500_000); + $this->invoice($doctor, $other, 800_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 800_000); $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?national_code=' . $code, $owner); self::assertSame(500_000, $res['data']['total_rials']); @@ -126,11 +156,32 @@ class PaymentsSummaryTest extends ApiTestCase ); } + public function testPatientInvoicesCarryTheirOwnSummary(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor); + + $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000); + $this->invoice($doctor, $record, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_100); + $this->invoice($doctor, $record, 900_000, Invoice::STATUS_DRAFT, 1_700_000_200, 900_000); + + $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(2, $summary['invoices_count']); + self::assertSame(2, $res['data']['meta']['totalRecords']); + } + public function testSummaryExcludesOtherTenants(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor); - $this->invoice($doctor, $record, 300_000, Invoice::STATUS_PAID, 1_700_000_000); + $this->invoice($doctor, $record, 300_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 300_000); [$otherOwner] = $this->doctor(); $res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $otherOwner);