落地陆运私聊文字询价:选项核对后再建单查价,并按线路拉产品进群。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 16:23:30 +08:00
co-authored by Cursor
parent 83b907c4b8
commit be95d823a2
52 changed files with 4534 additions and 76 deletions
@@ -0,0 +1,177 @@
"""
陆运查价参数:对照 TMS 出站是否带全。
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。
中港整车不带车型/数量。车型/数量是一个字段,出站再拆。
禁止:把运输分类、中港装载方式放进出站;直连 TMS;改六态。
线程:纯函数。
"""
from __future__ import annotations
import logging
import re
from typing import Any
from agent.schema.field_validate import apply_quote_date_default, normalize_facts
from agent.schema.land_options import canonicalize_hk_load
logger = logging.getLogger(__name__)
LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"})
FTL_TYPES = frozenset({"国内运输整车", "跨境整车"})
CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港"})
_TYPE_ENUM = {
"国内运输拼车": "DOMESTIC_LTL",
"国内运输整车": "DOMESTIC_FTL",
"跨境集拼": "CROSS_BORDER_LTL",
"跨境整车": "CROSS_BORDER_FTL",
"中港": "CHINA_HK",
}
_ROUTE_ENUM = {
"国内长途/零担": "DOMESTIC_LONG_LTL",
"国内城配/拖车/打包": "DOMESTIC_CITY_DIST",
"中港/中亚/中欧": "CN_HK_CA_EU",
"东南亚": "SOUTHEAST_ASIA",
}
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
_VEHICLE_SPLIT = re.compile(r"\s*[/xX×**]\s*")
_TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$")
def tms_land_type_code(raw: str) -> str:
"""销售中文运输类型 → TMS 6.1.1 landTransportType。对不上空串。"""
text = (raw or "").strip()
if text in _TYPE_ENUM:
return _TYPE_ENUM[text]
if text in set(_TYPE_ENUM.values()):
return text
return ""
def tms_land_route_code(raw: str) -> str:
"""销售中文线路类别 → TMS 6.1.1 routeCategory。对不上空串。"""
text = (raw or "").strip()
if text in _ROUTE_ENUM:
return _ROUTE_ENUM[text]
if text in set(_ROUTE_ENUM.values()):
return text
return ""
def _first_number(raw: str) -> str:
hit = _NUMBER.search((raw or "").strip())
return hit.group(1) if hit else ""
def split_vehicle_qty(raw: str) -> tuple[str, str]:
"""
把「车型/数量」拆成出站车型与台数。
例:9米6/1、9米6*2、9米6 1台。拆不出台数时台数默认 1,不回头补问。
不修改入参。
"""
text = (raw or "").strip()
if not text:
return "", ""
parts = _VEHICLE_SPLIT.split(text, maxsplit=1)
if len(parts) == 2 and parts[0].strip() and parts[1].strip():
return parts[0].strip(), _first_number(parts[1]) or parts[1].strip()
hit = _TAIL_QTY.match(text)
if hit and hit.group(1).strip():
return hit.group(1).strip(), hit.group(2)
return text, "1"
def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]:
"""
把已采集陆运字段拼成 TMS 查价参数。
不齐则 ok=False,missing 给销售补问。齐了也不带分类/中港装载。
改了运输类型后,outbound 只带当前类型需要的键。
"""
src = apply_quote_date_default(
normalize_facts({str(k): v for k, v in dict(facts or {}).items() if v is not None})
)
# 选项键不走港口别名折叠,再从原文补回
raw = {str(k): str(v).strip() for k, v in dict(facts or {}).items() if v is not None}
land_type = (raw.get("运输类型") or src.get("运输类型") or "").strip()
route = (raw.get("线路类别") or src.get("线路类别") or "").strip()
origin = (src.get("起运港") or "").strip()
dest = (src.get("目的港") or "").strip()
cargo = (src.get("品名") or "").strip()
quote_date = (src.get("报价日期") or "").strip()
customs = (raw.get("通关口岸") or src.get("通关口岸") or "").strip()
hk_load = canonicalize_hk_load(raw.get("中港装载方式") or src.get("中港装载方式") or "")
missing: list[str] = []
if not land_type:
missing.append("运输类型")
if not route:
missing.append("线路类别")
if not origin:
missing.append("起运港")
if not dest:
missing.append("目的港")
if not cargo:
missing.append("品名")
outbound: dict[str, Any] = {
"transportMode": "LAND",
"landTransportType": tms_land_type_code(land_type),
"routeCategory": tms_land_route_code(route),
"origin": origin,
"destination": dest,
"cargoName": cargo,
"quoteDate": quote_date,
}
if land_type in LCL_TYPES:
vol = _first_number(src.get("体积") or "")
wgt = _first_number(src.get("毛重") or "")
if not vol:
missing.append("体积")
else:
outbound["volumeCbm"] = vol
if not wgt:
missing.append("毛重")
else:
outbound["weightKg"] = wgt
elif land_type == "中港" and hk_load != "零担/集拼":
# 中港整车(或尚未选定装载)不采集、不带出车型/数量
pass
elif land_type in FTL_TYPES or land_type == "中港":
combo = (
src.get("车型/数量")
or raw.get("车型/数量")
or src.get("车型")
or raw.get("车型")
or ""
).strip()
if not combo:
missing.append("车型/数量")
else:
vehicle, qty = split_vehicle_qty(combo)
leftover_qty = (src.get("数量") or raw.get("数量") or "").strip()
if leftover_qty and leftover_qty not in combo:
qty = leftover_qty
outbound["vehicleType"] = vehicle
outbound["quantity"] = qty or "1"
elif land_type:
missing.append("运输类型")
if land_type in CUSTOMS_TYPES and customs:
outbound["customsPort"] = customs
ok = len(missing) == 0
logger.info("tms.land.assemble ok=%s missing=%s type=%s", ok, missing, land_type or "-")
return {
"ok": ok,
"missing": missing,
"facts": src,
"outbound": outbound,
}