空着的运输分类不再被菜单第一项认成已填。空运报价改为货物信息加价格方案,锁舱成功回执补起运地、目的地、航司和航班日期。 Co-authored-by: Cursor <cursoragent@cursor.com>
363 lines
13 KiB
Python
363 lines
13 KiB
Python
"""
|
||
空运 TMS 报价正文:私聊价格明细,以及协同群有价时的货物信息和 TMS 段。
|
||
|
||
本文件职责:只排销售和群里看得见的文字。费用名、单价、单位、备注、合计、有效期、航司、计费重、货型都取 TMS 原文。
|
||
起运地、品名、件数等仍用工单上销售已填的内容。
|
||
禁止:改按钮、改卡片、改海运陆运、没查到价时另写一套、本地编备注或货型。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
# 货型没有单独合同字段。TMS 若带回下列键,原样展示;都没有就「-」。
|
||
_CARGO_TYPE_KEYS = (
|
||
"cargoType",
|
||
"goodsType",
|
||
"cargoRatio",
|
||
"weightVolumeRatio",
|
||
"volumeWeightRatio",
|
||
"heavyBubbleRatio",
|
||
"bubbleRatio",
|
||
"densityRatio",
|
||
"cargoShape",
|
||
)
|
||
|
||
_REMARK_KEYS = (
|
||
"remark",
|
||
"remarks",
|
||
"note",
|
||
"specialRemarks",
|
||
"special_remarks",
|
||
"quoteRemark",
|
||
"description",
|
||
)
|
||
|
||
|
||
def air_private_quote_text(
|
||
*,
|
||
work_order_no: str,
|
||
facts: dict[str, str] | None,
|
||
quote: dict[str, Any] | None,
|
||
) -> str:
|
||
"""
|
||
私聊查到一条空运 TMS 报价后的完整文字。
|
||
|
||
先货物信息,再「价格方案如下」。协同说明和按钮不在这里,仍由后面的卡片发。
|
||
副作用:无。线程:调用方线程。
|
||
"""
|
||
lines = air_cargo_lines(work_order_no=work_order_no, facts=facts, quote=quote)
|
||
lines.append("")
|
||
lines.append("价格方案如下:")
|
||
lines.extend(air_price_lines(quote, facts))
|
||
return "\n".join(lines)
|
||
|
||
|
||
def air_cargo_lines(
|
||
*,
|
||
work_order_no: str,
|
||
facts: dict[str, str] | None,
|
||
quote: dict[str, Any] | None,
|
||
) -> list[str]:
|
||
"""
|
||
货物信息。工单字段与现在确认工单同一套单位;航司、计费重、货型只认 TMS。
|
||
|
||
报价日期没填写成「-」,不写「非必填」。增值服务有内容才多一行。
|
||
"""
|
||
from agent.policy.inquiry_copy import _with_unit
|
||
|
||
src = dict(facts or {})
|
||
origin = (src.get("起运港") or src.get("起运地") or "-").strip() or "-"
|
||
dest = (src.get("目的港") or src.get("目的地") or "-").strip() or "-"
|
||
cargo = (src.get("品名") or "-").strip() or "-"
|
||
pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-"
|
||
quote_date = (src.get("报价日期") or "").strip()
|
||
if quote_date in {"", "-", "—", "-"}:
|
||
quote_date = "-"
|
||
lines = [
|
||
f"工单号:{(work_order_no or '').strip()}",
|
||
"运输方式:空运",
|
||
f"起运地:{origin}",
|
||
f"目的地:{dest}",
|
||
f"航司:{_airline_display(quote, src)}",
|
||
f"品名:{cargo}",
|
||
f"件数:{_with_unit(str(src.get('件数') or ''), '件')}",
|
||
f"重量(KG):{_with_unit(str(src.get('毛重') or ''), 'KGS')}",
|
||
f"体积(CBM):{_with_unit(str(src.get('体积') or ''), 'CBM')}",
|
||
f"包装类型:{pack}",
|
||
]
|
||
added = (src.get("added_services") or src.get("增值服务") or "").strip()
|
||
if added and added not in {"-", "—", "-"}:
|
||
lines.append(f"增值服务:{added}")
|
||
lines.append(f"计费重:{_chargeable_display(quote, src)}")
|
||
lines.append(f"货型:{_cargo_type_display(quote, src)}")
|
||
lines.append(f"报价日期:{quote_date}")
|
||
return lines
|
||
|
||
|
||
def air_price_lines(
|
||
quote: dict[str, Any] | None,
|
||
facts: dict[str, str] | None = None,
|
||
) -> list[str]:
|
||
"""
|
||
TMS 价格段:费用行、备注、费用合计、报价有效期。
|
||
|
||
有单价且有计价单位:单价CNY11/KG。按票:CNY300/票。只有金额:CNY 100.0。
|
||
备注每次都有,TMS 没带回就「-」。不写时效。
|
||
"""
|
||
from agent.policy.inquiry_copy import money_text, tms_valid_until_display
|
||
|
||
src = dict(quote or {})
|
||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||
lines = [_format_fee(row, header_ccy) for row in _fee_rows(src, facts)]
|
||
lines = [line for line in lines if line]
|
||
lines.append(f"备注:{_remark_display(src, facts)}")
|
||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||
lines.append(f"费用合计:{total}")
|
||
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
|
||
return lines
|
||
|
||
|
||
def _airline_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str:
|
||
"""航司优先代码(TH)。只有中文名时用中文名。没有就「-」。"""
|
||
option = _option(quote, facts)
|
||
blobs = [option, dict(quote or {})]
|
||
for blob in blobs:
|
||
for key in ("airlineCode", "carrierCode"):
|
||
text = str(blob.get(key) or "").strip()
|
||
if text:
|
||
return text
|
||
for blob in blobs:
|
||
raw = str(blob.get("airline") or blob.get("carrier") or "").strip()
|
||
if raw and _looks_like_code(raw):
|
||
return raw
|
||
for blob in blobs:
|
||
name = str(blob.get("airlineName") or "").strip()
|
||
if name:
|
||
return name
|
||
raw = str(blob.get("airline") or blob.get("carrier") or "").strip()
|
||
if raw:
|
||
return raw
|
||
return "-"
|
||
|
||
|
||
def _looks_like_code(raw: str) -> bool:
|
||
"""两到四位字母数字才当航司代码,避免把中文名收成代码。"""
|
||
token = raw.replace(" ", "")
|
||
return token.isascii() and token.isalnum() and 2 <= len(token) <= 4
|
||
|
||
|
||
def _chargeable_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str:
|
||
"""计费重。纯数字补 KG;TMS 已带单位则原样。没有就「-」。"""
|
||
option = _option(quote, facts)
|
||
for blob in (option, dict(quote or {})):
|
||
raw = blob.get("chargeableWeightKg")
|
||
if raw in (None, "", "-"):
|
||
raw = blob.get("chargeable_weight_kg") or blob.get("chargeableWeight")
|
||
text = str(raw or "").strip()
|
||
if not text or text in {"-", "—", "-"}:
|
||
continue
|
||
upper = text.upper()
|
||
if any(upper.endswith(unit) for unit in ("KG", "KGS", "公斤", "千克")):
|
||
return text
|
||
return f"{_compact_number(text)}KG"
|
||
return "-"
|
||
|
||
|
||
def _cargo_type_display(quote: dict[str, Any] | None, facts: dict[str, str]) -> str:
|
||
"""货型只展示 TMS 带回的原文,例如 1:212。没有就「-」。"""
|
||
option = _option(quote, facts)
|
||
for blob in (option, dict(quote or {})):
|
||
for key in _CARGO_TYPE_KEYS:
|
||
text = str(blob.get(key) or "").strip()
|
||
if text and text not in {"-", "—", "-"}:
|
||
return text
|
||
return "-"
|
||
|
||
|
||
def _remark_display(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> str:
|
||
"""报价备注。整单没有时,把费用行上不重复的备注拼起来。仍没有就「-」。"""
|
||
src = dict(quote or {})
|
||
option = _option(src, facts or {})
|
||
for blob in (src, option):
|
||
for key in _REMARK_KEYS:
|
||
text = str(blob.get(key) or "").strip()
|
||
if text and text not in {"-", "—", "-"}:
|
||
return text
|
||
notes: list[str] = []
|
||
for row in _fee_rows(src, facts):
|
||
note = str(row.get("remark") or row.get("note") or row.get("description") or "").strip()
|
||
if note and note not in notes and note not in {"-", "—", "-"}:
|
||
notes.append(note)
|
||
return ";".join(notes) if notes else "-"
|
||
|
||
|
||
def _option(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> dict[str, Any]:
|
||
"""群摘要和私聊共用:已选定的那条,或仅一条,或起运目的对得上的那条。"""
|
||
from agent.schema.air_route_options import resolve_display_air_option
|
||
|
||
found = resolve_display_air_option(quote, facts)
|
||
return dict(found or {})
|
||
|
||
|
||
def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> list[dict[str, Any]]:
|
||
"""
|
||
该条线路的原始费用。
|
||
|
||
先读舱位上的空运段、起运港、目的地。多条线路对不上时不借用整单费用。
|
||
已经有其它分项时,去掉「空运费=总价」这一行兜底。
|
||
"""
|
||
from agent.schema.air_route_options import (
|
||
drop_air_freight_total_alias,
|
||
parse_air_options,
|
||
resolve_display_air_option,
|
||
)
|
||
|
||
src = dict(quote or {})
|
||
option = resolve_display_air_option(src, facts)
|
||
rows: list[dict[str, Any]] = []
|
||
if option:
|
||
rows.extend(_named_rows(option.get("feeItems") or option.get("fee_items")))
|
||
rows.extend(
|
||
_named_rows(
|
||
option.get("departureCharges")
|
||
or option.get("departure_charges")
|
||
or option.get("charges")
|
||
)
|
||
)
|
||
rows.extend(
|
||
_named_rows(
|
||
option.get("destinationCharges")
|
||
or option.get("destination_charges")
|
||
or option.get("arrivalCharges")
|
||
)
|
||
)
|
||
rows = _dedupe(rows)
|
||
if not rows and len(parse_air_options(src)) > 1:
|
||
return []
|
||
if not rows:
|
||
rows = _named_rows(src.get("feeItems") or src.get("fee_items"))
|
||
if not rows:
|
||
rows = _named_rows(src.get("fee_lines") or src.get("fee_rows"))
|
||
if not rows:
|
||
return []
|
||
projected = drop_air_freight_total_alias({"total": src.get("total"), "fee_lines": rows})
|
||
kept = projected.get("fee_lines")
|
||
return [row for row in kept if isinstance(row, dict)] if isinstance(kept, list) else rows
|
||
|
||
|
||
def _named_rows(raw: Any) -> list[dict[str, Any]]:
|
||
"""费用数组收成字典。字符串 JSON 也认。没有名称的跳过。"""
|
||
parsed = _parse_list(raw)
|
||
out: list[dict[str, Any]] = []
|
||
for item in parsed:
|
||
row = dict(item)
|
||
name = str(
|
||
row.get("name") or row.get("chargeName") or row.get("feeName") or row.get("label") or ""
|
||
).strip()
|
||
if not name:
|
||
continue
|
||
row["name"] = name
|
||
out.append(row)
|
||
return out
|
||
|
||
|
||
def _parse_list(raw: Any) -> list[dict[str, Any]]:
|
||
if isinstance(raw, str) and raw.strip():
|
||
try:
|
||
raw = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return []
|
||
if not isinstance(raw, list):
|
||
return []
|
||
return [dict(item) for item in raw if isinstance(item, dict)]
|
||
|
||
|
||
def _dedupe(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""同名费用保留先出现的桶,和主账投影一致。"""
|
||
seen: set[str] = set()
|
||
out: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
key = str(row.get("name") or "").strip().lower()
|
||
if not key or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(row)
|
||
return out
|
||
|
||
|
||
def _format_fee(row: dict[str, Any], header_ccy: str) -> str:
|
||
"""一条费用。没有可展示的金额就不出现。"""
|
||
from agent.policy.inquiry_copy import money_text
|
||
|
||
name = str(row.get("name") or "").strip()
|
||
if not name:
|
||
return ""
|
||
ccy = _currency(str(row.get("currency") or row.get("currencyCode") or header_ccy or ""))
|
||
unit = _bill_unit(str(row.get("unit") or row.get("unitName") or row.get("chargeUnit") or ""))
|
||
unit_price = _first_text(row.get("unitPrice"), row.get("unit_price"))
|
||
amount = _first_text(row.get("amount"), row.get("value"))
|
||
if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}:
|
||
shown = amount if "%" in amount else f"{amount}%"
|
||
return f"{name}:{shown}"
|
||
if unit and unit_price and unit != "票":
|
||
price = _compact_number(unit_price)
|
||
prefix = f"单价{ccy}{price}" if ccy else f"单价{price}"
|
||
return f"{name}:{prefix}/{unit}"
|
||
if unit == "票":
|
||
num = unit_price or amount
|
||
if num:
|
||
body = f"{ccy}{_compact_number(num)}" if ccy else _compact_number(num)
|
||
return f"{name}:{body}/票"
|
||
shown = amount or unit_price
|
||
if not shown:
|
||
return ""
|
||
return f"{name}:{money_text(shown, ccy)}"
|
||
|
||
|
||
def _bill_unit(raw: str) -> str:
|
||
"""计价单位收成 KG / 票 / CBM。空串表示 TMS 没给单位。"""
|
||
text = (raw or "").strip()
|
||
if not text:
|
||
return ""
|
||
token = text.upper().replace("/", "").replace("每", "").replace(" ", "")
|
||
if token in {"票", "SHIPMENT", "BL"} or text in {"票", "/票", "每票"}:
|
||
return "票"
|
||
if token in {"KG", "KGS", "公斤", "千克"}:
|
||
return "KG"
|
||
if token in {"CBM", "M3", "立方米", "立方"}:
|
||
return "CBM"
|
||
return text.lstrip("/")
|
||
|
||
|
||
def _currency(raw: str) -> str:
|
||
code = (raw or "").strip().upper()
|
||
if code == "RMB":
|
||
return "CNY"
|
||
return code
|
||
|
||
|
||
def _first_text(*values: Any) -> str:
|
||
for value in values:
|
||
text = str(value if value is not None else "").strip()
|
||
if text and text not in {"-", "—", "-", "None"}:
|
||
return text
|
||
return ""
|
||
|
||
|
||
def _compact_number(raw: str) -> str:
|
||
"""单价和按票金额去掉多余的尾零:11.00 写成 11。解析不了就原样。"""
|
||
text = (raw or "").strip().replace(",", "")
|
||
for prefix in ("CNY", "USD", "EUR", "HKD", "RMB"):
|
||
if text.upper().startswith(prefix):
|
||
text = text[len(prefix):].strip()
|
||
break
|
||
try:
|
||
number = float(text)
|
||
except ValueError:
|
||
return text
|
||
if number == int(number):
|
||
return str(int(number))
|
||
return f"{number:.4f}".rstrip("0").rstrip(".")
|