Files
inquiry_robot/inquiry-agent/agent/schema/air_route_options.py
T
2026-10-13 18:35:52 +08:00

555 lines
19 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 线路报价:从 airOptions 拆出可选线路。
本文件职责:判断一条还是多条、拼航线(含中转)、抽出该线路费用。
选定后把起运港写回 facts。群摘要现场投影同一套分项。
中转只用于展示,不写进报价单模板。
禁止:猜没回的机场名;把贸易条款当线路;改六态。
"""
from __future__ import annotations
import json
import logging
from typing import Any
from agent.schema.tms_air_query import _AIRPORTS, resolve_airport_code
logger = logging.getLogger(__name__)
# 三字码 → 销售侧地名;同码多个中文时取较短的常用名。
_CODE_NAME: dict[str, str] = {}
for name, code in _AIRPORTS.items():
prev = _CODE_NAME.get(code)
if prev is None or len(name) < len(prev):
_CODE_NAME[code] = name
def parse_air_options(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""把 quote.airOptions(list 或 JSON 串)收成字典列表。"""
src = dict(quote or {})
raw = src.get("airOptions") or src.get("air_options")
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
logger.warning("air.routes airOptions 不是 JSON")
return []
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw:
if isinstance(item, dict):
out.append(dict(item))
return out
def needs_route_pick(quote: dict[str, Any] | None) -> bool:
"""没写起运地、且多于一条线路,才进 H5 选。有起运地时由调用方先按起运地收口。"""
return len(parse_air_options(quote)) > 1
def options_matching_origin(
options: list[dict[str, Any]],
origin_text: str,
) -> list[dict[str, Any]]:
"""
销售已经写了起运地时,只留起运机场对得上的线路。
杭州对 HGH,珠海对 ZUH。回包里的南京/深圳/郑州不能拿来给这张单选。
对不上返回空列表。不改入参。
"""
text = (origin_text or "").strip()
if not text:
return list(options)
code = resolve_airport_code(text).upper()
raw = text.upper()
matched: list[dict[str, Any]] = []
for opt in options:
origin = str(opt.get("originAirportCode") or opt.get("origin") or "").strip().upper()
if not origin:
continue
if code and origin == code:
matched.append(opt)
elif origin == raw:
matched.append(opt)
return matched
def choose_stated_origin_option(
options: list[dict[str, Any]],
quote: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
起运地已指定时直接用一条,不再让销售选线。
只有一条就用它。多条时优先和报价总价相同的那条,否则用第一条。
"""
if len(options) == 1:
return options[0]
total = (quote or {}).get("total")
for opt in options:
if _same_amount(opt.get("salePrice"), total):
return opt
return options[0]
def _place_name(code: str, fallback: str = "") -> str:
"""三字码能对上就用中文,否则用原文或 fallback。"""
raw = (code or "").strip()
if not raw:
return (fallback or "").strip()
mapped = _CODE_NAME.get(raw.upper())
if mapped:
return mapped
if resolve_airport_code(raw):
return _CODE_NAME.get(resolve_airport_code(raw), raw)
return raw
def route_line(option: dict[str, Any] | None, facts: dict[str, str] | None = None) -> str:
"""
销售侧航线:有中转写成 起运-中转-目的。
对不上字典就展示三字码,禁止编城市。
"""
src = dict(option or {})
facts = facts or {}
origin = _place_name(
str(src.get("originAirportCode") or src.get("origin") or ""),
str(facts.get("起运港") or ""),
)
dest = _place_name(
str(src.get("destinationAirportCode") or src.get("destination") or ""),
str(facts.get("目的港") or ""),
)
transfer = _place_name(str(src.get("transferAirportCode") or src.get("transfer") or ""))
bits = [x for x in (origin, transfer, dest) if x]
return "-".join(bits)
def _iata_code(raw: str, fallback: str = "") -> str:
"""只要三字码。已是代码就用;中文能投影就投影。禁止编假码。"""
text = (raw or "").strip()
if len(text) == 3 and text.isalpha():
return text.upper()
mapped = resolve_airport_code(text) or resolve_airport_code(fallback)
return (mapped or "").upper()
def route_codes(
option: dict[str, Any] | None,
facts: dict[str, str] | None = None,
quote: dict[str, Any] | None = None,
) -> str:
"""
价格明细/方案卡用的航线三字码:有中转写成 ZUH-CAN-CRK。
对不上就不写假中转。
"""
src = dict(option or {})
if not src:
q = dict(quote or {})
src = dict(q.get("selected_air_option") or {})
if not src:
opts = parse_air_options(q)
src = dict(opts[0]) if opts else {}
facts = facts or {}
origin = _iata_code(
str(src.get("originAirportCode") or src.get("origin") or facts.get("originCode") or ""),
str(facts.get("起运港") or ""),
)
dest = _iata_code(
str(src.get("destinationAirportCode") or src.get("destination") or facts.get("destinationCode") or ""),
str(facts.get("目的港") or ""),
)
transfer = _iata_code(
str(
src.get("transferAirportCode")
or src.get("transfer")
or (quote or {}).get("transferAirportCode")
or ""
)
)
bits = [x for x in (origin, transfer, dest) if x]
return "-".join(bits)
def scheme_route_codes(
quote: dict[str, Any] | None,
facts: dict[str, str] | None = None,
) -> str:
"""方案卡航线:空运ZUH→中转→CRK。没有代码则退回地名。"""
codes = route_codes(None, facts, quote)
if codes:
return "空运" + codes.replace("-", "→")
facts = facts or {}
origin = (facts.get("起运港") or "-").strip() or "-"
dest = (facts.get("目的港") or "-").strip() or "-"
return f"空运{origin}→{dest}"
def _fee_row(item: dict[str, Any]) -> tuple[str, str] | None:
"""一条 TMS 费用。有名称才算;金额空则用单价。禁止编费目。"""
name = str(
item.get("chargeName") or item.get("feeName") or item.get("name") or ""
).strip()
if not name:
return None
ccy = str(item.get("currency") or "").strip()
amount = item.get("amount")
if amount is None or str(amount).strip() == "":
amount = item.get("unitPrice") or item.get("unit_price")
text = str(amount or "").strip()
if ccy and text and ccy not in text:
text = f"{ccy} {text}"
return (name, text or "-")
def _rows_from_list(raw: Any) -> list[tuple[str, str]]:
"""把 TMS 费用数组收成展示行。不是列表就当没有。"""
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
out: list[tuple[str, str]] = []
for item in raw:
if isinstance(item, dict):
row = _fee_row(item)
if row:
out.append(row)
return out
def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]:
"""
该线路 TMS 回了哪些费用就列哪些。
只读本条 departureCharges / feeItems。多条线路时,整单 fee_lines 是最低价方案,
禁止套到没回费用的线路。仅一条线路时,整单 fee_lines 才算这条的明细。
"""
src = dict(option or {})
rows = _rows_from_list(src.get("departureCharges") or src.get("departure_charges"))
rows.extend(_rows_from_list(src.get("feeItems") or src.get("fee_items")))
if rows:
return rows
q = dict(quote or {})
options = parse_air_options(q)
if len(options) > 1:
return []
return _rows_from_list(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems"))
def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> str:
"""该线路总销售价。"""
src = dict(option or {})
q = dict(quote or {})
raw = src.get("salePrice")
if raw is None or str(raw).strip() == "":
raw = q.get("total") or q.get("salePrice")
ccy = str(src.get("currency") or q.get("currency") or "").strip()
text = str(raw or "").strip() or "-"
if ccy and text != "-" and ccy not in text:
return f"{ccy} {text}"
return text
# TMS 舱位「班期」可能用的键。页面只展示第一个非空值,例如 D234567,不翻译。
_SCHEDULE_KEYS = (
"班期",
"schedule",
"flightSchedule",
"flight_schedule",
"frequency",
"weekFrequency",
"scheduleDays",
"operatingDays",
"flightFrequency",
"departureSchedule",
"scheduleCode",
"flightPeriod",
"frequencyCode",
)
def option_flight_date(option: dict[str, Any] | None) -> str:
"""
舱位上的航班日期。优先 flightDate,其次从起飞时间取日期。
没有则空串。不编当天。
"""
src = dict(option or {})
for key in ("flightDate", "flight_date", "departureDate", "departure_date"):
text = str(src.get(key) or "").strip()
if text:
return _date_head(text)
return _date_head(str(src.get("departureTime") or src.get("departure_time") or ""))
def _date_head(raw: str) -> str:
"""yyyy-MM-dd 开头就取日期,否则原样。空串保持空。"""
text = (raw or "").strip()
if len(text) >= 10 and text[4] == "-" and text[7] == "-":
return text[:10]
return text
def option_schedule(option: dict[str, Any] | None) -> str:
"""班期按 TMS 原样,例如 D234567。没有就空串,不编。"""
src = dict(option or {})
for key in _SCHEDULE_KEYS:
text = str(src.get(key) or "").strip()
if text:
return text
return ""
def option_airline(option: dict[str, Any] | None) -> str:
"""航司:有中文名用中文名,否则用代码。没有就空串。"""
src = dict(option or {})
for key in ("airlineName", "airline", "airlineCode", "carrier"):
text = str(src.get(key) or "").strip()
if text:
return text
return ""
def option_endpoint_name(
option: dict[str, Any] | None,
side: str,
facts: dict[str, str] | None = None,
) -> str:
"""
舱位起运地或目的地。三字码能对上就用中文,对不上展示代码。
side 取 origin / destination。不编城市。
"""
src = dict(option or {})
facts = facts or {}
if side == "destination":
code = str(src.get("destinationAirportCode") or src.get("destination") or "")
fallback = str(facts.get("目的港") or facts.get("目的地") or "")
else:
code = str(src.get("originAirportCode") or src.get("origin") or "")
fallback = str(facts.get("起运港") or facts.get("起运地") or "")
return _place_name(code, fallback)
def _price_sort_value(option: dict[str, Any]) -> float:
"""总价排序用。解析不出的排到同一天最后。"""
raw = option.get("salePrice")
if raw is None or str(raw).strip() == "":
return float("inf")
num = ""
for ch in str(raw).replace(",", ""):
if ch.isdigit() or ch == ".":
num += ch
elif num:
break
try:
return float(num) if num else float("inf")
except ValueError:
return float("inf")
def sort_cabin_options(options: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""
没填报价日期时的舱位顺序:航班日期从早到晚,同一天总价从低到高。
没有日期的排在最后。不改入参。
"""
rows = [dict(item) for item in list(options or [])]
def _key(item: dict[str, Any]) -> tuple[str, float]:
day = option_flight_date(item) or "9999-99-99"
return (day, _price_sort_value(item))
return sorted(rows, key=_key)
def apply_selected_option(facts: dict[str, str] | None, option: dict[str, Any] | None) -> dict[str, str]:
"""
选定舱位后写回起运港(及能对上的三字码)。改选覆盖。
工单还没有报价日期时,把这条的航班日期写回去,供报价单和锁舱使用。
销售已经说了日期则不覆盖。
"""
merged = dict(facts or {})
src = dict(option or {})
flight_date = option_flight_date(src)
if flight_date and not str(merged.get("报价日期") or "").strip():
merged["报价日期"] = flight_date
code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper()
name = _place_name(code)
if name:
merged["起运港"] = name
if code and len(code) == 3 and code.isalpha():
merged["originCode"] = code
dest_code = str(src.get("destinationAirportCode") or "").strip().upper()
dest_name = _place_name(dest_code, str(merged.get("目的港") or ""))
if dest_name:
merged["目的港"] = dest_name
if dest_code and len(dest_code) == 3 and dest_code.isalpha():
merged["destinationCode"] = dest_code
return merged
def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any] | None) -> dict[str, Any]:
"""把选中线路投影成价格卡用的 quote。"""
base = dict(quote or {})
src = dict(option or {})
lines = [{"name": n, "amount": a} for n, a in option_fee_lines(src, base)]
# 必须覆盖整单 fee_lines:空列表也要写回去,否则会残留别的线路空运费。
base["fee_lines"] = lines
if not lines:
base["freight"] = "-"
base["other_fee"] = "-"
total = src.get("salePrice")
if total is not None and str(total).strip() != "":
base["total"] = str(total).strip()
if src.get("currency"):
base["currency"] = src.get("currency")
if src.get("validUntil"):
base["valid_until"] = src.get("validUntil")
base["selected_air_option"] = src
base["route_line"] = route_line(src, {})
return base
def _parse_option_dict(raw: Any) -> dict[str, Any]:
"""selected_air_option:字典或 JSON 串。解不开当没有。"""
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return {}
return dict(raw) if isinstance(raw, dict) else {}
def resolve_display_air_option(
quote: dict[str, Any] | None,
facts: dict[str, str] | None = None,
) -> dict[str, Any] | None:
"""
群摘要用哪一条线路:已选定 > 仅 1 条 > 用工单起运/目的对上的那条。
多条对不上就不猜。禁止把整单最低价的空运费套到没选的线路。
"""
src = dict(quote or {})
selected = _parse_option_dict(src.get("selected_air_option"))
if selected:
return selected
options = parse_air_options(src)
if not options:
return None
if len(options) == 1:
return options[0]
facts = facts or {}
origin = str(facts.get("originCode") or "").strip().upper()
if not origin:
origin = resolve_airport_code(
str(facts.get("起运港") or facts.get("起运地") or "")
)
dest = str(facts.get("destinationCode") or "").strip().upper()
if not dest:
dest = resolve_airport_code(
str(facts.get("目的港") or facts.get("目的地") or "")
)
matched: list[dict[str, Any]] = []
for opt in options:
oc = str(opt.get("originAirportCode") or opt.get("origin") or "").strip().upper()
dc = str(
opt.get("destinationAirportCode") or opt.get("destination") or ""
).strip().upper()
if origin and oc and oc != origin:
continue
if dest and dc and dc != dest:
continue
if origin or dest:
matched.append(opt)
if len(matched) == 1:
return matched[0]
return None
def _amount_digits(raw: Any) -> str:
"""只留数字和小数点,便于判断空运费是否就是总价。"""
text = str(raw or "").strip().replace(",", "")
for prefix in ("CNY", "USD", "EUR", "HKD", "RMB"):
if text.upper().startswith(prefix):
text = text[len(prefix):].strip()
break
return text
def _same_amount(left: Any, right: Any) -> bool:
"""金额是否同一笔。币种前缀、逗号、小数末零忽略。"""
a = _amount_digits(left)
b = _amount_digits(right)
if not a or not b or a == "-" or b == "-":
return False
try:
return float(a) == float(b)
except ValueError:
return a == b
def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]:
"""
已经有其它费用分项时,去掉「空运费=总价」这一行兜底。
舱位上有运输费、提送货费时,主账快照常再塞一行空运费,金额就是总价,不能和分项叠在一起。
只有空运费这一项时必须留下:TMS 经常只回这一项,金额自然等于总价,群里仍要写出费用名。
不写库。群摘要投影之后调用。
"""
src = dict(quote or {})
total = src.get("total")
raw = src.get("fee_lines") or src.get("fee_rows")
parsed: Any = raw
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = None
if not isinstance(parsed, list):
return src
others: list[Any] = []
alias: list[Any] = []
for item in parsed:
if not isinstance(item, dict):
others.append(item)
continue
name = str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
amount = item.get("amount") or item.get("value")
if name == "空运费" and _same_amount(amount, total):
alias.append(item)
continue
others.append(item)
if others:
# 分项已经在,空运费=总价是重复兜底。运费字段同值也不再补一行。
src["fee_lines"] = others
if _same_amount(src.get("freight"), total):
src["freight"] = "-"
return src
# 明细就是这一条空运费,群摘要要显示费用名,不能只剩费用合计。
src["fee_lines"] = alias
return src
def project_group_air_quote(
quote: dict[str, Any] | None,
facts: dict[str, str] | None = None,
) -> dict[str, Any]:
"""
群摘要 TMS 段:有线路就投影该线路费用,与单聊价格明细同一套分项。
主账查价快照常只有「空运费=总价」;选定后的明细没写回主账,必须现场投影。
副作用:无。不写库、不改六态。
"""
src = dict(quote or {})
option = resolve_display_air_option(src, facts)
if option:
src = project_selected_quote(src, option)
return drop_air_freight_total_alias(src)