430 lines
18 KiB
Python
Raw Permalink 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
import re
from collections import Counter, defaultdict
from datetime import UTC, date, datetime
from sqlalchemy import String, func, or_, select
from sqlalchemy.exc import ProgrammingError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import AppException
from app.models.entities import AnomalyCase, AnomalyCaseSupplier, SupplierMaster
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 calendar_year_window, supplier_order_counts
class SupplierProfileService:
history_years = 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:
canonical = await self._load_master_summary(keyword=keyword, limit=limit)
if canonical:
return SupplierListData(items=canonical, default_supplier=canonical[0].name)
grouped = await self._load_supplier_summary(keyword=keyword)
# Some imported legacy rows have no anomaly date. They are excluded
# from the normal three-year risk window, but must still be discoverable
# when a user searches by a supplier/city name.
if keyword and not grouped:
grouped = await self._load_supplier_summary(
keyword=keyword,
include_undated=True,
)
items = [
SupplierOption(
name=name,
anomaly_count=summary["total"],
tier=self.tier_for(
summary["total"],
summary["a"],
summary["b"],
),
)
for name, summary in sorted(
grouped.items(), key=lambda item: (-item[1]["total"], item[0])
)[:limit]
]
return SupplierListData(
items=items,
default_supplier=items[0].name if items else None,
)
async def detail(self, supplier_name: str) -> SupplierProfileData:
linked_rows = await self._load_linked_rows(supplier_name)
all_rows = linked_rows or await self._load_rows()
rows = linked_rows or [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
used_legacy_fallback = False
if not rows:
all_rows = await self._load_rows(include_undated=True)
rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
used_legacy_fallback = bool(rows)
if not rows:
raise AppException("供应商不存在或暂无近3个自然年度异常记录", code=404, status_code=404)
anomaly_total = len(rows)
history_start, history_end = calendar_year_window(self.as_of, self.history_years)
order_count = await supplier_order_counts(
self.session,
supplier_name,
history_start,
history_end,
)
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,
rows=all_rows,
cooperation_count=order_count,
)
supplier_dimension = next(
(item for item in score.dimensions if item.name == "供应商表现"),
None,
)
cooperation_display = order_count if order_count > 0 else None
display_tier = {
"S级": "S级 · 战略",
"A级": "A级 · 优质",
"B级": "B级 · 关注",
"C级": "C级 · 高风险",
}.get(tier, tier)
return SupplierProfileData(
supplier_name=supplier_name,
tier=display_tier,
tier_condition=self.tier_condition(tier),
metric=SupplierMetric(
cooperation_total=cooperation_display,
anomaly_total=anomaly_total,
anomaly_rate=None,
frequency_alpha=(
supplier_dimension.frequency_alpha
if supplier_dimension and supplier_dimension.frequency_alpha is not None
else self.alpha(
cooperation_display or anomaly_total,
coefficient=scoring.frequency_coefficient,
cap=scoring.frequency_cap,
)
),
supplier_base_score=(supplier_dimension.base_score if supplier_dimension else None),
supplier_score=supplier_dimension.score if supplier_dimension else None,
risk_score=score.score,
main_category_count=len(categories),
),
category_breakdown=self._display_categories(categories),
monthly_trend=self._monthly_trend(rows),
recent_records=self._recent_records(rows),
factory_recommendations=[],
recommendation_note=(
"该供应商仅命中历史导入记录,部分异常缺少发生日期;本页展示可识别的历史记录,"
"不将日期缺失数据混入近3年趋势。"
if used_legacy_fallback
else "供应商画像按标准供应商关联统计;合作次数按采购订单去重单据编号统计。"
),
)
async def _load_master_summary(self, *, keyword: str | None, limit: int) -> list[SupplierOption]:
filters = [SupplierMaster.status == "启用"]
if keyword:
pattern = f"%{keyword.strip()}%"
filters.append(or_(
SupplierMaster.canonical_name.ilike(pattern),
SupplierMaster.short_name.ilike(pattern),
SupplierMaster.city.ilike(pattern),
SupplierMaster.province.ilike(pattern),
SupplierMaster.aliases.cast(String).ilike(pattern),
))
statement = (
select(SupplierMaster, func.count(AnomalyCaseSupplier.id).label("anomaly_count"))
.outerjoin(AnomalyCaseSupplier, AnomalyCaseSupplier.supplier_id == SupplierMaster.id)
.where(*filters)
.group_by(SupplierMaster.id)
.order_by(func.count(AnomalyCaseSupplier.id).desc(), SupplierMaster.canonical_name)
.limit(limit)
)
try:
async with self.session.begin_nested():
rows = (await self.session.execute(statement)).all()
except ProgrammingError:
# Allows a rolling deployment: the legacy raw supplier search stays
# available until the privileged migration has created master tables.
return []
return [SupplierOption(
name=master.canonical_name,
anomaly_count=int(count or 0),
tier="待确认" if master.review_status != "已确认" else self.tier_for(int(count or 0), 0, 0),
city=master.city,
province=master.province,
short_name=master.short_name,
review_status=master.review_status,
) for master, count in rows]
async def _load_linked_rows(self, supplier_name: str) -> list[dict]:
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,
)
.join(AnomalyCaseSupplier, AnomalyCaseSupplier.case_id == AnomalyCase.id)
.join(SupplierMaster, SupplierMaster.id == AnomalyCaseSupplier.supplier_id)
.where(or_(
SupplierMaster.canonical_name == supplier_name,
SupplierMaster.short_name == supplier_name,
SupplierMaster.aliases.cast(String).ilike(f"%{supplier_name}%"),
))
)
try:
async with self.session.begin_nested():
result = await self.session.execute(statement)
return [dict(row) for row in result.mappings().all()]
except ProgrammingError:
return []
async def _load_supplier_summary(
self,
*,
keyword: str | None = None,
include_undated: bool = False,
) -> dict[str, dict[str, int]]:
"""Load only supplier aggregates for the selector.
The selector used to fetch every anomaly row and group it in Python.
This grouped query returns a handful of rows even when the anomaly
table grows, which materially reduces first-navigation latency over
the remote PostgreSQL connection.
"""
start, end = calendar_year_window(self.as_of, self.history_years)
statement = (
select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count())
.where(AnomalyCase.supplier.is_not(None))
.group_by(AnomalyCase.supplier, AnomalyCase.defect_level)
)
if not include_undated:
statement = statement.where(
AnomalyCase.date.is_not(None),
AnomalyCase.date >= start,
AnomalyCase.date <= end,
)
grouped: dict[str, dict[str, int]] = defaultdict(
lambda: {"total": 0, "a": 0, "b": 0}
)
for supplier, defect_level, count in (await self.session.execute(statement)).all():
name = (supplier or "").strip()
if not name:
continue
if keyword and not self.matches(name, keyword):
continue
value = grouped[name]
amount = int(count or 0)
value["total"] += amount
code = self.defect_code(defect_level)
if code == "A":
value["a"] += amount
elif code == "B":
value["b"] += amount
return grouped
async def _load_rows(
self,
*,
supplier_name: str | None = None,
include_undated: bool = False,
) -> list[dict]:
start, end = calendar_year_window(self.as_of, self.history_years)
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()
if not include_undated:
statement = statement.where(
AnomalyCase.date.is_not(None),
AnomalyCase.date >= start,
AnomalyCase.date <= end,
)
if supplier_name:
statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%"))
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
@staticmethod
def matches(value: str | None, target: str) -> bool:
if not value or not target:
return False
# Supplier cells in the source workbooks often contain several names
# such as ``皓盛/佛山执得``. Match each segment separately and make
# common city/company suffixes optional so ``佛山市`` can find
# ``佛山执得`` while preserving the canonical raw value for display.
queries = SupplierProfileService._search_terms(target)
segments = SupplierProfileService._supplier_segments(value)
return any(
query and (query in segment or segment in query)
for query in queries
for segment in segments
)
@staticmethod
def _supplier_segments(value: str) -> list[str]:
segments = re.split(r"[//、,,;;|\\\\]+", value)
return [SupplierProfileService._compact_text(item) for item in segments if SupplierProfileService._compact_text(item)]
@staticmethod
def _compact_text(value: str) -> str:
return re.sub(r"[\s()()【】\[\]{}]+", "", value.casefold())
@classmethod
def _search_terms(cls, value: str) -> set[str]:
compact = cls._compact_text(value)
terms = {compact}
# A user commonly enters a city with the administrative suffix while
# the workbook keeps the company/trading name (e.g. 佛山市 vs 佛山执得).
if compact.endswith("市") and len(compact) > 2:
terms.add(compact[:-1])
for suffix in ("有限公司", "有限责任公司", "公司"):
if compact.endswith(suffix) and len(compact) > len(suffix) + 1:
terms.add(compact[: -len(suffix)])
return {term for term in terms if term}
@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:
match = re.search(r"[ABCD]", (value or "").upper())
return match.group(0) if match 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():
is_unclassified = category == "待补标品类"
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,
is_unclassified=is_unclassified,
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.is_unclassified, -item.anomaly_count, item.category))
@classmethod
def _display_categories(
cls, categories: list[SupplierCategoryBreakdown]
) -> list[SupplierCategoryBreakdown]:
"""Keep up to five real categories and always retain the unclassified row."""
real = [item for item in categories if not item.is_unclassified][: cls.max_categories]
unclassified = next((item for item in categories if item.is_unclassified), None)
return real + ([unclassified] if unclassified else [])
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]
]