按报告类型归并追加内容并统一导出
This commit is contained in:
parent
fd8369e2f3
commit
2f4d2260c8
@ -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} 次", "近三年采购订单去重单据数"])
|
||||||
supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"])
|
if supplier:
|
||||||
if supplier.alpha is not None:
|
supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"])
|
||||||
|
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} 次", "近三年采购订单去重单据数"])
|
||||||
rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"])
|
if result.supplier_freq:
|
||||||
if result.supplier_freq.alpha is not None:
|
rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"])
|
||||||
|
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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user