diff --git a/aibot-bridge/group_message_frame.cjs b/aibot-bridge/group_message_frame.cjs new file mode 100644 index 0000000..ea8bd3a --- /dev/null +++ b/aibot-bridge/group_message_frame.cjs @@ -0,0 +1,45 @@ +/** + * 空运群出站报文:禁止 stream(企微会加「内容由AI生成」和赞踩)。 + * 只发 markdown,且不带 feedback。 + * 有这次 @ 的 req_id 才走 aibot_respond_msg,群里才会引用原消息。 + */ +function pickReplyReqId({ fromCaller, fromChat, lastAny }) { + const caller = String(fromCaller || "").trim(); + if (caller) { + return caller; + } + const chat = String(fromChat || "").trim(); + if (chat) { + return chat; + } + return String(lastAny || "").trim(); +} + +function buildGroupMessageFrame({ chatId, content, callbackReqId, reqId }) { + const text = String(content || ""); + const markdown = { content: text }; + if (callbackReqId) { + return { + cmd: "aibot_respond_msg", + headers: { req_id: callbackReqId }, + body: { + chatid: chatId, + chat_type: 2, + msgtype: "markdown", + markdown, + }, + }; + } + return { + cmd: "aibot_send_msg", + headers: { req_id: reqId || "aibot_send_msg" }, + body: { + chatid: chatId, + chat_type: 2, + msgtype: "markdown", + markdown, + }, + }; +} + +module.exports = { buildGroupMessageFrame, pickReplyReqId }; diff --git a/aibot-bridge/long_connection.cjs b/aibot-bridge/long_connection.cjs index fad2b90..d5cb814 100644 --- a/aibot-bridge/long_connection.cjs +++ b/aibot-bridge/long_connection.cjs @@ -12,6 +12,7 @@ "use strict"; const crypto = require("crypto"); +const { buildGroupMessageFrame, pickReplyReqId } = require("./group_message_frame.cjs"); function generateReqId(prefix) { const timestamp = Date.now(); @@ -49,6 +50,7 @@ function startLongConnectionLoop(opts) { lastError: "", pending: new Map(), lastCallback: new Map(), + lastAnyReq: "", heartbeatTimer: null, reconnectTimer: null, stop: () => {}, @@ -83,38 +85,34 @@ function startLongConnectionLoop(opts) { }); } - function sendGroupMessage({ chatId, content }) { + function sendGroupMessage({ chatId, content, callbackReqId }) { const text = String(content || ""); - const callbackReqId = state.lastCallback.get(String(chatId || "").trim()) || ""; - if (callbackReqId) { - return sendWs( - { - cmd: "aibot_respond_msg", - headers: { req_id: callbackReqId }, - body: { - msgtype: "stream", - stream: { id: crypto.randomUUID(), finish: true, content: text }, - }, - }, - callbackReqId - ).finally(() => { - state.lastCallback.delete(String(chatId || "").trim()); - }); - } - const reqId = generateReqId("aibot_send_msg"); - return sendWs( - { - cmd: "aibot_send_msg", - headers: { req_id: reqId }, - body: { - chatid: chatId, - chat_type: 2, - msgtype: "markdown", - markdown: { content: text }, - }, - }, - reqId + const fromCaller = String(callbackReqId || "").trim(); + const chatKey = String(chatId || "").trim(); + const fromChat = state.lastCallback.get(chatKey) || ""; + const replyReqId = pickReplyReqId({ + fromCaller, + fromChat, + lastAny: state.lastAnyReq || "", + }); + // 用这次 @ 的 req_id 回 aibot_respond_msg,群里才会引用原消息。 + const reqId = replyReqId || generateReqId("aibot_send_msg"); + const frame = buildGroupMessageFrame({ + chatId, + content: text, + callbackReqId: replyReqId, + reqId, + }); + // eslint-disable-next-line no-console + console.log( + "[aibot-long-connection] 出站 cmd=", + frame.cmd, + "chat=", + chatKey || "-", + "hasReq=", + Boolean(replyReqId) ); + return sendWs(frame, reqId); } /** @@ -303,8 +301,10 @@ function startLongConnectionLoop(opts) { return; } const reqId = frame.headers && frame.headers.req_id; + // 出站 ACK 带 chatid,不能当成入站,否则会盖掉这次 @ 的 req_id,群里就没有引用。 if (reqId && state.pending.has(reqId)) { settlePending(reqId, frame.errcode, frame.errmsg, frame); + return; } if (frame.cmd === "aibot_event_callback") { const ev = (frame.body && frame.body.event && frame.body.event.eventtype) || ""; @@ -318,9 +318,7 @@ function startLongConnectionLoop(opts) { const bodyHint = frame.body || {}; const looksLikeInbound = Boolean( frame.cmd === "aibot_msg_callback" || - bodyHint.msgid || - bodyHint.chatid || - (bodyHint.text && bodyHint.text.content) + (bodyHint.msgid && (bodyHint.from || (bodyHint.text && bodyHint.text.content))) ); if (looksLikeInbound) { // eslint-disable-next-line no-console @@ -333,8 +331,12 @@ function startLongConnectionLoop(opts) { bodyHint.chatid || "-" ); const inbound = normalizeInbound(frame); - if (reqId && inbound.chat_id) { - state.lastCallback.set(inbound.chat_id, reqId); + const chatKey = inbound.chat_id || String(bodyHint.chatid || "").trim(); + if (reqId && chatKey) { + state.lastCallback.set(chatKey, reqId); + } + if (reqId) { + state.lastAnyReq = reqId; } const officialBody = Object.assign({}, frame.body || {}, { aibot_direct: true, diff --git a/aibot-bridge/server.cjs b/aibot-bridge/server.cjs index 1518b63..934c582 100644 --- a/aibot-bridge/server.cjs +++ b/aibot-bridge/server.cjs @@ -4,7 +4,7 @@ * 本文件职责: * - /health 存活 * - POST /forward 把已解析消息转到智能体 WECOM_AIBOT_INGRESS_URL(默认 8910/internal/aibot/inbound) - * - POST /send-group 走长连接 aibot_send_msg(发文件后马上在群里说话) + * - POST /send、/send-group:有 aibot_req_id 走 aibot_respond_msg(引用这次 @),没有则主动发言 * - 默认不连企微(并存期禁止双连);WECOM_AIBOT_ENABLED=true 时订阅长连接 * * 禁止:在本进程写工单/六态/Graph。 @@ -165,7 +165,11 @@ const server = http.createServer(async (req, res) => { sendJson(res, 400, { error: "missing_chat_or_content" }); return; } - await longConn.sendGroupMessage({ chatId, content }); + await longConn.sendGroupMessage({ + chatId, + content, + callbackReqId: String(body.aibot_req_id || body.callback_req_id || "").trim(), + }); sendJson(res, 200, { status: "sent", chatId }); } catch (e) { const code = e && e.code ? e.code : "send_failed"; @@ -232,7 +236,11 @@ const server = http.createServer(async (req, res) => { sendJson(res, 400, { error: "missing_chat_or_content" }); return; } - await longConn.sendGroupMessage({ chatId, content }); + await longConn.sendGroupMessage({ + chatId, + content, + callbackReqId: String(body.aibot_req_id || body.callback_req_id || "").trim(), + }); sendJson(res, 200, { status: "sent", chatId }); } catch (e) { const code = e && e.code ? e.code : "send_failed"; diff --git a/aibot-bridge/tests/group_message_frame.test.cjs b/aibot-bridge/tests/group_message_frame.test.cjs new file mode 100644 index 0000000..435ccb5 --- /dev/null +++ b/aibot-bridge/tests/group_message_frame.test.cjs @@ -0,0 +1,33 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { buildGroupMessageFrame, pickReplyReqId } = require("../group_message_frame.cjs"); + +test("group reply never uses stream", () => { + const withCb = buildGroupMessageFrame({ + chatId: "wr_1", + content: "工单号:WO202609210001", + callbackReqId: "cb_1", + }); + assert.notEqual(withCb.body.msgtype, "stream"); + assert.equal(withCb.body.msgtype, "markdown"); + assert.equal(withCb.cmd, "aibot_respond_msg"); + assert.equal(withCb.body.chatid, "wr_1"); + assert.equal(withCb.body.markdown.feedback, undefined); + + const proactive = buildGroupMessageFrame({ + chatId: "wr_1", + content: "附件已保存", + callbackReqId: "", + }); + assert.equal(proactive.cmd, "aibot_send_msg"); + assert.equal(proactive.body.msgtype, "markdown"); + assert.equal(proactive.body.markdown.feedback, undefined); +}); + +test("reply req id falls back to last inbound when chat map misses", () => { + assert.equal(pickReplyReqId({ fromCaller: "cb_1", fromChat: "old", lastAny: "any" }), "cb_1"); + assert.equal(pickReplyReqId({ fromCaller: "", fromChat: "chat_cb", lastAny: "any" }), "chat_cb"); + assert.equal(pickReplyReqId({ fromCaller: "", fromChat: "", lastAny: "jeD_msF" }), "jeD_msF"); + assert.equal(pickReplyReqId({ fromCaller: "", fromChat: "", lastAny: "" }), ""); +}); + diff --git a/inquiry-agent/agent/channel/aibot/__init__.py b/inquiry-agent/agent/channel/aibot/__init__.py index 715550b..b52d0cb 100644 --- a/inquiry-agent/agent/channel/aibot/__init__.py +++ b/inquiry-agent/agent/channel/aibot/__init__.py @@ -56,6 +56,9 @@ def normalize_aibot_payload(body: dict[str, Any]) -> AibotInboundBody: raw.setdefault("source", "aibot") if data.get("response_url"): raw.setdefault("response_url", data.get("response_url")) + req_id = str(data.get("aibot_req_id") or raw.get("aibot_req_id") or "").strip() + if req_id: + raw["aibot_req_id"] = req_id return AibotInboundBody( sender_id=sender, message_id=mid, diff --git a/inquiry-agent/agent/channel/aibot/reply.py b/inquiry-agent/agent/channel/aibot/reply.py index dc02839..311b84b 100644 --- a/inquiry-agent/agent/channel/aibot/reply.py +++ b/inquiry-agent/agent/channel/aibot/reply.py @@ -1,15 +1,15 @@ """ 空运群 BOT 回话。 -本文件职责:有即时 `response_url` 就流式回;没有(发文件走存档)就打本机桥 `/send-group`, -由长连接 `aibot_send_msg` 主动往群里说。 -禁止:走海运 appchat/send;在回调线程等 LLM。 +本文件职责:打本机桥 `/send`(骨架 `/send-group`)。 +有这次 @ 的 `aibot_req_id` 时,桥走 `aibot_respond_msg`,群里才会引用原消息; +没有 req_id(典型:会话存档听到附件)才走 `aibot_send_msg` 主动发言。 +禁止:走海运 appchat/send;用企微 stream 回复(会带「内容由AI生成」和赞踩);在回调线程等 LLM。 """ from __future__ import annotations import logging -import uuid from typing import Any import httpx @@ -23,13 +23,14 @@ class AibotReplyClient: """ 空运群出站客户端。 - @ 带了 response_url:流式只收口这次 @;正文走桥主动发言,和发模板同一条通道。 - 存档听到附件时没有 url:直接走桥。该群须先被 @ 过,企微才允许主动说。 + 空运群出站只走桥。带上这次 @ 的 req_id,桥才能引用原消息。 + 禁止 POST 企微 stream(群里会显示「内容由AI生成」和赞踩)。 """ - def __init__(self, response_url: str = "", timeout_sec: float = 8.0) -> None: + def __init__(self, response_url: str = "", timeout_sec: float = 8.0, callback_req_id: str = "") -> None: self._url = (response_url or "").strip() self._timeout = timeout_sec + self._callback_req_id = str(callback_req_id or "").strip() self.sent: list[dict[str, Any]] = [] def send_group( @@ -45,41 +46,15 @@ class AibotReplyClient: if not text: return {"ok": False, "error": "empty"} room = str(chat_id or "").strip() - # @ 当场流式回话群里经常看不见长摘要,模板却走主动发言,就会只剩一张表。 - # 工单信息必须再走桥,和模板同一条通道;流式只收口这次 @。 - if self._url: - self._send_stream(room, text.split("\n", 1)[0][:80]) + # 企微 stream 会自带「内容由AI生成」和赞踩,空运群业务话术不要这条通道。 + # 工单正文只走桥主动发言(markdown),和发模板同一条通道。 if room: - out = self._send_proactive(room, text) - if out.get("ok") or not self._url: - return out - if self._url: - return self._send_stream(room, text) + return self._send_proactive(room, text) return {"ok": False, "error": "no_chat_id"} - def _send_stream(self, chat_id: str, text: str) -> dict[str, Any]: - """@ 当场回话:POST 企微给的 response_url。""" - body = { - "msgtype": "stream", - "stream": { - "id": uuid.uuid4().hex, - "finish": True, - "content": text, - }, - } - try: - resp = httpx.post(self._url, json=body, timeout=self._timeout) - ok = 200 <= resp.status_code < 300 - if not ok: - logger.warning("aibot 回话 HTTP=%s chat=%s", resp.status_code, chat_id) - return {"ok": ok, "status": resp.status_code} - except Exception: - logger.exception("aibot 回话网络失败 chat=%s", chat_id) - return {"ok": False, "error": "network"} - def _send_proactive(self, chat_id: str, text: str) -> dict[str, Any]: """ - 没有 response_url(典型:会话存档听到附件)时,让桥主动往群里说。 + 把正文交给桥。有 callback_req_id 时桥引用这次 @;没有则主动发言。 副作用:回环 HTTP 打 8913,不直连企微。桥未连上或该群从未 @ 过,企微不会投递。 """ @@ -100,14 +75,24 @@ class AibotReplyClient: # 现网官方桥是 POST /send;本仓库骨架桥是 /send-group。两个都试,先打官方口径。 resp = httpx.post( f"{base}/send", - json={"chatid": chat_id, "operation": "text", "content": text}, + json={ + "chatid": chat_id, + "operation": "text", + "content": text, + "aibot_req_id": self._callback_req_id, + }, headers=headers, timeout=self._timeout, ) if resp.status_code == 404: resp = httpx.post( f"{base}/send-group", - json={"chat_id": chat_id, "content": text, "chat_type": 2}, + json={ + "chat_id": chat_id, + "content": text, + "chat_type": 2, + "aibot_req_id": self._callback_req_id, + }, headers=headers, timeout=self._timeout, ) diff --git a/inquiry-agent/agent/channel/h5/__init__.py b/inquiry-agent/agent/channel/h5/__init__.py index e51b512..a55d8e6 100644 --- a/inquiry-agent/agent/channel/h5/__init__.py +++ b/inquiry-agent/agent/channel/h5/__init__.py @@ -10,7 +10,7 @@ from __future__ import annotations import logging from typing import Any, Optional -from fastapi import APIRouter, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field @@ -112,6 +112,52 @@ async def h5_upload_submit( return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK)) +@router.get("/air-routes/{token}", response_class=HTMLResponse) +def h5_air_route_open(token: str) -> HTMLResponse: + """打开空运选线路页。身份只认 token。""" + from agent.channel.h5.air_route_page import expired_air_route_page, render_air_route_page + from agent.channel.h5.air_route_store import lookup_air_route_token + + rec = lookup_air_route_token(token) + if rec is None: + return HTMLResponse(expired_air_route_page(), status_code=410) + return HTMLResponse(render_air_route_page(rec)) + + +@router.post("/air-routes/{token}", response_class=HTMLResponse) +def h5_air_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse: + """ + 选定线路。请求线程只验 token、入出站队列,不查 TMS、不跑整图。 + """ + from agent.channel.h5.air_route_page import ( + expired_air_route_page, + render_air_route_ok, + render_air_route_page, + ) + from agent.channel.h5.air_route_store import lookup_air_route_token + from agent.channel.queue import get_message_store + from agent.policy.air_text_flow import get_air_text_flow + + rec = lookup_air_route_token(token) + if rec is None: + return HTMLResponse(expired_air_route_page(), status_code=410) + sender_id = str(rec.get("sender_id") or "").strip() + if not sender_id: + return HTMLResponse(expired_air_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_air_text_flow().select_air_route( + sender_id=sender_id, index=int(index), reply=reply + ) + logger.info("h5.air_route pick sender=%s phase=%s idx=%s", sender_id, phase, index) + if phase in {"route_expired", "route_invalid"}: + return HTMLResponse(render_air_route_page(rec), status_code=400) + return HTMLResponse(render_air_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 new file mode 100644 index 0000000..6fbcad0 --- /dev/null +++ b/inquiry-agent/agent/channel/h5/air_route_page.py @@ -0,0 +1,282 @@ +""" +空运选线路 H5 页。 + +本文件职责:列出 TMS 线路(航线+费用+总价),提交所选下标。 +样式对齐附件询价页:企微内嵌、手机竖屏、卡片点选。 +禁止:从 URL 读 userid;禁止本页查 TMS;禁止编造费用行。 +""" + +from __future__ import annotations + +import html +from typing import Any + +from agent.schema.air_route_options import ( + option_fee_lines, + option_total, + parse_air_options, + route_codes, + route_line, +) + +# 与 upload_page 同一套企微蓝、浅底、白卡片。禁止在页面写 userid。 +_PAGE_CSS = """ +:root { + --bg: #eef3f9; + --card: #ffffff; + --text: #17233d; + --muted: #5b6b7c; + --line: #c5d4ea; + --brand: #1677ff; + --brand-press: #0f62d9; + --ok: #0f8a5f; + --shadow: 0 10px 28px rgba(23, 51, 91, 0.10); +} +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; } +body { + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; +} +.wrap { + max-width: 480px; + margin: 0 auto; + padding: 16px 16px calc(96px + env(safe-area-inset-bottom)); +} +.hero { margin: 4px 0 16px; } +.wo { + display: inline-block; + font-size: 12px; + color: #2b5aa8; + background: #eef4ff; + border-radius: 999px; + padding: 4px 10px; + margin: 0 0 8px; + word-break: break-all; +} +h1 { font-size: 22px; line-height: 1.3; margin: 0 0 6px; font-weight: 600; } +.lead { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; } +.opt { + display: block; + position: relative; + background: var(--card); + border: 1.5px solid transparent; + border-radius: 16px; + padding: 16px 16px 14px; + margin: 0 0 12px; + box-shadow: var(--shadow); + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.opt:has(input:checked), .opt.is-on { + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.16), var(--shadow); +} +.opt input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + pointer-events: none; +} +.head { + display: flex; + align-items: flex-start; + gap: 12px; +} +.tick { + width: 22px; + height: 22px; + margin-top: 2px; + border: 2px solid var(--line); + border-radius: 50%; + flex-shrink: 0; + background: #fff; +} +.opt:has(input:checked) .tick, .opt.is-on .tick { + border-color: var(--brand); + background: var(--brand); + box-shadow: inset 0 0 0 4px #fff; +} +.route { flex: 1; min-width: 0; } +.route strong { + display: block; + font-size: 17px; + line-height: 1.4; + font-weight: 650; +} +.codes { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; letter-spacing: 0.04em; } +.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.pill { + font-size: 12px; + color: #2b5aa8; + background: #eef4ff; + border-radius: 999px; + padding: 3px 8px; +} +.pill.via { color: #8a5a00; background: #fff6e0; } +.fees { list-style: none; margin: 12px 0 0; padding: 0; } +.fees li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + border-top: 1px solid #eef2f6; + font-size: 14px; + line-height: 1.5; +} +.fees .n { color: var(--muted); } +.fees .a { font-variant-numeric: tabular-nums; text-align: right; } +.empty { + margin: 12px 0 0; + padding-top: 10px; + border-top: 1px solid #eef2f6; + color: var(--muted); + font-size: 13px; +} +.total { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin: 12px 0 0; + padding-top: 10px; + border-top: 1px dashed var(--line); + font-size: 15px; +} +.total b { color: var(--brand); font-size: 18px; font-variant-numeric: tabular-nums; } +.bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); + background: rgba(255,255,255,0.96); + box-shadow: 0 -8px 24px rgba(23, 51, 91, 0.08); +} +.bar-inner { max-width: 480px; margin: 0 auto; } +button[type="submit"] { + width: 100%; + min-height: 48px; + border: 0; + border-radius: 10px; + background: var(--brand); + color: #fff; + font-size: 16px; + font-weight: 700; + cursor: pointer; + touch-action: manipulation; +} +button[type="submit"]:active { background: var(--brand-press); } +.msg { + max-width: 480px; + margin: 48px auto; + padding: 0 16px; + text-align: center; +} +.msg h1 { font-size: 20px; margin: 0 0 8px; } +.msg p { margin: 0; color: var(--muted); font-size: 15px; line-height: 1.6; } +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; } +} +""" + + +def expired_air_route_page() -> str: + """token 过期页。""" + return _wrap( + "已过期", + "

链接已过期

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

", + ) + + +def render_air_route_page(payload: dict[str, Any]) -> str: + """ + 渲染选线路页。payload 来自 token:quote / facts / work_order_no。 + """ + quote = dict(payload.get("quote") or {}) + facts = dict(payload.get("facts") or {}) + wo = html.escape(str(payload.get("work_order_no") or "")) + options = parse_air_options(quote) + rows: list[str] = [] + for i, option in enumerate(options): + rows.append(_option_card(i, option, quote, facts)) + count = len(options) + body = ( + "
" + "
" + f"

{wo}

" + "

选择线路

" + f"

共 {count} 条线路,点选一条后确定。费用以 TMS 回包为准。

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

已选定

请回企微查看价格明细。

", + ) + + +def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str: + """一张线路卡:航线、TMS 费用、总价。没有费用行就留空说明,不编造。""" + src = dict(option or {}) + line = html.escape(route_line(src, facts)) + codes = html.escape(route_codes(src, facts, quote)) + fees = option_fee_lines(src, quote) + if fees: + fee_html = ( + "" + ) + else: + fee_html = "

本线路未返回费用明细

" + total = html.escape(option_total(src, quote)) + transfer = str(src.get("transferAirportCode") or src.get("transfer") or "").strip() + tag = "中转" if transfer else "直达" + tag_cls = "pill via" if transfer else "pill" + flight = html.escape(str(src.get("flightNo") or "").strip()) + pills = [f"{tag}"] + if flight: + pills.append(f"航班 {flight}") + codes_html = f"{codes}" if codes else "" + return ( + "" + ) + + +def _wrap(title: str, body: str) -> str: + """整页壳:标题、viewport、共用样式。""" + return ( + "" + "" + f"{html.escape(title)}" + f"" + f"{body}" + ) diff --git a/inquiry-agent/agent/channel/h5/air_route_store.py b/inquiry-agent/agent/channel/h5/air_route_store.py new file mode 100644 index 0000000..48620e1 --- /dev/null +++ b/inquiry-agent/agent/channel/h5/air_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__) + +AIR_ROUTE_TTL_SECONDS = 7 * 24 * 60 * 60 +_KEY = TOKEN_COPY_TICKET + ":air_route" +_MEM: dict[str, tuple[str, float]] = {} +_MEM_LOCK = threading.Lock() + + +def issue_air_route_token( + *, + sender_id: str, + work_order_no: str, + quote: dict[str, Any], + facts: dict[str, str] | None = None, + ttl_seconds: int = AIR_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("air route token 走内存 wo=%s", work_order_no) + return token + + +def lookup_air_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_air_route_url(token: str) -> str: + """拼选线路页:{inquiry-form}/air-routes/{token}。""" + raw = (token or "").strip() + if not raw: + return "" + return f"{inquiry_form_base().rstrip('/')}/air-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("air 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("air 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/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index 5955e8f..64220e3 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -60,7 +60,8 @@ def _attach_bot_client(engine, message: InboundMessage) -> None: 每次入站换一次 BOT 出站客户端。 测试注入的 FakeBotGroup 不动。 - 有 response_url 走当场流式回;没有(存档附件)走桥主动发言,马上在群里说。 + 有 response_url 也不走 stream;空运群正文一律走桥。 + 把这次入站的 aibot_req_id 带给桥,群里才能引用原 @。 不能复用上一次 @ 的过期 url。 """ from agent.channel.aibot.reply import AibotReplyClient @@ -69,7 +70,8 @@ def _attach_bot_client(engine, message: InboundMessage) -> None: if existing is not None and not isinstance(existing, AibotReplyClient): return engine._group_client = AibotReplyClient( - str((message.raw or {}).get("response_url") or "") + str((message.raw or {}).get("response_url") or ""), + callback_req_id=str((message.raw or {}).get("aibot_req_id") or ""), ) diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index b48a837..507249b 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -217,6 +217,24 @@ class HttpLedger: out["ok"] = True return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"} + def update_facts(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]: + """选定线路后写回起运港等询价事实。不改六态。""" + out = self._post( + "/inquiry/agent/ticket/updateFacts", + {"workOrderNo": work_order_no, "facts": facts}, + idem=f"update-facts:{work_order_no}:{sorted((facts or {}).items())}", + ) + return out if out.get("ok") else {"ok": False, "error": out.get("error") or "update_facts_failed"} + + def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]: + """采用后改选:作废旧报价单。不改六态。""" + out = self._post( + "/inquiry/agent/ticket/voidQuoteFiles", + {"workOrderNo": work_order_no}, + idem=f"void-quote:{work_order_no}", + ) + return out if out.get("ok") else {"ok": False, "error": out.get("error") or "void_quote_failed"} + def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]: """未成交后记下原因。不改六态。""" out = self._post( diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 5e92a98..d0b1074 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -192,6 +192,30 @@ class MemoryLedger: {"name": "其他附加费", "amount": "USD 80"}, ], } + if str(facts.get("tms_multi") or "") == "1": + quote["isMulti"] = "2" + quote["airOptions"] = [ + { + "originAirportCode": "ZUH", + "transferAirportCode": "COC", + "destinationAirportCode": "CRK", + "salePrice": 183370, + "currency": "CNY", + "departureCharges": [ + {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11} + ], + }, + { + "originAirportCode": "SZX", + "transferAirportCode": "", + "destinationAirportCode": "CRK", + "salePrice": 193370, + "currency": "CNY", + "departureCharges": [ + {"chargeName": "运输费", "currency": "CNY", "unitPrice": 12} + ], + }, + ] return { "ok": True, "classification": "HAS_PRICE", @@ -220,6 +244,28 @@ class MemoryLedger: "status": ticket.status, } + def update_facts(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]: + """选定线路后写回起运港等询价事实。""" + with self._lock: + ticket = self._tickets.get(work_order_no) + if ticket is None: + return {"ok": False, "error": "ticket_not_found"} + ticket.facts = dict(facts) + return {"ok": True} + + def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]: + """采用后改选:旧报价单不再当本单有效文件。""" + with self._lock: + ticket = self._tickets.get(work_order_no) + if ticket is None: + return {"ok": False, "error": "ticket_not_found"} + ticket.quote_file_ready = False + if ticket.quote: + ticket.quote = dict(ticket.quote) + ticket.quote["quoteFileIssued"] = False + ticket.quote["quoteFileVoided"] = True + return {"ok": True} + def transition( self, *, @@ -359,7 +405,9 @@ class MemoryLedger: wait_phase = "wait_deal" elif status == "已报价" and quote: # 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡 - if ticket.quote_file_ready or quote.get("quoteFileIssued"): + if quote.get("quoteFileVoided"): + wait_phase = "wait_adopt" + elif ticket.quote_file_ready or quote.get("quoteFileIssued"): wait_phase = "wait_deal" elif (ticket.business_line or "").upper() == "SEA": wait_phase = "wait_collab" diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index a024a0c..95f5d5d 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -23,19 +23,31 @@ _MODE_WORDS = ( _LABEL_LINE = re.compile( r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|" - r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" + r"贸易条款|货好时间|货源地|提货地址|收货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$" ) _LABEL_INLINE = re.compile( r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|" - r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" + r"贸易条款|货好时间|货源地|提货地址|收货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)" ) # 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。 _OPTIONAL_INQUIRY_LABELS = ( "通关口岸(非必填)", "通关口岸", + "货源地(非必填)", + "货好时间(非必填)", + "提货地址(非必填)", + "收货地址(非必填)", + "贸易条款(非必填)", + "起运港(非必填)", + "货源地", + "货好时间", + "提货地址", + "收货地址", + "贸易条款", + "起运港", ) # 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。 _COLLAB_LABELS = ( diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index ebe939c..66e8f5a 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -49,6 +49,18 @@ _LOCK_QUOTE = "__air_lock_quote" _LOCK_EXEC = "__air_lock_exec" +def _ticket_flight_date(ticket: object) -> str: + """锁舱 flightDate 用工单报价日期,和主账出站一致。""" + view = ticket_view(ticket) + facts = dict(view.get("facts") or {}) + return str( + facts.get("报价日期") + or view.get("quoteDate") + or view.get("quote_date") + or "" + ).strip() + + def is_airline(flow: Any, sender_id: str) -> bool: """员工管理岗位=航线·空运且启用。""" uid = (sender_id or "").strip() @@ -595,21 +607,13 @@ def apply_air_lock( if not is_airline(flow, sender_id): send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) return "not_airline" - from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused - - if ticket_is_paused(ticket): - reply_paused_ticket( - ticket=ticket, - reply=lambda text: send_group_text(group_client_of(flow), chat_id, text), - ) - return "system_exception_paused" + # 锁舱是航线主动再试:即使工单因上次失败标了系统异常,也要再打 TMS, + # 不能只回「TMS接口异常,IT运维排查中」。 no = ticket_no(ticket) name = operator_name(flow, sender_id) quote = ticket_view(ticket).get("quote") or {} options = _air_options(quote) - quote_id = _quote_id(quote) or f"QT-{no}" - if not options: - options = [{"optionNo": "AIR-OPT-01"}] + quote_id = _quote_id(quote) picked = (option_no or "").strip() if not picked and len(options) > 1: facts = dict(ticket_view(ticket).get("collab_facts") or {}) @@ -619,8 +623,10 @@ def apply_air_lock( ) send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options)) return "wait_lock_option" - if not picked: + if not picked and options: picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip() + if not picked: + picked = "AIR-OPT-01" locker = getattr(flow.ledger, "lock_cabin", None) if not callable(locker): send_group_text( @@ -630,7 +636,7 @@ def apply_air_lock( ) return "lock_unavailable" from agent.policy.system_exception import ( - STEP_TMS_CABIN, + STEP_TMS_LOCK, TYPE_TMS, SystemExceptionEvent, call_with_retries, @@ -649,21 +655,35 @@ def apply_air_lock( is_ok=lambda x: bool((x or {}).get("ok")), ) if not out.get("ok"): + why = copy.air_cabin_fail_reason( + tms_reason=str(out.get("failReason") or out.get("error") or ""), + flight_date=_ticket_flight_date(ticket), + ) + send_group_text( + group_client_of(flow), + chat_id, + copy.air_lock_fail(work_order_no=no, operator=name, reason=why), + ) confirm_system_exception( event=SystemExceptionEvent( exception_type=TYPE_TMS, - step=STEP_TMS_CABIN, + step=STEP_TMS_LOCK, reason="空运锁舱接口调用失败", service="空运锁舱", - error_code=str(out.get("failReason") or out.get("error") or "LOCK_FAIL"), + error_code=why or "LOCK_FAIL", work_order_no=no, ticket_status=ticket_status(ticket), conversation_kind="group", conversation_target=chat_id, - extra={"option_no": picked, "instruction": instruction, "sender_id": sender_id}, + extra={ + "option_no": picked, + "instruction": instruction, + "sender_id": sender_id, + "operator_name": name, + "flight_date": _ticket_flight_date(ticket), + }, ), ledger=flow.ledger, - reply=lambda text: send_group_text(group_client_of(flow), chat_id, text), ) return "system_exception" exec_no = str(out.get("lockId") or out.get("tmsRequestId") or "").strip() @@ -690,6 +710,10 @@ def apply_air_lock( quote_no=quote_no, ), ) + from agent.policy.system_exception import clear_system_exception, ticket_is_paused + + if ticket_is_paused(ticket): + clear_system_exception(ledger=flow.ledger, work_order_no=no) view = ticket_view(ticket) view["collab_chat_id"] = chat_id emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {})) @@ -707,14 +731,7 @@ def apply_air_release( if not is_airline(flow, sender_id): send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) return "not_airline" - from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused - - if ticket_is_paused(ticket): - reply_paused_ticket( - ticket=ticket, - reply=lambda text: send_group_text(group_client_of(flow), chat_id, text), - ) - return "system_exception_paused" + # 释放同理:暂停单也允许再打 TMS,回话用释放失败原因而不是接口异常兜底。 no = ticket_no(ticket) if ticket_status(ticket) == "已成交": send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no)) @@ -734,7 +751,7 @@ def apply_air_release( ) return "release_unavailable" from agent.policy.system_exception import ( - STEP_TMS_CABIN, + STEP_TMS_RELEASE, TYPE_TMS, SystemExceptionEvent, call_with_retries, @@ -753,21 +770,35 @@ def apply_air_release( is_ok=lambda x: bool((x or {}).get("ok")), ) if not out.get("ok"): + why = copy.air_cabin_fail_reason( + tms_reason=str(out.get("failReason") or out.get("error") or ""), + flight_date=_ticket_flight_date(ticket), + ) + send_group_text( + group_client_of(flow), + chat_id, + copy.air_release_fail(work_order_no=no, operator=name, reason=why), + ) confirm_system_exception( event=SystemExceptionEvent( exception_type=TYPE_TMS, - step=STEP_TMS_CABIN, + step=STEP_TMS_RELEASE, reason="空运释放舱位接口调用失败", service="空运舱位", - error_code=str(out.get("failReason") or out.get("error") or "RELEASE_FAIL"), + error_code=why or "RELEASE_FAIL", work_order_no=no, ticket_status=ticket_status(ticket), conversation_kind="group", conversation_target=chat_id, - extra={"lock_id": lock_id, "instruction": instruction, "sender_id": sender_id}, + extra={ + "lock_id": lock_id, + "instruction": instruction, + "sender_id": sender_id, + "operator_name": name, + "flight_date": _ticket_flight_date(ticket), + }, ), ledger=flow.ledger, - reply=lambda text: send_group_text(group_client_of(flow), chat_id, text), ) return "system_exception" exec_no = str(out.get("releaseId") or out.get("tmsRequestId") or lock_id).strip() @@ -782,6 +813,10 @@ def apply_air_release( quote_no=str(facts.get(_LOCK_QUOTE) or ""), ), ) + from agent.policy.system_exception import clear_system_exception, ticket_is_paused + + if ticket_is_paused(ticket): + clear_system_exception(ledger=flow.ledger, work_order_no=no) view = ticket_view(ticket) view["collab_chat_id"] = chat_id emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {})) diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 92c13d8..f48904c 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -1,7 +1,7 @@ """ 空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。 -本文件职责:补问到询价必填齐全 → 建单出卡片 → 必须打主账查 TMS(有价/暂无报价)。 +本文件职责:补问到必填齐 → 核对确定后建单 → 查 TMS(一条出卡,多条 H5 选线路)。 对不上机场也不能跳过查价、本地说暂无报价。 调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 禁止:直连 TMS;用图状态冒充主账成功;用户文字冒充文件已生成。 @@ -11,6 +11,7 @@ from __future__ import annotations import logging import threading +import time import uuid from dataclasses import asdict, dataclass, field from typing import Any, Callable, Optional @@ -20,6 +21,7 @@ from agent.ledger.memory_ledger import MemoryLedger from agent.llm.extract_text import extract_inquiry_snapshot from agent.policy import inquiry_copy as copy from agent.schema.field_validate import validate_required_fields +from agent.schema.land_options import looks_like_confirm from agent.schema.tms_air_query import assemble_air_query logger = logging.getLogger(__name__) @@ -114,6 +116,8 @@ class AirTextInquiryFlow: """ bookmark_kind = "air" + # 明细文本真正发出后再发卡,避免销售先看到半截引用条。 + _detail_before_card_gap_sec = 0.8 def __init__(self, ledger: Any = None, group_client: Any = None, *, bookmark_store: Any = None) -> None: self._ledger = ledger or build_ledger() @@ -410,7 +414,7 @@ class AirTextInquiryFlow: phase = (sess.phase or "").strip() if not no or is_virtual_no(no): return sess - if phase in {"clarify", "need_mode", "handoff_offer", "wait_lost_reason"}: + if phase in {"clarify", "need_mode", "handoff_offer", "wait_lost_reason", "wait_air_route"}: return sess logger.info( "%s.new_over_old phase=%s wo=%s", @@ -621,21 +625,13 @@ class AirTextInquiryFlow: ) return "wait_adopt" if sess.phase == "wait_collab": - reply( - copy.tms_hit_card( - work_order_no=sess.work_order_no, - quote=sess.quote, - first_or_same=sess.first_or_same, - history_work_order_no=sess.history_work_order_no, - status=sess.status or "已报价", - ), - copy.tms_hit_wecom_payload( - work_order_no=sess.work_order_no, - quote=sess.quote, - first_or_same=sess.first_or_same, - history_work_order_no=sess.history_work_order_no, - status=sess.status or "已报价", - ), + self._emit_air_hit( + reply, + work_order_no=sess.work_order_no, + quote=sess.quote, + first_or_same=sess.first_or_same, + history_work_order_no=sess.history_work_order_no, + status=sess.status or "已报价", ) return "wait_collab" if sess.phase == "tms_miss": @@ -716,6 +712,19 @@ class AirTextInquiryFlow: adjusted = try_private_adjust(self, sess, text, reply) if adjusted: return adjusted + if sess and sess.phase == "wait_confirm" and looks_like_confirm(text): + # 确定查价不再抽字段,避免「确定」被模型改成空 facts。 + logger.info("air.confirm sender=%s", sender_id) + return self._confirm_or_hold( + sender_id=sender_id, + facts=dict(sess.facts), + text=sess.immutable_text or text, + reply=reply, + sess=sess, + ) + if (not sess or sess.phase != "wait_confirm") and looks_like_confirm(text) and not sess: + reply(copy.AIR_CONFIRM_EXPIRED) + return "confirm_expired" sess = self._detach_ticketed_for_new_inquiry(sess) snap = extract_inquiry_snapshot( @@ -739,7 +748,7 @@ class AirTextInquiryFlow: return llm_phase mode = (snap.get("business_line") or "").upper() facts = dict(snap.get("facts") or {}) - if sess and sess.phase in {"clarify", "need_mode"}: + if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}: # 补问轮:旧字段打底;运输方式沿用上一句(飞=空运) from agent.llm.extract_text import detect_transport_mode from agent.schema.field_validate import harvest_oral_measures @@ -833,9 +842,78 @@ class AirTextInquiryFlow: ), ) - return self._create_and_quote( + return self._hold_for_confirm( sender_id=sender_id, facts=facts, + text=sess.immutable_text if sess and sess.immutable_text else text, + reply=reply, + sess=sess, + ) + + def _hold_for_confirm( + self, + *, + sender_id: str, + facts: dict[str, str], + text: str, + reply: ReplyFn, + sess: Optional[FlowSession], + ) -> str: + """必填已齐:出核对卡,等确定再建单。""" + reply(copy.air_confirm_card(facts)) + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="wait_confirm", + business_line="AIR", + facts=facts, + immutable_text=text, + wait_version=(sess.wait_version + 1) if sess else 1, + ) + ) + return "wait_confirm" + + def _confirm_or_hold( + self, + *, + sender_id: str, + facts: dict[str, str], + text: str, + reply: ReplyFn, + sess: FlowSession, + ) -> str: + """确定意图:字段仍齐则建单查价。""" + check = validate_required_fields(facts=facts, business_line="AIR") + tms_ready = assemble_air_query(check["facts"]) + if not check["ok"] or not tms_ready.get("ok"): + keys = list(check["missing"] or []) + for key in tms_ready.get("missing") or []: + if key not in keys: + keys.append(key) + new_sess = FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="clarify", + business_line="AIR", + facts=dict(tms_ready.get("facts") or facts), + immutable_text=sess.immutable_text or text, + wait_version=sess.wait_version + 1, + ) + return self._clarify_or_handoff( + sess=sess, + keys=keys, + new_sess=new_sess, + reply=reply, + clarify_text=copy.ask_clarify( + facts=new_sess.facts, + missing_keys=keys, + transport_mode="空运", + ), + ) + return self._create_and_quote( + sender_id=sender_id, + facts=dict(tms_ready["facts"]), text=text, reply=reply, ) @@ -925,6 +1003,15 @@ class AirTextInquiryFlow: return "tms_miss" quote = tms["quote"] + from agent.schema.air_route_options import ( + needs_route_pick, + parse_air_options, + project_selected_quote, + ) + + options = parse_air_options(quote) + if options and not needs_route_pick(quote): + quote = project_selected_quote(quote, options[0]) up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价") if not up.get("ok"): reply("主账写入报价失败,不能当作已报价。") @@ -933,29 +1020,193 @@ class AirTextInquiryFlow: return "ledger_fail" sess.quote = quote copy.remember_tms_quote(sess, quote) + sess.tms_quote = dict(quote) sess.status = "已报价" + if needs_route_pick(quote): + return self._offer_air_routes( + sess=sess, + work_order_no=no, + quote=quote, + reply=reply, + ) sess.phase = "wait_collab" sess.allowed = (copy.BTN_SKIP_COLLAB,) sess.wait_version += 1 self._save(sess) - reply( - copy.tms_hit_card( - work_order_no=no, - quote=quote, - first_or_same=first, - history_work_order_no=history_wo, - status="已报价", - ), - copy.tms_hit_wecom_payload( - work_order_no=no, - quote=quote, - first_or_same=first, - history_work_order_no=history_wo, - status="已报价", - ), + self._emit_air_hit( + reply, + work_order_no=no, + quote=quote, + first_or_same=first, + history_work_order_no=history_wo, + status="已报价", ) return "wait_collab" + def _offer_air_routes( + self, + *, + sess: FlowSession, + work_order_no: str, + quote: dict[str, Any], + reply: ReplyFn, + ) -> str: + """多条线路:企微只发入口卡,选线在 H5。""" + from agent.channel.h5.air_route_store import build_air_route_url, issue_air_route_token + from agent.schema.air_route_options import parse_air_options + + token = issue_air_route_token( + sender_id=sess.sender_id, + work_order_no=work_order_no, + quote=quote, + facts=dict(sess.facts), + ) + url = build_air_route_url(token) + count = len(parse_air_options(quote)) + sess.phase = "wait_air_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_air_route" + + def select_air_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.air_route_options import ( + apply_selected_option, + parse_air_options, + project_selected_quote, + ) + + raw_quote = dict(sess.tms_quote or sess.quote or {}) + options = parse_air_options(raw_quote) + if index < 0 or index >= len(options): + reply("没有这条线路,请重新打开页面选择。") + return "route_invalid" + option = options[index] + facts = apply_selected_option(dict(sess.facts), option) + sess.facts = facts + quote = project_selected_quote(raw_quote, option) + 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) + patcher = getattr(self._ledger, "update_facts", None) + if callable(patcher): + patcher(work_order_no=sess.work_order_no, facts=facts) + self._ledger.upsert_quote(work_order_no=sess.work_order_no, quote=quote, to_status="已报价") + sess.quote = quote + copy.remember_tms_quote(sess, 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=facts, + quote=quote, + transport_mode=mode, + ), + copy.scheme_wecom_payload( + work_order_no=sess.work_order_no, + facts=facts, + quote=quote, + status="已报价", + transport_mode=mode, + ), + ) + return "wait_adopt" + sess.phase = "wait_collab" + sess.allowed = (copy.BTN_SKIP_COLLAB,) + self._save(sess) + self._emit_air_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="已报价", + ) + return "wait_collab" + + def _emit_air_hit( + self, + reply: ReplyFn, + *, + work_order_no: str, + quote: dict[str, Any], + first_or_same: str = "first", + history_work_order_no: str = "", + status: str = "已报价", + ) -> None: + """ + 先发完整价格明细文本,等它真正发出,再发带按钮的卡。 + + 企微引用条超长会截成省略号。海运已走这条;空运多项费用同样不能塞进卡。 + 只等这一条出站,不锁其它工单。 + """ + src = dict(quote or {}) + out_id = reply( + copy.sea_quote_detail_text( + work_order_no=work_order_no, + quote=src, + first_or_same=first_or_same, + history_work_order_no=history_work_order_no, + status=status, + business_line="AIR", + ) + ) or "" + self._wait_outbound_sent( + str(out_id), work_order_no=work_order_no, what="价格明细文本" + ) + gap = float(getattr(self, "_detail_before_card_gap_sec", 0.8) or 0) + if out_id and gap > 0: + time.sleep(gap) + reply( + copy.tms_hit_card( + work_order_no=work_order_no, + quote=src, + first_or_same=first_or_same, + history_work_order_no=history_work_order_no, + status=status, + ), + copy.tms_hit_wecom_payload( + work_order_no=work_order_no, + quote=src, + first_or_same=first_or_same, + history_work_order_no=history_work_order_no, + status=status, + ), + ) + def _wait_inquiry_sent(self, out_id: str, *, work_order_no: str) -> None: """等询价确认出站完成后再查价。""" self._wait_outbound_sent(out_id, work_order_no=work_order_no, what="询价确认") diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index a1df9fc..b648e4b 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -15,6 +15,7 @@ from typing import Any, Iterable from agent.schema.field_validate import ( AIR_COLLAB_FIELDS, + AIR_OPTIONAL_FIELDS, AIR_REQUIRED_FIELDS, LAND_DISPLAY, SEA_COLLAB_FIELDS, @@ -27,7 +28,7 @@ from agent.schema.land_options import option_prompt as _land_option_prompt from agent.schema.quote_adjust import total_price_label from agent.schema.sea_options import sea_class_prompt -# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。 +# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。 _AIR_EXAMPLES = { "起运港": "南京", "目的港": "吉隆坡", @@ -37,10 +38,20 @@ _AIR_EXAMPLES = { "体积": "2CBM", "包装方式": "托盘", "报价日期": "2026-09-15", + "货源地": "东莞", + "货好时间": "随时可提", + "提货地址": "东莞仓库", + "收货地址": "马尼拉", + "贸易条款": "FOB", } _AIR_CLARIFY = tuple( (key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS ) +_AIR_OPTIONAL_CLARIFY = tuple( + (key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS +) +AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" +AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。" # 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。 _SEA_EXAMPLES = { @@ -373,6 +384,46 @@ def _sea_known_value(facts: dict[str, str], key: str) -> str: return (src.get(key) or "").strip() +def _air_field_line(key: str, value: str, *, required: bool, example: str = "") -> str: + """空运销售侧一行:名称(必填/非必填):值或示例。""" + label = AIR_DISPLAY_LABEL(key) + mark = "必填" if required else "非必填" + text = (value or "").strip() + if text: + return f"{label}({mark}):{text}" + if example: + return f"{label}({mark}):(如:{example})" + return f"{label}({mark}):" + + +def AIR_DISPLAY_LABEL(key: str) -> str: + """核对/补问对外名。起运港按产品口径写起运港。""" + if key == "起运港": + return "起运港" + return display_name(key, "AIR") + + +def air_confirm_card(facts: dict[str, str] | None) -> str: + """ + 确定查价前的核对卡。没有工单号。 + + 必填 7 项 + 6 个非必填都列出;空着的非必填留空行。 + """ + src = dict(facts or {}) + lines = ["运输方式(必填):空运"] + for key, _label in AIR_REQUIRED_FIELDS: + if key == "报价日期": + val = _air_known_value(src, key) or (src.get("报价日期") or "").strip() + lines.append(_air_field_line(key, val, required=True)) + continue + lines.append(_air_field_line(key, _air_known_value(src, key), required=True)) + for key, _label in AIR_OPTIONAL_FIELDS: + lines.append(_air_field_line(key, _air_known_value(src, key), required=False)) + lines.append("") + lines.append(AIR_CONFIRM_TAIL) + return "\n".join(lines) + + def ask_clarify( *, facts: dict[str, str] | None = None, @@ -383,6 +434,7 @@ def ask_clarify( 缺项补问:先回显已识别,再列待补充并给示例。 报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。 + 空运非必填一并列出,没填进待补充,不挡查价。 """ src = dict(facts or {}) skip = {"报价日期"} @@ -398,6 +450,16 @@ def ask_clarify( val = known(src, key) if val: lines.append(f"{label}:{val}") + if not sea: + origin = known(src, "起运港") + if origin: + lines.append(f"起运地:{origin}") + for key, label, _example in _AIR_OPTIONAL_CLARIFY: + if key == "起运港": + continue + val = known(src, key) + if val: + lines.append(f"{label}:{val}") lines.append("") lines.append("待补充:") any_missing = False @@ -411,6 +473,16 @@ def ask_clarify( lines.append(sea_class_prompt()) else: lines.append(f"{label}:(如:{example})") + if not sea: + optional_pending = [ + (key, label, example) + for key, label, example in _AIR_OPTIONAL_CLARIFY + if not known(src, key) + ] + for key, label, example in optional_pending: + any_missing = True + shown = "起运港" if key == "起运港" else label + lines.append(f"{shown}(非必填):(如:{example})") if not any_missing: return "" return "\n".join(lines) @@ -861,18 +933,24 @@ def quote_area_safe_text( transport_mode: str = "", ) -> str: """ - 卡片引用条:短报价原样;超长只留总价名称,避免半截费用名。 + 卡片引用条:空运有费用行就只留总价,禁止再塞费用。 - 完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。 + 三项短费用也会被企微裁成「人工费:CNY 123...」。完整明细走文本。 + 海运/陆运仍按字数:短的原样,超长同样只留总价。 """ - full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode) - if len(full) <= _QUOTE_AREA_SAFE_CHARS: - return full 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) n = len(quote_fee_lines(src)) label = _total_label(src, business_line, transport_mode) + line = (business_line or "").upper() + mode = (transport_mode or "").strip() + is_air = line == "AIR" or mode == "空运" + if is_air and n: + return f"{label}:{total}\n共{n}项费用,详见上一条明细" + full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode) + if len(full) <= _QUOTE_AREA_SAFE_CHARS: + return full if n: return f"{label}:{total}\n共{n}项费用,详见上一条明细" return f"{label}:{total}" @@ -888,7 +966,7 @@ def sea_quote_detail_text( business_line: str = "SEA", ) -> str: """ - 海运/陆运完整价格明细文本:来源、工单号、费用行、总销售价。 + 完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。 时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。 不受卡片引用条字数限制。first_or_same 只兼容调用方。 @@ -896,13 +974,15 @@ def sea_quote_detail_text( _ = first_or_same, history_work_order_no, status src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") - return "\n".join( - [ - source, - f"{work_order_no}价格明细", - quote_area_fee_text(src, business_line=business_line or "SEA"), - ] - ) + lines = [source, f"{work_order_no}价格明细"] + if (business_line or "").upper() == "AIR": + from agent.schema.air_route_options import route_codes + + route = route_codes(None, {}, src) + if route: + lines.append(route) + lines.append(quote_area_fee_text(src, business_line=business_line or "SEA")) + return "\n".join(lines) def collab_hint(work_order_no: str) -> str: @@ -1007,6 +1087,11 @@ def tms_hit_card( src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") lines = [source, f"{work_order_no}价格明细"] + from agent.schema.air_route_options import route_codes + + route = route_codes(None, {}, src) or str(src.get("route_line") or "").strip() + if route: + lines.append(route) lines.extend( quote_detail_lines( src, @@ -1022,6 +1107,32 @@ def tms_hit_card( return "\n".join(lines) +def air_route_entry_card(*, work_order_no: str, count: int, url: str) -> dict[str, Any]: + """ + 多条线路入口卡:短文案跳转 H5,避免企微截断。 + """ + n = max(int(count or 0), 2) + fallback = "https://ai.ytd-scm.com/inquiry-form" + action_url = (url or "").strip() or fallback + return { + "msgtype": "template_card", + "template_card": { + "card_type": "text_notice", + "source": {"desc": "TMS线路报价", "desc_color": 0}, + "main_title": {"title": f"{work_order_no}已查到{n}条线路"}, + "sub_title_text": "点击查看并选择一条线路,选定后回企微看价格明细。", + "card_action": {"type": 1, "url": action_url}, + "jump_list": [{"type": 1, "title": "查看并选择线路", "url": action_url}], + }, + } + + +def air_route_entry_text(*, work_order_no: str, count: int) -> str: + """入口卡失败时的文字兜底。""" + n = max(int(count or 0), 2) + return f"{work_order_no}已查到{n}条线路,请点击卡片查看并选择。" + + def tms_hit_wecom_payload( *, work_order_no: str, @@ -1062,7 +1173,7 @@ def tms_hit_wecom_payload( "main_title": {"title": f"{work_order_no}价格明细"}, "quote_area": { "type": 0, - "quote_text": quote_area_fee_text(src, business_line="AIR"), + "quote_text": quote_area_safe_text(src, business_line="AIR"), }, "sub_title_text": meta, "task_id": card_task_id("quote", work_order_no), @@ -1675,6 +1786,13 @@ def _clip(raw: str, limit: int) -> str: return text if len(text) <= limit else text[: limit - 1] + "…" +def _air_scheme_route(quote: dict[str, Any], facts: dict[str, str]) -> str: + """方案卡航线:空运ZUH→中转→CRK。没有三字码才退回地名。""" + from agent.schema.air_route_options import scheme_route_codes + + return scheme_route_codes(quote, facts) + + def scheme_transport_mode(business_line: str = "", fallback: str = "空运") -> str: """会话业务线 → 方案卡运输方式。海运不得再写成空运。""" raw = (business_line or "").strip() @@ -1736,7 +1854,7 @@ def scheme_horizontal_rows( pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-" return [ {"keyname": "工单号", "value": _clip(work_order_no, 26)}, - {"keyname": "航线", "value": _clip(f"空运 {origin}→{dest}", 26)}, + {"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)}, @@ -1798,11 +1916,8 @@ def scheme_wecom_payload( "main_title": {"title": f"{work_order_no}报价方案"}, "quote_area": { "type": 0, - "quote_text": ( - quote_area_safe_text(src, transport_mode=transport_mode) - if (transport_mode or "").strip() in {"海运", "陆运"} - else quote_area_fee_text(src, transport_mode=transport_mode) - ), + # 空运/海运/陆运引用条一律只留总价,费用在上一条明细。 + "quote_text": quote_area_safe_text(src, transport_mode=transport_mode), }, "horizontal_content_list": scheme_horizontal_rows( work_order_no=work_order_no, diff --git a/inquiry-agent/agent/policy/quote_adjust_ops.py b/inquiry-agent/agent/policy/quote_adjust_ops.py index eb03111..a450035 100644 --- a/inquiry-agent/agent/policy/quote_adjust_ops.py +++ b/inquiry-agent/agent/policy/quote_adjust_ops.py @@ -107,6 +107,17 @@ def _emit_private_card(flow: Any, sess: Any, reply) -> None: ) return sess.allowed = (copy.BTN_SKIP_COLLAB,) + emit_air = getattr(flow, "_emit_air_hit", None) + if callable(emit_air): + emit_air( + reply, + work_order_no=no, + quote=quote, + first_or_same=first, + history_work_order_no=history, + status="已报价", + ) + return reply( copy.tms_hit_card( work_order_no=no, diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 736b324..5f96972 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -95,6 +95,12 @@ def ticket_view(ticket: object) -> dict[str, Any]: "product_names": list(getattr(ticket, "product_names", []) or []), "sales_name": str(getattr(ticket, "sales_name", "") or ""), "lost_reason": str(getattr(ticket, "lost_reason", "") or ""), + "system_exception": str(getattr(ticket, "system_exception", "") or ""), + "systemException": str(getattr(ticket, "system_exception", "") or ""), + "system_exception_reason": str(getattr(ticket, "system_exception_reason", "") or ""), + "systemExceptionReason": str(getattr(ticket, "system_exception_reason", "") or ""), + "system_exception_step": str(getattr(ticket, "system_exception_step", "") or ""), + "system_exception_payload": dict(getattr(ticket, "system_exception_payload", {}) or {}), } @@ -418,6 +424,16 @@ def hydrate_ticket(flow: Any, ticket: object) -> object: merged["collab_facts"] = collab if view.get("collab_chat_id"): merged["collab_chat_id"] = view.get("collab_chat_id") + for key in ( + "system_exception", + "systemException", + "system_exception_reason", + "systemExceptionReason", + "system_exception_step", + "system_exception_payload", + ): + if full_view.get(key) and not merged.get(key): + merged[key] = full_view.get(key) return merged diff --git a/inquiry-agent/agent/policy/system_exception.py b/inquiry-agent/agent/policy/system_exception.py index 0071c94..fc6d4f0 100644 --- a/inquiry-agent/agent/policy/system_exception.py +++ b/inquiry-agent/agent/policy/system_exception.py @@ -27,6 +27,8 @@ STEP_LLM_NO_TICKET = copy.STEP_LLM_NO_TICKET STEP_LLM = copy.STEP_LLM STEP_TMS_QUOTE = copy.STEP_TMS_QUOTE STEP_TMS_CABIN = copy.STEP_TMS_CABIN +STEP_TMS_LOCK = copy.STEP_TMS_LOCK +STEP_TMS_RELEASE = copy.STEP_TMS_RELEASE STEP_GROUP = copy.STEP_GROUP MAX_AUTO_RETRIES = 3 @@ -140,15 +142,6 @@ def _now_text(now: datetime | None) -> str: return stamp.strftime("%Y-%m-%d %H:%M:%S") -def _test_prefix() -> bool: - try: - from agent.config import get_settings - - return (get_settings().ytd_env or "").strip().lower() == "test" - except Exception: # noqa: BLE001 - return False - - def notify_app_ready() -> bool: """询价消息服务已配才推告警。""" from agent.policy.deal_outcome import notify_app_ready as deal_ready @@ -244,7 +237,6 @@ def confirm_system_exception( users = _list_notify_users(ledger) if ledger is not None else [] if ready and users: occurred = _now_text(now) - test_prefix = _test_prefix() payload = copy.system_exception_alert_payload( work_order_no=no, exception_type=event.exception_type, @@ -252,7 +244,6 @@ def confirm_system_exception( error_code=event.error_code, step=event.step, occurred_at=occurred, - test_prefix=test_prefix, ) text = copy.system_exception_alert_text( work_order_no=no, @@ -261,7 +252,6 @@ def confirm_system_exception( error_code=event.error_code, step=event.step, occurred_at=occurred, - test_prefix=test_prefix, ) sender = enqueue or _enqueue_default for row in users: @@ -626,7 +616,7 @@ def run_admin_retry(work_order_no: str, *, ledger: Any = None) -> dict[str, Any] reply("请把刚才那句话再发一次。") return {"ok": True, "step": step} - if step == STEP_TMS_CABIN: + if step in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}: return _resume_cabin_step( ledger=book, work_order_no=no, @@ -634,6 +624,7 @@ def run_admin_retry(work_order_no: str, *, ledger: Any = None) -> dict[str, Any] target=target, status=status, reply=reply, + step=step, ) return {"ok": False, "error": "unknown_step", "step": step} @@ -716,10 +707,14 @@ def _resume_tms_quote( copy.remember_tms_quote(sess, quote) flow._save(sess) if biz == "AIR": - reply( - copy.tms_hit_card(work_order_no=no, quote=quote, status="已报价"), - copy.tms_hit_wecom_payload(work_order_no=no, quote=quote, status="已报价"), - ) + emit_air = getattr(flow, "_emit_air_hit", None) + if callable(emit_air): + emit_air(reply, work_order_no=no, quote=quote, status="已报价") + else: + reply( + copy.tms_hit_card(work_order_no=no, quote=quote, status="已报价"), + copy.tms_hit_wecom_payload(work_order_no=no, quote=quote, status="已报价"), + ) return emit = getattr(flow, "_emit_sea_hit", None) if callable(emit): @@ -742,13 +737,17 @@ def _resume_cabin_step( target: str, status: str, reply: ReplyFn, + step: str = "", ) -> dict[str, Any]: """重试空运锁舱或释放:payload 里有 lock_id 就是释放。""" no = (work_order_no or "").strip() lock_id = str(extra.get("lock_id") or "").strip() - option_no = str(extra.get("option_no") or "").strip() or "AIR-OPT-01" + option_no = str(extra.get("option_no") or "").strip() instruction = str(extra.get("instruction") or "") sender = str(extra.get("sender_id") or target or "").strip() + cabin_step = STEP_TMS_RELEASE if lock_id else STEP_TMS_LOCK + if (step or "").strip() in {STEP_TMS_LOCK, STEP_TMS_RELEASE}: + cabin_step = (step or "").strip() if lock_id: releaser = getattr(ledger, "release_cabin", None) out = call_with_retries( @@ -782,10 +781,19 @@ def _resume_cabin_step( service = "空运锁舱" reason = "空运锁舱接口调用失败" if not (out or {}).get("ok"): + why = copy.air_cabin_fail_reason( + tms_reason=str((out or {}).get("failReason") or (out or {}).get("error") or ""), + flight_date=str(extra.get("flight_date") or ""), + ) + operator = str(extra.get("operator_name") or "同事").strip() or "同事" + if cabin_step == STEP_TMS_RELEASE or lock_id: + reply(copy.air_release_fail(work_order_no=no, operator=operator, reason=why)) + else: + reply(copy.air_lock_fail(work_order_no=no, operator=operator, reason=why)) confirm_system_exception( event=SystemExceptionEvent( exception_type=TYPE_TMS, - step=STEP_TMS_CABIN, + step=cabin_step, reason=reason, service=service, error_code=str((out or {}).get("failReason") or (out or {}).get("error") or "CABIN_FAIL"), @@ -796,10 +804,9 @@ def _resume_cabin_step( extra=dict(extra), ), ledger=ledger, - reply=reply, ) - return {"ok": False, "error": "still_fail", "step": STEP_TMS_CABIN} - reply(copy.system_exception_recovery(STEP_TMS_CABIN)) + return {"ok": False, "error": "still_fail", "step": cabin_step} + reply(copy.system_exception_recovery(cabin_step)) clear_system_exception(ledger=ledger, work_order_no=no) if not lock_id: exec_no = str((out or {}).get("lockId") or (out or {}).get("tmsRequestId") or "").strip() @@ -813,4 +820,4 @@ def _resume_cabin_step( reply(copy.air_lock_ok(work_order_no=no, operator="同事", option_no=option_no, exec_no=exec_no, quote_no="")) else: reply(copy.air_release_ok(work_order_no=no, operator="同事", option_no="", exec_no=str((out or {}).get("releaseId") or lock_id), quote_no="")) - return {"ok": True, "step": STEP_TMS_CABIN} + return {"ok": True, "step": cabin_step} diff --git a/inquiry-agent/agent/redis_coord/quote_file.py b/inquiry-agent/agent/redis_coord/quote_file.py index 98ff453..dbc3d2d 100644 --- a/inquiry-agent/agent/redis_coord/quote_file.py +++ b/inquiry-agent/agent/redis_coord/quote_file.py @@ -30,6 +30,19 @@ def mark_quote_file_ready(work_order_no: str) -> None: logger.exception("写报价文件就绪键失败 wo=%s", no) +def clear_quote_file_ready(work_order_no: str) -> None: + """采用后改选:清掉就绪键,避免续办再进成交跟进。""" + no = (work_order_no or "").strip() + if not no: + return + try: + client = get_redis_runtime().client + client.raw.delete(client.key(QUOTE_FILE_READY, no)) + logger.info("报价文件就绪键已清 wo=%s", no) + except Exception: + logger.exception("清报价文件就绪键失败 wo=%s", no) + + def is_quote_file_ready(work_order_no: str) -> bool: """HTTP 读:文件是否已经发出。读失败当未完成,继续请稍候。""" no = (work_order_no or "").strip() diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py new file mode 100644 index 0000000..e9759ef --- /dev/null +++ b/inquiry-agent/agent/schema/air_route_options.py @@ -0,0 +1,250 @@ +""" +空运 TMS 线路报价:从 airOptions 拆出可选线路。 + +本文件职责:判断一条还是多条、拼航线(含中转)、抽出该线路费用。 +选定后把起运港写回 facts。中转只用于展示,不写进报价单模板。 +禁止:猜没回的机场名;把贸易条款当线路;改六态。 +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from agent.schema.tms_air_query import _AIRPORTS, resolve_airport_code + +logger = logging.getLogger(__name__) + +# 三字码 → 销售侧地名;同码多个中文时取较短的常用名。 +_CODE_NAME: dict[str, str] = {} +for name, code in _AIRPORTS.items(): + prev = _CODE_NAME.get(code) + if prev is None or len(name) < len(prev): + _CODE_NAME[code] = name + + +def parse_air_options(quote: dict[str, Any] | None) -> list[dict[str, Any]]: + """把 quote.airOptions(list 或 JSON 串)收成字典列表。""" + src = dict(quote or {}) + raw = src.get("airOptions") or src.get("air_options") + if isinstance(raw, str) and raw.strip(): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + logger.warning("air.routes airOptions 不是 JSON") + return [] + if not isinstance(raw, list): + return [] + out: list[dict[str, Any]] = [] + for item in raw: + if isinstance(item, dict): + out.append(dict(item)) + return out + + +def needs_route_pick(quote: dict[str, Any] | None) -> bool: + """多于一条线路才进 H5 选。""" + return len(parse_air_options(quote)) > 1 + + +def _place_name(code: str, fallback: str = "") -> str: + """三字码能对上就用中文,否则用原文或 fallback。""" + raw = (code or "").strip() + if not raw: + return (fallback or "").strip() + mapped = _CODE_NAME.get(raw.upper()) + if mapped: + return mapped + if resolve_airport_code(raw): + return _CODE_NAME.get(resolve_airport_code(raw), raw) + return raw + + +def route_line(option: dict[str, Any] | None, facts: dict[str, str] | None = None) -> str: + """ + 销售侧航线:有中转写成 起运-中转-目的。 + 对不上字典就展示三字码,禁止编城市。 + """ + src = dict(option or {}) + facts = facts or {} + origin = _place_name( + str(src.get("originAirportCode") or src.get("origin") or ""), + str(facts.get("起运港") or ""), + ) + dest = _place_name( + str(src.get("destinationAirportCode") or src.get("destination") or ""), + str(facts.get("目的港") or ""), + ) + transfer = _place_name(str(src.get("transferAirportCode") or src.get("transfer") or "")) + bits = [x for x in (origin, transfer, dest) if x] + return "-".join(bits) + + +def _iata_code(raw: str, fallback: str = "") -> str: + """只要三字码。已是代码就用;中文能投影就投影。禁止编假码。""" + text = (raw or "").strip() + if len(text) == 3 and text.isalpha(): + return text.upper() + mapped = resolve_airport_code(text) or resolve_airport_code(fallback) + return (mapped or "").upper() + + +def route_codes( + option: dict[str, Any] | None, + facts: dict[str, str] | None = None, + quote: dict[str, Any] | None = None, +) -> str: + """ + 价格明细/方案卡用的航线三字码:有中转写成 ZUH-CAN-CRK。 + 对不上就不写假中转。 + """ + src = dict(option or {}) + if not src: + q = dict(quote or {}) + src = dict(q.get("selected_air_option") or {}) + if not src: + opts = parse_air_options(q) + src = dict(opts[0]) if opts else {} + facts = facts or {} + origin = _iata_code( + str(src.get("originAirportCode") or src.get("origin") or facts.get("originCode") or ""), + str(facts.get("起运港") or ""), + ) + dest = _iata_code( + str(src.get("destinationAirportCode") or src.get("destination") or facts.get("destinationCode") or ""), + str(facts.get("目的港") or ""), + ) + transfer = _iata_code( + str( + src.get("transferAirportCode") + or src.get("transfer") + or (quote or {}).get("transferAirportCode") + or "" + ) + ) + bits = [x for x in (origin, transfer, dest) if x] + return "-".join(bits) + + +def scheme_route_codes( + quote: dict[str, Any] | None, + facts: dict[str, str] | None = None, +) -> str: + """方案卡航线:空运ZUH→中转→CRK。没有代码则退回地名。""" + codes = route_codes(None, facts, quote) + if codes: + return "空运" + codes.replace("-", "→") + facts = facts or {} + origin = (facts.get("起运港") or "-").strip() or "-" + dest = (facts.get("目的港") or "-").strip() or "-" + return f"空运{origin}→{dest}" + + +def _fee_row(item: dict[str, Any]) -> tuple[str, str] | None: + """一条 TMS 费用。有名称才算;金额空则用单价。禁止编费目。""" + name = str( + item.get("chargeName") or item.get("feeName") or item.get("name") or "" + ).strip() + if not name: + return None + ccy = str(item.get("currency") or "").strip() + amount = item.get("amount") + if amount is None or str(amount).strip() == "": + amount = item.get("unitPrice") or item.get("unit_price") + text = str(amount or "").strip() + if ccy and text and ccy not in text: + text = f"{ccy} {text}" + return (name, text or "-") + + +def _rows_from_list(raw: Any) -> list[tuple[str, str]]: + """把 TMS 费用数组收成展示行。不是列表就当没有。""" + if isinstance(raw, str) and raw.strip(): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(raw, list): + return [] + out: list[tuple[str, str]] = [] + for item in raw: + if isinstance(item, dict): + row = _fee_row(item) + if row: + out.append(row) + return out + + +def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]: + """ + 该线路 TMS 回了哪些费用就列哪些。 + + 只读本条 departureCharges / feeItems。多条线路时,整单 fee_lines 是最低价方案, + 禁止套到没回费用的线路。仅一条线路时,整单 fee_lines 才算这条的明细。 + """ + src = dict(option or {}) + rows = _rows_from_list(src.get("departureCharges") or src.get("departure_charges")) + rows.extend(_rows_from_list(src.get("feeItems") or src.get("fee_items"))) + if rows: + return rows + q = dict(quote or {}) + options = parse_air_options(q) + if len(options) > 1: + return [] + return _rows_from_list(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems")) + + +def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> str: + """该线路总销售价。""" + src = dict(option or {}) + q = dict(quote or {}) + raw = src.get("salePrice") + if raw is None or str(raw).strip() == "": + raw = q.get("total") or q.get("salePrice") + ccy = str(src.get("currency") or q.get("currency") or "").strip() + text = str(raw or "").strip() or "-" + if ccy and text != "-" and ccy not in text: + return f"{ccy} {text}" + return text + + +def apply_selected_option(facts: dict[str, str] | None, option: dict[str, Any] | None) -> dict[str, str]: + """选定线路后写回起运港(及能对上的三字码)。改选覆盖。""" + merged = dict(facts or {}) + src = dict(option or {}) + code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper() + name = _place_name(code) + if name: + merged["起运港"] = name + if code and len(code) == 3 and code.isalpha(): + merged["originCode"] = code + dest_code = str(src.get("destinationAirportCode") or "").strip().upper() + dest_name = _place_name(dest_code, str(merged.get("目的港") or "")) + if dest_name: + merged["目的港"] = dest_name + if dest_code and len(dest_code) == 3 and dest_code.isalpha(): + merged["destinationCode"] = dest_code + return merged + + +def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any] | None) -> dict[str, Any]: + """把选中线路投影成价格卡用的 quote。""" + base = dict(quote or {}) + src = dict(option or {}) + lines = [{"name": n, "amount": a} for n, a in option_fee_lines(src, base)] + # 必须覆盖整单 fee_lines:空列表也要写回去,否则会残留别的线路空运费。 + base["fee_lines"] = lines + if not lines: + base["freight"] = "-" + base["other_fee"] = "-" + total = src.get("salePrice") + if total is not None and str(total).strip() != "": + base["total"] = str(total).strip() + if src.get("currency"): + base["currency"] = src.get("currency") + if src.get("validUntil"): + base["valid_until"] = src.get("validUntil") + base["selected_air_option"] = src + base["route_line"] = route_line(src, {}) + return base diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 7048742..d75a9b8 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -19,9 +19,8 @@ logger = logging.getLogger(__name__) _SHANGHAI = ZoneInfo("Asia/Shanghai") -# 空运询价必填 8 项:销售侧名称。内部仍用合同键。 +# 空运询价必填:起运港改为选填。销售侧名称。内部仍用合同键。 AIR_REQUIRED_FIELDS = ( - ("起运港", "起运地"), ("目的港", "目的地"), ("品名", "品名"), ("件数", "件数"), @@ -30,7 +29,19 @@ AIR_REQUIRED_FIELDS = ( ("包装方式", "包装类型"), ("报价日期", "报价日期"), ) +# 空运询价非必填:有值收下;贸易条款只记工单,不挡查价。 +AIR_OPTIONAL_FIELDS = ( + ("起运港", "起运港"), + ("货源地", "货源地"), + ("货好时间", "货好时间"), + ("提货地址", "提货地址"), + ("收货地址", "收货地址"), + ("贸易条款", "贸易条款"), +) AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS} +AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS}) +# 补问回显仍用「起运地」,与历史卡片一致。 +AIR_DISPLAY["起运港"] = "起运地" # 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。 SEA_REQUIRED_FIELDS = ( @@ -193,6 +204,17 @@ _ALIASES = { "是否为危险品": "是否为危险品", "通关口岸": "通关口岸", "通关口岸(非必填)": "通关口岸", + "货源地": "货源地", + "货好时间": "货好时间", + "提货地址": "提货地址", + "收货地址": "收货地址", + "贸易条款": "贸易条款", + "货源地(非必填)": "货源地", + "货好时间(非必填)": "货好时间", + "提货地址(非必填)": "提货地址", + "收货地址(非必填)": "收货地址", + "贸易条款(非必填)": "贸易条款", + "起运港(非必填)": "起运港", } diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py index 1b28c60..d0bb111 100644 --- a/inquiry-agent/agent/schema/tms_air_query.py +++ b/inquiry-agent/agent/schema/tms_air_query.py @@ -2,6 +2,8 @@ 空运查价参数:对照 TMS V2 /quote/v2/query 是否带全。 本文件职责:询价卡片出现前,确认销售侧空运必填能拼出查价正文。 +起运港非必填。货源地/货好时间/提货地址/收货地址有值才带出站键。 +贸易条款只留中文键给工单,禁止投影成 transportTerms。 三字码能投影就带;对不上不拦建单、不拦查价。 包装:销售原文非空即收下,禁止白名单拦补问。 禁止:猜没写过的港口;因「对不上字典」跳过 TMS 或对销售说教;改六态。 @@ -35,8 +37,7 @@ _AIRPORTS = { "北京": "PEK", } -# TMS air_packaging_type 实测只认 1/2/3。1=全部,这条航线无价; -# 2/3 能出与现网相同的标准价。托盘走 2,纸箱/木箱走 3。 +# 历史字典:只给对照,空运出站不再编成 1/2/3。 _PACKS = { "全部": "1", "托盘": "2", @@ -170,10 +171,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: missing: list[str] = [] missing_display: list[str] = [] - # 销售侧必填只看 8 项是否非空。三字码是出站投影,对不上不拿来再补问起运地/目的地。 - if not origin: - missing.append("起运港") - missing_display.append("起运地") + # 起运港改为非必填:空着仍可查价。三字码是出站投影,对不上不拿来再补问。 if not dest: missing.append("目的港") missing_display.append("目的地") @@ -204,6 +202,8 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: out_facts["originCode"] = origin_code else: out_facts.pop("originCode", None) + else: + out_facts.pop("originCode", None) if dest: out_facts["目的港"] = dest if dest_code: @@ -220,10 +220,34 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: if str(src.get("体积") or "").strip(): out_facts["体积"] = str(src.get("体积")).strip() if pack_raw: + # 包装出站跟销售原文:输入托盘就传托盘,禁止编成 2。 out_facts["包装方式"] = pack_raw - out_facts["packageType"] = pack_code + out_facts["packageType"] = pack_raw if quote_date: out_facts["报价日期"] = quote_date + # 非必填有值才投影出站键;贸易条款禁止写成 TMS 条款字段。 + source_place = str(src.get("货源地") or "").strip() + ready = str(src.get("货好时间") or "").strip() + pickup = str(src.get("提货地址") or "").strip() + delivery = str(src.get("收货地址") or "").strip() + trade = str(src.get("贸易条款") or "").strip() + if source_place: + out_facts["货源地"] = source_place + out_facts["cargoSource"] = source_place + if ready: + out_facts["货好时间"] = ready + out_facts["cargoReadyDate"] = ready + if pickup: + out_facts["提货地址"] = pickup + out_facts["placeOfReceipt"] = pickup + if delivery: + out_facts["收货地址"] = delivery + out_facts["deliveryAddress"] = delivery + if trade: + out_facts["贸易条款"] = trade + out_facts.pop("tradeTerms", None) + out_facts.pop("transportTerms", None) + out_facts.pop("trade_terms", None) logger.info( "tms.air.assemble ok=%s origin=%s dest=%s pack=%s missing=%s", diff --git a/inquiry-agent/tests/test_aibot_reply.py b/inquiry-agent/tests/test_aibot_reply.py index a8ae686..5bdf1b8 100644 --- a/inquiry-agent/tests/test_aibot_reply.py +++ b/inquiry-agent/tests/test_aibot_reply.py @@ -37,6 +37,18 @@ class AibotInboundNormalizeTests(unittest.TestCase): self.assertIn("WO202609160009", parsed.content) self.assertEqual(parsed.raw.get("source"), "aibot") + def test_keeps_aibot_req_id_for_quote_reply(self) -> None: + parsed = normalize_aibot_payload( + { + "sender_id": "air1", + "message_id": "m2", + "content": "锁舱", + "chat_id": "wr_air_1", + "aibot_req_id": "cb_lock_1", + } + ) + self.assertEqual(parsed.raw.get("aibot_req_id"), "cb_lock_1") + class AibotReplyTests(unittest.TestCase): def test_empty_content_skips(self) -> None: @@ -64,6 +76,7 @@ class AibotReplyTests(unittest.TestCase): self.assertEqual(payload["chatid"], "wr_air_1") self.assertEqual(payload["operation"], "text") self.assertEqual(payload["content"], "附件已保存") + self.assertEqual(payload.get("aibot_req_id"), "") self.assertEqual( fake_http.post.call_args.kwargs["headers"]["X-Aibot-Bridge-Token"], "tok", @@ -92,6 +105,46 @@ class AibotReplyTests(unittest.TestCase): self.assertEqual(payload["chatid"], "wr_air_1") self.assertEqual(payload["filename"], "WO1_quote.xlsx") + def test_at_reply_does_not_post_stream_payload(self) -> None: + """ + 企微 stream 回复会自带「内容由AI生成」和赞踩。 + @ 当场也只走桥主动发言,不往 response_url 推 stream。 + """ + client = AibotReplyClient("https://example.com/reply") + fake_http = MagicMock() + fake_http.post.return_value.status_code = 200 + fake_http.post.return_value.text = '{"status":"sent"}' + settings = MagicMock() + settings.wecom_aibot_bridge_url = "http://127.0.0.1:8913" + settings.wecom_aibot_bridge_token = "tok" + with patch("agent.channel.aibot.reply.httpx", fake_http), patch( + "agent.channel.aibot.reply.get_settings", return_value=settings + ): + out = client.send_group(chat_id="wr_air_1", content="工单号:WO202609210001") + self.assertTrue(out["ok"]) + payloads = [c.kwargs.get("json") or {} for c in fake_http.post.call_args_list] + self.assertFalse(any(p.get("msgtype") == "stream" for p in payloads)) + self.assertFalse( + any(isinstance(p.get("stream"), dict) for p in payloads) + ) + urls = [c.args[0] for c in fake_http.post.call_args_list] + self.assertFalse(any("example.com/reply" in str(u) for u in urls)) + + def test_at_reply_passes_callback_req_id(self) -> None: + client = AibotReplyClient("", callback_req_id="cb_lock_1") + fake_http = MagicMock() + fake_http.post.return_value.status_code = 200 + fake_http.post.return_value.text = '{"status":"sent"}' + settings = MagicMock() + settings.wecom_aibot_bridge_url = "http://127.0.0.1:8913" + settings.wecom_aibot_bridge_token = "tok" + with patch("agent.channel.aibot.reply.httpx", fake_http), patch( + "agent.channel.aibot.reply.get_settings", return_value=settings + ): + client.send_group(chat_id="wr_air_1", content="工单WO1 空运锁舱失败。") + payload = fake_http.post.call_args.kwargs["json"] + self.assertEqual(payload["aibot_req_id"], "cb_lock_1") + def test_at_reply_puts_brief_on_same_channel_as_file(self) -> None: """ @ 当场流式回话群里经常只看得见后面的文件。 diff --git a/inquiry-agent/tests/test_air_field_contract.py b/inquiry-agent/tests/test_air_field_contract.py new file mode 100644 index 0000000..3df85d8 --- /dev/null +++ b/inquiry-agent/tests/test_air_field_contract.py @@ -0,0 +1,169 @@ +""" +空运询价字段合同:起运港非必填,5 个新非必填,贸易条款不进出站。 +""" + +from __future__ import annotations + +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) + +os.environ.setdefault("YTD_ENV", "test") + +from agent.ledger.memory_ledger import MemoryLedger +from agent.policy import inquiry_copy as copy +from agent.policy.air_text_flow import AirTextInquiryFlow +from agent.schema.field_validate import validate_required_fields +from agent.schema.tms_air_query import assemble_air_query + + +class AirFieldContractTests(unittest.TestCase): + def test_origin_optional_still_ok(self) -> None: + result = validate_required_fields( + facts={ + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘", + }, + business_line="AIR", + ) + self.assertTrue(result["ok"]) + self.assertNotIn("起运港", result["missing"]) + + def test_dest_still_required(self) -> None: + result = validate_required_fields( + facts={ + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘", + }, + business_line="AIR", + ) + self.assertFalse(result["ok"]) + self.assertIn("目的港", result["missing"]) + + def test_assemble_without_origin_ok_and_keeps_optional(self) -> None: + out = assemble_air_query( + { + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10件", + "毛重": "100KGS", + "体积": "2CBM", + "包装方式": "托盘", + "报价日期": "2026-09-22", + "货源地": "东莞", + "货好时间": "随时", + "提货地址": "东莞仓库", + "收货地址": "马尼拉", + "贸易条款": "FOB", + } + ) + self.assertTrue(out["ok"]) + self.assertNotIn("起运港", out["missing"]) + facts = out["facts"] + self.assertEqual(facts["货源地"], "东莞") + self.assertEqual(facts["货好时间"], "随时") + self.assertEqual(facts["提货地址"], "东莞仓库") + self.assertEqual(facts["收货地址"], "马尼拉") + self.assertEqual(facts["贸易条款"], "FOB") + self.assertNotIn("tradeTerms", facts) + self.assertNotIn("transportTerms", facts) + + def test_confirm_card_lists_optional_and_has_no_work_order(self) -> None: + text = copy.air_confirm_card( + { + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘", + "报价日期": "2026-09-22", + } + ) + self.assertNotIn("工单号", text) + self.assertIn("目的地(必填):克拉克", text) + self.assertIn("起运港(非必填):", text) + self.assertIn("货源地(非必填):", text) + self.assertIn("贸易条款(非必填):", text) + self.assertIn(copy.AIR_CONFIRM_TAIL, text) + + def test_empty_origin_confirm_then_quote(self) -> None: + flow = AirTextInquiryFlow(MemoryLedger()) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + phase = flow.on_text( + sender_id="u_no_pol", + text="空运", + reply=reply, + injected_facts={ + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘", + }, + injected_mode="AIR", + ) + self.assertEqual(phase, "wait_confirm") + self.assertFalse(flow.session_of("u_no_pol").work_order_no) + self.assertIn(copy.AIR_CONFIRM_TAIL, replies[-1]) + phase = flow.on_text(sender_id="u_no_pol", text="确定", reply=reply) + self.assertEqual(phase, "wait_collab") + self.assertTrue(flow.session_of("u_no_pol").work_order_no) + + def test_assemble_keeps_raw_package_not_code(self) -> None: + """包装出站跟销售原文,禁止编成 1/2/3。""" + out = assemble_air_query( + { + "起运港": "珠海", + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10件", + "毛重": "100KGS", + "体积": "2CBM", + "包装方式": "托盘", + "报价日期": "2026-09-22", + } + ) + self.assertTrue(out["ok"]) + facts = out["facts"] + self.assertEqual(facts["包装方式"], "托盘") + self.assertEqual(facts["packageType"], "托盘") + self.assertEqual(facts["originCode"], "ZUH") + self.assertEqual(facts["destinationCode"], "CRK") + self.assertNotIn("origin", facts) + self.assertNotIn("destination", facts) + + def test_assemble_missing_dest_still_blocks(self) -> None: + out = assemble_air_query( + { + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘", + "报价日期": "2026-09-22", + } + ) + self.assertFalse(out["ok"]) + self.assertIn("目的港", out["missing"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 87044c3..66bc377 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -79,7 +79,7 @@ class AirGroupCollabTests(unittest.TestCase): ] ) - def _create_air(self, *, sender="sales1", quote=None, status="已报价") -> str: + def _create_air(self, *, sender="sales1", quote=None, status="已报价", quote_date="2026-09-16") -> str: created = self.ledger.create_ticket( sender_id=sender, business_line="AIR", @@ -91,7 +91,7 @@ class AirGroupCollabTests(unittest.TestCase): "毛重": "100", "体积": "2", "包装类型": "纸箱", - "报价日期": "2026-09-16", + "报价日期": quote_date, }, ) wo = created["work_order_no"] @@ -122,6 +122,25 @@ class AirGroupCollabTests(unittest.TestCase): def _texts(self) -> list[str]: return [x.get("content") or "" for x in self.groups.sent] + def test_attach_bot_client_keeps_req_id_for_quote(self) -> None: + """这次 @ 的 req_id 要带给桥,群里才能引用原消息。""" + from agent.channel.aibot.reply import AibotReplyClient + from agent.handlers.air_group_collab import _attach_bot_client + + engine = AirTextInquiryFlow(ledger=self.ledger, group_client=None) + msg = InboundMessage( + sender_id="air1", + message_id="m_quote", + content="锁舱", + chat_id="wr_air_1", + chat_type="group", + raw={"source": "aibot", "aibot_req_id": "cb_lock_1"}, + ) + _attach_bot_client(engine, msg) + client = engine.group_client() + self.assertIsInstance(client, AibotReplyClient) + self.assertEqual(client._callback_req_id, "cb_lock_1") + def test_intent_lock_is_not_hardcoded(self) -> None: self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK) self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE) @@ -495,6 +514,7 @@ class AirGroupCollabTests(unittest.TestCase): ) self.assertEqual(phase, "lock_ok") self.assertTrue(any("执行成功" in t for t in self._texts())) + self.assertTrue(any("舱位选项:AIR-OPT-01" in t for t in self._texts())) self.assertFalse(any("还没有可锁的 TMS 报价" in t for t in self._texts())) def test_manual_quote_without_quote_id_can_lock(self) -> None: @@ -884,6 +904,113 @@ class AirGroupCollabTests(unittest.TestCase): ) self.assertEqual(phase, "not_air") + def test_lock_fail_tells_operator_and_tms_reason(self) -> None: + """锁舱失败群里要回操作人和 TMS 原因,不能被系统异常兜底盖掉。""" + wo = self._create_air() + self.ledger._cabin_lock_result = { + "ok": False, + "error": "lock_fail", + "failReason": "该航班日期不存在可锁定的配载记录", + } + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_fail", mid="lf1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "system_exception") + joined = "\n".join(self._texts()) + self.assertIn(f"工单{wo} 空运锁舱失败。", joined) + self.assertIn("操作人:HangXian", joined) + self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) + self.assertIn("已通知IT运维排查,请稍等。", joined) + self.assertNotIn("TMS接口异常,IT运维排查中", joined) + self.assertNotIn("报价选项不存在", joined) + self.assertNotIn("与工单不匹配", joined) + + def test_lock_without_quote_still_shows_tms_reason(self) -> None: + """没有报价也可以锁舱;群里只回 TMS 原因,不要说选项不匹配。""" + wo = self._create_air(quote={}, status="询价中") + self.ledger._cabin_lock_result = { + "ok": False, + "error": "lock_fail", + "failReason": "该航班日期不存在可锁定的配载记录", + } + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_no_quote", mid="lnq1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "system_exception") + joined = "\n".join(self._texts()) + self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) + self.assertNotIn("报价选项不存在", joined) + self.assertNotIn("与工单不匹配", joined) + + def test_paused_lock_retries_and_shows_tms_reason(self) -> None: + """上次锁舱失败标了系统异常后,再锁仍要打 TMS,不能只回兜底。""" + wo = self._create_air() + self.ledger.mark_system_exception( + work_order_no=wo, + system_exception="TMS", + system_exception_reason="空运锁舱接口调用失败", + step="tms_lock", + payload={}, + ) + hits = {"n": 0} + orig = self.ledger.lock_cabin + + def counted(**kwargs): + hits["n"] += 1 + return orig(**kwargs) + + self.ledger.lock_cabin = counted # type: ignore[method-assign] + self.ledger._cabin_lock_result = { + "ok": False, + "error": "lock_fail", + "failReason": "该航班日期不存在可锁定的配载记录", + } + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_paused", mid="lp1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertNotEqual(phase, "system_exception_paused") + self.assertGreaterEqual(hits["n"], 1) + joined = "\n".join(self._texts()) + self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) + self.assertNotIn("TMS接口异常,IT运维排查中", joined) + + def test_release_fail_tells_operator_and_tms_reason(self) -> None: + """释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_rl_fail", mid="rf0"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.ledger._cabin_release_result = { + "ok": False, + "error": "release_fail", + "failReason": "舱位不存在", + } + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_rl_fail", mid="rf1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_release", + ) + self.assertEqual(phase, "system_exception") + joined = "\n".join(self._texts()) + self.assertIn(f"工单{wo} 空运释放舱位失败。", joined) + self.assertIn("操作人:HangXian", joined) + self.assertIn("释放舱位失败的原因:舱位不存在", joined) + self.assertIn("已通知IT运维排查,请稍等。", joined) + self.assertNotIn("TMS接口异常,IT运维排查中", joined) + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py new file mode 100644 index 0000000..bae459d --- /dev/null +++ b/inquiry-agent/tests/test_air_route_options.py @@ -0,0 +1,260 @@ +""" +空运线路报价:一条直接展示,多条要选;航线带中转;费用取该线路回包。 +""" + +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.ledger.memory_ledger import MemoryLedger +from agent.policy import inquiry_copy as copy +from agent.policy.air_text_flow import AirTextInquiryFlow +from agent.channel.h5.air_route_page import render_air_route_page +from agent.schema.air_route_options import ( + apply_selected_option, + needs_route_pick, + option_fee_lines, + parse_air_options, + project_selected_quote, + route_codes, + route_line, +) + + +class AirRouteOptionsTests(unittest.TestCase): + def test_single_with_transfer_line(self) -> None: + quote = { + "airOptions": [ + { + "originAirportCode": "CAN", + "transferAirportCode": "SHA", + "destinationAirportCode": "LAX", + "salePrice": 110, + "currency": "CNY", + "departureCharges": [ + {"chargeName": "运输费", "currency": "CNY", "unitPrice": 110} + ], + } + ] + } + opts = parse_air_options(quote) + self.assertEqual(len(opts), 1) + self.assertFalse(needs_route_pick(quote)) + self.assertEqual(route_line(opts[0], {}), "广州-上海-洛杉矶") + self.assertEqual(route_codes(opts[0], {}), "CAN-SHA-LAX") + fees = option_fee_lines(opts[0], quote) + self.assertEqual(fees[0][0], "运输费") + self.assertIn("110", fees[0][1]) + + def test_multi_needs_pick(self) -> None: + quote = { + "isMulti": "2", + "airOptions": [ + {"originAirportCode": "ZUH", "transferAirportCode": "COC", "destinationAirportCode": "CRK", "salePrice": 183370}, + {"originAirportCode": "ZUH", "transferAirportCode": "ABC", "destinationAirportCode": "CRK", "salePrice": 193370}, + ], + } + self.assertTrue(needs_route_pick(quote)) + self.assertEqual(len(parse_air_options(quote)), 2) + + def test_select_writes_origin(self) -> None: + option = {"originAirportCode": "SZX", "destinationAirportCode": "KUL"} + facts = apply_selected_option({"目的港": "吉隆坡"}, option) + self.assertEqual(facts["起运港"], "深圳") + self.assertEqual(facts["originCode"], "SZX") + + def test_multi_quote_opens_h5_then_select_writes_origin(self) -> None: + flow = AirTextInquiryFlow(MemoryLedger()) + replies: list[str] = [] + extras: list[dict] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + extras.append(dict(extra or {})) + + facts = { + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘", + "tms_multi": "1", + } + phase = flow.on_text( + sender_id="u_multi", + text="空运", + reply=reply, + injected_facts=facts, + injected_mode="AIR", + ) + self.assertEqual(phase, "wait_confirm") + phase = flow.on_text(sender_id="u_multi", text="确定", reply=reply) + self.assertEqual(phase, "wait_air_route") + self.assertTrue(any("已查到" in x and "条线路" in x for x in replies)) + phase = flow.select_air_route(sender_id="u_multi", index=1, reply=reply) + self.assertEqual(phase, "wait_collab") + sess = flow.session_of("u_multi") + self.assertEqual(sess.facts.get("起运港"), "深圳") + self.assertIn("SZX-CRK", "\n".join(replies[-2:])) + + def test_reselect_after_adopt_voids_old_file(self) -> None: + """采用后再改选:旧报价单作废,回到方案卡重新采用。""" + ledger = MemoryLedger() + flow = AirTextInquiryFlow(ledger) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + facts = { + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘", + "tms_multi": "1", + } + phase = flow.on_text( + sender_id="u_void", + text="空运", + reply=reply, + injected_facts=facts, + injected_mode="AIR", + ) + self.assertEqual(phase, "wait_confirm") + self.assertEqual( + flow.on_text(sender_id="u_void", text="确定", reply=reply), + "wait_air_route", + ) + self.assertEqual(flow.select_air_route(sender_id="u_void", index=0, reply=reply), "wait_collab") + self.assertEqual( + flow.on_button(sender_id="u_void", action=copy.BTN_SKIP_COLLAB, reply=reply), + "wait_adopt", + ) + self.assertEqual( + flow.on_button(sender_id="u_void", action=copy.BTN_ADOPT_EXCEL, reply=reply), + "wait_deal", + ) + wo = flow.session_of("u_void").work_order_no + self.assertTrue(ledger.get_ticket(work_order_no=wo).quote_file_ready) + phase = flow.select_air_route(sender_id="u_void", index=1, reply=reply) + self.assertEqual(phase, "wait_adopt") + self.assertFalse(ledger.get_ticket(work_order_no=wo).quote_file_ready) + view = ledger.get_for_agent(work_order_no=wo, sender_id="u_void") + self.assertEqual(view.get("wait_phase"), "wait_adopt") + self.assertEqual(flow.session_of("u_void").facts.get("起运港"), "深圳") + self.assertTrue(any("报价方案" in x or "生成Excel" in x for x in replies[-3:])) + + def test_empty_route_fees_do_not_borrow_other_quote_lines(self) -> None: + """TMS 该线路费用列表为空时,禁止套整单或其他线路的空运费。""" + quote = { + "total": "183370.00", + "freight": "183370.00", + "currency": "CNY", + "fee_lines": [ + { + "name": "空运费", + "amount": "183370.00", + "currency": "CNY", + "unit_price": "11.00", + } + ], + "airOptions": [ + { + "originAirportCode": "ZUH", + "transferAirportCode": "CHG", + "destinationAirportCode": "CRK", + "salePrice": 183370, + "currency": "CNY", + "departureCharges": [ + {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11}, + {"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6}, + {"chargeName": "人工费", "currency": "CNY", "unitPrice": 123}, + ], + }, + { + "originAirportCode": "SZX", + "transferAirportCode": "", + "destinationAirportCode": "CRK", + "salePrice": 200040, + "currency": "CNY", + "departureCharges": [], + }, + ], + } + opts = parse_air_options(quote) + zuh = option_fee_lines(opts[0], quote) + self.assertEqual([name for name, _ in zuh], ["运输费", "提送货费", "人工费"]) + szx = option_fee_lines(opts[1], quote) + self.assertEqual(szx, []) + html = render_air_route_page( + {"work_order_no": "WO202609220009", "facts": {"目的港": "克拉克"}, "quote": quote} + ) + self.assertIn("运输费", html) + self.assertIn("深圳-克拉克", html) + self.assertNotIn("空运费", html) + self.assertIn("200040", html) + self.assertIn("本线路未返回费用明细", html) + self.assertIn("直达", html) + self.assertIn("中转", html) + projected = project_selected_quote(quote, opts[1]) + self.assertEqual(projected.get("fee_lines"), []) + self.assertIn("200040", str(projected.get("total") or "")) + self.assertEqual(copy.quote_fee_lines(projected), []) + detail = copy.sea_quote_detail_text( + work_order_no="WO202609220009", + quote=projected, + business_line="AIR", + ) + self.assertNotIn("空运费", detail) + self.assertIn("总销售价", detail) + self.assertIn("200040", detail) + + 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 + + html = render_air_route_page( + { + "work_order_no": "WO202609220001", + "facts": {"目的港": "克拉克"}, + "quote": { + "airOptions": [ + { + "originAirportCode": "ZUH", + "transferAirportCode": "CAN", + "destinationAirportCode": "CRK", + "salePrice": 183370, + "currency": "CNY", + "departureCharges": [ + {"chargeName": "运输费", "currency": "CNY", "unitPrice": 180000} + ], + } + ] + }, + } + ) + self.assertIn("珠海-广州-克拉克", html) + self.assertIn("运输费", html) + self.assertIn("总销售价", html) + self.assertIn("class='opt'", html) + self.assertIn("name='index'", html) + self.assertNotIn("userid", html.lower()) + self.assertNotIn("sender_id", html) + url = build_air_route_url("tok-abc") + self.assertIn("/air-routes/tok-abc", url) + self.assertNotIn("userid", url) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 46c5b5b..9269665 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -32,6 +32,14 @@ from agent.policy.air_text_flow import AirTextInquiryFlow from agent.schema.field_validate import validate_required_fields +def _drive_until_quoted(flow, *, sender_id: str, reply, **kwargs) -> str: + """必填齐时先出核对卡,再自动回确定。""" + phase = flow.on_text(sender_id=sender_id, reply=reply, **kwargs) + if phase == "wait_confirm": + phase = flow.on_text(sender_id=sender_id, text="确定", reply=reply) + return phase + + COMPLETE_AIR = { "起运港": "上海", "目的港": "洛杉矶", @@ -56,6 +64,19 @@ class AirTextInquiryTests(unittest.TestCase): self.replies.append(text) self.extras.append(dict(extra) if extra else {}) + def _on_text(self, **kwargs): + """单测默认:核对卡出现后自动回确定,对齐销售点确定后的旧断言。""" + reply = kwargs.get("reply") or self._reply + kwargs["reply"] = reply + phase = self.flow.on_text(**kwargs) + if phase == "wait_confirm": + phase = self.flow.on_text( + sender_id=kwargs.get("sender_id"), + text="确定", + reply=reply, + ) + return phase + def test_clarify_shows_known_and_examples(self) -> None: text = copy.ask_clarify( facts={ @@ -69,24 +90,17 @@ class AirTextInquiryTests(unittest.TestCase): missing_keys=["毛重", "体积", "包装方式"], transport_mode="空运", ) - self.assertEqual( - text, - "\n".join( - [ - "请确认并补充以下信息:", - "运输方式:空运", - "起运地:珠海", - "目的地:克拉克", - "品名:茶叶", - "件数:10件", - "", - "待补充:", - "重量:(如:100KGS)", - "体积:(如:2CBM)", - "包装类型:(如:托盘)", - ] - ), - ) + self.assertIn("请确认并补充以下信息:", text) + self.assertIn("运输方式:空运", text) + self.assertIn("起运地:珠海", text) + self.assertIn("目的地:克拉克", text) + self.assertIn("品名:茶叶", text) + self.assertIn("件数:10件", text) + self.assertIn("待补充:", text) + self.assertIn("重量:(如:100KGS)", text) + self.assertIn("体积:(如:2CBM)", text) + self.assertIn("包装类型:(如:托盘)", text) + self.assertIn("货源地(非必填)", text) def test_inquiry_card_matches_sales_format(self) -> None: text = copy.inquiry_card( @@ -130,7 +144,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotIn("报价日期", result["missing"]) def test_fly_is_air_and_followup_keeps_mode(self) -> None: - phase = self.flow.on_text( + phase = self._on_text( sender_id="u_fly", text="从南京飞吉隆坡,9月14日茶叶,托盘,4.2cbm,1.5KG", reply=self._reply, @@ -139,7 +153,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("运输方式:空运", self.replies[-1]) self.assertNotIn("请补充运输方式", self.replies[-1]) self.replies.clear() - phase = self.flow.on_text( + phase = self._on_text( sender_id="u_fly", text="10件", reply=self._reply, @@ -177,7 +191,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(sess.facts.get("包装方式"), "托盘") def test_missing_mode_asks_transport(self) -> None: - phase = self.flow.on_text( + phase = self._on_text( sender_id="u1", text="帮我看看价格", reply=self._reply, @@ -190,7 +204,7 @@ class AirTextInquiryTests(unittest.TestCase): def test_pack_bulk_cargo_accepted_as_user_said(self) -> None: facts = dict(COMPLETE_AIR) facts["包装方式"] = "散货" - phase = self.flow.on_text( + phase = self._on_text( sender_id="u_bulk", text="空运", reply=self._reply, @@ -204,7 +218,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(self.flow.session_of("u_bulk").facts["包装方式"], "散货") def test_oral_kgs_cbm_not_asked_again(self) -> None: - phase = self.flow.on_text( + phase = self._on_text( sender_id="u_kgs", text="空运 茶叶 10件 100KGS 2CBM", reply=self._reply, @@ -222,10 +236,10 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(sess.facts["体积"], "2CBM") self.assertEqual(sess.facts["originCode"], "ZUH") self.assertEqual(sess.facts["destinationCode"], "CRK") - self.assertEqual(sess.facts["packageType"], "2") + self.assertEqual(sess.facts["packageType"], "托盘") def test_missing_fields_prompt(self) -> None: - phase = self.flow.on_text( + phase = self._on_text( sender_id="u1", text="空运询价", reply=self._reply, @@ -245,7 +259,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotIn("报价日期:(如", last) def test_complete_air_hit_then_skip_adopt_excel_deal(self) -> None: - phase = self.flow.on_text( + phase = self._on_text( sender_id="u1", text="空运完整", reply=self._reply, @@ -299,7 +313,10 @@ class AirTextInquiryTests(unittest.TestCase): ] self.assertEqual(len(scheme_cards), 1) card = scheme_cards[0]["template_card"] - self.assertIn("空运费:USD 1,200", card["quote_area"]["quote_text"]) + self.assertEqual( + card["quote_area"]["quote_text"], + "总销售价:USD 1,280\n共2项费用,详见上一条明细", + ) self.assertEqual(len(card["button_list"]), 3) self.assertEqual( [x["text"] for x in card["button_list"]], @@ -371,7 +388,7 @@ class AirTextInquiryTests(unittest.TestCase): "包装方式": "散货", "报价日期": "2026-09-16", } - phase = self.flow.on_text( + phase = self._on_text( sender_id="u_nkg", text="南京飞吉隆坡,9月16日茶叶,散货,4.2cbm,1.5KG,10件", reply=self._reply, @@ -414,7 +431,8 @@ class AirTextInquiryTests(unittest.TestCase): facts["起运港"] = "阿拉伯" facts["目的港"] = "广州" flow = AirTextInquiryFlow(_Spy()) - phase = flow.on_text( + phase = _drive_until_quoted( + flow, sender_id="u_arab", text="空运", reply=self._reply, @@ -439,7 +457,7 @@ class AirTextInquiryTests(unittest.TestCase): def test_unknown_airport_no_card(self) -> None: facts = dict(COMPLETE_AIR) facts["目的港"] = "歧义港" - phase = self.flow.on_text( + phase = self._on_text( sender_id="u2c", text="空运", reply=self._reply, @@ -454,7 +472,7 @@ class AirTextInquiryTests(unittest.TestCase): def test_tms_miss_copy(self) -> None: facts = dict(COMPLETE_AIR) facts["tms_force"] = "1002" - phase = self.flow.on_text( + phase = self._on_text( sender_id="u2", text="空运", reply=self._reply, @@ -535,7 +553,8 @@ class AirTextInquiryTests(unittest.TestCase): try: with patch("agent.channel.queue.get_message_store", return_value=store): flow = AirTextInquiryFlow(_Led()) - phase = flow.on_text( + phase = _drive_until_quoted( + flow, sender_id="u_ord", text="空运", reply=reply, @@ -566,7 +585,8 @@ class AirTextInquiryTests(unittest.TestCase): } flow = AirTextInquiryFlow(_Skip()) - phase = flow.on_text( + phase = _drive_until_quoted( + flow, sender_id="u_skip", text="空运", reply=self._reply, @@ -580,7 +600,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotIn("查价参数尚未齐全", joined) def test_same_content_second_ticket(self) -> None: - self.flow.on_text( + self._on_text( sender_id="u3", text="第一次", reply=self._reply, @@ -589,7 +609,7 @@ class AirTextInquiryTests(unittest.TestCase): ) no1 = self.flow.session_of("u3").work_order_no self.replies.clear() - self.flow.on_text( + self._on_text( sender_id="u4", text="第二次相同", reply=self._reply, @@ -606,7 +626,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotEqual(no1, no2) def test_reject_then_adjust_prompt(self) -> None: - self.flow.on_text( + self._on_text( sender_id="u5", text="空运", reply=self._reply, @@ -619,7 +639,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("提货费加 10%", self.replies[-1]) def test_private_adjust_percent_refreshes_card(self) -> None: - self.flow.on_text( + self._on_text( sender_id="u-adj", text="空运", reply=self._reply, @@ -627,7 +647,7 @@ class AirTextInquiryTests(unittest.TestCase): injected_mode="AIR", ) self.replies.clear() - phase = self.flow.on_text( + phase = self._on_text( sender_id="u-adj", text="空运费加 10%", reply=self._reply, @@ -647,7 +667,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("1320", scheme) def test_adopt_pdf(self) -> None: - self.flow.on_text( + self._on_text( sender_id="u6", text="空运", reply=self._reply, @@ -676,6 +696,17 @@ class AirTextInquiryTests(unittest.TestCase): flow=self.flow, store=store, ) + if phase == "wait_confirm": + phase = handle_text_inquiry( + InboundMessage( + sender_id="u7", + message_id="m7b", + content="确定", + msg_type="text", + ), + flow=self.flow, + store=store, + ) self.assertEqual(phase, "wait_collab") pending = [i for i in store._outbox.values()] self.assertGreaterEqual(len(pending), 2) @@ -684,7 +715,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertFalse(any("【询价卡片】" in i.content for i in pending)) def test_wrong_button_discarded(self) -> None: - self.flow.on_text( + self._on_text( sender_id="u8", text="空运", reply=self._reply, @@ -705,7 +736,8 @@ class AirTextInquiryTests(unittest.TestCase): flow = AirTextInquiryFlow(_FailCreate()) replies: list[str] = [] - phase = flow.on_text( + phase = _drive_until_quoted( + flow, sender_id="u9", text="空运", reply=replies.append, @@ -775,8 +807,10 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotIn("horizontal_content_list", card) quote_text = card["quote_area"]["quote_text"] self.assertNotIn("title", card["quote_area"]) - self.assertIn("空运费:USD 1,200", quote_text) - self.assertIn("总销售价:USD 1,280", quote_text) + self.assertEqual( + quote_text, + "总销售价:USD 1,280\n共2项费用,详见上一条明细", + ) self.assertNotIn("时效:", quote_text) numeric = copy.tms_hit_wecom_payload( work_order_no="WO202609140048", @@ -788,8 +822,10 @@ class AirTextInquiryTests(unittest.TestCase): "fee_lines": [{"name": "空运费", "amount": "14669.60", "currency": "CNY"}], }, )["template_card"]["quote_area"]["quote_text"] - self.assertIn("空运费:CNY 14669.60", numeric) - self.assertIn("总销售价:CNY 14669.60", numeric) + self.assertEqual( + numeric, + "总销售价:CNY 14669.60\n共1项费用,详见上一条明细", + ) self.assertEqual(copy.money_text("14669.60", ""), "14669.60") self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text) sub = card["sub_title_text"] @@ -801,6 +837,132 @@ class AirTextInquiryTests(unittest.TestCase): self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001"))) self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False)) + def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None: + """空运多项费用跟海运一样:完整明细走文本,卡片引用条不得半截。""" + long_quote = { + "total": "142998.53", + "currency": "CNY", + "eta": "3天", + "valid_until": "2026-09-30", + "source_label": "TMS标准报价", + "fee_lines": [ + {"name": "操作费", "amount": "1220.00", "currency": "CNY"}, + {"name": "箱单费", "amount": "50.00", "currency": "CNY"}, + {"name": "文件费", "amount": "450.00", "currency": "CNY"}, + {"name": "电放费", "amount": "100.00", "currency": "CNY"}, + {"name": "更改费", "amount": "450.00", "currency": "CNY"}, + {"name": "空运费", "amount": "1250.00", "currency": "USD"}, + {"name": "目的港操作费", "amount": "240.00", "currency": "USD"}, + ], + } + text = copy.sea_quote_detail_text( + work_order_no="WO202609220005", + quote=long_quote, + business_line="AIR", + ) + self.assertIn("TMS标准报价", text) + self.assertIn("WO202609220005价格明细", text) + self.assertIn("操作费:CNY 1220.00", text) + self.assertIn("空运费:USD 1250.00", text) + self.assertIn("总销售价:CNY 142998.53", text) + self.assertNotIn("时效", text) + card_text = copy.quote_area_safe_text(long_quote, business_line="AIR") + self.assertIn("总销售价:CNY 142998.53", card_text) + self.assertIn("详见上一条明细", card_text) + self.assertNotIn("操作费:CNY 1220.00", card_text) + payload = copy.tms_hit_wecom_payload( + work_order_no="WO202609220005", quote=long_quote + ) + self.assertEqual( + payload["template_card"]["quote_area"]["quote_text"], card_text + ) + + def test_air_quote_area_always_summary_and_transfer_on_detail_and_scheme(self) -> None: + """ + 空运引用条即使只有 3 项也不能塞费用,企微仍会裁成半截。 + 明细和方案卡航线必须带中转三字码。 + """ + quote = { + "total": "183370", + "currency": "CNY", + "source_label": "TMS标准报价", + "selected_air_option": { + "originAirportCode": "ZUH", + "transferAirportCode": "CAN", + "destinationAirportCode": "CRK", + }, + "fee_lines": [ + {"name": "运输费", "amount": "11.0", "currency": "CNY"}, + {"name": "提送货费", "amount": "6.0", "currency": "CNY"}, + {"name": "人工费", "amount": "123.0", "currency": "CNY"}, + ], + } + card_text = copy.quote_area_safe_text(quote, business_line="AIR") + self.assertEqual( + card_text, + "总销售价:CNY 183370\n共3项费用,详见上一条明细", + ) + payload = copy.tms_hit_wecom_payload( + work_order_no="WO202609220006", quote=quote + ) + self.assertEqual(payload["template_card"]["quote_area"]["quote_text"], card_text) + self.assertNotIn("运输费", card_text) + detail = copy.sea_quote_detail_text( + work_order_no="WO202609220006", + quote=quote, + business_line="AIR", + ) + self.assertIn("ZUH-CAN-CRK", detail) + self.assertIn("运输费:CNY 11.0", detail) + self.assertIn("总销售价:CNY 183370", detail) + rows = { + x["keyname"]: x["value"] + for x in copy.scheme_horizontal_rows( + work_order_no="WO202609220006", + facts={"起运港": "珠海", "目的港": "克拉克", "品名": "茶叶"}, + quote=quote, + transport_mode="空运", + ) + } + self.assertEqual(rows["航线"], "空运ZUH→CAN→CRK") + scheme = copy.scheme_wecom_payload( + work_order_no="WO202609220006", + facts={"起运港": "珠海", "目的港": "克拉克", "品名": "茶叶"}, + quote=quote, + transport_mode="空运", + ) + self.assertEqual( + scheme["template_card"]["quote_area"]["quote_text"], + "总销售价:CNY 183370\n共3项费用,详见上一条明细", + ) + + def test_air_hit_sends_detail_text_before_card(self) -> None: + """有价必须先发出价格明细文字,再发卡,避免卡上半截费用。""" + steps: list[str] = [] + + def reply(text: str, extra=None) -> str: + kind = "card" if extra else "text" + steps.append(kind) + return f"out-{len(steps)}" + + def wait(out_id: str, *, work_order_no: str, what: str) -> None: + steps.append(f"wait:{out_id}") + + flow = AirTextInquiryFlow(MemoryLedger()) + flow._wait_outbound_sent = wait # type: ignore[method-assign] + flow._detail_before_card_gap_sec = 0.0 + flow._emit_air_hit( + reply, + work_order_no="WO202609220005", + quote={ + "total": "142998.53", + "currency": "CNY", + "source_label": "TMS标准报价", + "fee_lines": [{"name": "操作费", "amount": "1220", "currency": "CNY"}], + }, + ) + self.assertEqual(steps[:3], ["text", "wait:out-1", "card"]) + def test_scheme_card_payload_has_quote_and_buttons(self) -> None: quote = { "total": "USD 1,280", @@ -843,13 +1005,14 @@ class AirTextInquiryTests(unittest.TestCase): ) quote_text = card["quote_area"]["quote_text"] self.assertNotIn("title", card["quote_area"]) - self.assertIn("空运费:USD 1,200", quote_text) - self.assertIn("总销售价:USD 1,280", quote_text) + self.assertEqual( + quote_text, + "总销售价:USD 1,280\n共2项费用,详见上一条明细", + ) self.assertNotIn("sub_title_text", card) rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} self.assertEqual(rows["工单号"], "WO202609140001") - self.assertIn("上海", rows["航线"]) - self.assertIn("洛杉矶", rows["航线"]) + self.assertEqual(rows["航线"], "空运SHA→LAX") self.assertIn("普货", rows["货物"]) self.assertIn("2.5", rows["货物"]) self.assertEqual(rows["时效"], "3-5 天") @@ -953,7 +1116,8 @@ class AirTextInquiryTests(unittest.TestCase): replies_a.append(text) _ = extra - flow.on_text( + _drive_until_quoted( + flow, sender_id="hist-a", text="空运", reply=_keep_a, @@ -969,7 +1133,8 @@ class AirTextInquiryTests(unittest.TestCase): replies_b.append(text) _ = extra - flow.on_text( + _drive_until_quoted( + flow, sender_id="hist-b", text="空运", reply=_keep_b, @@ -988,7 +1153,8 @@ class AirTextInquiryTests(unittest.TestCase): replies_c.append(text) _ = extra - flow.on_text( + _drive_until_quoted( + flow, sender_id="hist-c", text="空运", reply=_keep_c, @@ -1023,7 +1189,7 @@ class AirTextInquiryTests(unittest.TestCase): copy.work_order_from_card_meta(msg.raw, text=msg.content), "WO202609140099", ) - self.flow.on_text( + self._on_text( sender_id="WuJiLin", text="空运", reply=self._reply, @@ -1033,7 +1199,7 @@ class AirTextInquiryTests(unittest.TestCase): wo = self.flow.session_of("WuJiLin").work_order_no xml_live = xml.replace("WO202609140099", wo) live = parse_plain_xml(xml_live, default_agent_id="1000010") - phase = self.flow.on_text( + phase = self._on_text( sender_id="WuJiLin", text=live.content, reply=self._reply, @@ -1047,7 +1213,7 @@ class AirTextInquiryTests(unittest.TestCase): facts_a["品名"] = "茶叶" facts_b = dict(COMPLETE_AIR) facts_b["品名"] = "服装" - self.flow.on_text( + self._on_text( sender_id="card-owner", text="空运", reply=self._reply, @@ -1056,7 +1222,7 @@ class AirTextInquiryTests(unittest.TestCase): ) wo1 = self.flow.session_of("card-owner").work_order_no self.assertTrue(wo1) - self.flow.on_text( + self._on_text( sender_id="card-owner", text="空运", reply=self._reply, @@ -1068,7 +1234,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotEqual(wo1, wo2) self.replies.clear() - phase = self.flow.on_text( + phase = self._on_text( sender_id="card-owner", text=f"skip_collab:{wo1}", reply=self._reply, @@ -1096,7 +1262,8 @@ class AirTextInquiryTests(unittest.TestCase): flow = AirTextInquiryFlow(ledger) def _open(uid: str) -> str: - flow.on_text( + _drive_until_quoted( + flow, sender_id=uid, text="空运", reply=lambda *_a, **_k: None, @@ -1136,7 +1303,8 @@ class AirTextInquiryTests(unittest.TestCase): ledger = MemoryLedger() flow = AirTextInquiryFlow(ledger) replies: list[str] = [] - flow.on_text( + _drive_until_quoted( + flow, sender_id="u-tpl-0", text="空运", reply=lambda t, extra=None: replies.append(t), @@ -1181,7 +1349,8 @@ class AirTextInquiryTests(unittest.TestCase): ) flow = AirTextInquiryFlow(ledger) replies: list[str] = [] - flow.on_text( + _drive_until_quoted( + flow, sender_id="u-tpl-k", text="空运", reply=lambda t, extra=None: replies.append(t), @@ -1207,7 +1376,8 @@ class AirTextInquiryTests(unittest.TestCase): ny["目的港"] = "纽约" flow2 = AirTextInquiryFlow(ledger) replies2: list[str] = [] - flow2.on_text( + _drive_until_quoted( + flow2, sender_id="u-tpl-d", text="空运", reply=lambda t, extra=None: replies2.append(t), @@ -1244,7 +1414,8 @@ class AirTextInquiryTests(unittest.TestCase): ledger.render_quote = _render # type: ignore[method-assign] flow = AirTextInquiryFlow(ledger) replies: list[str] = [] - flow.on_text( + _drive_until_quoted( + flow, sender_id="u-pdf-q", text="空运", reply=lambda t, extra=None: replies.append(t), @@ -1287,7 +1458,8 @@ class AirTextInquiryTests(unittest.TestCase): ledger.render_quote = _render # type: ignore[method-assign] http = AirTextInquiryFlow(ledger) replies: list[str] = [] - http.on_text( + _drive_until_quoted( + http, sender_id="u-pdf-x", text="空运", reply=lambda t, extra=None: replies.append(t), @@ -1334,7 +1506,8 @@ class AirTextInquiryTests(unittest.TestCase): ledger.render_quote = _render # type: ignore[method-assign] flow = AirTextInquiryFlow(ledger) - flow.on_text( + _drive_until_quoted( + flow, sender_id="u-pdf-d", text="空运", reply=lambda t, extra=None: None, diff --git a/inquiry-agent/tests/test_continue_ticket.py b/inquiry-agent/tests/test_continue_ticket.py index 5512452..636f8ec 100644 --- a/inquiry-agent/tests/test_continue_ticket.py +++ b/inquiry-agent/tests/test_continue_ticket.py @@ -40,6 +40,18 @@ COMPLETE_AIR = { } +def _until_quoted(flow, **kwargs) -> str: + """必填齐先出核对卡,单测自动回确定再建单查价。""" + phase = flow.on_text(**kwargs) + if phase == "wait_confirm": + phase = flow.on_text( + sender_id=kwargs["sender_id"], + text="确定", + reply=kwargs["reply"], + ) + return phase + + class ContinueTicketTests(unittest.TestCase): def setUp(self) -> None: from agent.redis_coord.runtime import shutdown_redis_runtime @@ -134,7 +146,8 @@ class ContinueTicketTests(unittest.TestCase): replies.append(text) extras.append(dict(extra) if extra else {}) - flow.on_text( + _until_quoted( + flow, sender_id="u1", text="空运", reply=reply, @@ -266,7 +279,8 @@ class ContinueTicketTests(unittest.TestCase): sink.append(text) _ = extra - live.on_text( + _until_quoted( + live, sender_id="u5", text="空运", reply=reply, @@ -277,7 +291,8 @@ class ContinueTicketTests(unittest.TestCase): live.on_button(sender_id="u5", action=copy.BTN_SKIP_COLLAB, reply=reply) other = dict(COMPLETE_AIR) other["品名"] = "服装" - live.on_text( + _until_quoted( + live, sender_id="u5", text="空运", reply=reply, diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py index 96e5aae..f3b612e 100644 --- a/inquiry-agent/tests/test_extract_oral.py +++ b/inquiry-agent/tests/test_extract_oral.py @@ -99,7 +99,7 @@ class OralExtractTests(unittest.TestCase): self.assertTrue(out["ok"]) self.assertEqual(out["facts"]["originCode"], "ZUH") self.assertEqual(out["facts"]["destinationCode"], "CRK") - self.assertEqual(out["facts"]["packageType"], "2") + self.assertEqual(out["facts"]["packageType"], "托盘") self.assertEqual(out["facts"]["件数"], "10件") bulk = assemble_air_query( @@ -317,6 +317,8 @@ class OralExtractTests(unittest.TestCase): reply=reply, chat_fn=chat_fn, ) + if phase == "wait_confirm": + phase = flow.on_text(sender_id="oral1", text="确定", reply=reply) self.assertEqual(phase, "wait_collab") joined = "\n".join(replies) self.assertIn("工单号:", joined) diff --git a/inquiry-agent/tests/test_http_ledger.py b/inquiry-agent/tests/test_http_ledger.py index 0833cc5..8656bdd 100644 --- a/inquiry-agent/tests/test_http_ledger.py +++ b/inquiry-agent/tests/test_http_ledger.py @@ -12,10 +12,27 @@ if ROOT not in sys.path: os.environ.setdefault("LEDGER_BACKEND", "memory") -from agent.ledger.http_ledger import _unwrap +from agent.ledger import LedgerResponse +from agent.ledger.http_ledger import HttpLedger, _unwrap from agent.schema.fingerprint import ascii_idempotency_key, inquiry_fingerprint +class _FakeLedgerClient: + """只记路径,不打 8180。""" + + def __init__(self) -> None: + self.calls: list[tuple[str, str, dict]] = [] + self.timeout_seconds = 10 + + def request_json(self, method, path, *, json_body=None, idempotency_key=""): + self.calls.append((method, path, dict(json_body or {}))) + _ = idempotency_key + return LedgerResponse(ok=True, status_code=200, data={"success": True, "result": {"ok": True}}) + + def close(self) -> None: + return None + + class HttpLedgerParseTests(unittest.TestCase): def test_unwrap_jeecg_ok(self) -> None: inner = _unwrap({"success": True, "result": {"ok": True, "has_price": True, "classification": "HAS_PRICE"}}) @@ -45,6 +62,27 @@ class HttpLedgerParseTests(unittest.TestCase): self.assertFalse(headers.get("X-Access-Token")) client.close() + def test_update_facts_posts_agent_path(self) -> None: + fake = _FakeLedgerClient() + out = HttpLedger(client=fake).update_facts( + work_order_no="WO202609220001", facts={"起运港": "深圳"} + ) + self.assertTrue(out.get("ok")) + method, path, body = fake.calls[0] + self.assertEqual(method, "POST") + self.assertEqual(path, "/inquiry/agent/ticket/updateFacts") + self.assertEqual(body["workOrderNo"], "WO202609220001") + self.assertEqual(body["facts"]["起运港"], "深圳") + + def test_void_quote_files_posts_agent_path(self) -> None: + fake = _FakeLedgerClient() + out = HttpLedger(client=fake).void_quote_files(work_order_no="WO202609220001") + self.assertTrue(out.get("ok")) + method, path, body = fake.calls[0] + self.assertEqual(method, "POST") + self.assertEqual(path, "/inquiry/agent/ticket/voidQuoteFiles") + self.assertEqual(body["workOrderNo"], "WO202609220001") + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_system_exception.py b/inquiry-agent/tests/test_system_exception.py index d343e2a..aa7fa40 100644 --- a/inquiry-agent/tests/test_system_exception.py +++ b/inquiry-agent/tests/test_system_exception.py @@ -43,6 +43,8 @@ class TestSystemExceptionCopy(unittest.TestCase): self.assertEqual(copy.system_exception_recovery(STEP_TMS_QUOTE), copy.SYS_EXC_RECOVERY_TMS) self.assertEqual(copy.system_exception_recovery(STEP_LLM), copy.SYS_EXC_RECOVERY_LLM) self.assertEqual(copy.system_exception_recovery(STEP_TMS_CABIN), copy.SYS_EXC_RECOVERY_CABIN) + self.assertEqual(copy.system_exception_recovery(copy.STEP_TMS_LOCK), copy.SYS_EXC_RECOVERY_CABIN) + self.assertEqual(copy.system_exception_recovery(copy.STEP_TMS_RELEASE), copy.SYS_EXC_RECOVERY_CABIN) self.assertEqual(copy.system_exception_recovery(STEP_GROUP), copy.SYS_EXC_RECOVERY_GROUP) def test_impact_by_step(self) -> None: @@ -52,6 +54,99 @@ class TestSystemExceptionCopy(unittest.TestCase): self.assertEqual(copy.system_exception_impact(STEP_TMS_CABIN), copy.IMPACT_TMS_CABIN) self.assertEqual(copy.system_exception_impact(STEP_GROUP), copy.IMPACT_GROUP) + def test_cabin_impact_lock_and_release_are_separate(self) -> None: + """锁舱失败和释放失败要分开说,不能再写成「锁舱/释放暂停」。""" + self.assertEqual( + copy.system_exception_impact(copy.STEP_TMS_LOCK), + "锁舱失败,工单状态保持不变", + ) + self.assertEqual( + copy.system_exception_impact(copy.STEP_TMS_RELEASE), + "释放舱位失败,工单状态保持不变", + ) + + def test_alert_title_never_has_test_prefix(self) -> None: + """系统异常通知标题不要 [test] / 【test】。""" + payload = copy.system_exception_alert_payload( + work_order_no="WO202609210002", + exception_type=TYPE_TMS, + service="空运锁舱", + error_code="LOCK_FAIL", + step=copy.STEP_TMS_LOCK, + occurred_at="2026-09-21 16:00:00", + test_prefix=True, + ) + title = payload["template_card"]["main_title"]["title"] + self.assertEqual(title, "系统异常通知") + self.assertNotIn("[test]", title) + self.assertNotIn("【test】", title) + text = copy.system_exception_alert_text( + work_order_no="WO202609210002", + exception_type=TYPE_TMS, + service="空运锁舱", + error_code="LOCK_FAIL", + step=copy.STEP_TMS_LOCK, + occurred_at="2026-09-21 16:00:00", + test_prefix=True, + ) + self.assertTrue(text.startswith("询价机器人系统告警")) + self.assertNotIn("[test]", text) + self.assertNotIn("【test】", text) + + def test_lock_fail_copy_has_operator_reason_and_it_notice(self) -> None: + text = copy.air_lock_fail( + work_order_no="WO202609210001", + operator="吴季林", + reason="该航班日期不存在可锁定的配载记录", + ) + self.assertEqual( + text, + "工单WO202609210001 空运锁舱失败。\n" + "操作人:吴季林\n" + "锁舱失败的原因:该航班日期不存在可锁定的配载记录\n" + "已通知IT运维排查,请稍等。", + ) + + def test_release_fail_copy_has_operator_reason_and_it_notice(self) -> None: + text = copy.air_release_fail( + work_order_no="WO202609210001", + operator="吴季林", + reason="舱位不存在", + ) + self.assertEqual( + text, + "工单WO202609210001 空运释放舱位失败。\n" + "操作人:吴季林\n" + "释放舱位失败的原因:舱位不存在\n" + "已通知IT运维排查,请稍等。", + ) + + def test_cabin_fail_reason_keeps_tms_text(self) -> None: + self.assertEqual( + copy.air_cabin_fail_reason( + tms_reason="该航班日期不存在可锁定的配载记录", + flight_date="2026-09-20", + today="2026-09-21", + ), + "该航班日期不存在可锁定的配载记录", + ) + self.assertNotIn( + "报价选项不存在", + copy.air_cabin_fail_reason( + tms_reason="该航班日期不存在可锁定的配载记录", + flight_date="2026-09-20", + today="2026-09-21", + ), + ) + self.assertEqual( + copy.air_cabin_fail_reason( + tms_reason="", + flight_date="2026-09-20", + today="2026-09-21", + ), + "", + ) + class TestSystemExceptionRules(unittest.TestCase): def test_tms_no_price_is_not_tech(self) -> None: @@ -145,6 +240,9 @@ class TestConfirmSystemException(unittest.TestCase): self.assertEqual(card["main_title"]["desc"], "关联工单 无") values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]} self.assertEqual(values["影响范围"], copy.IMPACT_LLM_NO_TICKET) + self.assertEqual(card["main_title"]["title"], "系统异常通知") + self.assertNotIn("[test]", card["main_title"]["title"]) + self.assertNotIn("[test]", queued[0]["content"]) def test_mark_ticket_and_pause(self) -> None: replies: list[str] = [] @@ -171,6 +269,59 @@ class TestConfirmSystemException(unittest.TestCase): self.assertTrue(ticket_is_paused(ticket)) self.assertEqual(replies[0], copy.SYS_EXC_TMS_FALLBACK) + def test_air_lock_alert_impact_keeps_status(self) -> None: + queued: list[dict] = [] + + def enqueue(*, touser, content, dedupe_key, payload): + queued.append({"payload": payload, "content": content}) + return True, "ok" + + confirm_system_exception( + event=SystemExceptionEvent( + exception_type=TYPE_TMS, + step=copy.STEP_TMS_LOCK, + reason="空运锁舱接口调用失败", + service="空运锁舱", + error_code="LOCK_FAIL", + work_order_no=self.wo, + ticket_status="已报价", + ), + ledger=self.ledger, + reply=lambda _t: None, + enqueue=enqueue, + notify_ready=True, + ) + card = queued[0]["payload"]["template_card"] + values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]} + self.assertEqual(values["影响范围"], "锁舱失败,工单状态保持不变") + self.assertEqual(card["main_title"]["title"], "系统异常通知") + + def test_air_release_alert_impact_keeps_status(self) -> None: + queued: list[dict] = [] + + def enqueue(*, touser, content, dedupe_key, payload): + queued.append({"payload": payload}) + return True, "ok" + + confirm_system_exception( + event=SystemExceptionEvent( + exception_type=TYPE_TMS, + step=copy.STEP_TMS_RELEASE, + reason="空运释放舱位接口调用失败", + service="空运舱位", + error_code="RELEASE_FAIL", + work_order_no=self.wo, + ticket_status="已锁舱", + ), + ledger=self.ledger, + reply=lambda _t: None, + enqueue=enqueue, + notify_ready=True, + ) + card = queued[0]["payload"]["template_card"] + values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]} + self.assertEqual(values["影响范围"], "释放舱位失败,工单状态保持不变") + def test_closed_ticket_not_marked(self) -> None: self.ledger.transition(work_order_no=self.wo, to_status="已关闭") out = confirm_system_exception( 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 fd0cd29..b6a52c1 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 @@ -399,6 +399,26 @@ public class InquiryAgentController { return Result.OK(workOrderService.patchCollabFacts(body)); } + @ApiOperation("选定线路后写回询价事实(不改六态)") + @PostMapping("/ticket/updateFacts") + public Result> updateFacts(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.updateInquiryFacts(body)); + } + + @ApiOperation("采用后改选:作废旧报价单(不改六态)") + @PostMapping("/ticket/voidQuoteFiles") + public Result> voidQuoteFiles(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.voidQuoteFiles(firstStr(body, "workOrderNo", "work_order_no"))); + } + @ApiOperation("按协同群 chat_id 找回工单(只读)") @PostMapping("/ticket/byCollabChat") public Result> byCollabChat(@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 255b772..5d1feec 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 @@ -91,6 +91,16 @@ public interface IInquiryWorkOrderService extends IService { */ void markQuoteFileIssued(String workOrderNo); + /** + * 选定线路后写回询价事实(起运港等)。不改六态、不写报价版本。 + */ + Map updateInquiryFacts(Map body); + + /** + * 采用后改选:作废旧报价单。不改六态。续办回到待采用。 + */ + Map voidQuoteFiles(String workOrderNo); + /** * 记下海运协同群。不改六态。 */ 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 0da3aaa..2846c9d 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 @@ -27,7 +27,9 @@ import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper; import org.jeecg.modules.inquiry.support.AgentSystemExceptionRetryClient; import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService; import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport; +import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport; import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport; +import org.jeecg.modules.inquiry.support.InquiryTicketFactsSupport; import org.jeecg.modules.inquiry.support.InquiryTicketPriceSupport; import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService; import org.springframework.beans.factory.annotation.Autowired; @@ -858,23 +860,92 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl updateInquiryFacts(Map body) { + String no = text(body, "workOrderNo", "work_order_no"); + InquiryTicket ticket = findByWorkOrderNo(no); + Map out = new LinkedHashMap<>(); + 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", "handoff_frozen"); + return out; + } + Map facts = new LinkedHashMap<>(); + Object raw = body == null ? null : body.get("facts"); + if (raw instanceof Map) { + for (Map.Entry e : ((Map) raw).entrySet()) { + if (e.getKey() == null || e.getValue() == null) { + continue; + } + facts.put(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + } + InquiryTicketFactsSupport.apply(ticket, facts); + ticket.setUpdateTime(new Date()); + updateById(ticket); + out.put("ok", true); + out.put("work_order_no", no); + out.put("facts", InquiryTicketFactsSupport.parseFacts(ticket.getFactsJson())); + return out; + } + + /** + * 采用后改选:作废旧报价单。不改六态。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map voidQuoteFiles(String workOrderNo) { + String no = StringUtils.trimToEmpty(workOrderNo); + Map out = new LinkedHashMap<>(); + if (StringUtils.isBlank(no)) { + out.put("ok", false); + out.put("error", "work_order_required"); + return out; + } + InquiryTicket ticket = findByWorkOrderNo(no); + if (ticket == null) { + out.put("ok", false); + out.put("error", "ticket_not_found"); + return out; + } + List quotes = quoteMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, no) + .orderByDesc(InquiryQuote::getCreateTime) + .last("LIMIT 1")); + if (quotes != null && !quotes.isEmpty()) { + InquiryQuote row = quotes.get(0); + row.setPayloadJson(InquiryQuoteFileSupport.applyVoid(row.getPayloadJson())); + row.setUpdateTime(new Date()); + quoteMapper.updateById(row); + } + out.put("ok", true); + out.put("work_order_no", no); + return out; + } + /** * 续办停点:文件已发出或已有附件,就不再回到出方案/协同。 + * 作废优先:改选后即使还有旧附件也不进成交跟进。 */ private boolean quoteFileAlreadyIssued(String workOrderNo, Map quote) { - Object flag = quote == null ? null : quote.get("quoteFileIssued"); - if (Boolean.TRUE.equals(flag) || "true".equalsIgnoreCase(String.valueOf(flag))) { - return true; - } + String payload = quote == null ? "" : JSON.toJSONString(quote); Long n = attachmentMapper.selectCount(new LambdaQueryWrapper() .eq(InquiryAttachment::getWorkOrderNo, workOrderNo)); - return n != null && n > 0; + return InquiryQuoteFileSupport.isIssued(payload, n != null && n > 0); } /** diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteFileSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteFileSupport.java new file mode 100644 index 0000000..caa2d49 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteFileSupport.java @@ -0,0 +1,66 @@ +package org.jeecg.modules.inquiry.support; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import org.apache.commons.lang3.StringUtils; + +/** + * 报价单发出 / 作废标记。 + *

+ * 职责:只改报价载荷里的 quoteFileIssued / quoteFileVoided,不改六态。 + * 副作用:无。调用方写回 inq_quote.payload_json。 + * 为何:采用后改选必须作废旧单,续办不能再走进成交跟进。 + */ +public final class InquiryQuoteFileSupport { + + public static final String ISSUED = "quoteFileIssued"; + public static final String VOIDED = "quoteFileVoided"; + + private InquiryQuoteFileSupport() { + } + + /** + * 作废:清发出标记,写下作废。有附件也不能再当有效报价单。 + */ + public static String applyVoid(String payloadJson) { + JSONObject payload = parse(payloadJson); + payload.put(ISSUED, false); + payload.put(VOIDED, true); + return payload.toJSONString(); + } + + /** + * 重新采用:打发出标记,去掉作废。 + */ + public static String markIssued(String payloadJson) { + JSONObject payload = parse(payloadJson); + payload.put(ISSUED, true); + payload.remove(VOIDED); + return payload.toJSONString(); + } + + /** + * 续办是否按「文件已发出」进成交跟进。 + * 作废优先:即使库里还有附件也不算有效报价单。 + */ + public static boolean isIssued(String payloadJson, boolean hasAttachments) { + JSONObject payload = parse(payloadJson); + if (Boolean.TRUE.equals(payload.getBoolean(VOIDED)) + || "true".equalsIgnoreCase(String.valueOf(payload.get(VOIDED)))) { + return false; + } + Object flag = payload.get(ISSUED); + if (Boolean.TRUE.equals(flag) || "true".equalsIgnoreCase(String.valueOf(flag))) { + return true; + } + return hasAttachments; + } + + private static JSONObject parse(String payloadJson) { + if (StringUtils.isBlank(payloadJson)) { + return new JSONObject(true); + } + JSONObject parsed = JSON.parseObject(payloadJson); + return parsed == null ? new JSONObject(true) : parsed; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java new file mode 100644 index 0000000..b2ee8e1 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java @@ -0,0 +1,157 @@ +package org.jeecg.modules.inquiry.support; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.inquiry.entity.InquiryTicket; + +import java.math.BigDecimal; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 工单询价事实写回:选定线路后覆盖起运港等列。 + *

+ * 职责:合并 factsJson 与列字段。不改六态、不写报价版本。 + * 副作用:无。调用方负责 updateById。 + * 为何:H5 选线路后工单起运港必须变成所选,改选覆盖。 + */ +public final class InquiryTicketFactsSupport { + + private InquiryTicketFactsSupport() { + } + + /** + * 用销售侧内部中文键覆盖工单。空值不删已有键(改选只覆盖有值项)。 + * + * @param ticket 当前工单 + * @param facts 完整或增量 facts + */ + public static void apply(InquiryTicket ticket, Map facts) { + if (ticket == null || facts == null || facts.isEmpty()) { + return; + } + Map merged = parseFacts(ticket.getFactsJson()); + for (Map.Entry e : facts.entrySet()) { + if (e.getKey() == null || e.getValue() == null) { + continue; + } + String val = e.getValue().trim(); + if (val.isEmpty()) { + continue; + } + merged.put(e.getKey(), val); + } + ticket.setFactsJson(JSON.toJSONString(merged)); + if (StringUtils.isNotBlank(merged.get("起运港"))) { + ticket.setOrigin(merged.get("起运港")); + } + if (StringUtils.isNotBlank(merged.get("目的港"))) { + ticket.setDestination(merged.get("目的港")); + } + if (StringUtils.isNotBlank(merged.get("品名"))) { + ticket.setGoodsName(merged.get("品名")); + } + Integer pieces = parseInt(merged.get("件数")); + if (pieces != null) { + ticket.setPieces(pieces); + } + BigDecimal weight = parseDecimal(first(merged, "重量", "毛重")); + if (weight != null) { + ticket.setWeightKg(weight); + } + BigDecimal volume = parseDecimal(merged.get("体积")); + if (volume != null) { + ticket.setVolumeCbm(volume); + } + Date quoteDate = parseDate(merged.get("报价日期")); + if (quoteDate != null) { + ticket.setQuoteDate(quoteDate); + } + String origin = StringUtils.defaultString(ticket.getOrigin()); + String dest = StringUtils.defaultString(ticket.getDestination()); + if (StringUtils.isNotBlank(origin) || StringUtils.isNotBlank(dest)) { + ticket.setDemandTitle(origin + " → " + dest); + } + } + + /** + * 解开 factsJson。坏 JSON 当空。 + */ + public static Map parseFacts(String factsJson) { + Map out = new LinkedHashMap<>(); + if (StringUtils.isBlank(factsJson)) { + return out; + } + try { + JSONObject parsed = JSON.parseObject(factsJson); + if (parsed == null) { + return out; + } + for (String key : parsed.keySet()) { + if (parsed.get(key) != null) { + out.put(key, String.valueOf(parsed.get(key))); + } + } + } catch (Exception ignored) { + return out; + } + return out; + } + + private static String first(Map facts, String... keys) { + if (facts == null || keys == null) { + return null; + } + for (String key : keys) { + String val = facts.get(key); + if (StringUtils.isNotBlank(val)) { + return val; + } + } + return null; + } + + private static Integer parseInt(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String digits = raw.replaceAll("[^0-9-]", ""); + if (digits.isEmpty() || "-".equals(digits)) { + return null; + } + try { + return Integer.valueOf(digits); + } catch (NumberFormatException ex) { + return null; + } + } + + private static BigDecimal parseDecimal(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String norm = raw.replaceAll("[^0-9.\\-]", ""); + if (norm.isEmpty() || ".".equals(norm) || "-".equals(norm)) { + return null; + } + try { + return new BigDecimal(norm); + } catch (NumberFormatException ex) { + return null; + } + } + + private static Date parseDate(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + try { + return new SimpleDateFormat("yyyy-MM-dd").parse(raw.trim()); + } catch (Exception ex) { + return null; + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java index ea56717..3d1e766 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -26,8 +26,10 @@ import java.util.Map; * 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。 *

* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。 - * 锁舱必须带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。 - * 仍没有编号才补 QT-{工单号} + AIR-OPT-01 出站;不因「没 TMS 报价」本地拦住。 + * 锁舱优先带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。 + * 没有 5.2 报价也可以锁舱:TMS 锁舱这两项必填,缺了会 1001「参数格式错误」。 + * 此时补 QT-{工单号} + AIR-OPT-01,禁止拿空字段出站。 + * 有真实 TMS 编号时不准用自编号盖掉,否则会回 2006。 * 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。 * 禁止:按 userid 伪造报价;单测打真实 TMS。 */ @@ -67,18 +69,20 @@ public class InquiryTmsCabinService { } Map quote = latestQuote(no); quote = refreshLockQuote(ticket, quote); - String quoteId = resolveQuoteId(quote, no); + String quoteId = lockQuoteId(resolveQuoteId(quote, no), no); Map option = resolveOption(quote, optionNo); - if (StringUtils.isBlank(quoteId) || option == null) { - return fail("no_lock_target", "锁舱缺少报价编号或舱位选项"); + if (option == null) { + option = new LinkedHashMap<>(); } + String resolvedOption = lockOptionNo(text(option, "optionNo", "option_no")); + option.put("optionNo", resolvedOption); if (!properties.lockConfigured()) { return fail("lock_disabled", "主账未开放锁舱"); } Map req = new LinkedHashMap<>(); req.put("workOrderNo", no); req.put("quoteId", quoteId); - req.put("optionNo", text(option, "optionNo", "option_no")); + req.put("optionNo", resolvedOption); req.put("flightDate", quoteDate(ticket)); req.put("origin", firstNonBlank(text(option, "originAirportCode"), ticket.getOrigin(), fact(ticket, "起运地", "起运港"))); @@ -97,7 +101,7 @@ public class InquiryTmsCabinService { req.put("operatorUserId", text(body, "operatorUserId", "sender_id")); req.put("operatorName", text(body, "operatorName", "operator_name")); req.put("instructionText", text(body, "instructionText", "instruction_text")); - return invokeLock(ticket, req, quoteId, text(option, "optionNo", "option_no")); + return invokeLock(ticket, req, quoteId, resolvedOption); } /** @@ -140,7 +144,10 @@ public class InquiryTmsCabinService { Map tms = gateway.lockCabin(req); int code = TmsV2ResponseNormalizer.number(tms.get("code")); if (code != 1) { - return fail("lock_fail", cabinFailReason(code, tms)); + String why = cabinFailReason(code, tms, quoteDate(ticket)); + log.info("tms.lock fail wo={} quoteId={} optionNo={} code={} reason={}", + ticket.getWorkOrderNo(), quoteId, optionNo, code, why); + return fail("lock_fail", why); } Map data = tmsData(tms); String lockId = firstNonBlank(text(data, "lockId"), text(tms, "lockId")); @@ -169,7 +176,9 @@ public class InquiryTmsCabinService { Map tms = gateway.releaseCabin(req); int code = TmsV2ResponseNormalizer.number(tms.get("code")); if (code != 1) { - return fail("release_fail", cabinFailReason(code, tms)); + String why = cabinFailReason(code, tms, quoteDate(ticket)); + log.info("tms.release fail wo={} code={} reason={}", ticket.getWorkOrderNo(), code, why); + return fail("release_fail", why); } Map data = tmsData(tms); Map out = okBase(); @@ -301,18 +310,49 @@ public class InquiryTmsCabinService { } /** - * 没有 TMS 5.2 报价编号时,用工单号生成关联 ID,仍调锁舱。 + * 库里的真实 TMS 报价编号。自编 QT-工单号不算:先再查一次 5.2, + * 仍没有才在 {@link #lockQuoteId} 补占位,给无报价锁舱用。 */ static String resolveQuoteId(Map quote, String workOrderNo) { String id = text(quote, "quoteId", "quote_id"); - if (StringUtils.isNotBlank(id)) { - return id; + if (StringUtils.isBlank(id)) { + return ""; } - return StringUtils.isBlank(workOrderNo) ? "" : "QT-" + workOrderNo.trim(); + String wo = StringUtils.trimToEmpty(workOrderNo); + if (id.equalsIgnoreCase("QT-" + wo) || id.toUpperCase().startsWith("QT-WO")) { + return ""; + } + return id; } /** - * 有舱位选项按名称挑;一张都没有时补 AIR-OPT-01,让人工价也能出站。 + * 锁舱出站用的报价编号。TMS /air/cabin/lock 的 quoteId 必填,缺了回 1001。 + * 有 5.2 真编号就用真编号;没有才补 QT-{工单号},允许无报价锁舱。 + */ + static String lockQuoteId(String resolved, String workOrderNo) { + if (StringUtils.isNotBlank(resolved)) { + return resolved.trim(); + } + String wo = StringUtils.trimToEmpty(workOrderNo); + if (wo.isEmpty()) { + return ""; + } + return "QT-" + wo; + } + + /** + * 锁舱出站用的舱位选项。TMS optionNo 必填,缺了回 1001。 + * 有选项用选项;没有补 AIR-OPT-01。 + */ + static String lockOptionNo(String resolved) { + if (StringUtils.isNotBlank(resolved)) { + return resolved.trim(); + } + return "AIR-OPT-01"; + } + + /** + * 有舱位选项按名称挑;没有就补 AIR-OPT-01,无报价锁舱也要带上必填项。 */ static Map resolveOption(Map quote, String optionNo) { List> options = airOptions(quote); @@ -324,10 +364,10 @@ public class InquiryTmsCabinService { if (options.size() == 1) { return options.get(0); } - return null; + return new LinkedHashMap<>(); } Map row = new LinkedHashMap<>(); - row.put("optionNo", StringUtils.isNotBlank(optionNo) ? optionNo.trim() : "AIR-OPT-01"); + row.put("optionNo", lockOptionNo(optionNo)); String airline = text(quote, "airlineCode", "airline", "airlineName"); if (StringUtils.isNotBlank(airline)) { row.put("airlineCode", airline); @@ -417,25 +457,51 @@ public class InquiryTmsCabinService { return tms == null ? new LinkedHashMap<>() : tms; } - private static String cabinFailReason(int code, Map tms) { + /** + * 锁舱/释放失败话术:原样用 TMS 的 failReason。 + * 禁止按航班日改写成「起飞」,禁止把 2006 映射成「报价选项不存在」——没有报价也可以锁舱。 + */ + static String cabinFailReason(int code, Map tms, String flightDate) { + return cabinFailReason(code, tms, flightDate, ""); + } + + static String cabinFailReason(int code, Map tms, String flightDate, String today) { + // flightDate / today 保留调用签名,不再按日期改写 TMS 原文。 Map data = tmsData(tms); - String detail = firstNonBlank(text(data, "failReason"), text(tms, "message")); - if (code == 2006) { - return firstNonBlank(detail, "舱位选项不存在或与报价不匹配"); + String detail = firstNonBlank( + text(data, "failReason", "fail_reason"), + text(tms, "failReason", "fail_reason"), + nestedFailReason(data), + text(data, "message"), + text(tms, "message")); + if (StringUtils.isNotBlank(detail)) { + return detail; } if (code == 2007) { - return firstNonBlank(detail, "报价已过期,请重新询价"); + return "报价已过期,请重新询价"; } if (code == 2008) { - return firstNonBlank(detail, "舱位不存在"); + return "舱位不存在"; } if (code == 2009) { - return firstNonBlank(detail, "锁舱被业务规则拒绝"); + return "锁舱被业务规则拒绝"; } if (code == 1005) { - return firstNonBlank(detail, "TMS 签名失败"); + return "TMS 签名失败"; } - return firstNonBlank(detail, "TMS 业务码=" + code); + return "TMS 业务码=" + code; + } + + @SuppressWarnings("unchecked") + private static String nestedFailReason(Map data) { + if (data == null) { + return ""; + } + Object inner = data.get("data"); + if (inner instanceof Map) { + return text((Map) inner, "failReason", "fail_reason"); + } + return ""; } private static Map okBase() { 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 79548de..c3ea94d 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 @@ -81,11 +81,11 @@ public class InquiryTmsQueryService { segment.put("transportMode", line); String origin = text(facts.get("起运港")); String destination = text(facts.get("目的港")); - segment.put("origin", origin); - segment.put("destination", destination); segment.put("cargoName", text(facts.get("品名"))); if ("SEA".equals(line)) { // 海运不拦空运件数/重量/体积/包装;港口只带名称,没有港口字典也不跳过。 + segment.put("origin", origin); + segment.put("destination", destination); segment.put("cargoQty", first(facts.get("货量"), facts.get("货物数量"))); segment.put("loadType", facts.get("整柜或拼柜")); segment.put("containerSpec", facts.get("箱型箱量")); @@ -100,14 +100,24 @@ public class InquiryTmsQueryService { facts.get("tradeTerms"))); } else if ("LAND".equals(line)) { // 陆运不套空运包装/件数;分类和中港装载只在工单,mapper 不会放进出站。 + segment.put("origin", origin); + segment.put("destination", destination); segment.putAll(TmsLandSegmentMapper.build(facts)); } else { + // 空运:地名栏留空,三字码只放 originCode / destinationCode。 + segment.put("origin", ""); + segment.put("destination", ""); segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数"))); segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重"))); segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积"))); fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin))); fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination))); - fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型")))); + // 包装优先销售原文,避免旧工单 packageType=2 盖住「托盘」。 + fillPackageType(segment, text(first(facts.get("包装方式"), facts.get("包装类型"), facts.get("packageType")))); + segment.put("cargoSource", first(facts.get("cargoSource"), facts.get("货源地"))); + segment.put("cargoReadyDate", first(facts.get("cargoReadyDate"), facts.get("货好时间"))); + segment.put("placeOfReceipt", first(facts.get("placeOfReceipt"), facts.get("提货地址"))); + segment.put("deliveryAddress", first(facts.get("deliveryAddress"), facts.get("收货地址"))); } String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期")); @@ -333,12 +343,12 @@ public class InquiryTmsQueryService { ? String.valueOf(airOptions) : JSON.toJSONString(airOptions)); } + Object isMulti = first(data.get("isMulti"), data.get("is_multi"), tms.get("isMulti")); + if (isMulti != null && !"".equals(String.valueOf(isMulti))) { + quote.put("isMulti", String.valueOf(isMulti)); + } // 陆运线路段:与空运 airOptions 同级透出,供模板 routeRegions 填项目一 putLandSegments(quote, data, tms); - String isMulti = firstText(data.get("isMulti"), data.get("is_multi")); - if (StringUtils.isNotBlank(isMulti)) { - quote.put("isMulti", isMulti); - } String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg")); if (StringUtils.isNotBlank(chargeable)) { quote.put("chargeableWeightKg", chargeable); @@ -669,7 +679,10 @@ public class InquiryTmsQueryService { return raw == null || String.valueOf(raw).trim().isEmpty(); } - private static List missingAirQuery(String workOrderNo, String quoteDate, Map segment) { + /** + * 空运出站齐套检查。地名栏允许空串,禁止再拿 destination 拦查价。 + */ + static List missingAirQuery(String workOrderNo, String quoteDate, Map segment) { List missing = new ArrayList<>(); if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) { missing.add("workOrderNo"); @@ -678,7 +691,6 @@ public class InquiryTmsQueryService { missing.add("quoteDate"); } for (String key : new String[] { - "origin", "destination", "cargoName", "quantity", "weightKg", "volumeCbm", "packageType" }) { if (segment.get(key) == null || "".equals(segment.get(key))) { @@ -711,28 +723,26 @@ public class InquiryTmsQueryService { } /** - * TMS 出站要 packageType。销售原文非空即收下,不对销售做包装白名单。 - * 认得出代码用代码;未对上默认 2。不要默认 1(全部),现网航线 1 常无价、2/3 有价。 + * 空运包装出站:销售输入什么就传什么,禁止编成 1/2/3。 */ private void fillPackageType(Map segment, String raw) { + Object pkg = airPackageType(raw); + if (pkg != null) { + segment.put("packageType", pkg); + } + } + + /** + * 包装原文原样。空串不写,避免主账再编一个默认码。 + * + * @param raw 销售写的包装,例如「托盘」 + * @return 去空白后的原文;空则 null + */ + static Object airPackageType(String raw) { if (raw == null || raw.trim().isEmpty()) { - segment.put("packageType", 2); - return; - } - if (raw.matches("\\d+")) { - segment.put("packageType", Integer.valueOf(raw)); - return; - } - TmsExactProjector.Projection hit = projector.resolvePackage(raw); - if (!hit.ok()) { - segment.put("packageType", 2); - return; - } - try { - segment.put("packageType", Integer.valueOf(hit.code)); - } catch (NumberFormatException ex) { - segment.put("packageType", hit.code); + return null; } + return raw.trim(); } private static Map tech(String message) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java index 3e9446b..10d1f33 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java @@ -12,7 +12,8 @@ import java.util.UUID; *

* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。 * 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。 - * quoteId / optionNo 由主账补齐:有 TMS 报价用原值,没有则 QT-工单号 + AIR-OPT-01。 + * quoteId / optionNo 必填:有 TMS 报价用真编号;没有时由 CabinService 补 QT-{工单号} / AIR-OPT-01。 + * mapper 仍跳过空字符串,避免把 null 写成 JSON。 */ public final class TmsCabinRequestMapper { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java index 6ac2159..48f691c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java @@ -55,28 +55,43 @@ public final class TmsV2RequestMapper { } List> segments = maps(internal.get("segments")); List> external = new ArrayList<>(); + Object parentMode = first(internal, "transportMode"); for (int i = 0; i < segments.size(); i++) { - external.add(segment(segments.get(i), i + 1)); + external.add(segment(segments.get(i), i + 1, parentMode)); } result.put("segments", external); return compact(result); } - private static Map segment(Map source, int fallbackSequence) { + private static Map segment(Map source, int fallbackSequence, Object parentMode) { Map target = new LinkedHashMap<>(); Object sequence = first(source, "sequence", "segmentSequence"); target.put("sequence", sequence == null ? fallbackSequence : asInteger(sequence)); - put(target, "transportMode", first(source, "transportMode")); - put(target, "origin", first(source, "origin")); - put(target, "originCode", first(source, "originCode")); - put(target, "destination", first(source, "destination")); - put(target, "destinationCode", first(source, "destinationCode")); + Object mode = first(source, "transportMode"); + if (mode == null) { + mode = parentMode; + } + put(target, "transportMode", mode); + if ("AIR".equalsIgnoreCase(String.valueOf(mode == null ? "" : mode))) { + // 空运地名栏必须空串,三字码只放 originCode / destinationCode。 + target.put("origin", ""); + target.put("destination", ""); + put(target, "originCode", first(source, "originCode")); + put(target, "destinationCode", first(source, "destinationCode")); + } else { + put(target, "origin", first(source, "origin")); + put(target, "originCode", first(source, "originCode")); + put(target, "destination", first(source, "destination")); + put(target, "destinationCode", first(source, "destinationCode")); + } put(target, "cargoName", first(source, "cargoName", "goodsName")); // 5.2:quantity=int,weightKg/volumeCbm=decimal>0,禁止带单位字符串 put(target, "quantity", asInteger(first(source, "quantity", "pieces"))); put(target, "weightKg", asPositiveDecimal(first(source, "weightKg"))); put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm"))); put(target, "packageType", first(source, "packageType", "packagingType")); + // 空运选填:有值才带。贸易条款不进出站,只给后台模板关键词匹配。 + applyAirOptionalFields(source, target); // 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL, // cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。 applySeaFields(source, target); @@ -84,6 +99,17 @@ public final class TmsV2RequestMapper { return compact(target); } + /** + * 空运选填出站。货源地/货好时间/提货/收货有值才写。 + * 禁止把贸易条款写成 transportTerms。 + */ + private static void applyAirOptionalFields(Map source, Map target) { + put(target, "cargoSource", first(source, "cargoSource", "货源地")); + put(target, "cargoReadyDate", first(source, "cargoReadyDate", "货好时间")); + put(target, "placeOfReceipt", first(source, "placeOfReceipt", "提货地址")); + put(target, "deliveryAddress", first(source, "deliveryAddress", "收货地址")); + } + /** * 把海运口语字段写成 TMS 查询字段。空运段没有这些键则不写。 */ @@ -223,7 +249,18 @@ public final class TmsV2RequestMapper { } private static Map compact(Map value) { - value.entrySet().removeIf(e -> e.getValue() == null || "".equals(e.getValue())); + // 空运地名栏要留空串给 TMS,禁止把 origin / destination 当成「没值」删掉。 + value.entrySet().removeIf(e -> { + Object current = e.getValue(); + if (current == null) { + return true; + } + if ("".equals(current)) { + String key = String.valueOf(e.getKey()); + return !"origin".equals(key) && !"destination".equals(key); + } + return false; + }); return value; } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_role_permission_operate_cols_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_role_permission_operate_cols_v1.sql new file mode 100644 index 0000000..ef9757a --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_role_permission_operate_cols_v1.sql @@ -0,0 +1,37 @@ +-- 角色菜单授权:补齐 Jeecg sys_role_permission 标准列。 +-- 根因:inquiry_robot 建表时只留了 id/role_id/permission_id, +-- MyBatis-Plus 按实体写入 operate_date 导致「授权失败」。 +-- 库:inquiry_robot;幂等。 + +SET @has_data_rule := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'sys_role_permission' + AND column_name = 'data_rule_ids' +); +SET @sql := IF(@has_data_rule = 0, + 'ALTER TABLE sys_role_permission ADD COLUMN data_rule_ids varchar(1000) NULL COMMENT ''数据规则id'' AFTER permission_id', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has_operate_date := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'sys_role_permission' + AND column_name = 'operate_date' +); +SET @sql := IF(@has_operate_date = 0, + 'ALTER TABLE sys_role_permission ADD COLUMN operate_date datetime NULL COMMENT ''操作时间'' AFTER data_rule_ids', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has_operate_ip := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'sys_role_permission' + AND column_name = 'operate_ip' +); +SET @sql := IF(@has_operate_ip = 0, + 'ALTER TABLE sys_role_permission ADD COLUMN operate_ip varchar(45) NULL COMMENT ''操作ip'' AFTER operate_date', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_role_tenant_id_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_role_tenant_id_v1.sql new file mode 100644 index 0000000..b68138a --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_user_role_tenant_id_v1.sql @@ -0,0 +1,14 @@ +-- 账号绑定角色后列表仍显示「未绑定」:补齐 sys_user_role.tenant_id。 +-- 根因:绑定写入成功,但 queryUserRole 按 Jeecg 实体 SELECT tenant_id,测库缺列导致查询失败。 +-- 库:inquiry_robot;幂等。 + +SET @has_col := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_role' + AND column_name = 'tenant_id' +); +SET @sql := IF(@has_col = 0, + 'ALTER TABLE sys_user_role ADD COLUMN tenant_id int NULL COMMENT ''租户ID'' AFTER role_id', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupportTest.java new file mode 100644 index 0000000..9b2c8cd --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupportTest.java @@ -0,0 +1,48 @@ +package org.jeecg.modules.inquiry.support; + +import org.jeecg.modules.inquiry.entity.InquiryTicket; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 选定线路后写回询价事实:起运港覆盖工单列,不改六态。 + */ +public class InquiryTicketFactsSupportTest { + + @Test + public void applyWritesOriginAndKeepsOtherFacts() { + InquiryTicket ticket = new InquiryTicket(); + ticket.setStatus("已报价"); + ticket.setDestination("克拉克"); + ticket.setFactsJson("{\"目的港\":\"克拉克\",\"品名\":\"茶叶\"}"); + Map facts = new LinkedHashMap<>(); + facts.put("目的港", "克拉克"); + facts.put("品名", "茶叶"); + facts.put("起运港", "深圳"); + facts.put("贸易条款", "FOB"); + InquiryTicketFactsSupport.apply(ticket, facts); + assertEquals("深圳", ticket.getOrigin()); + assertEquals("克拉克", ticket.getDestination()); + assertEquals("茶叶", ticket.getGoodsName()); + assertTrue(ticket.getFactsJson().contains("\"起运港\":\"深圳\"")); + assertTrue(ticket.getFactsJson().contains("\"贸易条款\":\"FOB\"")); + assertEquals("已报价", ticket.getStatus()); + assertEquals("深圳 → 克拉克", ticket.getDemandTitle()); + } + + @Test + public void voidedQuoteIsNotIssuedEvenWithAttachments() { + String raw = InquiryQuoteFileSupport.applyVoid("{\"quoteFileIssued\":true,\"total\":\"1\"}"); + assertTrue(raw.contains("\"quoteFileVoided\":true")); + assertFalse(InquiryQuoteFileSupport.isIssued(raw, true)); + String issued = InquiryQuoteFileSupport.markIssued(raw); + assertTrue(InquiryQuoteFileSupport.isIssued(issued, false)); + assertFalse(issued.contains("quoteFileVoided")); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java index 06894bc..5c155b9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -43,17 +43,35 @@ public class InquiryTmsCabinServiceTest { } @Test - public void resolveQuoteIdWithoutTmsUsesWorkOrder() { - assertEquals("QT-WO202609160012", - InquiryTmsCabinService.resolveQuoteId(new HashMap<>(), "WO202609160012")); + public void resolveQuoteIdWithoutTmsStaysBlank() { + assertEquals("", InquiryTmsCabinService.resolveQuoteId(new HashMap<>(), "WO202609160012")); } @Test - public void resolveOptionWithoutTmsSynthesizesDefault() { + public void resolveOptionWithoutTmsUsesAirOpt01() { Map option = InquiryTmsCabinService.resolveOption(new HashMap<>(), ""); assertEquals("AIR-OPT-01", option.get("optionNo")); } + @Test + public void lockQuoteIdWithoutTmsUsesWorkOrderPlaceholder() { + assertEquals("QT-WO202609210003", + InquiryTmsCabinService.lockQuoteId("", "WO202609210003")); + } + + @Test + public void lockQuoteIdKeepsRealAirId() { + assertEquals("AIR-2094721692181016578-2094721692654972929", + InquiryTmsCabinService.lockQuoteId( + "AIR-2094721692181016578-2094721692654972929", + "WO202609160015")); + } + + @Test + public void lockOptionNoWithoutTmsUsesAirOpt01() { + assertEquals("AIR-OPT-01", InquiryTmsCabinService.lockOptionNo("")); + } + @Test public void resolveQuoteIdKeepsRealAirId() { Map quote = new HashMap<>(); @@ -63,11 +81,10 @@ public class InquiryTmsCabinServiceTest { } @Test - public void priceOnlyStillFallsBackToWorkOrder() { + public void priceOnlyDoesNotInventQuoteId() { Map onlyPrice = new HashMap<>(); onlyPrice.put("total", "11792.00"); - assertEquals("QT-WO202609160015", - InquiryTmsCabinService.resolveQuoteId(onlyPrice, "WO202609160015")); + assertEquals("", InquiryTmsCabinService.resolveQuoteId(onlyPrice, "WO202609160015")); } @Test @@ -78,4 +95,36 @@ public class InquiryTmsCabinServiceTest { quote.put("airOptions", Arrays.asList(only)); assertEquals("AIR-OPT-03", InquiryTmsCabinService.resolveOption(quote, "").get("optionNo")); } + + @Test + public void lockFailShowsTmsFailReasonNotMapped2006() { + Map tms = new HashMap<>(); + tms.put("message", "报价选项不存在或与工单不匹配"); + Map data = new HashMap<>(); + data.put("failReason", "该航班日期不存在可锁定的配载记录"); + tms.put("tmsData", data); + assertEquals("该航班日期不存在可锁定的配载记录", + InquiryTmsCabinService.cabinFailReason(2006, tms, "2026-09-20", "2026-09-21")); + } + + @Test + public void lockFailKeepsTmsMessageWhenNoFailReason() { + Map tms = new HashMap<>(); + tms.put("message", "航班已经起飞或起飞时间已到"); + assertEquals("航班已经起飞或起飞时间已到", + InquiryTmsCabinService.cabinFailReason(2006, tms, "2026-09-28", "2026-09-21")); + } + + @Test + public void fakeQtWorkOrderQuoteIdIsDropped() { + Map quote = new HashMap<>(); + quote.put("quoteId", "QT-WO202609210003"); + assertEquals("", InquiryTmsCabinService.resolveQuoteId(quote, "WO202609210003")); + } + + @Test + public void lockFail2006WithoutTextDoesNotInventQuoteMismatch() { + String why = InquiryTmsCabinService.cabinFailReason(2006, new HashMap<>(), "2026-09-20", "2026-09-21"); + assertEquals("TMS 业务码=2006", why); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java index f6ae696..1c4a040 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java @@ -66,4 +66,30 @@ public class InquiryTmsQueryServiceTest { String line = InquiryTmsQueryService.resolveTransportLine(body, new java.util.HashMap(), "LAND"); assertEquals("LAND", line); } + + @Test + public void airPackageTypeKeepsUserText() { + // 销售写托盘就传托盘,禁止编成 2。 + assertEquals("托盘", InquiryTmsQueryService.airPackageType("托盘")); + assertEquals("纸箱", InquiryTmsQueryService.airPackageType("纸箱")); + assertEquals("2", InquiryTmsQueryService.airPackageType("2")); + } + + @Test + public void airQueryAllowsEmptyPlaceNames() { + // 地名栏空串不能拦出站;三字码和包装原文齐了就要打 TMS。 + java.util.Map segment = new java.util.HashMap(); + segment.put("origin", ""); + segment.put("destination", ""); + segment.put("originCode", "ZUH"); + segment.put("destinationCode", "CRK"); + segment.put("cargoName", "茶叶"); + segment.put("quantity", 1); + segment.put("weightKg", "1.5"); + segment.put("volumeCbm", "100"); + segment.put("packageType", "托盘"); + java.util.List missing = InquiryTmsQueryService.missingAirQuery( + "WO202609220004", "2026-09-25", segment); + org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing)); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java index e6f41fc..b6bf99d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java @@ -39,6 +39,31 @@ public class TmsCabinRequestMapperTest { assertTrue(body.containsKey("requestId")); } + @Test + public void lockOmitsBlankQuoteAndOption() { + Map internal = new LinkedHashMap<>(); + internal.put("workOrderNo", "WO202609210003"); + internal.put("quoteId", ""); + internal.put("optionNo", ""); + internal.put("flightDate", "2026-09-20"); + Map body = TmsCabinRequestMapper.lock(internal); + assertEquals("WO202609210003", body.get("workOrderNo")); + assertEquals(null, body.get("quoteId")); + assertEquals(null, body.get("optionNo")); + } + + @Test + public void lockKeepsNoQuotePlaceholders() { + Map filled = new LinkedHashMap<>(); + filled.put("workOrderNo", "WO202609210003"); + filled.put("quoteId", "QT-WO202609210003"); + filled.put("optionNo", "AIR-OPT-01"); + filled.put("flightDate", "2026-09-20"); + Map sent = TmsCabinRequestMapper.lock(filled); + assertEquals("QT-WO202609210003", sent.get("quoteId")); + assertEquals("AIR-OPT-01", sent.get("optionNo")); + } + @Test public void releaseKeepsLockId() { Map internal = new LinkedHashMap<>(); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java index 1b3a92e..2bec94d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java @@ -9,6 +9,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; /** * 请求映射单测:不打真实 TMS。 @@ -156,4 +157,69 @@ public class TmsV2RequestMapperTest { assertFalse(segs.get(0).containsKey("运输分类")); assertFalse(body.containsKey("运输分类")); } + + @Test + public void airOptionalFieldsPassThroughWithoutTradeTerms() { + Map seg = new HashMap<>(); + seg.put("destination", "克拉克"); + seg.put("destinationCode", "CRK"); + seg.put("cargoName", "茶叶"); + seg.put("quantity", "10"); + seg.put("weightKg", "100"); + seg.put("volumeCbm", "2"); + seg.put("packageType", "2"); + seg.put("cargoSource", "东莞"); + seg.put("cargoReadyDate", "2026-09-22"); + seg.put("placeOfReceipt", "东莞仓库"); + seg.put("deliveryAddress", "马尼拉"); + seg.put("贸易条款", "FOB"); + seg.put("tradeTerms", "FOB"); + Map internal = new HashMap<>(); + internal.put("transportMode", "AIR"); + internal.put("quoteDate", "2026-09-22"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + Map out = segs.get(0); + assertEquals("东莞", out.get("cargoSource")); + assertEquals("2026-09-22", out.get("cargoReadyDate")); + assertEquals("东莞仓库", out.get("placeOfReceipt")); + assertEquals("马尼拉", out.get("deliveryAddress")); + assertEquals("", out.get("origin")); + assertEquals("", out.get("destination")); + assertEquals("CRK", out.get("destinationCode")); + assertFalse(out.containsKey("transportTerms")); + assertFalse(out.containsKey("tradeTerms")); + assertFalse(out.containsKey("贸易条款")); + assertNull(out.get("originCode")); + } + + @Test + public void airPlaceNamesStayEmptyCodesOnlyAndRawPackage() { + // 地名栏必须空串;三字码只放 *Code。包装原文原样,禁止编成 2。 + Map seg = new HashMap<>(); + seg.put("origin", "ZUH"); + seg.put("originCode", "ZUH"); + seg.put("destination", "CRK"); + seg.put("destinationCode", "CRK"); + seg.put("cargoName", "茶叶"); + seg.put("quantity", "1"); + seg.put("weightKg", "1.5"); + seg.put("volumeCbm", "100"); + seg.put("packageType", "托盘"); + Map internal = new HashMap<>(); + internal.put("transportMode", "AIR"); + internal.put("quoteDate", "2026-09-25"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + Map out = segs.get(0); + assertEquals("", out.get("origin")); + assertEquals("ZUH", out.get("originCode")); + assertEquals("", out.get("destination")); + assertEquals("CRK", out.get("destinationCode")); + assertEquals("托盘", out.get("packageType")); + } } diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysRoleController.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysRoleController.java index 55a477d..077cbdf 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysRoleController.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysRoleController.java @@ -142,6 +142,8 @@ public class SysRoleController { try { role.setCreateTime(new Date()); sysRoleService.save(role); + // 询价后台新增角色后需立即授权菜单,必须把雪花 id 回传前端 + result.setResult(role); result.success("添加成功!"); } catch (Exception e) { log.error(e.getMessage(), e); diff --git a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysRolePermissionServiceImpl.java b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysRolePermissionServiceImpl.java index 6103c56..e44a58f 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysRolePermissionServiceImpl.java +++ b/inquiry-api/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysRolePermissionServiceImpl.java @@ -30,15 +30,7 @@ public class SysRolePermissionServiceImpl extends ServiceImpl query = new QueryWrapper().lambda().eq(SysRolePermission::getRoleId, roleId); this.remove(query); List list = new ArrayList(); @@ -56,15 +48,13 @@ public class SysRolePermissionServiceImpl extends ServiceImpl add = getDiff(lastPermissionIds,permissionIds); if(add!=null && add.size()>0) { List list = new ArrayList(); @@ -88,11 +78,30 @@ public class SysRolePermissionServiceImpl extends ServiceImpl 0) { + ip = ip.substring(0, comma).trim(); + } + if (ip.length() > 45) { + ip = ip.substring(0, 45); + } + return ip; + } + + /** 从 diff 中找出 main 中没有的元素,供增量授权增删菜单。 */ private List getDiff(String main,String diff){ if(oConvertUtils.isEmpty(diff)) { return null; diff --git a/inquiry-backend/src/views/inquiry/role/index.vue b/inquiry-backend/src/views/inquiry/role/index.vue index 9143100..b6e8bf5 100644 --- a/inquiry-backend/src/views/inquiry/role/index.vue +++ b/inquiry-backend/src/views/inquiry/role/index.vue @@ -25,6 +25,7 @@ import { } from "/@/views/inquiry/shared/dataPermCatalog"; import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue"; import AdminModal from "/@/views/inquiry/components/AdminModal.vue"; +import { joinPermissionIds } from "/@/views/inquiry/role/rolePermission"; const rows = ref([]); const loading = ref(false); @@ -37,6 +38,8 @@ const form = reactive({ roleCode: "", status: "active" as RoleRow["status"], checkedKeys: [] as string[], + /** 打开弹窗时已授权菜单,供 saveRolePermission 增量对比 */ + lastPermissionIds: [] as string[], dataPerms: [] as string[], /** 旧后台独立项:系统异常消息通知 */ systemExceptionNotify: false, @@ -157,6 +160,7 @@ function openCreate() { roleCode: "", status: "active", checkedKeys: [], + lastPermissionIds: [], dataPerms: [], systemExceptionNotify: false, }); @@ -171,14 +175,18 @@ async function openEdit(row: RoleRow) { roleCode: row.roleCode, status: row.status, checkedKeys: [] as string[], + lastPermissionIds: [] as string[], dataPerms: routeDataPermsOnly(allPerms), systemExceptionNotify: allPerms.includes(SYSTEM_EXCEPTION_NOTIFY_KEY), }); try { const perms: any = await queryRolePermission({ roleId: row.id }); - form.checkedKeys = (Array.isArray(perms) ? perms : perms?.result || []).map(String); + const ids = (Array.isArray(perms) ? perms : perms?.result || []).map(String); + form.checkedKeys = ids; + form.lastPermissionIds = [...ids]; } catch { form.checkedKeys = []; + form.lastPermissionIds = []; } modalOpen.value = true; } @@ -201,12 +209,12 @@ async function save() { }, Boolean(editing.value), ); - const roleId = editing.value?.id || saved?.id || saved?.result?.id; - if (roleId && form.checkedKeys.length) { + const roleId = String(editing.value?.id || saved?.id || saved?.result?.id || ""); + if (roleId) { await saveRolePermission({ roleId, - permissionIds: form.checkedKeys.join(","), - lastpermissionIds: "", + permissionIds: joinPermissionIds(form.checkedKeys), + lastpermissionIds: form.lastPermissionIds.join(","), }); } message.success(editing.value ? "角色已更新" : "角色已创建"); diff --git a/inquiry-backend/src/views/inquiry/role/rolePermission.ts b/inquiry-backend/src/views/inquiry/role/rolePermission.ts new file mode 100644 index 0000000..6bcd8e3 --- /dev/null +++ b/inquiry-backend/src/views/inquiry/role/rolePermission.ts @@ -0,0 +1,21 @@ +/** + * 角色菜单权限保存辅助:统一 a-tree 勾选值,并拼成 Jeecg saveRolePermission 所需字符串。 + * 询价角色页与 Jeecg 原生 RolePermissionDrawer 对齐 lastpermissionIds 增量语义。 + */ + +/** a-tree / BasicTree 勾选结果可能是数组,或 checkStrictly 下的 { checked, halfChecked } */ +export function normalizeMenuCheckedKeys(checked: unknown): string[] { + if (Array.isArray(checked)) { + return checked.map(String).filter(Boolean); + } + if (checked && typeof checked === 'object' && 'checked' in checked) { + const box = checked as { checked?: unknown[] }; + return (box.checked || []).map(String).filter(Boolean); + } + return []; +} + +/** 提交 saveRolePermission.permissionIds */ +export function joinPermissionIds(checked: unknown): string { + return normalizeMenuCheckedKeys(checked).join(','); +} diff --git a/inquiry-backend/tests/rolePermission.spec.ts b/inquiry-backend/tests/rolePermission.spec.ts new file mode 100644 index 0000000..02cbd93 --- /dev/null +++ b/inquiry-backend/tests/rolePermission.spec.ts @@ -0,0 +1,18 @@ +import { + joinPermissionIds, + normalizeMenuCheckedKeys, +} from '/@/views/inquiry/role/rolePermission'; + +test('normalizeMenuCheckedKeys accepts flat array', () => { + expect(normalizeMenuCheckedKeys(['a', 'b'])).toEqual(['a', 'b']); +}); + +test('normalizeMenuCheckedKeys accepts checkStrictly object', () => { + expect( + normalizeMenuCheckedKeys({ checked: ['x', 'y'], halfChecked: ['p'] }), + ).toEqual(['x', 'y']); +}); + +test('joinPermissionIds builds comma list', () => { + expect(joinPermissionIds(['id1', 'id2'])).toBe('id1,id2'); +});