落地陆运私聊文字询价:选项核对后再建单查价,并按线路拉产品进群。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -44,6 +44,18 @@ SEA_REQUIRED_FIELDS = (
|
||||
)
|
||||
SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
|
||||
|
||||
# 陆运销售侧名称:卡片写始发站/货物品名;内部仍用合同键起运港/品名。
|
||||
LAND_DISPLAY = {
|
||||
"起运港": "始发站",
|
||||
"目的港": "目的地",
|
||||
"品名": "货物品名",
|
||||
"毛重": "重量",
|
||||
"体积": "体积",
|
||||
"车型/数量": "车型/数量",
|
||||
"通关口岸": "通关口岸",
|
||||
"报价日期": "报价日期",
|
||||
}
|
||||
|
||||
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
|
||||
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
|
||||
# 空运群只补含电/含磁,摘要不要把海运六项搬过去。
|
||||
@@ -83,7 +95,9 @@ _ALIASES = {
|
||||
"重量(KG)": "毛重",
|
||||
"重量": "毛重",
|
||||
"体积(CBM)": "体积",
|
||||
"车型/数量": "车型数量",
|
||||
"车型/数量": "车型/数量",
|
||||
"车型数量": "车型/数量",
|
||||
"数量": "数量",
|
||||
"HS": "HS编码",
|
||||
"HS CODE": "HS编码",
|
||||
"商品海关编码": "HS编码",
|
||||
@@ -114,9 +128,33 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
|
||||
if not val:
|
||||
continue
|
||||
out[key] = val
|
||||
_fold_land_vehicle_qty(out)
|
||||
return out
|
||||
|
||||
|
||||
def _fold_land_vehicle_qty(merged: dict[str, str]) -> None:
|
||||
"""
|
||||
把拆开的车型、数量收成一个「车型/数量」。
|
||||
|
||||
销售侧只认这一项必填;模型若仍拆成两项,这里合并,禁止再分别补问。
|
||||
不改主账。有「车型/数量」则保留原文,缺的再用车型、数量拼上。
|
||||
"""
|
||||
combo = (merged.get("车型/数量") or "").strip()
|
||||
vehicle = (merged.get("车型") or "").strip()
|
||||
qty = (merged.get("数量") or "").strip()
|
||||
if not combo:
|
||||
if vehicle and qty:
|
||||
combo = f"{vehicle}/{qty}"
|
||||
elif vehicle:
|
||||
combo = vehicle
|
||||
elif qty:
|
||||
combo = qty
|
||||
elif qty and qty not in combo:
|
||||
combo = f"{combo}/{qty}"
|
||||
if combo:
|
||||
merged["车型/数量"] = combo
|
||||
|
||||
|
||||
def _harvest_packaging(raw: str) -> str:
|
||||
"""当前原话里最后出现的包装词;没有则空串。"""
|
||||
last = ""
|
||||
@@ -235,8 +273,13 @@ def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
|
||||
return merged
|
||||
|
||||
|
||||
def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_subtype: str) -> list[str]:
|
||||
"""当前激活的首次查价内部键列表。"""
|
||||
def _pricing_required_keys(
|
||||
contract: dict[str, Any],
|
||||
business_line: str,
|
||||
land_subtype: str,
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
"""当前激活的首次查价内部键列表。中港再按装载方式收窄。"""
|
||||
line = (business_line or "").strip().upper()
|
||||
pricing = contract.get("pricing_required") or {}
|
||||
if line == "SEA":
|
||||
@@ -248,6 +291,14 @@ def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_su
|
||||
block = land.get(land_subtype) or {}
|
||||
if not block:
|
||||
return []
|
||||
if land_subtype == "中港":
|
||||
from agent.schema.land_options import canonicalize_hk_load
|
||||
|
||||
load = canonicalize_hk_load(str((facts or {}).get("中港装载方式") or ""))
|
||||
by_load = block.get("by_hk_load") or {}
|
||||
picked = by_load.get(load) if load else None
|
||||
if isinstance(picked, list):
|
||||
return list(picked)
|
||||
return list(block.get("always") or [])
|
||||
return []
|
||||
|
||||
@@ -259,6 +310,8 @@ def display_name(internal_key: str, business_line: str = "AIR") -> str:
|
||||
return AIR_DISPLAY.get(internal_key, internal_key)
|
||||
if line == "SEA":
|
||||
return SEA_DISPLAY.get(internal_key, internal_key)
|
||||
if line == "LAND":
|
||||
return LAND_DISPLAY.get(internal_key, internal_key)
|
||||
return internal_key
|
||||
|
||||
|
||||
@@ -292,6 +345,10 @@ def validate_required_fields(
|
||||
"errors": [],
|
||||
"business_line": line,
|
||||
}
|
||||
if line == "LAND":
|
||||
land_subtype = (land_subtype or "").strip() or str(
|
||||
normalized.get("运输类型") or ""
|
||||
).strip()
|
||||
if line == "LAND" and not (land_subtype or "").strip():
|
||||
return {
|
||||
"ok": False,
|
||||
@@ -305,7 +362,7 @@ def validate_required_fields(
|
||||
"business_line": line,
|
||||
}
|
||||
|
||||
required = _pricing_required_keys(contract, line, land_subtype)
|
||||
required = _pricing_required_keys(contract, line, land_subtype, normalized)
|
||||
missing = [k for k in required if k != "报价日期" and not normalized.get(k)]
|
||||
missing_display = [display_name(k, line) for k in missing]
|
||||
ok = len(missing) == 0
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
陆运选项规则:运输类型、线路类别、运输分类、中港装载方式。
|
||||
|
||||
本文件职责:从销售原话收取标准项、校验联动、生成补问清单、判断「确定查价」意图。
|
||||
联动错误只纠正那一项能选什么,不整单作废。
|
||||
禁止:改六态;发明枚举;把选项当货物字段。
|
||||
线程:纯函数,无共享可变状态,可在 Worker 槽调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 与后台员工管理陆运「负责线路」同一套字,禁止改字。
|
||||
TRANSPORT_TYPES = (
|
||||
"国内运输拼车",
|
||||
"国内运输整车",
|
||||
"跨境集拼",
|
||||
"跨境整车",
|
||||
"中港",
|
||||
)
|
||||
DOMESTIC_ROUTES = ("国内长途/零担", "国内城配/拖车/打包")
|
||||
CROSS_ROUTES = ("中港/中亚/中欧", "东南亚")
|
||||
ROUTES_BY_TYPE = {
|
||||
"国内运输拼车": DOMESTIC_ROUTES,
|
||||
"国内运输整车": DOMESTIC_ROUTES,
|
||||
"跨境集拼": CROSS_ROUTES,
|
||||
"跨境整车": CROSS_ROUTES,
|
||||
"中港": CROSS_ROUTES,
|
||||
}
|
||||
CROSS_CLASSES = (
|
||||
"普件,中港、东南亚常用线路/常规车型",
|
||||
"普件,中亚、中欧、东南亚涉及国内+国外段",
|
||||
"普件,中亚、中欧、东南亚涉及境外段清派等特殊要求",
|
||||
"急件",
|
||||
)
|
||||
DOMESTIC_CLASSES = (
|
||||
"普件,常用线路/常规车型",
|
||||
"普件,非常用线路/临时线路/特种车型",
|
||||
"急件",
|
||||
)
|
||||
CLASSES_BY_ROUTE = {
|
||||
"中港/中亚/中欧": CROSS_CLASSES,
|
||||
"东南亚": CROSS_CLASSES,
|
||||
"国内长途/零担": DOMESTIC_CLASSES,
|
||||
"国内城配/拖车/打包": DOMESTIC_CLASSES,
|
||||
}
|
||||
HK_LOADS = ("整车", "零担/集拼")
|
||||
|
||||
# 口语别名 → 标准运输类型。长词优先,避免「拼车」误伤「整车」。
|
||||
_TYPE_ALIASES = (
|
||||
("国内运输拼车", "国内运输拼车"),
|
||||
("国内运输整车", "国内运输整车"),
|
||||
("国内拼车", "国内运输拼车"),
|
||||
("国内整车", "国内运输整车"),
|
||||
("跨境集拼", "跨境集拼"),
|
||||
("跨境整车", "跨境整车"),
|
||||
("中港", "中港"),
|
||||
)
|
||||
|
||||
_ROUTE_ALIASES = (
|
||||
("国内城配/拖车/打包", "国内城配/拖车/打包"),
|
||||
("国内长途/零担", "国内长途/零担"),
|
||||
("中港/中亚/中欧", "中港/中亚/中欧"),
|
||||
("国内城配", "国内城配/拖车/打包"),
|
||||
("国内长途", "国内长途/零担"),
|
||||
("东南亚", "东南亚"),
|
||||
)
|
||||
|
||||
# 分类全称从长到短;「急件」单独出现才算急件,避免普件句被误收。
|
||||
_CLASS_CANON = tuple(
|
||||
sorted(
|
||||
{*(CROSS_CLASSES + DOMESTIC_CLASSES)},
|
||||
key=len,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
|
||||
_HK_LOAD_ALIASES = (
|
||||
("零担/集拼", "零担/集拼"),
|
||||
("零担", "零担/集拼"),
|
||||
("集拼", "零担/集拼"),
|
||||
("整车", "整车"),
|
||||
)
|
||||
|
||||
_CONFIRM_PHRASES = ("可以查了", "确定", "确认", "查价")
|
||||
_CONFIRM_EXCLUDE = ("始发", "目的", "体积", "车型", "改成", "重量")
|
||||
|
||||
|
||||
def canonicalize_transport_type(raw: str) -> str:
|
||||
"""
|
||||
把销售原话收成标准运输类型。
|
||||
|
||||
参数:任意一句。返回 TRANSPORT_TYPES 之一或空串。
|
||||
副作用:无。先匹配更长别名。
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for needle, canon in _TYPE_ALIASES:
|
||||
if needle in text:
|
||||
return canon
|
||||
return ""
|
||||
|
||||
|
||||
def canonicalize_route(raw: str) -> str:
|
||||
"""把原话收成标准线路类别;对不上返回空串。"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for needle, canon in _ROUTE_ALIASES:
|
||||
if needle in text:
|
||||
return canon
|
||||
return ""
|
||||
|
||||
|
||||
def canonicalize_land_class(raw: str) -> str:
|
||||
"""
|
||||
把原话收成标准运输分类。
|
||||
|
||||
先匹配完整分类句;整句只有「急件」才收急件,避免普件描述被截成急件。
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for canon in _CLASS_CANON:
|
||||
if canon == "急件":
|
||||
continue
|
||||
if canon in text:
|
||||
return canon
|
||||
stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip()
|
||||
if stripped == "急件" or text.strip() == "急件":
|
||||
return "急件"
|
||||
if "急件" in text and "普件" not in text:
|
||||
return "急件"
|
||||
return ""
|
||||
|
||||
|
||||
def canonicalize_hk_load(raw: str) -> str:
|
||||
"""中港装载方式:整车或零担/集拼。"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for needle, canon in _HK_LOAD_ALIASES:
|
||||
if needle in text:
|
||||
return canon
|
||||
return ""
|
||||
|
||||
|
||||
def harvest_land_options(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
从一句原话收取陆运选项,合并到已有值上。
|
||||
|
||||
已有非空项默认保留;本句明确写出另一标准项时才覆盖。
|
||||
已有值对不上标准枚举则丢掉,避免模型把「陆运」写成运输类型后整张清单重问。
|
||||
不改货物字段。返回新 dict,不修改 current。
|
||||
"""
|
||||
merged = {
|
||||
k: str(v).strip()
|
||||
for k, v in dict(current or {}).items()
|
||||
if str(v).strip()
|
||||
}
|
||||
_keep_canonical_options(merged)
|
||||
blob = text or ""
|
||||
found_type = canonicalize_transport_type(blob)
|
||||
found_route = canonicalize_route(blob)
|
||||
found_class = canonicalize_land_class(blob)
|
||||
found_load = canonicalize_hk_load(blob)
|
||||
if found_type:
|
||||
merged["运输类型"] = found_type
|
||||
if found_route:
|
||||
merged["线路类别"] = found_route
|
||||
if found_class:
|
||||
merged["运输分类"] = found_class
|
||||
if found_load:
|
||||
merged["中港装载方式"] = found_load
|
||||
return merged
|
||||
|
||||
|
||||
def _keep_canonical_options(merged: dict[str, str]) -> None:
|
||||
"""只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。"""
|
||||
t = canonicalize_transport_type(merged.get("运输类型") or "")
|
||||
if t:
|
||||
merged["运输类型"] = t
|
||||
else:
|
||||
merged.pop("运输类型", None)
|
||||
r = canonicalize_route(merged.get("线路类别") or "")
|
||||
if r:
|
||||
merged["线路类别"] = r
|
||||
else:
|
||||
merged.pop("线路类别", None)
|
||||
c = canonicalize_land_class(merged.get("运输分类") or "")
|
||||
if c:
|
||||
merged["运输分类"] = c
|
||||
else:
|
||||
merged.pop("运输分类", None)
|
||||
load = canonicalize_hk_load(merged.get("中港装载方式") or "")
|
||||
if load:
|
||||
merged["中港装载方式"] = load
|
||||
else:
|
||||
merged.pop("中港装载方式", None)
|
||||
|
||||
|
||||
def validate_land_options(opts: dict[str, str]) -> dict[str, Any]:
|
||||
"""
|
||||
校验选项是否齐全且联动正确。
|
||||
|
||||
返回 ok / missing / errors。errors 每项 {field, message},只纠正错的那一项。
|
||||
中港缺装载方式进 missing,不改货物字段。
|
||||
副作用:无。
|
||||
"""
|
||||
src = {k: str(v).strip() for k, v in dict(opts or {}).items() if str(v).strip()}
|
||||
t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or ""
|
||||
r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or ""
|
||||
c = canonicalize_land_class(src.get("运输分类") or "") or src.get("运输分类") or ""
|
||||
load = canonicalize_hk_load(src.get("中港装载方式") or "") or src.get("中港装载方式") or ""
|
||||
|
||||
missing: list[str] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
if t not in TRANSPORT_TYPES:
|
||||
missing.append("运输类型")
|
||||
t = ""
|
||||
|
||||
if t:
|
||||
allowed_routes = ROUTES_BY_TYPE.get(t) or ()
|
||||
if not r:
|
||||
missing.append("线路类别")
|
||||
elif r not in allowed_routes:
|
||||
errors.append(
|
||||
{
|
||||
"field": "线路类别",
|
||||
"message": "线路类别只能选:" + "、".join(f"「{x}」" for x in allowed_routes),
|
||||
}
|
||||
)
|
||||
r = ""
|
||||
elif r:
|
||||
# 类型还没有,线路先记下但不判联动
|
||||
pass
|
||||
|
||||
if r:
|
||||
allowed_cls = CLASSES_BY_ROUTE.get(r) or ()
|
||||
if not c:
|
||||
missing.append("运输分类")
|
||||
elif c not in allowed_cls:
|
||||
errors.append(
|
||||
{
|
||||
"field": "运输分类",
|
||||
"message": "运输分类只能选:" + "、".join(f"「{x}」" for x in allowed_cls),
|
||||
}
|
||||
)
|
||||
c = ""
|
||||
elif "运输分类" not in missing and not c:
|
||||
missing.append("运输分类")
|
||||
|
||||
if t == "中港":
|
||||
if load not in HK_LOADS:
|
||||
missing.append("中港装载方式")
|
||||
ok = not missing and not errors
|
||||
return {
|
||||
"ok": ok,
|
||||
"missing": missing,
|
||||
"errors": errors,
|
||||
"normalized": {
|
||||
"运输类型": t,
|
||||
"线路类别": r,
|
||||
"运输分类": c,
|
||||
"中港装载方式": load if t == "中港" else "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def option_prompt(*, current: dict[str, str]) -> str:
|
||||
"""
|
||||
按已识别项生成补问清单。
|
||||
|
||||
全缺:一次列出类型、线路、分类、中港装载。
|
||||
类型已知:不再问类型,只列合法线路/分类。
|
||||
不修改 current。
|
||||
"""
|
||||
src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()}
|
||||
t = canonicalize_transport_type(src.get("运输类型") or "") or src.get("运输类型") or ""
|
||||
r = canonicalize_route(src.get("线路类别") or "") or src.get("线路类别") or ""
|
||||
lines: list[str] = []
|
||||
|
||||
if t not in TRANSPORT_TYPES:
|
||||
lines.append("请一次选择:运输类型、线路类别、运输分类。")
|
||||
lines.append("")
|
||||
lines.append("运输类型:")
|
||||
lines.append("、".join(TRANSPORT_TYPES))
|
||||
lines.append("")
|
||||
lines.append("线路类别(跟运输类型联动):")
|
||||
lines.append("国内运输拼车 / 国内运输整车:" + "、".join(DOMESTIC_ROUTES))
|
||||
lines.append("跨境集拼 / 跨境整车 / 中港:" + "、".join(CROSS_ROUTES))
|
||||
lines.append("")
|
||||
lines.append("运输分类(跟线路类别联动):")
|
||||
lines.append("中港/中亚/中欧、东南亚:" + ";".join(CROSS_CLASSES))
|
||||
lines.append("国内长途/零担、国内城配/拖车/打包:" + ";".join(DOMESTIC_CLASSES))
|
||||
lines.append("")
|
||||
lines.append("运输类型选中港时,还要选择:" + "、".join(HK_LOADS))
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
# 类型已认出来:不再问运输类型
|
||||
allowed_routes = ROUTES_BY_TYPE.get(t) or ()
|
||||
if r not in allowed_routes:
|
||||
lines.append(f"运输类型已选「{t}」。请选择线路类别:")
|
||||
lines.append("、".join(allowed_routes))
|
||||
route_for_class = ""
|
||||
else:
|
||||
route_for_class = r
|
||||
|
||||
if route_for_class:
|
||||
allowed_cls = CLASSES_BY_ROUTE.get(route_for_class) or ()
|
||||
if src.get("运输分类") not in allowed_cls:
|
||||
lines.append(f"线路类别已选「{route_for_class}」。请选择运输分类:")
|
||||
lines.append(";".join(allowed_cls))
|
||||
elif t in TRANSPORT_TYPES:
|
||||
# 线路未定,把该类型下各线路的分类一并列出,方便一次粘贴
|
||||
seen: set[str] = set()
|
||||
lines.append("运输分类(跟线路类别联动):")
|
||||
for route in allowed_routes:
|
||||
if route in seen:
|
||||
continue
|
||||
seen.add(route)
|
||||
lines.append(f"{route}:" + ";".join(CLASSES_BY_ROUTE.get(route) or ()))
|
||||
|
||||
if t == "中港" and (src.get("中港装载方式") not in HK_LOADS):
|
||||
lines.append("中港请再选择装载方式:" + "、".join(HK_LOADS))
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def looks_like_confirm(text: str) -> bool:
|
||||
"""
|
||||
判断是否在表示确定查价。
|
||||
|
||||
「确定」「确认」「查价」「可以查了」及相近说法都算。
|
||||
同一句像在改始发/目的/体积/车型/「改成」则不当确定,避免改内容被当成查价。
|
||||
"""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
if any(x in raw for x in _CONFIRM_EXCLUDE):
|
||||
return False
|
||||
return any(p in raw for p in _CONFIRM_PHRASES)
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user