""" 空运 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(".")