/* =========================================================================
保单摘要长图生成器 - 关键指标 + 完整数据表
========================================================================= */
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.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} 行)
| 年度 |
年龄 |
已缴保费 |
保证现价 |
非保证 |
总退保价值 |
倍数 |
IRR |
${tableRows}
由 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 || '未知错误'));
}
}