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.
This commit is contained in:
hamed
2026-07-19 13:29:46 +03:30
parent 0d9784b03a
commit 773f9d4d16
22 changed files with 251 additions and 70 deletions
@@ -22,6 +22,8 @@ const fullSession = {
session_at: 1700000000, paid_at: 1700100000, session_at: 1700000000, paid_at: 1700100000,
services_total_rials: 2_400_000, consumables_total_rials: 40_000, 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, 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: [ payments: [
{ uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000, created_by_name: 'منشی تست' }, { 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(); expect(screen.getByText('جمع مبلغ کالا')).toBeInTheDocument();
// وضعیت — مبالغ واقعی (نمایش تومان = ریال ÷ ۱۰): // وضعیت — مبالغ واقعی (نمایش تومان = ریال ÷ ۱۰):
// پرداخت‌شده ۱۵۰٬۰۰۰ (هم در جدول پرداختی‌ها هم وضعیت) و باقی‌مانده ۷۴٬۰۰۰ // پرداخت‌شده ۱۵۰٬۰۰۰ (هم در جدول پرداختی‌ها هم وضعیت) و باقی‌مانده ۵۴٬۰۰۰
// (۲۲۴۰۰۰۰ − ۲۰۰۰۰۰ تخفیف − ۱۵۰۰۰۰۰ پرداختی = ۵۴۰۰۰۰ ریال؛ final_price پیش از تخفیف است)
expect(screen.getAllByText(/۱۵۰٬۰۰۰/).length).toBeGreaterThanOrEqual(2); expect(screen.getAllByText(/۱۵۰٬۰۰۰/).length).toBeGreaterThanOrEqual(2);
expect(screen.getByText(/۷۴٬۰۰۰/)).toBeInTheDocument(); expect(screen.getByText(/۵۴٬۰۰۰/)).toBeInTheDocument();
}); });
it('بدون session (فاکتور قدیمی): رفتار قبلی حفظ می‌شود', async () => { it('بدون session (فاکتور قدیمی): رفتار قبلی حفظ می‌شود', async () => {
@@ -14,7 +14,8 @@ export interface SessionPaymentEntry {
export interface SessionCardData { export interface SessionCardData {
uuid: string; uuid: string;
services?: Array<{ service_item_uuid?: string; service_name?: string; name?: string; line_total_rials?: number; price_rials?: number; quantity?: number }>; 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; visit_price_rials?: number;
services_total_rials?: number; services_total_rials?: number;
session_at?: number | null; session_at?: number | null;
@@ -33,6 +33,8 @@ export default function DoctorTabs({
position: 'relative', background: 'none', border: 'none', cursor: 'pointer', position: 'relative', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 15, fontWeight: 500, padding: '8px 2px 12px', fontFamily: 'inherit', fontSize: 15, fontWeight: 500, padding: '8px 2px 12px',
color: active ? 'var(--primary)' : 'var(--text-2)', whiteSpace: 'nowrap', color: active ? 'var(--primary)' : 'var(--text-2)', whiteSpace: 'nowrap',
// برچسب‌های کوتاه («همه») بدون این، عرضشان زیر ۴۴px می‌ماند (WCAG 2.5.5)
minWidth: 44,
}} }}
> >
{d.name} {d.name}
@@ -58,7 +58,9 @@ describe('SettingsLayout', () => {
// SettingsMenuPage (mobile) keeps using menuForRole / SETTINGS_MENU // SettingsMenuPage (mobile) keeps using menuForRole / SETTINGS_MENU
expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic-doctors'); 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('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 // a plain doctor must not get the clinic-doctors management tab
expect(menuForRole('doctor').map((i) => i.key)).not.toContain('clinic-doctors'); expect(menuForRole('doctor').map((i) => i.key)).not.toContain('clinic-doctors');
}); });
@@ -41,7 +41,9 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P
// زمان مراجعه: زمان واقعی نوبت؛ در نبود آن، زمان ثبت پرونده. // زمان مراجعه: زمان واقعی نوبت؛ در نبود آن، زمان ثبت پرونده.
const visitAt = session.session_at ?? session.created_at ?? null; const visitAt = session.session_at ?? session.created_at ?? null;
// آیتم‌های هزینه‌ی تفکیک‌شده: ویزیت + هر سرویس، با جمع کل. // آیتم‌های هزینه‌ی تفکیک‌شده: ویزیت + هر سرویس + کالای مصرفی.
// کالای مصرفی هم باید بیاید چون سرور آن را در سهم بیمار می‌آورد
// (PatientService::…$consumablesTotal)؛ نبودش ریز هزینه‌ها را با جمع ناسازگار می‌کرد.
const costItems: Array<{ label: string; rials: number }> = []; const costItems: Array<{ label: string; rials: number }> = [];
if (visitPrice > 0) costItems.push({ label: 'ویزیت', rials: visitPrice }); if (visitPrice > 0) costItems.push({ label: 'ویزیت', rials: visitPrice });
(session.services ?? []).forEach((s) => { (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), 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 ( return (
<> <>
@@ -78,8 +93,28 @@ export default function DetailsStep({ session, recordUuid, onBack, onFinish }: P
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(it.rials)}</span> <span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(it.rials)}</span>
</div> </div>
))} ))}
{hasInsurance && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0 4px', borderTop: '1px dashed #E0E0E0' }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 700, color: '#2f2f2f' }}>جمع کل خدمات</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#2f2f2f' }}>{formatRial(grossTotal)}</span>
</div>
{baseInsurance > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0' }}>
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#616161' }}>سهم بیمه پایه</span>
<span style={{ fontSize: 14, fontWeight: 500, color: '#2E7D32' }}>{formatRial(baseInsurance)}</span>
</div>
)}
{suppInsurance > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0' }}>
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#616161' }}>سهم بیمه تکمیلی</span>
<span style={{ fontSize: 14, fontWeight: 500, color: '#2E7D32' }}>{formatRial(suppInsurance)}</span>
</div>
)}
</>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0 4px', borderTop: '1px dashed #E0E0E0' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0 4px', borderTop: '1px dashed #E0E0E0' }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 700, color: '#2f2f2f' }}>جمع کل</span> <span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 700, color: '#2f2f2f' }}>{hasInsurance ? 'سهم بیمار' : 'جمع کل'}</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#2f2f2f' }}>{formatRial(finalPrice)}</span> <span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#2f2f2f' }}>{formatRial(finalPrice)}</span>
</div> </div>
</div> </div>
@@ -124,9 +124,10 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
const baseInsurance = session.base_insurance_rials ?? 0; const baseInsurance = session.base_insurance_rials ?? 0;
const suppInsurance = session.supplementary_insurance_rials ?? 0; const suppInsurance = session.supplementary_insurance_rials ?? 0;
const hasInsurance = baseInsurance > 0 || suppInsurance > 0; const hasInsurance = baseInsurance > 0 || suppInsurance > 0;
const payable = session.remaining_rials !== undefined // همان فرمول سرور (PatientSession::getPayableRials). بازسازی از روی
? session.remaining_rials + (session.paid_total_rials ?? 0) // `remaining + paid` غلط بود: سرور remaining را در صفر کلمپ می‌کند، پس در
: Math.max(0, finalPrice - discountRials); // بیش‌پرداخت مبلغ نهایی تا اندازه‌ی پرداختی بالا می‌رفت و اضافه‌پرداخت پنهان می‌شد.
const payable = Math.max(0, finalPrice - discountRials);
const debt = session.remaining_rials ?? session.patient_debt_rials ?? 0; const debt = session.remaining_rials ?? session.patient_debt_rials ?? 0;
/** /**
@@ -24,7 +24,8 @@ describe('PersianDateInput', () => {
const onChange = vi.fn(); const onChange = vi.fn();
const { container } = render(<PersianDateInput value="2024-03-25" onChange={onChange} />); const { container } = render(<PersianDateInput value="2024-03-25" onChange={onChange} />);
fireEvent.click((container.firstChild as HTMLElement).firstChild as Element); 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() === '۱۵', (b) => b.textContent?.trim() === '۱۵',
); );
expect(target).toBeTruthy(); expect(target).toBeTruthy();
@@ -31,3 +31,25 @@ describe('SearchableSelect — تطبیق مقدار', () => {
expect(screen.queryByText('تامین اجتماعی')).not.toBeInTheDocument(); expect(screen.queryByText('تامین اجتماعی')).not.toBeInTheDocument();
}); });
}); });
describe('SearchableSelect — نام دسترس‌پذیر', () => {
it('در نبود ariaLabel، از placeholder به‌عنوان نام استفاده می‌کند', () => {
render(<SearchableSelect options={insuranceOpts} onChange={() => {}} placeholder="نوع بیمه" />);
expect(screen.getByRole('combobox', { name: 'نوع بیمه' })).toBeInTheDocument();
});
it('ariaLabel بر placeholder اولویت دارد', () => {
render(<SearchableSelect options={insuranceOpts} onChange={() => {}} placeholder="انتخاب کنید..." ariaLabel="بیمه پایه" />);
expect(screen.getByRole('combobox', { name: 'بیمه پایه' })).toBeInTheDocument();
});
it('ariaLabelledBy به label قابل‌مشاهده وصل می‌شود', () => {
render(
<>
<span id="lbl-city">شهر</span>
<SearchableSelect options={[{ value: 9, label: 'یزد' }]} onChange={() => {}} ariaLabelledBy="lbl-city" />
</>,
);
expect(screen.getByRole('combobox', { name: 'شهر' })).toBeInTheDocument();
});
});
@@ -18,6 +18,14 @@ interface Props {
noOptionsMessage?: string; noOptionsMessage?: string;
inputId?: string; inputId?: string;
height?: number; height?: number;
/**
* نام دسترس‌پذیر فیلد. react-select ورودی داخلی خودش را بدون label رندر می‌کند و
* placeholder را هم به‌صورت div می‌گذارد نه attribute، پس بدون این، فیلد برای
* screen reader بی‌نام می‌ماند. پیش‌فرض روی placeholder می‌افتد.
*/
ariaLabel?: string;
/** اگر label قابل‌مشاهده‌ای وجود دارد، id آن را بده (بر ariaLabel اولویت دارد). */
ariaLabelledBy?: string;
} }
export default function SearchableSelect({ export default function SearchableSelect({
@@ -31,6 +39,8 @@ export default function SearchableSelect({
noOptionsMessage = 'موردی یافت نشد', noOptionsMessage = 'موردی یافت نشد',
inputId, inputId,
height = 42, height = 42,
ariaLabel,
ariaLabelledBy,
}: Props) { }: Props) {
const darkMode = useUiStore((s) => s.darkMode); const darkMode = useUiStore((s) => s.darkMode);
@@ -118,6 +128,8 @@ export default function SearchableSelect({
noOptionsMessage={() => noOptionsMessage} noOptionsMessage={() => noOptionsMessage}
loadingMessage={() => 'در حال بارگذاری...'} loadingMessage={() => 'در حال بارگذاری...'}
inputId={inputId} inputId={inputId}
aria-labelledby={ariaLabelledBy}
aria-label={ariaLabelledBy ? undefined : (ariaLabel ?? placeholder)}
/> />
); );
} }
+2 -1
View File
@@ -14,7 +14,8 @@ import { api, ApiError } from '@/lib/api';
const replaceMock = vi.fn(); const replaceMock = vi.fn();
function jsonRes(body: unknown, ok = true, status = 200): Response { 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) { function setToken(token: string) {
@@ -17,13 +17,20 @@ const slotStart = Math.floor(new Date('2024-06-01T12:00:00').getTime() / 1000);
beforeEach(() => { beforeEach(() => {
get.mockReset(); get.mockReset();
get.mockResolvedValue({ // mock باید به URL حساس باشد: /events یک لیست برمی‌گرداند نه آبجکت نوبت
success: true, get.mockImplementation((url: string) =>
data: { Promise.resolve(
uuid: 'ap1', patient_name: 'ساغر', patient_mobile: '09120000000', String(url).endsWith('/events')
slot_start: slotStart, slot_end: slotStart + 1800, status: 'confirmed', version: 1, created_at: slotStart, ? { 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 — بازگشت به همان روز', () => { describe('AppointmentDetailPage — بازگشت به همان روز', () => {
+2 -2
View File
@@ -100,7 +100,7 @@ function LogoUploadField({ value, onChange, uploadUrl }: {
<label className={`btn ghost sm ${uploading ? 'disabled' : ''}`} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}> <label className={`btn ghost sm ${uploading ? 'disabled' : ''}`} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
<PhotoIcon style={{ width: 15, height: 15 }} /> <PhotoIcon style={{ width: 15, height: 15 }} />
{uploading ? 'در حال آپلود...' : (value ? 'تغییر لگو' : 'انتخاب لگو')} {uploading ? 'در حال آپلود...' : (value ? 'تغییر لگو' : 'انتخاب لگو')}
<input type="file" accept="image/jpeg,image/png,image/webp" style={{ display: 'none' }} onChange={handleFile} disabled={uploading} /> <input type="file" aria-label="انتخاب فایل لگو" accept="image/jpeg,image/png,image/webp" style={{ display: 'none' }} onChange={handleFile} disabled={uploading} />
</label> </label>
<span className="muted" style={{ fontSize: 11 }}>JPG، PNG یا WebP</span> <span className="muted" style={{ fontSize: 11 }}>JPG، PNG یا WebP</span>
</div> </div>
@@ -212,7 +212,7 @@ function TabActions({ label, onClick, exportUrl, exportFile, bundle, entityLabel
return ( return (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: 'var(--card-pad)' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: 'var(--card-pad)' }}>
<input ref={fileRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={handleFile} /> <input ref={fileRef} type="file" aria-label="انتخاب فایل JSON برای درون‌ریزی" accept=".json,application/json" style={{ display: 'none' }} onChange={handleFile} />
<button onClick={() => fileRef.current?.click()} className="btn ghost sm"> <button onClick={() => fileRef.current?.click()} className="btn ghost sm">
<ArrowUpTrayIcon style={{ width: 15, height: 15 }} /> <ArrowUpTrayIcon style={{ width: 15, height: 15 }} />
ورود JSON ورود JSON
+4 -4
View File
@@ -58,7 +58,7 @@ const clinicPayload = {
service_name: 'ویزیت عمومی', service_name: 'ویزیت عمومی',
slot_start: 1_718_000_000, slot_start: 1_718_000_000,
slot_end: 1_718_001_800, slot_end: 1_718_001_800,
status: 'visited', status: 'completed',
}, },
], ],
doctors: [], doctors: [],
@@ -95,9 +95,9 @@ describe('DashboardPage (ported clinic dashboard)', () => {
expect(screen.getByText('09136549874')).toBeInTheDocument(); expect(screen.getByText('09136549874')).toBeInTheDocument();
expect(screen.getByText('ویزیت عمومی')).toBeInTheDocument(); expect(screen.getByText('ویزیت عمومی')).toBeInTheDocument();
// ستون «وضعیت» از داشبورد حذف شده — تغییر وضعیت فقط در صفحه نوبت‌هاست // ستون «وضعیت» هست، ولی در داشبورد فقط خواندنی (بدون queryKey → StatusPill نه dropdown)
expect(screen.queryByText('وضعیت')).not.toBeInTheDocument(); expect(screen.getByText('وضعیت')).toBeInTheDocument();
expect(screen.queryByText('ویزیت شده')).not.toBeInTheDocument(); expect(screen.getByText('ویزیت شده').closest('button')).toBeNull();
// کارت «پزشکان کلینیک» از داشبورد حذف شده // کارت «پزشکان کلینیک» از داشبورد حذف شده
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument(); expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
+2
View File
@@ -5,6 +5,8 @@ import { renderWithProviders } from '@/test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/components/ui/PwaLoginCard', () => ({ default: () => null })); vi.mock('@/components/ui/PwaLoginCard', () => ({ default: () => null }));
// Altcha هنگام mount خودش fetch می‌زند؛ استاب‌کردنش «بدون fetch» را واقعاً معنادار می‌کند
vi.mock('@/components/ui/Altcha', () => ({ default: () => null }));
import { toast } from 'sonner'; import { toast } from 'sonner';
import LoginPage from '@/pages/LoginPage'; import LoginPage from '@/pages/LoginPage';
@@ -34,7 +34,8 @@ beforeEach(() => {
{ insurance_id: 5, insurance_name: 'بیمه دانا', type: 'supplementary' }, { insurance_id: 5, insurance_name: 'بیمه دانا', type: 'supplementary' },
], ],
} }); } });
if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: [ // صفحه ?filter=active را هم اضافه می‌کند، پس تطبیق باید پیشوندی باشد
if (url.startsWith('/api/v1/patient/r1/sessions')) return Promise.resolve({ success: true, data: [
{ uuid: 's1', services: [{ service_name: 'اسکیلینگ' }], doctor_name: 'دکتر فتحی', final_price_rials: 2500000, is_paid: false, patient_debt_rials: 1500000, notes: 'یادداشت', created_at: 1700000000, visit_price_rials: 0 }, { uuid: 's1', services: [{ service_name: 'اسکیلینگ' }], doctor_name: 'دکتر فتحی', final_price_rials: 2500000, is_paid: false, patient_debt_rials: 1500000, notes: 'یادداشت', created_at: 1700000000, visit_price_rials: 0 },
{ uuid: 's2', services: [{ service_name: 'روکش' }], doctor_name: 'دکتر فتحی', final_price_rials: 2350000, is_paid: true, patient_debt_rials: 0, invoice_uuid: 'iv1', created_at: 1700000000, visit_price_rials: 0 }, { uuid: 's2', services: [{ service_name: 'روکش' }], doctor_name: 'دکتر فتحی', final_price_rials: 2350000, is_paid: true, patient_debt_rials: 0, invoice_uuid: 'iv1', created_at: 1700000000, visit_price_rials: 0 },
], meta: { totalRecords: 2 } }); ], meta: { totalRecords: 2 } });
@@ -103,7 +104,7 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
it('issues the invoice first (create + finalize) when the paid session has none', async () => { it('issues the invoice first (create + finalize) when the paid session has none', async () => {
const base = get.getMockImplementation()!; const base = get.getMockImplementation()!;
get.mockImplementation((url: string) => { get.mockImplementation((url: string) => {
if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: [ if (url.startsWith('/api/v1/patient/r1/sessions')) return Promise.resolve({ success: true, data: [
{ uuid: 's3', services: [{ service_name: 'لیزر' }], doctor_name: 'دکتر فتحی', final_price_rials: 1000000, is_paid: true, patient_debt_rials: 0, created_at: 1700000000, visit_price_rials: 0 }, { uuid: 's3', services: [{ service_name: 'لیزر' }], doctor_name: 'دکتر فتحی', final_price_rials: 1000000, is_paid: true, patient_debt_rials: 0, created_at: 1700000000, visit_price_rials: 0 },
], meta: { totalRecords: 1 } }); ], meta: { totalRecords: 1 } });
if (url === '/api/v1/billing/invoices/iv9') return Promise.resolve({ success: true, data: { data: { if (url === '/api/v1/billing/invoices/iv9') return Promise.resolve({ success: true, data: { data: {
+50 -4
View File
@@ -93,10 +93,55 @@ describe('SessionPaymentPage (تکمیل پرداخت مراجعه)', () => {
await waitFor(() => expect(post).toHaveBeenCalledTimes(1)); await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
const [url, body] = post.mock.calls[0]; const [url, body] = post.mock.calls[0];
expect(url).toBe('/api/v1/session/s1/payments'); expect(url).toBe('/api/v1/session/s1/payments');
expect(body).toMatchObject({ method: 'cash', amount_rials: 500000 }); // فیلد به تومان است و کامپوننت tomanToRial می‌کند: ۵۰۰,۰۰۰ تومان = ۵,۰۰۰,۰۰۰ ریال
expect(body).toMatchObject({ method: 'cash', amount_rials: 5_000_000 });
expect(typeof (body as any).paid_at).toBe('number'); expect(typeof (body as any).paid_at).toBe('number');
}); });
it('does not inflate «مبلغ نهایی قابل پرداخت» when the patient overpaid', async () => {
// سرور remaining را در صفر کلمپ می‌کند. بازسازی از roo remaining+paid باعث
// می‌شد مبلغ نهایی به اندازه‌ی پرداختی بالا برود و بیش‌پرداخت دیده نشود.
mockGets([session({ remaining_rials: 0, paid_total_rials: 5_000_000 })]);
renderPage();
await screen.findByText('مبلغ نهایی قابل پرداخت:');
// ۲,۵۰۰,۰۰۰ − ۲۰۰,۰۰۰ تخفیف = ۲,۳۰۰,۰۰۰ — نه ۵,۰۰۰,۰۰۰
expect(screen.getAllByText(formatRial(2_300_000)).length).toBeGreaterThan(0);
expect(screen.queryByText(formatRial(5_000_000))).not.toBeInTheDocument();
});
it('lists consumables in the itemised cost breakdown', async () => {
// کالای مصرفی در سهم بیمار حساب می‌شود، پس باید در ریز هزینه‌ها هم بیاید
// وگرنه جمعِ خطوط با «جمع کل» نمی‌خواند.
mockGets([session({
visit_price_rials: 500_000,
consumables: [{ uuid: 'c1', item_name: 'عینک', quantity: 2, line_total_rials: 40_000 }],
payments: [{ uuid: 'p1', method: 'cash', amount_rials: 100_000, paid_at: 1_700_000_000 }],
})]);
renderPage();
fireEvent.click(await screen.findByText('ثبت و ادامه'));
expect(screen.getByText('عینک')).toBeInTheDocument();
expect(screen.getAllByText(formatRial(40_000)).length).toBeGreaterThan(0);
});
it('shows insurance shares so the breakdown reconciles with the patient share', async () => {
mockGets([session({
gross_total_rials: 4_000_000,
base_insurance_rials: 1_000_000,
supplementary_insurance_rials: 500_000,
final_price_rials: 2_500_000,
payments: [{ uuid: 'p1', method: 'cash', amount_rials: 100_000, paid_at: 1_700_000_000 }],
})]);
renderPage();
fireEvent.click(await screen.findByText('ثبت و ادامه'));
expect(screen.getByText('جمع کل خدمات')).toBeInTheDocument();
expect(screen.getByText('سهم بیمه پایه')).toBeInTheDocument();
expect(screen.getByText('سهم بیمه تکمیلی')).toBeInTheDocument();
expect(screen.getByText('سهم بیمار')).toBeInTheDocument();
});
it('prefills the amount field with the remaining balance when a method is opened', async () => { it('prefills the amount field with the remaining balance when a method is opened', async () => {
// مانده ۲,۳۰۰,۰۰۰ ریال → ۲۳۰,۰۰۰ تومان در فیلد مبلغ // مانده ۲,۳۰۰,۰۰۰ ریال → ۲۳۰,۰۰۰ تومان در فیلد مبلغ
mockGets([session({ remaining_rials: 2_300_000 })]); mockGets([session({ remaining_rials: 2_300_000 })]);
@@ -121,9 +166,9 @@ describe('SessionPaymentPage (تکمیل پرداخت مراجعه)', () => {
renderPage(); renderPage();
await screen.findByText('هزینه سرویس:'); await screen.findByText('هزینه سرویس:');
// حذف تخفیف → discount_type: null // حذف تخفیف → discount_rule_uuid: null (تخفیف قاعده‌محور را هم جدا می‌کند)
fireEvent.click(screen.getByText('حذف تخفیف')); fireEvent.click(screen.getByText('حذف تخفیف'));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { discount_type: null })); await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { discount_rule_uuid: null }));
}); });
it('shows registered payments and moves to details step', async () => { it('shows registered payments and moves to details step', async () => {
@@ -144,7 +189,8 @@ describe('SessionPaymentPage (تکمیل پرداخت مراجعه)', () => {
// گام جزییات // گام جزییات
fireEvent.click(screen.getByText('ثبت و ادامه')); fireEvent.click(screen.getByText('ثبت و ادامه'));
expect(screen.getByText('صدور فاکتور')).toBeInTheDocument(); expect(screen.getByText('صدور فاکتور')).toBeInTheDocument();
expect(screen.getByText('کاشت مو')).toBeInTheDocument(); // نام سرویس هم در سرتیتر و هم در ریز هزینه‌ها می‌آید
expect(screen.getAllByText('کاشت مو').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('دکتر فتحی')).toBeInTheDocument(); expect(screen.getByText('دکتر فتحی')).toBeInTheDocument();
expect(screen.getByText('مبلغ باقی مانده:')).toBeInTheDocument(); expect(screen.getByText('مبلغ باقی مانده:')).toBeInTheDocument();
}); });
+17
View File
@@ -514,6 +514,12 @@ body {
.muted { color: var(--text-3); } .muted { color: var(--text-3); }
.link { color: var(--primary); font-weight: 600; font-size: 13px; text-decoration: none; } .link { color: var(--primary); font-weight: 600; font-size: 13px; text-decoration: none; }
.link:hover { color: var(--primary-700); } .link:hover { color: var(--primary-700); }
/* هم‌راستا با .mini-btn: لینک‌های کوتاه («همه») روی موبایل باید ناحیه‌ی لمسی
حداقلی داشته باشند (WCAG 2.5.5). چون داخل .card-title-row فلکس‌اند، رشدِ
ارتفاع عنوان را جابه‌جا نمی‌کند. */
@media (max-width: 640px), (pointer: coarse) {
.link { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; min-width: 44px; }
}
.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } .card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.flex { display: flex; align-items: center; } .flex { display: flex; align-items: center; }
.between { display: flex; align-items: center; justify-content: space-between; } .between { display: flex; align-items: center; justify-content: space-between; }
@@ -623,6 +629,13 @@ table.t tbody tr:last-child td { border-bottom: none; }
} }
.mini-btn:hover { background: var(--surface-3); color: var(--text); } .mini-btn:hover { background: var(--surface-3); color: var(--text); }
.mini-btn.danger:hover { background: var(--danger-bg); color: var(--danger); } .mini-btn.danger:hover { background: var(--danger-bg); color: var(--danger); }
/* دکمه‌های عملیات ردیف روی موبایل/لمسی باید حداقل ۴۴px باشند (WCAG 2.5.5).
هم بر اساس عرض (۶۴۰px، همان بریک‌پوینت موبایلِ پروژه) و هم pointer لمسی،
تا تبلت لمسیِ عریض هم پوشش داده شود. چگالی دسکتاپ دست‌نخورده می‌ماند. */
@media (max-width: 640px), (pointer: coarse) {
.mini-btn { width: 44px; height: 44px; }
.row-actions { gap: 4px; }
}
/* ── Toolbar / Fields ────────────────────────────────────────── */ /* ── Toolbar / Fields ────────────────────────────────────────── */
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; } .toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
@@ -637,6 +650,10 @@ table.t tbody tr:last-child td { border-bottom: none; }
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; } .seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
.seg button { padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-2); transition: .14s; cursor: pointer; font-family: inherit; } .seg button { padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-2); transition: .14s; cursor: pointer; font-family: inherit; }
.seg button.on { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); } .seg button.on { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
/* هم‌راستا با .mini-btn: ارتفاع لمسیِ حداقلی روی موبایل (WCAG 2.5.5) */
@media (max-width: 640px), (pointer: coarse) {
.seg button { min-height: 44px; padding: 7px 16px; }
}
/* ── Switch toggle ───────────────────────────────────────────── */ /* ── Switch toggle ───────────────────────────────────────────── */
.switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; } .switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; }
+2 -2
View File
@@ -377,7 +377,7 @@ Returns paginated sessions for a patient record.
| Field | Type | Notes | | Field | Type | Notes |
|-------|------|-------| |-------|------|-------|
| `is_paid` | bool | `true` وقتی `payment_method !== "pending"` | | `is_paid` | bool | `true` وقتی `remaining_rials === 0` (یعنی مجموع `SessionPayment`ها + تخفیف به مبلغ نهایی رسیده) — نه صرفاً از روی `payment_method` |
| `services` | array | سرویس‌های ثبت‌شده در این session (نام، انجام‌دهنده/`staff`, قیمت، تعداد) | | `services` | array | سرویس‌های ثبت‌شده در این session (نام، انجام‌دهنده/`staff`, قیمت، تعداد) |
| `invoice_uuid` | string\|null | uuid فاکتور مرتبط (اگر ساخته شده باشد؛ برای «مشاهده فاکتور») | | `invoice_uuid` | string\|null | uuid فاکتور مرتبط (اگر ساخته شده باشد؛ برای «مشاهده فاکتور») |
| `invoice_status` | string\|null | `draft`\|`finalized`\|`paid`\|`void` | | `invoice_status` | string\|null | `draft`\|`finalized`\|`paid`\|`void` |
@@ -552,7 +552,7 @@ Updates mutable fields on a session.
} }
``` ```
- `payment_method: "wallet"` روی مراجعه‌ی تسویه‌نشده، کل مبلغ نهایی را از کیف پول بیمار کسر می‌کند (موجودی ناکافی → `422 ERR_WALLET_INSUFFICIENT`). - `payment_method: "wallet"` روی مراجعه‌ی تسویه‌نشده، **مانده** (`remaining_rials` = مبلغ نهایی − تخفیف − پرداخت‌های قبلی) را از کیف پول بیمار کسر می‌کند و همزمان یک `SessionPayment` با روش `wallet` ثبت می‌کند؛ در نتیجه `is_paid=true` و `paid_at` ست می‌شود و تراکنش debit با `reference: "session:{uuid}"` ثبت می‌گردد. موجودی ناکافی → `422 ERR_WALLET_INSUFFICIENT`. اگر مانده صفر باشد (مراجعه از قبل تسویه شده) هیچ کسری انجام نمی‌شود — فراخوانی دوباره **کسر مضاعف نمی‌کند**.
- **تخفیف تسویه (دستی):** `discount_type` = `percent` (۰..۱۰۰) یا `fixed` (ریال، حداکثر برابر مبلغ نهایی) یا `null` (حذف تخفیف). مبلغ محاسبه‌شده در `discount_rials` برمی‌گردد. تخفیف نمی‌تواند از «مبلغ نهایی منهای پرداخت‌های ثبت‌شده» بیشتر شود. تخفیفی که مانده را صفر کند مراجعه را تسویه‌شده می‌کند (`is_paid`, `paid_at`). - **تخفیف تسویه (دستی):** `discount_type` = `percent` (۰..۱۰۰) یا `fixed` (ریال، حداکثر برابر مبلغ نهایی) یا `null` (حذف تخفیف). مبلغ محاسبه‌شده در `discount_rials` برمی‌گردد. تخفیف نمی‌تواند از «مبلغ نهایی منهای پرداخت‌های ثبت‌شده» بیشتر شود. تخفیفی که مانده را صفر کند مراجعه را تسویه‌شده می‌کند (`is_paid`, `paid_at`).
- **تخفیف بر اساس قانون:** `discount_rule_uuid` (رشته) → قانون تخفیف (owner-scoped) اعمال می‌شود؛ مقدار ریالی از خود قانون توسط موتور محاسبه می‌گردد (نوع/مبنا بر اساس قانون). `''`/`null` → حذف تخفیف. اولویت بر `discount_type` دستی. قانونِ نامعتبر → `404`. منبع اعمال‌شده در پاسخ به‌صورت `applied_discount_rule_id` و `applied_discount_rule_label` (audit) برمی‌گردد. قوانین قابل‌اعمال از `GET /api/v1/session/{uuid}/discount-suggestions` (نگاه کنید به `discount.md`). - **تخفیف بر اساس قانون:** `discount_rule_uuid` (رشته) → قانون تخفیف (owner-scoped) اعمال می‌شود؛ مقدار ریالی از خود قانون توسط موتور محاسبه می‌گردد (نوع/مبنا بر اساس قانون). `''`/`null` → حذف تخفیف. اولویت بر `discount_type` دستی. قانونِ نامعتبر → `404`. منبع اعمال‌شده در پاسخ به‌صورت `applied_discount_rule_id` و `applied_discount_rule_label` (audit) برمی‌گردد. قوانین قابل‌اعمال از `GET /api/v1/session/{uuid}/discount-suggestions` (نگاه کنید به `discount.md`).
- `paid_at`: unix timestamp زمان تسویه. - `paid_at`: unix timestamp زمان تسویه.
+13 -8
View File
@@ -1073,15 +1073,20 @@ class PatientController extends BaseController
if (isset($data['payment_method'])) { if (isset($data['payment_method'])) {
$method = (string) $data['payment_method']; $method = (string) $data['payment_method'];
// پرداخت از کیف پول: سهمِ بیمار را از موجودی کسر کن (فقط یک‌بار، اگر // پرداخت از کیف پول: مانده را از موجودی کسر کن و همزمان SessionPayment ثبت کن.
// مراجعه هنوز تسویه نشده). موجودیِ ناکافی → AppException (۴۲۲). // از addSessionPayment استفاده می‌شود تا مبلغ = مانده (پس از تخفیف و پرداخت‌های
if ($method === 'wallet' // قبلی) باشد و is_paid/paid_at درست ست شود؛ موجودی ناکافی → AppException (۴۲۲).
&& $session->getPaymentMethod() === 'pending' if ($method === 'wallet' && $session->getRemainingRials() > 0) {
&& $session->getFinalPriceRials() > 0 $this->patientService->addSessionPayment(
) { $session,
$this->walletService->settleSessionFromWallet($session, $user); 'wallet',
$session->getRemainingRials(),
isset($data['paid_at']) ? (int) $data['paid_at'] : null,
$user,
);
} else {
$session->setPaymentMethod($method);
} }
$session->setPaymentMethod($method);
} }
$this->sessionRepo->save($session); $this->sessionRepo->save($session);
-25
View File
@@ -3,8 +3,6 @@
namespace App\Settlement\Service; namespace App\Settlement\Service;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionService;
use App\Settlement\Entity\WalletTransaction; use App\Settlement\Entity\WalletTransaction;
use App\Settlement\Repository\SettlementRepository; use App\Settlement\Repository\SettlementRepository;
use App\Settlement\Repository\WalletTransactionRepository; use App\Settlement\Repository\WalletTransactionRepository;
@@ -84,29 +82,6 @@ class WalletService
); );
} }
/**
* تسویهٔ یک مراجعه از کیف پول: مبلغِ نهاییِ سهمِ بیمار را به‌صورت debit کسر
* می‌کند و دلیل را به نامِ سرویس‌ها و شناسهٔ مراجعه گره می‌زند. موجودیِ ناکافی → ۴۲۲.
*/
public function settleSessionFromWallet(PatientSession $session, ?User $actor = null): WalletTransaction
{
$patient = $session->getRecord()->getUser();
$amount = $session->getFinalPriceRials();
$names = array_values(array_filter(array_map(
fn(SessionService $s) => $s->toArray()['service_name'] ?? null,
$session->getServices()->toArray(),
)));
$label = $names !== [] ? implode('، ', $names) : 'ویزیت';
return $this->withdraw(
$patient, $amount, $actor,
'پرداخت سرویس: ' . $label,
'wallet',
'session:' . $session->getUuid(),
);
}
private function record( private function record(
User $patient, User $patient,
int $amountRials, int $amountRials,
+3 -5
View File
@@ -3,7 +3,6 @@
namespace App\Tests\Audit; namespace App\Tests\Audit;
use App\Auth\Entity\PreRegistration; use App\Auth\Entity\PreRegistration;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase; use App\Tests\ApiTestCase;
/** /**
@@ -15,11 +14,10 @@ class LowTierFixesTest extends ApiTestCase
{ {
public function testUpdateSessionEnforcesPatientGate(): void public function testUpdateSessionEnforcesPatientGate(): void
{ {
// a doctor with no subscription → no patient_records feature // ROLE_DOCTOR بدون رکورد Doctor → resolveEntity مقدار entityId=null می‌دهد
// و گیت با ERR_FORBIDDEN_001 رد می‌کند. (به features پلن وابسته نیست: پلن
// free از Version20260705070546 به بعد patient_records را می‌دهد.)
$doctorUser = $this->createUser(['ROLE_DOCTOR']); $doctorUser = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر');
$this->em->persist($doctor);
$this->em->flush();
// gate fires before the session lookup → 403 (not 404) // gate fires before the session lookup → 403 (not 404)
$this->authJson('PATCH', '/api/v1/session/nonexistent-uuid', $doctorUser, ['notes' => 'x']); $this->authJson('PATCH', '/api/v1/session/nonexistent-uuid', $doctorUser, ['notes' => 'x']);
@@ -85,6 +85,56 @@ class PatientWalletSessionSettleTest extends ApiTestCase
self::assertSame(100_000, $wallet['data']['balance_rials']); self::assertSame(100_000, $wallet['data']['balance_rials']);
} }
/**
* رگرسیون: تسویهٔ کیف پول باید SessionPayment ثبت کند تا مراجعه واقعاً is_paid شود.
* پیش‌تر فقط موجودی کسر می‌شد و مراجعه پرداخت‌نشده می‌ماند، پس PATCH دوم دوباره
* از کیف پول کم می‌کرد (کسر مضاعف از پول واقعی بیمار).
*/
public function testSettlingTwiceDoesNotChargeWalletAgain(): void
{
[$owner, $record, $patient] = $this->recordFor();
$this->em->persist(new WalletTransaction($patient, 1_000_000, 'credit', 1_000_000));
$this->em->flush();
$session = $this->sessionFor($record, 400_000);
$url = '/api/v1/session/' . $session->getUuid();
$this->authJson('PATCH', $url, $owner, ['payment_method' => 'wallet']);
self::assertSame(200, $this->responseCode());
// تسویهٔ دوباره روی همان مراجعه نباید چیزی کسر کند.
$res = $this->authJson('PATCH', $url, $owner, ['payment_method' => 'wallet']);
self::assertSame(200, $this->responseCode());
self::assertTrue($res['data']['is_paid']);
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
self::assertSame(600_000, $wallet['data']['balance_rials']);
}
/**
* رگرسیون: مبلغِ کسرشده باید مانده (پس از تخفیف) باشد نه قیمتِ نهاییِ خام.
*/
public function testWalletSettleChargesPayableAfterDiscount(): void
{
[$owner, $record, $patient] = $this->recordFor();
$this->em->persist(new WalletTransaction($patient, 1_000_000, 'credit', 1_000_000));
$this->em->flush();
$session = $this->sessionFor($record, 400_000);
$session->setDiscount('fixed', 100_000, 100_000);
$this->em->flush();
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
'payment_method' => 'wallet',
]);
self::assertSame(200, $this->responseCode());
self::assertTrue($res['data']['is_paid']);
// ۴۰۰٬۰۰۰ − ۱۰۰٬۰۰۰ تخفیف = ۳۰۰٬۰۰۰ کسر شود، نه ۴۰۰٬۰۰۰.
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
self::assertSame(700_000, $wallet['data']['balance_rials']);
}
public function testSettleSessionWithCashDoesNotTouchWallet(): void public function testSettleSessionWithCashDoesNotTouchWallet(): void
{ {
[$owner, $record, $patient] = $this->recordFor(); [$owner, $record, $patient] = $this->recordFor();