309 lines
10 KiB
Python
309 lines
10 KiB
Python
"""
|
||
空运查价参数:对照 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"(?<![A-Za-z])([A-Za-z]{3})\s*[-–—/]\s*([A-Za-z]{3})(?![A-Za-z])"
|
||
)
|
||
# 原话里的 AAA-BBB 不一定是航线,货币/单位不能当成起运目的。
|
||
_IATA_PAIR_BLOCK = frozenset(
|
||
{
|
||
"USD",
|
||
"CNY",
|
||
"EUR",
|
||
"GBP",
|
||
"HKD",
|
||
"JPY",
|
||
"CBM",
|
||
"KGS",
|
||
"PCS",
|
||
"PKG",
|
||
"PDF",
|
||
"JPG",
|
||
"PNG",
|
||
"AND",
|
||
"THE",
|
||
"FOR",
|
||
"NOT",
|
||
"MAX",
|
||
"MIN",
|
||
"GMT",
|
||
"UTC",
|
||
}
|
||
)
|
||
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
|
||
# 销售写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3
|
||
_DIM_PRODUCT = re.compile(
|
||
r"(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?:\s*[*xX×]\s*(\d+(?:\.\d+)?))?"
|
||
)
|
||
|
||
|
||
def _first_number(raw: str) -> str:
|
||
hit = _NUMBER.search((raw or "").strip())
|
||
return hit.group(1) if hit else ""
|
||
|
||
|
||
def dimension_volume_text(raw: str) -> str:
|
||
"""
|
||
原话里的尺寸式体积,保持销售写法,不在这里相乘。
|
||
|
||
「1.1*2.1*2.2」「1.1x1.2x3」都算体积原文。出站数字仍交给 volume_cbm_number。
|
||
箱型「40HQ*1」中间有字母,不算尺寸。没有尺寸返回空串。
|
||
"""
|
||
hit = _DIM_PRODUCT.search(raw or "")
|
||
if not hit:
|
||
return ""
|
||
return hit.group(0).strip()
|
||
|
||
|
||
def volume_cbm_number(raw: str) -> str:
|
||
"""
|
||
体积出站用数字:尺寸式先相乘;否则取第一个数。
|
||
|
||
例:1.1*1.2*3 → 3.96;2CBM → 2。空串表示没有可用体积。
|
||
"""
|
||
text = (raw or "").strip()
|
||
if not text:
|
||
return ""
|
||
dims = _DIM_PRODUCT.search(text)
|
||
if dims:
|
||
try:
|
||
a = float(dims.group(1))
|
||
b = float(dims.group(2))
|
||
c = float(dims.group(3) or "1")
|
||
product = a * b * c
|
||
if product <= 0:
|
||
return ""
|
||
# 去掉多余尾零,保持可读小数
|
||
text_num = f"{product:.10f}".rstrip("0").rstrip(".")
|
||
return text_num or ""
|
||
except ValueError:
|
||
return ""
|
||
return _first_number(text)
|
||
|
||
|
||
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 = volume_cbm_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,
|
||
}
|