Files
clinicpro/assets/admin/lib/insuranceShares.ts
T
hamed e6267080b2 feat: Enhance insurance billing system to support supplementary insurance
- Updated CoverageRule and related entities to include franchise_percent instead of franchise_rials.
- Modified Appointment entity to carry supplementary insurance ID alongside base insurance.
- Implemented SessionBillingService to ensure finalized invoices for insured patient sessions.
- Created InvoiceFinalized event to trigger claims creation upon invoice finalization.
- Added BackfillMissingClaimsCommand to generate claims for finalized invoices without existing claims.
- Developed tests to validate the new functionality for supplementary insurance handling in appointments and claims.
2026-07-29 19:57:02 +03:30

121 lines
5.0 KiB
TypeScript

/**
* آینهٔ سمت‌کلاینتِ `BillingCalculator` سرور. تنها منبع محاسبهٔ سهم‌ها در پنل است —
* هیچ صفحه‌ای نباید فرمول درصدی جداگانه بنویسد، وگرنه مبلغِ نمایش‌داده‌شده با مبلغِ
* ثبت‌شده واگرا می‌شود.
*/
export interface CoverageRule {
covered: boolean;
percent: number;
/** درصد سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در بیمهٔ تکمیلی معنا دارد. */
franchise: number;
ceiling: number | null;
}
/** قراردادِ بیمهٔ tenant، همان شکلی که `/api/v1/billing/tenant-insurances` می‌دهد. */
export interface TenantContract {
insurance_id: number;
insurance_name: string | null;
insurance_kind: string | null;
is_active?: boolean;
coverage_percent: number;
franchise_percent: number;
annual_ceiling_rials: number | null;
category_coverages?: Record<string, number>;
}
/** ویزیت آیتم سرویس نیست؛ نوعِ پیش‌فرضش سرپایی است. */
export const DEFAULT_SERVICE_CATEGORY = 'outpatient';
/** سهم بیمهٔ پایه از کل مبلغ یک ردیف (با سقف تعهد). */
export function baseShareOf(total: number, base: CoverageRule | null): number {
if (!base || !base.covered) return 0;
const share = Math.round(total * (base.percent / 100));
return base.ceiling !== null ? Math.min(share, base.ceiling) : share;
}
/**
* سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی. محاسبه زنجیره‌ای است؛
* تکمیلی روی باقیماندهٔ بعد از پایه کار می‌کند، نه روی کل مبلغ. فرانشیزِ تکمیلی
* درصدی از همان باقیمانده است و از سهم تکمیلی کسر می‌شود، نه اینکه روی سهم بیمار
* سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند.
*/
export function patientShareOf(total: number, base: CoverageRule | null, supp: CoverageRule | null): number {
const baseShare = baseShareOf(total, base);
const remaining = total - baseShare;
let suppShare = 0;
if (supp && supp.covered) {
suppShare = Math.max(
0,
Math.round(remaining * (supp.percent / 100)) - Math.round(remaining * (supp.franchise / 100)),
);
if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling);
}
return total - baseShare - suppShare;
}
/** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */
export function contractPercentFor(contract: TenantContract, category: string): number {
return Number(contract.category_coverages?.[category] ?? contract.coverage_percent ?? 0);
}
/** قاعدهٔ پوشش یک ردیف تحت یک قرارداد. فرانشیز فقط از قرارداد تکمیلی خوانده می‌شود. */
export function ruleOf(contract: TenantContract | null, category: string): CoverageRule | null {
if (!contract) return null;
return {
covered: true,
percent: contractPercentFor(contract, category),
franchise: contract.insurance_kind === 'supplementary' ? contract.franchise_percent : 0,
ceiling: contract.annual_ceiling_rials,
};
}
export interface BillableLine {
/** مبلغ کل ردیف (با احتساب تعداد). */
total: number;
/** نوع خدمت؛ برای ویزیت نوعِ انتخاب‌شدهٔ مراجعه. */
category: string;
/** خدمتی که پوشش بیمه ندارد کامل سهم بیمار است. */
insured: boolean;
}
export interface ShareBreakdown {
total: number;
/** مجموع سهم هر دو بیمه — برای نمایشِ یک‌خطی. */
insurance: number;
base: number;
supplementary: number;
patient: number;
}
/**
* تفکیک سهم برای مجموعه‌ای از ردیف‌ها تحت قرارداد پایه و (اختیاری) تکمیلی.
* محاسبه زنجیره‌ای است: پایه روی کل، تکمیلی روی باقیماندهٔ بعد از پایه.
*/
export function breakdownOf(
lines: BillableLine[],
base: TenantContract | null,
supplementary: TenantContract | null = null,
): ShareBreakdown {
return lines.reduce<ShareBreakdown>((acc, line) => {
const baseRule = line.insured ? ruleOf(base, line.category) : null;
const suppRule = line.insured ? ruleOf(supplementary, line.category) : null;
const patient = line.insured ? patientShareOf(line.total, baseRule, suppRule) : line.total;
const baseShare = baseShareOf(line.total, baseRule);
return {
total: acc.total + line.total,
base: acc.base + baseShare,
supplementary: acc.supplementary + (line.total - patient - baseShare),
insurance: acc.insurance + (line.total - patient),
patient: acc.patient + patient,
};
}, { total: 0, base: 0, supplementary: 0, insurance: 0, patient: 0 });
}