"""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()