feat(insurance): bill an appointment with a chosen service kind and insurance

An appointment can now carry the insurance it is billed with: the service kind
(outpatient/inpatient) and the basic insurance. Confirming it no longer hands the
whole amount to the patient — the visit is split through BillingCalculator with the
coverage percent of that service kind, and the choice travels to the encounter and
the invoice built from it.

The enabled service kinds are a tenant-wide setting (all of that tenant's
insurances share it), so a tenant covering only one kind is never asked which one:
the panel resolves it the same way the server does.

- add tenant_service_category_settings + TenantServiceCategoryService, exposed on
  the existing insurance-pricing endpoint (service_categories,
  default_service_category); at least one kind must stay enabled
- add appointments.insurance_service_category / insurance_base_id with
  AppointmentInsuranceService validating them against the tenant's own settings
  and active contracts (basic only), accepted by PATCH and by confirm
- snapshot the kind on patient_sessions and invoices; the visit's coverage rule is
  resolved per kind (services keep using their own ServiceItem.service_category)
- lib/insuranceShares becomes the single client-side mirror of BillingCalculator,
  shared by the confirm modal, the appointment edit page and the session form
- surface the selection: confirm modal (with live shares), turns timeline chip,
  appointment edit page, patient record service card and invoice summary
- the session form shows the insurance block whenever the tenant has an active
  contract and prefills the patient's own insurance, so it can be changed
- fix: the confirm modal showed a zero visit price when the appointment had none —
  it now falls back to the tenant's free-visit price like the server
- fix: useServiceCategories read one level too shallow, so Persian labels never
  arrived and raw enum keys leaked into the contract summary
- fix: BlogsPage test asserted the public blogs endpoint after the page moved to
  the admin one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-25 17:50:14 +03:30
co-authored by Claude Opus 5
parent 58c6d9ac18
commit 1f58b1b9b3
47 changed files with 2693 additions and 86 deletions
@@ -112,3 +112,137 @@ describe('patientShareOf — آینه‌ی BillingCalculator', () => {
expect(share).toBe(600_000);
});
});
// ── بیمه: نوع خدمت در ثبت/ویرایش مراجعه ──────────────────────────────────────
const BASIC_CONTRACT = {
uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
category_coverages: { outpatient: 70, inpatient: 30 },
};
/** پروفایلِ بیمار با بیمهٔ پایه، تا بلوک بیمه نمایش داده شود. */
const insuredProfile = { basic_insurance_id: 3 } as never;
function mockInsuranceEndpoints(enabled: string[]) {
const categories = [
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: enabled.includes('outpatient') },
{ key: 'inpatient', label: 'خدمات بستری', enabled: enabled.includes('inpatient') },
];
get.mockImplementation((url: string) => {
if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: {
free_visit_price_rials: 5_952_000,
require_visit_price: false,
service_categories: categories,
default_service_category: enabled.length === 1 ? enabled[0] : null,
} });
if (url === '/api/v1/inventory-items') return Promise.resolve({ success: true, data: { items: [] } });
if (url === '/api/v1/billing/tenant-insurances') return Promise.resolve({ success: true, data: { data: [BASIC_CONTRACT] } });
return Promise.resolve({ success: true, data: [] });
});
}
/** react-select: منو با ArrowDown باز می‌شود و گزینه با role=option انتخاب می‌شود. */
async function pick(selectLabel: string, optionText: string) {
fireEvent.keyDown(screen.getByRole('combobox', { name: selectLabel }), { key: 'ArrowDown' });
fireEvent.click(await screen.findByRole('option', { name: optionText }));
}
describe('CreateStep — نوع خدمت بیمه', () => {
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده می‌شود', async () => {
mockInsuranceEndpoints(['outpatient', 'inpatient']);
renderWithProviders(
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
);
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
});
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
mockInsuranceEndpoints(['outpatient']);
renderWithProviders(
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
);
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان (ورودی قیمت ویزیت رقم خام است)
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('595200'));
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
});
it('انتخاب نوع خدمت، درصد پوشش همان نوع را روی فرم می‌گذارد و ارسال می‌کند', async () => {
mockInsuranceEndpoints(['outpatient', 'inpatient']);
renderWithProviders(
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
);
await screen.findByText('نوع خدمت');
await pick('بدون بیمه پایه', 'بیمه ایران');
await pick('انتخاب نوع خدمت', 'خدمات بستری');
// درصد بستری = ۳۰
await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('30'));
fireEvent.click(screen.getByText('ایجاد سرویس'));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][1]).toMatchObject({
insurance_base_id: 3,
insurance_service_category: 'inpatient',
base_insurance_discount_percent: 30,
});
});
});
describe('CreateStep — نمایش و پیش‌انتخاب بیمه', () => {
it('با داشتن قرارداد بیمه، بلوک بیمه نمایش داده می‌شود (بدون بیمهٔ پروفایل)', async () => {
mockInsuranceEndpoints(['outpatient', 'inpatient']);
renderWithProviders(
<CreateStep recordUuid="r-1" profile={null} onCreated={vi.fn()} onCancel={() => {}} />,
);
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
expect(screen.getByText('بیمه تکمیلی')).toBeInTheDocument();
});
it('بدون هیچ قراردادی، بلوک بیمه نمایش داده نمی‌شود', async () => {
mockEndpoints({ free_visit_price_rials: 300_000, require_visit_price: false });
renderWithProviders(
<CreateStep recordUuid="r-1" profile={null} onCreated={vi.fn()} onCancel={() => {}} />,
);
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument();
});
it('مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیش‌انتخاب می‌شود', async () => {
mockInsuranceEndpoints(['outpatient']);
renderWithProviders(
<CreateStep recordUuid="r-1" profile={insuredProfile} onCreated={vi.fn()} onCancel={() => {}} />,
);
// بیمهٔ پروفایل (id=3) قرارداد فعال دارد → انتخاب و درصد سرپایی ۷۰
expect(await screen.findByText('بیمه ایران')).toBeInTheDocument();
await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('70'));
});
it('ویرایش مراجعه: بیمهٔ ثبت‌شده مشخص و قابل تغییر است', async () => {
mockInsuranceEndpoints(['outpatient', 'inpatient']);
const editSession = {
uuid: 's-9', visit_price_rials: 5_952_000, session_at: 1_700_000_000,
insurance_base_id: 3, base_insurance_discount_percent: 30,
insurance_service_category: 'inpatient',
services: [], consumables: [],
} as never;
renderWithProviders(
<CreateStep recordUuid="r-1" profile={null} editSession={editSession} onCreated={vi.fn()} onCancel={() => {}} />,
);
expect(await screen.findByText('بیمه ایران')).toBeInTheDocument();
expect(screen.getByText('خدمات بستری')).toBeInTheDocument();
expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('30');
// قابل تغییر: انتخاب نوع سرپایی درصد را به ۷۰ می‌برد
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
await waitFor(() => expect(screen.getByLabelText('تخفیف بیمه پایه')).toHaveValue('70'));
});
});
+79 -35
View File
@@ -11,37 +11,23 @@ import PersianDateInput from '../ui/PersianDateInput';
import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons';
import { useAuthStore } from '../../stores/authStore';
import { digitsOnly } from '../../lib/utils';
import {
DEFAULT_SERVICE_CATEGORY, contractPercentFor, patientShareOf,
type CoverageRule as Rule, type TenantContract,
} from '../../lib/insuranceShares';
interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null; category_coverages?: Record<string, number> }
interface Contract extends TenantContract { uuid: string }
interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null }
interface Rule { covered: boolean; percent: number; franchise: number; ceiling: number | null }
interface InventoryItemRow { uuid: string; name: string; unit: string; price: number; stock: number; status: string }
interface PackageRow { uuid: string; title: string; total: number; available: boolean }
interface StaffRow { uuid: string; full_name: string; active?: boolean }
/** ویزیت خدمتِ سرپایی است. */
const VISIT_SERVICE_CATEGORY = 'outpatient';
const VISIT_SERVICE_CATEGORY = DEFAULT_SERVICE_CATEGORY;
/**
* آینه‌ی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل.
* فرانشیز فقط در بیمهٔ تکمیلی اثر دارد؛ بیمهٔ پایه صرفاً درصدی است.
*/
export function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number {
let baseShare = 0;
let remaining = total;
if (base && base.covered) {
baseShare = Math.round(total * (base.percent / 100));
if (base.ceiling !== null) baseShare = Math.min(baseShare, base.ceiling);
remaining = total - baseShare;
}
let suppShare = 0;
if (supp && supp.covered) {
suppShare = Math.round(remaining * (supp.percent / 100));
if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling);
remaining = remaining - suppShare;
}
return Math.min(remaining + (supp?.franchise ?? 0), total);
}
// آینه‌ی BillingCalculator در lib/insuranceShares است؛ اینجا فقط re-export می‌شود تا
// مصرف‌کنندگان قبلی (و تست‌ها) نشکنند.
export { patientShareOf };
const todayISO = () => new Date().toISOString().slice(0, 10);
const nowHHMM = () => {
@@ -90,6 +76,8 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const [suppId, setSuppId] = useState('');
const [basePercent, setBasePercent] = useState('0');
const [suppPercent, setSuppPercent] = useState('0');
/** نوع خدمتِ بیمه‌ایِ این مراجعه؛ خالی یعنی «پیش‌فرضِ tenant». */
const [serviceCategory, setServiceCategory] = useState('');
const [notes, setNotes] = useState('');
// ── داده‌ها ──────────────────────────────────────────────────────────────
@@ -124,6 +112,13 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
// نوع خدماتِ بیمه‌ایِ فعالِ این tenant — همان تنظیم سراسری «مدیریت بیمه».
const enabledCategories: { key: string; label: string }[] =
((pricingData as any)?.data?.service_categories ?? []).filter((c: any) => c.enabled);
const needsCategoryChoice = enabledCategories.length > 1;
const defaultCategory: string =
(pricingData as any)?.data?.default_service_category ?? enabledCategories[0]?.key ?? VISIT_SERVICE_CATEGORY;
useEffect(() => {
if (!isEdit && freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(rialToToman(freeVisit)));
}, [freeVisit]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -141,6 +136,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
setNotes((editSession as any).notes ?? '');
if (editSession.insurance_base_id) { setBaseId(String(editSession.insurance_base_id)); setBasePercent(String(editSession.base_insurance_discount_percent ?? 0)); }
if (editSession.insurance_supplementary_id) { setSuppId(String(editSession.insurance_supplementary_id)); setSuppPercent(String(editSession.supplementary_discount_percent ?? 0)); }
setServiceCategory(editSession.insurance_service_category ?? '');
setSelectedServices((editSession.services ?? []).map((s) => ({
uuid: s.service_item_uuid ?? '', name: s.service_name || s.name || '', price: s.price_rials ?? 0, qty: s.quantity ?? 1, insured: false,
category: VISIT_SERVICE_CATEGORY,
@@ -172,6 +168,21 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const baseContract = contracts.find(c => String(c.insurance_id) === baseId) ?? null;
const suppContract = contracts.find(c => String(c.insurance_id) === suppId) ?? null;
/**
* مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیش‌انتخاب می‌شود — فقط اگر برای همان بیمه
* قرارداد فعال وجود داشته باشد. یک‌بار، و بعدش انتخاب کاربر دست‌نخورده می‌ماند.
*/
const [insurancePrefilled, setInsurancePrefilled] = useState(false);
useEffect(() => {
if (isEdit || insurancePrefilled || contracts.length === 0) return;
const profileBase = profile?.basic_insurance_id ? String(profile.basic_insurance_id) : '';
const profileSupp = profile?.supplementary_insurance_id ? String(profile.supplementary_insurance_id) : '';
if (profileBase && baseOpts.some(o => o.value === profileBase)) applyBase(profileBase);
if (profileSupp && suppOpts.some(o => o.value === profileSupp)) applySupp(profileSupp);
setInsurancePrefilled(true);
}, [contracts.length, profile, isEdit, insurancePrefilled]); // eslint-disable-line react-hooks/exhaustive-deps
const baseCoverageQ = useQuery<{ data: { data: CoverageRow[] } }>({
queryKey: ['service-coverage', baseContract?.uuid],
queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${baseContract!.uuid}/service-coverage`),
@@ -185,10 +196,6 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const baseCoverage = (baseCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
const suppCoverage = (suppCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
/** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */
const contractPercent = (contract: Contract, category: string): number =>
Number(contract.category_coverages?.[category] ?? contract.coverage_percent ?? 0);
/**
* قاعده‌ی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه درصد
* همان نوع خدمت (سرپایی/بستری). فرانشیز فقط در قرارداد تکمیلی خوانده می‌شود.
@@ -200,21 +207,39 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const isSupplementary = contract.insurance_kind === 'supplementary';
return {
covered: true,
percent: ov?.coverage_percent ?? contractPercent(contract, category),
percent: ov?.coverage_percent ?? contractPercentFor(contract, category),
franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0,
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
};
};
const coverageOf = (id: string): number => {
/** نوع خدمتِ مؤثرِ ویزیت: انتخاب کاربر، وگرنه تنها نوع فعالِ tenant. */
const visitCategory = serviceCategory || defaultCategory;
const coverageOf = (id: string, category = visitCategory): number => {
const contract = contracts.find(c => String(c.insurance_id) === id);
return contract ? contractPercent(contract, VISIT_SERVICE_CATEGORY) : 0;
return contract ? contractPercentFor(contract, category) : 0;
};
const applyBase = (id: string) => { setBaseId(id); setBasePercent(id ? String(coverageOf(id)) : '0'); };
const applySupp = (id: string) => { setSuppId(id); setSuppPercent(id ? String(coverageOf(id)) : '0'); };
// نمایش شرطی بلوک بیمه: سرویسِ تحت پوشش بیمه انتخاب شده یا پروفایل بیمار بیمه دارد.
const showInsurance = selectedServices.some(s => s.insured)
/** تغییر نوع خدمت، درصدهای ویزیت را با همان نوع همگام می‌کند. */
const applyServiceCategory = (category: string) => {
setServiceCategory(category);
if (baseId) setBasePercent(String(coverageOf(baseId, category || defaultCategory)));
if (suppId) setSuppPercent(String(coverageOf(suppId, category || defaultCategory)));
};
/**
* بلوک بیمه هر وقت این پزشک/کلینیک قرارداد بیمهٔ فعال دارد نمایش داده می‌شود تا
* بیمه قابل انتخاب و تغییر باشد؛ پیش‌تر تنها با سرویسِ تحت‌پوشش یا بیمهٔ پروفایل
* ظاهر می‌شد و کاربر راهی برای انتخاب بیمه نداشت. مراجعه‌ای که بیمه دارد هم
* (حالت ویرایش) همیشه بلوک را نشان می‌دهد.
*/
const showInsurance = contracts.length > 0
|| !!baseId
|| !!suppId
|| selectedServices.some(s => s.insured)
|| !!profile?.basic_insurance_id
|| !!profile?.supplementary_insurance_id;
@@ -257,9 +282,12 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
const servicesPatient = useMemo(
() => selectedServices.reduce((sum, x) => {
const total = x.price * x.qty;
if (!x.insured) return sum + total;
// نوع خدمت از کاتالوگ خوانده می‌شود تا ردیف‌های پیش‌پرشدهٔ ویرایش هم درست باشند.
const category = serviceItems.find(i => i.uuid === x.uuid)?.service_category ?? x.category;
// نوع خدمت و پرچم پوشش از کاتالوگ خوانده می‌شوند تا ردیف‌های پیش‌پرشدهٔ ویرایش
// هم مثل سرور حساب شوند (هنگام prefill این دو را نداریم).
const catalogItem = serviceItems.find(i => i.uuid === x.uuid);
const insured = catalogItem?.insurance_covered ?? x.insured;
if (!insured) return sum + total;
const category = catalogItem?.service_category ?? x.category;
return sum + patientShareOf(
total,
ruleFor(baseContract, baseCoverage, x.uuid, category),
@@ -297,6 +325,8 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
supplementary_discount_percent: showInsurance ? supp : 0,
...(showInsurance && baseId ? { insurance_base_id: Number(baseId) } : {}),
...(showInsurance && suppId ? { insurance_supplementary_id: Number(suppId) } : {}),
// نوع خدمتِ ویزیت؛ سرور درصد پوشش را بر پایهٔ همین resolve می‌کند.
insurance_service_category: showInsurance && (baseId || suppId) ? visitCategory : null,
...(isEdit ? {} : { payment_method: 'pending' }),
...(notes ? { notes } : {}),
session_at: toSessionAt(dateISO, time),
@@ -453,6 +483,20 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
{showInsurance && (
<div style={{ marginBottom: 16 }}>
<span style={fieldLabel}>بیمه</span>
{/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود؛ وگرنه همان نوعِ فعال. */}
{needsCategoryChoice && (
<div style={{ marginBottom: 12 }}>
<span style={fieldLabel}>نوع خدمت</span>
<SearchableSelect
inputId="service-category-select"
options={enabledCategories.map(c => ({ value: c.key, label: c.label }))}
value={serviceCategory || null}
onChange={v => applyServiceCategory(v ? String(v) : '')}
placeholder="انتخاب نوع خدمت"
isClearable
/>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<span style={fieldLabel}>بیمه پایه</span>