OCR 表格模式改为更适合计划书的 PSM 4。 分别支持斜杠年度、独立年度/年龄列、纵向压缩表格、保证/非保证双栏。 防止错误的坐标解析结果覆盖正确 OCR 数据。 身故利益表与退保价值表按保单年度合并。 修复吸烟状态、保额、年缴/单缴金额和缴费年期。 补齐 SIUL3、SBIUL2、GIUL3、FWD IF、AIA PIL2 产品配置。 多份计划书现在保留全部保司,不再只取第一家公司。 用户端保司选项、生成快照和渲染过程统一使用后台脱敏名
1914 lines
78 KiB
Python
1914 lines
78 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
fast_pptx_renderer.py — PPTX 渲染器
|
||
接收 DeckContract JSON,按 requiredPageTypes 生成专业 PPTX
|
||
|
||
使用方法:
|
||
python fast_pptx_renderer.py --deck-json <path> --output <path> --theme <theme>
|
||
|
||
主题:
|
||
deepblue - 深海蓝(默认)
|
||
caramel - 焦糖色
|
||
chinese - 中国红
|
||
business - 商务蓝
|
||
minimal - 极简灰
|
||
ink - 水墨黑
|
||
|
||
输出:
|
||
JSON: { "ok": true, "path": "...", "size": 12345, "slides": 10 }
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from pptx import Presentation
|
||
from pptx.chart.data import CategoryChartData
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
|
||
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_SHAPE_TYPE
|
||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||
from pptx.util import Inches, Pt
|
||
|
||
|
||
# ─── 主题配色 ────────────────────────────────────────────────
|
||
|
||
THEMES = {
|
||
"deepblue": {
|
||
"bg": RGBColor(10, 28, 50),
|
||
"panel": RGBColor(255, 255, 255),
|
||
"primary": RGBColor(255, 255, 255),
|
||
"text_dark": RGBColor(30, 30, 40),
|
||
"accent": RGBColor(24, 137, 141),
|
||
"accent_light": RGBColor(200, 230, 230),
|
||
"gold": RGBColor(201, 160, 39),
|
||
"muted": RGBColor(150, 160, 175),
|
||
"line": RGBColor(220, 225, 235),
|
||
"good": RGBColor(24, 168, 100),
|
||
"good_light": RGBColor(180, 230, 200),
|
||
"alert": RGBColor(200, 60, 60),
|
||
},
|
||
"caramel": {
|
||
"bg": RGBColor(248, 245, 239),
|
||
"panel": RGBColor(255, 255, 255),
|
||
"primary": RGBColor(38, 38, 38),
|
||
"text_dark": RGBColor(38, 38, 38),
|
||
"accent": RGBColor(206, 138, 44),
|
||
"accent_light": RGBColor(240, 230, 208),
|
||
"gold": RGBColor(206, 138, 44),
|
||
"muted": RGBColor(96, 96, 96),
|
||
"line": RGBColor(223, 214, 195),
|
||
"good": RGBColor(24, 108, 79),
|
||
"good_light": RGBColor(165, 204, 189),
|
||
"alert": RGBColor(173, 68, 52),
|
||
},
|
||
"chinese": {
|
||
"bg": RGBColor(180, 30, 30),
|
||
"panel": RGBColor(255, 255, 255),
|
||
"primary": RGBColor(255, 255, 255),
|
||
"text_dark": RGBColor(40, 40, 40),
|
||
"accent": RGBColor(220, 180, 60),
|
||
"accent_light": RGBColor(255, 240, 200),
|
||
"gold": RGBColor(220, 180, 60),
|
||
"muted": RGBColor(180, 160, 160),
|
||
"line": RGBColor(200, 180, 180),
|
||
"good": RGBColor(100, 180, 100),
|
||
"good_light": RGBColor(200, 230, 200),
|
||
"alert": RGBColor(255, 200, 100),
|
||
},
|
||
"business": {
|
||
"bg": RGBColor(23, 32, 51),
|
||
"panel": RGBColor(255, 255, 255),
|
||
"primary": RGBColor(255, 255, 255),
|
||
"text_dark": RGBColor(23, 32, 51),
|
||
"accent": RGBColor(37, 99, 235),
|
||
"accent_light": RGBColor(219, 234, 254),
|
||
"gold": RGBColor(201, 160, 39),
|
||
"muted": RGBColor(148, 163, 184),
|
||
"line": RGBColor(203, 213, 225),
|
||
"good": RGBColor(5, 150, 105),
|
||
"good_light": RGBColor(209, 250, 229),
|
||
"alert": RGBColor(220, 38, 38),
|
||
},
|
||
"minimal": {
|
||
"bg": RGBColor(248, 250, 252),
|
||
"panel": RGBColor(255, 255, 255),
|
||
"primary": RGBColor(15, 23, 42),
|
||
"text_dark": RGBColor(15, 23, 42),
|
||
"accent": RGBColor(71, 85, 105),
|
||
"accent_light": RGBColor(226, 232, 240),
|
||
"gold": RGBColor(161, 98, 7),
|
||
"muted": RGBColor(100, 116, 139),
|
||
"line": RGBColor(226, 232, 240),
|
||
"good": RGBColor(22, 101, 52),
|
||
"good_light": RGBColor(220, 252, 231),
|
||
"alert": RGBColor(185, 28, 28),
|
||
},
|
||
"ink": {
|
||
"bg": RGBColor(39, 39, 42),
|
||
"panel": RGBColor(250, 250, 249),
|
||
"primary": RGBColor(250, 250, 249),
|
||
"text_dark": RGBColor(39, 39, 42),
|
||
"accent": RGBColor(87, 83, 78),
|
||
"accent_light": RGBColor(231, 229, 228),
|
||
"gold": RGBColor(180, 140, 70),
|
||
"muted": RGBColor(168, 162, 158),
|
||
"line": RGBColor(214, 211, 209),
|
||
"good": RGBColor(63, 98, 18),
|
||
"good_light": RGBColor(236, 252, 203),
|
||
"alert": RGBColor(153, 27, 27),
|
||
},
|
||
"sage": {
|
||
"bg": RGBColor(248, 246, 239),
|
||
"panel": RGBColor(255, 255, 255),
|
||
"primary": RGBColor(31, 82, 67),
|
||
"text_dark": RGBColor(34, 63, 55),
|
||
"accent": RGBColor(47, 112, 91),
|
||
"accent_light": RGBColor(224, 239, 232),
|
||
"gold": RGBColor(204, 151, 57),
|
||
"muted": RGBColor(92, 112, 104),
|
||
"line": RGBColor(218, 224, 215),
|
||
"good": RGBColor(47, 112, 91),
|
||
"good_light": RGBColor(211, 233, 221),
|
||
"alert": RGBColor(173, 68, 52),
|
||
},
|
||
}
|
||
|
||
FONT_CN = "Microsoft YaHei"
|
||
|
||
|
||
# ─── 计算工具(移植自 baodanppt) ───────────────────────────
|
||
|
||
def money(value: float | int) -> str:
|
||
return f"{round(float(value)):,}"
|
||
|
||
|
||
def pct(value: float) -> str:
|
||
return f"{value:.2f}%"
|
||
|
||
|
||
def paid_premium_for_year(year: int, annual_premium: float, pay_years: int) -> float:
|
||
return float(annual_premium) * min(int(year), int(pay_years))
|
||
|
||
|
||
def simple_return(value: float, premium: float) -> float:
|
||
if premium <= 0:
|
||
return 0.0
|
||
return (float(value) / float(premium) - 1.0) * 100.0
|
||
|
||
|
||
def compound_return(value: float, premium: float, year: int) -> float:
|
||
if premium <= 0 or value <= 0 or year <= 0:
|
||
return 0.0
|
||
return ((float(value) / float(premium)) ** (1.0 / float(year)) - 1.0) * 100.0
|
||
|
||
|
||
def find_payback_year(benefit_rows: list, annual_premium: float, pay_years: int):
|
||
for row in benefit_rows:
|
||
yr = int(row.get("policyYear", 0))
|
||
sv = float(row.get("totalSurrenderValue", 0))
|
||
if sv >= paid_premium_for_year(yr, annual_premium, pay_years) and yr > 1:
|
||
return yr
|
||
return None
|
||
|
||
|
||
def decade_rows(benefit_rows, annual_premium, pay_years, withdraw_rows=None, max_year=90):
|
||
"""每 10 年数据行,支持不提领/提领两种口径。"""
|
||
targets = [y for y in [1, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] if y <= max_year]
|
||
by_year = {}
|
||
for r in benefit_rows:
|
||
by_year[int(r.get("policyYear", 0))] = r
|
||
w_by_year = {}
|
||
if withdraw_rows:
|
||
for r in withdraw_rows:
|
||
w_by_year[int(r.get("policyYear", 0))] = r
|
||
|
||
rows = []
|
||
for yr in targets:
|
||
base = by_year.get(yr)
|
||
wr = w_by_year.get(yr)
|
||
if not base and not wr:
|
||
continue
|
||
ref = wr or base
|
||
age = int(ref.get("age", 0))
|
||
paid = paid_premium_for_year(yr, annual_premium, pay_years)
|
||
if wr:
|
||
annual_w = float(wr.get("annualWithdrawal", 0))
|
||
cum_w = float(wr.get("cumulativeWithdrawal", 0))
|
||
sv_after = float(wr.get("surrenderValueAfter", 0))
|
||
econ = cum_w + sv_after
|
||
else:
|
||
annual_w = 0.0
|
||
cum_w = 0.0
|
||
sv_after = float(base.get("totalSurrenderValue", 0))
|
||
econ = sv_after
|
||
rows.append({
|
||
"age": age, "year": yr, "paid": paid,
|
||
"annualWithdrawal": annual_w, "cumulativeWithdrawal": cum_w,
|
||
"surrenderValue": sv_after, "economicTotal": econ,
|
||
"simpleRate": simple_return(econ, paid),
|
||
"compoundRate": compound_return(econ, paid, yr),
|
||
})
|
||
return rows
|
||
|
||
|
||
# ─── 基础绘图函数 ────────────────────────────────────────────
|
||
|
||
def add_bg(slide, colors):
|
||
fill = slide.background.fill
|
||
fill.solid()
|
||
fill.fore_color.rgb = colors["bg"]
|
||
|
||
|
||
def add_blank_slide(prs):
|
||
"""兼容只有少量自定义版式的源 PPTX。"""
|
||
template_queue = getattr(prs, "_insurance_template_slide_queue", None)
|
||
if template_queue:
|
||
return template_queue.pop(0)
|
||
layouts = list(prs.slide_layouts)
|
||
if not layouts:
|
||
raise ValueError("源模板没有可用幻灯片版式")
|
||
layout = next(
|
||
(
|
||
item for item in layouts
|
||
if str(getattr(item, "name", "")).lower() in {"blank", "空白"}
|
||
),
|
||
layouts[-1],
|
||
)
|
||
return prs.slides.add_slide(layout)
|
||
|
||
|
||
def add_textbox(slide, left, top, width, height, text, size=18, bold=False,
|
||
color=None, align=PP_ALIGN.LEFT, line_spacing=1.5, colors=None):
|
||
if color is None:
|
||
color = colors["primary"] if colors else RGBColor(255, 255, 255)
|
||
box = slide.shapes.add_textbox(left, top, width, height)
|
||
tf = box.text_frame
|
||
tf.clear()
|
||
tf.word_wrap = True
|
||
tf.vertical_anchor = MSO_ANCHOR.TOP
|
||
p = tf.paragraphs[0]
|
||
p.alignment = align
|
||
p.line_spacing = line_spacing
|
||
run = p.add_run()
|
||
run.text = str(text)
|
||
run.font.name = FONT_CN
|
||
run.font.size = Pt(size)
|
||
run.font.bold = bold
|
||
run.font.color.rgb = color
|
||
return box
|
||
|
||
|
||
def add_paragraphs(slide, left, top, width, height, lines, size=15, color=None,
|
||
bullet=True, line_spacing=1.5, colors=None):
|
||
if color is None:
|
||
color = colors["primary"] if colors else RGBColor(255, 255, 255)
|
||
box = slide.shapes.add_textbox(left, top, width, height)
|
||
tf = box.text_frame
|
||
tf.clear()
|
||
tf.word_wrap = True
|
||
tf.vertical_anchor = MSO_ANCHOR.TOP
|
||
for idx, line in enumerate(lines):
|
||
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
||
p.alignment = PP_ALIGN.LEFT
|
||
p.line_spacing = line_spacing
|
||
p.text = f"• {line}" if bullet else str(line)
|
||
if p.runs:
|
||
run = p.runs[0]
|
||
run.font.name = FONT_CN
|
||
run.font.size = Pt(size)
|
||
run.font.color.rgb = color
|
||
return box
|
||
|
||
|
||
def add_card(slide, left, top, width, height, title, value, subtitle="",
|
||
accent=False, colors=None, title_size=12, value_size=20, subtitle_size=10.5):
|
||
if colors is None:
|
||
colors = THEMES["deepblue"]
|
||
shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height)
|
||
shape.fill.solid()
|
||
shape.fill.fore_color.rgb = colors["accent_light"] if accent else colors["panel"]
|
||
shape.line.color.rgb = colors["line"]
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.12), width - Inches(0.28),
|
||
Inches(0.22), title, size=title_size, bold=True, color=colors["muted"],
|
||
line_spacing=1.2, colors=colors)
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.38), width - Inches(0.28),
|
||
Inches(0.4), value, size=value_size, bold=True,
|
||
color=colors["accent"] if accent else colors["text_dark"],
|
||
line_spacing=1.2, colors=colors)
|
||
if subtitle:
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.82), width - Inches(0.28),
|
||
Inches(0.22), subtitle, size=subtitle_size, color=colors["muted"],
|
||
line_spacing=1.2, colors=colors)
|
||
|
||
|
||
def add_fact_card(slide, left, top, width, height, title, body,
|
||
fill_color=None, colors=None, title_size=11.5, body_size=12.8):
|
||
if colors is None:
|
||
colors = THEMES["deepblue"]
|
||
if fill_color is None:
|
||
fill_color = colors["panel"]
|
||
shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height)
|
||
shape.fill.solid()
|
||
shape.fill.fore_color.rgb = fill_color
|
||
shape.line.color.rgb = colors["line"]
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.12), width - Inches(0.28),
|
||
Inches(0.2), title, size=title_size, bold=True, color=colors["muted"],
|
||
line_spacing=1.2, colors=colors)
|
||
add_textbox(slide, left + Inches(0.14), top + Inches(0.36), width - Inches(0.28),
|
||
height - Inches(0.48), body, size=body_size, color=colors["text_dark"],
|
||
line_spacing=1.42, colors=colors)
|
||
|
||
|
||
def add_footer(slide, text, colors=None):
|
||
if colors is None:
|
||
colors = THEMES["deepblue"]
|
||
add_textbox(slide, Inches(0.65), Inches(7.0), Inches(12.0), Inches(0.2),
|
||
text, size=10, color=colors["muted"], line_spacing=1.2, colors=colors)
|
||
|
||
|
||
def add_title(slide, title, subtitle=None, colors=None):
|
||
if colors is None:
|
||
colors = THEMES["deepblue"]
|
||
add_textbox(slide, Inches(0.6), Inches(0.35), Inches(8.8), Inches(0.45),
|
||
title, size=24, bold=True, line_spacing=1.2, colors=colors)
|
||
if subtitle:
|
||
add_textbox(slide, Inches(0.6), Inches(0.78), Inches(9.2), Inches(0.28),
|
||
subtitle, size=11, color=colors["muted"], line_spacing=1.2, colors=colors)
|
||
|
||
|
||
def add_line_chart(slide, left, top, width, height, categories, series_spec, colors):
|
||
chart_data = CategoryChartData()
|
||
chart_data.categories = [str(x) for x in categories]
|
||
for name, values in series_spec:
|
||
chart_data.add_series(name, values)
|
||
chart = slide.shapes.add_chart(
|
||
XL_CHART_TYPE.LINE_MARKERS, left, top, width, height, chart_data
|
||
).chart
|
||
chart.has_legend = True
|
||
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
|
||
chart.legend.font.size = Pt(10)
|
||
chart.value_axis.has_major_gridlines = True
|
||
chart.value_axis.tick_labels.font.size = Pt(10)
|
||
chart.category_axis.tick_labels.font.size = Pt(10)
|
||
chart.value_axis.tick_labels.number_format = '$#,##0'
|
||
palette = [colors["accent"], colors["good"], RGBColor(54, 94, 140), colors["alert"]]
|
||
for idx, series in enumerate(chart.series):
|
||
series.format.line.width = Pt(2.2)
|
||
series.format.line.color.rgb = palette[idx % len(palette)]
|
||
return chart
|
||
|
||
|
||
def add_timeline_marker(slide, left, title, lines, colors):
|
||
shape = slide.shapes.add_shape(
|
||
MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE,
|
||
left, Inches(2.75), Inches(2.1), Inches(2.05)
|
||
)
|
||
shape.fill.solid()
|
||
shape.fill.fore_color.rgb = colors["panel"]
|
||
shape.line.color.rgb = colors["line"]
|
||
add_textbox(slide, left + Inches(0.14), Inches(2.9), Inches(1.82), Inches(0.26),
|
||
title, size=13, bold=True, color=colors["accent"],
|
||
line_spacing=1.2, colors=colors)
|
||
add_paragraphs(slide, left + Inches(0.14), Inches(3.18), Inches(1.82), Inches(1.25),
|
||
lines, size=11, bullet=False, line_spacing=1.35, colors=colors)
|
||
|
||
|
||
# ─── 幻灯片构建函数(10 种 pageType) ───────────────────────
|
||
|
||
def _slide_meta(slides_config, idx, page_type):
|
||
"""从 slides_config 中获取第 idx 个 slide 的元数据。"""
|
||
if slides_config and idx < len(slides_config):
|
||
return slides_config[idx]
|
||
return {"pageType": page_type}
|
||
|
||
|
||
def _product_summary(product):
|
||
"""从产品数据中提取关键数字摘要。"""
|
||
policy = product.get("policy", {})
|
||
ap = policy.get("annualPremium", 0)
|
||
py = policy.get("payYears", 0)
|
||
total = ap * py
|
||
br = product.get("benefitRows", [])
|
||
payback = find_payback_year(br, ap, py) if br else None
|
||
final = br[-1].get("totalSurrenderValue", 0) if br else 0
|
||
mult = f"{final / total:.1f}x" if total > 0 and final > 0 else "-"
|
||
return {"annualPremium": ap, "payYears": py, "totalPremium": total,
|
||
"paybackYear": payback, "finalValue": final, "multiple": mult,
|
||
"benefitRows": br, "withdrawalRows": product.get("withdrawalRows", []),
|
||
"productName": product.get("productName", ""),
|
||
"companyName": (product.get("company") or {}).get("displayName", ""),
|
||
"insured": product.get("insured", {})}
|
||
|
||
|
||
def _selected_product(deck, meta):
|
||
products = deck.get("products", [])
|
||
if not products:
|
||
return {}
|
||
try:
|
||
index = int(meta.get("productIndex", 0))
|
||
except (TypeError, ValueError):
|
||
index = 0
|
||
return products[index] if 0 <= index < len(products) else products[0]
|
||
|
||
|
||
def _source_pages(rows):
|
||
pages = sorted({
|
||
int(row["sourcePage"])
|
||
for row in rows
|
||
if row.get("sourcePage")
|
||
})
|
||
return "、".join(str(page) for page in pages) if pages else "待正式计划书确认"
|
||
|
||
|
||
def _pending_money(value):
|
||
if value is None:
|
||
return "待确认"
|
||
return money(value)
|
||
|
||
|
||
def add_slide_cover(prs, deck, colors, meta):
|
||
"""封面页:客户名 + 产品名 + 公司名 + 核心指标卡片。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
customer = deck.get("customer", {})
|
||
products = deck.get("products", [])
|
||
company = deck.get("company", {})
|
||
product = products[0] if products else {}
|
||
s = _product_summary(product)
|
||
|
||
title_text = meta.get("title", "").replace("{{customerName}}", customer.get("name", "客户"))
|
||
if not title_text or "{{" in title_text:
|
||
title_text = f"{customer.get('name', '客户')} 专属方案"
|
||
|
||
add_textbox(slide, Inches(0.8), Inches(1.2), Inches(11.7), Inches(0.8),
|
||
title_text, size=38, bold=True, colors=colors)
|
||
|
||
subtitle = meta.get("subtitle", "").replace("{{productName}}", s["productName"])
|
||
if not subtitle or "{{" in subtitle:
|
||
subtitle = s["productName"] or "财富增值与传承方案"
|
||
add_textbox(slide, Inches(0.8), Inches(2.2), Inches(11.7), Inches(0.5),
|
||
subtitle, size=20, color=colors["muted"], colors=colors)
|
||
|
||
company_name = company.get("displayName", "")
|
||
if company_name:
|
||
add_textbox(slide, Inches(0.8), Inches(2.9), Inches(11.7), Inches(0.4),
|
||
company_name, size=14, color=colors["accent"], colors=colors)
|
||
|
||
metrics = [
|
||
("年缴保费", f"${money(s['annualPremium'])}"),
|
||
("缴费年期", f"{s['payYears']}年"),
|
||
("总投入", f"${money(s['totalPremium'])}"),
|
||
("期末倍数", s["multiple"]),
|
||
]
|
||
for i, (label, value) in enumerate(metrics):
|
||
x = Inches(0.8 + i * 3.0)
|
||
add_card(slide, x, Inches(4.8), Inches(2.5), Inches(1.2),
|
||
label, value, colors=colors, accent=(i == 3))
|
||
|
||
add_footer(slide, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
|
||
def add_slide_company(prs, deck, colors, meta):
|
||
"""公司介绍页:公司简介 + 事实卡片。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
company = deck.get("company", {})
|
||
title = meta.get("title", "").replace("{{companyName}}", company.get("displayName", ""))
|
||
if not title or "{{" in title:
|
||
title = f"{company.get('displayName', '合作保司')} 公司介绍"
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
intro = company.get("companyIntro", "")
|
||
if intro:
|
||
add_paragraphs(slide, Inches(0.85), Inches(1.45), Inches(6.0), Inches(2.5),
|
||
[intro], size=13, bullet=False, line_spacing=1.5, colors=colors)
|
||
|
||
highlights = company.get("companyHighlights", [])[:4]
|
||
positions = [
|
||
(Inches(0.8), Inches(4.2)), (Inches(3.5), Inches(4.2)),
|
||
(Inches(6.2), Inches(4.2)), (Inches(8.9), Inches(4.2)),
|
||
]
|
||
for idx, h in enumerate(highlights):
|
||
if idx >= 4:
|
||
break
|
||
left, top = positions[idx]
|
||
text = h.get("text", "") if isinstance(h, dict) else str(h)
|
||
source = h.get("sourceFile", "") if isinstance(h, dict) else ""
|
||
fill = colors["accent_light"] if idx == 0 else colors["panel"]
|
||
add_fact_card(slide, left, top, Inches(2.5), Inches(1.8),
|
||
source[:20] if source else f"亮点 {idx+1}", text,
|
||
fill_color=fill, colors=colors, body_size=11)
|
||
|
||
rating = company.get("rating", "")
|
||
if rating:
|
||
add_textbox(slide, Inches(7.5), Inches(1.45), Inches(5.0), Inches(0.4),
|
||
f"评级: {rating}", size=14, bold=True, color=colors["accent"], colors=colors)
|
||
|
||
add_footer(slide, "公司事实来自内部知识库权威口径。", colors=colors)
|
||
|
||
|
||
def add_slide_narrative(prs, deck, colors, meta):
|
||
"""叙事页:受保人画像 + 方案逻辑 + 产品定位。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
product = _selected_product(deck, meta)
|
||
s = _product_summary(product)
|
||
insured = s["insured"]
|
||
|
||
title = meta.get("title", "方案核心逻辑")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
age = insured.get("age", "?")
|
||
name = insured.get("name", "受保人")
|
||
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(2.5), Inches(1.6),
|
||
f"{name} · {age}岁", f"年缴 US${money(s['annualPremium'])}\n缴费 {s['payYears']} 年",
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
|
||
if s["paybackYear"]:
|
||
add_fact_card(slide, Inches(3.6), Inches(1.45), Inches(2.5), Inches(1.6),
|
||
"回本年份", f"约第 {s['paybackYear']} 年", colors=colors)
|
||
|
||
kind = product.get("kind", "savings")
|
||
if kind == "savings":
|
||
points = [
|
||
f"总保费约 US${money(s['totalPremium'])},长期退保价值可持续抬升。",
|
||
"它负责未来的确定节奏,用时间换复利。",
|
||
]
|
||
wr = s["withdrawalRows"]
|
||
if wr:
|
||
first_w = next((r for r in wr if float(r.get("annualWithdrawal", 0)) > 0), None)
|
||
if first_w:
|
||
points.insert(0, f"第 {int(first_w['policyYear'])} 年开始每年约 US${money(first_w['annualWithdrawal'])},可按阶段释放现金流。")
|
||
elif kind == "ci":
|
||
policy = product.get("policy", {})
|
||
points = [
|
||
f"基础保额 US${money(policy.get('sumInsured', 0))},先把家庭底线守住。",
|
||
"这张单的任务不是替代储蓄,而是保护储蓄不被提前动用。",
|
||
]
|
||
else:
|
||
policy = product.get("policy", {})
|
||
points = [
|
||
f"基础保额 US${money(policy.get('sumInsured', 0))},用有限保费建立更高身故保障。",
|
||
"它不是给短期取用,而是给家庭做长期传承和现金价值缓冲。",
|
||
]
|
||
add_paragraphs(slide, Inches(0.85), Inches(3.5), Inches(11.4), Inches(2.5),
|
||
points, size=14.5, bullet=True, colors=colors)
|
||
|
||
|
||
def add_slide_chart(prs, deck, colors, meta):
|
||
"""图表页:python-pptx 原生折线图 + 侧边解读。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
product = _selected_product(deck, meta)
|
||
s = _product_summary(product)
|
||
|
||
chart_type = meta.get("chartType", "growth")
|
||
title = meta.get("title", "价值增长曲线" if chart_type == "growth" else "保证/非保证构成")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
if chart_type == "stacked":
|
||
_draw_stacked_chart(slide, s, colors)
|
||
else:
|
||
_draw_growth_chart(slide, s, colors)
|
||
|
||
|
||
def _draw_growth_chart(slide, s, colors):
|
||
"""增长曲线图:退保价值 vs 保证价值 vs 已交保费。"""
|
||
br = s["benefitRows"]
|
||
if not br:
|
||
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
|
||
["暂无增长数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
years = [1, 5, 10, 15, 20, 25, 30, 40, 50]
|
||
by_year = {int(r.get("policyYear", 0)): r for r in br}
|
||
valid_years = [y for y in years if y in by_year]
|
||
if not valid_years:
|
||
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
|
||
["暂无增长数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
|
||
series = [
|
||
("总退保价值", [float(by_year[y].get("totalSurrenderValue", 0)) for y in valid_years]),
|
||
("保证现金价值", [float(by_year[y].get("guaranteedCashValue", 0)) for y in valid_years]),
|
||
("已交总保费", [paid_premium_for_year(y, s["annualPremium"], s["payYears"]) for y in valid_years]),
|
||
]
|
||
add_line_chart(slide, Inches(0.65), Inches(1.45), Inches(6.3), Inches(4.25),
|
||
valid_years, series, colors)
|
||
|
||
notes = []
|
||
if s["paybackYear"]:
|
||
notes.append(f"回本约第 {s['paybackYear']} 年")
|
||
y20 = by_year.get(20)
|
||
if y20:
|
||
notes.append(f"第20年退保价值 US${money(y20.get('totalSurrenderValue', 0))}")
|
||
y30 = by_year.get(30)
|
||
if y30:
|
||
notes.append(f"第30年退保价值 US${money(y30.get('totalSurrenderValue', 0))}")
|
||
notes.append(f"期末倍数约 {s['multiple']}")
|
||
|
||
add_fact_card(slide, Inches(7.35), Inches(1.6), Inches(5.0), Inches(3.5),
|
||
"图表解读", "\n".join(notes), colors=colors)
|
||
|
||
|
||
def _draw_stacked_chart(slide, s, colors):
|
||
"""保证/非保证构成图。"""
|
||
br = s["benefitRows"]
|
||
if not br:
|
||
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
|
||
["暂无构成数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
years = [1, 5, 10, 15, 20, 25, 30, 40, 50]
|
||
by_year = {int(r.get("policyYear", 0)): r for r in br}
|
||
valid_years = [y for y in years if y in by_year]
|
||
if not valid_years:
|
||
add_paragraphs(slide, Inches(2), Inches(3.5), Inches(8), Inches(1),
|
||
["暂无构成数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
|
||
series = [
|
||
("保证现金价值", [float(by_year[y].get("guaranteedCashValue", 0)) for y in valid_years]),
|
||
("归原红利", [float(by_year[y].get("reversionaryBonus", 0)) for y in valid_years]),
|
||
("终期分红", [float(by_year[y].get("terminalDividend", 0)) for y in valid_years]),
|
||
]
|
||
add_line_chart(slide, Inches(0.65), Inches(1.45), Inches(6.3), Inches(4.25),
|
||
valid_years, series, colors)
|
||
|
||
add_fact_card(slide, Inches(7.35), Inches(1.6), Inches(5.0), Inches(3.5),
|
||
"构成解读", "先看保证底盘,再看非保证弹性,明确长期收益主要来源。",
|
||
colors=colors)
|
||
|
||
|
||
def add_slide_timeline(prs, deck, colors, meta):
|
||
"""时间轴页:横向里程碑。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
product = _selected_product(deck, meta)
|
||
s = _product_summary(product)
|
||
|
||
title = meta.get("title", "家庭时间轴")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
# 画横线
|
||
line = slide.shapes.add_shape(
|
||
MSO_AUTO_SHAPE_TYPE.RECTANGLE,
|
||
Inches(1.0), Inches(2.35), Inches(10.8), Inches(0.05)
|
||
)
|
||
line.fill.solid()
|
||
line.fill.fore_color.rgb = colors["line"]
|
||
line.line.color.rgb = colors["line"]
|
||
|
||
wr = {int(r.get("policyYear", 0)): r for r in s["withdrawalRows"]}
|
||
insured_age = int(s["insured"].get("age", 1))
|
||
|
||
milestones = _build_milestones(s, wr, insured_age, meta)
|
||
positions = [Inches(0.95), Inches(3.2), Inches(5.45), Inches(7.7), Inches(9.95)]
|
||
for i, ms in enumerate(milestones[:5]):
|
||
left = positions[i] if i < len(positions) else Inches(0.95 + i * 2.3)
|
||
add_timeline_marker(slide, left, ms["title"], ms["lines"], colors)
|
||
|
||
add_footer(slide, "关键里程碑按受保人年龄和保单年度展开。", colors=colors)
|
||
|
||
|
||
def _build_milestones(s, wr, insured_age, meta):
|
||
"""构建时间轴里程碑。"""
|
||
ms_list = []
|
||
ms_list.append({"title": "现在", "lines": [f"年缴 US${money(s['annualPremium'])}", "保单生效"]})
|
||
|
||
# 第一个提领点
|
||
first_w = next((r for r in s["withdrawalRows"] if float(r.get("annualWithdrawal", 0)) > 0), None)
|
||
if first_w:
|
||
wyr = int(first_w["policyYear"])
|
||
wage = insured_age + wyr
|
||
ms_list.append({"title": f"第{wyr}年({wage}岁)",
|
||
"lines": [f"每年约 US${money(first_w['annualWithdrawal'])}", "开始提领"]})
|
||
|
||
# 18/21岁
|
||
for age_m in [18, 21]:
|
||
for yr, r in wr.items():
|
||
if int(r.get("age", 0)) == age_m:
|
||
ms_list.append({"title": f"{age_m}岁(第{yr}年)",
|
||
"lines": [f"累计 US${money(r.get('cumulativeWithdrawal', 0))}"]})
|
||
break
|
||
|
||
# 20年/30年
|
||
br_by_year = {int(r.get("policyYear", 0)): r for r in s["benefitRows"]}
|
||
for yr in [20, 30]:
|
||
r = br_by_year.get(yr)
|
||
if r:
|
||
ms_list.append({"title": f"第{yr}年",
|
||
"lines": [f"退保价值 US${money(r.get('totalSurrenderValue', 0))}"]})
|
||
|
||
return ms_list[:5]
|
||
|
||
|
||
def add_slide_table(prs, deck, colors, meta):
|
||
"""数据表页:每 10 年数据 + 单利/复利 + 侧边解读。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
product = _selected_product(deck, meta)
|
||
s = _product_summary(product)
|
||
|
||
table_type = meta.get("tableType", "no_withdraw")
|
||
title = meta.get("title", "不提领方案数据表(每10年)" if table_type == "no_withdraw" else "提领方案数据表(每10年)")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
has_withdraw = table_type == "withdraw" and s["withdrawalRows"]
|
||
rows = decade_rows(s["benefitRows"], s["annualPremium"], s["payYears"],
|
||
s["withdrawalRows"] if has_withdraw else None)
|
||
if not rows:
|
||
add_paragraphs(slide, Inches(1), Inches(3), Inches(10), Inches(1),
|
||
["暂无数据"], size=16, bullet=False, colors=colors)
|
||
return
|
||
|
||
if has_withdraw:
|
||
headers = ["年龄", "年度", "已交保费", "年领金额", "累计领取", "提后现价", "经济总值", "单利", "复利"]
|
||
col_widths = [0.6, 0.7, 1.1, 1.0, 1.1, 1.1, 1.1, 0.8, 0.8]
|
||
else:
|
||
headers = ["年龄", "年度", "已交保费", "退保价值", "单利", "复利"]
|
||
col_widths = [0.7, 0.8, 1.3, 1.5, 1.0, 1.0]
|
||
|
||
n_cols = len(headers)
|
||
n_rows = len(rows) + 1
|
||
table_width = Inches(sum(col_widths))
|
||
table = slide.shapes.add_table(
|
||
n_rows, n_cols, Inches(0.55), Inches(1.38), table_width, Inches(min(n_rows * 0.42, 5.0))
|
||
).table
|
||
|
||
for i, w in enumerate(col_widths):
|
||
table.columns[i].width = Inches(w)
|
||
|
||
# 表头
|
||
for c, h in enumerate(headers):
|
||
cell = table.cell(0, c)
|
||
cell.text = h
|
||
_style_cell(cell, bold=True, bg=colors["accent"])
|
||
|
||
# 数据行
|
||
for r_idx, row in enumerate(rows, 1):
|
||
if has_withdraw:
|
||
vals = [
|
||
str(row["age"]), str(row["year"]),
|
||
money(row["paid"]), money(row["annualWithdrawal"]),
|
||
money(row["cumulativeWithdrawal"]), money(row["surrenderValue"]),
|
||
money(row["economicTotal"]),
|
||
pct(row["simpleRate"]), pct(row["compoundRate"]),
|
||
]
|
||
else:
|
||
vals = [
|
||
str(row["age"]), str(row["year"]),
|
||
money(row["paid"]), money(row["surrenderValue"]),
|
||
pct(row["simpleRate"]), pct(row["compoundRate"]),
|
||
]
|
||
for c, v in enumerate(vals):
|
||
cell = table.cell(r_idx, c)
|
||
cell.text = v
|
||
_style_cell(cell)
|
||
|
||
# 解读面板
|
||
notes = []
|
||
if s["paybackYear"]:
|
||
notes.append(f"总保费 US${money(s['totalPremium'])},回本约第 {s['paybackYear']} 年")
|
||
y20 = next((r for r in rows if r["year"] == 20), None)
|
||
if y20:
|
||
mult20 = y20["economicTotal"] / s["totalPremium"] if s["totalPremium"] > 0 else 0
|
||
notes.append(f"20年约 {mult20:.1f} 倍")
|
||
add_fact_card(slide, Inches(10.0), Inches(1.65), Inches(2.7), Inches(3.0),
|
||
"解读", "\n".join(notes) if notes else "数据来自官方计划书",
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
|
||
add_footer(slide, "表格口径:关键年度每10年展示,数字来自官方计划书标准化结果。", colors=colors)
|
||
|
||
|
||
def _style_cell(cell, bold=False, bg=None):
|
||
"""样式化表格单元格。"""
|
||
for p in cell.text_frame.paragraphs:
|
||
p.alignment = PP_ALIGN.CENTER
|
||
for run in p.runs:
|
||
run.font.name = FONT_CN
|
||
run.font.size = Pt(10)
|
||
run.font.bold = bold
|
||
if bg:
|
||
cell.fill.solid()
|
||
cell.fill.fore_color.rgb = bg
|
||
|
||
|
||
def add_slide_guidance(prs, deck, colors, meta):
|
||
"""场景叙事页:只展示规则化、面向客户的说明,不补造业务数字。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
add_title(slide, meta.get("title", "方案说明"), meta.get("narrativeHint", ""), colors=colors)
|
||
bullets = [str(item) for item in meta.get("bullets", []) if item]
|
||
if not bullets:
|
||
bullets = ["具体结论以正式计划书及客户确认资料为准。"]
|
||
add_paragraphs(
|
||
slide,
|
||
Inches(1.0),
|
||
Inches(1.65),
|
||
Inches(11.1),
|
||
Inches(4.6),
|
||
bullets,
|
||
size=18,
|
||
bullet=True,
|
||
line_spacing=1.65,
|
||
colors=colors,
|
||
)
|
||
add_footer(slide, "缺失数据统一标记待确认,不根据其他情景自行推算。", colors=colors)
|
||
|
||
|
||
def add_slide_policy_summary(prs, deck, colors, meta):
|
||
"""每份正式计划书对应一页摘要;提取与不提取情景分开。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
product = _selected_product(deck, meta)
|
||
summary = _product_summary(product)
|
||
mode = meta.get("summaryMode", "no_withdraw")
|
||
title = meta.get("title") or f"{summary['productName']} 保单摘要"
|
||
subtitle = summary["productName"]
|
||
if summary.get("companyName"):
|
||
subtitle = f"{summary['companyName']}|{summary['productName']}"
|
||
add_title(slide, title, subtitle, colors=colors)
|
||
|
||
policy = product.get("policy", {})
|
||
currency = policy.get("currency") or "USD"
|
||
withdrawal_rows = summary["withdrawalRows"]
|
||
benefit_rows = summary["benefitRows"]
|
||
first_withdraw = next(
|
||
(row for row in withdrawal_rows if float(row.get("annualWithdrawal", 0)) > 0),
|
||
None,
|
||
)
|
||
last_withdraw = next(
|
||
(row for row in reversed(withdrawal_rows) if float(row.get("annualWithdrawal", 0)) > 0),
|
||
None,
|
||
)
|
||
metrics = [
|
||
("投保年龄", f"{summary['insured'].get('age') or '待确认'} 岁"),
|
||
("年缴保费", f"{currency} {_pending_money(summary['annualPremium'])}"),
|
||
("缴费年限", f"{summary['payYears'] or '待确认'} 年"),
|
||
("总计划保费", f"{currency} {_pending_money(summary['totalPremium'])}"),
|
||
]
|
||
if first_withdraw:
|
||
metrics.extend([
|
||
("开始提取", f"第 {first_withdraw.get('policyYear')} 保单年度"),
|
||
("年度提取", f"{currency} {_pending_money(first_withdraw.get('annualWithdrawal'))}"),
|
||
])
|
||
for index, (label, value) in enumerate(metrics[:6]):
|
||
left = Inches(0.7 + (index % 3) * 4.15)
|
||
top = Inches(1.25 + (index // 3) * 0.9)
|
||
add_card(
|
||
slide,
|
||
left,
|
||
top,
|
||
Inches(3.75),
|
||
Inches(0.72),
|
||
label,
|
||
value,
|
||
colors=colors,
|
||
title_size=9.5,
|
||
value_size=15,
|
||
)
|
||
|
||
if mode == "withdraw" and withdrawal_rows:
|
||
source_rows = withdrawal_rows
|
||
milestone_years = {
|
||
int(row.get("policyYear", 0))
|
||
for row in [first_withdraw, last_withdraw]
|
||
if row
|
||
} | {10, 20, 30}
|
||
rows_by_year = {
|
||
int(row.get("policyYear", 0)): row
|
||
for row in source_rows
|
||
if int(row.get("policyYear", 0)) > 0
|
||
}
|
||
selected_rows = [
|
||
rows_by_year.get(year, {"policyYear": year})
|
||
for year in sorted(year for year in milestone_years if year > 0)
|
||
]
|
||
headers = ["年龄", "保单年度", "年度提取", "累计提取", "提取后价值", "保证价值", "来源页"]
|
||
values_for = lambda row: [
|
||
str(row.get("age") or "—"),
|
||
str(row.get("policyYear") or "—"),
|
||
_pending_money(row.get("annualWithdrawal")),
|
||
_pending_money(row.get("cumulativeWithdrawal")),
|
||
_pending_money(row.get("surrenderValueAfter")),
|
||
_pending_money(row.get("guaranteedValueAfter")),
|
||
str(row.get("sourcePage") or "待确认"),
|
||
]
|
||
else:
|
||
source_rows = benefit_rows
|
||
milestone_years = {summary["payYears"], 10, 20, 30}
|
||
rows_by_year = {
|
||
int(row.get("policyYear", 0)): row
|
||
for row in source_rows
|
||
if int(row.get("policyYear", 0)) > 0
|
||
}
|
||
selected_rows = [
|
||
rows_by_year.get(year, {"policyYear": year})
|
||
for year in sorted(
|
||
int(year) for year in milestone_years
|
||
if year is not None and int(year) > 0
|
||
)
|
||
]
|
||
kind = product.get("kind")
|
||
if kind == "iul":
|
||
headers = ["年龄", "保单年度", "累计保费", "账户/退保价值", "身故保障", "保证现金价值", "来源页"]
|
||
values_for = lambda row: [
|
||
str(row.get("age") or "—"),
|
||
str(row.get("policyYear") or "—"),
|
||
_pending_money(row.get("totalPremiumPaid")),
|
||
_pending_money(row.get("totalSurrenderValue")),
|
||
_pending_money(
|
||
row.get("nonGuaranteedDeathBenefit")
|
||
or row.get("guaranteedDeathBenefit")
|
||
),
|
||
_pending_money(row.get("guaranteedCashValue")),
|
||
str(row.get("sourcePage") or "待确认"),
|
||
]
|
||
else:
|
||
headers = ["年龄", "保单年度", "累计保费", "保证价值", "非保证利益", "总退保价值", "来源页"]
|
||
values_for = lambda row: [
|
||
str(row.get("age") or "—"),
|
||
str(row.get("policyYear") or "—"),
|
||
_pending_money(row.get("totalPremiumPaid")),
|
||
_pending_money(row.get("guaranteedCashValue")),
|
||
_pending_money(
|
||
float(row.get("reversionaryBonus", 0))
|
||
+ float(row.get("terminalDividend", 0))
|
||
),
|
||
_pending_money(row.get("totalSurrenderValue")),
|
||
str(row.get("sourcePage") or "待确认"),
|
||
]
|
||
|
||
if not selected_rows:
|
||
add_paragraphs(
|
||
slide,
|
||
Inches(1.0),
|
||
Inches(4.0),
|
||
Inches(11.0),
|
||
Inches(1.0),
|
||
["关键年度数据待正式计划书确认"],
|
||
size=17,
|
||
bullet=False,
|
||
colors=colors,
|
||
)
|
||
else:
|
||
table = slide.shapes.add_table(
|
||
len(selected_rows) + 1,
|
||
len(headers),
|
||
Inches(0.55),
|
||
Inches(3.15),
|
||
Inches(12.2),
|
||
Inches(min(0.43 * (len(selected_rows) + 1), 2.8)),
|
||
).table
|
||
widths = [0.8, 1.0, 1.55, 1.55, 1.7, 1.7, 1.05]
|
||
for index, width in enumerate(widths):
|
||
table.columns[index].width = Inches(width)
|
||
for column, header in enumerate(headers):
|
||
table.cell(0, column).text = header
|
||
_style_cell(table.cell(0, column), bold=True, bg=colors["accent"])
|
||
for row_index, row in enumerate(selected_rows, 1):
|
||
for column, value in enumerate(values_for(row)):
|
||
table.cell(row_index, column).text = value
|
||
_style_cell(table.cell(row_index, column))
|
||
|
||
add_footer(
|
||
slide,
|
||
f"数据来源页:{_source_pages(source_rows)};保证与非保证口径不得混用。",
|
||
colors=colors,
|
||
)
|
||
|
||
|
||
def add_slide_comparison_chart(prs, deck, colors, meta):
|
||
"""多份储蓄计划按相同保单年度比较总退保价值。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
add_title(slide, meta.get("title", "同年度价值走势"), "", colors=colors)
|
||
products = deck.get("products", [])
|
||
preferred_years = [5, 10, 20, 25, 30]
|
||
series = []
|
||
valid_years = []
|
||
for year in preferred_years:
|
||
if any(
|
||
any(int(row.get("policyYear", 0)) == year for row in product.get("benefitRows", []))
|
||
for product in products
|
||
):
|
||
valid_years.append(year)
|
||
for product in products:
|
||
by_year = {
|
||
int(row.get("policyYear", 0)): row
|
||
for row in product.get("benefitRows", [])
|
||
}
|
||
series.append((
|
||
product.get("productName") or "未命名计划",
|
||
[
|
||
(
|
||
float(by_year[year].get("totalSurrenderValue", 0))
|
||
if year in by_year
|
||
else None
|
||
)
|
||
for year in valid_years
|
||
],
|
||
))
|
||
if valid_years and series:
|
||
add_line_chart(
|
||
slide,
|
||
Inches(0.7),
|
||
Inches(1.4),
|
||
Inches(11.9),
|
||
Inches(4.8),
|
||
valid_years,
|
||
series,
|
||
colors,
|
||
)
|
||
else:
|
||
add_paragraphs(
|
||
slide,
|
||
Inches(1.0),
|
||
Inches(3.0),
|
||
Inches(11.0),
|
||
Inches(1.0),
|
||
["缺少共同关键年度,待正式计划书确认"],
|
||
size=17,
|
||
bullet=False,
|
||
colors=colors,
|
||
)
|
||
add_footer(slide, "只比较正式计划书已提供的相同保单年度。", colors=colors)
|
||
|
||
|
||
def add_slide_comparison_table(prs, deck, colors, meta):
|
||
"""在同一表中拆分各产品关键年度的保证价值和总演示价值。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
add_title(slide, meta.get("title", "关键年度价值对比"), "", colors=colors)
|
||
products = deck.get("products", [])
|
||
years = [5, 10, 20, 30]
|
||
headers = ["产品"] + [f"第{year}年\n保证/总值" for year in years]
|
||
table = slide.shapes.add_table(
|
||
len(products) + 1,
|
||
len(headers),
|
||
Inches(0.55),
|
||
Inches(1.55),
|
||
Inches(12.2),
|
||
Inches(min(0.75 * (len(products) + 1), 4.7)),
|
||
).table
|
||
table.columns[0].width = Inches(2.6)
|
||
for index in range(1, len(headers)):
|
||
table.columns[index].width = Inches(2.35)
|
||
for column, header in enumerate(headers):
|
||
table.cell(0, column).text = header
|
||
_style_cell(table.cell(0, column), bold=True, bg=colors["accent"])
|
||
for row_index, product in enumerate(products, 1):
|
||
by_year = {
|
||
int(row.get("policyYear", 0)): row
|
||
for row in product.get("benefitRows", [])
|
||
}
|
||
values = [product.get("productName") or f"方案 {row_index}"]
|
||
for year in years:
|
||
row = by_year.get(year)
|
||
values.append(
|
||
"待确认" if not row else
|
||
f"{_pending_money(row.get('guaranteedCashValue'))} / "
|
||
f"{_pending_money(row.get('totalSurrenderValue'))}"
|
||
)
|
||
for column, value in enumerate(values):
|
||
table.cell(row_index, column).text = value
|
||
_style_cell(table.cell(row_index, column), bold=(column == 0))
|
||
add_footer(
|
||
slide,
|
||
"前值为保证现金价值,后值为总演示退保价值;币种和投入条件不一致时不做排名。",
|
||
colors=colors,
|
||
)
|
||
|
||
|
||
def _portfolio_products(deck):
|
||
products = deck.get("products", [])
|
||
savings = next((product for product in products if product.get("kind") == "savings"), None)
|
||
iul = next((product for product in products if product.get("kind") == "iul"), None)
|
||
return savings, iul
|
||
|
||
|
||
def _portfolio_cashflow(deck):
|
||
savings, iul = _portfolio_products(deck)
|
||
withdrawal = None
|
||
if savings:
|
||
withdrawal = next(
|
||
(
|
||
row for row in savings.get("withdrawalRows", [])
|
||
if float(row.get("annualWithdrawal", 0)) > 0
|
||
),
|
||
None,
|
||
)
|
||
iul_premium = float((iul or {}).get("policy", {}).get("annualPremium", 0))
|
||
withdrawal_amount = float((withdrawal or {}).get("annualWithdrawal", 0))
|
||
currencies = {
|
||
(product.get("policy", {}).get("currency") or "").upper()
|
||
for product in (savings, iul)
|
||
if product
|
||
}
|
||
same_currency = len(currencies) == 1
|
||
remainder = withdrawal_amount - iul_premium if same_currency and withdrawal and iul_premium else None
|
||
return savings, iul, withdrawal, iul_premium, remainder, next(iter(currencies), "USD")
|
||
|
||
|
||
def add_slide_cashflow_bridge(prs, deck, colors, meta):
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
add_title(slide, meta.get("title", "现金流接力"), "", colors=colors)
|
||
_savings, _iul, withdrawal, iul_premium, remainder, currency = _portfolio_cashflow(deck)
|
||
items = [
|
||
("储蓄计划年度提取", f"{currency} {_pending_money((withdrawal or {}).get('annualWithdrawal'))}"),
|
||
("IUL 年度计划保费", f"{currency} {_pending_money(iul_premium or None)}"),
|
||
("覆盖保费后年度余量", f"{currency} {_pending_money(remainder)}"),
|
||
]
|
||
for index, (label, value) in enumerate(items):
|
||
add_card(
|
||
slide,
|
||
Inches(0.8 + index * 4.15),
|
||
Inches(2.0),
|
||
Inches(3.65),
|
||
Inches(1.45),
|
||
label,
|
||
value,
|
||
colors=colors,
|
||
accent=(index == 2),
|
||
)
|
||
add_paragraphs(
|
||
slide,
|
||
Inches(1.0),
|
||
Inches(4.25),
|
||
Inches(11.0),
|
||
Inches(1.3),
|
||
["年度余量 = 正式计划提取金额 − IUL 正式计划保费;币种不一致时不计算。"],
|
||
size=16,
|
||
bullet=False,
|
||
colors=colors,
|
||
)
|
||
add_footer(slide, "计算只使用两份正式计划书已经提供的金额。", colors=colors)
|
||
|
||
|
||
def add_slide_launch_paths(prs, deck, colors, meta):
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
add_title(slide, meta.get("title", "两种启动路径"), "", colors=colors)
|
||
_savings, iul, withdrawal, _premium, _remainder, _currency = _portfolio_cashflow(deck)
|
||
start_year = int((withdrawal or {}).get("policyYear", 0))
|
||
pay_years = int((iul or {}).get("policy", {}).get("payYears", 0))
|
||
funding_years = max(0, min(pay_years, start_year - 1)) if start_year else None
|
||
early_text = (
|
||
f"IUL 从第1年启动;前 {funding_years} 年保费由客户自有资金支付,"
|
||
f"第 {start_year} 年起才可由储蓄提取承接。"
|
||
if funding_years is not None else
|
||
"IUL 从第1年启动;储蓄正式提取前的保费由客户自有资金支付,具体年数待确认。"
|
||
)
|
||
sync_text = (
|
||
f"前期先完成储蓄安排;第 {start_year} 年正式开始提取时再启动 IUL。"
|
||
if start_year else
|
||
"前期先完成储蓄安排;待正式计划书确认可提取年度后再启动 IUL。"
|
||
)
|
||
add_fact_card(
|
||
slide,
|
||
Inches(0.8),
|
||
Inches(1.55),
|
||
Inches(5.65),
|
||
Inches(3.7),
|
||
"路径一|保障先行",
|
||
early_text + "\n优点:保障建立较早。\n代价:前期现金流支出较多。",
|
||
fill_color=colors["accent_light"],
|
||
colors=colors,
|
||
body_size=14,
|
||
)
|
||
add_fact_card(
|
||
slide,
|
||
Inches(6.85),
|
||
Inches(1.55),
|
||
Inches(5.65),
|
||
Inches(3.7),
|
||
"路径二|现金流同步",
|
||
sync_text + "\n优点:无需额外准备前期 IUL 保费。\n代价:保障建立时间较晚。",
|
||
fill_color=colors["good_light"],
|
||
colors=colors,
|
||
body_size=14,
|
||
)
|
||
add_footer(slide, "两条路径没有绝对好坏,选择取决于保障紧迫度和前期现金流。", colors=colors)
|
||
|
||
|
||
def add_slide_combined_summary(prs, deck, colors, meta):
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
add_title(slide, meta.get("title", "组合保单摘要"), "", colors=colors)
|
||
savings, iul, withdrawal, iul_premium, remainder, currency = _portfolio_cashflow(deck)
|
||
savings_summary = _product_summary(savings or {})
|
||
iul_summary = _product_summary(iul or {})
|
||
blocks = [
|
||
(
|
||
"储蓄计划",
|
||
f"年缴 {currency} {_pending_money(savings_summary['annualPremium'])} × "
|
||
f"{savings_summary['payYears'] or '待确认'} 年\n"
|
||
f"开始提取:第 {(withdrawal or {}).get('policyYear') or '待确认'} 保单年度\n"
|
||
f"年度提取:{currency} {_pending_money((withdrawal or {}).get('annualWithdrawal'))}",
|
||
),
|
||
(
|
||
"IUL 计划",
|
||
f"年缴 {currency} {_pending_money(iul_summary['annualPremium'])} × "
|
||
f"{iul_summary['payYears'] or '待确认'} 年\n"
|
||
f"总计划保费:{currency} {_pending_money(iul_summary['totalPremium'])}\n"
|
||
f"初始身故保障:{currency} "
|
||
f"{_pending_money((iul or {}).get('policy', {}).get('sumInsured'))}",
|
||
),
|
||
(
|
||
"组合结果",
|
||
f"IUL 年度保费:{currency} {_pending_money(iul_premium or None)}\n"
|
||
f"覆盖后年度余量:{currency} {_pending_money(remainder)}\n"
|
||
f"启动年度是否一致:{'是' if (withdrawal or {}).get('policyYear') == 1 else '否'}",
|
||
),
|
||
]
|
||
for index, (label, body) in enumerate(blocks):
|
||
add_fact_card(
|
||
slide,
|
||
Inches(0.65 + index * 4.2),
|
||
Inches(1.65),
|
||
Inches(3.85),
|
||
Inches(3.75),
|
||
label,
|
||
body,
|
||
fill_color=colors["accent_light"] if index == 2 else colors["panel"],
|
||
colors=colors,
|
||
body_size=13.2,
|
||
)
|
||
add_footer(slide, "组合总投入仅在币种一致时具有可加总意义。", colors=colors)
|
||
|
||
|
||
def add_slide_alignment_table(prs, deck, colors, meta):
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
add_title(slide, meta.get("title", "两份计划书对应表"), "", colors=colors)
|
||
savings, iul, withdrawal, _premium, _remainder, _currency = _portfolio_cashflow(deck)
|
||
milestones = {
|
||
1,
|
||
5,
|
||
10,
|
||
20,
|
||
30,
|
||
int((withdrawal or {}).get("policyYear", 0)),
|
||
}
|
||
years = sorted(year for year in milestones if year > 0)
|
||
savings_benefits = {
|
||
int(row.get("policyYear", 0)): row
|
||
for row in (savings or {}).get("benefitRows", [])
|
||
}
|
||
savings_withdrawals = {
|
||
int(row.get("policyYear", 0)): row
|
||
for row in (savings or {}).get("withdrawalRows", [])
|
||
}
|
||
iul_benefits = {
|
||
int(row.get("policyYear", 0)): row
|
||
for row in (iul or {}).get("benefitRows", [])
|
||
}
|
||
headers = ["保单年度", "储蓄累计保费", "储蓄计划提取", "储蓄退保价值", "IUL累计保费", "IUL账户价值", "IUL身故保障"]
|
||
table = slide.shapes.add_table(
|
||
len(years) + 1,
|
||
len(headers),
|
||
Inches(0.35),
|
||
Inches(1.35),
|
||
Inches(12.6),
|
||
Inches(min(0.52 * (len(years) + 1), 4.9)),
|
||
).table
|
||
widths = [1.0, 1.65, 1.65, 1.65, 1.65, 1.65, 1.75]
|
||
for index, width in enumerate(widths):
|
||
table.columns[index].width = Inches(width)
|
||
for column, header in enumerate(headers):
|
||
table.cell(0, column).text = header
|
||
_style_cell(table.cell(0, column), bold=True, bg=colors["accent"])
|
||
for row_index, year in enumerate(years, 1):
|
||
savings_row = savings_benefits.get(year, {})
|
||
withdrawal_row = savings_withdrawals.get(year, {})
|
||
iul_row = iul_benefits.get(year, {})
|
||
values = [
|
||
str(year),
|
||
_pending_money(savings_row.get("totalPremiumPaid")),
|
||
_pending_money(withdrawal_row.get("annualWithdrawal")),
|
||
_pending_money(
|
||
withdrawal_row.get("surrenderValueAfter")
|
||
if withdrawal_row else savings_row.get("totalSurrenderValue")
|
||
),
|
||
_pending_money(iul_row.get("totalPremiumPaid")),
|
||
_pending_money(iul_row.get("totalSurrenderValue")),
|
||
_pending_money(
|
||
iul_row.get("nonGuaranteedDeathBenefit")
|
||
or iul_row.get("guaranteedDeathBenefit")
|
||
),
|
||
]
|
||
for column, value in enumerate(values):
|
||
table.cell(row_index, column).text = value
|
||
_style_cell(table.cell(row_index, column), bold=(column == 0))
|
||
add_footer(
|
||
slide,
|
||
"以保单年度和现金流发生年度为主对应;不同受保人年龄不强行对齐。",
|
||
colors=colors,
|
||
)
|
||
|
||
|
||
def _compare_card_body(product, comparison_product):
|
||
"""按险种生成对比卡片正文。CI/IUL 使用与储蓄险不同的指标。"""
|
||
kind = str(product.get("kind") or "savings").lower()
|
||
policy = product.get("policy") or {}
|
||
ap = policy.get("annualPremium", 0)
|
||
|
||
if kind == "ci":
|
||
si = policy.get("sumInsured") or 0
|
||
br = product.get("benefitRows") or []
|
||
period = int(br[-1].get("policyYear", 0)) if br else 0
|
||
return (f"年缴 ${money(ap)}\n"
|
||
f"基本保额 ${money(si)}\n"
|
||
f"保障期 {period} 年")
|
||
|
||
if kind == "iul":
|
||
# 优先使用比较契约中已计算的年份数据
|
||
yv = (comparison_product or {}).get("yearValues") or {}
|
||
g10 = None
|
||
ng10 = None
|
||
for yr_key in ("10", "20", "30"):
|
||
vals = yv.get(yr_key)
|
||
if vals:
|
||
g10 = vals.get("guaranteedValue")
|
||
ng10 = vals.get("totalValue")
|
||
break
|
||
if g10 is None:
|
||
br = product.get("benefitRows") or []
|
||
row10 = next((r for r in br if int(r.get("policyYear", 0)) == 10), None)
|
||
if row10:
|
||
g10 = row10.get("guaranteedCashValue", 0)
|
||
ng10 = row10.get("totalSurrenderValue", 0)
|
||
return (f"年缴 ${money(ap)}\n"
|
||
f"第10年保证 ${money(g10 or 0)}\n"
|
||
f"第10年非保证 ${money(ng10 or 0)}")
|
||
|
||
# 储蓄险(默认)
|
||
s = _product_summary(product)
|
||
return (f"年缴 ${money(s['annualPremium'])}\n"
|
||
f"总投入 ${money(s['totalPremium'])}\n"
|
||
f"回本约第 {s['paybackYear'] or '?'} 年")
|
||
|
||
|
||
def add_slide_compare(prs, deck, colors, meta):
|
||
"""对比页:多产品对比,支持 1~10 份。按险种显示不同指标。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
comparison = deck.get("comparison") or {}
|
||
comp_products = comparison.get("products") or []
|
||
title = meta.get("title", "产品对比")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
if len(products) < 2:
|
||
# 单产品时显示保证 vs 非保证对比
|
||
product = products[0] if products else {}
|
||
kind = str(product.get("kind") or "savings").lower()
|
||
s = _product_summary(product)
|
||
if kind == "ci":
|
||
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5),
|
||
"重疾保障", f"基本保额 ${money(s.get('annualPremium', 0) * 20)}\n保障期内确诊即赔\n多次赔付视条款而定",
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.5),
|
||
"早期重疾", f"原位癌、早期危疾等\n赔付比例通常 20%~25%\n不终止主保单",
|
||
fill_color=colors["good_light"], colors=colors)
|
||
elif kind == "iul":
|
||
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5),
|
||
"保证部分", f"保证现金价值\n最低身故保障\n不受投资表现影响",
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.5),
|
||
"非保证部分", f"指数账户收益\n演示利率仅供参考\n实际取决于市场表现",
|
||
fill_color=colors["good_light"], colors=colors)
|
||
else:
|
||
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5),
|
||
"保证部分", f"保证现金价值\n回本约第 {s['paybackYear'] or '?'} 年\n确定性高",
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.5),
|
||
"非保证部分", f"归原红利 + 终期分红\n长期弹性空间大\n取决于公司投资表现",
|
||
fill_color=colors["good_light"], colors=colors)
|
||
add_paragraphs(slide, Inches(0.85), Inches(4.5), Inches(11.4), Inches(1.5),
|
||
["保证部分是底线,非保证部分是弹性。两者合计才是长期回报的完整图景。"],
|
||
size=15, bullet=True, colors=colors)
|
||
elif len(products) <= 4:
|
||
# 2~4 产品:卡片网格,按险种显示不同指标
|
||
card_h = Inches(2.5) if len(products) <= 2 else Inches(2.0)
|
||
fill_colors = [colors["accent_light"], colors["good_light"],
|
||
colors["accent_light"], colors["good_light"]]
|
||
positions = [
|
||
(Inches(0.8), Inches(1.45)),
|
||
(Inches(6.95), Inches(1.45)),
|
||
(Inches(0.8), Inches(3.7)),
|
||
(Inches(6.95), Inches(3.7)),
|
||
]
|
||
for i, product in enumerate(products):
|
||
cp = comp_products[i] if i < len(comp_products) else None
|
||
body = _compare_card_body(product, cp)
|
||
x, y = positions[i]
|
||
name = product.get("productName", "") or f"产品 {i + 1}"
|
||
kind_label = _kind_label(product.get("kind", "savings"))
|
||
add_fact_card(slide, x, y, Inches(5.45), card_h,
|
||
f"{name}({kind_label})", body,
|
||
fill_color=fill_colors[i % len(fill_colors)], colors=colors)
|
||
bottom_y = Inches(6.0) if len(products) <= 2 else Inches(6.0)
|
||
add_paragraphs(slide, Inches(0.85), bottom_y, Inches(11.4), Inches(1.0),
|
||
["对比重点在相同保单年度下的保证价值、总退保价值和回本时间。"],
|
||
size=15, bullet=True, colors=colors)
|
||
else:
|
||
# 5+ 产品:表格布局,按险种调整列
|
||
kinds = {str(p.get("kind") or "savings").lower() for p in products}
|
||
if len(kinds) == 1 and "ci" in kinds:
|
||
headers = ["产品", "年缴保费", "基本保额", "保障期", "缴费年期"]
|
||
elif len(kinds) == 1 and "iul" in kinds:
|
||
headers = ["产品", "年缴保费", "第10年保证", "第10年非保证", "缴费年期"]
|
||
else:
|
||
headers = ["产品", "险种", "年缴保费", "回本年", "期末倍数"]
|
||
rows = len(products) + 1
|
||
cols = len(headers)
|
||
table_left, table_top = Inches(0.6), Inches(1.5)
|
||
table_w, table_h = Inches(12.0), Inches(0.4 * rows + 0.2)
|
||
table_shape = slide.shapes.add_table(rows, cols, table_left, table_top, table_w, table_h)
|
||
table = table_shape.table
|
||
for c, h in enumerate(headers):
|
||
table.cell(0, c).text = h
|
||
_style_cell(table.cell(0, c), bold=True, bg=colors.get("accent", RGBColor(59, 122, 87)))
|
||
for r, product in enumerate(products, 1):
|
||
kind = str(product.get("kind") or "savings").lower()
|
||
policy = product.get("policy") or {}
|
||
s = _product_summary(product)
|
||
name = product.get("productName", "") or f"产品 {r}"
|
||
if len(kinds) == 1 and "ci" in kinds:
|
||
br = product.get("benefitRows") or []
|
||
period = int(br[-1].get("policyYear", 0)) if br else 0
|
||
values = [name, f"${money(policy.get('annualPremium', 0))}",
|
||
f"${money(policy.get('sumInsured', 0))}",
|
||
f"{period} 年", f"{policy.get('payYears', '-')} 年"]
|
||
elif len(kinds) == 1 and "iul" in kinds:
|
||
cp = comp_products[r - 1] if (r - 1) < len(comp_products) else None
|
||
yv = (cp or {}).get("yearValues") or {}
|
||
v10 = yv.get("10") or {}
|
||
values = [name, f"${money(policy.get('annualPremium', 0))}",
|
||
f"${money(v10.get('guaranteedValue', 0))}",
|
||
f"${money(v10.get('totalValue', 0))}",
|
||
f"{policy.get('payYears', '-')} 年"]
|
||
else:
|
||
values = [name, _kind_label(kind),
|
||
f"${money(s['annualPremium'])}",
|
||
str(s["paybackYear"] or "-"), s["multiple"]]
|
||
for c, v in enumerate(values):
|
||
table.cell(r, c).text = v
|
||
_style_cell(table.cell(r, c), bold=(c == 0))
|
||
add_paragraphs(slide, Inches(0.85), Inches(5.5), Inches(11.4), Inches(1.0),
|
||
["以上为各产品在相同口径下的核心指标汇总,具体差异请结合正式计划书逐项核对。"],
|
||
size=14, bullet=True, colors=colors)
|
||
|
||
add_footer(slide, "", colors=colors)
|
||
|
||
|
||
def add_slide_synergy(prs, deck, colors, meta):
|
||
"""协同关系页:多产品如何配合,支持 N 份。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
title = meta.get("title", "协同关系")
|
||
add_title(slide, title, meta.get("narrativeHint", "功能分层,互不冲突"), colors=colors)
|
||
|
||
if len(products) >= 2:
|
||
fill_colors = [colors["accent_light"], colors["good_light"],
|
||
colors["accent_light"], colors["good_light"]]
|
||
if len(products) <= 2:
|
||
# 双产品:左右卡片
|
||
left_p = _product_summary(products[0])
|
||
right_p = _product_summary(products[1])
|
||
left_kind = products[0].get("kind", "savings")
|
||
right_kind = products[1].get("kind", "savings")
|
||
left_label = _kind_label(left_kind)
|
||
right_label = _kind_label(right_kind)
|
||
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.05),
|
||
f"{left_label} 负责", _kind_synergy_left(left_kind, left_p),
|
||
fill_color=colors["accent_light"], colors=colors)
|
||
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.05),
|
||
f"{right_label} 负责", _kind_synergy_right(right_kind, right_p),
|
||
fill_color=colors["good_light"], colors=colors)
|
||
add_paragraphs(slide, Inches(0.85), Inches(4.0), Inches(11.3), Inches(2.0),
|
||
[f"{left_label}和{right_label}不是重复配置,而是功能分层。",
|
||
"先看谁扛风险,再看谁承接未来目标。"],
|
||
size=15, bullet=True, colors=colors)
|
||
else:
|
||
# 3+ 产品:垂直列表
|
||
card_h = min(1.3, 4.5 / len(products))
|
||
for i, product in enumerate(products):
|
||
s = _product_summary(product)
|
||
kind = product.get("kind", "savings")
|
||
label = _kind_label(kind)
|
||
y = 1.45 + i * (card_h + 0.15)
|
||
body = _kind_synergy_left(kind, s) if i % 2 == 0 else _kind_synergy_right(kind, s)
|
||
add_fact_card(slide, Inches(0.8), Inches(y), Inches(11.5), Inches(card_h),
|
||
f"{label}|{s['productName'] or f'产品 {i + 1}'}",
|
||
body.replace("\n", " · "),
|
||
fill_color=fill_colors[i % len(fill_colors)], colors=colors)
|
||
add_paragraphs(slide, Inches(0.85), Inches(5.8), Inches(11.3), Inches(0.8),
|
||
["各产品按功能分层配置,不重复承担相同风险。"],
|
||
size=15, bullet=True, colors=colors)
|
||
else:
|
||
add_paragraphs(slide, Inches(1), Inches(3), Inches(10), Inches(2),
|
||
["单产品方案,无需展示协同关系。"],
|
||
size=16, bullet=False, colors=colors)
|
||
|
||
add_footer(slide, "", colors=colors)
|
||
|
||
|
||
def _kind_label(kind):
|
||
return {"savings": "储蓄险", "ci": "重疾险", "iul": "IUL"}.get(kind, "产品")
|
||
|
||
|
||
def _kind_synergy_left(kind, s):
|
||
if kind == "ci":
|
||
return "风险来时,用重疾险扛支出\n储蓄险继续留给家庭\n教育金目标不被打断"
|
||
if kind == "iul":
|
||
return "更高身故保障底盘\n家族传承效率\n晚年流动性与应急缓冲"
|
||
return f"未来现金流节奏\n回本约第 {s['paybackYear'] or '?'} 年\n为教育/成长阶段提供稳健现金流"
|
||
|
||
|
||
def _kind_synergy_right(kind, s):
|
||
if kind == "ci":
|
||
return "家庭防火墙\n先扛风险支出\n保护储蓄不被提前动用"
|
||
if kind == "iul":
|
||
return "长期身故杠杆\n代际传承效率\n现金价值缓冲"
|
||
return f"长期财富累积\n期末倍数 {s['multiple']}\n时间换复利"
|
||
|
||
|
||
def add_slide_conclusion(prs, deck, colors, meta):
|
||
"""结论页:总结 + 核心数据,支持 N 份产品。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
products = deck.get("products", [])
|
||
title = meta.get("title", "结论")
|
||
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
|
||
|
||
if len(products) <= 1:
|
||
product = products[0] if products else {}
|
||
s = _product_summary(product)
|
||
points = []
|
||
if s["paybackYear"]:
|
||
points.append(f"总投入 ${money(s['totalPremium'])},回本约第 {s['paybackYear']} 年")
|
||
points.append(f"期末退保价值约 ${money(s['finalValue'])},倍数 {s['multiple']}")
|
||
add_paragraphs(slide, Inches(0.85), Inches(1.5), Inches(6.0), Inches(3.0),
|
||
points, size=15, bullet=True, colors=colors)
|
||
cards = [
|
||
("总投入", f"${money(s['totalPremium'])}"),
|
||
("期末价值", f"${money(s['finalValue'])}"),
|
||
("倍数", s["multiple"]),
|
||
]
|
||
for i, (label, value) in enumerate(cards):
|
||
x = Inches(0.9 + i * 2.8)
|
||
add_card(slide, x, Inches(5.0), Inches(2.4), Inches(1.1),
|
||
label, value, colors=colors, accent=(i == 2))
|
||
else:
|
||
# 多产品:逐产品摘要 + 总览卡片
|
||
lines = []
|
||
for i, product in enumerate(products):
|
||
s = _product_summary(product)
|
||
name = s["productName"] or f"产品 {i + 1}"
|
||
line = f"{name}:投入 ${money(s['totalPremium'])},回本第 {s['paybackYear'] or '?'} 年,倍数 {s['multiple']}"
|
||
lines.append(line)
|
||
if len(products) >= 2:
|
||
lines.append("组合方案把家庭资产目标拆成不同的功能层")
|
||
add_paragraphs(slide, Inches(0.85), Inches(1.5), Inches(11.4), Inches(3.0),
|
||
lines, size=14, bullet=True, colors=colors)
|
||
|
||
# 指标卡片:展示总投入和产品数
|
||
total_investment = sum(_product_summary(p)["totalPremium"] for p in products)
|
||
cards = [
|
||
("计划书数量", f"{len(products)} 份"),
|
||
("总投入", f"${money(total_investment)}"),
|
||
("对比维度", "按保单年度"),
|
||
]
|
||
for i, (label, value) in enumerate(cards):
|
||
x = Inches(0.9 + i * 3.8)
|
||
add_card(slide, x, Inches(5.0), Inches(3.2), Inches(1.1),
|
||
label, value, colors=colors, accent=(i == 2))
|
||
|
||
add_footer(slide, "", colors=colors)
|
||
|
||
|
||
def add_slide_closing(prs, deck, colors, meta):
|
||
"""结束页:感谢 + 声明。"""
|
||
slide = add_blank_slide(prs)
|
||
add_bg(slide, colors)
|
||
|
||
title = meta.get("title", "感谢信任")
|
||
add_textbox(slide, Inches(0.8), Inches(2.0), Inches(11.7), Inches(1.0),
|
||
title, size=36, bold=True, align=PP_ALIGN.CENTER, colors=colors)
|
||
|
||
narrative = meta.get("narrativeHint", "方案可继续迭代优化,如有疑问请随时联系。")
|
||
add_textbox(slide, Inches(1.5), Inches(3.5), Inches(10.3), Inches(1.0),
|
||
narrative, size=18, color=colors["muted"],
|
||
align=PP_ALIGN.CENTER, colors=colors)
|
||
|
||
add_paragraphs(slide, Inches(2.5), Inches(5.0), Inches(8.3), Inches(1.5),
|
||
["本方案用于沟通理解,最终权益以保险公司正式文件为准。",
|
||
"建议尽快与您的保险经纪人预约时间完成方案确认。"],
|
||
size=12, bullet=True, color=colors["muted"], colors=colors)
|
||
|
||
|
||
# ─── pageType → builder 映射 ────────────────────────────────
|
||
|
||
SLIDE_BUILDERS = {
|
||
"cover": add_slide_cover,
|
||
"company": add_slide_company,
|
||
"narrative": add_slide_narrative,
|
||
"guidance": add_slide_guidance,
|
||
"chart": add_slide_chart,
|
||
"comparison_chart": add_slide_comparison_chart,
|
||
"comparison_table": add_slide_comparison_table,
|
||
"timeline": add_slide_timeline,
|
||
"table": add_slide_table,
|
||
"policy_summary": add_slide_policy_summary,
|
||
"compare": add_slide_compare,
|
||
"synergy": add_slide_synergy,
|
||
"cashflow_bridge": add_slide_cashflow_bridge,
|
||
"launch_paths": add_slide_launch_paths,
|
||
"combined_summary": add_slide_combined_summary,
|
||
"alignment_table": add_slide_alignment_table,
|
||
"conclusion": add_slide_conclusion,
|
||
"closing": add_slide_closing,
|
||
}
|
||
|
||
# 默认 pageType 序列(当 DeckContract 无 templateConfig 时使用)
|
||
DEFAULT_PAGE_TYPES = {
|
||
"savings": ["cover", "company", "narrative", "chart", "chart", "timeline", "timeline", "table", "table", "conclusion", "closing"],
|
||
"ci": ["cover", "company", "narrative", "chart", "table", "conclusion", "closing"],
|
||
"iul": ["cover", "company", "narrative", "chart", "compare", "table", "conclusion", "closing"],
|
||
}
|
||
|
||
|
||
_BUILTIN_FRAME_MAPS = {
|
||
# 这些映射只定义“当前场景页使用哪一张源模板页作为可编辑底稿”。
|
||
# 模板中的示例文本、图表和表格会在绘制真实数据前清空。
|
||
"single_savings.pptx": {
|
||
12: [1, 2, 3, 6, 8, 9, 10, 11, 18, 13, 17, 20],
|
||
14: [1, 2, 3, 6, 8, 9, 10, 11, 18, 14, 19, 13, 17, 20],
|
||
},
|
||
"multi_savings_comparison.pptx": {
|
||
10: [1, 2, 3, 4, 15, 16, 11, 13, 14, 17],
|
||
},
|
||
"savings_iul_comprehensive.pptx": {
|
||
15: [1, 2, 3, 4, 8, 9, 11, 12, 13, 14, 17, 18, 6, 16, 15],
|
||
},
|
||
}
|
||
|
||
_TEMPLATE_THEME_BY_ASSET = {
|
||
"single_savings.pptx": "caramel",
|
||
"multi_savings_comparison.pptx": "caramel",
|
||
"savings_iul_comprehensive.pptx": "sage",
|
||
}
|
||
|
||
_PAGE_TYPE_ALIASES = {
|
||
"comparison_chart": {"chart"},
|
||
"comparison_table": {"compare", "policy_summary", "table"},
|
||
"table": {"policy_summary", "chart"},
|
||
"launch_paths": {"guidance", "timeline"},
|
||
"alignment_table": {"combined_summary", "policy_summary", "closing"},
|
||
"conclusion": {"guidance", "synergy", "closing"},
|
||
}
|
||
|
||
|
||
def _auto_frame_map(page_types: list[str], template_config: dict, slide_count: int) -> list[int]:
|
||
"""为上传模板建立确定性的逐页映射;页面不足时明确失败。"""
|
||
if slide_count < len(page_types):
|
||
raise ValueError(
|
||
f"所选模板只有 {slide_count} 页,当前方案需要 {len(page_types)} 页,"
|
||
"请上传页数充足的模板或调整模板页面配置"
|
||
)
|
||
|
||
source_slides = template_config.get("slidesConfig") or []
|
||
source_types = [str(item.get("pageType") or "") for item in source_slides]
|
||
if len(source_types) < slide_count:
|
||
source_types.extend([""] * (slide_count - len(source_types)))
|
||
|
||
available = set(range(slide_count))
|
||
selected = []
|
||
for page_type in page_types:
|
||
candidates = [
|
||
index for index in sorted(available)
|
||
if source_types[index] == page_type
|
||
]
|
||
if not candidates:
|
||
aliases = _PAGE_TYPE_ALIASES.get(page_type, set())
|
||
candidates = [
|
||
index for index in sorted(available)
|
||
if source_types[index] in aliases
|
||
]
|
||
index = candidates[0] if candidates else min(available)
|
||
available.remove(index)
|
||
selected.append(index + 1)
|
||
return selected
|
||
|
||
|
||
def _resolve_template_frame_map(
|
||
source_template_path: str,
|
||
page_types: list[str],
|
||
template_config: dict,
|
||
slide_count: int,
|
||
) -> list[int]:
|
||
configured = template_config.get("frameMap")
|
||
if configured:
|
||
frame_map = [int(value) for value in configured]
|
||
if len(frame_map) != len(page_types):
|
||
raise ValueError("模板 frameMap 页数与当前方案页数不一致")
|
||
if any(value < 1 or value > slide_count for value in frame_map):
|
||
raise ValueError("模板 frameMap 包含无效源页码")
|
||
if len(set(frame_map)) != len(frame_map):
|
||
raise ValueError("模板 frameMap 不允许重复使用同一源页面")
|
||
return frame_map
|
||
|
||
asset_name = os.path.basename(source_template_path).lower()
|
||
builtin = _BUILTIN_FRAME_MAPS.get(asset_name, {}).get(len(page_types))
|
||
if builtin:
|
||
return list(builtin)
|
||
return _auto_frame_map(page_types, template_config, slide_count)
|
||
|
||
|
||
def _remove_shape(shape):
|
||
element = shape._element
|
||
element.getparent().remove(element)
|
||
|
||
|
||
def _group_contains_picture(shape) -> bool:
|
||
for child in shape.shapes:
|
||
if child.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
||
return True
|
||
if child.shape_type == MSO_SHAPE_TYPE.GROUP and _group_contains_picture(child):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _sanitize_template_shape(shape, slide_width: int, slide_height: int):
|
||
"""移除示例数据,保留背景、图片和形状几何作为模板底稿。"""
|
||
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
||
if not _group_contains_picture(shape):
|
||
_remove_shape(shape)
|
||
return
|
||
for child in list(shape.shapes):
|
||
_sanitize_template_shape(child, slide_width, slide_height)
|
||
return
|
||
if getattr(shape, "has_chart", False) or getattr(shape, "has_table", False):
|
||
_remove_shape(shape)
|
||
return
|
||
if getattr(shape, "has_text_frame", False) and shape.text.strip():
|
||
_remove_shape(shape)
|
||
return
|
||
if shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE:
|
||
is_edge_or_background = (
|
||
shape.width >= slide_width * 0.9
|
||
or shape.height >= slide_height * 0.9
|
||
or shape.width <= slide_width * 0.02
|
||
or shape.height <= slide_height * 0.02
|
||
)
|
||
if not is_edge_or_background:
|
||
_remove_shape(shape)
|
||
|
||
|
||
def _prepare_template_frames(prs, frame_map: list[int]):
|
||
"""保留并重排选中的源页面,随后让现有 builder 原位写入真实数据。"""
|
||
slide_ids = list(prs.slides._sldIdLst)
|
||
selected_ids = [slide_ids[index - 1] for index in frame_map]
|
||
selected_identity = {id(item) for item in selected_ids}
|
||
|
||
for slide_id in list(slide_ids):
|
||
if id(slide_id) in selected_identity:
|
||
continue
|
||
prs.part.drop_rel(slide_id.rId)
|
||
prs.slides._sldIdLst.remove(slide_id)
|
||
|
||
for slide_id in selected_ids:
|
||
prs.slides._sldIdLst.remove(slide_id)
|
||
prs.slides._sldIdLst.append(slide_id)
|
||
|
||
slides = list(prs.slides)
|
||
for slide in slides:
|
||
for shape in list(slide.shapes):
|
||
_sanitize_template_shape(shape, prs.slide_width, prs.slide_height)
|
||
prs._insurance_template_slide_queue = slides
|
||
|
||
|
||
def _resolve_page_types(deck):
|
||
"""从 DeckContract 中解析要渲染的页面类型列表。"""
|
||
scenario_slides = deck.get("scenarioSlides", [])
|
||
if scenario_slides:
|
||
return [slide["pageType"] for slide in scenario_slides]
|
||
tc = deck.get("templateConfig", {})
|
||
# 优先从 slidesConfig(管理员配置的逐页数据)中提取顺序
|
||
slides_cfg = tc.get("slidesConfig", [])
|
||
if slides_cfg and isinstance(slides_cfg, list) and len(slides_cfg) > 0:
|
||
if isinstance(slides_cfg[0], dict) and "pageType" in slides_cfg[0]:
|
||
return [s["pageType"] for s in slides_cfg]
|
||
# 其次从 requiredPageTypes(JSON seed 数据)中读取
|
||
page_types = tc.get("requiredPageTypes", [])
|
||
if page_types:
|
||
return page_types
|
||
# 无配置时根据产品类型生成默认序列
|
||
products = deck.get("products", [])
|
||
kind = products[0].get("kind", "savings") if products else "savings"
|
||
return DEFAULT_PAGE_TYPES.get(kind, DEFAULT_PAGE_TYPES["savings"])
|
||
|
||
|
||
# ─── 主渲染函数 ──────────────────────────────────────────────
|
||
|
||
def render_deck(deck: dict, output_path: str, theme: str = "deepblue") -> dict:
|
||
"""渲染 DeckContract 为 PPTX。"""
|
||
template_config = deck.get("templateConfig", {})
|
||
slides_config = deck.get("scenarioSlides") or template_config.get("slidesConfig", [])
|
||
page_types = _resolve_page_types(deck)
|
||
source_template_path = (
|
||
template_config.get("sourceTemplatePath")
|
||
)
|
||
renderer_mode = "generic-builder-v1"
|
||
if source_template_path and os.path.isfile(source_template_path):
|
||
prs = Presentation(source_template_path)
|
||
frame_map = _resolve_template_frame_map(
|
||
source_template_path,
|
||
page_types,
|
||
template_config,
|
||
len(prs.slides),
|
||
)
|
||
_prepare_template_frames(prs, frame_map)
|
||
renderer_mode = "clone-edit-v2"
|
||
asset_name = os.path.basename(source_template_path).lower()
|
||
theme = template_config.get("renderTheme") or _TEMPLATE_THEME_BY_ASSET.get(
|
||
asset_name, theme
|
||
)
|
||
else:
|
||
prs = Presentation()
|
||
prs.slide_width = Inches(13.33)
|
||
prs.slide_height = Inches(7.5)
|
||
colors = THEMES.get(theme, THEMES["deepblue"])
|
||
|
||
slide_errors = []
|
||
for i, page_type in enumerate(page_types):
|
||
builder = SLIDE_BUILDERS.get(page_type)
|
||
if not builder:
|
||
continue
|
||
meta = _slide_meta(slides_config, i, page_type)
|
||
try:
|
||
builder(prs, deck, colors, meta)
|
||
except Exception as e:
|
||
print(f"[warn] slide {i} ({page_type}) failed: {e}", file=sys.stderr)
|
||
slide_errors.append(f"{page_type}: {e}")
|
||
|
||
if deck.get("scenarioSlides") and slide_errors:
|
||
raise RuntimeError(
|
||
"required scenario slides failed: " + "; ".join(slide_errors)
|
||
)
|
||
|
||
prs.save(output_path)
|
||
file_size = os.path.getsize(output_path)
|
||
slide_count = len(prs.slides)
|
||
|
||
return {
|
||
"ok": True,
|
||
"path": output_path,
|
||
"size": file_size,
|
||
"slides": slide_count,
|
||
"rendererMode": renderer_mode,
|
||
"templateFrameMap": frame_map if renderer_mode == "clone-edit-v2" else [],
|
||
}
|
||
|
||
|
||
# ─── CLI 入口 ────────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Fast PPTX Renderer")
|
||
parser.add_argument("--deck-json", required=True, help="DeckContract JSON 文件路径")
|
||
parser.add_argument("--output", required=True, help="输出 PPTX 文件路径")
|
||
parser.add_argument("--theme", default="deepblue",
|
||
choices=["deepblue", "caramel", "chinese", "business", "minimal", "ink", "sage"],
|
||
help="主题配色")
|
||
args = parser.parse_args()
|
||
|
||
try:
|
||
with open(args.deck_json, "r", encoding="utf-8") as f:
|
||
deck = json.load(f)
|
||
result = render_deck(deck, args.output, args.theme)
|
||
print(json.dumps(result, ensure_ascii=False))
|
||
except Exception as e:
|
||
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|