""" 空运 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 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 {}) 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)