512 lines
22 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 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()