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:
hamed
2026-07-25 16:21:19 +03:30
co-authored by Claude Opus 5
parent 1a9eda3576
commit 58c6d9ac18
41 changed files with 2558 additions and 143 deletions
@@ -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);
});
});
+42 -15
View File
@@ -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));