dev #1

Merged
kx_guo merged 24 commits from dev into main 2026-09-21 10:51:18 +08:00
17 changed files with 755 additions and 29 deletions
Showing only changes of commit 680b0c20cf - Show all commits

View File

@ -8,6 +8,7 @@ from app.api.v1.dashboard import router as dashboard_router
from app.api.v1.health import router as health_router
from app.api.v1.model_config import router as model_config_router
from app.api.v1.ocr import router as ocr_router
from app.api.v1.reports import router as reports_router
from app.api.v1.risk_items import router as risk_items_router
from app.api.v1.risk_predictions import router as risk_predictions_router
from app.api.v1.standards import router as standards_router
@ -21,6 +22,7 @@ api_router.include_router(auth_router)
api_router.include_router(conversations_router)
api_router.include_router(dashboard_router)
api_router.include_router(risk_items_router)
api_router.include_router(reports_router)
api_router.include_router(risk_predictions_router)
api_router.include_router(standards_router)
api_router.include_router(suppliers_router)

38
app/api/v1/reports.py Normal file
View File

@ -0,0 +1,38 @@
"""Downloadable risk report endpoints."""
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi.responses import Response
from app.api.dependencies import require_permission
from app.models.entities import UserAccount
from app.schemas.risk_prediction import RiskAssessmentResult
from app.services.reports import build_risk_report_docx, build_risk_report_pdf
router = APIRouter(prefix="/reports", tags=["reports"])
@router.post("/pdf")
async def download_report_pdf(
payload: RiskAssessmentResult,
_current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))],
) -> Response:
pdf = build_risk_report_pdf(payload)
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="dieya-risk-report.pdf"'},
)
@router.post("/word")
async def download_report_word(
payload: RiskAssessmentResult,
_current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))],
) -> Response:
return Response(
content=build_risk_report_docx(payload),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": 'attachment; filename="dieya-risk-report.docx"'},
)

View File

@ -5,6 +5,7 @@ from app.models.entities import (
Conversation,
ConversationMessage,
OperationAudit,
PurchaseOrderLine,
ReviewRecord,
RiskAssessment,
RiskItem,
@ -24,6 +25,7 @@ __all__ = [
"RiskAssessment",
"RiskItem",
"RiskModelConfig",
"PurchaseOrderLine",
"RolePermission",
"StandardRule",
"SupplierProfile",

View File

@ -94,6 +94,36 @@ class SupplierProfile(Base):
)
class PurchaseOrderLine(Base):
"""A source row from the procurement order ledger."""
__tablename__ = "purchase_order_lines"
__table_args__ = (
Index("ix_purchase_order_lines_supplier_date", "supplier", "purchase_date"),
Index("ix_purchase_order_lines_document", "document_number"),
Index("uq_purchase_order_lines_source", "import_fingerprint", unique=True),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
document_number: Mapped[str] = mapped_column(String(64), nullable=False)
purchase_date: Mapped[date | None] = mapped_column(Date)
supplier: Mapped[str] = mapped_column(String(300), nullable=False)
document_status: Mapped[str | None] = mapped_column(String(30))
close_status: Mapped[str | None] = mapped_column(String(30))
material_code: Mapped[str | None] = mapped_column(String(200))
material_name: Mapped[str | None] = mapped_column(Text)
purchase_unit: Mapped[str | None] = mapped_column(String(30))
purchase_quantity: Mapped[Decimal | None] = mapped_column(Numeric(18, 4))
delivery_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tax_inclusive_unit_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 6))
is_gift: Mapped[str | None] = mapped_column(String(10))
business_close: Mapped[str | None] = mapped_column(String(30))
remark: Mapped[str | None] = mapped_column(Text)
source_file: Mapped[str] = mapped_column(String(300), nullable=False)
import_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class RiskAssessment(Base):
"""A user-triggered risk assessment and its structured result."""

View File

@ -46,7 +46,7 @@ class SupplierFrequency(ApiSchema):
alpha: float | None = None
tier: str | None = None
suggestion: str | None = None
frequency_basis: str = "近3年异常记录数"
frequency_basis: str = "暂无匹配采购订单"
note: str | None = None
data_available: bool = False

View File

@ -23,7 +23,7 @@ class SupplierMetric(ApiSchema):
frequency_alpha: float
risk_score: float | None = None
main_category_count: int
cooperation_count_basis: str = "暂无采购订单台账;合作次数不可由异常记录推算"
cooperation_count_basis: str = "按近3年采购订单去重单据编号统计"
anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率"

View File

@ -29,6 +29,7 @@ from app.schemas.risk_prediction import (
)
from app.services.database_risk_scoring import DatabaseRiskScoringService
from app.services.llm_client import LLMClient
from app.services.purchase_orders import supplier_order_counts
from app.services.rag import AnomalyCaseRAG
logger = logging.getLogger("dieya_risk_prediction.conversations")
@ -556,6 +557,18 @@ class ConversationService:
is_factory = any(word in text for word in ("加工厂", "工厂", "生产厂")) and "供应商" not in text
column = AnomalyCase.factory if is_factory else AnomalyCase.supplier
anomaly_count, a_count, b_count = await self._supplier_history_stats(column, name)
cooperation_count = (
await supplier_order_counts(
self.session,
name,
self._supplier_history_start(),
datetime.now(UTC).date(),
)
if hasattr(self, "session")
else None
)
has_order_data = bool(cooperation_count)
cooperation_display = cooperation_count if has_order_data else None
problems = await self._top_anomaly_types(
column,
name,
@ -569,14 +582,20 @@ class ConversationService:
"name": name,
"partyType": party_type,
"anomalyCount": anomaly_count,
"cooperationCount": anomaly_count,
"cooperationCount": cooperation_display,
"tier": tier,
"frequencyCoefficient": round(min(1.5, 1 + anomaly_count / 100 * 0.3), 2) if anomaly_count else 1.0,
"frequencyCoefficient": round(min(1.5, 1 + cooperation_display / 100 * 0.3), 2)
if cooperation_display is not None
else 1.0,
"topProblems": problems,
"suggestions": self._supplier_management_suggestions(tier),
"frequencyBasis": "近3年异常记录数",
"note": "合作次数为近3年异常记录数代理值,不代表真实采购订单合作次数。",
"dataAvailable": True,
"frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
"note": (
"合作次数按近3年采购订单去重单据编号统计。"
if has_order_data
else "未匹配到该供应商采购订单,不展示合作次数。"
),
"dataAvailable": has_order_data,
}
async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
@ -973,8 +992,18 @@ class ConversationService:
anomaly_type=self._semantic_text(semantics, "anomaly_type"),
)
alpha_count = supplier_count if supplier else 0
alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if alpha_count else 1.0
cooperation_count = (
await supplier_order_counts(
self.session,
supplier,
self._supplier_history_start(),
datetime.now(UTC).date(),
)
if supplier
else None
)
alpha_count = cooperation_count or 0
alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if cooperation_count else 1.0
tier = (
self._supplier_tier(
alpha_count,
@ -1048,14 +1077,18 @@ class ConversationService:
"supplier_freq": (
SupplierFrequency(
supplier_name=supplier,
cooperation_count=supplier_count,
cooperation_count=cooperation_count,
anomaly_count=supplier_count,
alpha=alpha,
tier=tier,
suggestion=";".join(self._supplier_management_suggestions(tier)),
frequency_basis="近3年异常记录数",
note="合作次数为近3年异常记录数代理值,不代表真实采购订单合作次数。",
data_available=True,
frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
note=(
"合作次数按采购订单去重单据编号统计;异常数仍按近3年异常案例统计。"
if cooperation_count
else "未匹配到该供应商采购订单,不展示合作次数。"
),
data_available=bool(cooperation_count),
)
if supplier
else SupplierFrequency(

View File

@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS
from app.domain.risk_engine import RiskLevel
from app.models.entities import AnomalyCase, RiskModelConfig
from app.services.purchase_orders import supplier_order_counts
STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理")
@ -66,8 +67,9 @@ class DatabaseRiskScoringService:
stage_name = normalize_stage(stage)
party_name = clean_value(supplier)
cooperation_count = await self._supplier_order_count(party_name)
dimensions = (
self._supplier_dimension(rows, party_name),
self._supplier_dimension(rows, party_name, cooperation_count),
self._material_dimension(rows, material_name),
self._category_dimension(rows, category_name),
DimensionScore(
@ -90,6 +92,19 @@ class DatabaseRiskScoringService:
score = round(sum(item.score * item.weight / 100 for item in dimensions), 2)
return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions)
async def _supplier_order_count(self, party_name: str | None) -> int | None:
if not party_name or not hasattr(self.session, "execute"):
return None
try:
return await supplier_order_counts(
self.session,
party_name,
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
async def _load_model_config(self) -> None:
get = getattr(self.session, "get", None)
if get is None:
@ -121,7 +136,12 @@ class DatabaseRiskScoringService:
)
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
def _supplier_dimension(self, rows: list[dict[str, Any]], party_name: str | None) -> DimensionScore:
def _supplier_dimension(
self,
rows: list[dict[str, Any]],
party_name: str | None,
cooperation_count: int | None = None,
) -> DimensionScore:
weight = self.weights["供应商表现"]
if not party_name:
return DimensionScore(
@ -132,7 +152,8 @@ class DatabaseRiskScoringService:
reason="未提供供应商/加工厂,按 PRD 缺失维度默认 50 分",
)
matched = [row for row in rows if party_matches(row, party_name)]
count = len(matched)
anomaly_count = len(matched)
count = cooperation_count if cooperation_count is not None and cooperation_count > 0 else anomaly_count
if count == 0:
base = 70.0
elif count <= 20:
@ -170,8 +191,12 @@ class DatabaseRiskScoringService:
score=score,
weight=weight,
status="database",
reason=f"近 3 年异常 {count} 条,基础分 {base:g},α={alpha:.2f}",
sample_size=count,
reason=(
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}"
),
sample_size=anomaly_count,
raw_value=count,
)

View File

@ -0,0 +1,130 @@
"""Procurement-order ledger import and supplier cooperation aggregation."""
from __future__ import annotations
import hashlib
import re
from datetime import date, datetime
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.entities import PurchaseOrderLine
def clean_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).replace("\u200b", "").strip()
return text or None
def parse_date(value: Any) -> date | None:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = clean_text(value)
if not text:
return None
text = re.sub(r"\s+.*$", "", text).replace("/", "-").replace("年", "-").replace("月", "-").replace("日", "")
for fmt in ("%Y-%m-%d", "%Y.%m.%d"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def parse_datetime(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value
text = clean_text(value)
if not text:
return None
for fmt in ("%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%d"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None
def fingerprint(file_name: str, row_number: int) -> str:
return hashlib.sha256(f"{file_name}\x1fSheet1\x1f{row_number}".encode()).hexdigest()
def read_purchase_order_rows(path: Path) -> list[dict[str, Any]]:
workbook = load_workbook(path, read_only=True, data_only=True)
sheet = workbook.active
headers = [clean_text(value) for value in next(sheet.iter_rows(values_only=True))]
required = ["单据编号", "采购日期", "供应商"]
missing = [name for name in required if name not in headers]
if missing:
raise ValueError(f"采购订单缺少必要字段:{', '.join(missing)}")
indexes = {name: headers.index(name) for name in headers if name}
def value(row: tuple[Any, ...], name: str) -> Any:
index = indexes.get(name)
return row[index] if index is not None and index < len(row) else None
rows: list[dict[str, Any]] = []
for row_number, raw in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
document = clean_text(value(raw, "单据编号"))
supplier = clean_text(value(raw, "供应商"))
if not document or not supplier:
continue
rows.append(
{
"document_number": document,
"purchase_date": parse_date(value(raw, "采购日期")),
"supplier": supplier,
"document_status": clean_text(value(raw, "单据状态")),
"close_status": clean_text(value(raw, "关闭状态")),
"material_code": clean_text(value(raw, "物料编码")),
"material_name": clean_text(value(raw, "物料名称")),
"purchase_unit": clean_text(value(raw, "采购单位")),
"purchase_quantity": value(raw, "采购数量"),
"delivery_date": parse_datetime(value(raw, "交货日期")),
"tax_inclusive_unit_price": value(raw, "含税单价"),
"is_gift": clean_text(value(raw, "是否赠品")),
"business_close": clean_text(value(raw, "业务关闭")),
"remark": clean_text(value(raw, "备注")),
"source_file": path.name,
"import_fingerprint": fingerprint(path.name, row_number),
}
)
return rows
async def import_purchase_orders(session: AsyncSession, path: Path) -> dict[str, int]:
rows = read_purchase_order_rows(path)
inserted = 0
for start in range(0, len(rows), 500):
batch = rows[start : start + 500]
result = await session.execute(
insert(PurchaseOrderLine).values(batch).on_conflict_do_nothing(index_elements=["import_fingerprint"])
)
inserted += result.rowcount or 0
await session.flush()
return {"source_rows": len(rows), "inserted_rows": inserted, "skipped_rows": len(rows) - inserted}
async def supplier_order_counts(
session: AsyncSession,
supplier: str,
start: date,
end: date,
) -> int:
value = await session.scalar(
select(func.count(func.distinct(PurchaseOrderLine.document_number))).where(
PurchaseOrderLine.supplier.ilike(f"%{supplier}%"),
PurchaseOrderLine.purchase_date >= start,
PurchaseOrderLine.purchase_date <= end,
)
)
return int(value or 0)

377
app/services/reports.py Normal file
View File

@ -0,0 +1,377 @@
"""PDF rendering for the prototype-aligned risk report."""
from io import BytesIO
from pathlib import Path
from docx import Document
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.shared import Pt, RGBColor
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle
from app.schemas.risk_prediction import RiskAssessmentResult
LEVEL_LABELS = {
"low": "低风险",
"moderately_low": "较低风险",
"medium": "中等风险",
"high": "高风险",
"very_high": "极高风险",
}
def _font_name() -> str:
candidates = [
Path("C:/Windows/Fonts/msyh.ttc"),
Path("C:/Windows/Fonts/msyh.ttf"),
Path("C:/Windows/Fonts/simhei.ttf"),
]
for path in candidates:
if path.exists():
name = "DieyaCjk"
pdfmetrics.registerFont(TTFont(name, str(path), subfontIndex=0))
return name
return "Helvetica"
def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
font = _font_name()
styles = getSampleStyleSheet()
title = ParagraphStyle(
"title", parent=styles["Title"], fontName=font, fontSize=20, alignment=TA_CENTER, spaceAfter=4
)
subtitle = ParagraphStyle(
"subtitle",
parent=styles["Normal"],
fontName=font,
fontSize=9,
textColor=colors.HexColor("#64748b"),
alignment=TA_CENTER,
spaceAfter=14,
)
section = ParagraphStyle(
"section",
parent=styles["Heading2"],
fontName=font,
fontSize=11,
textColor=colors.HexColor("#111827"),
leftIndent=5,
borderPadding=3,
spaceBefore=12,
spaceAfter=7,
)
body = ParagraphStyle("body", parent=styles["BodyText"], fontName=font, fontSize=9, leading=15)
small = ParagraphStyle("small", parent=body, fontSize=8, textColor=colors.HexColor("#64748b"))
supplier_name = result.supplier_freq.supplier_name if result.supplier_freq else None
report_number = f"RP-{result.subject_id[:8]}-{round(result.score)}"
generated_at = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M")
story = [
Paragraph("新品风险预估报告", title),
Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle),
Paragraph(
f"产品/评估对象:{result.subject_id}  供应商:{supplier_name or '未指定'}  报告编号:{report_number}",
body,
),
]
def table(rows: list[list[str]], widths: list[float] | None = None) -> Table:
converted = [[Paragraph(str(cell), body) for cell in row] for row in rows]
item = Table(converted, colWidths=widths, repeatRows=1)
item.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#111827")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, -1), font),
("GRID", (0, 0), (-1, -1), 0.45, colors.HexColor("#dbe3ef")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 7),
("RIGHTPADDING", (0, 0), (-1, -1), 7),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
return item
story += [Paragraph("一、风险结论", section)]
rows = [
["评估项", "结论"],
[
"综合评分",
f"{round(result.score)} / 100 · "
f"{LEVEL_LABELS.get(result.level.value, result.level.value)} · "
f"{result.risk_badge or '需关注'}",
],
["风险警示", result.alert_text or "当前暂无足够历史数据,建议按标准库完成基础验证。"],
]
dimensions = result.dimension_scores or [
{"dimension": factor.name, "score": 100 - factor.value, "weight": factor.weight, "reason": None}
for factor in result.factors
]
for item in dimensions:
name = item.dimension if hasattr(item, "dimension") else item["dimension"]
if name in {"节点阶段", "缺陷等级"}:
continue
score = item.score if hasattr(item, "score") else item["score"]
weight = item.weight if hasattr(item, "weight") else item["weight"]
reason = item.reason if hasattr(item, "reason") else item.get("reason")
supplier = result.supplier_freq
if name == "供应商表现" and supplier:
order_text = f"合作 {supplier.cooperation_count} 次、" if supplier.cooperation_count is not None else ""
conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次"
elif name == "标准符合性" and result.standard_judgment:
conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认"
else:
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
rows.append([f"{name}({weight:g}%)", conclusion])
story += [table(rows, [55 * mm, 105 * mm])]
if result.category_warning:
story += [
Paragraph("二、品类风险预警", section),
table(
[
["品类", "历史异常", "风险等级", "建议"],
[
result.category_warning.category,
f"{result.category_warning.anomaly_count} 条",
result.category_warning.risk_level,
result.category_warning.suggestion or "结合相似品类和标准库预判",
],
],
[35 * mm, 30 * mm, 30 * mm, 65 * mm],
),
]
supplier = result.supplier_freq
if supplier and supplier.supplier_name:
supplier_rows = [["维度", "数值", "说明"]]
if supplier.cooperation_count is not None:
supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"])
supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"])
if supplier.alpha is not None:
supplier_rows.append(
["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
)
if supplier.tier:
supplier_rows.append(["供应商层级", supplier.tier, supplier.suggestion or ""])
story += [Paragraph("三、供应商合作频次分析", section), table(supplier_rows, [38 * mm, 35 * mm, 87 * mm])]
story += [Paragraph(f"四、相似案例匹配({len(result.similar_cases)} 条)", section)]
if result.similar_cases:
story.append(
table(
[["日期", "产品", "问题", "处理方案"]]
+ [
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
for case in result.similar_cases
],
[28 * mm, 42 * mm, 48 * mm, 42 * mm],
)
)
else:
story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small))
story += [Paragraph("五、预防建议", section)]
for index, suggestion in enumerate(
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
):
story.append(Paragraph(f"{index}. {suggestion}", body))
story += [
Paragraph("六、AI 推理方案(无历史案例场景)", section),
Paragraph(
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。",
body,
),
]
story += [Paragraph("七、标准判定结果", section)]
if result.standard_judgment:
story.append(
table(
[["判定维度", "判定标准", "标准来源", "当前状态"]]
+ [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
for item in result.standard_judgment
],
[38 * mm, 55 * mm, 40 * mm, 27 * mm],
)
)
else:
story.append(Paragraph("暂无相关标准。", small))
story.append(Paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。", small))
output = BytesIO()
doc = SimpleDocTemplate(
output,
pagesize=A4,
rightMargin=18 * mm,
leftMargin=18 * mm,
topMargin=16 * mm,
bottomMargin=16 * mm,
title="蝶雅新品风险预估报告",
)
doc.build(story)
return output.getvalue()
def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
document = Document()
section = document.sections[0]
section.page_width = 210 * 36000
section.page_height = 297 * 36000
section.top_margin = 16 * 36000
section.bottom_margin = 16 * 36000
section.left_margin = 18 * 36000
section.right_margin = 18 * 36000
normal = document.styles["Normal"]
normal.font.name = "微软雅黑"
normal.font.size = Pt(9)
title = document.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title.add_run("新品风险预估报告")
run.bold = True
run.font.size = Pt(20)
subtitle = document.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
subtitle.add_run(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {result.subject_id}").font.size = Pt(9)
def heading(text: str) -> None:
p = document.add_paragraph()
p.paragraph_format.space_before = Pt(12)
p.paragraph_format.space_after = Pt(6)
p.paragraph_format.left_indent = Pt(5)
r = p.add_run(text)
r.bold = True
r.font.size = Pt(11)
def add_table(rows: list[list[str]]) -> None:
table = document.add_table(rows=len(rows), cols=len(rows[0]))
table.style = "Table Grid"
table.autofit = True
for row_index, row in enumerate(rows):
for col_index, value in enumerate(row):
cell = table.cell(row_index, col_index)
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.TOP
cell.text = str(value)
for paragraph in cell.paragraphs:
paragraph.paragraph_format.space_after = Pt(0)
for run in paragraph.runs:
run.font.name = "微软雅黑"
run.font.size = Pt(9)
if row_index == 0:
run.bold = True
run.font.color.rgb = RGBColor(255, 255, 255)
if row_index == 0:
shading = OxmlElement("w:shd")
shading.set("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}fill", "111827")
cell._tc.get_or_add_tcPr().append(shading)
document.add_paragraph()
heading("一、风险结论")
rows = [
["评估项", "结论"],
[
"综合评分",
f"{round(result.score)} / 100 · "
f"{LEVEL_LABELS.get(result.level.value, result.level.value)} · "
f"{result.risk_badge or '需关注'}",
],
["风险警示", result.alert_text or "当前暂无足够历史数据,建议按标准库完成基础验证。"],
]
dimensions = result.dimension_scores or [
{
"dimension": factor.name,
"score": 100 - factor.value,
"weight": factor.weight,
"reason": None,
}
for factor in result.factors
]
for item in dimensions:
name = item.dimension if hasattr(item, "dimension") else item["dimension"]
if name in {"节点阶段", "缺陷等级"}:
continue
score = item.score if hasattr(item, "score") else item["score"]
weight = item.weight if hasattr(item, "weight") else item["weight"]
reason = item.reason if hasattr(item, "reason") else item.get("reason")
supplier = result.supplier_freq
if name == "供应商表现" and supplier:
order_text = f"合作 {supplier.cooperation_count} 次、" if supplier.cooperation_count is not None else ""
conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次"
elif name == "标准符合性" and result.standard_judgment:
conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认"
else:
conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "")
rows.append([f"{name}({weight:g}%)", conclusion])
add_table(rows)
if result.category_warning:
heading("二、品类风险预警")
add_table(
[
["品类", "历史异常", "风险等级", "建议"],
[
result.category_warning.category,
f"{result.category_warning.anomaly_count} 条",
result.category_warning.risk_level,
result.category_warning.suggestion or "结合相似品类和标准库预判",
],
]
)
if result.supplier_freq and result.supplier_freq.supplier_name:
heading("三、供应商合作频次分析")
rows = [["维度", "数值", "说明"]]
if result.supplier_freq.cooperation_count is not None:
rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"])
rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"])
if result.supplier_freq.alpha is not None:
rows.append(
["频次风险系数", f"×{result.supplier_freq.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"]
)
if result.supplier_freq.tier:
rows.append(["供应商层级", result.supplier_freq.tier, result.supplier_freq.suggestion or ""])
add_table(rows)
heading(f"四、相似案例匹配({len(result.similar_cases)} 条)")
if result.similar_cases:
add_table(
[["日期", "产品", "问题", "处理方案"]]
+ [
[case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"]
for case in result.similar_cases
]
)
else:
document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。")
heading("五、预防建议")
for index, suggestion in enumerate(
result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1
):
document.add_paragraph(f"{index}. {suggestion}")
heading("六、AI 推理方案(无历史案例场景)")
document.add_paragraph(
"本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。"
)
heading("七、标准判定结果")
if result.standard_judgment:
add_table(
[["判定维度", "判定标准", "标准来源", "当前状态"]]
+ [
[f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status]
for item in result.standard_judgment
]
)
else:
document.add_paragraph("暂无相关标准。")
document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。")
output = BytesIO()
document.save(output)
return output.getvalue()

View File

@ -19,6 +19,7 @@ from app.schemas.suppliers import (
SupplierRecentRecord,
)
from app.services.database_risk_scoring import DatabaseRiskScoringService, normalize_category
from app.services.purchase_orders import supplier_order_counts
class SupplierProfileService:
@ -57,6 +58,12 @@ class SupplierProfileService:
raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404)
anomaly_total = len(rows)
order_count = await supplier_order_counts(
self.session,
supplier_name,
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)
tier = self.tier_for(anomaly_total, a_count, b_count)
@ -68,11 +75,11 @@ class SupplierProfileService:
tier=tier,
tier_condition=self.tier_condition(tier),
metric=SupplierMetric(
cooperation_total=None,
cooperation_total=order_count,
anomaly_total=anomaly_total,
anomaly_rate=None,
frequency_alpha=self.alpha(
anomaly_total,
order_count,
coefficient=scoring.frequency_coefficient,
cap=scoring.frequency_cap,
),
@ -83,10 +90,7 @@ class SupplierProfileService:
monthly_trend=self._monthly_trend(rows),
recent_records=self._recent_records(rows),
factory_recommendations=[],
recommendation_note=(
"合作次数、供应商真实异常率和代工厂真实异常率需要采购订单或生产批次总量;"
"当前已接入近3年异常库聚合,但不会用异常记录数伪造上述指标。"
),
recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
)
async def _load_rows(self) -> list[dict]:

View File

@ -0,0 +1,48 @@
"""Create the procurement-order ledger used for real supplier cooperation counts."""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260908_0018"
down_revision: str | None = "20260907_0017"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"purchase_order_lines",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("document_number", sa.String(length=64), nullable=False),
sa.Column("purchase_date", sa.Date(), nullable=True),
sa.Column("supplier", sa.String(length=300), nullable=False),
sa.Column("document_status", sa.String(length=30), nullable=True),
sa.Column("close_status", sa.String(length=30), nullable=True),
sa.Column("material_code", sa.String(length=200), nullable=True),
sa.Column("material_name", sa.Text(), nullable=True),
sa.Column("purchase_unit", sa.String(length=30), nullable=True),
sa.Column("purchase_quantity", sa.Numeric(18, 4), nullable=True),
sa.Column("delivery_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("tax_inclusive_unit_price", sa.Numeric(18, 6), nullable=True),
sa.Column("is_gift", sa.String(length=10), nullable=True),
sa.Column("business_close", sa.String(length=30), nullable=True),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("source_file", sa.String(length=300), nullable=False),
sa.Column("import_fingerprint", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("import_fingerprint"),
)
op.create_index("ix_purchase_order_lines_supplier_date", "purchase_order_lines", ["supplier", "purchase_date"])
op.create_index("ix_purchase_order_lines_document", "purchase_order_lines", ["document_number"])
op.create_index("uq_purchase_order_lines_source", "purchase_order_lines", ["import_fingerprint"], unique=True)
op.execute("COMMENT ON TABLE purchase_order_lines IS '采购订单明细台账;供应商合作频次按单据编号去重统计。'")
def downgrade() -> None:
op.drop_index("uq_purchase_order_lines_source", table_name="purchase_order_lines")
op.drop_index("ix_purchase_order_lines_document", table_name="purchase_order_lines")
op.drop_index("ix_purchase_order_lines_supplier_date", table_name="purchase_order_lines")
op.drop_table("purchase_order_lines")

View File

@ -15,6 +15,8 @@ dependencies = [
"httpx>=0.27,<1.0",
"openpyxl>=3.1,<4.0",
"python-multipart>=0.0.9,<1.0",
"reportlab>=4.0,<5.0",
"python-docx>=1.1,<2.0",
"PyJWT>=2.8,<3.0",
"pydantic>=2.8,<3.0",
"pydantic-settings>=2.4,<3.0",

View File

@ -4,6 +4,8 @@ fastapi>=0.115,<1.0
httpx>=0.27,<1.0
openpyxl>=3.1,<4.0
python-multipart>=0.0.9,<1.0
reportlab>=4.0,<5.0
python-docx>=1.1,<2.0
PyJWT>=2.8,<3.0
pydantic>=2.8,<3.0
pydantic-settings>=2.4,<3.0

View File

@ -0,0 +1,32 @@
"""Import a procurement-order workbook into the purchase order ledger."""
import argparse
import asyncio
import logging
from pathlib import Path
from app.core.config import get_settings
from app.database.session import SessionFactory
from app.services.purchase_orders import import_purchase_orders
async def run(path: Path) -> None:
async with SessionFactory() as session:
result = await import_purchase_orders(session, path)
await session.commit()
for key, value in result.items():
print(f"{key}={value}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--file", type=Path, required=True)
args = parser.parse_args()
if not args.file.is_file():
parser.error(f"file does not exist: {args.file}")
logging.basicConfig(level=get_settings().log_level)
asyncio.run(run(args.file))
if __name__ == "__main__":
main()

View File

@ -229,12 +229,12 @@ async def test_supplier_card_uses_prd_proxy_frequency_and_tier_advice(
)
assert result["anomalyCount"] == 70
assert result["cooperationCount"] == 70
assert result["frequencyCoefficient"] == 1.21
assert result["cooperationCount"] is None
assert result["frequencyCoefficient"] == 1.0
assert result["tier"] == "B级 · 关注"
assert result["suggestions"] == ["加强巡检", "新品打样加严"]
assert result["frequencyBasis"] == "近3年异常记录数"
assert result["dataAvailable"] is True
assert result["frequencyBasis"] == "暂无匹配采购订单"
assert result["dataAvailable"] is False
def test_new_supplier_frequency_serializes_zero_proxy_count() -> None:
@ -249,7 +249,7 @@ def test_new_supplier_frequency_serializes_zero_proxy_count() -> None:
assert result["cooperationCount"] == 0
assert result["anomalyCount"] == 0
assert result["frequencyBasis"] == "近3年异常记录数"
assert result["frequencyBasis"] == "暂无匹配采购订单"
def test_unspecified_supplier_returns_displayable_no_data_frequency_card() -> None:

View File

@ -26,6 +26,7 @@ def test_prd_core_tables_are_registered() -> None:
"risk_assessments",
"risk_items",
"risk_model_configs",
"purchase_order_lines",
"role_permissions",
"standard_rules",
"supplier_profiles",