dev #1

Merged
kx_guo merged 24 commits from dev into main 2026-09-21 10:51:18 +08:00
7 changed files with 71 additions and 36 deletions
Showing only changes of commit b7a79f1791 - Show all commits

View File

@ -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 = "暂无采购订单/生产批次总量,无法计算真实异常率"

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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:

View File

@ -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

View 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