kx_guo 680b0c20cf 接入采购订单台账并统一PDF与Word报告导出
按供应商近三年去重采购单据统计真实合作频次,替换异常记录代理值;新增采购订单导入、合作频次计算、风险评分口径同步,以及PDF/Word报告生成接口。
2026-09-08 11:59:10 +08:00

218 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Live supplier profile aggregation from the approved anomaly case data."""
from __future__ import annotations
from collections import Counter, defaultdict
from datetime import UTC, date, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import AppException
from app.models.entities import AnomalyCase
from app.schemas.suppliers import (
SupplierCategoryBreakdown,
SupplierListData,
SupplierMetric,
SupplierOption,
SupplierProfileData,
SupplierRecentRecord,
)
from app.services.database_risk_scoring import DatabaseRiskScoringService, normalize_category
from app.services.purchase_orders import supplier_order_counts
class SupplierProfileService:
history_days = 365 * 3
recent_months = 6
max_categories = 5
max_recent_records = 10
def __init__(self, session: AsyncSession, *, as_of: date | None = None) -> None:
self.session = session
self.as_of = as_of or datetime.now(UTC).date()
async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData:
rows = await self._load_rows()
grouped = self._group_suppliers(rows, keyword)
items = [
SupplierOption(
name=name,
anomaly_count=len(items),
tier=self.tier_for(
len(items),
sum(self.defect_code(row.get("defect_level")) == "A" for row in items),
sum(self.defect_code(row.get("defect_level")) == "B" for row in items),
),
)
for name, items in sorted(grouped.items(), key=lambda item: (-len(item[1]), item[0]))[:limit]
]
return SupplierListData(
items=items,
default_supplier=items[0].name if items else None,
)
async def detail(self, supplier_name: str) -> SupplierProfileData:
rows = [row for row in await self._load_rows() if self.matches(row.get("supplier"), supplier_name)]
if not rows:
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)
categories = self._categories(rows)
scoring = DatabaseRiskScoringService(self.session, as_of=self.as_of)
score = await scoring.score(supplier=supplier_name)
return SupplierProfileData(
supplier_name=supplier_name,
tier=tier,
tier_condition=self.tier_condition(tier),
metric=SupplierMetric(
cooperation_total=order_count,
anomaly_total=anomaly_total,
anomaly_rate=None,
frequency_alpha=self.alpha(
order_count,
coefficient=scoring.frequency_coefficient,
cap=scoring.frequency_cap,
),
risk_score=score.score,
main_category_count=len(categories),
),
category_breakdown=categories[: self.max_categories],
monthly_trend=self._monthly_trend(rows),
recent_records=self._recent_records(rows),
factory_recommendations=[],
recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
)
async def _load_rows(self) -> list[dict]:
start = self.as_of - timedelta(days=self.history_days)
statement = select(
AnomalyCase.id,
AnomalyCase.date,
AnomalyCase.product_name,
AnomalyCase.product_category,
AnomalyCase.material_type,
AnomalyCase.supplier,
AnomalyCase.factory,
AnomalyCase.anomaly_type,
AnomalyCase.defect_level,
).where(
AnomalyCase.date.is_not(None),
AnomalyCase.date >= start,
AnomalyCase.date <= self.as_of,
)
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
@staticmethod
def matches(value: str | None, target: str) -> bool:
return bool(value and target.strip().lower() in value.lower())
@classmethod
def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]:
grouped: dict[str, list[dict]] = defaultdict(list)
for row in rows:
name = (row.get("supplier") or "").strip()
if name and (not keyword or cls.matches(name, keyword)):
grouped[name].append(row)
return grouped
@staticmethod
def defect_code(value: str | None) -> str | None:
value = (value or "").strip().upper()
return value if value in {"A", "B", "C"} else None
@staticmethod
def alpha(anomaly_count: int, *, coefficient: float = 0.3, cap: float = 1.5) -> float:
return round(min(cap, 1 + anomaly_count / 100 * coefficient), 2)
@classmethod
def tier_for(cls, anomaly_count: int, a_count: int, b_count: int) -> str:
if anomaly_count == 0:
return "新供应商"
ab_ratio = (a_count + b_count) / anomaly_count
if anomaly_count > 120 or a_count > 0 or ab_ratio > 0.5:
return "C级"
if anomaly_count > 60 or ab_ratio >= 0.3:
return "B级"
if anomaly_count >= 20:
return "A级"
return "S级"
@staticmethod
def tier_condition(tier: str) -> str:
return {
"S级": "异常记录 < 20 次,且无 A 级缺陷",
"A级": "异常记录 20~60 次,且 A/B 级缺陷占比 < 30%",
"B级": "异常记录 60~120 次,或 A/B 级缺陷占比 30%~50%",
"C级": "异常记录 > 120 次,或存在 A 级缺陷,或 A/B 级缺陷占比 > 50%",
"新供应商": "近3年异常记录为 0",
}[tier]
@classmethod
def _categories(cls, rows: list[dict]) -> list[SupplierCategoryBreakdown]:
grouped: dict[str, list[dict]] = defaultdict(list)
for row in rows:
category = normalize_category(row.get("product_category")) or normalize_category(row.get("product_name"))
grouped[category or "待补标品类"].append(row)
result = []
for category, items in grouped.items():
materials = Counter(row.get("material_type") for row in items if row.get("material_type"))
problems = Counter(row.get("anomaly_type") for row in items if row.get("anomaly_type"))
result.append(
SupplierCategoryBreakdown(
category=category,
anomaly_count=len(items),
anomaly_rate=None,
top_material=materials.most_common(1)[0][0] if materials else None,
top_problem=problems.most_common(1)[0][0] if problems else None,
)
)
return sorted(result, key=lambda item: (-item.anomaly_count, item.category))
def _monthly_trend(self, rows: list[dict]) -> list[dict[str, int | str]]:
start = self._month_offset(self.as_of.replace(day=1), -5)
counts = Counter(
row["date"].strftime("%Y-%m")
for row in rows
if row.get("date") and row["date"] >= start
)
output = []
cursor = start
for _ in range(self.recent_months):
key = cursor.strftime("%Y-%m")
output.append({"month": key, "count": counts.get(key, 0)})
cursor = date(cursor.year + (cursor.month == 12), 1 if cursor.month == 12 else cursor.month + 1, 1)
return output
@staticmethod
def _month_offset(value: date, months: int) -> date:
index = value.year * 12 + value.month - 1 + months
return date(index // 12, index % 12 + 1, 1)
def _recent_records(self, rows: list[dict]) -> list[SupplierRecentRecord]:
start = self._month_offset(self.as_of.replace(day=1), -5)
recent = sorted(
(row for row in rows if row.get("date") and row["date"] >= start),
key=lambda row: row["date"],
reverse=True,
)
return [
SupplierRecentRecord(
case_id=int(row["id"]),
month=row["date"].strftime("%Y-%m") if row.get("date") else None,
product=row.get("product_name"),
anomaly=row.get("anomaly_type"),
defect_level=self.defect_code(row.get("defect_level")),
)
for row in recent[: self.max_recent_records]
]