落地私聊空运询价与TMS报价回填,按后台模板出单。
查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,6 +35,8 @@ class TemplateEntry:
|
||||
mapping_json: str = ""
|
||||
biz_type: str = ""
|
||||
category: str = ""
|
||||
keywords: tuple[str, ...] = ()
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
def local_cache_dir() -> Path:
|
||||
@@ -93,6 +95,8 @@ def entries_from_ledger_response(resp: LedgerResponse) -> list[TemplateEntry]:
|
||||
mapping_json=str(it.get("mappingJson") or ""),
|
||||
biz_type=str(it.get("bizType") or ""),
|
||||
category=str(it.get("category") or ""),
|
||||
keywords=tuple(str(x) for x in (it.get("keywords") or []) if str(x).strip()),
|
||||
is_default=bool(it.get("isDefault")),
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -160,22 +164,35 @@ def registry_as_dicts() -> list[dict[str, Any]]:
|
||||
def pick_template_for_biz(
|
||||
*,
|
||||
biz_type: str,
|
||||
facts: Optional[dict[str, Any]] = None,
|
||||
quote: Optional[dict[str, Any]] = None,
|
||||
keywords: Optional[list[str]] = None,
|
||||
entries: Optional[list[TemplateEntry]] = None,
|
||||
) -> Optional[TemplateEntry]:
|
||||
"""
|
||||
按业务线 + 关键词挑选模板(优先 is_default 由主账排序保证)。
|
||||
按询价需求匹配模板关键词;都未命中则同业务默认模板。
|
||||
|
||||
关键词命中任一即优;否则同 biz 第一条。
|
||||
keywords 入参已废弃(兼容旧调用,忽略)。生产出单以主账 render 为准。
|
||||
"""
|
||||
_ = keywords
|
||||
from agent.quote_templates.match import pick_quote_template
|
||||
|
||||
items = entries if entries is not None else list_template_registry()
|
||||
same = [e for e in items if (e.biz_type == biz_type or not e.biz_type)]
|
||||
if not same:
|
||||
same = list(items)
|
||||
if keywords:
|
||||
kw_l = [k.lower() for k in keywords if k]
|
||||
for e in same:
|
||||
blob = f"{e.filename} {e.category} {e.code}".lower()
|
||||
if any(k in blob for k in kw_l):
|
||||
return e
|
||||
return same[0] if same else None
|
||||
rows = [
|
||||
{
|
||||
"templateId": e.template_id or e.code,
|
||||
"name": e.filename or e.code,
|
||||
"bizType": e.biz_type,
|
||||
"keywords": list(e.keywords),
|
||||
"isDefault": e.is_default,
|
||||
"_entry": e,
|
||||
}
|
||||
for e in items
|
||||
]
|
||||
hit = pick_quote_template(
|
||||
templates=rows, business_line=biz_type, facts=facts, quote=quote
|
||||
)
|
||||
if hit is None:
|
||||
return None
|
||||
entry = hit.raw.get("_entry")
|
||||
return entry if isinstance(entry, TemplateEntry) else None
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
报价模板匹配:询价需求对模板关键词,未命中则同业务默认模板。
|
||||
|
||||
本文件职责:纯函数,与 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]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return [str(x).strip() for x in raw if str(x).strip()]
|
||||
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()
|
||||
keyword_hit: Optional[dict[str, Any]] = None
|
||||
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
|
||||
if keyword_hit is None:
|
||||
for kw in _as_keywords(row.get("keywords")):
|
||||
if kw.lower() in haystack:
|
||||
keyword_hit = row
|
||||
break
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user