512 lines
22 KiB
Python
512 lines
22 KiB
Python
"""PDF rendering for the prototype-aligned risk report."""
|
||
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
|
||
from docx import Document
|
||
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||
from docx.oxml import OxmlElement
|
||
from docx.shared import Pt, RGBColor
|
||
from reportlab.lib import colors
|
||
from reportlab.lib.enums import TA_CENTER
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||
from reportlab.lib.units import mm
|
||
from reportlab.pdfbase import pdfmetrics
|
||
from reportlab.pdfbase.ttfonts import TTFont
|
||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle
|
||
|
||
from app.schemas.risk_prediction import ReportCard, RiskAssessmentResult
|
||
|
||
LEVEL_LABELS = {
|
||
"low": "低风险",
|
||
"moderately_low": "较低风险",
|
||
"medium": "中等风险",
|
||
"high": "高风险",
|
||
"very_high": "极高风险",
|
||
}
|
||
|
||
|
||
def effective_report_cards(result: RiskAssessmentResult) -> list[ReportCard]:
|
||
"""Return the single canonical collection used by every report renderer.
|
||
|
||
``ai_solution_cards`` predates the generic card report feature. It is
|
||
accepted as a read-only compatibility input when a client sends an old
|
||
report payload, while new payloads always use ``report_cards``.
|
||
"""
|
||
|
||
if result.report_cards:
|
||
return list(result.report_cards)
|
||
return [
|
||
ReportCard(
|
||
type="solution",
|
||
title=item.title,
|
||
confidence=item.confidence,
|
||
analysis_base=item.analysis_base,
|
||
standard_fallback_enabled=item.standard_fallback_enabled,
|
||
standards=item.standards,
|
||
similar_cases=item.similar_cases,
|
||
risks=item.risks,
|
||
suggestions=item.suggestions,
|
||
warning=item.warning,
|
||
)
|
||
for item in result.ai_solution_cards
|
||
]
|
||
|
||
|
||
def report_card_lines(card: ReportCard) -> list[tuple[str, str]]:
|
||
"""Flatten a card into ordered, renderer-neutral report lines.
|
||
|
||
PDF, Word and the web page all consume this same field contract. Keeping
|
||
formatting here prevents one export from silently dropping appended card
|
||
details that are visible in another format.
|
||
"""
|
||
|
||
lines: list[tuple[str, str]] = [("卡片类型", card.type)]
|
||
subject = card.name or card.material or card.category
|
||
if subject:
|
||
lines.append(("分析对象", subject))
|
||
if card.party_type:
|
||
lines.append(("对象类型", card.party_type))
|
||
if card.question:
|
||
lines.append(("原始问题", card.question))
|
||
if card.anomaly_count is not None:
|
||
lines.append(("历史异常", f"{card.anomaly_count} 条"))
|
||
if card.cooperation_count is not None:
|
||
lines.append(("合作次数", f"{card.cooperation_count} 次"))
|
||
if card.frequency_coefficient is not None:
|
||
lines.append(("频次系数", f"×{card.frequency_coefficient:.2f}"))
|
||
if card.supplier_base_score is not None:
|
||
lines.append(("供应商基础分", f"{card.supplier_base_score:.2f} 分"))
|
||
if card.supplier_score is not None:
|
||
lines.append(("供应商得分", f"{card.supplier_score:.2f} 分"))
|
||
if card.tier:
|
||
lines.append(("供应商层级", card.tier))
|
||
if card.matched_count is not None:
|
||
lines.append(("匹配数量", f"{card.matched_count} 条"))
|
||
if card.frequency_basis:
|
||
lines.append(("数据口径", card.frequency_basis))
|
||
if card.data_available is not None:
|
||
lines.append(("数据状态", "有匹配数据" if card.data_available else "暂无匹配数据"))
|
||
if card.note:
|
||
lines.append(("备注", card.note))
|
||
if card.detected_values:
|
||
values = "、".join(f"{item.get('name', '')}:{item.get('value', '')}" for item in card.detected_values)
|
||
lines.append(("检测值", values))
|
||
if card.confidence:
|
||
lines.append(("置信度", card.confidence))
|
||
if card.analysis_base:
|
||
lines.append(("分析基础", card.analysis_base))
|
||
if card.standard_fallback_enabled:
|
||
lines.append(("标准兜底校验", "已启用(无直接历史案例)"))
|
||
if card.top_problems:
|
||
lines.append(
|
||
("高频问题", "、".join(f"{item.get('name', '')}({item.get('count', 0)}次)" for item in card.top_problems))
|
||
)
|
||
if card.frequent_problems:
|
||
lines.append(
|
||
(
|
||
"常见问题",
|
||
"、".join(f"{item.get('name', '')}({item.get('count', 0)}次)" for item in card.frequent_problems),
|
||
)
|
||
)
|
||
if card.standards:
|
||
lines.append(
|
||
(
|
||
"关联标准",
|
||
";".join(
|
||
f"{item.category} · {item.risk_point}:{item.rule}"
|
||
f"({item.source or '标准文件.xlsx'})· {item.status}"
|
||
for item in card.standards
|
||
),
|
||
)
|
||
)
|
||
if card.similar_cases:
|
||
lines.append(
|
||
(
|
||
"相似案例",
|
||
";".join(
|
||
f"{item.date or '日期未知'} · {item.product or '未命名产品'} · "
|
||
f"{item.problem or item.description or '异常问题未填写'} → {item.solution or '解决方案未填写'}"
|
||
+ (f"(相似度 {round(item.similarity * 100)}%)" if item.similarity is not None else "")
|
||
for item in card.similar_cases
|
||
),
|
||
)
|
||
)
|
||
if card.risks:
|
||
lines.append(("风险项", ";".join(f"{item.level} · {item.title}:{item.detail}" for item in card.risks)))
|
||
if card.suggestions:
|
||
lines.append(("建议方案", ";".join(card.suggestions)))
|
||
if card.warning:
|
||
lines.append(("风险提示", card.warning))
|
||
return lines
|
||
|
||
|
||
def _font_name() -> str:
|
||
candidates = [
|
||
Path("C:/Windows/Fonts/msyh.ttc"),
|
||
Path("C:/Windows/Fonts/msyh.ttf"),
|
||
Path("C:/Windows/Fonts/simhei.ttf"),
|
||
]
|
||
for path in candidates:
|
||
if path.exists():
|
||
name = "DieyaCjk"
|
||
pdfmetrics.registerFont(TTFont(name, str(path), subfontIndex=0))
|
||
return name
|
||
return "Helvetica"
|
||
|
||
|
||
def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
|
||
font = _font_name()
|
||
styles = getSampleStyleSheet()
|
||
title = ParagraphStyle(
|
||
"title", parent=styles["Title"], fontName=font, fontSize=20, alignment=TA_CENTER, spaceAfter=4
|
||
)
|
||
subtitle = ParagraphStyle(
|
||
"subtitle",
|
||
parent=styles["Normal"],
|
||
fontName=font,
|
||
fontSize=9,
|
||
textColor=colors.HexColor("#64748b"),
|
||
alignment=TA_CENTER,
|
||
spaceAfter=14,
|
||
)
|
||
section = ParagraphStyle(
|
||
"section",
|
||
parent=styles["Heading2"],
|
||
fontName=font,
|
||
fontSize=11,
|
||
textColor=colors.HexColor("#111827"),
|
||
leftIndent=5,
|
||
borderPadding=3,
|
||
spaceBefore=12,
|
||
spaceAfter=7,
|
||
)
|
||
body = ParagraphStyle("body", parent=styles["BodyText"], fontName=font, fontSize=9, leading=15)
|
||
small = ParagraphStyle("small", parent=body, fontSize=8, textColor=colors.HexColor("#64748b"))
|
||
supplier_name = result.supplier_freq.supplier_name if result.supplier_freq else None
|
||
report_number = f"RP-{result.subject_id[:8]}-{round(result.score)}"
|
||
generated_at = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
story = [
|
||
Paragraph("新品风险预估报告", title),
|
||
Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle),
|
||
Paragraph(
|
||
f"产品/评估对象:{result.subject_id} 供应商:{supplier_name or '未指定'} 报告编号:{report_number}",
|
||
body,
|
||
),
|
||
]
|
||
|
||
def table(rows: list[list[str]], widths: list[float] | None = None) -> Table:
|
||
converted = [[Paragraph(str(cell), body) for cell in row] for row in rows]
|
||
item = Table(converted, colWidths=widths, repeatRows=1)
|
||
item.setStyle(
|
||
TableStyle(
|
||
[
|
||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#111827")),
|
||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||
("FONTNAME", (0, 0), (-1, -1), font),
|
||
("GRID", (0, 0), (-1, -1), 0.45, colors.HexColor("#dbe3ef")),
|
||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 7),
|
||
("RIGHTPADDING", (0, 0), (-1, -1), 7),
|
||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||
]
|
||
)
|
||
)
|
||
return item
|
||
|
||
story += [Paragraph("一、风险结论", section)]
|
||
rows = [
|
||
["评估项", "结论"],
|
||
[
|
||
"综合评分",
|
||
f"{round(result.score)} / 100 · "
|
||
f"{LEVEL_LABELS.get(result.level.value, result.level.value)} · "
|
||
f"{result.risk_badge or '需关注'}",
|
||
],
|
||
["风险警示", result.alert_text or "当前暂无足够历史数据,建议按标准库完成基础验证。"],
|
||
]
|
||
dimensions = result.dimension_scores or [
|
||
{"dimension": factor.name, "score": 100 - factor.value, "weight": factor.weight, "reason": None}
|
||
for factor in result.factors
|
||
]
|
||
for item in dimensions:
|
||
name = item.dimension if hasattr(item, "dimension") else item["dimension"]
|
||
if name in {"节点阶段", "缺陷等级"}:
|
||
continue
|
||
score = item.score if hasattr(item, "score") else item["score"]
|
||
weight = item.weight if hasattr(item, "weight") else item["weight"]
|
||
reason = item.reason if hasattr(item, "reason") else item.get("reason")
|
||
supplier = result.supplier_freq
|
||
if name == "供应商表现" and supplier:
|
||
order_text = f"合作 {supplier.cooperation_count} 次、" if supplier.cooperation_count is not None else ""
|
||
conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次"
|
||
elif name == "标准符合性" and result.standard_judgment:
|
||
conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认"
|
||
else:
|
||
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
|
||
rows.append([f"{name}({weight:g}%)", conclusion])
|
||
story += [table(rows, [55 * mm, 105 * mm])]
|
||
|
||
if result.category_warning:
|
||
story += [
|
||
Paragraph("二、品类风险预警", section),
|
||
table(
|
||
[
|
||
["品类", "历史异常", "风险等级", "建议"],
|
||
[
|
||
result.category_warning.category,
|
||
f"{result.category_warning.anomaly_count} 条",
|
||
result.category_warning.risk_level,
|
||
result.category_warning.suggestion or "结合相似品类和标准库预判",
|
||
],
|
||
],
|
||
[35 * mm, 30 * mm, 30 * mm, 65 * mm],
|
||
),
|
||
]
|
||
|
||
supplier = result.supplier_freq
|
||
if supplier and supplier.supplier_name:
|
||
supplier_rows = [["维度", "数值", "说明"]]
|
||
if supplier.cooperation_count is not None:
|
||
supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"])
|
||
supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"])
|
||
if supplier.alpha is not None:
|
||
supplier_rows.append(
|
||
["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
|
||
)
|
||
if supplier.tier:
|
||
supplier_rows.append(["供应商层级", supplier.tier, supplier.suggestion or ""])
|
||
story += [Paragraph("三、供应商合作频次分析", section), table(supplier_rows, [38 * mm, 35 * mm, 87 * mm])]
|
||
|
||
story += [Paragraph(f"四、相似案例匹配({len(result.similar_cases)} 条)", section)]
|
||
if result.similar_cases:
|
||
story.append(
|
||
table(
|
||
[["日期", "产品", "问题", "处理方案"]]
|
||
+ [
|
||
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
|
||
for case in result.similar_cases
|
||
],
|
||
[28 * mm, 42 * mm, 48 * mm, 42 * mm],
|
||
)
|
||
)
|
||
else:
|
||
story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small))
|
||
|
||
story += [Paragraph("五、预防建议", section)]
|
||
for index, suggestion in enumerate(
|
||
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
|
||
):
|
||
story.append(Paragraph(f"{index}. {suggestion}", body))
|
||
story += [
|
||
Paragraph("六、AI 推理方案(无历史案例场景)", section),
|
||
Paragraph(
|
||
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。",
|
||
body,
|
||
),
|
||
]
|
||
|
||
story += [Paragraph("七、标准判定结果", section)]
|
||
if result.standard_judgment:
|
||
story.append(
|
||
table(
|
||
[["判定维度", "判定标准", "标准来源", "当前状态"]]
|
||
+ [
|
||
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
|
||
for item in result.standard_judgment
|
||
],
|
||
[38 * mm, 55 * mm, 40 * mm, 27 * mm],
|
||
)
|
||
)
|
||
else:
|
||
story.append(Paragraph("暂无相关标准。", small))
|
||
story.append(Paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。", small))
|
||
|
||
cards = effective_report_cards(result)
|
||
if cards:
|
||
story += [Paragraph("八、追加卡片分析", section)]
|
||
for index, card in enumerate(cards, 1):
|
||
story.append(Paragraph(f"{index}. {card.title}", body))
|
||
story.append(
|
||
table(
|
||
[["字段", "内容"]] + [[label, value] for label, value in report_card_lines(card)],
|
||
[38 * mm, 122 * mm],
|
||
)
|
||
)
|
||
|
||
output = BytesIO()
|
||
doc = SimpleDocTemplate(
|
||
output,
|
||
pagesize=A4,
|
||
rightMargin=18 * mm,
|
||
leftMargin=18 * mm,
|
||
topMargin=16 * mm,
|
||
bottomMargin=16 * mm,
|
||
title="蝶雅新品风险预估报告",
|
||
)
|
||
doc.build(story)
|
||
return output.getvalue()
|
||
|
||
|
||
def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
|
||
document = Document()
|
||
section = document.sections[0]
|
||
section.page_width = 210 * 36000
|
||
section.page_height = 297 * 36000
|
||
section.top_margin = 16 * 36000
|
||
section.bottom_margin = 16 * 36000
|
||
section.left_margin = 18 * 36000
|
||
section.right_margin = 18 * 36000
|
||
normal = document.styles["Normal"]
|
||
normal.font.name = "微软雅黑"
|
||
normal.font.size = Pt(9)
|
||
title = document.add_paragraph()
|
||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
run = title.add_run("新品风险预估报告")
|
||
run.bold = True
|
||
run.font.size = Pt(20)
|
||
subtitle = document.add_paragraph()
|
||
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
subtitle.add_run(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {result.subject_id}").font.size = Pt(9)
|
||
|
||
def heading(text: str) -> None:
|
||
p = document.add_paragraph()
|
||
p.paragraph_format.space_before = Pt(12)
|
||
p.paragraph_format.space_after = Pt(6)
|
||
p.paragraph_format.left_indent = Pt(5)
|
||
r = p.add_run(text)
|
||
r.bold = True
|
||
r.font.size = Pt(11)
|
||
|
||
def add_table(rows: list[list[str]]) -> None:
|
||
table = document.add_table(rows=len(rows), cols=len(rows[0]))
|
||
table.style = "Table Grid"
|
||
table.autofit = True
|
||
for row_index, row in enumerate(rows):
|
||
for col_index, value in enumerate(row):
|
||
cell = table.cell(row_index, col_index)
|
||
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.TOP
|
||
cell.text = str(value)
|
||
for paragraph in cell.paragraphs:
|
||
paragraph.paragraph_format.space_after = Pt(0)
|
||
for run in paragraph.runs:
|
||
run.font.name = "微软雅黑"
|
||
run.font.size = Pt(9)
|
||
if row_index == 0:
|
||
run.bold = True
|
||
run.font.color.rgb = RGBColor(255, 255, 255)
|
||
if row_index == 0:
|
||
shading = OxmlElement("w:shd")
|
||
shading.set("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}fill", "111827")
|
||
cell._tc.get_or_add_tcPr().append(shading)
|
||
document.add_paragraph()
|
||
|
||
heading("一、风险结论")
|
||
rows = [
|
||
["评估项", "结论"],
|
||
[
|
||
"综合评分",
|
||
f"{round(result.score)} / 100 · "
|
||
f"{LEVEL_LABELS.get(result.level.value, result.level.value)} · "
|
||
f"{result.risk_badge or '需关注'}",
|
||
],
|
||
["风险警示", result.alert_text or "当前暂无足够历史数据,建议按标准库完成基础验证。"],
|
||
]
|
||
dimensions = result.dimension_scores or [
|
||
{
|
||
"dimension": factor.name,
|
||
"score": 100 - factor.value,
|
||
"weight": factor.weight,
|
||
"reason": None,
|
||
}
|
||
for factor in result.factors
|
||
]
|
||
for item in dimensions:
|
||
name = item.dimension if hasattr(item, "dimension") else item["dimension"]
|
||
if name in {"节点阶段", "缺陷等级"}:
|
||
continue
|
||
score = item.score if hasattr(item, "score") else item["score"]
|
||
weight = item.weight if hasattr(item, "weight") else item["weight"]
|
||
reason = item.reason if hasattr(item, "reason") else item.get("reason")
|
||
supplier = result.supplier_freq
|
||
if name == "供应商表现" and supplier:
|
||
order_text = f"合作 {supplier.cooperation_count} 次、" if supplier.cooperation_count is not None else ""
|
||
conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次"
|
||
elif name == "标准符合性" and result.standard_judgment:
|
||
conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认"
|
||
else:
|
||
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
|
||
rows.append([f"{name}({weight:g}%)", conclusion])
|
||
add_table(rows)
|
||
if result.category_warning:
|
||
heading("二、品类风险预警")
|
||
add_table(
|
||
[
|
||
["品类", "历史异常", "风险等级", "建议"],
|
||
[
|
||
result.category_warning.category,
|
||
f"{result.category_warning.anomaly_count} 条",
|
||
result.category_warning.risk_level,
|
||
result.category_warning.suggestion or "结合相似品类和标准库预判",
|
||
],
|
||
]
|
||
)
|
||
if result.supplier_freq and result.supplier_freq.supplier_name:
|
||
heading("三、供应商合作频次分析")
|
||
rows = [["维度", "数值", "说明"]]
|
||
if result.supplier_freq.cooperation_count is not None:
|
||
rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"])
|
||
rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"])
|
||
if result.supplier_freq.alpha is not None:
|
||
rows.append(
|
||
["频次风险系数", f"×{result.supplier_freq.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
|
||
)
|
||
if result.supplier_freq.tier:
|
||
rows.append(["供应商层级", result.supplier_freq.tier, result.supplier_freq.suggestion or ""])
|
||
add_table(rows)
|
||
heading(f"四、相似案例匹配({len(result.similar_cases)} 条)")
|
||
if result.similar_cases:
|
||
add_table(
|
||
[["日期", "产品", "问题", "处理方案"]]
|
||
+ [
|
||
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
|
||
for case in result.similar_cases
|
||
]
|
||
)
|
||
else:
|
||
document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。")
|
||
heading("五、预防建议")
|
||
for index, suggestion in enumerate(
|
||
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
|
||
):
|
||
document.add_paragraph(f"{index}. {suggestion}")
|
||
heading("六、AI 推理方案(无历史案例场景)")
|
||
document.add_paragraph(
|
||
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。"
|
||
)
|
||
heading("七、标准判定结果")
|
||
if result.standard_judgment:
|
||
add_table(
|
||
[["判定维度", "判定标准", "标准来源", "当前状态"]]
|
||
+ [
|
||
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
|
||
for item in result.standard_judgment
|
||
]
|
||
)
|
||
else:
|
||
document.add_paragraph("暂无相关标准。")
|
||
document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。")
|
||
|
||
cards = effective_report_cards(result)
|
||
if cards:
|
||
heading("八、追加卡片分析")
|
||
for index, card in enumerate(cards, 1):
|
||
document.add_paragraph(f"{index}. {card.title}")
|
||
add_table([["字段", "内容"]] + [[label, value] for label, value in report_card_lines(card)])
|
||
output = BytesIO()
|
||
document.save(output)
|
||
return output.getvalue()
|