/** * آینهٔ سمت‌کلاینتِ `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; } /** ویزیت آیتم سرویس نیست؛ نوعِ پیش‌فرضش سرپایی است. */ 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((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 }); }