176 lines
6.0 KiB
Python
176 lines
6.0 KiB
Python
"""
|
||
陆运查价参数:对照 TMS 出站是否带全。
|
||
|
||
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
|
||
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
|
||
出站类型/线路译成 TMS 用的值,线路键名 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
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"})
|
||
FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
|
||
HK_FTL_TYPE = "中港整车"
|
||
CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
|
||
|
||
_TYPE_ENUM = {
|
||
"国内运输拼车": "DOMESTIC_LTL",
|
||
"国内运输整车": "DOMESTIC_FTL",
|
||
"跨境集拼": "CROSS_BORDER_LTL",
|
||
"跨境整车": "CROSS_BORDER_FTL",
|
||
"中港整车": "中港",
|
||
"中港零担/集拼": "中港",
|
||
}
|
||
_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:
|
||
"""销售中文运输类型 → 查价 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()
|
||
|
||
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 == HK_FTL_TYPE:
|
||
pass
|
||
elif land_type in FTL_TYPES:
|
||
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,
|
||
}
|