643 lines
28 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.

"""PDF rendering for the prototype-aligned risk report."""
from io import BytesIO
from pathlib import Path
from typing import Any
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 _unique_by(items: list[Any], key) -> list[Any]: # noqa: ANN001
seen: set[Any] = set()
result: list[Any] = []
for item in items:
value = key(item)
if value in seen:
continue
seen.add(value)
result.append(item)
return result
def aggregate_report_data(result: RiskAssessmentResult) -> dict[str, Any]:
"""Merge appended cards into the report's native sections.
A card is an input action, not a report section. Its fields are folded
into the matching section so the browser, PDF and Word output have the
same information architecture.
"""
cards = effective_report_cards(result)
supplier_cards = [card for card in cards if card.type == "supplier"]
material_cards = [card for card in cards if card.type == "material"]
solution_cards = [card for card in cards if card.type == "solution"]
standard_cards = [card for card in cards if card.type in {"standard", "compliance"}]
standards = _unique_by(
[*result.standard_judgment, *(item for card in cards for item in card.standards)],
lambda item: (item.category, item.risk_point, item.rule, item.status),
)
similar_cases = _unique_by(
[*result.similar_cases, *(item for card in cards for item in card.similar_cases)],
lambda item: (
item.case_id,
item.date,
item.product,
item.problem or item.description,
item.solution,
),
)
# Only base assessment suggestions belong to the prevention section.
# Card-specific suggestions stay with their owning section (supplier,
# material, standard/compliance or AI reasoning) to avoid duplication.
suggestions = _unique_by(list(result.ai_suggestions), lambda item: item)
detected_values = [item for card in cards for item in card.detected_values]
risks = [(card.title, risk) for card in solution_cards for risk in card.risks]
analyses = [card for card in solution_cards if card.analysis_base or card.confidence or card.warning]
return {
"cards": cards,
"supplier_cards": supplier_cards,
"material_cards": material_cards,
"solution_cards": solution_cards,
"standard_cards": standard_cards,
"standards": standards,
"similar_cases": similar_cases,
"suggestions": suggestions,
"detected_values": detected_values,
"risks": risks,
"analyses": analyses,
}
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")
report_data = aggregate_report_data(result)
standards = report_data["standards"]
similar_cases = report_data["similar_cases"]
suggestions = report_data["suggestions"]
material_cards = report_data["material_cards"]
standard_cards = report_data["standard_cards"]
section_shift = 1 if material_cards else 0
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(standards)} 条判定标准,待检测项需人工确认"
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
supplier_cards = report_data["supplier_cards"]
if (supplier and supplier.supplier_name) or supplier_cards:
supplier_rows = [["维度", "数值", "说明"]]
if supplier and supplier.cooperation_count is not None:
supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"])
if supplier:
supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"])
if supplier and supplier.alpha is not None:
supplier_rows.append(
["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
)
if supplier and supplier.tier:
supplier_rows.append(["供应商层级", supplier.tier, supplier.suggestion or ""])
for card in supplier_cards:
if card.name:
supplier_rows.append(["供应商名称", card.name, "供应商画像统计"])
for label, value in report_card_lines(card):
if label not in {"卡片类型", "分析对象", "对象类型"}:
supplier_rows.append([label, value, "追加供应商画像数据"])
story += [Paragraph("三、供应商合作频次分析", section), table(supplier_rows, [38 * mm, 35 * mm, 87 * mm])]
if material_cards:
story += [Paragraph("四、物料风险分析", section)]
material_rows = [["物料/字段", "分析结果"]]
for card in material_cards:
material_rows.append(
[
card.material or card.title,
";".join(
f"{label}:{value}"
for label, value in report_card_lines(card)
if label not in {"卡片类型", "分析对象"}
),
]
)
story.append(table(material_rows, [45 * mm, 115 * mm]))
story += [Paragraph(f"{'五' if section_shift else '四'}、相似案例匹配({len(similar_cases)} 条)", section)]
if similar_cases:
story.append(
table(
[["日期", "产品", "问题", "处理方案"]]
+ [
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
for case in similar_cases
],
[28 * mm, 42 * mm, 48 * mm, 42 * mm],
)
)
else:
story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small))
story += [Paragraph(f"{'六' if section_shift else '五'}、预防建议", section)]
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
story.append(Paragraph(f"{index}. {suggestion}", body))
story += [
Paragraph(f"{'七' if section_shift else '六'}、AI 推理方案(无历史案例场景)", section),
Paragraph(
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。",
body,
),
]
for card in report_data["solution_cards"]:
if card.analysis_base:
story.append(Paragraph(f"分析基础:{card.analysis_base}", body))
if card.confidence:
story.append(Paragraph(f"置信度:{card.confidence}", body))
for risk in card.risks:
story.append(Paragraph(f"{risk.level} · {risk.title}:{risk.detail}", body))
if card.warning:
story.append(Paragraph(f"风险提示:{card.warning}", body))
story += [Paragraph(f"{'八' if section_shift else '七'}、标准判定结果", section)]
standard_rows = [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
for item in standards
]
for card in standard_cards:
if card.detected_values:
standard_rows.append(
[
"检测值",
"、".join(f"{item.get('name', '')}:{item.get('value', '')}" for item in card.detected_values),
card.title,
"待确认",
]
)
if card.note:
standard_rows.append(["补充说明", card.note, card.title, "待确认"])
if standard_rows:
story.append(
table(
[["判定维度", "判定标准", "标准来源", "当前状态"]]
+ standard_rows,
[38 * mm, 55 * mm, 40 * mm, 27 * mm],
)
)
else:
story.append(Paragraph("暂无相关标准。", small))
story.append(Paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。", small))
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)
report_data = aggregate_report_data(result)
standards = report_data["standards"]
similar_cases = report_data["similar_cases"]
suggestions = report_data["suggestions"]
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(standards)} 条判定标准,待检测项需人工确认"
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 "结合相似品类和标准库预判",
],
]
)
supplier_cards = report_data["supplier_cards"]
if (result.supplier_freq and result.supplier_freq.supplier_name) or supplier_cards:
heading("三、供应商合作频次分析")
rows = [["维度", "数值", "说明"]]
if result.supplier_freq and result.supplier_freq.cooperation_count is not None:
rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"])
if result.supplier_freq:
rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"])
if result.supplier_freq and 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 and result.supplier_freq.tier:
rows.append(["供应商层级", result.supplier_freq.tier, result.supplier_freq.suggestion or ""])
for card in supplier_cards:
if card.name:
rows.append(["供应商名称", card.name, "供应商画像统计"])
for label, value in report_card_lines(card):
if label not in {"卡片类型", "分析对象", "对象类型"}:
rows.append([label, value, "追加供应商画像数据"])
add_table(rows)
material_cards = report_data["material_cards"]
if material_cards:
heading("四、物料风险分析")
rows = [["物料/字段", "分析结果"]]
for card in material_cards:
rows.append(
[
card.material or card.title,
";".join(
f"{label}:{value}"
for label, value in report_card_lines(card)
if label not in {"卡片类型", "分析对象"}
),
]
)
add_table(rows)
heading(f"五、相似案例匹配({len(similar_cases)} 条)")
if similar_cases:
add_table(
[["日期", "产品", "问题", "处理方案"]]
+ [
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
for case in similar_cases
]
)
else:
document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。")
heading("六、预防建议")
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
document.add_paragraph(f"{index}. {suggestion}")
heading("七、AI 推理方案(无历史案例场景)")
document.add_paragraph(
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。"
)
for card in report_data["solution_cards"]:
if card.analysis_base:
document.add_paragraph(f"分析基础:{card.analysis_base}")
if card.confidence:
document.add_paragraph(f"置信度:{card.confidence}")
for risk in card.risks:
document.add_paragraph(f"{risk.level} · {risk.title}:{risk.detail}")
if card.warning:
document.add_paragraph(f"风险提示:{card.warning}")
heading("八、标准判定结果")
if standards:
add_table(
[["判定维度", "判定标准", "标准来源", "当前状态"]]
+ [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
for item in standards
]
)
else:
document.add_paragraph("暂无相关标准。")
document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。")
output = BytesIO()
document.save(output)
return output.getvalue()