baodan/baodanppt/public/js/screens/result-summary.js
2026-07-23 08:52:59 +08:00

271 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =========================================================================
保单摘要长图生成器 - 关键指标 + 完整数据表
========================================================================= */
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 '<div style="padding:40px;text-align:center;color:#999;">暂无提取数据</div>';
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 ? `<div style="background:rgba(255,255,255,.18);border-radius:10px;padding:6px 10px;text-align:center;"><div style="font-size:9px;opacity:.7;">回本</div><div style="font-size:16px;font-weight:700;">第${milestones.payback}年</div></div>` : '',
milestones.double ? `<div style="background:rgba(255,255,255,.18);border-radius:10px;padding:6px 10px;text-align:center;"><div style="font-size:9px;opacity:.7;">翻倍</div><div style="font-size:16px;font-weight:700;">第${milestones.double}年</div></div>` : '',
milestones.triple ? `<div style="background:rgba(255,255,255,.18);border-radius:10px;padding:6px 10px;text-align:center;"><div style="font-size:9px;opacity:.7;">三倍</div><div style="font-size:16px;font-weight:700;">第${milestones.triple}年</div></div>` : '',
].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 `<tr style="${bg}">
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:center;color:#666;">${y}</td>
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:center;color:#666;">${ins.age ? Number(ins.age) + y - 1 : '—'}</td>
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:right;">${fmtNum(prem)}</td>
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:right;">${fmtNum(guar)}</td>
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:right;color:#2563eb;">${fmtNum(nonGuar > 0 ? nonGuar : 0)}</td>
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:right;font-weight:600;">${fmtNum(total)}</td>
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:center;font-weight:600;">${(mult > 0 && isFinite(mult)) ? mult.toFixed(2) + 'x' : '—'}</td>
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:center;color:#999;">${(irr && isFinite(irr)) ? (irr * 100).toFixed(2) + '%' : '—'}</td>
</tr>`;
}).join('');
allHtml += `
<div style="background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08);margin-bottom:24px;">
<div style="background:${heroUrl ? `linear-gradient(rgba(0,0,0,.55),rgba(0,0,0,.65)),url(${heroUrl})` : 'linear-gradient(135deg,#1a2a4a,#2d4a6a)'};background-size:cover;background-position:center;color:#fff;padding:20px 16px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px;">
<div>
<div style="font-size:9px;opacity:.6;letter-spacing:.1em;margin-bottom:3px;">${typeLabel.toUpperCase()} · 保单摘要 · ${intervalLabel}</div>
<div style="font-size:18px;font-weight:700;line-height:1.3;">${productName}</div>
</div>
<div style="text-align:right;flex-shrink:0;">
<div style="font-size:9px;opacity:.6;">受保人</div>
<div style="font-size:14px;font-weight:600;">${ins.name || '—'}${ins.age ? `${ins.age}岁)` : ''}</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:12px;">
<div style="background:rgba(255,255,255,.12);border-radius:8px;padding:8px 6px;text-align:center;">
<div style="font-size:9px;opacity:.7;margin-bottom:2px;">年缴保费</div>
<div style="font-size:14px;font-weight:700;">${currency} ${(pol.annual_premium || 0).toLocaleString()}</div>
</div>
<div style="background:rgba(255,255,255,.12);border-radius:8px;padding:8px 6px;text-align:center;">
<div style="font-size:9px;opacity:.7;margin-bottom:2px;">缴费年期</div>
<div style="font-size:14px;font-weight:700;">${pol.premium_payment_period || '—'}</div>
</div>
<div style="background:rgba(255,255,255,.12);border-radius:8px;padding:8px 6px;text-align:center;">
<div style="font-size:9px;opacity:.7;margin-bottom:2px;">总缴保费</div>
<div style="font-size:14px;font-weight:700;">${currency} ${paidTotal.toLocaleString()}</div>
</div>
</div>
${msItems ? `<div style="display:grid;grid-template-columns:repeat(${Math.min(msItems.split('</div>').length - 1, 3)},1fr);gap:6px;">${msItems}</div>` : ''}
</div>
<div style="padding:12px 8px;">
<div style="font-size:12px;font-weight:600;color:#1a1a2e;margin-bottom:8px;">📋 利益演示(${intervalLabel} · 共 ${displayYears.length} 行)</div>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-family:monospace,'Courier New',sans-serif;">
<thead>
<tr style="background:#f0f2f5;">
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:center;border-bottom:2px solid #ddd;">年度</th>
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:center;border-bottom:2px solid #ddd;">年龄</th>
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:right;border-bottom:2px solid #ddd;">已缴保费</th>
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:right;border-bottom:2px solid #ddd;">保证现价</th>
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:right;border-bottom:2px solid #ddd;">非保证</th>
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:right;border-bottom:2px solid #ddd;">总退保价值</th>
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:center;border-bottom:2px solid #ddd;">倍数</th>
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:center;border-bottom:2px solid #ddd;">IRR</th>
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
</div>
</div>
<div style="padding:8px 12px;border-top:1px solid #eee;display:flex;justify-content:space-between;font-size:8px;color:#999;">
<span>由 AI Insurance 生成</span>
<span>${new Date().toLocaleDateString('zh-CN')}</span>
</div>
</div>`;
});
return `<div style="padding:16px 10px;background:#f0f2f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">${allHtml}</div>`;
}
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 = [
'<div style="background:#fff;border-radius:20px;padding:28px 24px;width:320px;box-shadow:0 20px 60px rgba(0,0,0,.25);text-align:center;">',
' <div style="font-size:18px;font-weight:700;color:#1a1a2e;margin-bottom:4px;">📸 导出摘要图</div>',
' <div style="font-size:12px;color:#94a3b8;margin-bottom:20px;">选择数据显示间隔</div>',
' <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:20px;">',
' <button class="interval-opt" data-interval="1" style="padding:14px;border:2px solid #e2e8f0;border-radius:12px;background:#fff;font-size:15px;font-weight:600;color:#0f172a;cursor:pointer;width:100%;">📋 每年显示<span style="font-weight:400;font-size:12px;color:#94a3b8;display:block;margin-top:2px;">完整展示所有年份</span></button>',
' <button class="interval-opt" data-interval="5" style="padding:14px;border:2px solid #2563eb;border-radius:12px;background:#eff6ff;font-size:15px;font-weight:600;color:#1e40af;cursor:pointer;width:100%;">📊 每5年显示<span style="font-weight:400;font-size:12px;color:#64748b;display:block;margin-top:2px;">推荐,兼顾完整与简洁</span></button>',
' <button class="interval-opt" data-interval="10" style="padding:14px;border:2px solid #e2e8f0;border-radius:12px;background:#fff;font-size:15px;font-weight:600;color:#0f172a;cursor:pointer;width:100%;">📈 每10年显示<span style="font-weight:400;font-size:12px;color:#94a3b8;display:block;margin-top:2px;">最简洁,突出趋势</span></button>',
' </div>',
' <button id="intervalCancelBtn" style="padding:8px 20px;border:none;border-radius:8px;background:#f1f5f9;font-size:13px;color:#64748b;cursor:pointer;">取消</button>',
'</div>',
].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 || '未知错误'));
}
}