214 lines
8.7 KiB
Python
214 lines
8.7 KiB
Python
"""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
|
||
|
||
|
||
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)
|
||
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=None,
|
||
anomaly_total=anomaly_total,
|
||
anomaly_rate=None,
|
||
frequency_alpha=self.alpha(
|
||
anomaly_total,
|
||
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]
|
||
]
|