""" 空运查价参数:对照 TMS V2 /quote/v2/query 是否带全。 本文件职责:询价卡片出现前,确认销售侧空运必填能拼出查价正文。 起运港非必填。货源地/货好时间/提货地址/收货地址有值才带出站键。 贸易条款只留中文键给工单,禁止投影成 transportTerms。 三字码能投影就带;对不上不拦建单、不拦查价。 包装:销售原文非空即收下,禁止白名单拦补问。 禁止:猜没写过的港口;因「对不上字典」跳过 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", } # 历史字典:只给对照,空运出站不再编成 1/2/3。 _PACKS = { "全部": "1", "托盘": "2", "卡板": "2", "散货": "2", "纸箱": "3", "木箱": "3", } _IATA = re.compile(r"^[A-Za-z]{3}$") _IATA_PAIR = re.compile( r"(? 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]: """ 当前原话里的航线覆盖起运地、目的地。 图上/原话写了 SZX-CGK 这类三字码对,必须保持英文,禁止改写成深圳/雅加达。 「从南京飞吉隆坡」仍按对照表收中文地名。换城市必须丢掉旧三字码。 """ merged = dict(facts or {}) raw = text or "" pair = _IATA_PAIR.search(raw) if pair: origin_code = pair.group(1).upper() dest_code = pair.group(2).upper() blocked = origin_code in _IATA_PAIR_BLOCK or dest_code in _IATA_PAIR_BLOCK if origin_code != dest_code and not blocked: merged["起运港"] = origin_code merged["目的港"] = dest_code merged.pop("originCode", None) merged.pop("destinationCode", None) return merged 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] = [] # 起运港改为非必填:空着仍可查价。三字码是出站投影,对不上不拿来再补问。 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("重量(KG)") if not volume: missing.append("体积") missing_display.append("体积(CBM)") 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) 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: # 包装出站跟销售原文:输入托盘就传托盘,禁止编成 2。 out_facts["包装方式"] = pack_raw out_facts["packageType"] = pack_raw if quote_date: out_facts["报价日期"] = quote_date # 非必填有值才投影出站键;贸易条款禁止写成 TMS 条款字段。 source_place = str(src.get("货源地") or "").strip() ready = str(src.get("货好时间") or "").strip() pickup = str(src.get("提货地址") or "").strip() delivery = str(src.get("收货地址") or "").strip() trade = str(src.get("贸易条款") or "").strip() if source_place: out_facts["货源地"] = source_place out_facts["cargoSource"] = source_place if ready: out_facts["货好时间"] = ready out_facts["cargoReadyDate"] = ready if pickup: out_facts["提货地址"] = pickup out_facts["placeOfReceipt"] = pickup if delivery: out_facts["收货地址"] = delivery out_facts["deliveryAddress"] = delivery if trade: out_facts["贸易条款"] = trade out_facts.pop("tradeTerms", None) out_facts.pop("transportTerms", None) out_facts.pop("trade_terms", None) 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, }