feat(insurance): resolve coverage percent per service category
Base insurance is a percentage-only rule: patient share is now total minus the base share, and the contract franchise no longer inflates it (franchise stays meaningful for supplementary contracts only). Coverage percentages are managed centrally by admin per service category (outpatient/inpatient, extensible via the ServiceCategory enum). A tenant contract may override a category, otherwise it follows the admin default live — changing the central value immediately applies to every contract that did not override it. - add ServiceCategory enum + GET /api/v1/service-categories as the single source of the category list for every client - add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints) and expose coverage_defaults on the insurance list and insurance-pricing - add tenant_insurance_category_coverage; tenant-insurances accepts optional category_coverages (needs insurances.update) and returns the effective percentages with their source - add service_items.service_category; visits always resolve as outpatient - drop the reverse-engineered percent from patient_share_rials in MyPatientsPage and align the client-side BillingCalculator mirror in CreateStep Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ vi.mock('../../lib/api', () => ({
|
||||
}));
|
||||
|
||||
import { api } from '../../lib/api';
|
||||
import CreateStep from './CreateStep';
|
||||
import CreateStep, { patientShareOf } from './CreateStep';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
@@ -81,3 +81,34 @@ describe('CreateStep — الزامی بودن قیمت ویزیت با فلگ r
|
||||
await waitFor(() => expect(screen.getByLabelText('قیمت ویزیت')).toHaveValue('30000'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('patientShareOf — آینهی BillingCalculator', () => {
|
||||
it('سناریوی مرجع: ۳۰٪ پوشش پایه روی ۵,۹۵۲,۰۰۰ ریال', () => {
|
||||
const share = patientShareOf(5_952_000, { covered: true, percent: 30, franchise: 0, ceiling: null }, null);
|
||||
expect(share).toBe(4_166_400);
|
||||
});
|
||||
|
||||
it('فرانشیز بیمهٔ پایه سهم بیمار را زیاد نمیکند', () => {
|
||||
const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50_000, ceiling: null }, null);
|
||||
expect(share).toBe(0);
|
||||
});
|
||||
|
||||
it('فرانشیز بیمهٔ تکمیلی به سهم بیمار اضافه میشود', () => {
|
||||
const share = patientShareOf(
|
||||
600_000,
|
||||
{ covered: true, percent: 70, franchise: 90_000, ceiling: null },
|
||||
{ covered: true, percent: 100, franchise: 50_000, ceiling: null },
|
||||
);
|
||||
expect(share).toBe(50_000);
|
||||
});
|
||||
|
||||
it('سقف تعهد سهم بیمه را محدود میکند', () => {
|
||||
const share = patientShareOf(600_000, { covered: true, percent: 70, franchise: 0, ceiling: 300_000 }, null);
|
||||
expect(share).toBe(300_000);
|
||||
});
|
||||
|
||||
it('notCovered → کل مبلغ سهم بیمار', () => {
|
||||
const share = patientShareOf(600_000, { covered: false, percent: 0, franchise: 0, ceiling: null }, null);
|
||||
expect(share).toBe(600_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,15 +12,21 @@ import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesS
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { digitsOnly } from '../../lib/utils';
|
||||
|
||||
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 }
|
||||
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 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 }
|
||||
|
||||
// آینهی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل.
|
||||
function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number {
|
||||
/** ویزیت خدمتِ سرپایی است. */
|
||||
const VISIT_SERVICE_CATEGORY = 'outpatient';
|
||||
|
||||
/**
|
||||
* آینهی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل.
|
||||
* فرانشیز فقط در بیمهٔ تکمیلی اثر دارد؛ بیمهٔ پایه صرفاً درصدی است.
|
||||
*/
|
||||
export function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number {
|
||||
let baseShare = 0;
|
||||
let remaining = total;
|
||||
if (base && base.covered) {
|
||||
@@ -34,8 +40,7 @@ function patientShareOf(total: number, base: Rule | null, supp: Rule | null): nu
|
||||
if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling);
|
||||
remaining = remaining - suppShare;
|
||||
}
|
||||
const franchise = (base?.franchise ?? 0) + (supp?.franchise ?? 0);
|
||||
return Math.min(remaining + franchise, total);
|
||||
return Math.min(remaining + (supp?.franchise ?? 0), total);
|
||||
}
|
||||
|
||||
const todayISO = () => new Date().toISOString().slice(0, 10);
|
||||
@@ -75,7 +80,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const [itemUuid, setItemUuid] = useState('');
|
||||
const [staffUuid, setStaffUuid] = useState('');
|
||||
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean }[]>([]);
|
||||
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean; category: string }[]>([]);
|
||||
const [consumableUuid, setConsumableUuid] = useState('');
|
||||
const [selectedConsumables, setSelectedConsumables] = useState<{ uuid: string; name: string; price: number; qty: number }[]>([]);
|
||||
const [packageUuid, setPackageUuid] = useState('');
|
||||
@@ -138,6 +143,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
if (editSession.insurance_supplementary_id) { setSuppId(String(editSession.insurance_supplementary_id)); setSuppPercent(String(editSession.supplementary_discount_percent ?? 0)); }
|
||||
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,
|
||||
})).filter((s) => s.uuid));
|
||||
setSelectedConsumables((editSession.consumables ?? []).map((c) => ({
|
||||
uuid: c.inventory_item_uuid ?? '', name: c.item_name ?? '', price: c.price_rials ?? 0, qty: c.quantity ?? 1,
|
||||
@@ -179,20 +185,31 @@ 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 ?? [];
|
||||
|
||||
// قاعدهی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه پیشفرض قرارداد.
|
||||
const ruleFor = (contract: Contract | null, coverage: CoverageRow[], serviceUuid: string): Rule | null => {
|
||||
/** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */
|
||||
const contractPercent = (contract: Contract, category: string): number =>
|
||||
Number(contract.category_coverages?.[category] ?? contract.coverage_percent ?? 0);
|
||||
|
||||
/**
|
||||
* قاعدهی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه درصد
|
||||
* همان نوع خدمت (سرپایی/بستری). فرانشیز فقط در قرارداد تکمیلی خوانده میشود.
|
||||
*/
|
||||
const ruleFor = (contract: Contract | null, coverage: CoverageRow[], serviceUuid: string, category: string): Rule | null => {
|
||||
if (!contract) return null;
|
||||
const ov = coverage.find(r => r.service_item_uuid === serviceUuid);
|
||||
if (ov && !ov.covered) return { covered: false, percent: 0, franchise: 0, ceiling: null };
|
||||
const isSupplementary = contract.insurance_kind === 'supplementary';
|
||||
return {
|
||||
covered: true,
|
||||
percent: ov?.coverage_percent ?? contract.coverage_percent,
|
||||
franchise: ov?.franchise_rials ?? contract.franchise_rials,
|
||||
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
|
||||
percent: ov?.coverage_percent ?? contractPercent(contract, category),
|
||||
franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0,
|
||||
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
|
||||
};
|
||||
};
|
||||
|
||||
const coverageOf = (id: string): number => contracts.find(c => String(c.insurance_id) === id)?.coverage_percent ?? 0;
|
||||
const coverageOf = (id: string): number => {
|
||||
const contract = contracts.find(c => String(c.insurance_id) === id);
|
||||
return contract ? contractPercent(contract, VISIT_SERVICE_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'); };
|
||||
|
||||
@@ -204,7 +221,11 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
// ── خدمات ────────────────────────────────────────────────────────────────
|
||||
const addService = () => {
|
||||
if (!currentItem || selectedServices.some(s => s.uuid === currentItem.uuid)) return;
|
||||
setSelectedServices(p => [...p, { uuid: currentItem.uuid, name: currentItem.name, price: currentItem.price_rials, qty: 1, insured: !!currentItem.insurance_covered }]);
|
||||
setSelectedServices(p => [...p, {
|
||||
uuid: currentItem.uuid, name: currentItem.name, price: currentItem.price_rials, qty: 1,
|
||||
insured: !!currentItem.insurance_covered,
|
||||
category: currentItem.service_category ?? VISIT_SERVICE_CATEGORY,
|
||||
}]);
|
||||
setItemUuid('');
|
||||
};
|
||||
const setServiceQty = (uuid: string, qty: number) => {
|
||||
@@ -237,9 +258,15 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e
|
||||
() => selectedServices.reduce((sum, x) => {
|
||||
const total = x.price * x.qty;
|
||||
if (!x.insured) return sum + total;
|
||||
return sum + patientShareOf(total, ruleFor(baseContract, baseCoverage, x.uuid), ruleFor(suppContract, suppCoverage, x.uuid));
|
||||
// نوع خدمت از کاتالوگ خوانده میشود تا ردیفهای پیشپرشدهٔ ویرایش هم درست باشند.
|
||||
const category = serviceItems.find(i => i.uuid === x.uuid)?.service_category ?? x.category;
|
||||
return sum + patientShareOf(
|
||||
total,
|
||||
ruleFor(baseContract, baseCoverage, x.uuid, category),
|
||||
ruleFor(suppContract, suppCoverage, x.uuid, category),
|
||||
);
|
||||
}, 0),
|
||||
[selectedServices, baseContract, suppContract, baseCoverage, suppCoverage], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[selectedServices, baseContract, suppContract, baseCoverage, suppCoverage, serviceItems], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
const consumablesTotal = useMemo(() => selectedConsumables.reduce((s, c) => s + c.price * c.qty, 0), [selectedConsumables]);
|
||||
const afterBase = Math.round(visit * (1 - base / 100));
|
||||
|
||||
Reference in New Issue
Block a user