dev #1
@ -25,7 +25,7 @@ class SupplierMetric(ApiSchema):
|
||||
supplier_score: float | None = None
|
||||
risk_score: float | None = None
|
||||
main_category_count: int
|
||||
cooperation_count_basis: str = "按近3年采购订单去重单据编号统计"
|
||||
cooperation_count_basis: str = "按自然年度边界采购订单去重单据编号统计"
|
||||
anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率"
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
@ -36,14 +36,14 @@ from app.schemas.risk_prediction import (
|
||||
)
|
||||
from app.services.database_risk_scoring import DatabaseRiskScoringService
|
||||
from app.services.llm_client import LLMClient
|
||||
from app.services.purchase_orders import supplier_order_counts
|
||||
from app.services.purchase_orders import calendar_year_window, supplier_order_counts
|
||||
from app.services.rag import AnomalyCaseRAG
|
||||
|
||||
logger = logging.getLogger("dieya_risk_prediction.conversations")
|
||||
|
||||
|
||||
class ConversationService:
|
||||
_supplier_history_days = 365 * 3
|
||||
_supplier_history_years = 3
|
||||
_engine = BaselineRiskEngine()
|
||||
_risk_intents = {
|
||||
"assessment",
|
||||
@ -582,7 +582,7 @@ class ConversationService:
|
||||
self.session,
|
||||
name,
|
||||
self._supplier_history_start(),
|
||||
datetime.now(UTC).date(),
|
||||
self._supplier_history_end(),
|
||||
)
|
||||
if hasattr(self, "session")
|
||||
else None
|
||||
@ -626,9 +626,9 @@ class ConversationService:
|
||||
"supplierScore": supplier_dimension.score if supplier_dimension else None,
|
||||
"topProblems": problems,
|
||||
"suggestions": self._supplier_management_suggestions(tier),
|
||||
"frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
|
||||
"frequencyBasis": "按自然年度边界采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
|
||||
"note": (
|
||||
"合作次数按近3年采购订单去重单据编号统计。"
|
||||
"合作次数按自然年度边界采购订单去重单据编号统计。"
|
||||
if has_order_data
|
||||
else "未匹配到该供应商采购订单,不展示合作次数。"
|
||||
),
|
||||
@ -938,7 +938,7 @@ class ConversationService:
|
||||
[
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= since,
|
||||
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||
AnomalyCase.date <= self._supplier_history_end(),
|
||||
]
|
||||
)
|
||||
rows = await self.session.execute(
|
||||
@ -1082,7 +1082,7 @@ class ConversationService:
|
||||
self.session,
|
||||
supplier,
|
||||
self._supplier_history_start(),
|
||||
datetime.now(UTC).date(),
|
||||
self._supplier_history_end(),
|
||||
)
|
||||
if supplier
|
||||
else None
|
||||
@ -1175,9 +1175,9 @@ class ConversationService:
|
||||
),
|
||||
tier=tier,
|
||||
suggestion=";".join(self._supplier_management_suggestions(tier)),
|
||||
frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
|
||||
frequency_basis="按自然年度边界采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
|
||||
note=(
|
||||
"合作次数按采购订单去重单据编号统计;异常数仍按近3年异常案例统计。"
|
||||
"合作次数按自然年度边界采购订单去重单据编号统计;异常数按自然年度边界异常案例统计。"
|
||||
if cooperation_count
|
||||
else "未匹配到该供应商采购订单,不展示合作次数。"
|
||||
),
|
||||
@ -1209,14 +1209,18 @@ class ConversationService:
|
||||
column.ilike(f"%{value}%"),
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= self._supplier_history_start(),
|
||||
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||
AnomalyCase.date <= self._supplier_history_end(),
|
||||
)
|
||||
)
|
||||
return int(count or 0)
|
||||
|
||||
@classmethod
|
||||
def _supplier_history_start(cls) -> date:
|
||||
return datetime.now(UTC).date() - timedelta(days=cls._supplier_history_days)
|
||||
return calendar_year_window(datetime.now(UTC).date(), cls._supplier_history_years)[0]
|
||||
|
||||
@classmethod
|
||||
def _supplier_history_end(cls) -> date:
|
||||
return calendar_year_window(datetime.now(UTC).date(), cls._supplier_history_years)[1]
|
||||
|
||||
async def _supplier_history_stats(
|
||||
self,
|
||||
@ -1234,7 +1238,7 @@ class ConversationService:
|
||||
column.ilike(f"%{value}%"),
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= self._supplier_history_start(),
|
||||
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||
AnomalyCase.date <= self._supplier_history_end(),
|
||||
)
|
||||
.group_by(AnomalyCase.defect_level)
|
||||
)
|
||||
@ -1354,7 +1358,7 @@ class ConversationService:
|
||||
),
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= self._supplier_history_start(),
|
||||
AnomalyCase.date <= datetime.now(UTC).date(),
|
||||
AnomalyCase.date <= self._supplier_history_end(),
|
||||
)
|
||||
)
|
||||
return int(count or 0)
|
||||
|
||||
@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS
|
||||
from app.domain.risk_engine import RiskLevel
|
||||
from app.models.entities import AnomalyCase, RiskModelConfig
|
||||
from app.services.purchase_orders import supplier_order_counts
|
||||
from app.services.purchase_orders import calendar_year_window, supplier_order_counts
|
||||
|
||||
STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理")
|
||||
|
||||
@ -107,8 +107,7 @@ class DatabaseRiskScoringService:
|
||||
return await supplier_order_counts(
|
||||
self.session,
|
||||
party_name,
|
||||
self.as_of - timedelta(days=365 * 3),
|
||||
self.as_of,
|
||||
*calendar_year_window(self.as_of, 3),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - legacy/test databases may not have the order ledger yet
|
||||
return None
|
||||
@ -127,7 +126,7 @@ class DatabaseRiskScoringService:
|
||||
self.frequency_cap = float(config.frequency_cap)
|
||||
|
||||
async def _load_rows(self) -> list[dict[str, Any]]:
|
||||
start = self.as_of - timedelta(days=365 * 3)
|
||||
start, end = calendar_year_window(self.as_of, 3)
|
||||
statement = select(
|
||||
AnomalyCase.date,
|
||||
AnomalyCase.product_name,
|
||||
@ -140,7 +139,7 @@ class DatabaseRiskScoringService:
|
||||
).where(
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= start,
|
||||
AnomalyCase.date <= self.as_of,
|
||||
AnomalyCase.date <= end,
|
||||
)
|
||||
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
|
||||
|
||||
@ -200,9 +199,9 @@ class DatabaseRiskScoringService:
|
||||
weight=weight,
|
||||
status="database",
|
||||
reason=(
|
||||
f"近 3 年采购订单 {count} 单,异常案例 {anomaly_count} 条,基础分 {base:g},α={alpha:.2f}"
|
||||
f"近 3 个自然年度采购订单 {count} 单,异常案例 {anomaly_count} 条,基础分 {base:g},α={alpha:.2f}"
|
||||
if cooperation_count is not None and cooperation_count > 0
|
||||
else f"近 3 年异常 {count} 条,基础分 {base:g},α={alpha:.2f}"
|
||||
else f"近 3 个自然年度异常 {count} 条,基础分 {base:g},α={alpha:.2f}"
|
||||
),
|
||||
sample_size=anomaly_count,
|
||||
raw_value=count,
|
||||
|
||||
@ -16,6 +16,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.entities import PurchaseOrderLine
|
||||
|
||||
|
||||
def calendar_year_window(as_of: date, years: int = 3) -> tuple[date, date]:
|
||||
"""Return the natural-year window used by historical statistics.
|
||||
|
||||
``years=3`` means the year three calendar years before ``as_of`` through
|
||||
the end of ``as_of``'s calendar year. For example, an as-of date in 2026
|
||||
uses 2023-01-01 through 2026-12-31, so a partial current year never drops
|
||||
an otherwise valid record merely because of its month/day.
|
||||
"""
|
||||
|
||||
if years < 1:
|
||||
raise ValueError("years must be positive")
|
||||
return date(as_of.year - years, 1, 1), date(as_of.year, 12, 31)
|
||||
|
||||
|
||||
def clean_text(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@ -341,7 +341,7 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
|
||||
if (supplier and supplier.supplier_name) or supplier_cards:
|
||||
supplier_rows = [["维度", "数值", "说明"]]
|
||||
if supplier and supplier.cooperation_count is not None:
|
||||
supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"])
|
||||
supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "按自然年度边界采购订单去重单据数"])
|
||||
if supplier:
|
||||
supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"])
|
||||
if supplier and supplier.alpha is not None:
|
||||
@ -572,7 +572,9 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
|
||||
heading("三、供应商合作频次分析")
|
||||
rows = [["维度", "数值", "说明"]]
|
||||
if result.supplier_freq and result.supplier_freq.cooperation_count is not None:
|
||||
rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"])
|
||||
rows.append(
|
||||
["合作次数", f"{result.supplier_freq.cooperation_count} 次", "按自然年度边界采购订单去重单据数"]
|
||||
)
|
||||
if result.supplier_freq:
|
||||
rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"])
|
||||
if result.supplier_freq and result.supplier_freq.alpha is not None:
|
||||
|
||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@ -20,11 +20,11 @@ from app.schemas.suppliers import (
|
||||
SupplierRecentRecord,
|
||||
)
|
||||
from app.services.database_risk_scoring import DatabaseRiskScoringService, normalize_category
|
||||
from app.services.purchase_orders import supplier_order_counts
|
||||
from app.services.purchase_orders import calendar_year_window, supplier_order_counts
|
||||
|
||||
|
||||
class SupplierProfileService:
|
||||
history_days = 365 * 3
|
||||
history_years = 3
|
||||
recent_months = 6
|
||||
max_categories = 5
|
||||
max_recent_records = 10
|
||||
@ -58,14 +58,15 @@ class SupplierProfileService:
|
||||
all_rows = await self._load_rows()
|
||||
rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
|
||||
if not rows:
|
||||
raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404)
|
||||
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,
|
||||
self.as_of - timedelta(days=self.history_days),
|
||||
self.as_of,
|
||||
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)
|
||||
@ -116,7 +117,7 @@ class SupplierProfileService:
|
||||
monthly_trend=self._monthly_trend(rows),
|
||||
recent_records=self._recent_records(rows),
|
||||
factory_recommendations=[],
|
||||
recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
|
||||
recommendation_note="合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
|
||||
)
|
||||
|
||||
async def _load_supplier_summary(
|
||||
@ -130,14 +131,14 @@ class SupplierProfileService:
|
||||
the remote PostgreSQL connection.
|
||||
"""
|
||||
|
||||
start = self.as_of - timedelta(days=self.history_days)
|
||||
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),
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= start,
|
||||
AnomalyCase.date <= self.as_of,
|
||||
AnomalyCase.date <= end,
|
||||
)
|
||||
.group_by(AnomalyCase.supplier, AnomalyCase.defect_level)
|
||||
)
|
||||
@ -161,7 +162,7 @@ class SupplierProfileService:
|
||||
return grouped
|
||||
|
||||
async def _load_rows(self, *, supplier_name: str | None = None) -> list[dict]:
|
||||
start = self.as_of - timedelta(days=self.history_days)
|
||||
start, end = calendar_year_window(self.as_of, self.history_years)
|
||||
statement = select(
|
||||
AnomalyCase.id,
|
||||
AnomalyCase.date,
|
||||
@ -175,7 +176,7 @@ class SupplierProfileService:
|
||||
).where(
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= start,
|
||||
AnomalyCase.date <= self.as_of,
|
||||
AnomalyCase.date <= end,
|
||||
)
|
||||
if supplier_name:
|
||||
statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%"))
|
||||
@ -223,7 +224,7 @@ class SupplierProfileService:
|
||||
"A级": "异常记录 20~60 次,且 A/B 级缺陷占比 < 30%",
|
||||
"B级": "异常记录 60~120 次,或 A/B 级缺陷占比 30%~50%",
|
||||
"C级": "异常记录 > 120 次,或存在 A 级缺陷,或 A/B 级缺陷占比 > 50%",
|
||||
"新供应商": "近3年异常记录为 0",
|
||||
"新供应商": "近3个自然年度异常记录为 0",
|
||||
}[tier]
|
||||
|
||||
@classmethod
|
||||
|
||||
15
tests/test_purchase_orders.py
Normal file
15
tests/test_purchase_orders.py
Normal file
@ -0,0 +1,15 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from app.services.purchase_orders import calendar_year_window
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("as_of", "expected"),
|
||||
[
|
||||
(date(2026, 9, 10), (date(2023, 1, 1), date(2026, 12, 31))),
|
||||
(date(2025, 1, 1), (date(2022, 1, 1), date(2025, 12, 31))),
|
||||
],
|
||||
)
|
||||
def test_three_year_window_uses_calendar_years(as_of: date, expected: tuple[date, date]) -> None:
|
||||
assert calendar_year_window(as_of, 3) == expected
|
||||
Loading…
x
Reference in New Issue
Block a user