/* ========================================================================= 保单摘要长图生成器 - 关键指标 + 完整数据表 ========================================================================= */ import { state } from '../state.js'; function fmtNum(n) { if (n === null || n === undefined || isNaN(n)) return '—'; if (typeof n === 'number') return n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); return n; } function calcMilestones(bi, paidTotal) { let payback = null, double = null, triple = null; const sorted = [...bi].sort((a, b) => a.policy_year - b.policy_year); for (const r of sorted) { const y = r.policy_year; const total = r.total_surrender_value || 0; if (total <= 0) continue; const mult = paidTotal > 0 ? total / paidTotal : 0; if (payback === null && total >= paidTotal) payback = y; if (double === null && mult >= 2.0) double = y; if (triple === null && mult >= 3.0) triple = y; } return { payback, double, triple }; } function getCompanyId(planType) { if (planType === 'ci') return state.ciCompany; if (planType === 'iul') return state.iulCompany; return state.savingsCompany; } // === M-A NPV IRR (与 TypeScript server.ts / Python savings_normalizer 完全一致) === // 现金流: -P at t=0..n-1, +SV at t=year. 求解 NPV=0, 封顶 HK IA (港元 6.0% / 非港元 6.5%). function _maNpv(r, cf) { let s = 0; for (const [t, a] of cf) s += a / Math.pow(1 + r, t); return s; } function _maIrrBisect(cf) { let lo = -0.99, hi = 1.0; let fLo = _maNpv(lo, cf), fHi = _maNpv(hi, cf); if (fLo * fHi > 0) return null; for (let i = 0; i < 200; i++) { const mid = (lo + hi) / 2; const fMid = _maNpv(mid, cf); if (Math.abs(fMid) < 1e-6 || (hi - lo) < 1e-10) return mid; if (fLo * fMid < 0) { hi = mid; fHi = fMid; } else { lo = mid; fLo = fMid; } } return (lo + hi) / 2; } function _iaIrrCap(currency) { const c = String(currency || 'USD').toUpperCase().trim(); return (c === 'HKD' || c === '港币' || c === '港元') ? 0.06 : 0.065; } function computeIrrMA(annualPrem, payYrs, sv, yr, currency) { if (yr <= 0 || annualPrem <= 0 || sv <= 0 || payYrs < 1) return null; const cf = [[0, -annualPrem]]; for (let i = 1; i < payYrs; i++) cf.push([i, -annualPrem]); cf.push([yr, sv]); const r = _maIrrBisect(cf); if (r === null) return null; return Math.min(r, _iaIrrCap(currency)); } function buildFullSummaryHTML(interval = 5) { const extractions = state.extractions || []; if (!extractions.length) return '
暂无提取数据
'; let allHtml = ''; extractions.forEach((extraction, idx) => { const data = extraction.data || {}; const ins = data.insured || {}; const pol = data.policy || {}; const bi = (data.benefit_illustration || []).filter(r => r.total_surrender_value > 0); const payPeriod = Math.max(parseInt(String(pol.premium_payment_period || '5').replace('年','')) || 5, 5); const paidTotal = (pol.annual_premium || 0) * payPeriod; const milestones = calcMilestones(bi, paidTotal); const currency = pol.currency || 'USD'; const productName = data.product_name || pol.product_name || '—'; const planType = extraction.planType || 'savings'; const typeLabel = { savings: '储蓄险', ci: '重疾险', iul: 'IUL' }[planType] || '保险'; const companyId = getCompanyId(planType); const heroUrl = companyId ? `/assets/library/companies/${companyId}/company-hero-01.png` : ''; // 按间隔过滤 let displayYears = bi .filter(r => r.policy_year === 1 || r.policy_year % interval === 0) .sort((a, b) => a.policy_year - b.policy_year); const lastYear = bi.length ? bi[bi.length - 1].policy_year : 0; if (lastYear > 0 && !displayYears.find(d => d.policy_year === lastYear)) { const last = bi.find(r => r.policy_year === lastYear); if (last) displayYears.push(last); } const msItems = [ milestones.payback ? `
回本
第${milestones.payback}年
` : '', milestones.double ? `
翻倍
第${milestones.double}年
` : '', milestones.triple ? `
三倍
第${milestones.triple}年
` : '', ].filter(Boolean).join(''); const intervalLabel = { 1: '每年', 5: '每5年', 10: '每10年' }[interval] || `每${interval}年`; const rowFontSize = displayYears.length > 80 ? '9px' : '10px'; const tableRows = displayYears.map(r => { const y = r.policy_year; const prem = r.total_premium_paid || 0; const total = r.total_surrender_value || 0; const guar = r.guaranteed_cash_value || 0; const nonGuar = total - guar; const mult = paidTotal > 0 ? (total / paidTotal) : 0; // M-A NPV IRR (与 PPT/服务端一致) + HK IA 封顶 const irr = (total > 0 && prem > 0 && y > 0) ? computeIrrMA(pol.annual_premium || 0, payPeriod, total, y, currency) : null; const bg = y % 2 === 0 ? 'background:#f8f9fb;' : ''; return ` ${y} ${ins.age ? Number(ins.age) + y - 1 : '—'} ${fmtNum(prem)} ${fmtNum(guar)} ${fmtNum(nonGuar > 0 ? nonGuar : 0)} ${fmtNum(total)} ${(mult > 0 && isFinite(mult)) ? mult.toFixed(2) + 'x' : '—'} ${(irr && isFinite(irr)) ? (irr * 100).toFixed(2) + '%' : '—'} `; }).join(''); allHtml += `
${typeLabel.toUpperCase()} · 保单摘要 · ${intervalLabel}
${productName}
受保人
${ins.name || '—'}${ins.age ? `(${ins.age}岁)` : ''}
年缴保费
${currency} ${(pol.annual_premium || 0).toLocaleString()}
缴费年期
${pol.premium_payment_period || '—'}
总缴保费
${currency} ${paidTotal.toLocaleString()}
${msItems ? `
${msItems}
` : ''}
📋 利益演示(${intervalLabel} · 共 ${displayYears.length} 行)
${tableRows}
年度 年龄 已缴保费 保证现价 非保证 总退保价值 倍数 IRR
由 AI Insurance 生成 ${new Date().toLocaleDateString('zh-CN')}
`; }); return `
${allHtml}
`; } export function renderSummaryTo(containerId) { const container = document.getElementById(containerId); if (!container) return; container.innerHTML = buildFullSummaryHTML(); } /** 弹出间隔选择器 */ export function showIntervalDialog() { const existing = document.getElementById('summaryIntervalOverlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'summaryIntervalOverlay'; overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;'; overlay.innerHTML = [ '
', '
📸 导出摘要图
', '
选择数据显示间隔
', '
', ' ', ' ', ' ', '
', ' ', '
', ].join(''); document.body.appendChild(overlay); overlay.querySelectorAll('.interval-opt').forEach(btn => { btn.onclick = async () => { const interval = parseInt(btn.dataset.interval); overlay.remove(); await exportSummaryAsImage(interval); }; }); document.getElementById('intervalCancelBtn').onclick = () => overlay.remove(); overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; } async function exportSummaryAsImage(interval = 5) { const container = document.getElementById('summaryExportArea'); if (!container) { console.error('summaryExportArea not found'); return; } container.innerHTML = buildFullSummaryHTML(interval); await new Promise(r => setTimeout(r, 400)); try { container.style.display = 'block'; container.style.position = 'fixed'; container.style.left = '-9999px'; container.style.top = '0'; container.style.zIndex = '-1'; container.style.width = '460px'; await new Promise(r => setTimeout(r, 400)); const rowCount = container.querySelectorAll('tbody tr').length; const scale = rowCount > 100 ? 1.2 : rowCount > 60 ? 1.5 : 2.0; const canvas = await html2canvas(container, { scale, useCORS: true, backgroundColor: '#f0f2f5', logging: false, width: 460, height: container.scrollHeight, }); container.style.display = 'none'; container.style.position = ''; container.style.left = ''; container.style.top = ''; container.style.zIndex = ''; container.style.width = ''; const link = document.createElement('a'); const suffix = interval === 1 ? '每年' : interval === 5 ? '每5年' : '每10年'; link.download = `保单摘要_${suffix}.png`; link.href = canvas.toDataURL('image/png'); link.click(); } catch (err) { console.error('导出摘要图失败:', err); container.style.display = 'none'; alert('导出失败: ' + (err.message || '未知错误')); } }