"""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] ]