落地私聊空运询价与TMS报价回填,按后台模板出单。

查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 20:25:35 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 99da1faf30
78 changed files with 8838 additions and 321 deletions
+200
View File
@@ -0,0 +1,200 @@
"""
空运查价参数:对照 TMS V2 /quote/v2/query 是否带全。
本文件职责:询价卡片出现前,确认销售侧空运必填能拼出查价正文。
三字码能投影就带;对不上不拦建单、不拦查价。
包装:销售原文非空即收下,禁止白名单拦补问。
禁止:猜没写过的港口;因「对不上字典」跳过 TMS 或对销售说教;改六态。
"""
from __future__ import annotations
import logging
import re
from typing import Any
logger = logging.getLogger(__name__)
# 与 tms-air-exact-seed.json 对齐,只用于把已写地名填成 TMS 要的代码
_AIRPORTS = {
"上海": "SHA",
"浦东": "PVG",
"上海浦东": "PVG",
"虹桥": "SHA",
"上海虹桥": "SHA",
"洛杉矶": "LAX",
"深圳": "SZX",
"广州": "CAN",
"香港": "HKG",
"北京首都": "PEK",
"北京大兴": "PKX",
"珠海": "ZUH",
"克拉克": "CRK",
"南京": "NKG",
"吉隆坡": "KUL",
"北京": "PEK",
}
# TMS air_packaging_type 实测只认 1/2/3。1=全部,这条航线无价;
# 2/3 能出与现网相同的标准价。托盘走 2,纸箱/木箱走 3。
_PACKS = {
"全部": "1",
"托盘": "2",
"卡板": "2",
"散货": "2",
"纸箱": "3",
"木箱": "3",
}
_IATA = re.compile(r"^[A-Za-z]{3}$")
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
def _first_number(raw: str) -> str:
hit = _NUMBER.search((raw or "").strip())
return hit.group(1) if hit else ""
def resolve_airport_code(raw: str) -> str:
"""销售原文是地名或三字码时,给出 TMS originCode/destinationCode。"""
text = (raw or "").strip()
if not text:
return ""
if _IATA.match(text):
return text.upper()
return _AIRPORTS.get(text, "")
def harvest_named_air_route(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
当前原话里的「从南京飞吉隆坡 / 北京飞吉隆坡」覆盖起运地、目的地。
只认对照表里的地名,不猜没写过的港口。换城市必须丢掉旧三字码。
"""
merged = dict(facts or {})
raw = text or ""
names = sorted(_AIRPORTS.keys(), key=len, reverse=True)
for origin in names:
for dest in names:
if origin == dest:
continue
token = f"{origin}飞{dest}"
if token in raw or f"从{token}" in raw:
merged["起运港"] = origin
merged["目的港"] = dest
merged.pop("originCode", None)
merged.pop("destinationCode", None)
return merged
return merged
def resolve_package_type(raw: str) -> str:
"""
只给 TMS 出站用的 packageType,不决定销售是否算已填包装。
认得出代码就用;认不出默认 2(不要默认 1,1=全部在现网航线常无价)。
禁止:因不在对照表而返回空,从而再向销售补问。
"""
text = (raw or "").strip()
if not text:
return "2"
if text in _PACKS:
return _PACKS[text]
if text.isdigit():
return text
return "2"
def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
"""
把已采集字段拼成 TMS 空运查价参数。
不齐则 ok=False,missing_display 给销售补问;不建单、不出卡片。
齐了则 facts 带上 originCode/destinationCode/packageType 与纯数字件数毛重体积。
包装只看销售是否说了,散货等原文原样保留。
"""
src = dict(facts or {})
origin = str(src.get("起运港") or "").strip()
dest = str(src.get("目的港") or "").strip()
cargo = str(src.get("品名") or "").strip()
pieces = _first_number(str(src.get("件数") or ""))
weight = _first_number(str(src.get("毛重") or ""))
volume = _first_number(str(src.get("体积") or ""))
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
pack_code = resolve_package_type(pack_raw)
quote_date = str(src.get("报价日期") or "").strip()
# 三字码必须按当前地名重算,禁止沿用上一句的 NKG/KUL
origin_code = resolve_airport_code(origin)
dest_code = resolve_airport_code(dest)
missing: list[str] = []
missing_display: list[str] = []
# 销售侧必填只看 8 项是否非空。三字码是出站投影,对不上不拿来再补问起运地/目的地。
if not origin:
missing.append("起运港")
missing_display.append("起运地")
if not dest:
missing.append("目的港")
missing_display.append("目的地")
if not cargo:
missing.append("品名")
missing_display.append("品名")
if not pieces:
missing.append("件数")
missing_display.append("件数")
if not weight:
missing.append("毛重")
missing_display.append("重量")
if not volume:
missing.append("体积")
missing_display.append("体积")
if not pack_raw:
missing.append("包装方式")
missing_display.append("包装类型")
if not quote_date:
missing.append("报价日期")
missing_display.append("报价日期")
ok = not missing
out_facts = dict(src)
if origin:
out_facts["起运港"] = origin
if origin_code:
out_facts["originCode"] = origin_code
else:
out_facts.pop("originCode", None)
if dest:
out_facts["目的港"] = dest
if dest_code:
out_facts["destinationCode"] = dest_code
else:
out_facts.pop("destinationCode", None)
if cargo:
out_facts["品名"] = cargo
# 件数/重量/体积保留销售原文(1.5KG),数字只作出站,禁止回写成 1.5 再被补成 KGS
if str(src.get("件数") or "").strip():
out_facts["件数"] = str(src.get("件数")).strip()
if str(src.get("毛重") or "").strip():
out_facts["毛重"] = str(src.get("毛重")).strip()
if str(src.get("体积") or "").strip():
out_facts["体积"] = str(src.get("体积")).strip()
if pack_raw:
out_facts["包装方式"] = pack_raw
out_facts["packageType"] = pack_code
if quote_date:
out_facts["报价日期"] = quote_date
logger.info(
"tms.air.assemble ok=%s origin=%s dest=%s pack=%s missing=%s",
ok,
origin_code or "-",
dest_code or "-",
pack_raw or pack_code or "-",
missing,
)
return {
"ok": ok,
"missing": missing,
"missing_display": missing_display,
"facts": out_facts,
}