From 49faa35f478e8e0179c7b375b86f3b538c724616 Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Tue, 22 Sep 2026 18:28:29 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=86=E8=BF=90=E5=A4=9A=E6=9D=A1=E7=BA=BF?= =?UTF-8?q?=E8=B7=AF=E6=8C=89=E6=AE=B5=E5=B1=95=E7=A4=BA=20TMS=20=E8=B4=B9?= =?UTF-8?q?=E7=94=A8=E5=90=8D=E5=92=8C=E6=97=B6=E6=95=88=EF=BC=8C=E9=80=89?= =?UTF-8?q?=E5=AE=9A=E5=90=8E=E5=86=8D=E5=86=99=E5=9B=9E=E8=AF=A5=E6=9D=A1?= =?UTF-8?q?=E6=88=90=E6=9C=AC=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- inquiry-agent/agent/channel/h5/__init__.py | 46 ++ .../agent/channel/h5/air_route_page.py | 1 + .../agent/channel/h5/land_route_page.py | 111 ++++ .../agent/channel/h5/land_route_store.py | 121 ++++ inquiry-agent/agent/ledger/http_ledger.py | 21 +- inquiry-agent/agent/ledger/memory_ledger.py | 46 +- inquiry-agent/agent/policy/inquiry_copy.py | 116 +++- inquiry-agent/agent/policy/land_text_flow.py | 131 ++++- .../agent/schema/air_route_options.py | 131 ++++- .../agent/schema/land_route_options.py | 420 ++++++++++++++ inquiry-agent/tests/test_air_group_collab.py | 123 ++++ inquiry-agent/tests/test_air_route_options.py | 68 +++ inquiry-agent/tests/test_air_text_inquiry.py | 29 +- inquiry-agent/tests/test_http_ledger.py | 15 + .../tests/test_land_route_options.py | 546 ++++++++++++++++++ inquiry-agent/tests/test_quote_adjust.py | 2 + .../controller/InquiryAgentController.java | 10 + .../service/IInquiryWorkOrderService.java | 6 + .../impl/InquiryQuoteTemplateServiceImpl.java | 44 +- .../impl/InquiryWorkOrderServiceImpl.java | 12 + .../inquiry/tms/InquiryTmsQueryService.java | 237 +++++++- .../template/QuoteTemplateFillTest.java | 25 + .../inquiry/tms/TmsQuoteCurrencyTest.java | 117 ++++ 23 files changed, 2323 insertions(+), 55 deletions(-) create mode 100644 inquiry-agent/agent/channel/h5/land_route_page.py create mode 100644 inquiry-agent/agent/channel/h5/land_route_store.py create mode 100644 inquiry-agent/agent/schema/land_route_options.py create mode 100644 inquiry-agent/tests/test_land_route_options.py diff --git a/inquiry-agent/agent/channel/h5/__init__.py b/inquiry-agent/agent/channel/h5/__init__.py index a55d8e6..5e8d1f0 100644 --- a/inquiry-agent/agent/channel/h5/__init__.py +++ b/inquiry-agent/agent/channel/h5/__init__.py @@ -158,6 +158,52 @@ def h5_air_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse return HTMLResponse(render_air_route_ok()) +@router.get("/land-routes/{token}", response_class=HTMLResponse) +def h5_land_route_open(token: str) -> HTMLResponse: + """打开陆运选线路页。身份只认 token。""" + from agent.channel.h5.land_route_page import expired_land_route_page, render_land_route_page + from agent.channel.h5.land_route_store import lookup_land_route_token + + rec = lookup_land_route_token(token) + if rec is None: + return HTMLResponse(expired_land_route_page(), status_code=410) + return HTMLResponse(render_land_route_page(rec)) + + +@router.post("/land-routes/{token}", response_class=HTMLResponse) +def h5_land_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse: + """ + 选定陆运线路。请求线程只验 token、入出站队列,不查 TMS、不跑整图。 + """ + from agent.channel.h5.land_route_page import ( + expired_land_route_page, + render_land_route_ok, + render_land_route_page, + ) + from agent.channel.h5.land_route_store import lookup_land_route_token + from agent.channel.queue import get_message_store + from agent.policy.land_text_flow import get_land_text_flow + + rec = lookup_land_route_token(token) + if rec is None: + return HTMLResponse(expired_land_route_page(), status_code=410) + sender_id = str(rec.get("sender_id") or "").strip() + if not sender_id: + return HTMLResponse(expired_land_route_page(), status_code=410) + + def reply(text: str, extra=None) -> None: + store = get_message_store() + store.enqueue_outbound(touser=sender_id, content=text, payload=dict(extra or {})) + + phase = get_land_text_flow().select_land_route( + sender_id=sender_id, index=int(index), reply=reply + ) + logger.info("h5.land_route pick sender=%s phase=%s idx=%s", sender_id, phase, index) + if phase in {"route_expired", "route_invalid"}: + return HTMLResponse(render_land_route_page(rec), status_code=400) + return HTMLResponse(render_land_route_ok()) + + @router.get("/{token}") def h5_open(token: str) -> dict[str, Any]: """ diff --git a/inquiry-agent/agent/channel/h5/air_route_page.py b/inquiry-agent/agent/channel/h5/air_route_page.py index 6fbcad0..0ae3416 100644 --- a/inquiry-agent/agent/channel/h5/air_route_page.py +++ b/inquiry-agent/agent/channel/h5/air_route_page.py @@ -128,6 +128,7 @@ h1 { font-size: 22px; line-height: 1.3; margin: 0 0 6px; font-weight: 600; } line-height: 1.5; } .fees .n { color: var(--muted); } +.fees li.leg .n { color: inherit; font-weight: 650; } .fees .a { font-variant-numeric: tabular-nums; text-align: right; } .empty { margin: 12px 0 0; diff --git a/inquiry-agent/agent/channel/h5/land_route_page.py b/inquiry-agent/agent/channel/h5/land_route_page.py new file mode 100644 index 0000000..26b6bd4 --- /dev/null +++ b/inquiry-agent/agent/channel/h5/land_route_page.py @@ -0,0 +1,111 @@ +""" +陆运选线路 H5 页。 + +本文件职责:按完整线路列出各段成本与合计,提交所选下标。 +样式对齐空运选线路页。禁止 URL userid;禁止本页查 TMS。 +""" + +from __future__ import annotations + +import html +from typing import Any + +from agent.channel.h5.air_route_page import _PAGE_CSS, _wrap +from agent.schema.land_route_options import ( + _money, + group_land_routes, + land_leg_fee_blocks_for_route, + option_eta_bits, + option_total, + route_line, +) + + +def expired_land_route_page() -> str: + """token 过期页。""" + return _wrap( + "已过期", + "

链接已过期

请回企微重新打开选线路卡片。

", + ) + + +def render_land_route_page(payload: dict[str, Any]) -> str: + """渲染选线路页。payload 来自 token。""" + quote = dict(payload.get("quote") or {}) + wo = html.escape(str(payload.get("work_order_no") or "")) + routes = group_land_routes(quote) + rows = [_route_card(i, route, quote) for i, route in enumerate(routes)] + body = ( + "
" + "
" + f"

{wo}

" + "

选择线路

" + f"

共 {len(routes)} 条线路,每条按段报价。点选一条后确定。

" + "
" + "
" + + "".join(rows) + + "
" + "" + "
" + "" + ) + return _wrap("选择线路", body) + + +def render_land_route_ok() -> str: + """选定成功。""" + return _wrap( + "已选定", + "

已选定

请回企微查看价格明细。

", + ) + + +def _route_card(index: int, route: dict[str, Any], quote: dict[str, Any] | None = None) -> str: + """一张线路:航线、各段路段+TMS 费目、合计。""" + line = html.escape(route_line(route)) + blocks = land_leg_fee_blocks_for_route(quote, route) + etas = {i: d for i, (_, d) in enumerate(option_eta_bits(route), 1)} + fee_bits: list[str] = [] + items = "" + if blocks: + for i, (title, fees, eta) in enumerate(blocks, 1): + items += ( + f"
  • {html.escape(title)}" + "
  • " + ) + for fee in fees: + amount = _money(fee.get("amount"), str(fee.get("currency") or "").strip()) + name = str(fee.get("name") or "").strip() + if not name or not amount: + continue + items += ( + f"
  • {html.escape(name)}" + f"{html.escape(amount)}
  • " + ) + days = eta or etas.get(i) or "" + if days: + items += ( + f"
  • 时效 {html.escape(days)}" + "
  • " + ) + if items: + fee_bits.append(f"") + else: + fee_bits.append("

    本线路未返回分段成本

    ") + total = html.escape(option_total(route)) + legs = list(route.get("legs") or []) + tag = "中转" if len(legs) > 1 else "直达" + tag_cls = "pill via" if len(legs) > 1 else "pill" + return ( + "" + ) diff --git a/inquiry-agent/agent/channel/h5/land_route_store.py b/inquiry-agent/agent/channel/h5/land_route_store.py new file mode 100644 index 0000000..1d1e1b3 --- /dev/null +++ b/inquiry-agent/agent/channel/h5/land_route_store.py @@ -0,0 +1,121 @@ +""" +陆运选线路 H5 的短命 token。 + +本文件职责:token 绑定 sender + 工单 + 线路报价;URL 禁止 userid。 +优先 Redis;单测走内存。 +禁止:在本包查 TMS、改六态。 +""" + +from __future__ import annotations + +import json +import logging +import secrets +import threading +import time +from typing import Any, Optional + +from agent.channel.h5.copy_store import inquiry_form_base +from agent.config import get_settings +from agent.redis_coord.keys import TOKEN_COPY_TICKET + +logger = logging.getLogger(__name__) + +LAND_ROUTE_TTL_SECONDS = 7 * 24 * 60 * 60 +_KEY = TOKEN_COPY_TICKET + ":land_route" +_MEM: dict[str, tuple[str, float]] = {} +_MEM_LOCK = threading.Lock() + + +def issue_land_route_token( + *, + sender_id: str, + work_order_no: str, + quote: dict[str, Any], + facts: dict[str, str] | None = None, + ttl_seconds: int = LAND_ROUTE_TTL_SECONDS, +) -> str: + """签发选线路 token。副作用:写 Redis 或内存。""" + payload = json.dumps( + { + "sender_id": sender_id, + "work_order_no": work_order_no, + "quote": quote, + "facts": dict(facts or {}), + }, + ensure_ascii=False, + ) + token = secrets.token_urlsafe(24) + if _redis_set(token, payload, ttl_seconds): + return token + expires = time.time() + ttl_seconds + with _MEM_LOCK: + _MEM[token] = (payload, expires) + logger.info("land route token 走内存 wo=%s", work_order_no) + return token + + +def lookup_land_route_token(token: str) -> Optional[dict[str, Any]]: + """读 token。过期或不存在返回 None。""" + raw = (token or "").strip() + if not raw: + return None + text = _redis_get(raw) + if text is None: + with _MEM_LOCK: + hit = _MEM.get(raw) + if not hit: + return None + payload, expires = hit + if expires < time.time(): + _MEM.pop(raw, None) + return None + text = payload + try: + data = json.loads(text) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def build_land_route_url(token: str) -> str: + """拼选线路页:{inquiry-form}/land-routes/{token}。""" + raw = (token or "").strip() + if not raw: + return "" + return f"{inquiry_form_base().rstrip('/')}/land-routes/{raw}" + + +def _redis_set(token: str, payload: str, ttl: int) -> bool: + client = _try_redis() + if client is None: + return False + try: + client.raw.set(client.key(_KEY, token), payload, ex=ttl) + return True + except Exception as exc: # noqa: BLE001 + logger.warning("land route token Redis 写入失败:%s", exc) + return False + + +def _redis_get(token: str) -> Optional[str]: + client = _try_redis() + if client is None: + return None + try: + raw = client.raw.get(client.key(_KEY, token)) + except Exception as exc: # noqa: BLE001 + logger.warning("land route token Redis 读取失败:%s", exc) + return None + if raw is None: + return None + return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw) + + +def _try_redis(): + try: + from agent.redis_coord import get_redis_runtime + + return get_redis_runtime(get_settings()).client + except Exception: # noqa: BLE001 + return None diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 507249b..dd55b24 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -144,9 +144,24 @@ class HttpLedger: quote: dict[str, Any], to_status: str = "", ) -> dict[str, Any]: - # 有价时主账 query 已写快照并迁「已报价」;此处只做幂等确认,避免二次假写入。 - _ = (work_order_no, quote, to_status) - return {"ok": True, "quote_version": 1, "status": to_status or "已报价"} + """ + 覆盖本单最新 TMS 快照。选定陆运线路后必须走这里,才能把该条成本写回工单。 + 空报价不打主账,避免把查价结果抹掉。 + """ + no = (work_order_no or "").strip() + src = dict(quote or {}) + if not no or not src: + return {"ok": True, "quote_version": 1, "status": to_status or "已报价"} + out = self._post( + "/inquiry/agent/ticket/replaceTmsQuote", + {"workOrderNo": no, "quote": src, "toStatus": to_status}, + idem=f"tms-quote:{no}:{src.get('total') or ''}:{src.get('route_line') or ''}", + ) + if out.get("ok") is False: + return out + out.setdefault("ok", True) + out.setdefault("status", to_status or "已报价") + return out def render_quote( self, diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index d0b1074..a20b718 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -73,7 +73,21 @@ class MemoryLedger: "bizType": "空运", "keywords": [], "isDefault": True, - } + }, + { + "templateId": "mem-default-land", + "name": "陆运默认", + "bizType": "陆运", + "keywords": [], + "isDefault": True, + }, + { + "templateId": "mem-default-sea", + "name": "海运默认", + "bizType": "海运", + "keywords": [], + "isDefault": True, + }, ] self.last_render: dict[str, Any] = {} self._sea_staff: list[dict[str, Any]] = [] @@ -192,6 +206,36 @@ class MemoryLedger: {"name": "其他附加费", "amount": "USD 80"}, ], } + if str(facts.get("tms_land_multi") or "") == "1": + quote["isMulti"] = "2" + quote["business_line"] = "LAND" + quote["landSegments"] = [ + {"segmentSequence": 1, "routeLegSequence": 1, "origin": "上海", "destination": "谅山", "costPrice": 7500, "currency": "CNY", "transitDays": 48}, + {"segmentSequence": 1, "routeLegSequence": 2, "origin": "谅山", "destination": "胡志明", "costPrice": 9600, "currency": "CNY", "transitDays": 72}, + {"segmentSequence": 2, "routeLegSequence": 1, "origin": "上海", "destination": "深圳", "costPrice": 7500, "currency": "CNY", "transitDays": 48}, + {"segmentSequence": 2, "routeLegSequence": 2, "origin": "深圳", "destination": "胡志明", "costPrice": 9600, "currency": "CNY", "transitDays": 72}, + {"segmentSequence": 3, "routeLegSequence": 1, "origin": "上海", "destination": "广西", "costPrice": 7500, "currency": "CNY", "transitDays": 48}, + {"segmentSequence": 3, "routeLegSequence": 2, "origin": "广西", "destination": "胡志明", "costPrice": 9600, "currency": "CNY", "transitDays": 72}, + ] + quote["fee_lines"] = [ + {"name": "运输费", "feeName": "运输费", "amount": 7500, "currency": "CNY", "segmentSequence": 1, "routeLegSequence": 1}, + {"name": "运输费", "feeName": "运输费", "amount": 9600, "currency": "CNY", "segmentSequence": 1, "routeLegSequence": 2}, + {"name": "运输费", "feeName": "运输费", "amount": 7500, "currency": "CNY", "segmentSequence": 2, "routeLegSequence": 1}, + {"name": "运输费", "feeName": "运输费", "amount": 9600, "currency": "CNY", "segmentSequence": 2, "routeLegSequence": 2}, + {"name": "运输费", "feeName": "运输费", "amount": 7500, "currency": "CNY", "segmentSequence": 3, "routeLegSequence": 1}, + {"name": "运输费", "feeName": "运输费", "amount": 9600, "currency": "CNY", "segmentSequence": 3, "routeLegSequence": 2}, + ] + if str(facts.get("tms_land_single") or "") == "1": + quote["isMulti"] = "1" + quote["business_line"] = "LAND" + quote["landSegments"] = [ + {"segmentSequence": 1, "routeLegSequence": 1, "origin": "上海", "destination": "谅山", "costPrice": 7500, "currency": "CNY", "transitDays": 48}, + {"segmentSequence": 1, "routeLegSequence": 2, "origin": "谅山", "destination": "胡志明", "costPrice": 9600, "currency": "CNY", "transitDays": 72}, + ] + quote["fee_lines"] = [ + {"name": "运输费", "feeName": "运输费", "amount": 7500, "currency": "CNY", "segmentSequence": 1, "routeLegSequence": 1}, + {"name": "运输费", "feeName": "运输费", "amount": 9600, "currency": "CNY", "segmentSequence": 1, "routeLegSequence": 2}, + ] if str(facts.get("tms_multi") or "") == "1": quote["isMulti"] = "2" quote["airOptions"] = [ diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index b648e4b..49956df 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -901,6 +901,38 @@ def sea_bucket_fee_lines(quote: dict[str, Any] | None) -> list[str]: return sections +def _land_grouped_fee_lines(quote: dict[str, Any] | None) -> list[str]: + """陆运:每段先写始发→目的,再写该段 TMS 费用名。""" + from agent.schema.land_route_options import land_leg_fee_blocks + + src = dict(quote or {}) + header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() + lines: list[str] = [] + for title, fees, eta in land_leg_fee_blocks(src): + lines.append(title) + for fee in fees: + name = str(fee.get("name") or "").strip() + amount = str(fee.get("amount") or "").strip() + line_ccy = str(fee.get("currency") or fee.get("currencyCode") or header_ccy).strip() + if name and amount and amount != "-": + lines.append(f"{name}:{money_text(amount, line_ccy)}") + if eta: + lines.append(f"时效:{eta}") + return lines + + +def _is_land_surface(*, quote: dict[str, Any] | None = None, business_line: str = "", transport_mode: str = "") -> bool: + """陆运销售面:有 landSegments / 陆运业务线就按段出明细。""" + src = dict(quote or {}) + if src.get("landSegments") or src.get("land_segments") or src.get("selected_land_route"): + return True + raw_line = (business_line or "").strip() + if raw_line and scheme_transport_mode(raw_line, fallback="") == "陆运": + return True + mode = (transport_mode or "").strip() + return bool(mode) and scheme_transport_mode(mode, fallback="") == "陆运" + + def quote_area_fee_text( quote: dict[str, Any] | None, business_line: str = "", @@ -915,8 +947,13 @@ def quote_area_fee_text( header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) bucket_lines = sea_bucket_fee_lines(src) + land_lines = _land_grouped_fee_lines(src) if _is_land_surface( + quote=src, business_line=business_line, transport_mode=transport_mode + ) else [] if bucket_lines: fee_lines = list(bucket_lines) + elif land_lines: + fee_lines = list(land_lines) else: fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] fee_lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}") @@ -968,20 +1005,28 @@ def sea_quote_detail_text( """ 完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。 - 时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。 + 陆运分段时效跟在各段费用后面,用 TMS 原文。海运时效、报价有效期仍只在卡片上。 + 空运:业务确认没有时效,总销售价下面只留报价有效期。 不受卡片引用条字数限制。first_or_same 只兼容调用方。 """ _ = first_or_same, history_work_order_no, status src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") lines = [source, f"{work_order_no}价格明细"] - if (business_line or "").upper() == "AIR": + line = (business_line or "").upper() + if line == "AIR": from agent.schema.air_route_options import route_codes route = route_codes(None, {}, src) if route: lines.append(route) + if line == "LAND": + land_line = str(src.get("route_line") or "").strip() + if land_line: + lines.append(land_line) lines.append(quote_area_fee_text(src, business_line=business_line or "SEA")) + if line == "AIR": + lines.append(f"报价有效期:{tms_valid_until_display(src)}") return "\n".join(lines) @@ -1048,10 +1093,10 @@ def quote_detail_lines( transport_mode: str = "", ) -> list[str]: """ - 价格明细正文:TMS 费用、总销售价、时效、有效期。 + 价格明细正文:TMS 费用、总销售价一行、有效期;海运/陆运再加时效。 - 给文本兜底和企微引用条共用,禁止在这里编时效/有效期。 - 价格卡不展示工单状态,也不展示相同询价内容检索。 + 空运业务确认没有时效,这里不写。禁止编时效/有效期。 + 总销售价只写一次。价格卡不展示工单状态,也不展示相同询价内容检索。 """ _ = status, first_or_same, history_work_order_no src = dict(quote or {}) @@ -1060,13 +1105,18 @@ def quote_detail_lines( bucket_lines = sea_bucket_fee_lines(src) if (business_line or "").upper() == "SEA" or ( transport_mode or "" ).strip() == "海运" else [] + land_lines = _land_grouped_fee_lines(src) if _is_land_surface( + quote=src, business_line=business_line, transport_mode=transport_mode + ) else [] if bucket_lines: lines = list(bucket_lines) + elif land_lines: + lines = list(land_lines) else: lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}") - lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}") - lines.append(f"时效:{tms_eta_display(src)}") + if not _is_air_surface(business_line=business_line, transport_mode=transport_mode): + lines.append(f"时效:{tms_eta_display(src)}") lines.append(f"报价有效期:{tms_valid_until_display(src)}") return lines @@ -1146,7 +1196,7 @@ def tms_hit_wecom_payload( 价格明细 ┌ 引用条:空运费 / 其它费用 / 总销售价 - 时效、报价有效期 + 报价有效期(空运不写时效) 协同说明(普通文字,贴按钮上方) 按钮 @@ -1159,7 +1209,6 @@ def tms_hit_wecom_payload( source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") meta = "\n".join( [ - f"时效:{tms_eta_display(src)}", f"报价有效期:{tms_valid_until_display(src)}", "", collab_hint(work_order_no), @@ -1770,13 +1819,18 @@ def scheme_card( body, "价格明细", quote_area_fee_text(src, transport_mode=transport_mode), - f"时效:{eta or '-'}", - f"报价有效期:{valid}", - "请确认是否采用该报价方案:", - f"【{BTN_ADOPT_EXCEL_CARD}】", - f"【{BTN_ADOPT_PDF_CARD}】", - f"【{BTN_REJECT}】", ] + if not _is_air_surface(transport_mode=transport_mode): + lines.append(f"时效:{eta or '-'}") + lines.extend( + [ + f"报价有效期:{valid}", + "请确认是否采用该报价方案:", + f"【{BTN_ADOPT_EXCEL_CARD}】", + f"【{BTN_ADOPT_PDF_CARD}】", + f"【{BTN_REJECT}】", + ] + ) return "\n".join(lines) @@ -1806,6 +1860,19 @@ def scheme_transport_mode(business_line: str = "", fallback: str = "空运") -> return (fallback or "空运").strip() or "空运" +def _is_air_surface(*, business_line: str = "", transport_mode: str = "") -> bool: + """ + 空运销售面:业务确认没有时效,展示层一律不写。 + + 海运/陆运仍保留时效。两边都空时不算空运,避免误藏海运/陆运卡。 + """ + raw_line = (business_line or "").strip() + if raw_line and scheme_transport_mode(raw_line, fallback="") == "空运": + return True + mode = (transport_mode or "").strip() + return bool(mode) and scheme_transport_mode(mode, fallback="") == "空运" + + def scheme_horizontal_rows( *, work_order_no: str, @@ -1857,7 +1924,6 @@ def scheme_horizontal_rows( {"keyname": "航线", "value": _clip(_air_scheme_route(q, src), 26)}, {"keyname": "货物", "value": _clip(f"{cargo};{pieces};{weight};{volume}", 26)}, {"keyname": "包装", "value": _clip(pack, 26)}, - {"keyname": "时效", "value": _clip(tms_eta_display(q), 26)}, {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)}, ] @@ -2044,19 +2110,22 @@ def deal_horizontal_rows( transport_mode: str = "", ) -> list[dict[str, str]]: """ - 成交跟进卡字段:总销售价、时效、报价有效期。 + 成交跟进卡字段:总销售价、报价有效期;海运/陆运再加时效。 + 空运业务确认没有时效,这里不写。 不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。 """ _ = facts src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) - return [ + rows = [ {"keyname": _total_label(src, business_line, transport_mode), "value": _clip(total, 26)}, - {"keyname": "时效", "value": _clip(tms_eta_display(src), 26)}, - {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)}, ] + if not _is_air_surface(business_line=business_line, transport_mode=transport_mode): + rows.append({"keyname": "时效", "value": _clip(tms_eta_display(src), 26)}) + rows.append({"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)}) + return rows def deal_card( @@ -2098,7 +2167,7 @@ def deal_wecom_payload( 成交跟进卡:对齐现网 button_interaction。 顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。 - 字段只要总销售价、时效、有效期。 + 字段:总销售价、报价有效期;海运/陆运再加时效。空运不写时效。 """ src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") @@ -2406,6 +2475,7 @@ def air_group_brief( 空运群激活摘要:与海运进群同一套结构,字段用空运。 先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。 + TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。 不写「当前工单/询价要点/现在等到哪一步」。 """ no = (work_order_no or "").strip() @@ -2433,6 +2503,10 @@ def air_group_brief( product_quote=product_quote, has_price=has_price, ) + if tms: + from agent.schema.air_route_options import project_group_air_quote + + tms = project_group_air_quote(tms, facts) lines.append("") lines.append("TMS报价:") if tms and (tms.get("total") or quote_fee_lines(tms)): diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 351bf8d..b61de74 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -2,8 +2,9 @@ 陆运文字询价确定性流程。 本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。 +查价后:1 条完整线路直接出价格明细;多于 1 条发入口卡,H5 选定后再出该条明细。 查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。 -禁止:字段齐了就建单;直连 TMS;把海运「出卡即查价」改掉。 +禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路;把海运「出卡即查价」改掉。 调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 """ @@ -31,7 +32,7 @@ from agent.schema.tms_land_query import assemble_land_query logger = logging.getLogger(__name__) -_LAND_FRONT = frozenset({"need_mode", "need_land_options", "clarify", "wait_confirm", "idle", ""}) +_LAND_FRONT = frozenset({"need_mode", "need_land_options", "clarify", "wait_confirm", "wait_land_route", "idle", ""}) class LandTextInquiryFlow(SeaTextInquiryFlow): @@ -365,7 +366,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): reply: ReplyFn, retrying: bool, ) -> str: - """查价回包落到有价/无价/技术失败。失败时把工单号记进待重查。""" + """ + 查价回包落到有价/无价/技术失败。失败时把工单号记进待重查。 + + 有价时按完整线路计条:1 条投影后出明细;多于 1 条进 H5。 + """ _ = retrying no = (sess.work_order_no or "").strip() if not tms.get("ok"): @@ -395,7 +400,17 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): self._save(sess) return "tms_miss" - quote = tms["quote"] + quote = dict(tms["quote"] or {}) + from agent.schema.land_route_options import ( + group_land_routes, + needs_route_pick, + project_selected_route, + ) + + raw_quote = dict(quote) + routes = group_land_routes(raw_quote) + if routes and not needs_route_pick(raw_quote): + quote = project_selected_route(raw_quote, routes[0]) up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价") if not up.get("ok"): reply("主账写入报价失败,不能当作已报价。") @@ -403,8 +418,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): self._save(sess) return "ledger_fail" sess.quote = quote - copy.remember_tms_quote(sess, quote) + copy.remember_tms_quote(sess, raw_quote) sess.status = "已报价" + if needs_route_pick(raw_quote): + return self._offer_land_routes(sess=sess, work_order_no=no, quote=raw_quote, reply=reply) sess.phase = "wait_collab" sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) sess.wait_version += 1 @@ -420,6 +437,110 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): ) return "wait_collab" + def _offer_land_routes( + self, + *, + sess: FlowSession, + work_order_no: str, + quote: dict[str, Any], + reply: ReplyFn, + ) -> str: + """多条陆运线路:企微只发入口卡,选线在 H5。""" + from agent.channel.h5.land_route_store import build_land_route_url, issue_land_route_token + from agent.schema.land_route_options import group_land_routes + + token = issue_land_route_token( + sender_id=sess.sender_id, + work_order_no=work_order_no, + quote=quote, + facts=dict(sess.facts), + ) + url = build_land_route_url(token) + count = len(group_land_routes(quote)) + sess.phase = "wait_land_route" + sess.allowed = () + sess.wait_version += 1 + self._save(sess) + reply( + copy.air_route_entry_text(work_order_no=work_order_no, count=count), + copy.air_route_entry_card(work_order_no=work_order_no, count=count, url=url), + ) + return "wait_land_route" + + def select_land_route( + self, + *, + sender_id: str, + index: int, + reply: ReplyFn, + ) -> str: + """ + H5 选定一条陆运线路:只留该条各段,出价格明细。 + + 已采用过则作废旧报价单后再出新卡。 + """ + sess = self.session_of(sender_id) + if sess is None or not (sess.work_order_no or "").strip(): + reply("线路选择已过期,请回企微重新询价。") + return "route_expired" + from agent.schema.land_route_options import group_land_routes, project_selected_route + + raw_quote = dict(sess.tms_quote or sess.quote or {}) + routes = group_land_routes(raw_quote) + if index < 0 or index >= len(routes): + reply("没有这条线路,请重新打开页面选择。") + return "route_invalid" + route = routes[index] + quote = project_selected_route(raw_quote, route) + prev_phase = (sess.phase or "").strip() + already_adopted = prev_phase in {"wait_deal", "wait_file", "wait_adopt"} + if already_adopted: + clearer = getattr(self._ledger, "void_quote_files", None) + if callable(clearer): + clearer(work_order_no=sess.work_order_no) + from agent.redis_coord.quote_file import clear_quote_file_ready + + clear_quote_file_ready(sess.work_order_no) + self._ledger.upsert_quote(work_order_no=sess.work_order_no, quote=quote, to_status="已报价") + sess.quote = quote + sess.status = "已报价" + sess.wait_version += 1 + if already_adopted: + sess.phase = "wait_adopt" + sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT) + self._save(sess) + mode = copy.scheme_transport_mode(sess.business_line) + reply( + copy.scheme_card( + work_order_no=sess.work_order_no, + status="已报价", + facts=dict(sess.facts), + quote=quote, + transport_mode=mode, + ), + copy.scheme_wecom_payload( + work_order_no=sess.work_order_no, + facts=dict(sess.facts), + quote=quote, + status="已报价", + transport_mode=mode, + ), + ) + return "wait_adopt" + sess.phase = "wait_collab" + sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) + self._save(sess) + self._emit_sea_hit( + reply, + work_order_no=sess.work_order_no, + quote=quote, + first_or_same=sess.first_or_same, + history_work_order_no=sess.history_work_order_no, + status="已报价", + business_line="LAND", + ) + return "wait_collab" + def _land_ready(self, facts: dict[str, str]) -> dict[str, Any]: """选项+货物是否可出核对卡。""" opt = validate_land_options(facts) diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py index e9759ef..dea0ad2 100644 --- a/inquiry-agent/agent/schema/air_route_options.py +++ b/inquiry-agent/agent/schema/air_route_options.py @@ -2,7 +2,8 @@ 空运 TMS 线路报价:从 airOptions 拆出可选线路。 本文件职责:判断一条还是多条、拼航线(含中转)、抽出该线路费用。 -选定后把起运港写回 facts。中转只用于展示,不写进报价单模板。 +选定后把起运港写回 facts。群摘要现场投影同一套分项。 +中转只用于展示,不写进报价单模板。 禁止:猜没回的机场名;把贸易条款当线路;改六态。 """ @@ -248,3 +249,131 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any] 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]: + """ + 整单兜底「空运费=总价」不是线路分项,群摘要禁止再写。 + + 真有空运费且金额不等于总价(例如另有附加费)则保留。 + """ + src = dict(quote or {}) + total = src.get("total") + if _same_amount(src.get("freight"), total): + src["freight"] = "-" + 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 + kept: list[Any] = [] + for item in parsed: + if not isinstance(item, dict): + kept.append(item) + continue + name = str(item.get("name") or item.get("label") or "").strip() + amount = item.get("amount") or item.get("value") + if name == "空运费" and _same_amount(amount, total): + continue + kept.append(item) + src["fee_lines"] = kept + 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) diff --git a/inquiry-agent/agent/schema/land_route_options.py b/inquiry-agent/agent/schema/land_route_options.py new file mode 100644 index 0000000..269a783 --- /dev/null +++ b/inquiry-agent/agent/schema/land_route_options.py @@ -0,0 +1,420 @@ +""" +陆运 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 diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 66bc377..132c7af 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -171,6 +171,129 @@ class AirGroupCollabTests(unittest.TestCase): self.assertNotIn("当前工单:", text) self.assertNotIn("贸易条款", text) + def test_air_group_brief_tms_total_only_once(self) -> None: + """群摘要 TMS 段总销售价只一行,禁止重复。""" + tms = { + "source": "TMS", + "source_label": "TMS标准报价", + "total": "183370.0", + "currency": "CNY", + "eta": "-", + "valid_until": "2026-09-30", + "fee_lines": [ + {"name": "运输费", "amount": "11.0", "currency": "CNY"}, + ], + } + text = copy.air_group_brief( + work_order_no="WO202609220018", + facts={"起运港": "珠海", "目的港": "克拉克"}, + quote=tms, + collab_facts={}, + has_price=True, + tms_quote=tms, + ) + tms_block = text.split("TMS报价:", 1)[1] + self.assertEqual(tms_block.count("总销售价:"), 1) + self.assertIn("总销售价:CNY 183370.0", tms_block) + self.assertNotIn("时效", tms_block) + self.assertIn("报价有效期:2026-09-30", tms_block) + + def test_air_group_brief_uses_route_fees_not_air_freight_total(self) -> None: + """ + 群摘要必须跟单聊同一套线路分项。 + 主账快照若只有「空运费=总价」,仍要列出该线路运输费/提送货费/人工费。 + """ + tms = { + "source": "TMS", + "source_label": "TMS标准报价", + "total": "183370.0", + "freight": "183370.0", + "currency": "CNY", + "eta": "-", + "valid_until": "2026-09-30", + "fee_lines": [ + { + "name": "空运费", + "amount": "183370.00", + "currency": "CNY", + } + ], + "airOptions": [ + { + "originAirportCode": "ZUH", + "transferAirportCode": "CHG", + "destinationAirportCode": "CRK", + "salePrice": 183370, + "currency": "CNY", + "departureCharges": [ + {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0}, + {"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6.0}, + {"chargeName": "人工费", "currency": "CNY", "unitPrice": 123.0}, + ], + } + ], + } + text = copy.air_group_brief( + work_order_no="WO202609220018", + facts={"起运港": "珠海", "目的港": "克拉克", "originCode": "ZUH"}, + quote=tms, + collab_facts={}, + has_price=True, + tms_quote=tms, + ) + tms_block = text.split("TMS报价:", 1)[1] + self.assertIn("运输费:CNY 11.0", tms_block) + self.assertIn("提送货费:CNY 6.0", tms_block) + self.assertIn("人工费:CNY 123.0", tms_block) + self.assertIn("总销售价:CNY 183370", tms_block) + self.assertEqual(tms_block.count("总销售价:"), 1) + self.assertNotIn("空运费", tms_block) + + def test_air_group_brief_multi_route_does_not_borrow_other_air_freight(self) -> None: + """多条线路:只出对上起运港那条的分项,禁止套整单空运费或另一条的运输费。""" + tms = { + "source": "TMS", + "total": "200040", + "freight": "183370.00", + "currency": "CNY", + "valid_until": "2026-09-30", + "fee_lines": [ + {"name": "空运费", "amount": "183370.00", "currency": "CNY"}, + ], + "airOptions": [ + { + "originAirportCode": "ZUH", + "transferAirportCode": "CHG", + "destinationAirportCode": "CRK", + "salePrice": 183370, + "currency": "CNY", + "departureCharges": [ + {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0}, + ], + }, + { + "originAirportCode": "SZX", + "destinationAirportCode": "CRK", + "salePrice": 200040, + "currency": "CNY", + "departureCharges": [], + }, + ], + } + text = copy.air_group_brief( + work_order_no="WO202609220018", + facts={"起运港": "深圳", "目的港": "克拉克", "originCode": "SZX"}, + quote=tms, + collab_facts={}, + has_price=True, + tms_quote=tms, + ) + tms_block = text.split("TMS报价:", 1)[1] + self.assertNotIn("空运费", tms_block) + self.assertNotIn("运输费", tms_block) + self.assertIn("总销售价:CNY 200040", tms_block) + self.assertEqual(tms_block.count("总销售价:"), 1) + def test_work_order_only_is_activate_not_quote(self) -> None: from agent.routing.air_group_intent import INTENT_ACTIVATE diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py index bae459d..bb70326 100644 --- a/inquiry-agent/tests/test_air_route_options.py +++ b/inquiry-agent/tests/test_air_route_options.py @@ -22,6 +22,7 @@ from agent.schema.air_route_options import ( needs_route_pick, option_fee_lines, parse_air_options, + project_group_air_quote, project_selected_quote, route_codes, route_line, @@ -220,6 +221,73 @@ class AirRouteOptionsTests(unittest.TestCase): self.assertIn("总销售价", detail) self.assertIn("200040", detail) + def test_group_air_quote_projects_route_fees_and_drops_total_alias(self) -> None: + """群摘要投影:该线路分项留下,空运费=总价丢掉。""" + quote = { + "total": "183370.0", + "freight": "183370.0", + "currency": "CNY", + "fee_lines": [ + {"name": "空运费", "amount": "183370.00", "currency": "CNY"}, + ], + "airOptions": [ + { + "originAirportCode": "ZUH", + "transferAirportCode": "CHG", + "destinationAirportCode": "CRK", + "salePrice": 183370, + "currency": "CNY", + "departureCharges": [ + {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0}, + {"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6.0}, + {"chargeName": "人工费", "currency": "CNY", "unitPrice": 123.0}, + ], + } + ], + } + projected = project_group_air_quote(quote, {"originCode": "ZUH"}) + names = [str(x.get("name") or "") for x in (projected.get("fee_lines") or [])] + self.assertEqual(names, ["运输费", "提送货费", "人工费"]) + self.assertNotIn("空运费", names) + self.assertEqual(copy.quote_fee_lines(projected)[0][0], "运输费") + + def test_air_detail_text_hides_eta_keeps_valid_until(self) -> None: + """空运没有时效;价格明细只留报价有效期。""" + text = copy.sea_quote_detail_text( + work_order_no="WO202609220013", + quote={ + "source_label": "TMS标准报价", + "selected_air_option": { + "originAirportCode": "NKG", + "destinationAirportCode": "KUL", + }, + "fee_lines": [ + {"name": "增点费", "amount": "100.0", "currency": "CNY"}, + {"name": "拆板费", "amount": "100.0", "currency": "USD"}, + ], + "total": "5001.0", + "currency": "CNY", + "eta": "3天", + "valid_until": "2026-09-30", + }, + business_line="AIR", + ) + self.assertEqual( + text, + "\n".join( + [ + "TMS标准报价", + "WO202609220013价格明细", + "NKG-KUL", + "增点费:CNY 100.0", + "拆板费:USD 100.0", + "总销售价:CNY 5001.0", + "报价有效期:2026-09-30", + ] + ), + ) + self.assertNotIn("时效", text) + def test_h5_page_lists_route_and_fees(self) -> None: from agent.channel.h5.air_route_page import render_air_route_page from agent.channel.h5.air_route_store import build_air_route_url diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 9269665..2977ef5 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -280,7 +280,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn(copy.BTN_SKIP_COLLAB, joined) self.assertIn("价格明细", joined) self.assertIn("总销售价", joined) - self.assertIn("时效:", joined) + self.assertNotIn("时效:", joined) self.assertIn("报价有效期:", joined) self.assertNotIn("相同询价内容检索", joined) self.assertNotIn("引用历史工单号", joined) @@ -324,7 +324,7 @@ class AirTextInquiryTests(unittest.TestCase): ) rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} self.assertIn("2.5", rows["货物"]) - self.assertEqual(rows["时效"], "3-5 天") + self.assertNotIn("时效", rows) self.assertNotIn("sub_title_text", card) n = len(self.replies) again = self.flow.on_button( @@ -346,6 +346,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertTrue(str(follow.get("task_id") or "").startswith("deal-")) deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []} self.assertIn("总销售价", deal_rows) + self.assertNotIn("时效", deal_rows) self.assertNotIn("历史报价", deal_rows) self.assertNotIn("工单状态", deal_rows) self.assertNotIn("来源", deal_rows) @@ -829,7 +830,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(copy.money_text("14669.60", ""), "14669.60") self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text) sub = card["sub_title_text"] - self.assertIn("时效:3-5 天", sub) + self.assertNotIn("时效", sub) self.assertIn("报价有效期:2026-09-20", sub) self.assertNotIn("相同询价内容检索", sub) self.assertNotIn("引用历史工单号", sub) @@ -866,6 +867,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("空运费:USD 1250.00", text) self.assertIn("总销售价:CNY 142998.53", text) self.assertNotIn("时效", text) + self.assertIn("报价有效期:2026-09-30", text) card_text = copy.quote_area_safe_text(long_quote, business_line="AIR") self.assertIn("总销售价:CNY 142998.53", card_text) self.assertIn("详见上一条明细", card_text) @@ -985,7 +987,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("起运地:上海", text) self.assertIn("体积:2.5", text) self.assertIn("空运费:USD 1,200", text) - self.assertIn("时效:3-5 天", text) + self.assertNotIn("时效", text) self.assertIn("报价有效期:2026-09-20", text) self.assertNotIn("报价日期:", text) self.assertNotIn("工单状态", text) @@ -1015,20 +1017,20 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(rows["航线"], "空运SHA→LAX") self.assertIn("普货", rows["货物"]) self.assertIn("2.5", rows["货物"]) - self.assertEqual(rows["时效"], "3-5 天") + self.assertNotIn("时效", rows) self.assertEqual(rows["报价有效期"], "2026-09-20") self.assertNotIn("报价日期", rows) empty_eta = copy.scheme_horizontal_rows( work_order_no="WO1", facts=COMPLETE_AIR, quote={"valid_until": "2026-09-20"} ) - self.assertEqual({x["keyname"]: x["value"] for x in empty_eta}["时效"], "-") + self.assertNotIn("时效", {x["keyname"] for x in empty_eta}) dashed = copy.scheme_horizontal_rows( work_order_no="WO1", facts=COMPLETE_AIR, quote={"eta": "-3-5 天", "valid_until": "2026-09-20"}, ) dashed_rows = {x["keyname"]: x["value"] for x in dashed} - self.assertEqual(dashed_rows["时效"], "3-5 天") + self.assertNotIn("时效", dashed_rows) self.assertEqual(len(card["button_list"]), 3) self.assertEqual( [x["text"] for x in card["button_list"]], @@ -1060,8 +1062,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("报价单已发送,请跟进成交状态", text) self.assertIn("咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。", text) self.assertIn("总销售价:CNY 722.5", text) - self.assertIn("时效:2天", text) - self.assertNotIn("时效(要求/TMS)", text) + self.assertNotIn("时效", text) self.assertNotIn("- / -", text) self.assertIn("报价有效期:2026-08-24", text) self.assertNotIn("历史报价", text) @@ -1081,7 +1082,6 @@ class AirTextInquiryTests(unittest.TestCase): rows, { "总销售价": "CNY 722.5", - "时效": "2天", "报价有效期": "2026-08-24", }, ) @@ -1102,10 +1102,13 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(copy.canonical_button("negotiate:WO202608210017"), copy.BTN_NEGOTIATE) empty_rows = { x["keyname"]: x["value"] - for x in copy.deal_horizontal_rows(quote={"total": "-", "valid_until": "-"}) + for x in copy.deal_horizontal_rows( + quote={"total": "-", "valid_until": "-"}, + business_line="AIR", + ) } - self.assertEqual(empty_rows["时效"], "-") - self.assertNotEqual(empty_rows["时效"], "- / -") + self.assertNotIn("时效", empty_rows) + self.assertEqual(empty_rows["报价有效期"], "-") def test_same_demand_cites_latest_even_if_closed(self) -> None: ledger = MemoryLedger() diff --git a/inquiry-agent/tests/test_http_ledger.py b/inquiry-agent/tests/test_http_ledger.py index 8656bdd..cfaac3d 100644 --- a/inquiry-agent/tests/test_http_ledger.py +++ b/inquiry-agent/tests/test_http_ledger.py @@ -74,6 +74,21 @@ class HttpLedgerParseTests(unittest.TestCase): self.assertEqual(body["workOrderNo"], "WO202609220001") self.assertEqual(body["facts"]["起运港"], "深圳") + def test_upsert_quote_posts_replace_tms(self) -> None: + fake = _FakeLedgerClient() + out = HttpLedger(client=fake).upsert_quote( + work_order_no="WO202609220015", + quote={"total": "17100", "currency": "CNY", "route_line": "上海-谅山-胡志明"}, + to_status="已报价", + ) + self.assertTrue(out.get("ok")) + self.assertEqual(len(fake.calls), 1) + method, path, body = fake.calls[0] + self.assertEqual(method, "POST") + self.assertEqual(path, "/inquiry/agent/ticket/replaceTmsQuote") + self.assertEqual(body["workOrderNo"], "WO202609220015") + self.assertEqual(body["quote"]["total"], "17100") + def test_void_quote_files_posts_agent_path(self) -> None: fake = _FakeLedgerClient() out = HttpLedger(client=fake).void_quote_files(work_order_no="WO202609220001") diff --git a/inquiry-agent/tests/test_land_route_options.py b/inquiry-agent/tests/test_land_route_options.py new file mode 100644 index 0000000..504b227 --- /dev/null +++ b/inquiry-agent/tests/test_land_route_options.py @@ -0,0 +1,546 @@ +""" +陆运线路:按 segmentSequence 成条;一段两段是一条带中转,不是两条。 +""" + +from __future__ import annotations + +import json +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from agent.schema.land_route_options import ( + group_land_routes, + needs_route_pick, + option_fee_lines, + option_total, + project_selected_route, + route_line, +) + + +def _single_with_transfer() -> dict: + return { + "isMulti": 1, + "landSegments": [ + { + "segmentSequence": 1, + "routeLegSequence": 1, + "origin": "上海", + "destination": "谅山", + "costPrice": 7500, + "currency": "CNY", + "transitDays": 48, + }, + { + "segmentSequence": 1, + "routeLegSequence": 2, + "origin": "谅山", + "destination": "胡志明", + "costPrice": 9600, + "currency": "CNY", + "transitDays": 72, + }, + ], + } + + +def _three_routes() -> dict: + base = _single_with_transfer() + base["isMulti"] = 2 + extra = [ + { + "segmentSequence": 2, + "routeLegSequence": 1, + "origin": "上海", + "destination": "深圳", + "costPrice": 7500, + "currency": "CNY", + "transitDays": 48, + }, + { + "segmentSequence": 2, + "routeLegSequence": 2, + "origin": "深圳", + "destination": "胡志明", + "costPrice": 9600, + "currency": "CNY", + "transitDays": 72, + }, + { + "segmentSequence": 3, + "routeLegSequence": 1, + "origin": "上海", + "destination": "广西", + "costPrice": 7500, + "currency": "CNY", + "transitDays": 48, + }, + { + "segmentSequence": 3, + "routeLegSequence": 2, + "origin": "广西", + "destination": "胡志明", + "costPrice": 9600, + "currency": "CNY", + "transitDays": 72, + }, + ] + base["landSegments"] = list(base["landSegments"]) + extra + return base + + +def _tms_land_fees(*pairs: tuple[int, int, str, int]) -> list[dict]: + """TMS feeItems 原文:费用名按回包,不编路段名。""" + rows: list[dict] = [] + for seq, leg, name, amount in pairs: + rows.append( + { + "feeName": name, + "name": name, + "amount": amount, + "currency": "CNY", + "segmentSequence": seq, + "routeLegSequence": leg, + "unit": "台", + "quantity": 1, + } + ) + return rows + + +class LandRouteOptionsTests(unittest.TestCase): + def test_two_legs_one_route(self) -> None: + quote = _single_with_transfer() + routes = group_land_routes(quote) + self.assertEqual(len(routes), 1) + self.assertFalse(needs_route_pick(quote)) + self.assertEqual(route_line(routes[0]), "上海-谅山-胡志明") + fees = option_fee_lines(routes[0]) + self.assertEqual(fees[0][0], "上海 → 谅山") + self.assertIn("7500", fees[0][1]) + self.assertEqual(fees[1][0], "谅山 → 胡志明") + self.assertIn("17100", option_total(routes[0])) + + def test_three_segment_sequences_need_h5(self) -> None: + quote = _three_routes() + routes = group_land_routes(quote) + self.assertEqual(len(routes), 3) + self.assertTrue(needs_route_pick(quote)) + self.assertEqual(route_line(routes[1]), "上海-深圳-胡志明") + + def test_project_writes_n_plus_eta_not_hours(self) -> None: + quote = { + "isMulti": 1, + "eta": "-", + "landSegments": [ + { + "segmentSequence": 1, + "routeLegSequence": 1, + "origin": "上海", + "destination": "谅山", + "costPrice": 7500, + "currency": "CNY", + "transitDays": "N+2", + }, + { + "segmentSequence": 1, + "routeLegSequence": 2, + "origin": "谅山", + "destination": "胡志明", + "costPrice": 9600, + "currency": "CNY", + "transitDays": "N+3", + }, + ], + } + routes = group_land_routes(quote) + projected = project_selected_route(quote, routes[0]) + eta = str(projected.get("eta") or "") + self.assertIn("N+2", eta) + self.assertIn("N+3", eta) + self.assertIn("17100", str(projected.get("total") or "")) + + def test_project_keeps_tms_fee_names_not_od(self) -> None: + quote = _single_with_transfer() + quote["fee_lines"] = _tms_land_fees( + (1, 1, "运输费", 7500), + (1, 2, "运输费", 9600), + ) + routes = group_land_routes(quote) + projected = project_selected_route(quote, routes[0]) + names = [x.get("name") for x in projected.get("fee_lines") or []] + self.assertEqual(names, ["运输费", "运输费"]) + joined = json.dumps(projected.get("fee_lines"), ensure_ascii=False) + self.assertNotIn("上海 →", joined) + self.assertIn("7500", joined) + self.assertIn("9600", joined) + + def test_project_keeps_only_selected_legs(self) -> None: + quote = _three_routes() + quote["fee_lines"] = _tms_land_fees( + (1, 1, "运输费", 7500), + (1, 2, "运输费", 9600), + (2, 1, "运输费", 7500), + (2, 2, "运输费", 9600), + (3, 1, "提货费", 500), + (3, 2, "运输费", 9600), + ) + routes = group_land_routes(quote) + projected = project_selected_route(quote, routes[1]) + raw = projected.get("landSegments") + if isinstance(raw, str): + raw = json.loads(raw) + seqs = {int(x.get("segmentSequence") or 0) for x in raw} + self.assertEqual(seqs, {2}) + names = [x.get("name") for x in projected.get("fee_lines") or []] + self.assertEqual(names, ["运输费", "运输费"]) + self.assertIn("17100", str(projected.get("total") or "")) + self.assertNotIn("谅山", json.dumps(projected.get("fee_lines"), ensure_ascii=False)) + self.assertNotIn("提货费", json.dumps(projected.get("fee_lines"), ensure_ascii=False)) + self.assertNotIn("上海 →", json.dumps(projected.get("fee_lines"), ensure_ascii=False)) + + def test_detail_puts_tms_fee_under_each_leg(self) -> None: + from agent.policy import inquiry_copy as copy + + quote = _single_with_transfer() + quote["landSegments"][0]["origin"] = "上海" + quote["landSegments"][0]["destination"] = "谅山Lạng Sơn-春疆货场Huu Nghi" + quote["landSegments"][0]["transitDays"] = "N+2" + quote["landSegments"][1]["origin"] = "谅山Lạng Sơn-春疆货场Huu Nghi" + quote["landSegments"][1]["destination"] = "胡志明Hồ Chí Minh" + quote["landSegments"][1]["transitDays"] = "N+3" + quote["fee_lines"] = _tms_land_fees( + (1, 1, "运输费", 7500.0), + (1, 2, "运输费", 9600.0), + ) + routes = group_land_routes(quote) + projected = project_selected_route(quote, routes[0]) + text = copy.sea_quote_detail_text( + work_order_no="WO202609220029", + quote=projected, + business_line="LAND", + ) + self.assertIn("上海-谅山Lạng Sơn-春疆货场Huu Nghi-胡志明Hồ Chí Minh", text) + od1 = text.find("上海 → 谅山Lạng Sơn-春疆货场Huu Nghi") + fee1 = text.find("运输费:CNY 7500", od1) + od2 = text.find("谅山Lạng Sơn-春疆货场Huu Nghi → 胡志明Hồ Chí Minh") + fee2 = text.find("运输费:CNY 9600", od2) + self.assertGreaterEqual(od1, 0) + self.assertGreater(fee1, od1) + self.assertGreater(od2, fee1) + self.assertGreater(fee2, od2) + eta1 = text.find("时效:N+2", fee1) + eta2 = text.find("时效:N+3", fee2) + self.assertGreater(eta1, fee1) + self.assertLess(eta1, od2) + self.assertGreater(eta2, fee2) + self.assertIn("总成本价:CNY 17100", text) + + def test_h5_lists_segment_costs(self) -> None: + from agent.channel.h5.land_route_page import render_land_route_page + + html = render_land_route_page( + {"work_order_no": "WO202609220010", "quote": _three_routes()} + ) + self.assertIn("上海-深圳-胡志明", html) + self.assertIn("上海 → 深圳", html) + self.assertIn("成本价合计", html) + self.assertNotIn("userid", html.lower()) + + def test_h5_puts_tms_fee_under_each_leg(self) -> None: + from agent.channel.h5.land_route_page import render_land_route_page + + quote = _three_routes() + for leg in quote["landSegments"]: + if str(leg.get("destination") or "") == "深圳": + leg["transitDays"] = "N+2" + if str(leg.get("origin") or "") == "深圳": + leg["transitDays"] = "N+3" + quote["fee_lines"] = _tms_land_fees( + (1, 1, "运输费", 7500), + (1, 2, "运输费", 9600), + (2, 1, "运输费", 7500), + (2, 2, "运输费", 9600), + (3, 1, "运输费", 7500), + (3, 2, "运输费", 9600), + ) + page = render_land_route_page( + {"work_order_no": "WO202609220010", "quote": quote} + ) + od = page.find("上海 → 深圳") + fee = page.find("运输费", od) + self.assertGreaterEqual(od, 0) + self.assertGreater(fee, od) + eta = page.find("时效", fee) + self.assertGreater(eta, fee) + self.assertIn("N+2", page) + self.assertIn("N+3", page) + self.assertIn("CNY 7500", page) + self.assertIn("成本价合计", page) + + def test_h5_and_detail_use_tms_fees_when_java_sends_json_strings(self) -> None: + """主账 quote 常是 JSON 串,且 fee_lines 可能没带段号。""" + from agent.channel.h5.land_route_page import render_land_route_page + from agent.policy import inquiry_copy as copy + + quote = { + "isMulti": "2", + "currency": "CNY", + "landSegments": json.dumps( + [ + { + "segmentSequence": 1, + "routeLegSequence": 1, + "origin": "上海", + "destination": "谅山Lạng Sơn-春疆货场Huu Nghi", + "costPrice": 7500.0, + "currency": "CNY", + "transitDays": "N+2", + }, + { + "segmentSequence": 1, + "routeLegSequence": 2, + "origin": "谅山Lạng Sơn-春疆货场Huu Nghi", + "destination": "胡志明Hồ Chí Minh", + "costPrice": 9600.0, + "currency": "CNY", + "transitDays": "N+3", + }, + { + "segmentSequence": 2, + "routeLegSequence": 1, + "origin": "上海", + "destination": "广西", + "costPrice": 7500.0, + "currency": "CNY", + "transitDays": "N+3", + }, + { + "segmentSequence": 2, + "routeLegSequence": 2, + "origin": "广西", + "destination": "胡志明Hồ Chí Minh", + "costPrice": 12500.0, + "currency": "CNY", + "transitDays": "N+3", + }, + ], + ensure_ascii=False, + ), + "fee_lines": json.dumps( + [ + {"name": "运输费", "amount": "7500.0", "currency": "CNY"}, + {"name": "运输费", "amount": "9600.0", "currency": "CNY"}, + {"name": "运输费", "amount": "7500.0", "currency": "CNY"}, + {"name": "运输费", "amount": "12500.0", "currency": "CNY"}, + ], + ensure_ascii=False, + ), + } + page = render_land_route_page( + {"work_order_no": "WO202609220036", "quote": quote} + ) + od = page.find("上海 → 谅山Lạng Sơn-春疆货场Huu Nghi") + fee = page.find("运输费", od) + eta = page.find("时效", fee) + self.assertGreaterEqual(od, 0) + self.assertGreater(fee, od) + self.assertGreater(eta, fee) + self.assertIn("N+2", page) + routes = group_land_routes(quote) + projected = project_selected_route(quote, routes[0]) + text = copy.sea_quote_detail_text( + work_order_no="WO202609220036", + quote=projected, + business_line="LAND", + ) + self.assertIn("运输费:CNY 7500", text) + self.assertIn("时效:N+2", text) + self.assertIn("时效:N+3", text) + self.assertNotRegex(text, r"上海 → 谅山.+:CNY") + + def test_recovers_transport_fee_when_fee_lines_are_od_names(self) -> None: + """测服 WO036 这类:fee_lines 被写成路段名,销售侧仍要看到运输费和时效。""" + from agent.channel.h5.land_route_page import render_land_route_page + from agent.policy import inquiry_copy as copy + + quote = { + "isMulti": "2", + "currency": "CNY", + "route_line": "上海-谅山Lạng Sơn-春疆货场Huu Nghi-胡志明Hồ Chí Minh", + "landSegments": [ + { + "segmentSequence": 1, + "routeLegSequence": 1, + "origin": "上海", + "destination": "谅山Lạng Sơn-春疆货场Huu Nghi", + "costPrice": 7500.0, + "currency": "CNY", + "transitDays": "N+2", + }, + { + "segmentSequence": 1, + "routeLegSequence": 2, + "origin": "谅山Lạng Sơn-春疆货场Huu Nghi", + "destination": "胡志明Hồ Chí Minh", + "costPrice": 9600.0, + "currency": "CNY", + "transitDays": "N+3", + }, + { + "segmentSequence": 2, + "routeLegSequence": 1, + "origin": "上海", + "destination": "广西", + "costPrice": 7500.0, + "currency": "CNY", + "transitDays": "N+3", + }, + { + "segmentSequence": 2, + "routeLegSequence": 2, + "origin": "广西", + "destination": "胡志明Hồ Chí Minh", + "costPrice": 12500.0, + "currency": "CNY", + "transitDays": "N+3", + }, + ], + "fee_lines": [ + {"name": "上海 → 谅山Lạng Sơn-春疆货场Huu Nghi", "amount": "7500.0", "currency": "CNY"}, + {"name": "谅山Lạng Sơn-春疆货场Huu Nghi → 胡志明Hồ Chí Minh", "amount": "9600.0", "currency": "CNY"}, + ], + } + page = render_land_route_page( + {"work_order_no": "WO202609220036", "quote": quote} + ) + od = page.find("上海 → 谅山Lạng Sơn-春疆货场Huu Nghi") + fee = page.find("运输费", od) + eta = page.find("时效", fee) + self.assertGreaterEqual(od, 0) + self.assertGreater(fee, od) + self.assertGreater(eta, fee) + self.assertIn("N+2", page) + routes = group_land_routes(quote) + projected = project_selected_route(quote, routes[0]) + text = copy.sea_quote_detail_text( + work_order_no="WO202609220036", + quote=projected, + business_line="LAND", + ) + self.assertIn("运输费:CNY 7500", text) + self.assertIn("时效:N+2", text) + self.assertIn("时效:N+3", text) + self.assertNotRegex(text, r"上海 → 谅山.+:CNY") + + def test_single_emits_detail_multi_opens_h5(self) -> None: + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.land_text_flow import LandTextInquiryFlow + + def _go(flag: str) -> tuple[str, list[str]]: + flow = LandTextInquiryFlow(MemoryLedger()) + flow._detail_before_card_gap_sec = 0 + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + facts = { + "起运港": "上海", + "目的港": "胡志明", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + flag: "1", + } + flow.on_text( + sender_id="u_land_rt", + text="国内运输拼车 国内长途/零担 急件", + reply=reply, + injected_facts=facts, + injected_mode="LAND", + ) + phase = flow.on_text(sender_id="u_land_rt", text="确定", reply=reply) + return phase, replies + + phase, replies = _go("tms_land_single") + self.assertEqual(phase, "wait_collab") + self.assertTrue(any("运输费" in x for x in replies)) + self.assertTrue(any("上海-谅山-胡志明" in x for x in replies)) + self.assertTrue(any("上海 → 谅山" in x for x in replies)) + phase, replies = _go("tms_land_multi") + self.assertEqual(phase, "wait_land_route") + self.assertTrue(any("条线路" in x for x in replies)) + + def test_select_then_reselect_after_adopt_voids_old_file(self) -> None: + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy import inquiry_copy as copy + from agent.policy.land_text_flow import LandTextInquiryFlow + + ledger = MemoryLedger() + flow = LandTextInquiryFlow(ledger) + flow._detail_before_card_gap_sec = 0 + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + facts = { + "起运港": "上海", + "目的港": "胡志明", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + "tms_land_multi": "1", + } + flow.on_text( + sender_id="u_land_void", + text="国内运输拼车 国内长途/零担 急件", + reply=reply, + injected_facts=facts, + injected_mode="LAND", + ) + self.assertEqual( + flow.on_text(sender_id="u_land_void", text="确定", reply=reply), + "wait_land_route", + ) + self.assertEqual( + flow.select_land_route(sender_id="u_land_void", index=1, reply=reply), + "wait_collab", + ) + joined = "\n".join(replies) + self.assertIn("上海-深圳-胡志明", joined) + self.assertIn("运输费", joined) + self.assertIn("上海 → 深圳", joined) + self.assertNotIn("谅山", "\n".join(replies[-3:])) + self.assertEqual( + flow.on_button(sender_id="u_land_void", action=copy.BTN_SKIP_COLLAB, reply=reply), + "wait_adopt", + ) + self.assertEqual( + flow.on_button(sender_id="u_land_void", action=copy.BTN_ADOPT_EXCEL, reply=reply), + "wait_deal", + ) + wo = flow.session_of("u_land_void").work_order_no + self.assertTrue(ledger.get_ticket(work_order_no=wo).quote_file_ready) + phase = flow.select_land_route(sender_id="u_land_void", index=0, reply=reply) + self.assertEqual(phase, "wait_adopt") + self.assertFalse(ledger.get_ticket(work_order_no=wo).quote_file_ready) + ticket = ledger.get_ticket(work_order_no=wo) + segs = ticket.quote.get("landSegments") or [] + seqs = {int(x.get("segmentSequence") or 0) for x in segs} + self.assertEqual(seqs, {1}) + self.assertTrue(any("报价方案" in x or "生成Excel" in x for x in replies[-3:])) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_quote_adjust.py b/inquiry-agent/tests/test_quote_adjust.py index 42c0693..9a6339e 100644 --- a/inquiry-agent/tests/test_quote_adjust.py +++ b/inquiry-agent/tests/test_quote_adjust.py @@ -300,8 +300,10 @@ class QuoteAdjustTests(unittest.TestCase): self.assertIn("总成本价:CNY 2287.65", "\n".join(copy.quote_detail_lines(sea, business_line="SEA"))) rows = copy.deal_horizontal_rows(quote=air, business_line="AIR") self.assertEqual(rows[0]["keyname"], "总销售价") + self.assertNotIn("时效", [x["keyname"] for x in rows]) land_rows = copy.deal_horizontal_rows(quote=sea, business_line="LAND") self.assertEqual(land_rows[0]["keyname"], "总成本价") + self.assertIn("时效", [x["keyname"] for x in land_rows]) def test_adjust_cards_use_sales_adjust_not_tms(self) -> None: """调价后价格明细卡、方案卡必须写销售调价,禁止再套 TMS 标准报价。""" diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java index b6a52c1..f27843a 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java @@ -290,6 +290,16 @@ public class InquiryAgentController { return Result.OK(workOrderService.queryTms(body)); } + @ApiOperation("覆盖本单最新 TMS 报价(选定陆运线路后写回成本)") + @PostMapping("/ticket/replaceTmsQuote") + public Result> replaceTmsQuote(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.replaceTmsQuote(body)); + } + @ApiOperation("海运拉群:按起运/目的匹配启用的产品·海运") @PostMapping("/staff/matchSea") public Result> matchSea(@RequestBody Map body, diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java index 5d1feec..fa0eb44 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java @@ -78,6 +78,12 @@ public interface IInquiryWorkOrderService extends IService { */ Map queryTms(Map body); + /** + * 覆盖本单最新 TMS 报价快照(选定陆运线路后写回成本/时效)。 + * 不新插 TMS 行,不改手工/调价行。 + */ + Map replaceTmsQuote(Map body); + /** * 智能体续办:按工单号取字段/报价/推断停点。只读。 * quote 是当前生效价(可能已是销售调价);tms_quote 永远是 TMS 原价,拉群摘要必须用这份。 diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index 741392d..8a91b8e 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -751,7 +751,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl facts, Map quote, String salesWecomId) { + return buildFillFacts(workOrderNo, facts, quote, salesWecomId, null); + } + + /** + * 出单填表投影。空运不写时效:业务确认空运没有时效字段。 + * + * @param businessLine 智能体传入的业务线;空时再看询价事实 / 报价快照 + */ + public static JSONObject buildFillFacts(String workOrderNo, Map facts, + Map quote, String salesWecomId, + String businessLine) { JSONObject out = new JSONObject(true); Map src = facts == null ? new LinkedHashMap<>() : facts; Map q = quote == null ? new LinkedHashMap<>() : quote; @@ -844,8 +855,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl src, Map quote, + String businessLine) { + String line = StringUtils.defaultString(businessLine); + if (StringUtils.isBlank(line) && src != null) { + line = first(src, "业务线", "business_line", "businessLine", "运输方式"); + } + if ("AIR".equalsIgnoreCase(line) || "空".equals(line) || (line != null && line.contains("空运"))) { + return true; + } + if (quote != null && (quote.get("airOptions") != null || quote.get("air_options") != null + || quote.get("selected_air_option") != null)) { + return true; + } + return false; + } + /** * 询价事实里已有陆运选项/始发站,运费兜底名就不能再写成空运费。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java index 2846c9d..9394301 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java @@ -586,6 +586,18 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl replaceTmsQuote(Map body) { + String no = text(body, "workOrderNo", "work_order_no"); + Object raw = body == null ? null : body.get("quote"); + @SuppressWarnings("unchecked") + Map quote = raw instanceof Map ? (Map) raw : new LinkedHashMap(); + return tmsQueryService.replaceTmsQuote(no, quote); + } + private String nextWorkOrderNo() { String day = new SimpleDateFormat("yyyyMMdd").format(new Date()); long n = count(new LambdaQueryWrapper().likeRight(InquiryTicket::getWorkOrderNo, "WO" + day)); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index 459e104..e547d3b 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -238,12 +238,7 @@ public class InquiryTmsQueryService { row.setTicketId(ticket.getId()); row.setWorkOrderNo(workOrderNo); row.setQuoteVersion(1); - row.setSource("TMS"); - row.setSourceLabel("TMS 标准报价"); - // 后台售价栏必须带 TMS 币种,不能只落纯数字。 - row.setPriceText(TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total"))); - row.setHighlighted(1); - row.setPayloadJson(JSON.toJSONString(quote)); + applySnapshotToQuoteRow(row, quote); row.setCreateTime(new Date()); quoteMapper.insert(row); List quotes = quoteMapper.selectList(new LambdaQueryWrapper() @@ -323,6 +318,7 @@ public class InquiryTmsQueryService { quote.put("source", "TMS"); quote.put("source_label", "TMS 标准报价"); quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency)); + putFeeItemsRaw(quote, data); // 海运 feeItems 是分段桶:polCostItems / oceanCostItems / podCostItems,单独透出给模板分区回填 putSeaFeeBuckets(quote, data); // 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。 @@ -349,6 +345,8 @@ public class InquiryTmsQueryService { } // 陆运线路段:与空运 airOptions 同级透出,供模板 routeRegions 填项目一 putLandSegments(quote, data, tms); + // 陆运常无整单总价:一条线路才把各段成本加总;多条备选禁止加在一起 + applyLandSegmentTotals(quote); String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg")); if (StringUtils.isNotBlank(chargeable)) { quote.put("chargeableWeightKg", chargeable); @@ -385,9 +383,9 @@ public class InquiryTmsQueryService { if (!rows.isEmpty()) { quote.put("route_rows", JSON.toJSONString(rows)); } - // feeItems 为空时,用各腿售价补一条运输费,避免报价明细全空 + // feeItems 为空时,用各腿成本补运输费。顶层总价会被误收成「空运费」,陆运要换掉。 String feeLines = quote.get("fee_lines"); - if ((StringUtils.isBlank(feeLines) || "[]".equals(feeLines.trim())) && !rows.isEmpty()) { + if (shouldFillLandFeeLines(feeLines) && !rows.isEmpty()) { List> lines = new ArrayList<>(); for (Map seg : rows) { String amount = firstText(seg.get("salePrice"), seg.get("costPrice"), seg.get("amount")); @@ -395,11 +393,17 @@ public class InquiryTmsQueryService { continue; } Map line = new LinkedHashMap<>(); - line.put("name", "运输费"); + line.put("name", firstText(seg.get("feeName"), seg.get("name"), "运输费")); line.put("amount", amount); if (StringUtils.isNotBlank(seg.get("currency"))) { line.put("currency", seg.get("currency")); } + if (StringUtils.isNotBlank(seg.get("segmentSequence"))) { + line.put("segmentSequence", seg.get("segmentSequence")); + } + if (StringUtils.isNotBlank(seg.get("routeLegSequence"))) { + line.put("routeLegSequence", seg.get("routeLegSequence")); + } if (StringUtils.isNotBlank(seg.get("remark"))) { line.put("remark", seg.get("remark")); } @@ -477,6 +481,201 @@ public class InquiryTmsQueryService { return out; } + /** + * 陆运分段成本收成工单总价。 + *

    + * 同一 segmentSequence 是一条完整线路。只有 1 条时才把各段 costPrice 相加, + * 时效按段原文用「 / 」拼上(如 N+2 / N+3)。多条备选禁止加总,避免 37100 这种假合计。 + * 顶层已有真实总价时不覆盖。 + */ + static void applyLandSegmentTotals(Map quote) { + if (quote == null) { + return; + } + List> rows = normalizeLandSegmentRows(quote.get("landSegments")); + if (rows.isEmpty()) { + return; + } + Map>> groups = new LinkedHashMap<>(); + for (Map row : rows) { + int seq = 1; + try { + seq = Integer.parseInt(firstText(row.get("segmentSequence"), "1")); + } catch (RuntimeException ex) { + seq = 1; + } + groups.computeIfAbsent(seq, k -> new ArrayList<>()).add(row); + } + if (groups.size() != 1) { + return; + } + List> legs = groups.values().iterator().next(); + java.math.BigDecimal sum = java.math.BigDecimal.ZERO; + boolean any = false; + List etas = new ArrayList<>(); + String ccy = firstText(quote.get("currency")); + for (Map leg : legs) { + String amt = firstText(leg.get("costPrice"), leg.get("salePrice"), leg.get("amount")); + if (StringUtils.isNotBlank(amt) && !"-".equals(amt)) { + try { + sum = sum.add(new java.math.BigDecimal(amt.trim().replace(",", ""))); + any = true; + } catch (RuntimeException ignored) { + // 非数字成本不编 + } + } + if (StringUtils.isBlank(ccy)) { + ccy = firstText(leg.get("currency")); + } + String days = firstText(leg.get("transitDays"), leg.get("transit_time")); + if (StringUtils.isNotBlank(days) && !"-".equals(days)) { + etas.add(days); + } + } + if (any && blankOrDash(quote.get("total"))) { + quote.put("total", stripPlainNumber(sum)); + } + if (any && blankOrDash(quote.get("cost"))) { + quote.put("cost", stripPlainNumber(sum)); + } + if (StringUtils.isNotBlank(ccy) && StringUtils.isBlank(quote.get("currency"))) { + quote.put("currency", ccy); + } + if (!etas.isEmpty() && blankOrDash(quote.get("eta"))) { + quote.put("eta", String.join(" / ", etas)); + } + } + + /** + * 把规范化 quote 写进 TMS 报价行:金额带币种,载荷原样 JSON。 + * 查价落库与选定线路后覆盖共用,禁止再插一条假 TMS。 + */ + static void applySnapshotToQuoteRow(InquiryQuote row, Map quote) { + if (row == null || quote == null) { + return; + } + row.setSource("TMS"); + row.setSourceLabel("TMS 标准报价"); + row.setPriceText(TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total"))); + row.setHighlighted(1); + row.setPayloadJson(JSON.toJSONString(quote)); + } + + /** + * 选定陆运线路后覆盖本单最新 TMS 快照,并重算工单成本价。 + *

    + * 为何:查价当时可能是多条备选,工单总价只能先空着;销售选定后必须把该条成本写回。 + * 不新插 TMS 行,避免后台出现两条「TMS标准报价」。已冻结工单拒绝。 + */ + public Map replaceTmsQuote(String workOrderNo, Map incoming) { + Map out = new LinkedHashMap<>(); + if (StringUtils.isBlank(workOrderNo) || incoming == null || incoming.isEmpty()) { + out.put("ok", false); + out.put("error", "quote_blank"); + return out; + } + InquiryTicket ticket = workOrderService.getOne( + new LambdaQueryWrapper() + .eq(InquiryTicket::getWorkOrderNo, workOrderNo) + .last("LIMIT 1")); + if (ticket == null) { + out.put("ok", false); + out.put("error", "ticket_not_found"); + return out; + } + if (TicketStatuses.isFrozen(ticket.getStatus())) { + out.put("ok", false); + out.put("error", "frozen"); + out.put("status", ticket.getStatus()); + return out; + } + Map quote = stringifyQuote(incoming); + applyLandSegmentTotals(quote); + InquiryQuote row = quoteMapper.selectOne(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, workOrderNo) + .eq(InquiryQuote::getSource, "TMS") + .orderByDesc(InquiryQuote::getCreateTime) + .last("LIMIT 1")); + boolean insert = row == null; + if (insert) { + row = new InquiryQuote(); + row.setTicketId(ticket.getId()); + row.setWorkOrderNo(workOrderNo); + row.setQuoteVersion(1); + row.setCreateTime(new Date()); + } + applySnapshotToQuoteRow(row, quote); + row.setUpdateTime(new Date()); + if (insert) { + quoteMapper.insert(row); + } else { + quoteMapper.updateById(row); + } + List quotes = quoteMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, workOrderNo) + .orderByAsc(InquiryQuote::getCreateTime)); + InquiryTicketPriceSupport.apply(ticket, quotes); + ticket.setTmsHit(1); + workOrderService.updateById(ticket); + if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) { + TicketTransitionRequest tr = new TicketTransitionRequest(); + tr.setWorkOrderNo(workOrderNo); + tr.setToStatus(TicketStatuses.QUOTED); + workOrderService.transition(tr, "agent"); + } + out.put("ok", true); + out.put("quote_version", row.getQuoteVersion() == null ? 1 : row.getQuoteVersion()); + out.put("status", ticket.getStatus()); + return out; + } + + /** + * 智能体回写的 quote 可能带 list/map,落库前收成字符串。 + */ + static Map stringifyQuote(Map incoming) { + Map quote = new LinkedHashMap<>(); + if (incoming == null) { + return quote; + } + for (Map.Entry e : incoming.entrySet()) { + if (e.getKey() == null || e.getValue() == null) { + continue; + } + Object v = e.getValue(); + if (v instanceof String) { + quote.put(e.getKey(), (String) v); + } else if (v instanceof Map || v instanceof List) { + quote.put(e.getKey(), JSON.toJSONString(v)); + } else { + quote.put(e.getKey(), String.valueOf(v)); + } + } + return quote; + } + + private static boolean blankOrDash(String raw) { + return StringUtils.isBlank(raw) || "-".equals(raw.trim()); + } + + /** + * 陆运分段行能不能盖掉当前 fee_lines。 + * 空列表、或只有「空运费」兜底(把陆运总价误写成空运)时才盖。 + */ + static boolean shouldFillLandFeeLines(String feeLines) { + if (StringUtils.isBlank(feeLines) || "[]".equals(feeLines.trim())) { + return true; + } + String text = feeLines.replace(" ", ""); + return text.contains("空运费") && !text.contains("运输费"); + } + + private static String stripPlainNumber(java.math.BigDecimal value) { + if (value == null) { + return ""; + } + return value.stripTrailingZeros().toPlainString(); + } + /** * 把本次 TMS 报价写回工单主表,供后台列表/详情展示。 * 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。 @@ -762,9 +961,29 @@ public class InquiryTmsQueryService { putExtra(extras, "quantity", firstText(row.get("quantity"), row.get("chargeQuantity"), row.get("charge_quantity"))); putExtra(extras, "unit_price", firstText(row.get("unitPrice"), row.get("unit_price"))); putExtra(extras, "remark", firstText(row.get("remark"), row.get("description"), row.get("note"))); + putExtra(extras, "segmentSequence", firstText(row.get("segmentSequence"), row.get("segment_sequence"))); + putExtra(extras, "routeLegSequence", firstText(row.get("routeLegSequence"), row.get("route_leg_sequence"))); return extras; } + /** + * 陆运选线要按段过滤费用行:feeItems 原文单独落下,名称保持 TMS 回的 feeName。 + */ + private static void putFeeItemsRaw(Map quote, Map data) { + if (quote == null || data == null) { + return; + } + Object raw = first(data.get("feeItems"), data.get("fee_items"), data.get("feeList")); + if (raw == null) { + return; + } + String json = raw instanceof String ? String.valueOf(raw) : JSON.toJSONString(raw); + if (StringUtils.isBlank(json) || "null".equalsIgnoreCase(json) || "[]".equals(json.trim())) { + return; + } + quote.put("feeItems", json); + } + private static void putExtra(Map extras, String key, String value) { if (StringUtils.isNotBlank(value) && !"-".equals(value) && !"null".equalsIgnoreCase(value)) { extras.put(key, value); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java index 578c3d5..e7fd585 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java @@ -87,6 +87,31 @@ public class QuoteTemplateFillTest { assertEquals("CNY", ((JSONArray) fee).getJSONObject(0).getString("currency")); } + /** + * 空运没有时效:即便 TMS 回了 eta,出单也不填 transit_* / 时效。 + * 报价有效期仍要留下。 + */ + @Test + public void buildFillFactsHidesAirTransitKeepsValidUntil() { + Map facts = new HashMap<>(); + facts.put("起运港", "南京"); + facts.put("目的港", "吉隆坡"); + facts.put("运输方式", "空运"); + facts.put("时效", "3-5 天"); + Map quote = new HashMap<>(); + quote.put("total", "5001.0"); + quote.put("currency", "CNY"); + quote.put("eta", "3天"); + quote.put("valid_until", "2026-09-30"); + quote.put("airOptions", "[{\"originAirportCode\":\"NKG\"}]"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202609220018", facts, quote, "WuJiLin", "AIR"); + assertEquals("2026-09-30", out.getString("valid_until")); + assertTrue(StringUtils.isBlank(out.getString("transit_days"))); + assertTrue(StringUtils.isBlank(out.getString("transit_time"))); + assertTrue(StringUtils.isBlank(out.getString("时效"))); + } + @Test public void writeFeeDetailColumnsOntoNamedFeeRow() throws Exception { Path src = Files.createTempFile("quote-fee-src", ".xlsx"); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java index 699d4e5..0b232f5 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java @@ -331,4 +331,121 @@ public class TmsQuoteCurrencyTest { assertEquals("11.00", lines.get(0).get("unit_price")); assertEquals("123.00", lines.get(1).get("amount")); } + + @Test + public void toAgentQuoteSumsSingleLandRouteCostWhenNoHeaderTotal() { + // 陆运只有分段成本、没有整单总价:一条两段要加出 17100,时效带上各段原文。 + Map data = new HashMap<>(); + data.put("quoteFound", true); + data.put("currency", "CNY"); + data.put("isMulti", 1); + data.put("landSegments", java.util.Arrays.asList( + landLeg(1, 1, "上海", "谅山", 7500, "N+2"), + landLeg(1, 2, "谅山", "胡志明", 9600, "N+3"))); + Map tms = new HashMap<>(); + tms.put("code", 1); + tms.put("tmsData", data); + tms.put("quoteFound", true); + Map quote = InquiryTmsQueryService.toAgentQuote(tms); + assertEquals("17100", quote.get("total")); + assertEquals("17100", quote.get("cost")); + assertTrue(quote.get("eta").contains("N+2")); + assertTrue(quote.get("eta").contains("N+3")); + assertFalse(quote.get("eta").contains("120")); + } + + @Test + public void toAgentQuoteKeepsLandFeeItemNameAndSegment() { + // 陆运 feeItems 的费用名原样落下,并带上段号,选线后才能按条过滤。 + Map data = new HashMap<>(); + data.put("quoteFound", true); + data.put("currency", "CNY"); + data.put("isMulti", 2); + data.put("landSegments", java.util.Arrays.asList( + landLeg(1, 1, "上海", "谅山", 7500, "N+2"), + landLeg(1, 2, "谅山", "胡志明", 9600, "N+3"), + landLeg(2, 1, "上海", "广西", 7500, "N+3"), + landLeg(2, 2, "广西", "胡志明", 12500, "N+3"))); + data.put("feeItems", java.util.Arrays.asList( + landFee(1, 1, "运输费", 7500), + landFee(1, 2, "运输费", 9600), + landFee(2, 1, "运输费", 7500), + landFee(2, 2, "运输费", 12500))); + Map tms = new HashMap<>(); + tms.put("code", 1); + tms.put("tmsData", data); + tms.put("quoteFound", true); + Map quote = InquiryTmsQueryService.toAgentQuote(tms); + String fees = quote.get("fee_lines"); + assertTrue(fees.contains("\"name\":\"运输费\"")); + assertTrue(fees.contains("\"segmentSequence\":\"1\"")); + assertTrue(fees.contains("\"routeLegSequence\":\"2\"")); + assertFalse(fees.contains("上海 →")); + assertTrue(quote.get("feeItems").contains("运输费")); + } + + @Test + public void toAgentQuoteDoesNotSumTwoLandRoutes() { + // 两条备选不能加在一起写成 37100;未选线时整单总价保持空。 + Map data = new HashMap<>(); + data.put("quoteFound", true); + data.put("currency", "CNY"); + data.put("isMulti", 2); + data.put("landSegments", java.util.Arrays.asList( + landLeg(1, 1, "上海", "谅山", 7500, "N+2"), + landLeg(1, 2, "谅山", "胡志明", 9600, "N+3"), + landLeg(2, 1, "上海", "广西", 7500, "N+3"), + landLeg(2, 2, "广西", "胡志明", 12500, "N+3"))); + Map tms = new HashMap<>(); + tms.put("code", 1); + tms.put("tmsData", data); + tms.put("quoteFound", true); + Map quote = InquiryTmsQueryService.toAgentQuote(tms); + assertTrue(StringUtilsBlankOrDash(quote.get("total"))); + assertFalse("37100".equals(quote.get("total"))); + assertTrue(quote.get("landSegments").contains("谅山")); + assertTrue(quote.get("landSegments").contains("广西")); + } + + @Test + public void applySnapshotWritesLandCostAsTmsPriceText() { + Map quote = new LinkedHashMap<>(); + quote.put("currency", "CNY"); + quote.put("total", "17100"); + org.jeecg.modules.inquiry.entity.InquiryQuote row = + new org.jeecg.modules.inquiry.entity.InquiryQuote(); + InquiryTmsQueryService.applySnapshotToQuoteRow(row, quote); + assertEquals("CNY 17100", row.getPriceText()); + assertTrue(row.getPayloadJson().contains("17100")); + } + + private static boolean StringUtilsBlankOrDash(String raw) { + return raw == null || raw.trim().isEmpty() || "-".equals(raw.trim()); + } + + private static Map landLeg(int seq, int leg, String origin, String dest, + int cost, String transit) { + Map row = new LinkedHashMap<>(); + row.put("segmentSequence", seq); + row.put("routeLegSequence", leg); + row.put("origin", origin); + row.put("destination", dest); + row.put("costPrice", cost); + row.put("currency", "CNY"); + row.put("transitDays", transit); + row.put("vehicleType", "3T"); + return row; + } + + private static Map landFee(int seq, int leg, String name, int amount) { + Map row = new LinkedHashMap<>(); + row.put("segmentSequence", seq); + row.put("routeLegSequence", leg); + row.put("feeName", name); + row.put("amount", amount); + row.put("currency", "CNY"); + row.put("unit", "台"); + row.put("quantity", 1); + return row; + } }