From a967b827115b1634c9dd8fc64c9a33756ffbe22a Mon Sep 17 00:00:00 2001 From: kx_guo Date: Thu, 10 Sep 2026 16:10:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=85=BC=E5=AE=B9=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=E5=90=8D=E7=A7=B0=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/suppliers.py | 12 +++++- app/services/suppliers.py | 91 ++++++++++++++++++++++++++++++++------- tests/test_suppliers.py | 22 ++++++++++ 3 files changed, 108 insertions(+), 17 deletions(-) diff --git a/app/api/v1/suppliers.py b/app/api/v1/suppliers.py index a28c23f..187bdb1 100644 --- a/app/api/v1/suppliers.py +++ b/app/api/v1/suppliers.py @@ -23,7 +23,17 @@ async def list_suppliers( return ApiResponse(data=await SupplierProfileService(session).list(keyword=keyword, limit=limit)) -@router.get("/{supplier_name}", response_model=ApiResponse[SupplierProfileData]) +@router.get("/profile", response_model=ApiResponse[SupplierProfileData]) +async def get_supplier_profile_by_query( + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("supplier.read"))], + supplier_name: str = Query(min_length=1, max_length=300), +) -> ApiResponse[SupplierProfileData]: + """Fetch a profile by query parameter so names containing '/' are safe.""" + return ApiResponse(data=await SupplierProfileService(session).detail(supplier_name)) + + +@router.get("/{supplier_name:path}", response_model=ApiResponse[SupplierProfileData]) async def get_supplier_profile( supplier_name: str, session: DatabaseSession, diff --git a/app/services/suppliers.py b/app/services/suppliers.py index 99404c5..49fc95f 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -35,6 +35,14 @@ class SupplierProfileService: async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData: 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, @@ -57,6 +65,11 @@ class SupplierProfileService: async def detail(self, supplier_name: str) -> SupplierProfileData: all_rows = await self._load_rows() rows = [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) @@ -117,11 +130,19 @@ class SupplierProfileService: monthly_trend=self._monthly_trend(rows), recent_records=self._recent_records(rows), factory_recommendations=[], - recommendation_note="合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。", + recommendation_note=( + "该供应商仅命中历史导入记录,部分异常缺少发生日期;本页展示可识别的历史记录," + "不将日期缺失数据混入近3年趋势。" + if used_legacy_fallback + else "合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。" + ), ) async def _load_supplier_summary( - self, *, keyword: str | None = None + self, + *, + keyword: str | None = None, + include_undated: bool = False, ) -> dict[str, dict[str, int]]: """Load only supplier aggregates for the selector. @@ -134,14 +155,15 @@ class SupplierProfileService: 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), + .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, ) - .group_by(AnomalyCase.supplier, AnomalyCase.defect_level) - ) grouped: dict[str, dict[str, int]] = defaultdict( lambda: {"total": 0, "a": 0, "b": 0} ) @@ -161,7 +183,12 @@ class SupplierProfileService: value["b"] += amount return grouped - async def _load_rows(self, *, supplier_name: str | None = None) -> list[dict]: + 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, @@ -173,22 +200,54 @@ class SupplierProfileService: AnomalyCase.factory, AnomalyCase.anomaly_type, AnomalyCase.defect_level, - ).where( - AnomalyCase.date.is_not(None), - AnomalyCase.date >= start, - AnomalyCase.date <= end, - ) + ).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: + if not value or not target: return False - source = "".join(value.casefold().split()) - query = "".join(target.casefold().split()) - return bool(query and (query in source or source in query)) + # 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]]: diff --git a/tests/test_suppliers.py b/tests/test_suppliers.py index 7d175d3..fa9f89f 100644 --- a/tests/test_suppliers.py +++ b/tests/test_suppliers.py @@ -19,6 +19,28 @@ def test_supplier_name_matching_supports_full_name_and_short_name() -> None: assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿") +def test_supplier_name_matching_supports_city_suffix_and_compound_supplier_cells() -> None: + assert SupplierProfileService.matches("皓盛/佛山执得", "佛山市") + assert SupplierProfileService.matches("皓盛/佛山执得", "佛山") + assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿") + + +def test_supplier_list_falls_back_to_undated_legacy_rows(monkeypatch) -> None: + service = SupplierProfileService.__new__(SupplierProfileService) + calls = [] + + async def fake_summary(*, keyword=None, include_undated=False): + calls.append(include_undated) + if not include_undated: + return {} + return {"皓盛/佛山执得": {"total": 1, "a": 0, "b": 0}} + + service._load_supplier_summary = fake_summary + result = __import__("asyncio").run(service.list(keyword="佛山市", limit=8)) + assert calls == [False, True] + assert result.items[0].name == "皓盛/佛山执得" + + def test_supplier_alpha_is_capped_and_rounded() -> None: assert SupplierProfileService.alpha(0) == 1.0 assert SupplierProfileService.alpha(42) == 1.13