256 lines
9.8 KiB
Python
256 lines
9.8 KiB
Python
"""方案导出服务:生成 PDF/Word 文件。"""
|
||
import io
|
||
import json
|
||
from insurance.db.compat import db
|
||
from insurance.models.recommendation import RecommendationRecord
|
||
|
||
DISCLAIMER = (
|
||
"\n\n免责声明:以上方案仅供参考,具体保障内容以保险合同条款为准。"
|
||
"投保前请仔细阅读产品条款,了解保险责任、责任免除、等待期等重要内容。"
|
||
)
|
||
|
||
|
||
def export_word(proposal_id: str):
|
||
"""导出方案为 Word 文档。"""
|
||
from docx import Document
|
||
from docx.shared import Pt, Cm
|
||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||
|
||
record = db.session.query(RecommendationRecord).filter_by(id=int(proposal_id)).first()
|
||
if not record:
|
||
return None, "方案不存在"
|
||
|
||
plans = json.loads(record.plan_variants) if record.plan_variants else []
|
||
|
||
# 如果结构化解析为空但有 Markdown 原文,直接渲染 Markdown
|
||
if not plans and record.generated_plan:
|
||
return _export_word_from_markdown(record)
|
||
|
||
doc = Document()
|
||
|
||
# 标题
|
||
title = doc.add_heading("保险产品推荐方案", level=0)
|
||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
|
||
# 客户信息
|
||
doc.add_heading("客户信息", level=1)
|
||
info_table = doc.add_table(rows=4, cols=4, style="Table Grid")
|
||
info_data = [
|
||
("姓名", record.customer_name or "-", "年龄", f"{record.customer_age}岁"),
|
||
("性别", "男" if record.customer_gender == "male" else "女", "职业", record.occupation or "-"),
|
||
("健康状况", record.health_status or "-", "年收入", f"{record.annual_income}万"),
|
||
("月预算", f"{record.monthly_budget}元", "保障期限", record.coverage_period or "-"),
|
||
]
|
||
for i, (k1, v1, k2, v2) in enumerate(info_data):
|
||
info_table.rows[i].cells[0].text = k1
|
||
info_table.rows[i].cells[1].text = v1
|
||
info_table.rows[i].cells[2].text = k2
|
||
info_table.rows[i].cells[3].text = v2
|
||
|
||
# 各方案
|
||
for plan in plans:
|
||
doc.add_heading(plan.get("name", "方案"), level=1)
|
||
premium = plan.get("total_premium", "")
|
||
doc.add_paragraph(f"年保费合计:{premium}元" if premium else "年保费合计:-")
|
||
|
||
items = plan.get("items", [])
|
||
if items:
|
||
table = doc.add_table(rows=1 + len(items), cols=4, style="Table Grid")
|
||
headers = ["产品名称", "保额", "年保费", "推荐理由"]
|
||
for j, h in enumerate(headers):
|
||
table.rows[0].cells[j].text = h
|
||
for run in table.rows[0].cells[j].paragraphs[0].runs:
|
||
run.bold = True
|
||
|
||
for k, item in enumerate(items):
|
||
table.rows[k + 1].cells[0].text = item.get("product_name", "")
|
||
table.rows[k + 1].cells[1].text = item.get("coverage", "")
|
||
table.rows[k + 1].cells[2].text = item.get("premium", "")
|
||
table.rows[k + 1].cells[3].text = item.get("reason", "")
|
||
|
||
summary = plan.get("summary", "")
|
||
if summary:
|
||
doc.add_paragraph(summary)
|
||
|
||
# 免责声明
|
||
doc.add_paragraph(DISCLAIMER)
|
||
|
||
buf = io.BytesIO()
|
||
doc.save(buf)
|
||
buf.seek(0)
|
||
return buf, None
|
||
|
||
|
||
def export_pdf(proposal_id: str):
|
||
"""导出方案为 PDF(通过 HTML 转换)。"""
|
||
record = db.session.query(RecommendationRecord).filter_by(id=int(proposal_id)).first()
|
||
if not record:
|
||
return None, "方案不存在"
|
||
|
||
plans = json.loads(record.plan_variants) if record.plan_variants else []
|
||
|
||
# 如果结构化解析为空但有 Markdown 原文,直接渲染 Markdown
|
||
if not plans and record.generated_plan:
|
||
return _export_pdf_from_markdown(record)
|
||
|
||
# 构建 HTML
|
||
html_parts = [
|
||
"<!DOCTYPE html><html><head><meta charset='utf-8'>",
|
||
"<style>",
|
||
"body{font-family:sans-serif;padding:20px}",
|
||
"table{width:100%;border-collapse:collapse;margin:10px 0}",
|
||
"th,td{border:1px solid #ddd;padding:8px;text-align:left}",
|
||
"th{background:#f5f5f5}",
|
||
".disclaimer{color:#999;font-size:12px;margin-top:20px}",
|
||
"</style></head><body>",
|
||
"<h1 style='text-align:center'>保险产品推荐方案</h1>",
|
||
"<h2>客户信息</h2>",
|
||
f"<p>姓名:{record.customer_name or '-'} | 年龄:{record.customer_age}岁 | "
|
||
f"性别:{'男' if record.customer_gender == 'male' else '女'} | "
|
||
f"职业:{record.occupation or '-'}</p>",
|
||
f"<p>健康状况:{record.health_status or '-'} | 年收入:{record.annual_income}万 | "
|
||
f"月预算:{record.monthly_budget}元 | 保障期限:{record.coverage_period or '-'}</p>",
|
||
]
|
||
|
||
for plan in plans:
|
||
html_parts.append(f"<h2>{plan.get('name', '方案')}</h2>")
|
||
premium = plan.get("total_premium", "")
|
||
html_parts.append(f"<p><strong>年保费合计:{premium}元</strong></p>" if premium else "<p><strong>年保费合计:-</strong></p>")
|
||
items = plan.get("items", [])
|
||
if items:
|
||
html_parts.append("<table><thead><tr><th>产品</th><th>保额</th><th>年保费</th><th>推荐理由</th></tr></thead><tbody>")
|
||
for item in items:
|
||
html_parts.append(
|
||
f"<tr><td>{item.get('product_name', '')}</td>"
|
||
f"<td>{item.get('coverage', '')}</td>"
|
||
f"<td>{item.get('premium', '')}</td>"
|
||
f"<td>{item.get('reason', '')}</td></tr>"
|
||
)
|
||
html_parts.append("</tbody></table>")
|
||
summary = plan.get("summary", "")
|
||
if summary:
|
||
html_parts.append(f"<p>{summary}</p>")
|
||
|
||
html_parts.append(f"<p class='disclaimer'>{DISCLAIMER}</p>")
|
||
html_parts.append("</body></html>")
|
||
|
||
html_content = "\n".join(html_parts)
|
||
|
||
# 尝试用 weasyprint 生成 PDF
|
||
try:
|
||
from weasyprint import HTML
|
||
buf = io.BytesIO()
|
||
HTML(string=html_content).write_pdf(buf)
|
||
buf.seek(0)
|
||
return buf, None
|
||
except ImportError:
|
||
pass
|
||
|
||
# 回退:返回 HTML 文件
|
||
buf = io.BytesIO(html_content.encode("utf-8"))
|
||
return buf, None
|
||
|
||
|
||
def _export_word_from_markdown(record) -> tuple:
|
||
"""当结构化解析为空时,直接将 Markdown 渲染为 Word。"""
|
||
from docx import Document
|
||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||
import re
|
||
|
||
doc = Document()
|
||
title = doc.add_heading("保险产品推荐方案", level=0)
|
||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
|
||
# 客户信息
|
||
doc.add_heading("客户信息", level=1)
|
||
doc.add_paragraph(
|
||
f"姓名:{record.customer_name or '-'} | 年龄:{record.customer_age}岁 | "
|
||
f"性别:{'男' if record.customer_gender == 'male' else '女'} | "
|
||
f"职业:{record.occupation or '-'}"
|
||
)
|
||
|
||
# 将 Markdown 逐段写入 Word
|
||
md_text = record.generated_plan or ""
|
||
for line in md_text.split("\n"):
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
if line.startswith("### "):
|
||
doc.add_heading(line[4:], level=2)
|
||
elif line.startswith("## "):
|
||
doc.add_heading(line[3:], level=1)
|
||
elif line.startswith("# "):
|
||
doc.add_heading(line[2:], level=0)
|
||
elif line.startswith("|") and "---" not in line:
|
||
# 表格行 → 用段落模拟
|
||
cells = [c.strip() for c in line.split("|") if c.strip()]
|
||
doc.add_paragraph(" | ".join(cells))
|
||
else:
|
||
doc.add_paragraph(line)
|
||
|
||
doc.add_paragraph(DISCLAIMER)
|
||
|
||
buf = io.BytesIO()
|
||
doc.save(buf)
|
||
buf.seek(0)
|
||
return buf, None
|
||
|
||
|
||
def _export_pdf_from_markdown(record) -> tuple:
|
||
"""当结构化解析为空时,直接将 Markdown 渲染为 PDF/HTML。"""
|
||
import re
|
||
|
||
md_text = record.generated_plan or ""
|
||
|
||
# 简单 Markdown → HTML 转换
|
||
html_body = md_text
|
||
html_body = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html_body, flags=re.MULTILINE)
|
||
html_body = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html_body, flags=re.MULTILINE)
|
||
html_body = re.sub(r'^# (.+)$', r'<h1>\1</h1>', html_body, flags=re.MULTILINE)
|
||
html_body = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html_body)
|
||
# 表格行转 HTML 表格(简化处理)
|
||
table_lines = re.findall(r'^(\|.+\|)$', html_body, re.MULTILINE)
|
||
if table_lines:
|
||
table_html = "<table>"
|
||
for i, line in enumerate(table_lines):
|
||
cells = [c.strip() for c in line.split("|") if c.strip()]
|
||
tag = "th" if i == 0 else "td"
|
||
row = "".join(f"<{tag}>{c}</{tag}>" for c in cells)
|
||
table_html += f"<tr>{row}</tr>"
|
||
table_html += "</table>"
|
||
html_body = re.sub(r'^(\|.+\|)$', '', html_body, flags=re.MULTILINE)
|
||
html_body += table_html
|
||
html_body = re.sub(r'\n{2,}', '</p><p>', html_body)
|
||
html_body = f"<p>{html_body}</p>"
|
||
|
||
html_content = (
|
||
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
||
"<style>"
|
||
"body{font-family:sans-serif;padding:20px}"
|
||
"table{width:100%;border-collapse:collapse;margin:10px 0}"
|
||
"th,td{border:1px solid #ddd;padding:8px;text-align:left}"
|
||
"th{background:#f5f5f5}"
|
||
".disclaimer{color:#999;font-size:12px;margin-top:20px}"
|
||
"</style></head><body>"
|
||
f"<h1 style='text-align:center'>保险产品推荐方案</h1>"
|
||
f"<h2>客户信息</h2>"
|
||
f"<p>姓名:{record.customer_name or '-'} | 年龄:{record.customer_age}岁 | "
|
||
f"性别:{'男' if record.customer_gender == 'male' else '女'} | "
|
||
f"职业:{record.occupation or '-'}</p>"
|
||
f"{html_body}"
|
||
f"<p class='disclaimer'>{DISCLAIMER}</p>"
|
||
"</body></html>"
|
||
)
|
||
|
||
try:
|
||
from weasyprint import HTML
|
||
buf = io.BytesIO()
|
||
HTML(string=html_content).write_pdf(buf)
|
||
buf.seek(0)
|
||
return buf, None
|
||
except ImportError:
|
||
pass
|
||
|
||
buf = io.BytesIO(html_content.encode("utf-8"))
|
||
return buf, None
|