Compare commits
No commits in common. "5c7163d79ae638923fe438686b240919acd43626" and "680b0c20cfe35f8a295d72ee29c050ebafc64596" have entirely different histories.
5c7163d79a
...
680b0c20cf
@ -7,7 +7,6 @@ from app.api.v1.conversations import router as conversations_router
|
|||||||
from app.api.v1.dashboard import router as dashboard_router
|
from app.api.v1.dashboard import router as dashboard_router
|
||||||
from app.api.v1.health import router as health_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.model_config import router as model_config_router
|
||||||
from app.api.v1.monitoring import router as monitoring_router
|
|
||||||
from app.api.v1.ocr import router as ocr_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.reports import router as reports_router
|
||||||
from app.api.v1.risk_items import router as risk_items_router
|
from app.api.v1.risk_items import router as risk_items_router
|
||||||
@ -18,7 +17,6 @@ from app.api.v1.suppliers import router as suppliers_router
|
|||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(health_router)
|
api_router.include_router(health_router)
|
||||||
api_router.include_router(model_config_router)
|
api_router.include_router(model_config_router)
|
||||||
api_router.include_router(monitoring_router)
|
|
||||||
api_router.include_router(ocr_router)
|
api_router.include_router(ocr_router)
|
||||||
api_router.include_router(auth_router)
|
api_router.include_router(auth_router)
|
||||||
api_router.include_router(conversations_router)
|
api_router.include_router(conversations_router)
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.api.dependencies import DatabaseSession, require_permission
|
|
||||||
from app.models.entities import OperationAudit, UserAccount
|
|
||||||
from app.schemas.common import ApiResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/monitoring", tags=["monitoring"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/risk")
|
|
||||||
async def monitor_risk(
|
|
||||||
payload: dict,
|
|
||||||
session: DatabaseSession,
|
|
||||||
current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))],
|
|
||||||
) -> ApiResponse[dict]:
|
|
||||||
session.add(
|
|
||||||
OperationAudit(
|
|
||||||
actor_user_id=current_user.id,
|
|
||||||
actor_emp_no=current_user.emp_no,
|
|
||||||
action="risk.monitor.add",
|
|
||||||
resource_type="risk_assessment",
|
|
||||||
resource_id=str(payload.get("subject_id") or ""),
|
|
||||||
audit_metadata=payload,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ApiResponse(data={"monitored": True, "subject_id": payload.get("subject_id")})
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
|
||||||
|
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.config import Settings, get_settings
|
from app.core.config import Settings, get_settings
|
||||||
@ -26,7 +25,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["Authorization", "Content-Type", "Accept"],
|
allow_headers=["Authorization", "Content-Type", "Accept"],
|
||||||
)
|
)
|
||||||
application.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5)
|
|
||||||
application.add_exception_handler(AppException, app_exception_handler)
|
application.add_exception_handler(AppException, app_exception_handler)
|
||||||
application.include_router(api_router)
|
application.include_router(api_router)
|
||||||
return application
|
return application
|
||||||
|
|||||||
@ -79,7 +79,6 @@ class SimilarCase(ApiSchema):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
root_cause: str | None = None
|
root_cause: str | None = None
|
||||||
defect_level: str | None = None
|
defect_level: str | None = None
|
||||||
result: str | None = None
|
|
||||||
problem: str | None = None
|
problem: str | None = None
|
||||||
solution: str | None = None
|
solution: str | None = None
|
||||||
similarity: float | None = None
|
similarity: float | None = None
|
||||||
|
|||||||
@ -21,8 +21,6 @@ class SupplierMetric(ApiSchema):
|
|||||||
anomaly_total: int
|
anomaly_total: int
|
||||||
anomaly_rate: float | None = None
|
anomaly_rate: float | None = None
|
||||||
frequency_alpha: float
|
frequency_alpha: float
|
||||||
supplier_base_score: float | None = None
|
|
||||||
supplier_score: float | None = None
|
|
||||||
risk_score: float | None = None
|
risk_score: float | None = None
|
||||||
main_category_count: int
|
main_category_count: int
|
||||||
cooperation_count_basis: str = "按近3年采购订单去重单据编号统计"
|
cooperation_count_basis: str = "按近3年采购订单去重单据编号统计"
|
||||||
@ -31,7 +29,6 @@ class SupplierMetric(ApiSchema):
|
|||||||
|
|
||||||
class SupplierCategoryBreakdown(ApiSchema):
|
class SupplierCategoryBreakdown(ApiSchema):
|
||||||
category: str
|
category: str
|
||||||
is_unclassified: bool = False
|
|
||||||
anomaly_count: int
|
anomaly_count: int
|
||||||
anomaly_rate: float | None = None
|
anomaly_rate: float | None = None
|
||||||
top_material: str | None = None
|
top_material: str | None = None
|
||||||
|
|||||||
@ -576,16 +576,6 @@ class ConversationService:
|
|||||||
)
|
)
|
||||||
party_type = "加工厂" if is_factory else "供应商"
|
party_type = "加工厂" if is_factory else "供应商"
|
||||||
tier = self._supplier_tier(anomaly_count, a_count=a_count, b_count=b_count)
|
tier = self._supplier_tier(anomaly_count, a_count=a_count, b_count=b_count)
|
||||||
supplier_dimension = None
|
|
||||||
if not is_factory and hasattr(self, "session"):
|
|
||||||
database_score = await DatabaseRiskScoringService(
|
|
||||||
self.session,
|
|
||||||
as_of=datetime.now(UTC).date(),
|
|
||||||
).score(supplier=name)
|
|
||||||
supplier_dimension = next(
|
|
||||||
(item for item in database_score.dimensions if item.name == "供应商表现"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"type": "supplier",
|
"type": "supplier",
|
||||||
"title": "供应商深度画像",
|
"title": "供应商深度画像",
|
||||||
@ -594,15 +584,9 @@ class ConversationService:
|
|||||||
"anomalyCount": anomaly_count,
|
"anomalyCount": anomaly_count,
|
||||||
"cooperationCount": cooperation_display,
|
"cooperationCount": cooperation_display,
|
||||||
"tier": tier,
|
"tier": tier,
|
||||||
"frequencyCoefficient": (
|
"frequencyCoefficient": round(min(1.5, 1 + cooperation_display / 100 * 0.3), 2)
|
||||||
supplier_dimension.frequency_alpha
|
|
||||||
if supplier_dimension and supplier_dimension.frequency_alpha is not None
|
|
||||||
else round(min(1.5, 1 + cooperation_display / 100 * 0.3), 2)
|
|
||||||
if cooperation_display is not None
|
if cooperation_display is not None
|
||||||
else 1.0
|
else 1.0,
|
||||||
),
|
|
||||||
"supplierBaseScore": supplier_dimension.base_score if supplier_dimension else None,
|
|
||||||
"supplierScore": supplier_dimension.score if supplier_dimension else None,
|
|
||||||
"topProblems": problems,
|
"topProblems": problems,
|
||||||
"suggestions": self._supplier_management_suggestions(tier),
|
"suggestions": self._supplier_management_suggestions(tier),
|
||||||
"frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
|
"frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
|
||||||
@ -974,10 +958,7 @@ class ConversationService:
|
|||||||
supplier = None
|
supplier = None
|
||||||
supplier_or_factory = supplier or factory
|
supplier_or_factory = supplier or factory
|
||||||
|
|
||||||
database_score = await DatabaseRiskScoringService(
|
database_score = await DatabaseRiskScoringService(self.session).score(
|
||||||
self.session,
|
|
||||||
as_of=datetime.now(UTC).date(),
|
|
||||||
).score(
|
|
||||||
category=category,
|
category=category,
|
||||||
material=self._first_match(
|
material=self._first_match(
|
||||||
text,
|
text,
|
||||||
@ -1025,7 +1006,7 @@ class ConversationService:
|
|||||||
alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if cooperation_count else 1.0
|
alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if cooperation_count else 1.0
|
||||||
tier = (
|
tier = (
|
||||||
self._supplier_tier(
|
self._supplier_tier(
|
||||||
supplier_count,
|
alpha_count,
|
||||||
a_count=supplier_a_count,
|
a_count=supplier_a_count,
|
||||||
b_count=supplier_b_count,
|
b_count=supplier_b_count,
|
||||||
)
|
)
|
||||||
@ -1052,10 +1033,6 @@ class ConversationService:
|
|||||||
)
|
)
|
||||||
for item in database_score.dimensions
|
for item in database_score.dimensions
|
||||||
]
|
]
|
||||||
supplier_dimension = next(
|
|
||||||
(item for item in database_score.dimensions if item.name == "供应商表现"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
factors = [
|
factors = [
|
||||||
RiskFactorResult(
|
RiskFactorResult(
|
||||||
name=item.name,
|
name=item.name,
|
||||||
@ -1102,11 +1079,7 @@ class ConversationService:
|
|||||||
supplier_name=supplier,
|
supplier_name=supplier,
|
||||||
cooperation_count=cooperation_count,
|
cooperation_count=cooperation_count,
|
||||||
anomaly_count=supplier_count,
|
anomaly_count=supplier_count,
|
||||||
alpha=(
|
alpha=alpha,
|
||||||
supplier_dimension.frequency_alpha
|
|
||||||
if supplier_dimension and supplier_dimension.frequency_alpha is not None
|
|
||||||
else alpha
|
|
||||||
),
|
|
||||||
tier=tier,
|
tier=tier,
|
||||||
suggestion=";".join(self._supplier_management_suggestions(tier)),
|
suggestion=";".join(self._supplier_management_suggestions(tier)),
|
||||||
frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
|
frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
|
||||||
|
|||||||
@ -26,8 +26,6 @@ class DimensionScore:
|
|||||||
reason: str | None = None
|
reason: str | None = None
|
||||||
sample_size: int | None = None
|
sample_size: int | None = None
|
||||||
raw_value: float | int | None = None
|
raw_value: float | int | None = None
|
||||||
base_score: float | None = None
|
|
||||||
frequency_alpha: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@ -61,25 +59,19 @@ class DatabaseRiskScoringService:
|
|||||||
factory: str | None = None,
|
factory: str | None = None,
|
||||||
stage: str | None = None,
|
stage: str | None = None,
|
||||||
defect_level: str | None = None,
|
defect_level: str | None = None,
|
||||||
rows: list[dict[str, Any]] | None = None,
|
|
||||||
cooperation_count: int | None = None,
|
|
||||||
) -> DatabaseRiskScore:
|
) -> DatabaseRiskScore:
|
||||||
await self._load_model_config()
|
await self._load_model_config()
|
||||||
source_rows = rows if rows is not None else await self._load_rows()
|
rows = await self._load_rows()
|
||||||
category_name = normalize_category(category)
|
category_name = normalize_category(category)
|
||||||
material_name = normalize_material(material)
|
material_name = normalize_material(material)
|
||||||
stage_name = normalize_stage(stage)
|
stage_name = normalize_stage(stage)
|
||||||
party_name = clean_value(supplier)
|
party_name = clean_value(supplier)
|
||||||
|
|
||||||
resolved_cooperation_count = (
|
cooperation_count = await self._supplier_order_count(party_name)
|
||||||
cooperation_count
|
|
||||||
if cooperation_count is not None
|
|
||||||
else await self._supplier_order_count(party_name)
|
|
||||||
)
|
|
||||||
dimensions = (
|
dimensions = (
|
||||||
self._supplier_dimension(source_rows, party_name, resolved_cooperation_count),
|
self._supplier_dimension(rows, party_name, cooperation_count),
|
||||||
self._material_dimension(source_rows, material_name),
|
self._material_dimension(rows, material_name),
|
||||||
self._category_dimension(source_rows, category_name),
|
self._category_dimension(rows, category_name),
|
||||||
DimensionScore(
|
DimensionScore(
|
||||||
name="加工厂质量",
|
name="加工厂质量",
|
||||||
score=50.0,
|
score=50.0,
|
||||||
@ -94,8 +86,8 @@ class DatabaseRiskScoringService:
|
|||||||
status="default",
|
status="default",
|
||||||
reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入",
|
reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入",
|
||||||
),
|
),
|
||||||
self._stage_dimension(source_rows, stage_name),
|
self._stage_dimension(rows, stage_name),
|
||||||
self._defect_dimension(source_rows, category_name, material_name, party_name, defect_level),
|
self._defect_dimension(rows, category_name, material_name, party_name, defect_level),
|
||||||
)
|
)
|
||||||
score = round(sum(item.score * item.weight / 100 for item in dimensions), 2)
|
score = round(sum(item.score * item.weight / 100 for item in dimensions), 2)
|
||||||
return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions)
|
return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions)
|
||||||
@ -206,8 +198,6 @@ class DatabaseRiskScoringService:
|
|||||||
),
|
),
|
||||||
sample_size=anomaly_count,
|
sample_size=anomaly_count,
|
||||||
raw_value=count,
|
raw_value=count,
|
||||||
base_score=base,
|
|
||||||
frequency_alpha=alpha,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _material_dimension(self, rows: list[dict[str, Any]], material: str | None) -> DimensionScore:
|
def _material_dimension(self, rows: list[dict[str, Any]], material: str | None) -> DimensionScore:
|
||||||
|
|||||||
@ -123,7 +123,6 @@ class AnomalyCaseRAG:
|
|||||||
AnomalyCase.solution,
|
AnomalyCase.solution,
|
||||||
AnomalyCase.root_cause,
|
AnomalyCase.root_cause,
|
||||||
AnomalyCase.defect_level,
|
AnomalyCase.defect_level,
|
||||||
AnomalyCase.result,
|
|
||||||
)
|
)
|
||||||
vector_ready = await self._vector_column_ready()
|
vector_ready = await self._vector_column_ready()
|
||||||
has_vectors = await self._has_stored_vectors() if vector_ready else False
|
has_vectors = await self._has_stored_vectors() if vector_ready else False
|
||||||
@ -136,7 +135,7 @@ class AnomalyCaseRAG:
|
|||||||
"""
|
"""
|
||||||
SELECT id, date, product_name, product_category, material_type, supplier,
|
SELECT id, date, product_name, product_category, material_type, supplier,
|
||||||
factory, stage_new, anomaly_type, description, solution, root_cause,
|
factory, stage_new, anomaly_type, description, solution, root_cause,
|
||||||
defect_level, result, 1 - (embedding_vector <=> CAST(:embedding AS halfvec)) AS vector_score
|
defect_level, 1 - (embedding_vector <=> CAST(:embedding AS halfvec)) AS vector_score
|
||||||
FROM anomaly_cases
|
FROM anomaly_cases
|
||||||
WHERE embedding_vector IS NOT NULL
|
WHERE embedding_vector IS NOT NULL
|
||||||
ORDER BY embedding_vector <=> CAST(:embedding AS halfvec)
|
ORDER BY embedding_vector <=> CAST(:embedding AS halfvec)
|
||||||
@ -307,7 +306,6 @@ class AnomalyCaseRAG:
|
|||||||
description=row.get("description"),
|
description=row.get("description"),
|
||||||
root_cause=row.get("root_cause"),
|
root_cause=row.get("root_cause"),
|
||||||
defect_level=row.get("defect_level"),
|
defect_level=row.get("defect_level"),
|
||||||
result=row.get("result"),
|
|
||||||
problem=(row.get("anomaly_type") or row.get("description") or "历史异常未填写"),
|
problem=(row.get("anomaly_type") or row.get("description") or "历史异常未填写"),
|
||||||
solution=(row.get("solution") or row.get("root_cause") or "请查看原始案例记录"),
|
solution=(row.get("solution") or row.get("root_cause") or "请查看原始案例记录"),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.exceptions import AppException
|
from app.core.exceptions import AppException
|
||||||
@ -34,20 +33,19 @@ class SupplierProfileService:
|
|||||||
self.as_of = as_of or datetime.now(UTC).date()
|
self.as_of = as_of or datetime.now(UTC).date()
|
||||||
|
|
||||||
async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData:
|
async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData:
|
||||||
grouped = await self._load_supplier_summary(keyword=keyword)
|
rows = await self._load_rows()
|
||||||
|
grouped = self._group_suppliers(rows, keyword)
|
||||||
items = [
|
items = [
|
||||||
SupplierOption(
|
SupplierOption(
|
||||||
name=name,
|
name=name,
|
||||||
anomaly_count=summary["total"],
|
anomaly_count=len(items),
|
||||||
tier=self.tier_for(
|
tier=self.tier_for(
|
||||||
summary["total"],
|
len(items),
|
||||||
summary["a"],
|
sum(self.defect_code(row.get("defect_level")) == "A" for row in items),
|
||||||
summary["b"],
|
sum(self.defect_code(row.get("defect_level")) == "B" for row in items),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for name, summary in sorted(
|
for name, items in sorted(grouped.items(), key=lambda item: (-len(item[1]), item[0]))[:limit]
|
||||||
grouped.items(), key=lambda item: (-item[1]["total"], item[0])
|
|
||||||
)[:limit]
|
|
||||||
]
|
]
|
||||||
return SupplierListData(
|
return SupplierListData(
|
||||||
items=items,
|
items=items,
|
||||||
@ -55,8 +53,7 @@ class SupplierProfileService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def detail(self, supplier_name: str) -> SupplierProfileData:
|
async def detail(self, supplier_name: str) -> SupplierProfileData:
|
||||||
all_rows = await self._load_rows()
|
rows = [row for row in await self._load_rows() if self.matches(row.get("supplier"), supplier_name)]
|
||||||
rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
|
|
||||||
if not rows:
|
if not rows:
|
||||||
raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404)
|
raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404)
|
||||||
|
|
||||||
@ -72,95 +69,31 @@ class SupplierProfileService:
|
|||||||
tier = self.tier_for(anomaly_total, a_count, b_count)
|
tier = self.tier_for(anomaly_total, a_count, b_count)
|
||||||
categories = self._categories(rows)
|
categories = self._categories(rows)
|
||||||
scoring = DatabaseRiskScoringService(self.session, as_of=self.as_of)
|
scoring = DatabaseRiskScoringService(self.session, as_of=self.as_of)
|
||||||
score = await scoring.score(
|
score = await scoring.score(supplier=supplier_name)
|
||||||
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(
|
return SupplierProfileData(
|
||||||
supplier_name=supplier_name,
|
supplier_name=supplier_name,
|
||||||
tier=display_tier,
|
tier=tier,
|
||||||
tier_condition=self.tier_condition(tier),
|
tier_condition=self.tier_condition(tier),
|
||||||
metric=SupplierMetric(
|
metric=SupplierMetric(
|
||||||
cooperation_total=cooperation_display,
|
cooperation_total=order_count,
|
||||||
anomaly_total=anomaly_total,
|
anomaly_total=anomaly_total,
|
||||||
anomaly_rate=None,
|
anomaly_rate=None,
|
||||||
frequency_alpha=(
|
frequency_alpha=self.alpha(
|
||||||
supplier_dimension.frequency_alpha
|
order_count,
|
||||||
if supplier_dimension and supplier_dimension.frequency_alpha is not None
|
|
||||||
else self.alpha(
|
|
||||||
cooperation_display or anomaly_total,
|
|
||||||
coefficient=scoring.frequency_coefficient,
|
coefficient=scoring.frequency_coefficient,
|
||||||
cap=scoring.frequency_cap,
|
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,
|
risk_score=score.score,
|
||||||
main_category_count=len(categories),
|
main_category_count=len(categories),
|
||||||
),
|
),
|
||||||
category_breakdown=self._display_categories(categories),
|
category_breakdown=categories[: self.max_categories],
|
||||||
monthly_trend=self._monthly_trend(rows),
|
monthly_trend=self._monthly_trend(rows),
|
||||||
recent_records=self._recent_records(rows),
|
recent_records=self._recent_records(rows),
|
||||||
factory_recommendations=[],
|
factory_recommendations=[],
|
||||||
recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
|
recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _load_supplier_summary(
|
async def _load_rows(self) -> list[dict]:
|
||||||
self, *, keyword: str | None = None
|
|
||||||
) -> 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 = self.as_of - timedelta(days=self.history_days)
|
|
||||||
statement = (
|
|
||||||
select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count())
|
|
||||||
.where(
|
|
||||||
AnomalyCase.supplier.is_not(None),
|
|
||||||
AnomalyCase.date.is_not(None),
|
|
||||||
AnomalyCase.date >= start,
|
|
||||||
AnomalyCase.date <= self.as_of,
|
|
||||||
)
|
|
||||||
.group_by(AnomalyCase.supplier, AnomalyCase.defect_level)
|
|
||||||
)
|
|
||||||
if keyword and keyword.strip():
|
|
||||||
statement = statement.where(AnomalyCase.supplier.ilike(f"%{keyword.strip()}%"))
|
|
||||||
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
|
|
||||||
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) -> list[dict]:
|
|
||||||
start = self.as_of - timedelta(days=self.history_days)
|
start = self.as_of - timedelta(days=self.history_days)
|
||||||
statement = select(
|
statement = select(
|
||||||
AnomalyCase.id,
|
AnomalyCase.id,
|
||||||
@ -177,8 +110,6 @@ class SupplierProfileService:
|
|||||||
AnomalyCase.date >= start,
|
AnomalyCase.date >= start,
|
||||||
AnomalyCase.date <= self.as_of,
|
AnomalyCase.date <= self.as_of,
|
||||||
)
|
)
|
||||||
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()]
|
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -196,8 +127,8 @@ class SupplierProfileService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def defect_code(value: str | None) -> str | None:
|
def defect_code(value: str | None) -> str | None:
|
||||||
match = re.search(r"[ABCD]", (value or "").upper())
|
value = (value or "").strip().upper()
|
||||||
return match.group(0) if match else None
|
return value if value in {"A", "B", "C"} else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def alpha(anomaly_count: int, *, coefficient: float = 0.3, cap: float = 1.5) -> float:
|
def alpha(anomaly_count: int, *, coefficient: float = 0.3, cap: float = 1.5) -> float:
|
||||||
@ -234,30 +165,18 @@ class SupplierProfileService:
|
|||||||
grouped[category or "待补标品类"].append(row)
|
grouped[category or "待补标品类"].append(row)
|
||||||
result = []
|
result = []
|
||||||
for category, items in grouped.items():
|
for category, items in grouped.items():
|
||||||
is_unclassified = category == "待补标品类"
|
|
||||||
materials = Counter(row.get("material_type") for row in items if row.get("material_type"))
|
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"))
|
problems = Counter(row.get("anomaly_type") for row in items if row.get("anomaly_type"))
|
||||||
result.append(
|
result.append(
|
||||||
SupplierCategoryBreakdown(
|
SupplierCategoryBreakdown(
|
||||||
category=category,
|
category=category,
|
||||||
is_unclassified=is_unclassified,
|
|
||||||
anomaly_count=len(items),
|
anomaly_count=len(items),
|
||||||
anomaly_rate=None,
|
anomaly_rate=None,
|
||||||
top_material=materials.most_common(1)[0][0] if materials else 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,
|
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))
|
return sorted(result, key=lambda item: (-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]]:
|
def _monthly_trend(self, rows: list[dict]) -> list[dict[str, int | str]]:
|
||||||
start = self._month_offset(self.as_of.replace(day=1), -5)
|
start = self._month_offset(self.as_of.replace(day=1), -5)
|
||||||
|
|||||||
@ -47,32 +47,6 @@ def test_supplier_category_breakdown_is_top_five_with_material_and_problem() ->
|
|||||||
assert result[0].top_problem == "漏液"
|
assert result[0].top_problem == "漏液"
|
||||||
|
|
||||||
|
|
||||||
def test_unclassified_category_is_marked_and_sorted_after_real_categories() -> None:
|
|
||||||
rows = [
|
|
||||||
{"product_category": None, "product_name": "未命名产品", "material_type": "瓶子", "anomaly_type": "刮花"},
|
|
||||||
{"product_category": "洗发水", "product_name": "洗发水", "material_type": "泵头", "anomaly_type": "漏液"},
|
|
||||||
]
|
|
||||||
|
|
||||||
result = SupplierProfileService._categories(rows)
|
|
||||||
|
|
||||||
assert result[0].category == "洗发水/洗护类"
|
|
||||||
assert result[0].is_unclassified is False
|
|
||||||
assert result[1].category == "待补标品类"
|
|
||||||
assert result[1].is_unclassified is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_display_categories_keeps_unclassified_after_five_real_categories() -> None:
|
|
||||||
rows = [
|
|
||||||
{"product_category": category, "product_name": category, "material_type": "瓶子", "anomaly_type": "异常"}
|
|
||||||
for category in ["洗发水", "面霜", "沐浴露", "牙膏", "清洁剂", "脱毛膏"]
|
|
||||||
] + [{"product_category": None, "product_name": "未命名", "material_type": "瓶子", "anomaly_type": "异常"}]
|
|
||||||
|
|
||||||
result = SupplierProfileService._display_categories(SupplierProfileService._categories(rows))
|
|
||||||
|
|
||||||
assert len(result) == 6
|
|
||||||
assert result[-1].is_unclassified is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_supplier_recent_records_are_limited_and_sorted() -> None:
|
def test_supplier_recent_records_are_limited_and_sorted() -> None:
|
||||||
service = SupplierProfileService(object(), as_of=date(2026, 9, 7))
|
service = SupplierProfileService(object(), as_of=date(2026, 9, 7))
|
||||||
rows = [
|
rows = [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user