""" 陆运查价参数:对照 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, }