From 680b0c20cfe35f8a295d72ee29c050ebafc64596 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Tue, 8 Sep 2026 11:48:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=87=87=E8=B4=AD=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=8F=B0=E8=B4=A6=E5=B9=B6=E7=BB=9F=E4=B8=80PDF?= =?UTF-8?q?=E4=B8=8EWord=E6=8A=A5=E5=91=8A=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按供应商近三年去重采购单据统计真实合作频次,替换异常记录代理值;新增采购订单导入、合作频次计算、风险评分口径同步,以及PDF/Word报告生成接口。 --- app/api/router.py | 2 + app/api/v1/reports.py | 38 ++ app/models/__init__.py | 2 + app/models/entities.py | 30 ++ app/schemas/risk_prediction.py | 2 +- app/schemas/suppliers.py | 2 +- app/services/conversations.py | 55 ++- app/services/database_risk_scoring.py | 35 +- app/services/purchase_orders.py | 130 ++++++ app/services/reports.py | 377 ++++++++++++++++++ app/services/suppliers.py | 16 +- .../20260908_0018_purchase_order_ledger.py | 48 +++ pyproject.toml | 2 + requirements.txt | 2 + scripts/import_purchase_orders.py | 32 ++ tests/test_conversations.py | 10 +- tests/test_models.py | 1 + 17 files changed, 755 insertions(+), 29 deletions(-) create mode 100644 app/api/v1/reports.py create mode 100644 app/services/purchase_orders.py create mode 100644 app/services/reports.py create mode 100644 migrations/versions/20260908_0018_purchase_order_ledger.py create mode 100644 scripts/import_purchase_orders.py diff --git a/app/api/router.py b/app/api/router.py index 4cc5194..9e2c9d8 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -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) diff --git a/app/api/v1/reports.py b/app/api/v1/reports.py new file mode 100644 index 0000000..6692d1d --- /dev/null +++ b/app/api/v1/reports.py @@ -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"'}, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py index 6f86600..4eafc59 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/entities.py b/app/models/entities.py index 6328709..253ca86 100644 --- a/app/models/entities.py +++ b/app/models/entities.py @@ -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.""" diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index f3af422..4cf102f 100644 --- a/app/schemas/risk_prediction.py +++ b/app/schemas/risk_prediction.py @@ -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 diff --git a/app/schemas/suppliers.py b/app/schemas/suppliers.py index 5417498..7421307 100644 --- a/app/schemas/suppliers.py +++ b/app/schemas/suppliers.py @@ -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 = "暂无采购订单/生产批次总量,无法计算真实异常率" diff --git a/app/services/conversations.py b/app/services/conversations.py index 9d57f15..25408e4 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -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( diff --git a/app/services/database_risk_scoring.py b/app/services/database_risk_scoring.py index 5531c06..d007f0a 100644 --- a/app/services/database_risk_scoring.py +++ b/app/services/database_risk_scoring.py @@ -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, ) diff --git a/app/services/purchase_orders.py b/app/services/purchase_orders.py new file mode 100644 index 0000000..d104043 --- /dev/null +++ b/app/services/purchase_orders.py @@ -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) diff --git a/app/services/reports.py b/app/services/reports.py new file mode 100644 index 0000000..a9192d2 --- /dev/null +++ b/app/services/reports.py @@ -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() diff --git a/app/services/suppliers.py b/app/services/suppliers.py index 19d90f9..84003d6 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -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]: diff --git a/migrations/versions/20260908_0018_purchase_order_ledger.py b/migrations/versions/20260908_0018_purchase_order_ledger.py new file mode 100644 index 0000000..0adaba4 --- /dev/null +++ b/migrations/versions/20260908_0018_purchase_order_ledger.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index db007ed..d203934 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/requirements.txt b/requirements.txt index 6a14f9b..629f82a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/import_purchase_orders.py b/scripts/import_purchase_orders.py new file mode 100644 index 0000000..83e81ae --- /dev/null +++ b/scripts/import_purchase_orders.py @@ -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() diff --git a/tests/test_conversations.py b/tests/test_conversations.py index cd2b809..fb840ff 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -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: diff --git a/tests/test_models.py b/tests/test_models.py index 9643dc3..eacd546 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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",