Files
inquiry_robot/inquiry-agent/agent/schema/tms_air_query.py
T

312 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
空运查价参数:对照 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",
"杭州": "HGH",
"萧山": "HGH",
"杭州萧山": "HGH",
}
# 历史字典:只给对照,空运出站不再编成 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 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,
}