baodan/api/insurance/ppt/renderer.py
wsb1224 1debd7df39 feat(ppt): upgrade renderer from 5 fixed slides to 10+ configurable pages
Rewrite fast_pptx_renderer.py to read requiredPageTypes from template
config instead of hardcoding 5 slides. Add 10 slide builder functions
(cover, company, narrative, chart, timeline, table, compare, synergy,
conclusion, closing) with python-pptx native charts.

Key changes:
- Renderer reads templateConfig.requiredPageTypes and slidesConfig
  from DeckContract to determine slide sequence and per-slide metadata
- routes.py loads PptTemplate and PptCompany from DB, normalizes all
  PDF extractions (not just the first), passes full context to renderer
- renderer.py injects templateConfig, company info, and multi-product
  data into DeckContract
- Add slides_config_json column to PptTemplate (migrate_017) for
  per-slide title/narrative/chartType configuration via admin UI
- Admin template editor now supports drag-reorder slides, per-slide
  title/narrative hint, chart/table type selection
- Add requiredPageTypes to savings/ink, savings/minimal, savings/business
  templates (were missing, causing fallback to defaults)
- Fix IUL normalizer: add payYears and totalPremium to policy dict
- Fix CI/IUL normalizer: add totalSurrenderValue alias to benefitRows
  so charts render correctly for all product types
- Port calculation functions from baodanppt: decade_rows, paid_premium,
  simple_return, compound_return, find_payback_year

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:49:09 +08:00

408 lines
17 KiB
Python
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.

"""PPT 渲染服务 — 调用 python-pptx / insurance-deck 生成 PPT。"""
import os
import json
import uuid
import subprocess
import logging
from typing import Optional
logger = logging.getLogger(__name__)
# 默认 Python 路径
DEFAULT_PYTHON = "python"
def _resolve_python() -> str:
"""查找可用的 Python 解释器。
优先使用虚拟环境中的 Python然后是系统 Python。
"""
import shutil
import sys
# 优先使用当前运行的 Python 解释器
current_python = sys.executable
if current_python and os.path.exists(current_python):
return current_python
# 查找虚拟环境中的 Python
venv_python = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".venv", "bin", "python")
if os.path.exists(venv_python):
return venv_python
# 查找系统 Python
for candidate in ["python3.12", "python3.11", "python3", "python"]:
path = shutil.which(candidate)
if path:
return path
return DEFAULT_PYTHON
def _build_deck_contract(
normalized_data: dict,
company_info: dict = None,
theme: str = "broker",
template_config: dict = None,
all_products: list = None,
) -> dict:
"""将归一化数据转换为 DeckContract 格式。"""
theme_map = {
"broker": "broker",
"modern": "modern",
"fresh": "fresh",
"warm": "warm",
"deepblue": "broker",
"caramel": "caramel",
"chinese": "chinese",
}
def _extract_product(data):
return {
"kind": data.get("kind", "savings"),
"productName": data.get("productName", ""),
"rawProductName": data.get("rawProductName", ""),
"insured": data.get("insured", {}),
"policy": data.get("policy", {}),
"benefitRows": data.get("benefitRows", []),
"withdrawalRows": data.get("withdrawalRows", []),
"withdrawalProvenance": data.get("withdrawalProvenance", "missing"),
}
products = [_extract_product(normalized_data)]
if all_products:
for extra in all_products:
if extra is not normalized_data:
products.append(_extract_product(extra))
# 公司信息(扩展:传递 companyIntro, companyHighlights 等用于公司介绍页)
company = {
"id": "",
"displayName": "",
"shortEn": "",
"evidence": [],
"companyIntro": "",
"companyHighlights": [],
"rating": "",
"logoUrl": "",
}
if company_info:
company.update(company_info)
deck = {
"id": f"deck_{uuid.uuid4().hex[:12]}",
"generatedAt": __import__("datetime").datetime.now().isoformat(),
"customer": {
"name": normalized_data.get("insured", {}).get("name", "客户"),
"age": normalized_data.get("insured", {}).get("age"),
"gender": normalized_data.get("insured", {}).get("gender", ""),
},
"tenantId": "default",
"stylePreset": theme_map.get(theme, "broker"),
"quality": "standard",
"outputFormat": "pptx",
"outputStem": "insurance_plan",
"products": products,
"company": company,
"templateConfig": template_config or {},
"meta": normalized_data.get("source", {
"pdfHash": "",
"pdfPath": "",
"parser": "llm-json",
"extractedAt": __import__("datetime").datetime.now().isoformat(),
}),
"fidelity": {
"passed": True,
"issueCount": 0,
"errors": 0,
"warnings": 0,
},
}
return deck
class PptRenderer:
"""PPT 渲染器。"""
def __init__(self, work_dir: str = None):
self.work_dir = work_dir or os.path.join(os.getcwd(), "ppt_work")
os.makedirs(self.work_dir, exist_ok=True)
def render_enhanced(
self,
normalized_data: dict,
output_path: str,
theme: str = "broker",
company_id: str = None,
company_info: dict = None,
template_config: dict = None,
all_products: list = None,
) -> dict:
"""增强渲染(使用 fast_pptx_renderer.py
Args:
normalized_data: 归一化后的计划数据
output_path: 输出 PPTX 路径
theme: 视觉主题
company_id: 公司 ID
company_info: 公司信息 dict
Returns:
{"ok": bool, "path": str, "slideCount": int}
"""
try:
# 准备工作目录
session_dir = os.path.join(self.work_dir, uuid.uuid4().hex[:8])
os.makedirs(session_dir, exist_ok=True)
# 转换为 DeckContract 格式
deck = _build_deck_contract(normalized_data, company_info, theme,
template_config, all_products)
# 写入 DeckContract JSON
deck_path = os.path.join(session_dir, "deck.json")
with open(deck_path, "w", encoding="utf-8") as f:
json.dump(deck, f, ensure_ascii=False, indent=2)
# 查找渲染脚本
python = _resolve_python()
script_dir = os.path.join(os.path.dirname(__file__), "scripts")
render_script = os.path.join(script_dir, "fast_pptx_renderer.py")
if os.path.exists(render_script):
# 映射主题到渲染脚本支持的主题
theme_map = {
"broker": "deepblue",
"modern": "deepblue",
"fresh": "deepblue",
"warm": "caramel",
"deepblue": "deepblue",
"caramel": "caramel",
"chinese": "chinese",
}
script_theme = theme_map.get(theme, "deepblue")
# 调用 fast_pptx_renderer.py
cmd = [
python, render_script,
"--deck-json", deck_path,
"--output", output_path,
"--theme", script_theme,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
# 解析脚本输出
try:
script_result = json.loads(result.stdout.strip())
if script_result.get("ok"):
return {
"ok": True,
"path": output_path,
"slideCount": script_result.get("slides", 0),
}
else:
return {
"ok": False,
"path": "",
"slideCount": 0,
"error": script_result.get("error", "渲染脚本返回失败"),
}
except json.JSONDecodeError:
# 脚本输出不是 JSON尝试统计幻灯片数
slide_count = self._count_slides(output_path)
return {"ok": True, "path": output_path, "slideCount": slide_count}
else:
logger.error(f"渲染脚本失败: {result.stderr}")
return {"ok": False, "path": "", "slideCount": 0, "error": result.stderr}
else:
# 回退到基础渲染
logger.warning(f"渲染脚本不存在: {render_script},使用基础渲染")
return self._render_basic(normalized_data, output_path, theme)
except Exception as e:
logger.error(f"渲染失败: {e}")
return {"ok": False, "path": "", "slideCount": 0, "error": str(e)}
def _render_basic(self, data: dict, output_path: str, theme: str) -> dict:
"""基础 python-pptx 渲染(无 insurance-deck 时的回退)。"""
try:
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
# 主题配色
themes = {
"broker": {"primary": "0A3C5F", "accent": "18898D", "gold": "C9A027"},
"modern": {"primary": "1A1A2E", "accent": "1A73E8", "gold": "FFD700"},
"fresh": {"primary": "2E7D32", "accent": "4CAF50", "gold": "8BC34A"},
"warm": {"primary": "5D4037", "accent": "FF9800", "gold": "FFC107"},
}
colors = themes.get(theme, themes["broker"])
def hex_to_rgb(hex_color: str) -> RGBColor:
h = hex_color.lstrip("#")
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def add_text_box(slide, left, top, width, height, text, font_size=18, bold=False, color="FFFFFF", align=PP_ALIGN.LEFT):
txBox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = text
p.font.size = Pt(font_size)
p.font.bold = bold
p.font.color.rgb = hex_to_rgb(color)
p.alignment = align
return txBox
# ─── 封面页 ─────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6]) # 空白布局
# 背景色
background = slide.background
fill = background.fill
fill.solid()
fill.fore_color.rgb = hex_to_rgb(colors["primary"])
product_name = data.get("productName", "保险计划书")
insured = data.get("insured", {})
customer_name = insured.get("name", "客户")
add_text_box(slide, 0.8, 1.5, 11.7, 1.0, customer_name, font_size=42, bold=True)
add_text_box(slide, 0.8, 2.5, 11.7, 0.6, "财富增值与传承方案", font_size=24, color="90CAF9")
add_text_box(slide, 0.8, 3.5, 11.7, 0.5, product_name, font_size=16, color="8899AA")
# 关键指标卡片
policy = data.get("policy", {})
annual_premium = policy.get("annualPremium", 0)
pay_years = policy.get("payYears", 0)
benefit_rows = data.get("benefitRows", [])
total_premium = annual_premium * pay_years
multiple = "-"
final_value = 0
if benefit_rows:
last_row = benefit_rows[-1]
final_value = last_row.get("totalSurrenderValue", 0)
if total_premium > 0:
multiple = f"{final_value / total_premium:.1f}x"
metrics = [
("年缴保费", f"${annual_premium:,.0f}"),
("缴费年期", f"{pay_years}"),
("总投入", f"${total_premium:,.0f}"),
("期末倍数", multiple),
]
for i, (label, value) in enumerate(metrics):
x = 0.8 + i * 3.0
add_text_box(slide, x, 4.5, 2.5, 0.4, label, font_size=12, color="8899AA")
add_text_box(slide, x, 4.9, 2.5, 0.5, value, font_size=24, bold=True, color=colors["accent"])
# ─── 数据总览页 ─────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
background = slide.background
fill = background.fill
fill.solid()
fill.fore_color.rgb = hex_to_rgb("FFFFFF")
add_text_box(slide, 0.5, 0.3, 12.3, 0.6, "保单数据总览", font_size=28, bold=True, color=colors["primary"])
# 基本信息表
info_items = [
("产品名称", product_name),
("受保人", f"{customer_name} ({insured.get('age', '')}岁)"),
("保单货币", policy.get("currency", "USD")),
("年缴保费", f"${annual_premium:,.0f}"),
("缴费年期", f"{pay_years}"),
("保障期间", policy.get("coveragePeriod", "")),
]
for i, (label, value) in enumerate(info_items):
y = 1.2 + i * 0.5
add_text_box(slide, 1.0, y, 3.0, 0.4, label, font_size=14, color="666666")
add_text_box(slide, 4.0, y, 8.0, 0.4, str(value), font_size=14, bold=True, color="333333")
# ─── 利益演示页 ─────────────────────────────────
if benefit_rows:
slide = prs.slides.add_slide(prs.slide_layouts[6])
background = slide.background
fill = background.fill
fill.solid()
fill.fore_color.rgb = hex_to_rgb("FFFFFF")
add_text_box(slide, 0.5, 0.3, 12.3, 0.6, "逐年利益演示", font_size=28, bold=True, color=colors["primary"])
# 表头
headers = ["保单年度", "年龄", "已缴保费", "保证现金价值", "归原红利", "终期分红", "退保发还总额"]
col_widths = [1.5, 1.2, 1.8, 2.0, 1.8, 1.8, 2.2]
x = 0.5
for header, width in zip(headers, col_widths):
add_text_box(slide, x, 1.1, width, 0.4, header, font_size=11, bold=True, color=colors["primary"])
x += width
# 数据行最多显示20行
display_rows = benefit_rows[:20]
for row_idx, row in enumerate(display_rows):
y = 1.5 + row_idx * 0.35
values = [
str(row.get("policyYear", "")),
str(row.get("age", "")),
f"${row.get('totalPremiumPaid', 0):,.0f}",
f"${row.get('guaranteedCashValue', 0):,.0f}",
f"${row.get('reversionaryBonus', 0):,.0f}",
f"${row.get('terminalDividend', 0):,.0f}",
f"${row.get('totalSurrenderValue', 0):,.0f}",
]
x = 0.5
for value, width in zip(values, col_widths):
add_text_box(slide, x, y, width, 0.3, value, font_size=10, color="333333")
x += width
# ─── 总结页 ─────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
background = slide.background
fill = background.fill
fill.solid()
fill.fore_color.rgb = hex_to_rgb(colors["primary"])
add_text_box(slide, 0.8, 2.0, 11.7, 1.0, "方案总结", font_size=36, bold=True, align=PP_ALIGN.CENTER)
add_text_box(slide, 0.8, 3.2, 11.7, 0.6, f"总投入 ${total_premium:,.0f} → 期末价值 ${final_value:,.0f}", font_size=24, color="90CAF9", align=PP_ALIGN.CENTER)
add_text_box(slide, 0.8, 4.0, 11.7, 0.5, f"倍数增长 {multiple}", font_size=28, bold=True, color=colors["gold"], align=PP_ALIGN.CENTER)
prs.save(output_path)
slide_count = len(prs.slides)
return {"ok": True, "path": output_path, "slideCount": slide_count}
except ImportError:
return {"ok": False, "path": "", "slideCount": 0, "error": "python-pptx 未安装"}
def _count_slides(self, pptx_path: str) -> int:
"""统计 PPTX 幻灯片数量。"""
try:
from pptx import Presentation
prs = Presentation(pptx_path)
return len(prs.slides)
except Exception:
return 0
def generate_previews(self, pptx_path: str, output_dir: str) -> dict:
"""生成幻灯片预览图(需要 LibreOffice"""
os.makedirs(output_dir, exist_ok=True)
try:
soffice = os.environ.get("SOFFICE_BIN", "/usr/bin/soffice")
cmd = [
soffice, "--headless", "--convert-to", "pdf",
"--outdir", output_dir, pptx_path,
]
subprocess.run(cmd, capture_output=True, timeout=60)
# 预览图生成需要额外的 PDF→PNG 转换
return {"ok": True, "previewPaths": []}
except Exception as e:
logger.warning(f"预览生成失败: {e}")
return {"ok": False, "previewPaths": [], "error": str(e)}