修复供应商查询兼容历史记录与名称匹配
This commit is contained in:
parent
a7f4fef025
commit
a967b82711
@ -23,7 +23,17 @@ async def list_suppliers(
|
|||||||
return ApiResponse(data=await SupplierProfileService(session).list(keyword=keyword, limit=limit))
|
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(
|
async def get_supplier_profile(
|
||||||
supplier_name: str,
|
supplier_name: str,
|
||||||
session: DatabaseSession,
|
session: DatabaseSession,
|
||||||
|
|||||||
@ -35,6 +35,14 @@ class SupplierProfileService:
|
|||||||
|
|
||||||
async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData:
|
async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData:
|
||||||
grouped = await self._load_supplier_summary(keyword=keyword)
|
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 = [
|
items = [
|
||||||
SupplierOption(
|
SupplierOption(
|
||||||
name=name,
|
name=name,
|
||||||
@ -57,6 +65,11 @@ class SupplierProfileService:
|
|||||||
async def detail(self, supplier_name: str) -> SupplierProfileData:
|
async def detail(self, supplier_name: str) -> SupplierProfileData:
|
||||||
all_rows = await self._load_rows()
|
all_rows = await self._load_rows()
|
||||||
rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
|
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:
|
if not rows:
|
||||||
raise AppException("供应商不存在或暂无近3个自然年度异常记录", code=404, status_code=404)
|
raise AppException("供应商不存在或暂无近3个自然年度异常记录", code=404, status_code=404)
|
||||||
|
|
||||||
@ -117,11 +130,19 @@ class SupplierProfileService:
|
|||||||
monthly_trend=self._monthly_trend(rows),
|
monthly_trend=self._monthly_trend(rows),
|
||||||
recent_records=self._recent_records(rows),
|
recent_records=self._recent_records(rows),
|
||||||
factory_recommendations=[],
|
factory_recommendations=[],
|
||||||
recommendation_note="合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。",
|
recommendation_note=(
|
||||||
|
"该供应商仅命中历史导入记录,部分异常缺少发生日期;本页展示可识别的历史记录,"
|
||||||
|
"不将日期缺失数据混入近3年趋势。"
|
||||||
|
if used_legacy_fallback
|
||||||
|
else "合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _load_supplier_summary(
|
async def _load_supplier_summary(
|
||||||
self, *, keyword: str | None = None
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
include_undated: bool = False,
|
||||||
) -> dict[str, dict[str, int]]:
|
) -> dict[str, dict[str, int]]:
|
||||||
"""Load only supplier aggregates for the selector.
|
"""Load only supplier aggregates for the selector.
|
||||||
|
|
||||||
@ -134,14 +155,15 @@ class SupplierProfileService:
|
|||||||
start, end = calendar_year_window(self.as_of, self.history_years)
|
start, end = calendar_year_window(self.as_of, self.history_years)
|
||||||
statement = (
|
statement = (
|
||||||
select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count())
|
select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count())
|
||||||
.where(
|
.where(AnomalyCase.supplier.is_not(None))
|
||||||
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.is_not(None),
|
||||||
AnomalyCase.date >= start,
|
AnomalyCase.date >= start,
|
||||||
AnomalyCase.date <= end,
|
AnomalyCase.date <= end,
|
||||||
)
|
)
|
||||||
.group_by(AnomalyCase.supplier, AnomalyCase.defect_level)
|
|
||||||
)
|
|
||||||
grouped: dict[str, dict[str, int]] = defaultdict(
|
grouped: dict[str, dict[str, int]] = defaultdict(
|
||||||
lambda: {"total": 0, "a": 0, "b": 0}
|
lambda: {"total": 0, "a": 0, "b": 0}
|
||||||
)
|
)
|
||||||
@ -161,7 +183,12 @@ class SupplierProfileService:
|
|||||||
value["b"] += amount
|
value["b"] += amount
|
||||||
return grouped
|
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)
|
start, end = calendar_year_window(self.as_of, self.history_years)
|
||||||
statement = select(
|
statement = select(
|
||||||
AnomalyCase.id,
|
AnomalyCase.id,
|
||||||
@ -173,7 +200,9 @@ class SupplierProfileService:
|
|||||||
AnomalyCase.factory,
|
AnomalyCase.factory,
|
||||||
AnomalyCase.anomaly_type,
|
AnomalyCase.anomaly_type,
|
||||||
AnomalyCase.defect_level,
|
AnomalyCase.defect_level,
|
||||||
).where(
|
).where()
|
||||||
|
if not include_undated:
|
||||||
|
statement = statement.where(
|
||||||
AnomalyCase.date.is_not(None),
|
AnomalyCase.date.is_not(None),
|
||||||
AnomalyCase.date >= start,
|
AnomalyCase.date >= start,
|
||||||
AnomalyCase.date <= end,
|
AnomalyCase.date <= end,
|
||||||
@ -184,11 +213,41 @@ class SupplierProfileService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def matches(value: str | None, target: str) -> bool:
|
def matches(value: str | None, target: str) -> bool:
|
||||||
if not value:
|
if not value or not target:
|
||||||
return False
|
return False
|
||||||
source = "".join(value.casefold().split())
|
# Supplier cells in the source workbooks often contain several names
|
||||||
query = "".join(target.casefold().split())
|
# such as ``皓盛/佛山执得``. Match each segment separately and make
|
||||||
return bool(query and (query in source or source in query))
|
# 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
|
@classmethod
|
||||||
def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]:
|
def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]:
|
||||||
|
|||||||
@ -19,6 +19,28 @@ def test_supplier_name_matching_supports_full_name_and_short_name() -> None:
|
|||||||
assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿")
|
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:
|
def test_supplier_alpha_is_capped_and_rounded() -> None:
|
||||||
assert SupplierProfileService.alpha(0) == 1.0
|
assert SupplierProfileService.alpha(0) == 1.0
|
||||||
assert SupplierProfileService.alpha(42) == 1.13
|
assert SupplierProfileService.alpha(42) == 1.13
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user