200 lines
6.2 KiB
Python
200 lines
6.2 KiB
Python
"""
|
|
报价模板匹配:询价需求对模板关键词,未命中则同业务默认模板。
|
|
|
|
本文件职责:纯函数,与 Java QuoteTemplateMatcher 同一套规则。
|
|
调用:MemoryLedger / 单测;生产出单以主账 render 为准。
|
|
禁止:在此发明价格;禁止读仓库 xlsx 当生产真相。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterable, Optional
|
|
|
|
# 与 Java QuoteTemplateMatcher.FAIL_MESSAGE 保持一字不差
|
|
FAIL_MESSAGE = "生成报价单失败,请联系IT运维维护后台报价模板。"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TemplatePick:
|
|
"""一次匹配结果。"""
|
|
|
|
template_id: str
|
|
name: str
|
|
match_type: str
|
|
raw: dict[str, Any]
|
|
|
|
|
|
def normalize_biz(raw: str) -> str:
|
|
"""业务线归一:AIR/空 → 空运,SEA/海 → 海运,LAND/陆 → 陆运。"""
|
|
t = (raw or "").strip()
|
|
if not t:
|
|
return ""
|
|
lower = t.lower()
|
|
if lower == "air" or t == "空" or "空运" in t or t == "AIR":
|
|
return "空运"
|
|
if lower == "sea" or t == "海" or "海运" in t or t == "SEA":
|
|
return "海运"
|
|
if lower == "land" or t == "陆" or "陆运" in t or t == "LAND":
|
|
return "陆运"
|
|
return t
|
|
|
|
|
|
def _as_keywords(raw: Any) -> list[str]:
|
|
"""摊开关键词。列表里若整段是 [海运,整柜] 也要拆开,与 Java asKeywords 一致。"""
|
|
if raw is None:
|
|
return []
|
|
if isinstance(raw, (list, tuple)):
|
|
out: list[str] = []
|
|
for x in raw:
|
|
s = str(x).strip()
|
|
if s.startswith("["):
|
|
out.extend(_as_keywords(s))
|
|
elif s:
|
|
out.append(s)
|
|
return out
|
|
text = str(raw).strip()
|
|
if not text or text.lower() == "null":
|
|
return []
|
|
if text.startswith("["):
|
|
inner = text[1:-1] if text.endswith("]") else text[1:]
|
|
out: list[str] = []
|
|
for part in inner.split(","):
|
|
s = part.strip().strip("\"'")
|
|
if s:
|
|
out.append(s)
|
|
return out
|
|
return [text]
|
|
|
|
|
|
def _is_default(raw: Any) -> bool:
|
|
if raw is True:
|
|
return True
|
|
if isinstance(raw, (int, float)) and int(raw) == 1:
|
|
return True
|
|
s = str(raw or "").strip().lower()
|
|
return s in {"1", "true"}
|
|
|
|
|
|
def _append(parts: list[str], val: Any) -> None:
|
|
if val is None:
|
|
return
|
|
s = str(val).strip()
|
|
if not s or s.lower() == "null":
|
|
return
|
|
parts.append(s)
|
|
|
|
|
|
def build_inquiry_haystack(
|
|
*,
|
|
business_line: str,
|
|
facts: Optional[dict[str, Any]] = None,
|
|
quote: Optional[dict[str, Any]] = None,
|
|
) -> str:
|
|
"""拼匹配文本:已冻结字段 + 业务线 + 报价费用名。"""
|
|
parts: list[str] = []
|
|
_append(parts, business_line)
|
|
_append(parts, normalize_biz(business_line))
|
|
for key, val in (facts or {}).items():
|
|
_append(parts, key)
|
|
_append(parts, val)
|
|
q = quote or {}
|
|
for key in ("source", "source_label", "freight", "total"):
|
|
_append(parts, q.get(key))
|
|
lines = q.get("fee_lines") or []
|
|
if isinstance(lines, Iterable) and not isinstance(lines, (str, bytes)):
|
|
for row in lines:
|
|
if isinstance(row, dict):
|
|
_append(parts, row.get("name"))
|
|
_append(parts, row.get("amount"))
|
|
else:
|
|
_append(parts, row)
|
|
return " ".join(parts)
|
|
|
|
|
|
def _row_of(item: Any) -> dict[str, Any]:
|
|
if isinstance(item, dict):
|
|
return item
|
|
return {
|
|
"templateId": getattr(item, "template_id", "") or getattr(item, "code", ""),
|
|
"name": getattr(item, "filename", "") or getattr(item, "code", ""),
|
|
"bizType": getattr(item, "biz_type", ""),
|
|
"keywords": list(getattr(item, "keywords", None) or []),
|
|
"isDefault": bool(getattr(item, "is_default", False)),
|
|
}
|
|
|
|
|
|
def pick_quote_template(
|
|
*,
|
|
templates: Optional[list[Any]],
|
|
business_line: str,
|
|
facts: Optional[dict[str, Any]] = None,
|
|
quote: Optional[dict[str, Any]] = None,
|
|
) -> Optional[TemplatePick]:
|
|
"""
|
|
同业务线:先按关键词打分(运输类型上的词优先),再退回默认模板。
|
|
「陆运/国内」不当关键词,避免中港模板抢走拼车单。
|
|
参数 templates:主账 listEnabled 行或 TemplateEntry。
|
|
返回 None 时销售侧用 FAIL_MESSAGE。
|
|
"""
|
|
want = normalize_biz(business_line)
|
|
haystack = build_inquiry_haystack(
|
|
business_line=business_line, facts=facts, quote=quote
|
|
).lower()
|
|
type_val = str((facts or {}).get("运输类型") or "").strip().lower()
|
|
keyword_hit: Optional[dict[str, Any]] = None
|
|
best: tuple[int, int] = (-1, -1)
|
|
default_hit: Optional[dict[str, Any]] = None
|
|
for item in templates or []:
|
|
row = _row_of(item)
|
|
biz = normalize_biz(str(row.get("bizType") or ""))
|
|
if want and biz and want != biz:
|
|
continue
|
|
if default_hit is None and _is_default(row.get("isDefault")):
|
|
default_hit = row
|
|
score = _keyword_score(row.get("keywords"), haystack, type_val)
|
|
if score == (0, 0):
|
|
continue
|
|
if score > best:
|
|
best = score
|
|
keyword_hit = row
|
|
hit = keyword_hit or default_hit
|
|
if hit is None:
|
|
return None
|
|
match_type = "keyword" if keyword_hit is not None else "default"
|
|
return TemplatePick(
|
|
template_id=str(hit.get("templateId") or hit.get("id") or ""),
|
|
name=str(hit.get("name") or ""),
|
|
match_type=match_type,
|
|
raw=hit,
|
|
)
|
|
|
|
|
|
_GENERIC_KEYWORDS = frozenset(
|
|
{"海运", "空运", "陆运", "sea", "air", "land", "国内"}
|
|
)
|
|
|
|
|
|
def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
|
|
"""
|
|
运输类型上的命中优先,其次全文词长。
|
|
「陆运/国内」跳过,避免中港、国内整车抢走拼车单。
|
|
"""
|
|
type_hits = 0
|
|
length = 0
|
|
any_hit = False
|
|
for kw in _as_keywords(raw):
|
|
k = kw.lower()
|
|
if k in _GENERIC_KEYWORDS:
|
|
continue
|
|
if type_val and k in type_val:
|
|
type_hits += 1
|
|
length += len(k)
|
|
any_hit = True
|
|
elif k in haystack:
|
|
length += len(k)
|
|
any_hit = True
|
|
if not any_hit:
|
|
return (0, 0)
|
|
return (type_hits, length)
|