From 773f9d4d1678650824bed2cfb4347339af8433db Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 13:29:46 +0330 Subject: [PATCH] feat: update session payment logic to ensure accurate payable amounts and reflect consumables in cost breakdown - Adjusted the calculation of payable amounts in PaymentStep to align with server logic, ensuring overpayments are handled correctly. - Enhanced DetailsStep to include consumables in the itemized cost breakdown, ensuring consistency with patient share calculations. - Updated tests for SessionPaymentPage to validate new behavior regarding overpayments and consumable listings. - Modified PatientController to register SessionPayment correctly when settling sessions via wallet, preventing double charges. - Refactored WalletService to remove outdated methods and ensure wallet transactions reflect the correct amounts after discounts. - Improved accessibility in SearchableSelect component by adding aria labels and ensuring proper role attributes for screen readers. - Updated styles to ensure minimum touch targets meet WCAG guidelines for mobile usability. --- .../components/InvoiceSummaryModal.test.tsx | 7 ++- .../admin/components/SessionServiceCard.tsx | 3 +- .../components/appointments/DoctorTabs.tsx | 2 + .../components/layout/SettingsLayout.test.tsx | 4 +- .../admin/components/session/DetailsStep.tsx | 39 +++++++++++++- .../admin/components/session/PaymentStep.tsx | 7 +-- .../components/ui/PersianDateInput.test.tsx | 3 +- .../components/ui/SearchableSelect.test.tsx | 22 ++++++++ .../admin/components/ui/SearchableSelect.tsx | 12 +++++ assets/admin/lib/api.test.ts | 3 +- .../pages/AppointmentDetailPage.test.tsx | 21 +++++--- assets/admin/pages/CategoriesPage.tsx | 4 +- assets/admin/pages/DashboardPage.test.tsx | 8 +-- assets/admin/pages/LoginPage.test.tsx | 2 + assets/admin/pages/PatientDetailPage.test.tsx | 5 +- .../admin/pages/SessionPaymentPage.test.tsx | 54 +++++++++++++++++-- assets/admin/styles.css | 17 ++++++ docs/api/patient.md | 4 +- src/Patient/Controller/PatientController.php | 21 +++++--- src/Settlement/Service/WalletService.php | 25 --------- tests/Audit/LowTierFixesTest.php | 8 ++- .../PatientWalletSessionSettleTest.php | 50 +++++++++++++++++ 22 files changed, 251 insertions(+), 70 deletions(-) diff --git a/assets/admin/components/InvoiceSummaryModal.test.tsx b/assets/admin/components/InvoiceSummaryModal.test.tsx index a9d1e71b..33bbaa79 100644 --- a/assets/admin/components/InvoiceSummaryModal.test.tsx +++ b/assets/admin/components/InvoiceSummaryModal.test.tsx @@ -22,6 +22,8 @@ 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, + // API واقعی همیشه این را می‌فرستد: max(0, final − discount − paid) + remaining_rials: 540_000, payments: [ { uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000, created_by_name: 'منشی تست' }, ], @@ -59,9 +61,10 @@ describe('InvoiceSummaryModal', () => { expect(screen.getByText('جمع مبلغ کالا')).toBeInTheDocument(); // وضعیت — مبالغ واقعی (نمایش تومان = ریال ÷ ۱۰): - // پرداخت‌شده ۱۵۰٬۰۰۰ (هم در جدول پرداختی‌ها هم وضعیت) و باقی‌مانده ۷۴٬۰۰۰ + // پرداخت‌شده ۱۵۰٬۰۰۰ (هم در جدول پرداختی‌ها هم وضعیت) و باقی‌مانده ۵۴٬۰۰۰ + // (۲۲۴۰۰۰۰ − ۲۰۰۰۰۰ تخفیف − ۱۵۰۰۰۰۰ پرداختی = ۵۴۰۰۰۰ ریال؛ final_price پیش از تخفیف است) expect(screen.getAllByText(/۱۵۰٬۰۰۰/).length).toBeGreaterThanOrEqual(2); - expect(screen.getByText(/۷۴٬۰۰۰/)).toBeInTheDocument(); + expect(screen.getByText(/۵۴٬۰۰۰/)).toBeInTheDocument(); }); it('بدون session (فاکتور قدیمی): رفتار قبلی حفظ می‌شود', async () => { diff --git a/assets/admin/components/SessionServiceCard.tsx b/assets/admin/components/SessionServiceCard.tsx index 4978c51a..8fd9e9ba 100644 --- a/assets/admin/components/SessionServiceCard.tsx +++ b/assets/admin/components/SessionServiceCard.tsx @@ -14,7 +14,8 @@ export interface SessionPaymentEntry { export interface SessionCardData { uuid: string; services?: Array<{ service_item_uuid?: string; service_name?: string; name?: string; line_total_rials?: number; price_rials?: number; quantity?: number }>; - consumables?: Array<{ inventory_item_uuid?: string; item_name?: string; price_rials?: number; quantity?: number }>; + // مطابق SessionConsumable::toArray در بک‌اند + consumables?: Array<{ uuid?: string; inventory_item_uuid?: string; item_name?: string; unit?: string; price_rials?: number; quantity?: number; line_total_rials?: number }>; visit_price_rials?: number; services_total_rials?: number; session_at?: number | null; diff --git a/assets/admin/components/appointments/DoctorTabs.tsx b/assets/admin/components/appointments/DoctorTabs.tsx index bee9671a..b084702d 100644 --- a/assets/admin/components/appointments/DoctorTabs.tsx +++ b/assets/admin/components/appointments/DoctorTabs.tsx @@ -33,6 +33,8 @@ export default function DoctorTabs({ position: 'relative', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 15, fontWeight: 500, padding: '8px 2px 12px', color: active ? 'var(--primary)' : 'var(--text-2)', whiteSpace: 'nowrap', + // برچسب‌های کوتاه («همه») بدون این، عرضشان زیر ۴۴px می‌ماند (WCAG 2.5.5) + minWidth: 44, }} > {d.name} diff --git a/assets/admin/components/layout/SettingsLayout.test.tsx b/assets/admin/components/layout/SettingsLayout.test.tsx index 2c3c42fb..53ddcc3f 100644 --- a/assets/admin/components/layout/SettingsLayout.test.tsx +++ b/assets/admin/components/layout/SettingsLayout.test.tsx @@ -58,7 +58,9 @@ describe('SettingsLayout', () => { // SettingsMenuPage (mobile) keeps using menuForRole / SETTINGS_MENU expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic-doctors'); expect(menuForRole('clinic').map((i) => i.key)).not.toContain('doctor'); - expect(menuForRole('clinic').map((i) => i.key)).not.toContain('appointment'); + // کلینیک ورودی تنظیمات نوبت‌دهیِ مخصوص خودش را دارد + expect(menuForRole('clinic').find((i) => i.key === 'appointment')?.to) + .toBe('/admin/settings/appointment-settings'); // a plain doctor must not get the clinic-doctors management tab expect(menuForRole('doctor').map((i) => i.key)).not.toContain('clinic-doctors'); }); diff --git a/assets/admin/components/session/DetailsStep.tsx b/assets/admin/components/session/DetailsStep.tsx index f95a14d4..737169f3 100644 --- a/assets/admin/components/session/DetailsStep.tsx +++ b/assets/admin/components/session/DetailsStep.tsx @@ -41,7 +41,9 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P // زمان مراجعه: زمان واقعی نوبت؛ در نبود آن، زمان ثبت پرونده. const visitAt = session.session_at ?? session.created_at ?? null; - // آیتم‌های هزینه‌ی تفکیک‌شده: ویزیت + هر سرویس، با جمع کل. + // آیتم‌های هزینه‌ی تفکیک‌شده: ویزیت + هر سرویس + کالای مصرفی. + // کالای مصرفی هم باید بیاید چون سرور آن را در سهم بیمار می‌آورد + // (PatientService::…$consumablesTotal)؛ نبودش ریز هزینه‌ها را با جمع ناسازگار می‌کرد. const costItems: Array<{ label: string; rials: number }> = []; if (visitPrice > 0) costItems.push({ label: 'ویزیت', rials: visitPrice }); (session.services ?? []).forEach((s) => { @@ -50,6 +52,19 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P rials: s.line_total_rials ?? (s.price_rials ?? 0) * (s.quantity ?? 1), }); }); + (session.consumables ?? []).forEach((c) => { + costItems.push({ + label: c.item_name ?? 'کالای مصرفی', + rials: c.line_total_rials ?? 0, + }); + }); + + // خطوط بالا ناخالص‌اند (پیش از بیمه) ولی finalPrice سهمِ بیمار پس از بیمه است. + // پس وقتی بیمه هست، جمعِ خطوط را ناخالص نشان بده و سهم بیمه را جدا کم کن. + const grossTotal = session.gross_total_rials ?? finalPrice; + const baseInsurance = session.base_insurance_rials ?? 0; + const suppInsurance = session.supplementary_insurance_rials ?? 0; + const hasInsurance = baseInsurance > 0 || suppInsurance > 0; return ( <> @@ -78,8 +93,28 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P {formatRial(it.rials)} ))} + {hasInsurance && ( + <> +
+ جمع کل خدمات + {formatRial(grossTotal)} +
+ {baseInsurance > 0 && ( +
+ سهم بیمه پایه + {formatRial(baseInsurance)} +
+ )} + {suppInsurance > 0 && ( +
+ سهم بیمه تکمیلی + {formatRial(suppInsurance)} +
+ )} + + )}
- جمع کل + {hasInsurance ? 'سهم بیمار' : 'جمع کل'} {formatRial(finalPrice)}
diff --git a/assets/admin/components/session/PaymentStep.tsx b/assets/admin/components/session/PaymentStep.tsx index 715f1deb..64f1bc9a 100644 --- a/assets/admin/components/session/PaymentStep.tsx +++ b/assets/admin/components/session/PaymentStep.tsx @@ -124,9 +124,10 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont const baseInsurance = session.base_insurance_rials ?? 0; const suppInsurance = session.supplementary_insurance_rials ?? 0; const hasInsurance = baseInsurance > 0 || suppInsurance > 0; - const payable = session.remaining_rials !== undefined - ? session.remaining_rials + (session.paid_total_rials ?? 0) - : Math.max(0, finalPrice - discountRials); + // همان فرمول سرور (PatientSession::getPayableRials). بازسازی از روی + // `remaining + paid` غلط بود: سرور remaining را در صفر کلمپ می‌کند، پس در + // بیش‌پرداخت مبلغ نهایی تا اندازه‌ی پرداختی بالا می‌رفت و اضافه‌پرداخت پنهان می‌شد. + const payable = Math.max(0, finalPrice - discountRials); const debt = session.remaining_rials ?? session.patient_debt_rials ?? 0; /** diff --git a/assets/admin/components/ui/PersianDateInput.test.tsx b/assets/admin/components/ui/PersianDateInput.test.tsx index f9556533..6f348c0e 100644 --- a/assets/admin/components/ui/PersianDateInput.test.tsx +++ b/assets/admin/components/ui/PersianDateInput.test.tsx @@ -24,7 +24,8 @@ describe('PersianDateInput', () => { const onChange = vi.fn(); const { container } = render(); fireEvent.click((container.firstChild as HTMLElement).firstChild as Element); - const target = Array.from(container.querySelectorAll('button')).find( + // تقویم با createPortal به document.body می‌رود، پس بیرون از container است + const target = Array.from(document.body.querySelectorAll('button')).find( (b) => b.textContent?.trim() === '۱۵', ); expect(target).toBeTruthy(); diff --git a/assets/admin/components/ui/SearchableSelect.test.tsx b/assets/admin/components/ui/SearchableSelect.test.tsx index 8b6b27e4..58338e7a 100644 --- a/assets/admin/components/ui/SearchableSelect.test.tsx +++ b/assets/admin/components/ui/SearchableSelect.test.tsx @@ -31,3 +31,25 @@ describe('SearchableSelect — تطبیق مقدار', () => { expect(screen.queryByText('تامین اجتماعی')).not.toBeInTheDocument(); }); }); + +describe('SearchableSelect — نام دسترس‌پذیر', () => { + it('در نبود ariaLabel، از placeholder به‌عنوان نام استفاده می‌کند', () => { + render( {}} placeholder="نوع بیمه" />); + expect(screen.getByRole('combobox', { name: 'نوع بیمه' })).toBeInTheDocument(); + }); + + it('ariaLabel بر placeholder اولویت دارد', () => { + render( {}} placeholder="انتخاب کنید..." ariaLabel="بیمه پایه" />); + expect(screen.getByRole('combobox', { name: 'بیمه پایه' })).toBeInTheDocument(); + }); + + it('ariaLabelledBy به label قابل‌مشاهده وصل می‌شود', () => { + render( + <> + شهر + {}} ariaLabelledBy="lbl-city" /> + , + ); + expect(screen.getByRole('combobox', { name: 'شهر' })).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/ui/SearchableSelect.tsx b/assets/admin/components/ui/SearchableSelect.tsx index 0b77dd41..6d4cb1cc 100644 --- a/assets/admin/components/ui/SearchableSelect.tsx +++ b/assets/admin/components/ui/SearchableSelect.tsx @@ -18,6 +18,14 @@ interface Props { noOptionsMessage?: string; inputId?: string; height?: number; + /** + * نام دسترس‌پذیر فیلد. react-select ورودی داخلی خودش را بدون label رندر می‌کند و + * placeholder را هم به‌صورت div می‌گذارد نه attribute، پس بدون این، فیلد برای + * screen reader بی‌نام می‌ماند. پیش‌فرض روی placeholder می‌افتد. + */ + ariaLabel?: string; + /** اگر label قابل‌مشاهده‌ای وجود دارد، id آن را بده (بر ariaLabel اولویت دارد). */ + ariaLabelledBy?: string; } export default function SearchableSelect({ @@ -31,6 +39,8 @@ export default function SearchableSelect({ noOptionsMessage = 'موردی یافت نشد', inputId, height = 42, + ariaLabel, + ariaLabelledBy, }: Props) { const darkMode = useUiStore((s) => s.darkMode); @@ -118,6 +128,8 @@ export default function SearchableSelect({ noOptionsMessage={() => noOptionsMessage} loadingMessage={() => 'در حال بارگذاری...'} inputId={inputId} + aria-labelledby={ariaLabelledBy} + aria-label={ariaLabelledBy ? undefined : (ariaLabel ?? placeholder)} /> ); } diff --git a/assets/admin/lib/api.test.ts b/assets/admin/lib/api.test.ts index e8d6df7c..acdf4f01 100644 --- a/assets/admin/lib/api.test.ts +++ b/assets/admin/lib/api.test.ts @@ -14,7 +14,8 @@ import { api, ApiError } from '@/lib/api'; const replaceMock = vi.fn(); function jsonRes(body: unknown, ok = true, status = 200): Response { - return { ok, status, json: () => Promise.resolve(body) } as unknown as Response; + // headers لازم است: api.ts پیش از خواندن بدنه، Content-Length را برای پاسخ خالی چک می‌کند + return { ok, status, headers: new Headers(), json: () => Promise.resolve(body) } as unknown as Response; } function setToken(token: string) { diff --git a/assets/admin/pages/AppointmentDetailPage.test.tsx b/assets/admin/pages/AppointmentDetailPage.test.tsx index 23ac40aa..66f72915 100644 --- a/assets/admin/pages/AppointmentDetailPage.test.tsx +++ b/assets/admin/pages/AppointmentDetailPage.test.tsx @@ -17,13 +17,20 @@ const slotStart = Math.floor(new Date('2024-06-01T12:00:00').getTime() / 1000); beforeEach(() => { get.mockReset(); - get.mockResolvedValue({ - success: true, - data: { - uuid: 'ap1', patient_name: 'ساغر', patient_mobile: '09120000000', - slot_start: slotStart, slot_end: slotStart + 1800, status: 'confirmed', version: 1, created_at: slotStart, - }, - }); + // mock باید به URL حساس باشد: /events یک لیست برمی‌گرداند نه آبجکت نوبت + get.mockImplementation((url: string) => + Promise.resolve( + String(url).endsWith('/events') + ? { success: true, data: [] } + : { + success: true, + data: { + uuid: 'ap1', patient_name: 'ساغر', patient_mobile: '09120000000', + slot_start: slotStart, slot_end: slotStart + 1800, status: 'confirmed', version: 1, created_at: slotStart, + }, + }, + ), + ); }); describe('AppointmentDetailPage — بازگشت به همان روز', () => { diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx index 52c65c5a..5e60a2ff 100644 --- a/assets/admin/pages/CategoriesPage.tsx +++ b/assets/admin/pages/CategoriesPage.tsx @@ -100,7 +100,7 @@ function LogoUploadField({ value, onChange, uploadUrl }: { JPG، PNG یا WebP @@ -212,7 +212,7 @@ function TabActions({ label, onClick, exportUrl, exportFile, bundle, entityLabel return (
- +