Compare commits
No commits in common. "a967b827115b1634c9dd8fc64c9a33756ffbe22a" and "9e13c95ba807feeb7f77dcaffa7735f493e3025e" have entirely different histories.
a967b82711
...
9e13c95ba8
@ -23,17 +23,7 @@ async def list_suppliers(
|
||||
return ApiResponse(data=await SupplierProfileService(session).list(keyword=keyword, limit=limit))
|
||||
|
||||
|
||||
@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])
|
||||
@router.get("/{supplier_name}", response_model=ApiResponse[SupplierProfileData])
|
||||
async def get_supplier_profile(
|
||||
supplier_name: str,
|
||||
session: DatabaseSession,
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
"""Risk prediction request and response contracts."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.domain.risk_engine import RiskLevel
|
||||
@ -105,44 +103,6 @@ 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
|
||||
@ -158,7 +118,6 @@ 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):
|
||||
|
||||
@ -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 = "按自然年度边界采购订单去重单据编号统计"
|
||||
cooperation_count_basis: str = "按近3年采购订单去重单据编号统计"
|
||||
anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率"
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
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 calendar_year_window, supplier_order_counts
|
||||
from app.services.purchase_orders import supplier_order_counts
|
||||
from app.services.rag import AnomalyCaseRAG
|
||||
|
||||
logger = logging.getLogger("dieya_risk_prediction.conversations")
|
||||
|
||||
|
||||
class ConversationService:
|
||||
_supplier_history_years = 3
|
||||
_supplier_history_days = 365 * 3
|
||||
_engine = BaselineRiskEngine()
|
||||
_risk_intents = {
|
||||
"assessment",
|
||||
@ -345,14 +345,6 @@ 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 ("供应商", "加工厂", "工厂"))
|
||||
@ -362,8 +354,6 @@ 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 ("合规", "宣称", "备案", "法规")):
|
||||
@ -582,7 +572,7 @@ class ConversationService:
|
||||
self.session,
|
||||
name,
|
||||
self._supplier_history_start(),
|
||||
self._supplier_history_end(),
|
||||
datetime.now(UTC).date(),
|
||||
)
|
||||
if hasattr(self, "session")
|
||||
else None
|
||||
@ -626,9 +616,9 @@ class ConversationService:
|
||||
"supplierScore": supplier_dimension.score if supplier_dimension else None,
|
||||
"topProblems": problems,
|
||||
"suggestions": self._supplier_management_suggestions(tier),
|
||||
"frequencyBasis": "按自然年度边界采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
|
||||
"frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
|
||||
"note": (
|
||||
"合作次数按自然年度边界采购订单去重单据编号统计。"
|
||||
"合作次数按近3年采购订单去重单据编号统计。"
|
||||
if has_order_data
|
||||
else "未匹配到该供应商采购订单,不展示合作次数。"
|
||||
),
|
||||
@ -677,8 +667,6 @@ 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)
|
||||
@ -938,7 +926,7 @@ class ConversationService:
|
||||
[
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= since,
|
||||
AnomalyCase.date <= self._supplier_history_end(),
|
||||
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||
]
|
||||
)
|
||||
rows = await self.session.execute(
|
||||
@ -1082,7 +1070,7 @@ class ConversationService:
|
||||
self.session,
|
||||
supplier,
|
||||
self._supplier_history_start(),
|
||||
self._supplier_history_end(),
|
||||
datetime.now(UTC).date(),
|
||||
)
|
||||
if supplier
|
||||
else None
|
||||
@ -1175,9 +1163,9 @@ class ConversationService:
|
||||
),
|
||||
tier=tier,
|
||||
suggestion=";".join(self._supplier_management_suggestions(tier)),
|
||||
frequency_basis="按自然年度边界采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
|
||||
frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
|
||||
note=(
|
||||
"合作次数按自然年度边界采购订单去重单据编号统计;异常数按自然年度边界异常案例统计。"
|
||||
"合作次数按采购订单去重单据编号统计;异常数仍按近3年异常案例统计。"
|
||||
if cooperation_count
|
||||
else "未匹配到该供应商采购订单,不展示合作次数。"
|
||||
),
|
||||
@ -1209,18 +1197,14 @@ class ConversationService:
|
||||
column.ilike(f"%{value}%"),
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= self._supplier_history_start(),
|
||||
AnomalyCase.date <= self._supplier_history_end(),
|
||||
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||
)
|
||||
)
|
||||
return int(count or 0)
|
||||
|
||||
@classmethod
|
||||
def _supplier_history_start(cls) -> date:
|
||||
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]
|
||||
return datetime.now(UTC).date() - timedelta(days=cls._supplier_history_days)
|
||||
|
||||
async def _supplier_history_stats(
|
||||
self,
|
||||
@ -1238,7 +1222,7 @@ class ConversationService:
|
||||
column.ilike(f"%{value}%"),
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= self._supplier_history_start(),
|
||||
AnomalyCase.date <= self._supplier_history_end(),
|
||||
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||
)
|
||||
.group_by(AnomalyCase.defect_level)
|
||||
)
|
||||
@ -1358,7 +1342,7 @@ class ConversationService:
|
||||
),
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= self._supplier_history_start(),
|
||||
AnomalyCase.date <= self._supplier_history_end(),
|
||||
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||
)
|
||||
)
|
||||
return int(count or 0)
|
||||
|
||||
@ -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 calendar_year_window, supplier_order_counts
|
||||
from app.services.purchase_orders import supplier_order_counts
|
||||
|
||||
STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理")
|
||||
|
||||
@ -107,7 +107,8 @@ class DatabaseRiskScoringService:
|
||||
return await supplier_order_counts(
|
||||
self.session,
|
||||
party_name,
|
||||
*calendar_year_window(self.as_of, 3),
|
||||
self.as_of - timedelta(days=365 * 3),
|
||||
self.as_of,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - legacy/test databases may not have the order ledger yet
|
||||
return None
|
||||
@ -126,7 +127,7 @@ class DatabaseRiskScoringService:
|
||||
self.frequency_cap = float(config.frequency_cap)
|
||||
|
||||
async def _load_rows(self) -> list[dict[str, Any]]:
|
||||
start, end = calendar_year_window(self.as_of, 3)
|
||||
start = self.as_of - timedelta(days=365 * 3)
|
||||
statement = select(
|
||||
AnomalyCase.date,
|
||||
AnomalyCase.product_name,
|
||||
@ -139,7 +140,7 @@ class DatabaseRiskScoringService:
|
||||
).where(
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= start,
|
||||
AnomalyCase.date <= end,
|
||||
AnomalyCase.date <= self.as_of,
|
||||
)
|
||||
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
|
||||
|
||||
@ -199,9 +200,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,
|
||||
|
||||
@ -16,20 +16,6 @@ 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
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
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
|
||||
@ -18,7 +17,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 ReportCard, RiskAssessmentResult
|
||||
from app.schemas.risk_prediction import RiskAssessmentResult
|
||||
|
||||
LEVEL_LABELS = {
|
||||
"low": "低风险",
|
||||
@ -29,182 +28,6 @@ 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"),
|
||||
@ -250,13 +73,6 @@ 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),
|
||||
@ -313,7 +129,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(standards)} 条判定标准,待检测项需人工确认"
|
||||
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])
|
||||
@ -337,51 +153,27 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
|
||||
]
|
||||
|
||||
supplier = result.supplier_freq
|
||||
supplier_cards = report_data["supplier_cards"]
|
||||
if (supplier and supplier.supplier_name) or supplier_cards:
|
||||
if supplier and supplier.supplier_name:
|
||||
supplier_rows = [["维度", "数值", "说明"]]
|
||||
if supplier and supplier.cooperation_count is not None:
|
||||
supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "按自然年度边界采购订单去重单据数"])
|
||||
if supplier:
|
||||
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 and supplier.alpha is not None:
|
||||
if supplier.alpha is not None:
|
||||
supplier_rows.append(
|
||||
["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
|
||||
)
|
||||
if supplier and supplier.tier:
|
||||
if 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])]
|
||||
|
||||
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 += [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 similar_cases
|
||||
for case in result.similar_cases
|
||||
],
|
||||
[28 * mm, 42 * mm, 48 * mm, 42 * mm],
|
||||
)
|
||||
@ -389,53 +181,28 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
|
||||
else:
|
||||
story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small))
|
||||
|
||||
story += [Paragraph(f"{'六' if section_shift else '五'}、预防建议", section)]
|
||||
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
|
||||
story += [Paragraph("五、预防建议", section)]
|
||||
for index, suggestion in enumerate(
|
||||
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
|
||||
):
|
||||
story.append(Paragraph(f"{index}. {suggestion}", body))
|
||||
story += [
|
||||
Paragraph(f"{'七' if section_shift else '六'}、AI 推理方案(无历史案例场景)", section),
|
||||
Paragraph("六、AI 推理方案(无历史案例场景)", section),
|
||||
Paragraph(
|
||||
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。",
|
||||
body,
|
||||
),
|
||||
]
|
||||
|
||||
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 += [Paragraph("七、标准判定结果", section)]
|
||||
if result.standard_judgment:
|
||||
story.append(
|
||||
table(
|
||||
[["判定维度", "判定标准", "标准来源", "当前状态"]]
|
||||
+ standard_rows,
|
||||
+ [
|
||||
[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],
|
||||
)
|
||||
)
|
||||
@ -477,13 +244,6 @@ 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()
|
||||
@ -549,7 +309,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(standards)} 条判定标准,待检测项需人工确认"
|
||||
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])
|
||||
@ -567,101 +327,51 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
|
||||
],
|
||||
]
|
||||
)
|
||||
supplier_cards = report_data["supplier_cards"]
|
||||
if (result.supplier_freq and result.supplier_freq.supplier_name) or supplier_cards:
|
||||
if result.supplier_freq and result.supplier_freq.supplier_name:
|
||||
heading("三、供应商合作频次分析")
|
||||
rows = [["维度", "数值", "说明"]]
|
||||
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:
|
||||
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 and result.supplier_freq.alpha is not None:
|
||||
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 and result.supplier_freq.tier:
|
||||
if 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)
|
||||
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:
|
||||
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 similar_cases
|
||||
for case in result.similar_cases
|
||||
]
|
||||
)
|
||||
else:
|
||||
document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。")
|
||||
heading(f"{'六' if section_shift else '五'}、预防建议")
|
||||
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
|
||||
heading("五、预防建议")
|
||||
for index, suggestion in enumerate(
|
||||
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
|
||||
):
|
||||
document.add_paragraph(f"{index}. {suggestion}")
|
||||
heading(f"{'七' if section_shift else '六'}、AI 推理方案(无历史案例场景)")
|
||||
heading("六、AI 推理方案(无历史案例场景)")
|
||||
document.add_paragraph(
|
||||
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。"
|
||||
)
|
||||
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:
|
||||
heading("七、标准判定结果")
|
||||
if result.standard_judgment:
|
||||
add_table(
|
||||
[["判定维度", "判定标准", "标准来源", "当前状态"]]
|
||||
+ standard_rows
|
||||
+ [
|
||||
[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("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。")
|
||||
|
||||
output = BytesIO()
|
||||
document.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
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 calendar_year_window, supplier_order_counts
|
||||
from app.services.purchase_orders import supplier_order_counts
|
||||
|
||||
|
||||
class SupplierProfileService:
|
||||
history_years = 3
|
||||
history_days = 365 * 3
|
||||
recent_months = 6
|
||||
max_categories = 5
|
||||
max_recent_records = 10
|
||||
@ -35,14 +35,6 @@ 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,
|
||||
@ -65,21 +57,15 @@ 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:
|
||||
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)
|
||||
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,
|
||||
history_start,
|
||||
history_end,
|
||||
self.as_of - timedelta(days=self.history_days),
|
||||
self.as_of,
|
||||
)
|
||||
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)
|
||||
@ -130,19 +116,11 @@ class SupplierProfileService:
|
||||
monthly_trend=self._monthly_trend(rows),
|
||||
recent_records=self._recent_records(rows),
|
||||
factory_recommendations=[],
|
||||
recommendation_note=(
|
||||
"该供应商仅命中历史导入记录,部分异常缺少发生日期;本页展示可识别的历史记录,"
|
||||
"不将日期缺失数据混入近3年趋势。"
|
||||
if used_legacy_fallback
|
||||
else "合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。"
|
||||
),
|
||||
recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
|
||||
)
|
||||
|
||||
async def _load_supplier_summary(
|
||||
self,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
include_undated: bool = False,
|
||||
self, *, keyword: str | None = None
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Load only supplier aggregates for the selector.
|
||||
|
||||
@ -152,18 +130,19 @@ class SupplierProfileService:
|
||||
the remote PostgreSQL connection.
|
||||
"""
|
||||
|
||||
start, end = calendar_year_window(self.as_of, self.history_years)
|
||||
start = self.as_of - timedelta(days=self.history_days)
|
||||
statement = (
|
||||
select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count())
|
||||
.where(AnomalyCase.supplier.is_not(None))
|
||||
.group_by(AnomalyCase.supplier, AnomalyCase.defect_level)
|
||||
)
|
||||
if not include_undated:
|
||||
statement = statement.where(
|
||||
.where(
|
||||
AnomalyCase.supplier.is_not(None),
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= start,
|
||||
AnomalyCase.date <= end,
|
||||
AnomalyCase.date <= self.as_of,
|
||||
)
|
||||
.group_by(AnomalyCase.supplier, AnomalyCase.defect_level)
|
||||
)
|
||||
if keyword and keyword.strip():
|
||||
statement = statement.where(AnomalyCase.supplier.ilike(f"%{keyword.strip()}%"))
|
||||
grouped: dict[str, dict[str, int]] = defaultdict(
|
||||
lambda: {"total": 0, "a": 0, "b": 0}
|
||||
)
|
||||
@ -171,8 +150,6 @@ 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
|
||||
@ -183,13 +160,8 @@ class SupplierProfileService:
|
||||
value["b"] += amount
|
||||
return grouped
|
||||
|
||||
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)
|
||||
async def _load_rows(self, *, supplier_name: str | None = None) -> list[dict]:
|
||||
start = self.as_of - timedelta(days=self.history_days)
|
||||
statement = select(
|
||||
AnomalyCase.id,
|
||||
AnomalyCase.date,
|
||||
@ -200,12 +172,10 @@ class SupplierProfileService:
|
||||
AnomalyCase.factory,
|
||||
AnomalyCase.anomaly_type,
|
||||
AnomalyCase.defect_level,
|
||||
).where()
|
||||
if not include_undated:
|
||||
statement = statement.where(
|
||||
).where(
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= start,
|
||||
AnomalyCase.date <= end,
|
||||
AnomalyCase.date <= self.as_of,
|
||||
)
|
||||
if supplier_name:
|
||||
statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%"))
|
||||
@ -213,41 +183,7 @@ class SupplierProfileService:
|
||||
|
||||
@staticmethod
|
||||
def matches(value: str | None, target: str) -> bool:
|
||||
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}
|
||||
return bool(value and target.strip().lower() in value.lower())
|
||||
|
||||
@classmethod
|
||||
def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]:
|
||||
@ -287,7 +223,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
|
||||
|
||||
@ -104,19 +104,6 @@ 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(
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
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
|
||||
@ -1,62 +0,0 @@
|
||||
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
|
||||
@ -14,33 +14,6 @@ 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user