Compare commits

..

8 Commits

12 changed files with 643 additions and 92 deletions

View File

@ -23,7 +23,17 @@ async def list_suppliers(
return ApiResponse(data=await SupplierProfileService(session).list(keyword=keyword, limit=limit))
@router.get("/{supplier_name}", response_model=ApiResponse[SupplierProfileData])
@router.get("/profile", response_model=ApiResponse[SupplierProfileData])
async def get_supplier_profile_by_query(
session: DatabaseSession,
_current_user: Annotated[UserAccount, Depends(require_permission("supplier.read"))],
supplier_name: str = Query(min_length=1, max_length=300),
) -> ApiResponse[SupplierProfileData]:
"""Fetch a profile by query parameter so names containing '/' are safe."""
return ApiResponse(data=await SupplierProfileService(session).detail(supplier_name))
@router.get("/{supplier_name:path}", response_model=ApiResponse[SupplierProfileData])
async def get_supplier_profile(
supplier_name: str,
session: DatabaseSession,

View File

@ -1,5 +1,7 @@
"""Risk prediction request and response contracts."""
from typing import Literal
from pydantic import Field
from app.domain.risk_engine import RiskLevel
@ -103,6 +105,44 @@ class AiSolutionReport(ApiSchema):
warning: str | None = None
class ReportCard(ApiSchema):
"""A response card explicitly appended to the risk report.
The card is intentionally a superset of the five assistant card types so
that the report page and both export formats can consume the exact same
payload without type-specific ad-hoc transformations.
"""
type: Literal["supplier", "material", "standard", "compliance", "solution"]
title: str = "风险分析卡片"
name: str | None = None
party_type: str | None = None
material: str | None = None
category: str | None = None
question: str | None = None
anomaly_count: int | None = None
cooperation_count: int | None = None
frequency_coefficient: float | None = None
supplier_base_score: float | None = None
supplier_score: float | None = None
tier: str | None = None
matched_count: int | None = None
detected_values: list[dict[str, str]] = Field(default_factory=list)
top_problems: list[dict[str, int | str]] = Field(default_factory=list)
frequent_problems: list[dict[str, int | str]] = Field(default_factory=list)
standards: list[StandardJudgment] = Field(default_factory=list)
similar_cases: list[SimilarCase] = Field(default_factory=list)
confidence: str | None = None
analysis_base: str | None = None
standard_fallback_enabled: bool = False
risks: list[AiSolutionRisk] = Field(default_factory=list)
suggestions: list[str] = Field(default_factory=list)
note: str | None = None
warning: str | None = None
frequency_basis: str | None = None
data_available: bool | None = None
class RiskAssessmentResult(ApiSchema):
subject_id: str
score: float
@ -118,6 +158,7 @@ class RiskAssessmentResult(ApiSchema):
similar_cases: list[SimilarCase] = Field(default_factory=list)
ai_suggestions: list[str] = Field(default_factory=list)
ai_solution_cards: list[AiSolutionReport] = Field(default_factory=list)
report_cards: list[ReportCard] = Field(default_factory=list)
class ModelInfo(ApiSchema):

View File

@ -25,7 +25,7 @@ class SupplierMetric(ApiSchema):
supplier_score: float | None = None
risk_score: float | None = None
main_category_count: int
cooperation_count_basis: str = "按近3年采购订单去重单据编号统计"
cooperation_count_basis: str = "按自然年度边界采购订单去重单据编号统计"
anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率"

View File

@ -4,7 +4,7 @@ import json
import logging
import re
from collections.abc import AsyncGenerator, Sequence
from datetime import UTC, date, datetime, timedelta
from datetime import UTC, date, datetime
from typing import Any, Literal
from sqlalchemy import delete, func, or_, select
@ -36,14 +36,14 @@ from app.schemas.risk_prediction import (
)
from app.services.database_risk_scoring import DatabaseRiskScoringService
from app.services.llm_client import LLMClient
from app.services.purchase_orders import supplier_order_counts
from app.services.purchase_orders import calendar_year_window, supplier_order_counts
from app.services.rag import AnomalyCaseRAG
logger = logging.getLogger("dieya_risk_prediction.conversations")
class ConversationService:
_supplier_history_days = 365 * 3
_supplier_history_years = 3
_engine = BaselineRiskEngine()
_risk_intents = {
"assessment",
@ -345,6 +345,14 @@ class ConversationService:
if cls._is_placeholder_supplier_name(text):
return "general"
if text.startswith("查物料风险"):
return "material"
if text.startswith("搜索相似案例"):
return "risk_assessment"
if text.startswith("生成 AI 案例推理方案"):
return "solution"
if text.startswith("评估新品"):
return "risk_assessment"
intent = str(semantics.get("intent") or "").strip().lower()
if intent == "supplier" or (
any(word in text for word in ("供应商", "加工厂", "工厂"))
@ -354,6 +362,8 @@ class ConversationService:
return "supplier"
if intent == "material" or "物料" in text or "类物料" in text:
return "material"
if intent == "cases":
return "risk_assessment"
if intent == "standard" or any(word in text for word in ("检测报告", "检测值", "判定", "pH", "PH")):
return "standard"
if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")):
@ -572,7 +582,7 @@ class ConversationService:
self.session,
name,
self._supplier_history_start(),
datetime.now(UTC).date(),
self._supplier_history_end(),
)
if hasattr(self, "session")
else None
@ -616,9 +626,9 @@ class ConversationService:
"supplierScore": supplier_dimension.score if supplier_dimension else None,
"topProblems": problems,
"suggestions": self._supplier_management_suggestions(tier),
"frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
"frequencyBasis": "按自然年度边界采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
"note": (
"合作次数按近3年采购订单去重单据编号统计。"
"合作次数按自然年度边界采购订单去重单据编号统计。"
if has_order_data
else "未匹配到该供应商采购订单,不展示合作次数。"
),
@ -667,6 +677,8 @@ class ConversationService:
async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET"))
if not material and "查物料风险:" in text:
material = text.split("查物料风险:", 1)[1].strip().split(",", 1)[0]
material = material or self._semantic_text(semantics, "packaging") or "未指定物料"
query_term = "软管" if "软管" in material else material
count = await self._count_anomalies(AnomalyCase.material_type, query_term)
@ -926,7 +938,7 @@ class ConversationService:
[
AnomalyCase.date.is_not(None),
AnomalyCase.date >= since,
AnomalyCase.date <= datetime.now(UTC).date(),
AnomalyCase.date <= self._supplier_history_end(),
]
)
rows = await self.session.execute(
@ -1070,7 +1082,7 @@ class ConversationService:
self.session,
supplier,
self._supplier_history_start(),
datetime.now(UTC).date(),
self._supplier_history_end(),
)
if supplier
else None
@ -1163,9 +1175,9 @@ class ConversationService:
),
tier=tier,
suggestion=";".join(self._supplier_management_suggestions(tier)),
frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
frequency_basis="按自然年度边界采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
note=(
"合作次数按采购订单去重单据编号统计;异常数仍按近3年异常案例统计。"
"合作次数按自然年度边界采购订单去重单据编号统计;异常数按自然年度边界异常案例统计。"
if cooperation_count
else "未匹配到该供应商采购订单,不展示合作次数。"
),
@ -1197,14 +1209,18 @@ class ConversationService:
column.ilike(f"%{value}%"),
AnomalyCase.date.is_not(None),
AnomalyCase.date >= self._supplier_history_start(),
AnomalyCase.date <= datetime.now(UTC).date(),
AnomalyCase.date <= self._supplier_history_end(),
)
)
return int(count or 0)
@classmethod
def _supplier_history_start(cls) -> date:
return datetime.now(UTC).date() - timedelta(days=cls._supplier_history_days)
return calendar_year_window(datetime.now(UTC).date(), cls._supplier_history_years)[0]
@classmethod
def _supplier_history_end(cls) -> date:
return calendar_year_window(datetime.now(UTC).date(), cls._supplier_history_years)[1]
async def _supplier_history_stats(
self,
@ -1222,7 +1238,7 @@ class ConversationService:
column.ilike(f"%{value}%"),
AnomalyCase.date.is_not(None),
AnomalyCase.date >= self._supplier_history_start(),
AnomalyCase.date <= datetime.now(UTC).date(),
AnomalyCase.date <= self._supplier_history_end(),
)
.group_by(AnomalyCase.defect_level)
)
@ -1342,7 +1358,7 @@ class ConversationService:
),
AnomalyCase.date.is_not(None),
AnomalyCase.date >= self._supplier_history_start(),
AnomalyCase.date <= datetime.now(UTC).date(),
AnomalyCase.date <= self._supplier_history_end(),
)
)
return int(count or 0)

View File

@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS
from app.domain.risk_engine import RiskLevel
from app.models.entities import AnomalyCase, RiskModelConfig
from app.services.purchase_orders import supplier_order_counts
from app.services.purchase_orders import calendar_year_window, supplier_order_counts
STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理")
@ -107,8 +107,7 @@ class DatabaseRiskScoringService:
return await supplier_order_counts(
self.session,
party_name,
self.as_of - timedelta(days=365 * 3),
self.as_of,
*calendar_year_window(self.as_of, 3),
)
except Exception: # noqa: BLE001 - legacy/test databases may not have the order ledger yet
return None
@ -127,7 +126,7 @@ class DatabaseRiskScoringService:
self.frequency_cap = float(config.frequency_cap)
async def _load_rows(self) -> list[dict[str, Any]]:
start = self.as_of - timedelta(days=365 * 3)
start, end = calendar_year_window(self.as_of, 3)
statement = select(
AnomalyCase.date,
AnomalyCase.product_name,
@ -140,7 +139,7 @@ class DatabaseRiskScoringService:
).where(
AnomalyCase.date.is_not(None),
AnomalyCase.date >= start,
AnomalyCase.date <= self.as_of,
AnomalyCase.date <= end,
)
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
@ -200,9 +199,9 @@ class DatabaseRiskScoringService:
weight=weight,
status="database",
reason=(
f"近 3 年采购订单 {count} 单,异常案例 {anomaly_count} 条,基础分 {base:g},α={alpha:.2f}"
f"近 3 个自然年度采购订单 {count} 单,异常案例 {anomaly_count} 条,基础分 {base:g},α={alpha:.2f}"
if cooperation_count is not None and cooperation_count > 0
else f"近 3 年异常 {count} 条,基础分 {base:g},α={alpha:.2f}"
else f"近 3 个自然年度异常 {count} 条,基础分 {base:g},α={alpha:.2f}"
),
sample_size=anomaly_count,
raw_value=count,

View File

@ -16,6 +16,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.entities import PurchaseOrderLine
def calendar_year_window(as_of: date, years: int = 3) -> tuple[date, date]:
"""Return the natural-year window used by historical statistics.
``years=3`` means the year three calendar years before ``as_of`` through
the end of ``as_of``'s calendar year. For example, an as-of date in 2026
uses 2023-01-01 through 2026-12-31, so a partial current year never drops
an otherwise valid record merely because of its month/day.
"""
if years < 1:
raise ValueError("years must be positive")
return date(as_of.year - years, 1, 1), date(as_of.year, 12, 31)
def clean_text(value: Any) -> str | None:
if value is None:
return None

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
@ -17,7 +18,7 @@ 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 RiskAssessmentResult
from app.schemas.risk_prediction import ReportCard, RiskAssessmentResult
LEVEL_LABELS = {
"low": "低风险",
@ -28,6 +29,182 @@ LEVEL_LABELS = {
}
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 _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"]
standard_cards = [card for card in cards if card.type in {"standard", "compliance"}]
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,
),
)
# Only base assessment suggestions belong to the prevention section.
# Card-specific suggestions stay with their owning section (supplier,
# material, standard/compliance or AI reasoning) to avoid duplication.
suggestions = _unique_by(list(result.ai_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,
"standard_cards": standard_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"),
@ -73,6 +250,13 @@ 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"]
material_cards = report_data["material_cards"]
standard_cards = report_data["standard_cards"]
section_shift = 1 if material_cards else 0
story = [
Paragraph("新品风险预估报告", title),
Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle),
@ -129,7 +313,7 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
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)} 条判定标准,待检测项需人工确认"
conclusion = f"{round(score)} 分 · 命中 {len(standards)} 条判定标准,待检测项需人工确认"
else:
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
rows.append([f"{name}({weight:g}%)", conclusion])
@ -153,27 +337,51 @@ 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:
supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"])
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:
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"{'五' if section_shift else '四'}、相似案例匹配({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],
)
@ -181,28 +389,53 @@ 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(f"{'六' if section_shift else '五'}、预防建议", section)]
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
story.append(Paragraph(f"{index}. {suggestion}", body))
story += [
Paragraph("六、AI 推理方案(无历史案例场景)", section),
Paragraph(f"{'七' if section_shift else '六'}、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))
for index, suggestion in enumerate(card.suggestions, 1):
story.append(Paragraph(f"方案建议 {index}:{suggestion}", body))
if card.warning:
story.append(Paragraph(f"风险提示:{card.warning}", body))
story += [Paragraph(f"{'八' if section_shift else '七'}、标准判定结果", section)]
standard_rows = [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
for item in standards
]
for card in standard_cards:
if card.detected_values:
standard_rows.append(
[
"检测值",
"、".join(f"{item.get('name', '')}:{item.get('value', '')}" for item in card.detected_values),
card.title,
"待确认",
]
)
if card.note:
standard_rows.append(["补充说明", card.note, card.title, "待确认"])
for suggestion in card.suggestions:
standard_rows.append(["执行建议", suggestion, card.title, "待确认"])
if standard_rows:
story.append(
table(
[["判定维度", "判定标准", "标准来源", "当前状态"]]
+ [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
for item in result.standard_judgment
],
+ standard_rows,
[38 * mm, 55 * mm, 40 * mm, 27 * mm],
)
)
@ -244,6 +477,13 @@ 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"]
material_cards = report_data["material_cards"]
standard_cards = report_data["standard_cards"]
section_shift = 1 if material_cards else 0
def heading(text: str) -> None:
p = document.add_paragraph()
@ -309,7 +549,7 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
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)} 条判定标准,待检测项需人工确认"
conclusion = f"{round(score)} 分 · 命中 {len(standards)} 条判定标准,待检测项需人工确认"
else:
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
rows.append([f"{name}({weight:g}%)", conclusion])
@ -327,51 +567,101 @@ 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:
rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"])
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:
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"{'五' if section_shift else '四'}、相似案例匹配({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(f"{'六' if section_shift else '五'}、预防建议")
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
document.add_paragraph(f"{index}. {suggestion}")
heading("六、AI 推理方案(无历史案例场景)")
heading(f"{'七' if section_shift else '六'}、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}")
for index, suggestion in enumerate(card.suggestions, 1):
document.add_paragraph(f"方案建议 {index}:{suggestion}")
if card.warning:
document.add_paragraph(f"风险提示:{card.warning}")
heading(f"{'八' if section_shift else '七'}、标准判定结果")
standard_rows = [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
for item in standards
]
for card in standard_cards:
if card.detected_values:
standard_rows.append(
[
"检测值",
"、".join(f"{item.get('name', '')}:{item.get('value', '')}" for item in card.detected_values),
card.title,
"待确认",
]
)
if card.note:
standard_rows.append(["补充说明", card.note, card.title, "待确认"])
for suggestion in card.suggestions:
standard_rows.append(["执行建议", suggestion, card.title, "待确认"])
if standard_rows:
add_table(
[["判定维度", "判定标准", "标准来源", "当前状态"]]
+ [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
for item in result.standard_judgment
]
+ standard_rows
)
else:
document.add_paragraph("暂无相关标准。")
document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。")
output = BytesIO()
document.save(output)
return output.getvalue()

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import re
from collections import Counter, defaultdict
from datetime import UTC, date, datetime, timedelta
from datetime import UTC, date, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -20,11 +20,11 @@ from app.schemas.suppliers import (
SupplierRecentRecord,
)
from app.services.database_risk_scoring import DatabaseRiskScoringService, normalize_category
from app.services.purchase_orders import supplier_order_counts
from app.services.purchase_orders import calendar_year_window, supplier_order_counts
class SupplierProfileService:
history_days = 365 * 3
history_years = 3
recent_months = 6
max_categories = 5
max_recent_records = 10
@ -35,6 +35,14 @@ class SupplierProfileService:
async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData:
grouped = await self._load_supplier_summary(keyword=keyword)
# Some imported legacy rows have no anomaly date. They are excluded
# from the normal three-year risk window, but must still be discoverable
# when a user searches by a supplier/city name.
if keyword and not grouped:
grouped = await self._load_supplier_summary(
keyword=keyword,
include_undated=True,
)
items = [
SupplierOption(
name=name,
@ -57,15 +65,21 @@ class SupplierProfileService:
async def detail(self, supplier_name: str) -> SupplierProfileData:
all_rows = await self._load_rows()
rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
used_legacy_fallback = False
if not rows:
raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404)
all_rows = await self._load_rows(include_undated=True)
rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
used_legacy_fallback = bool(rows)
if not rows:
raise AppException("供应商不存在或暂无近3个自然年度异常记录", code=404, status_code=404)
anomaly_total = len(rows)
history_start, history_end = calendar_year_window(self.as_of, self.history_years)
order_count = await supplier_order_counts(
self.session,
supplier_name,
self.as_of - timedelta(days=self.history_days),
self.as_of,
history_start,
history_end,
)
a_count = sum(self.defect_code(row.get("defect_level")) == "A" for row in rows)
b_count = sum(self.defect_code(row.get("defect_level")) == "B" for row in rows)
@ -116,11 +130,19 @@ class SupplierProfileService:
monthly_trend=self._monthly_trend(rows),
recent_records=self._recent_records(rows),
factory_recommendations=[],
recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
recommendation_note=(
"该供应商仅命中历史导入记录,部分异常缺少发生日期;本页展示可识别的历史记录,"
"不将日期缺失数据混入近3年趋势。"
if used_legacy_fallback
else "合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。"
),
)
async def _load_supplier_summary(
self, *, keyword: str | None = None
self,
*,
keyword: str | None = None,
include_undated: bool = False,
) -> dict[str, dict[str, int]]:
"""Load only supplier aggregates for the selector.
@ -130,19 +152,18 @@ class SupplierProfileService:
the remote PostgreSQL connection.
"""
start = self.as_of - timedelta(days=self.history_days)
start, end = calendar_year_window(self.as_of, self.history_years)
statement = (
select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count())
.where(
AnomalyCase.supplier.is_not(None),
AnomalyCase.date.is_not(None),
AnomalyCase.date >= start,
AnomalyCase.date <= self.as_of,
)
.where(AnomalyCase.supplier.is_not(None))
.group_by(AnomalyCase.supplier, AnomalyCase.defect_level)
)
if keyword and keyword.strip():
statement = statement.where(AnomalyCase.supplier.ilike(f"%{keyword.strip()}%"))
if not include_undated:
statement = statement.where(
AnomalyCase.date.is_not(None),
AnomalyCase.date >= start,
AnomalyCase.date <= end,
)
grouped: dict[str, dict[str, int]] = defaultdict(
lambda: {"total": 0, "a": 0, "b": 0}
)
@ -150,6 +171,8 @@ class SupplierProfileService:
name = (supplier or "").strip()
if not name:
continue
if keyword and not self.matches(name, keyword):
continue
value = grouped[name]
amount = int(count or 0)
value["total"] += amount
@ -160,8 +183,13 @@ class SupplierProfileService:
value["b"] += amount
return grouped
async def _load_rows(self, *, supplier_name: str | None = None) -> list[dict]:
start = self.as_of - timedelta(days=self.history_days)
async def _load_rows(
self,
*,
supplier_name: str | None = None,
include_undated: bool = False,
) -> list[dict]:
start, end = calendar_year_window(self.as_of, self.history_years)
statement = select(
AnomalyCase.id,
AnomalyCase.date,
@ -172,10 +200,12 @@ class SupplierProfileService:
AnomalyCase.factory,
AnomalyCase.anomaly_type,
AnomalyCase.defect_level,
).where(
).where()
if not include_undated:
statement = statement.where(
AnomalyCase.date.is_not(None),
AnomalyCase.date >= start,
AnomalyCase.date <= self.as_of,
AnomalyCase.date <= end,
)
if supplier_name:
statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%"))
@ -183,7 +213,41 @@ class SupplierProfileService:
@staticmethod
def matches(value: str | None, target: str) -> bool:
return bool(value and target.strip().lower() in value.lower())
if not value or not target:
return False
# Supplier cells in the source workbooks often contain several names
# such as ``皓盛/佛山执得``. Match each segment separately and make
# common city/company suffixes optional so ``佛山市`` can find
# ``佛山执得`` while preserving the canonical raw value for display.
queries = SupplierProfileService._search_terms(target)
segments = SupplierProfileService._supplier_segments(value)
return any(
query and (query in segment or segment in query)
for query in queries
for segment in segments
)
@staticmethod
def _supplier_segments(value: str) -> list[str]:
segments = re.split(r"[//、,,;;|\\\\]+", value)
return [SupplierProfileService._compact_text(item) for item in segments if SupplierProfileService._compact_text(item)]
@staticmethod
def _compact_text(value: str) -> str:
return re.sub(r"[\s()()【】\[\]{}]+", "", value.casefold())
@classmethod
def _search_terms(cls, value: str) -> set[str]:
compact = cls._compact_text(value)
terms = {compact}
# A user commonly enters a city with the administrative suffix while
# the workbook keeps the company/trading name (e.g. 佛山市 vs 佛山执得).
if compact.endswith("市") and len(compact) > 2:
terms.add(compact[:-1])
for suffix in ("有限公司", "有限责任公司", "公司"):
if compact.endswith(suffix) and len(compact) > len(suffix) + 1:
terms.add(compact[: -len(suffix)])
return {term for term in terms if term}
@classmethod
def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]:
@ -223,7 +287,7 @@ class SupplierProfileService:
"A级": "异常记录 20~60 次,且 A/B 级缺陷占比 < 30%",
"B级": "异常记录 60~120 次,或 A/B 级缺陷占比 30%~50%",
"C级": "异常记录 > 120 次,或存在 A 级缺陷,或 A/B 级缺陷占比 > 50%",
"新供应商": "近3年异常记录为 0",
"新供应商": "近3个自然年度异常记录为 0",
}[tier]
@classmethod

View File

@ -104,6 +104,19 @@ def test_only_explicit_risk_requests_generate_risk_cards() -> None:
) is True
@pytest.mark.parametrize(
("text", "expected"),
[
("评估新品:洗发水,包装材质:泵头", "risk_assessment"),
("查物料风险:软管", "material"),
("搜索相似案例:软管印刷遇水脱落", "risk_assessment"),
("生成 AI 案例推理方案:铝瓶+喷头密封验证", "solution"),
],
)
def test_tool_requests_keep_their_intent_for_card_routing(text: str, expected: str) -> None:
assert ConversationService._response_type(text, {"intent": "general"}) == expected
def test_legacy_general_semantics_hide_old_risk_card() -> None:
messages = [
ConversationMessage(

View File

@ -0,0 +1,15 @@
from datetime import date
import pytest
from app.services.purchase_orders import calendar_year_window
@pytest.mark.parametrize(
("as_of", "expected"),
[
(date(2026, 9, 10), (date(2023, 1, 1), date(2026, 12, 31))),
(date(2025, 1, 1), (date(2022, 1, 1), date(2025, 12, 31))),
],
)
def test_three_year_window_uses_calendar_years(as_of: date, expected: tuple[date, date]) -> None:
assert calendar_year_window(as_of, 3) == expected

62
tests/test_reports.py Normal file
View File

@ -0,0 +1,62 @@
from io import BytesIO
from app.schemas.risk_prediction import AiSolutionReport, ReportCard, RiskAssessmentResult
from app.services.reports import (
build_risk_report_docx,
build_risk_report_pdf,
effective_report_cards,
report_card_lines,
)
from docx import Document
def _result() -> RiskAssessmentResult:
return RiskAssessmentResult(
subject_id="洗发水新品",
score=72,
level="medium",
model_version="test",
factors=[],
report_cards=[
ReportCard(
type="supplier",
title="供应商深度画像",
name="佳供应商",
party_type="供应商",
supplier_score=88.5,
frequency_coefficient=1.2,
top_problems=[{"name": "泄漏", "count": 3}],
suggestions=["安排首单全检"],
)
],
)
def test_report_card_lines_are_canonical() -> None:
lines = dict(report_card_lines(_result().report_cards[0]))
assert lines["分析对象"] == "佳供应商"
assert lines["供应商得分"] == "88.50 分"
assert lines["高频问题"] == "泄漏(3次)"
assert lines["建议方案"] == "安排首单全检"
def test_old_ai_solution_payload_is_supported() -> None:
result = _result().model_copy(
update={"report_cards": [], "ai_solution_cards": [AiSolutionReport(title="旧方案")]}
)
cards = effective_report_cards(result)
assert len(cards) == 1
assert cards[0].title == "旧方案"
def test_pdf_and_word_include_appended_card_content() -> None:
result = _result()
pdf = build_risk_report_pdf(result)
word = build_risk_report_docx(result)
assert pdf.startswith(b"%PDF")
document = Document(BytesIO(word))
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
text += "\n" + "\n".join(cell.text for table in document.tables for row in table.rows for cell in row.cells)
assert "佳供应商" in text
assert "88.50 分" in text
assert "安排首单全检" in text

View File

@ -14,6 +14,33 @@ def test_supplier_tier_rules_follow_prd() -> None:
assert SupplierProfileService.tier_for(121, 0, 0) == "C级"
def test_supplier_name_matching_supports_full_name_and_short_name() -> None:
assert SupplierProfileService.matches("佳亿", "佛山市佳亿包装有限公司")
assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿")
def test_supplier_name_matching_supports_city_suffix_and_compound_supplier_cells() -> None:
assert SupplierProfileService.matches("皓盛/佛山执得", "佛山市")
assert SupplierProfileService.matches("皓盛/佛山执得", "佛山")
assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿")
def test_supplier_list_falls_back_to_undated_legacy_rows(monkeypatch) -> None:
service = SupplierProfileService.__new__(SupplierProfileService)
calls = []
async def fake_summary(*, keyword=None, include_undated=False):
calls.append(include_undated)
if not include_undated:
return {}
return {"皓盛/佛山执得": {"total": 1, "a": 0, "b": 0}}
service._load_supplier_summary = fake_summary
result = __import__("asyncio").run(service.list(keyword="佛山市", limit=8))
assert calls == [False, True]
assert result.items[0].name == "皓盛/佛山执得"
def test_supplier_alpha_is_capped_and_rounded() -> None:
assert SupplierProfileService.alpha(0) == 1.0
assert SupplierProfileService.alpha(42) == 1.13