修复供应商查询兼容历史记录与名称匹配

This commit is contained in:
kx_guo 2026-09-10 16:10:28 +08:00
parent a7f4fef025
commit a967b82711
3 changed files with 108 additions and 17 deletions

View File

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

View File

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

View File

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