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))
|
return ApiResponse(data=await SupplierProfileService(session).list(keyword=keyword, limit=limit))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile", response_model=ApiResponse[SupplierProfileData])
|
@router.get("/{supplier_name}", 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(
|
async def get_supplier_profile(
|
||||||
supplier_name: str,
|
supplier_name: str,
|
||||||
session: DatabaseSession,
|
session: DatabaseSession,
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
"""Risk prediction request and response contracts."""
|
"""Risk prediction request and response contracts."""
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from app.domain.risk_engine import RiskLevel
|
from app.domain.risk_engine import RiskLevel
|
||||||
@ -105,44 +103,6 @@ class AiSolutionReport(ApiSchema):
|
|||||||
warning: str | None = None
|
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):
|
class RiskAssessmentResult(ApiSchema):
|
||||||
subject_id: str
|
subject_id: str
|
||||||
score: float
|
score: float
|
||||||
@ -158,7 +118,6 @@ class RiskAssessmentResult(ApiSchema):
|
|||||||
similar_cases: list[SimilarCase] = Field(default_factory=list)
|
similar_cases: list[SimilarCase] = Field(default_factory=list)
|
||||||
ai_suggestions: list[str] = Field(default_factory=list)
|
ai_suggestions: list[str] = Field(default_factory=list)
|
||||||
ai_solution_cards: list[AiSolutionReport] = Field(default_factory=list)
|
ai_solution_cards: list[AiSolutionReport] = Field(default_factory=list)
|
||||||
report_cards: list[ReportCard] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class ModelInfo(ApiSchema):
|
class ModelInfo(ApiSchema):
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class SupplierMetric(ApiSchema):
|
|||||||
supplier_score: float | None = None
|
supplier_score: float | None = None
|
||||||
risk_score: float | None = None
|
risk_score: float | None = None
|
||||||
main_category_count: int
|
main_category_count: int
|
||||||
cooperation_count_basis: str = "按自然年度边界采购订单去重单据编号统计"
|
cooperation_count_basis: str = "按近3年采购订单去重单据编号统计"
|
||||||
anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率"
|
anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections.abc import AsyncGenerator, Sequence
|
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 typing import Any, Literal
|
||||||
|
|
||||||
from sqlalchemy import delete, func, or_, select
|
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.database_risk_scoring import DatabaseRiskScoringService
|
||||||
from app.services.llm_client import LLMClient
|
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
|
from app.services.rag import AnomalyCaseRAG
|
||||||
|
|
||||||
logger = logging.getLogger("dieya_risk_prediction.conversations")
|
logger = logging.getLogger("dieya_risk_prediction.conversations")
|
||||||
|
|
||||||
|
|
||||||
class ConversationService:
|
class ConversationService:
|
||||||
_supplier_history_years = 3
|
_supplier_history_days = 365 * 3
|
||||||
_engine = BaselineRiskEngine()
|
_engine = BaselineRiskEngine()
|
||||||
_risk_intents = {
|
_risk_intents = {
|
||||||
"assessment",
|
"assessment",
|
||||||
@ -345,14 +345,6 @@ class ConversationService:
|
|||||||
|
|
||||||
if cls._is_placeholder_supplier_name(text):
|
if cls._is_placeholder_supplier_name(text):
|
||||||
return "general"
|
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()
|
intent = str(semantics.get("intent") or "").strip().lower()
|
||||||
if intent == "supplier" or (
|
if intent == "supplier" or (
|
||||||
any(word in text for word in ("供应商", "加工厂", "工厂"))
|
any(word in text for word in ("供应商", "加工厂", "工厂"))
|
||||||
@ -362,8 +354,6 @@ class ConversationService:
|
|||||||
return "supplier"
|
return "supplier"
|
||||||
if intent == "material" or "物料" in text or "类物料" in text:
|
if intent == "material" or "物料" in text or "类物料" in text:
|
||||||
return "material"
|
return "material"
|
||||||
if intent == "cases":
|
|
||||||
return "risk_assessment"
|
|
||||||
if intent == "standard" or any(word in text for word in ("检测报告", "检测值", "判定", "pH", "PH")):
|
if intent == "standard" or any(word in text for word in ("检测报告", "检测值", "判定", "pH", "PH")):
|
||||||
return "standard"
|
return "standard"
|
||||||
if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")):
|
if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")):
|
||||||
@ -582,7 +572,7 @@ class ConversationService:
|
|||||||
self.session,
|
self.session,
|
||||||
name,
|
name,
|
||||||
self._supplier_history_start(),
|
self._supplier_history_start(),
|
||||||
self._supplier_history_end(),
|
datetime.now(UTC).date(),
|
||||||
)
|
)
|
||||||
if hasattr(self, "session")
|
if hasattr(self, "session")
|
||||||
else None
|
else None
|
||||||
@ -626,9 +616,9 @@ class ConversationService:
|
|||||||
"supplierScore": supplier_dimension.score if supplier_dimension else None,
|
"supplierScore": supplier_dimension.score if supplier_dimension else None,
|
||||||
"topProblems": problems,
|
"topProblems": problems,
|
||||||
"suggestions": self._supplier_management_suggestions(tier),
|
"suggestions": self._supplier_management_suggestions(tier),
|
||||||
"frequencyBasis": "按自然年度边界采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
|
"frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
|
||||||
"note": (
|
"note": (
|
||||||
"合作次数按自然年度边界采购订单去重单据编号统计。"
|
"合作次数按近3年采购订单去重单据编号统计。"
|
||||||
if has_order_data
|
if has_order_data
|
||||||
else "未匹配到该供应商采购订单,不展示合作次数。"
|
else "未匹配到该供应商采购订单,不展示合作次数。"
|
||||||
),
|
),
|
||||||
@ -677,8 +667,6 @@ class ConversationService:
|
|||||||
|
|
||||||
async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
|
async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
|
||||||
material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET"))
|
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 "未指定物料"
|
material = material or self._semantic_text(semantics, "packaging") or "未指定物料"
|
||||||
query_term = "软管" if "软管" in material else material
|
query_term = "软管" if "软管" in material else material
|
||||||
count = await self._count_anomalies(AnomalyCase.material_type, query_term)
|
count = await self._count_anomalies(AnomalyCase.material_type, query_term)
|
||||||
@ -938,7 +926,7 @@ class ConversationService:
|
|||||||
[
|
[
|
||||||
AnomalyCase.date.is_not(None),
|
AnomalyCase.date.is_not(None),
|
||||||
AnomalyCase.date >= since,
|
AnomalyCase.date >= since,
|
||||||
AnomalyCase.date <= self._supplier_history_end(),
|
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
rows = await self.session.execute(
|
rows = await self.session.execute(
|
||||||
@ -1082,7 +1070,7 @@ class ConversationService:
|
|||||||
self.session,
|
self.session,
|
||||||
supplier,
|
supplier,
|
||||||
self._supplier_history_start(),
|
self._supplier_history_start(),
|
||||||
self._supplier_history_end(),
|
datetime.now(UTC).date(),
|
||||||
)
|
)
|
||||||
if supplier
|
if supplier
|
||||||
else None
|
else None
|
||||||
@ -1175,9 +1163,9 @@ class ConversationService:
|
|||||||
),
|
),
|
||||||
tier=tier,
|
tier=tier,
|
||||||
suggestion=";".join(self._supplier_management_suggestions(tier)),
|
suggestion=";".join(self._supplier_management_suggestions(tier)),
|
||||||
frequency_basis="按自然年度边界采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
|
frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
|
||||||
note=(
|
note=(
|
||||||
"合作次数按自然年度边界采购订单去重单据编号统计;异常数按自然年度边界异常案例统计。"
|
"合作次数按采购订单去重单据编号统计;异常数仍按近3年异常案例统计。"
|
||||||
if cooperation_count
|
if cooperation_count
|
||||||
else "未匹配到该供应商采购订单,不展示合作次数。"
|
else "未匹配到该供应商采购订单,不展示合作次数。"
|
||||||
),
|
),
|
||||||
@ -1209,18 +1197,14 @@ class ConversationService:
|
|||||||
column.ilike(f"%{value}%"),
|
column.ilike(f"%{value}%"),
|
||||||
AnomalyCase.date.is_not(None),
|
AnomalyCase.date.is_not(None),
|
||||||
AnomalyCase.date >= self._supplier_history_start(),
|
AnomalyCase.date >= self._supplier_history_start(),
|
||||||
AnomalyCase.date <= self._supplier_history_end(),
|
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return int(count or 0)
|
return int(count or 0)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _supplier_history_start(cls) -> date:
|
def _supplier_history_start(cls) -> date:
|
||||||
return calendar_year_window(datetime.now(UTC).date(), cls._supplier_history_years)[0]
|
return datetime.now(UTC).date() - timedelta(days=cls._supplier_history_days)
|
||||||
|
|
||||||
@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(
|
async def _supplier_history_stats(
|
||||||
self,
|
self,
|
||||||
@ -1238,7 +1222,7 @@ class ConversationService:
|
|||||||
column.ilike(f"%{value}%"),
|
column.ilike(f"%{value}%"),
|
||||||
AnomalyCase.date.is_not(None),
|
AnomalyCase.date.is_not(None),
|
||||||
AnomalyCase.date >= self._supplier_history_start(),
|
AnomalyCase.date >= self._supplier_history_start(),
|
||||||
AnomalyCase.date <= self._supplier_history_end(),
|
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||||
)
|
)
|
||||||
.group_by(AnomalyCase.defect_level)
|
.group_by(AnomalyCase.defect_level)
|
||||||
)
|
)
|
||||||
@ -1358,7 +1342,7 @@ class ConversationService:
|
|||||||
),
|
),
|
||||||
AnomalyCase.date.is_not(None),
|
AnomalyCase.date.is_not(None),
|
||||||
AnomalyCase.date >= self._supplier_history_start(),
|
AnomalyCase.date >= self._supplier_history_start(),
|
||||||
AnomalyCase.date <= self._supplier_history_end(),
|
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return int(count or 0)
|
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.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS
|
||||||
from app.domain.risk_engine import RiskLevel
|
from app.domain.risk_engine import RiskLevel
|
||||||
from app.models.entities import AnomalyCase, RiskModelConfig
|
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 = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理")
|
STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理")
|
||||||
|
|
||||||
@ -107,7 +107,8 @@ class DatabaseRiskScoringService:
|
|||||||
return await supplier_order_counts(
|
return await supplier_order_counts(
|
||||||
self.session,
|
self.session,
|
||||||
party_name,
|
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
|
except Exception: # noqa: BLE001 - legacy/test databases may not have the order ledger yet
|
||||||
return None
|
return None
|
||||||
@ -126,7 +127,7 @@ class DatabaseRiskScoringService:
|
|||||||
self.frequency_cap = float(config.frequency_cap)
|
self.frequency_cap = float(config.frequency_cap)
|
||||||
|
|
||||||
async def _load_rows(self) -> list[dict[str, Any]]:
|
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(
|
statement = select(
|
||||||
AnomalyCase.date,
|
AnomalyCase.date,
|
||||||
AnomalyCase.product_name,
|
AnomalyCase.product_name,
|
||||||
@ -139,7 +140,7 @@ class DatabaseRiskScoringService:
|
|||||||
).where(
|
).where(
|
||||||
AnomalyCase.date.is_not(None),
|
AnomalyCase.date.is_not(None),
|
||||||
AnomalyCase.date >= start,
|
AnomalyCase.date >= start,
|
||||||
AnomalyCase.date <= end,
|
AnomalyCase.date <= self.as_of,
|
||||||
)
|
)
|
||||||
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
|
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
|
||||||
|
|
||||||
@ -199,9 +200,9 @@ class DatabaseRiskScoringService:
|
|||||||
weight=weight,
|
weight=weight,
|
||||||
status="database",
|
status="database",
|
||||||
reason=(
|
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
|
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,
|
sample_size=anomaly_count,
|
||||||
raw_value=count,
|
raw_value=count,
|
||||||
|
|||||||
@ -16,20 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.models.entities import PurchaseOrderLine
|
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:
|
def clean_text(value: Any) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
||||||
@ -18,7 +17,7 @@ from reportlab.pdfbase import pdfmetrics
|
|||||||
from reportlab.pdfbase.ttfonts import TTFont
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle
|
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 = {
|
LEVEL_LABELS = {
|
||||||
"low": "低风险",
|
"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:
|
def _font_name() -> str:
|
||||||
candidates = [
|
candidates = [
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
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
|
supplier_name = result.supplier_freq.supplier_name if result.supplier_freq else None
|
||||||
report_number = f"RP-{result.subject_id[:8]}-{round(result.score)}"
|
report_number = f"RP-{result.subject_id[:8]}-{round(result.score)}"
|
||||||
generated_at = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M")
|
generated_at = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
report_data = aggregate_report_data(result)
|
|
||||||
standards = report_data["standards"]
|
|
||||||
similar_cases = report_data["similar_cases"]
|
|
||||||
suggestions = report_data["suggestions"]
|
|
||||||
material_cards = report_data["material_cards"]
|
|
||||||
standard_cards = report_data["standard_cards"]
|
|
||||||
section_shift = 1 if material_cards else 0
|
|
||||||
story = [
|
story = [
|
||||||
Paragraph("新品风险预估报告", title),
|
Paragraph("新品风险预估报告", title),
|
||||||
Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle),
|
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 ""
|
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} 次"
|
conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次"
|
||||||
elif name == "标准符合性" and result.standard_judgment:
|
elif name == "标准符合性" and result.standard_judgment:
|
||||||
conclusion = f"{round(score)} 分 · 命中 {len(standards)} 条判定标准,待检测项需人工确认"
|
conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认"
|
||||||
else:
|
else:
|
||||||
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
|
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
|
||||||
rows.append([f"{name}({weight:g}%)", conclusion])
|
rows.append([f"{name}({weight:g}%)", conclusion])
|
||||||
@ -337,51 +153,27 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
|
|||||||
]
|
]
|
||||||
|
|
||||||
supplier = result.supplier_freq
|
supplier = result.supplier_freq
|
||||||
supplier_cards = report_data["supplier_cards"]
|
if supplier and supplier.supplier_name:
|
||||||
if (supplier and supplier.supplier_name) or supplier_cards:
|
|
||||||
supplier_rows = [["维度", "数值", "说明"]]
|
supplier_rows = [["维度", "数值", "说明"]]
|
||||||
if supplier and supplier.cooperation_count is not None:
|
if supplier.cooperation_count is not None:
|
||||||
supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "按自然年度边界采购订单去重单据数"])
|
supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"])
|
||||||
if supplier:
|
|
||||||
supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"])
|
supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"])
|
||||||
if supplier and supplier.alpha is not None:
|
if supplier.alpha is not None:
|
||||||
supplier_rows.append(
|
supplier_rows.append(
|
||||||
["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
|
["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
|
||||||
)
|
)
|
||||||
if supplier and supplier.tier:
|
if supplier.tier:
|
||||||
supplier_rows.append(["供应商层级", supplier.tier, supplier.suggestion or ""])
|
supplier_rows.append(["供应商层级", supplier.tier, supplier.suggestion or ""])
|
||||||
for card in supplier_cards:
|
|
||||||
if card.name:
|
|
||||||
supplier_rows.append(["供应商名称", card.name, "供应商画像统计"])
|
|
||||||
for label, value in report_card_lines(card):
|
|
||||||
if label not in {"卡片类型", "分析对象", "对象类型"}:
|
|
||||||
supplier_rows.append([label, value, "追加供应商画像数据"])
|
|
||||||
story += [Paragraph("三、供应商合作频次分析", section), table(supplier_rows, [38 * mm, 35 * mm, 87 * mm])]
|
story += [Paragraph("三、供应商合作频次分析", section), table(supplier_rows, [38 * mm, 35 * mm, 87 * mm])]
|
||||||
|
|
||||||
if material_cards:
|
story += [Paragraph(f"四、相似案例匹配({len(result.similar_cases)} 条)", section)]
|
||||||
story += [Paragraph("四、物料风险分析", section)]
|
if result.similar_cases:
|
||||||
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(
|
story.append(
|
||||||
table(
|
table(
|
||||||
[["日期", "产品", "问题", "处理方案"]]
|
[["日期", "产品", "问题", "处理方案"]]
|
||||||
+ [
|
+ [
|
||||||
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
|
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
|
||||||
for case in similar_cases
|
for case in result.similar_cases
|
||||||
],
|
],
|
||||||
[28 * mm, 42 * mm, 48 * mm, 42 * mm],
|
[28 * mm, 42 * mm, 48 * mm, 42 * mm],
|
||||||
)
|
)
|
||||||
@ -389,53 +181,28 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
|
|||||||
else:
|
else:
|
||||||
story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small))
|
story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small))
|
||||||
|
|
||||||
story += [Paragraph(f"{'六' if section_shift else '五'}、预防建议", section)]
|
story += [Paragraph("五、预防建议", section)]
|
||||||
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
|
for index, suggestion in enumerate(
|
||||||
|
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
|
||||||
|
):
|
||||||
story.append(Paragraph(f"{index}. {suggestion}", body))
|
story.append(Paragraph(f"{index}. {suggestion}", body))
|
||||||
story += [
|
story += [
|
||||||
Paragraph(f"{'七' if section_shift else '六'}、AI 推理方案(无历史案例场景)", section),
|
Paragraph("六、AI 推理方案(无历史案例场景)", section),
|
||||||
Paragraph(
|
Paragraph(
|
||||||
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。",
|
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。",
|
||||||
body,
|
body,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
for card in report_data["solution_cards"]:
|
story += [Paragraph("七、标准判定结果", section)]
|
||||||
if card.analysis_base:
|
if result.standard_judgment:
|
||||||
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(
|
story.append(
|
||||||
table(
|
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],
|
[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 = document.add_paragraph()
|
||||||
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
subtitle.add_run(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {result.subject_id}").font.size = Pt(9)
|
subtitle.add_run(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {result.subject_id}").font.size = Pt(9)
|
||||||
report_data = aggregate_report_data(result)
|
|
||||||
standards = report_data["standards"]
|
|
||||||
similar_cases = report_data["similar_cases"]
|
|
||||||
suggestions = report_data["suggestions"]
|
|
||||||
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:
|
def heading(text: str) -> None:
|
||||||
p = document.add_paragraph()
|
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 ""
|
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} 次"
|
conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次"
|
||||||
elif name == "标准符合性" and result.standard_judgment:
|
elif name == "标准符合性" and result.standard_judgment:
|
||||||
conclusion = f"{round(score)} 分 · 命中 {len(standards)} 条判定标准,待检测项需人工确认"
|
conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认"
|
||||||
else:
|
else:
|
||||||
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
|
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
|
||||||
rows.append([f"{name}({weight:g}%)", conclusion])
|
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:
|
||||||
if (result.supplier_freq and result.supplier_freq.supplier_name) or supplier_cards:
|
|
||||||
heading("三、供应商合作频次分析")
|
heading("三、供应商合作频次分析")
|
||||||
rows = [["维度", "数值", "说明"]]
|
rows = [["维度", "数值", "说明"]]
|
||||||
if result.supplier_freq and result.supplier_freq.cooperation_count is not None:
|
if result.supplier_freq.cooperation_count is not None:
|
||||||
rows.append(
|
rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"])
|
||||||
["合作次数", f"{result.supplier_freq.cooperation_count} 次", "按自然年度边界采购订单去重单据数"]
|
|
||||||
)
|
|
||||||
if result.supplier_freq:
|
|
||||||
rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"])
|
rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"])
|
||||||
if result.supplier_freq and result.supplier_freq.alpha is not None:
|
if result.supplier_freq.alpha is not None:
|
||||||
rows.append(
|
rows.append(
|
||||||
["频次风险系数", f"×{result.supplier_freq.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
|
["频次风险系数", f"×{result.supplier_freq.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
|
||||||
)
|
)
|
||||||
if result.supplier_freq and result.supplier_freq.tier:
|
if result.supplier_freq.tier:
|
||||||
rows.append(["供应商层级", result.supplier_freq.tier, result.supplier_freq.suggestion or ""])
|
rows.append(["供应商层级", result.supplier_freq.tier, result.supplier_freq.suggestion or ""])
|
||||||
for card in supplier_cards:
|
|
||||||
if card.name:
|
|
||||||
rows.append(["供应商名称", card.name, "供应商画像统计"])
|
|
||||||
for label, value in report_card_lines(card):
|
|
||||||
if label not in {"卡片类型", "分析对象", "对象类型"}:
|
|
||||||
rows.append([label, value, "追加供应商画像数据"])
|
|
||||||
add_table(rows)
|
add_table(rows)
|
||||||
if material_cards:
|
heading(f"四、相似案例匹配({len(result.similar_cases)} 条)")
|
||||||
heading("四、物料风险分析")
|
if result.similar_cases:
|
||||||
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(
|
add_table(
|
||||||
[["日期", "产品", "问题", "处理方案"]]
|
[["日期", "产品", "问题", "处理方案"]]
|
||||||
+ [
|
+ [
|
||||||
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
|
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
|
||||||
for case in similar_cases
|
for case in result.similar_cases
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。")
|
document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。")
|
||||||
heading(f"{'六' if section_shift else '五'}、预防建议")
|
heading("五、预防建议")
|
||||||
for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1):
|
for index, suggestion in enumerate(
|
||||||
|
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
|
||||||
|
):
|
||||||
document.add_paragraph(f"{index}. {suggestion}")
|
document.add_paragraph(f"{index}. {suggestion}")
|
||||||
heading(f"{'七' if section_shift else '六'}、AI 推理方案(无历史案例场景)")
|
heading("六、AI 推理方案(无历史案例场景)")
|
||||||
document.add_paragraph(
|
document.add_paragraph(
|
||||||
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。"
|
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。"
|
||||||
)
|
)
|
||||||
for card in report_data["solution_cards"]:
|
heading("七、标准判定结果")
|
||||||
if card.analysis_base:
|
if result.standard_judgment:
|
||||||
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(
|
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:
|
else:
|
||||||
document.add_paragraph("暂无相关标准。")
|
document.add_paragraph("暂无相关标准。")
|
||||||
document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。")
|
document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。")
|
||||||
|
|
||||||
output = BytesIO()
|
output = BytesIO()
|
||||||
document.save(output)
|
document.save(output)
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from collections import Counter, defaultdict
|
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 import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@ -20,11 +20,11 @@ from app.schemas.suppliers import (
|
|||||||
SupplierRecentRecord,
|
SupplierRecentRecord,
|
||||||
)
|
)
|
||||||
from app.services.database_risk_scoring import DatabaseRiskScoringService, normalize_category
|
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:
|
class SupplierProfileService:
|
||||||
history_years = 3
|
history_days = 365 * 3
|
||||||
recent_months = 6
|
recent_months = 6
|
||||||
max_categories = 5
|
max_categories = 5
|
||||||
max_recent_records = 10
|
max_recent_records = 10
|
||||||
@ -35,14 +35,6 @@ class SupplierProfileService:
|
|||||||
|
|
||||||
async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData:
|
async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData:
|
||||||
grouped = await self._load_supplier_summary(keyword=keyword)
|
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 = [
|
items = [
|
||||||
SupplierOption(
|
SupplierOption(
|
||||||
name=name,
|
name=name,
|
||||||
@ -65,21 +57,15 @@ class SupplierProfileService:
|
|||||||
async def detail(self, supplier_name: str) -> SupplierProfileData:
|
async def detail(self, supplier_name: str) -> SupplierProfileData:
|
||||||
all_rows = await self._load_rows()
|
all_rows = await self._load_rows()
|
||||||
rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
|
rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
|
||||||
used_legacy_fallback = False
|
|
||||||
if not rows:
|
if not rows:
|
||||||
all_rows = await self._load_rows(include_undated=True)
|
raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404)
|
||||||
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)
|
anomaly_total = len(rows)
|
||||||
history_start, history_end = calendar_year_window(self.as_of, self.history_years)
|
|
||||||
order_count = await supplier_order_counts(
|
order_count = await supplier_order_counts(
|
||||||
self.session,
|
self.session,
|
||||||
supplier_name,
|
supplier_name,
|
||||||
history_start,
|
self.as_of - timedelta(days=self.history_days),
|
||||||
history_end,
|
self.as_of,
|
||||||
)
|
)
|
||||||
a_count = sum(self.defect_code(row.get("defect_level")) == "A" for row in rows)
|
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)
|
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),
|
monthly_trend=self._monthly_trend(rows),
|
||||||
recent_records=self._recent_records(rows),
|
recent_records=self._recent_records(rows),
|
||||||
factory_recommendations=[],
|
factory_recommendations=[],
|
||||||
recommendation_note=(
|
recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
|
||||||
"该供应商仅命中历史导入记录,部分异常缺少发生日期;本页展示可识别的历史记录,"
|
|
||||||
"不将日期缺失数据混入近3年趋势。"
|
|
||||||
if used_legacy_fallback
|
|
||||||
else "合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _load_supplier_summary(
|
async def _load_supplier_summary(
|
||||||
self,
|
self, *, keyword: str | None = None
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
include_undated: bool = False,
|
|
||||||
) -> dict[str, dict[str, int]]:
|
) -> dict[str, dict[str, int]]:
|
||||||
"""Load only supplier aggregates for the selector.
|
"""Load only supplier aggregates for the selector.
|
||||||
|
|
||||||
@ -152,18 +130,19 @@ class SupplierProfileService:
|
|||||||
the remote PostgreSQL connection.
|
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 = (
|
statement = (
|
||||||
select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count())
|
select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count())
|
||||||
.where(AnomalyCase.supplier.is_not(None))
|
.where(
|
||||||
.group_by(AnomalyCase.supplier, AnomalyCase.defect_level)
|
AnomalyCase.supplier.is_not(None),
|
||||||
)
|
|
||||||
if not include_undated:
|
|
||||||
statement = statement.where(
|
|
||||||
AnomalyCase.date.is_not(None),
|
AnomalyCase.date.is_not(None),
|
||||||
AnomalyCase.date >= start,
|
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(
|
grouped: dict[str, dict[str, int]] = defaultdict(
|
||||||
lambda: {"total": 0, "a": 0, "b": 0}
|
lambda: {"total": 0, "a": 0, "b": 0}
|
||||||
)
|
)
|
||||||
@ -171,8 +150,6 @@ class SupplierProfileService:
|
|||||||
name = (supplier or "").strip()
|
name = (supplier or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
if keyword and not self.matches(name, keyword):
|
|
||||||
continue
|
|
||||||
value = grouped[name]
|
value = grouped[name]
|
||||||
amount = int(count or 0)
|
amount = int(count or 0)
|
||||||
value["total"] += amount
|
value["total"] += amount
|
||||||
@ -183,13 +160,8 @@ class SupplierProfileService:
|
|||||||
value["b"] += amount
|
value["b"] += amount
|
||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
async def _load_rows(
|
async def _load_rows(self, *, supplier_name: str | None = None) -> list[dict]:
|
||||||
self,
|
start = self.as_of - timedelta(days=self.history_days)
|
||||||
*,
|
|
||||||
supplier_name: str | None = None,
|
|
||||||
include_undated: bool = False,
|
|
||||||
) -> list[dict]:
|
|
||||||
start, end = calendar_year_window(self.as_of, self.history_years)
|
|
||||||
statement = select(
|
statement = select(
|
||||||
AnomalyCase.id,
|
AnomalyCase.id,
|
||||||
AnomalyCase.date,
|
AnomalyCase.date,
|
||||||
@ -200,12 +172,10 @@ class SupplierProfileService:
|
|||||||
AnomalyCase.factory,
|
AnomalyCase.factory,
|
||||||
AnomalyCase.anomaly_type,
|
AnomalyCase.anomaly_type,
|
||||||
AnomalyCase.defect_level,
|
AnomalyCase.defect_level,
|
||||||
).where()
|
).where(
|
||||||
if not include_undated:
|
|
||||||
statement = statement.where(
|
|
||||||
AnomalyCase.date.is_not(None),
|
AnomalyCase.date.is_not(None),
|
||||||
AnomalyCase.date >= start,
|
AnomalyCase.date >= start,
|
||||||
AnomalyCase.date <= end,
|
AnomalyCase.date <= self.as_of,
|
||||||
)
|
)
|
||||||
if supplier_name:
|
if supplier_name:
|
||||||
statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%"))
|
statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%"))
|
||||||
@ -213,41 +183,7 @@ class SupplierProfileService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def matches(value: str | None, target: str) -> bool:
|
def matches(value: str | None, target: str) -> bool:
|
||||||
if not value or not target:
|
return bool(value and target.strip().lower() in value.lower())
|
||||||
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
|
@classmethod
|
||||||
def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]:
|
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%",
|
"A级": "异常记录 20~60 次,且 A/B 级缺陷占比 < 30%",
|
||||||
"B级": "异常记录 60~120 次,或 A/B 级缺陷占比 30%~50%",
|
"B级": "异常记录 60~120 次,或 A/B 级缺陷占比 30%~50%",
|
||||||
"C级": "异常记录 > 120 次,或存在 A 级缺陷,或 A/B 级缺陷占比 > 50%",
|
"C级": "异常记录 > 120 次,或存在 A 级缺陷,或 A/B 级缺陷占比 > 50%",
|
||||||
"新供应商": "近3个自然年度异常记录为 0",
|
"新供应商": "近3年异常记录为 0",
|
||||||
}[tier]
|
}[tier]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@ -104,19 +104,6 @@ def test_only_explicit_risk_requests_generate_risk_cards() -> None:
|
|||||||
) is True
|
) 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:
|
def test_legacy_general_semantics_hide_old_risk_card() -> None:
|
||||||
messages = [
|
messages = [
|
||||||
ConversationMessage(
|
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级"
|
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:
|
def test_supplier_alpha_is_capped_and_rounded() -> None:
|
||||||
assert SupplierProfileService.alpha(0) == 1.0
|
assert SupplierProfileService.alpha(0) == 1.0
|
||||||
assert SupplierProfileService.alpha(42) == 1.13
|
assert SupplierProfileService.alpha(42) == 1.13
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user