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

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 io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any
from docx import Document from docx import Document
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT 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 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: def _font_name() -> str:
candidates = [ candidates = [
Path("C:/Windows/Fonts/msyh.ttc"), 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 supplier_name = result.supplier_freq.supplier_name if result.supplier_freq else None
report_number = f"RP-{result.subject_id[:8]}-{round(result.score)}" report_number = f"RP-{result.subject_id[:8]}-{round(result.score)}"
generated_at = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M") 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 = [ story = [
Paragraph("新品风险预估报告", title), Paragraph("新品风险预估报告", title),
Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle), Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle),
@ -268,27 +332,52 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
] ]
supplier = result.supplier_freq 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 = [["维度", "数值", "说明"]] 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} 次", "近三年采购订单去重单据数"]) supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"])
if supplier:
supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"]) 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( supplier_rows.append(
["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"] ["频次风险系数", 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 ""]) 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("三、供应商合作频次分析", section), table(supplier_rows, [38 * mm, 35 * mm, 87 * mm])]
story += [Paragraph(f"四、相似案例匹配({len(result.similar_cases)} 条)", section)] material_cards = report_data["material_cards"]
if result.similar_cases: 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( story.append(
table( table(
[["日期", "产品", "问题", "处理方案"]] [["日期", "产品", "问题", "处理方案"]]
+ [ + [
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"] [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], [28 * mm, 42 * mm, 48 * mm, 42 * mm],
) )
@ -296,27 +385,35 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
else: else:
story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small)) story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small))
story += [Paragraph("五、预防建议", section)] story += [Paragraph("六、预防建议", section)]
for index, suggestion in enumerate( for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
):
story.append(Paragraph(f"{index}. {suggestion}", body)) story.append(Paragraph(f"{index}. {suggestion}", body))
story += [ story += [
Paragraph("六、AI 推理方案(无历史案例场景)", section), Paragraph("七、AI 推理方案(无历史案例场景)", section),
Paragraph( Paragraph(
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。", "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。",
body, body,
), ),
] ]
story += [Paragraph("七、标准判定结果", section)] for card in report_data["solution_cards"]:
if result.standard_judgment: 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( story.append(
table( table(
[["判定维度", "判定标准", "标准来源", "当前状态"]] [["判定维度", "判定标准", "标准来源", "当前状态"]]
+ [ + [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] [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], [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))
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() output = BytesIO()
doc = SimpleDocTemplate( doc = SimpleDocTemplate(
output, output,
@ -371,6 +456,10 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
subtitle = document.add_paragraph() subtitle = document.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
subtitle.add_run(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {result.subject_id}").font.size = Pt(9) 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: def heading(text: str) -> None:
p = document.add_paragraph() 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("三、供应商合作频次分析") heading("三、供应商合作频次分析")
rows = [["维度", "数值", "说明"]] 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} 次", "近三年采购订单去重单据数"]) rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"])
if result.supplier_freq:
rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"]) 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( rows.append(
["频次风险系数", f"×{result.supplier_freq.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"] ["频次风险系数", 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 ""]) 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) add_table(rows)
heading(f"四、相似案例匹配({len(result.similar_cases)} 条)") material_cards = report_data["material_cards"]
if result.similar_cases: 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( add_table(
[["日期", "产品", "问题", "处理方案"]] [["日期", "产品", "问题", "处理方案"]]
+ [ + [
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"] [case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
for case in result.similar_cases for case in similar_cases
] ]
) )
else: else:
document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。") document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。")
heading("五、预防建议") heading("六、预防建议")
for index, suggestion in enumerate( for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
):
document.add_paragraph(f"{index}. {suggestion}") document.add_paragraph(f"{index}. {suggestion}")
heading("六、AI 推理方案(无历史案例场景)") heading("七、AI 推理方案(无历史案例场景)")
document.add_paragraph( document.add_paragraph(
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。" "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。"
) )
heading("七、标准判定结果") for card in report_data["solution_cards"]:
if result.standard_judgment: 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( add_table(
[["判定维度", "判定标准", "标准来源", "当前状态"]] [["判定维度", "判定标准", "标准来源", "当前状态"]]
+ [ + [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] [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: else:
document.add_paragraph("暂无相关标准。") document.add_paragraph("暂无相关标准。")
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() output = BytesIO()
document.save(output) document.save(output)
return output.getvalue() return output.getvalue()