按报告类型归并追加内容并统一导出

This commit is contained in:
kx_guo 2026-09-09 17:23:43 +08:00
parent fd8369e2f3
commit 2f4d2260c8

View File

@ -2,6 +2,7 @@
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
@ -143,6 +144,65 @@ def report_card_lines(card: ReportCard) -> list[tuple[str, str]]:
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"]
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,
),
)
suggestions = _unique_by(
[*result.ai_suggestions, *(item for card in cards for item in card.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,
"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"),
@ -188,6 +248,10 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
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"]
story = [
Paragraph("新品风险预估报告", title),
Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle),
@ -268,27 +332,52 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
]
supplier = result.supplier_freq
if supplier and supplier.supplier_name:
supplier_cards = report_data["supplier_cards"]
if (supplier and supplier.supplier_name) or supplier_cards:
supplier_rows = [["维度", "数值", "说明"]]
if supplier.cooperation_count is not None:
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.alpha is not None:
if supplier and supplier.alpha is not None:
supplier_rows.append(
["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
)
if supplier.tier:
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])]
story += [Paragraph(f"四、相似案例匹配({len(result.similar_cases)} 条)", section)]
if result.similar_cases:
material_cards = report_data["material_cards"]
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"五、相似案例匹配({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 result.similar_cases
for case in similar_cases
],
[28 * mm, 42 * mm, 48 * mm, 42 * mm],
)
@ -296,27 +385,35 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
else:
story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small))
story += [Paragraph("五、预防建议", section)]
for index, suggestion in enumerate(
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
):
story += [Paragraph("六、预防建议", section)]
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
story.append(Paragraph(f"{index}. {suggestion}", body))
story += [
Paragraph("六、AI 推理方案(无历史案例场景)", section),
Paragraph("七、AI 推理方案(无历史案例场景)", section),
Paragraph(
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。",
body,
),
]
story += [Paragraph("七、标准判定结果", section)]
if result.standard_judgment:
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("八、标准判定结果", section)]
if standards:
story.append(
table(
[["判定维度", "判定标准", "标准来源", "当前状态"]]
+ [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
for item in result.standard_judgment
for item in standards
],
[38 * mm, 55 * mm, 40 * mm, 27 * mm],
)
@ -325,18 +422,6 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
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,
@ -371,6 +456,10 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
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()
@ -454,58 +543,83 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
],
]
)
if result.supplier_freq and result.supplier_freq.supplier_name:
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.cooperation_count is not None:
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.alpha is not None:
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.tier:
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)
heading(f"四、相似案例匹配({len(result.similar_cases)} 条)")
if result.similar_cases:
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 result.similar_cases
for case in similar_cases
]
)
else:
document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。")
heading("五、预防建议")
for index, suggestion in enumerate(
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
):
heading("六、预防建议")
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
document.add_paragraph(f"{index}. {suggestion}")
heading("六、AI 推理方案(无历史案例场景)")
heading("七、AI 推理方案(无历史案例场景)")
document.add_paragraph(
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。"
)
heading("七、标准判定结果")
if result.standard_judgment:
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 result.standard_judgment
for item in standards
]
)
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()