diff --git a/app/schemas/suppliers.py b/app/schemas/suppliers.py index dbbb959..fb2ead1 100644 --- a/app/schemas/suppliers.py +++ b/app/schemas/suppliers.py @@ -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 = "暂无采购订单/生产批次总量,无法计算真实异常率" diff --git a/app/services/conversations.py b/app/services/conversations.py index 9b60b42..8408c3b 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -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) diff --git a/app/services/database_risk_scoring.py b/app/services/database_risk_scoring.py index bff3302..b495a6a 100644 --- a/app/services/database_risk_scoring.py +++ b/app/services/database_risk_scoring.py @@ -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, diff --git a/app/services/purchase_orders.py b/app/services/purchase_orders.py index d104043..b3b3427 100644 --- a/app/services/purchase_orders.py +++ b/app/services/purchase_orders.py @@ -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 diff --git a/app/services/reports.py b/app/services/reports.py index 804344f..def24b9 100644 --- a/app/services/reports.py +++ b/app/services/reports.py @@ -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: diff --git a/app/services/suppliers.py b/app/services/suppliers.py index 439bedc..229f9e9 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -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 diff --git a/tests/test_purchase_orders.py b/tests/test_purchase_orders.py new file mode 100644 index 0000000..f6526c2 --- /dev/null +++ b/tests/test_purchase_orders.py @@ -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