421 lines
14 KiB
Python
421 lines
14 KiB
Python
"""
|
|
陆运 TMS 线路报价:按 segmentSequence 收成一条条完整线路。
|
|
|
|
本文件职责:一段里的多段是中转,不是多条线路;抽出每段成本;选定后只留这一条的段。
|
|
禁止:把 costPrice 标成销售价;编没有回的站名;改六态。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _as_list(raw: Any) -> list[Any]:
|
|
"""list 或 JSON 串。"""
|
|
if isinstance(raw, str) and raw.strip():
|
|
try:
|
|
raw = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
logger.warning("land.routes landSegments 不是 JSON")
|
|
return []
|
|
return list(raw) if isinstance(raw, list) else []
|
|
|
|
|
|
def parse_land_legs(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
"""摊平 quote.landSegments。"""
|
|
src = dict(quote or {})
|
|
out: list[dict[str, Any]] = []
|
|
for item in _as_list(src.get("landSegments") or src.get("land_segments")):
|
|
if isinstance(item, dict):
|
|
out.append(dict(item))
|
|
return out
|
|
|
|
|
|
def _seq(item: dict[str, Any]) -> int:
|
|
try:
|
|
return int(item.get("segmentSequence") or item.get("segment_sequence") or 1)
|
|
except (TypeError, ValueError):
|
|
return 1
|
|
|
|
|
|
def _leg_no(item: dict[str, Any]) -> int:
|
|
try:
|
|
return int(item.get("routeLegSequence") or item.get("route_leg_sequence") or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def group_land_routes(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
"""
|
|
同一 segmentSequence 收成一条线路,段按 routeLegSequence 排。
|
|
"""
|
|
buckets: dict[int, list[dict[str, Any]]] = {}
|
|
order: list[int] = []
|
|
for leg in parse_land_legs(quote):
|
|
key = _seq(leg)
|
|
if key not in buckets:
|
|
buckets[key] = []
|
|
order.append(key)
|
|
buckets[key].append(leg)
|
|
routes: list[dict[str, Any]] = []
|
|
for key in order:
|
|
legs = sorted(buckets[key], key=_leg_no)
|
|
routes.append({"segmentSequence": key, "legs": legs})
|
|
return routes
|
|
|
|
|
|
def needs_route_pick(quote: dict[str, Any] | None) -> bool:
|
|
"""多于一条完整线路才进 H5。"""
|
|
return len(group_land_routes(quote)) > 1
|
|
|
|
|
|
def route_line(route: dict[str, Any] | None) -> str:
|
|
"""上海-谅山-胡志明:第一段起运 + 各段目的。"""
|
|
legs = list((route or {}).get("legs") or [])
|
|
if not legs:
|
|
return ""
|
|
names = [str(legs[0].get("origin") or "").strip()]
|
|
for leg in legs:
|
|
dest = str(leg.get("destination") or "").strip()
|
|
if dest:
|
|
names.append(dest)
|
|
return "-".join(x for x in names if x)
|
|
|
|
|
|
def _money(raw: Any, currency: str = "") -> str:
|
|
text = str(raw if raw is not None else "").strip()
|
|
if not text or text in {"None", "null"}:
|
|
return ""
|
|
ccy = (currency or "").strip()
|
|
if ccy and ccy not in text:
|
|
return f"{ccy} {text}"
|
|
return text
|
|
|
|
|
|
def option_fee_lines(route: dict[str, Any] | None) -> list[tuple[str, str]]:
|
|
"""选线路页用:始发 → 目的 + 该段成本,方便区分备选。报价明细不走这里。"""
|
|
rows: list[tuple[str, str]] = []
|
|
for leg in list((route or {}).get("legs") or []):
|
|
origin = str(leg.get("origin") or "").strip() or "-"
|
|
dest = str(leg.get("destination") or "").strip() or "-"
|
|
ccy = str(leg.get("currency") or "").strip()
|
|
amount = _money(leg.get("costPrice") if leg.get("costPrice") is not None else leg.get("cost_price"), ccy)
|
|
if not amount:
|
|
continue
|
|
rows.append((f"{origin} → {dest}", amount))
|
|
return rows
|
|
|
|
|
|
def _num(raw: Any) -> float | None:
|
|
"""金额比大小:去掉币种和逗号。"""
|
|
if raw is None:
|
|
return None
|
|
text = str(raw).strip()
|
|
if not text or text in {"-", "None", "null"}:
|
|
return None
|
|
for bit in text.replace(",", " ").split():
|
|
try:
|
|
return float(bit)
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _same_amount(left: Any, right: Any) -> bool:
|
|
a, b = _num(left), _num(right)
|
|
if a is None or b is None:
|
|
return False
|
|
return abs(a - b) < 0.0001
|
|
|
|
|
|
def _leg_cost(leg: dict[str, Any] | None) -> Any:
|
|
src = dict(leg or {})
|
|
if src.get("costPrice") is not None:
|
|
return src.get("costPrice")
|
|
return src.get("cost_price")
|
|
|
|
|
|
def _has_seq(item: dict[str, Any] | None) -> bool:
|
|
src = dict(item or {})
|
|
raw = src.get("segmentSequence")
|
|
if raw is None:
|
|
raw = src.get("segment_sequence")
|
|
return raw not in (None, "", "null")
|
|
|
|
|
|
def _fee_name(item: dict[str, Any] | None) -> str:
|
|
"""费用名只认 TMS 原文,禁止用路段名冒充。"""
|
|
src = dict(item or {})
|
|
return str(
|
|
src.get("feeName")
|
|
or src.get("name")
|
|
or src.get("chargeName")
|
|
or src.get("itemName")
|
|
or src.get("feeType")
|
|
or ""
|
|
).strip()
|
|
|
|
|
|
def parse_tms_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
"""摊平 TMS feeItems / 已投影的 fee_lines。"""
|
|
src = dict(quote or {})
|
|
raw = src.get("feeItems") or src.get("fee_items") or src.get("fee_lines") or src.get("fee_rows")
|
|
return [dict(x) for x in _as_list(raw) if isinstance(x, dict)]
|
|
|
|
|
|
def _named_from_rows(raw: Any) -> list[dict[str, Any]]:
|
|
"""只要 TMS 费目名。路段名冒充的费用行丢掉。"""
|
|
rows: list[dict[str, Any]] = []
|
|
for item in _as_list(raw):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
name = _fee_name(item)
|
|
if not name or "→" in name or "->" in name:
|
|
continue
|
|
amount = item.get("amount")
|
|
if amount is None:
|
|
amount = item.get("costPrice") if item.get("costPrice") is not None else item.get("salePrice")
|
|
if _num(amount) is None:
|
|
continue
|
|
line = dict(item)
|
|
line["name"] = name
|
|
line["amount"] = amount
|
|
rows.append(line)
|
|
return rows
|
|
|
|
|
|
def _named_tms_fees(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
"""
|
|
先看已投影的 fee_lines,没有真费目再看 feeItems。
|
|
|
|
主账有时把路段名写进 fee_lines;这时必须回到 TMS feeItems 取运输费。
|
|
"""
|
|
src = dict(quote or {})
|
|
named = _named_from_rows(src.get("fee_lines") or src.get("fee_rows"))
|
|
if named:
|
|
return named
|
|
named = _named_from_rows(src.get("feeItems") or src.get("fee_items"))
|
|
if named:
|
|
return named
|
|
return _named_from_rows(parse_tms_fee_rows(src))
|
|
|
|
|
|
def _pick_fee_for_leg(
|
|
fees: list[dict[str, Any]],
|
|
used: set[int],
|
|
leg: dict[str, Any],
|
|
prefer_seq: int = 0,
|
|
) -> dict[str, Any] | None:
|
|
"""按段号 / 腿号 / 金额认回这一段的 TMS 费用。"""
|
|
want_leg = _leg_no(leg)
|
|
want_amt = _leg_cost(leg)
|
|
checks = (
|
|
lambda f: prefer_seq and _has_seq(f) and _seq(f) == prefer_seq and _leg_no(f) == want_leg,
|
|
lambda f: prefer_seq and _has_seq(f) and _seq(f) == prefer_seq and _same_amount(f.get("amount"), want_amt),
|
|
lambda f: _leg_no(f) == want_leg and _same_amount(f.get("amount"), want_amt),
|
|
lambda f: _same_amount(f.get("amount"), want_amt),
|
|
)
|
|
for check in checks:
|
|
for i, fee in enumerate(fees):
|
|
if i in used:
|
|
continue
|
|
try:
|
|
ok = check(fee)
|
|
except (TypeError, ValueError):
|
|
ok = False
|
|
if ok:
|
|
used.add(i)
|
|
return fee
|
|
return None
|
|
|
|
|
|
def selected_tms_fee_lines(
|
|
quote: dict[str, Any] | None,
|
|
route: dict[str, Any] | None,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
选定一条陆运线路后:只留该条 TMS 费用行,名称原样。
|
|
|
|
主账常把 fee_lines 收成 JSON 串,也不一定带段号;这时按各段成本和腿号认回去。
|
|
"""
|
|
src = dict(route or {})
|
|
try:
|
|
seq = int(src.get("segmentSequence") or src.get("segment_sequence") or 0)
|
|
except (TypeError, ValueError):
|
|
seq = 0
|
|
fees = _named_tms_fees(quote)
|
|
matched = [f for f in fees if _has_seq(f) and _seq(f) == seq]
|
|
if matched:
|
|
return matched
|
|
used: set[int] = set()
|
|
out: list[dict[str, Any]] = []
|
|
for leg in list(src.get("legs") or []):
|
|
if not isinstance(leg, dict):
|
|
continue
|
|
picked = _pick_fee_for_leg(fees, used, leg, prefer_seq=seq)
|
|
if picked:
|
|
out.append(picked)
|
|
if out:
|
|
return out
|
|
if fees and not needs_route_pick(quote):
|
|
return fees
|
|
return []
|
|
|
|
|
|
def _od_title(leg: dict[str, Any] | None) -> str:
|
|
"""上海 → 谅山:站名用 TMS 原文。"""
|
|
src = dict(leg or {})
|
|
origin = str(src.get("origin") or "").strip()
|
|
dest = str(src.get("destination") or "").strip()
|
|
if not origin and not dest:
|
|
return ""
|
|
return f"{origin or '-'} → {dest or '-'}"
|
|
|
|
|
|
def _quote_fee_dicts(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
"""优先已选定的 fee_lines,避免再摊开整单 feeItems。"""
|
|
src = dict(quote or {})
|
|
rows = [dict(x) for x in _as_list(src.get("fee_lines") or src.get("fee_rows")) if isinstance(x, dict)]
|
|
if rows:
|
|
return rows
|
|
return parse_tms_fee_rows(src)
|
|
|
|
|
|
def _selected_legs(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
"""当前这条线路的各段。多条未选时不编。"""
|
|
src = dict(quote or {})
|
|
selected = src.get("selected_land_route")
|
|
if isinstance(selected, dict):
|
|
legs = [dict(x) for x in list(selected.get("legs") or []) if isinstance(x, dict)]
|
|
if legs:
|
|
return legs
|
|
routes = group_land_routes(src)
|
|
if len(routes) == 1:
|
|
return [dict(x) for x in list(routes[0].get("legs") or []) if isinstance(x, dict)]
|
|
legs = parse_land_legs(src)
|
|
seqs = {_seq(x) for x in legs}
|
|
if len(seqs) == 1:
|
|
return sorted(legs, key=_leg_no)
|
|
return []
|
|
|
|
|
|
def land_leg_fee_blocks(
|
|
quote: dict[str, Any] | None,
|
|
) -> list[tuple[str, list[dict[str, Any]], str]]:
|
|
"""
|
|
价格明细按段:路段标题、该段 TMS 费目、该段时效原文。
|
|
|
|
例:上海 → 谅山 / 运输费 7500 / N+2。时效没有就不写,禁止编。
|
|
"""
|
|
legs = _selected_legs(quote)
|
|
if not legs:
|
|
return []
|
|
fees = _named_tms_fees(quote)
|
|
fallback_name = next((str(f.get("name") or "").strip() for f in fees if f.get("name")), "")
|
|
# 路段名把 TMS 费目冲掉时,各段成本仍按运输费展示,禁止再用始发→目的冒充费名。
|
|
if not fallback_name:
|
|
fallback_name = "运输费"
|
|
used: set[int] = set()
|
|
blocks: list[tuple[str, list[dict[str, Any]], str]] = []
|
|
for leg in legs:
|
|
title = _od_title(leg)
|
|
if not title:
|
|
continue
|
|
eta = str(leg.get("transitDays") or leg.get("transit_days") or "").strip()
|
|
picked: list[dict[str, Any]] = []
|
|
one = _pick_fee_for_leg(fees, used, leg, prefer_seq=_seq(leg))
|
|
if one:
|
|
picked.append(one)
|
|
elif fallback_name and _num(_leg_cost(leg)) is not None:
|
|
picked.append(
|
|
{
|
|
"name": fallback_name,
|
|
"amount": _leg_cost(leg),
|
|
"currency": str(leg.get("currency") or "").strip(),
|
|
}
|
|
)
|
|
blocks.append((title, picked, eta))
|
|
return blocks
|
|
|
|
|
|
def land_leg_fee_blocks_for_route(
|
|
quote: dict[str, Any] | None,
|
|
route: dict[str, Any] | None,
|
|
) -> list[tuple[str, list[dict[str, Any]], str]]:
|
|
"""H5 某一条备选:只收该条各段,费用名仍是 TMS 原文。"""
|
|
src = dict(route or {})
|
|
mini = dict(quote or {})
|
|
mini["selected_land_route"] = src
|
|
mini["landSegments"] = [dict(x) for x in list(src.get("legs") or []) if isinstance(x, dict)]
|
|
picked = selected_tms_fee_lines(quote, src)
|
|
if picked:
|
|
mini["fee_lines"] = picked
|
|
return land_leg_fee_blocks(mini)
|
|
|
|
|
|
def option_total(route: dict[str, Any] | None) -> str:
|
|
"""各段成本相加。加不上就空。"""
|
|
total = 0.0
|
|
ccy = ""
|
|
any_num = False
|
|
for leg in list((route or {}).get("legs") or []):
|
|
raw = leg.get("costPrice")
|
|
if raw is None:
|
|
raw = leg.get("cost_price")
|
|
if raw is None or str(raw).strip() in {"", "None", "null"}:
|
|
continue
|
|
try:
|
|
total += float(raw)
|
|
any_num = True
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not ccy:
|
|
ccy = str(leg.get("currency") or "").strip()
|
|
if not any_num:
|
|
return "-"
|
|
text = str(int(total)) if total == int(total) else str(total)
|
|
return _money(text, ccy) or "-"
|
|
|
|
|
|
def option_eta_bits(route: dict[str, Any] | None) -> list[tuple[str, str]]:
|
|
"""每段时效,有 transitDays 才写。"""
|
|
bits: list[tuple[str, str]] = []
|
|
for i, leg in enumerate(list((route or {}).get("legs") or []), 1):
|
|
days = str(leg.get("transitDays") or leg.get("transit_days") or "").strip()
|
|
if days:
|
|
bits.append((f"第{i}段时效", days))
|
|
return bits
|
|
|
|
|
|
def project_selected_route(quote: dict[str, Any] | None, route: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""
|
|
选定一条后:只留该条 landSegments,费用名按 TMS 原文,合计用成本加总。
|
|
"""
|
|
base = dict(quote or {})
|
|
src = dict(route or {})
|
|
legs = [dict(x) for x in list(src.get("legs") or [])]
|
|
base["landSegments"] = legs
|
|
lines = selected_tms_fee_lines(base, src)
|
|
if not lines:
|
|
mini = dict(base)
|
|
mini["selected_land_route"] = src
|
|
for _, fees, _eta in land_leg_fee_blocks(mini):
|
|
lines.extend(fees)
|
|
base["fee_lines"] = lines
|
|
base["total"] = option_total(src)
|
|
base["freight"] = "-"
|
|
base["other_fee"] = "-"
|
|
base["route_line"] = route_line(src)
|
|
base["selected_land_route"] = src
|
|
etas = option_eta_bits(src)
|
|
if etas:
|
|
# 时效按段原文拼接(N+2 / N+3),禁止当成小时数相加。
|
|
base["eta"] = " / ".join(d for _, d in etas)
|
|
if src:
|
|
base["cost"] = option_total(src)
|
|
return base
|