落地空运单聊询价字段与线路选择。

必填核对后再建单;多条线路走 H5 点选;费用只展示该线路 TMS 回包,不套用整单空运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 15:02:32 +08:00
co-authored by Cursor
parent 8afe909230
commit cfbce2fd69
53 changed files with 3470 additions and 372 deletions
+45
View File
@@ -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 };
+36 -34
View File
@@ -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 fromCaller = String(callbackReqId || "").trim();
const chatKey = String(chatId || "").trim();
const fromChat = state.lastCallback.get(chatKey) || "";
const replyReqId = pickReplyReqId({
fromCaller,
fromChat,
lastAny: state.lastAnyReq || "",
});
}
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
// 用这次 @ 的 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,
+11 -3
View File
@@ -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";
@@ -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: "" }), "");
});
@@ -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,
+24 -39
View File
@@ -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,
)
+47 -1
View File
@@ -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]:
"""
@@ -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(
"已过期",
"<div class='msg'><h1>链接已过期</h1><p>请回企微重新打开选线路卡片。</p></div>",
)
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 = (
"<div class='wrap'>"
"<header class='hero'>"
f"<p class='wo'>{wo}</p>"
"<h1>选择线路</h1>"
f"<p class='lead'>共 {count} 条线路,点选一条后确定。费用以 TMS 回包为准。</p>"
"</header>"
"<form method='post'>"
+ "".join(rows)
+ "<div class='bar'><div class='bar-inner'>"
"<button type='submit'>选定这条</button>"
"</div></div></form></div>"
"<script>(function(){var f=document.querySelector('form');if(!f)return;"
"function sync(){f.querySelectorAll('.opt').forEach(function(el){"
"el.classList.toggle('is-on',!!(el.querySelector('input')||{}).checked);});}"
"f.addEventListener('change',sync);})();</script>"
)
return _wrap("选择线路", body)
def render_air_route_ok() -> str:
"""选定成功。"""
return _wrap(
"已选定",
"<div class='msg'><h1>已选定</h1><p>请回企微查看价格明细。</p></div>",
)
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 = (
"<ul class='fees'>"
+ "".join(
f"<li><span class='n'>{html.escape(name)}</span>"
f"<span class='a'>{html.escape(amount)}</span></li>"
for name, amount in fees
)
+ "</ul>"
)
else:
fee_html = "<p class='empty'>本线路未返回费用明细</p>"
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"<span class='{tag_cls}'>{tag}</span>"]
if flight:
pills.append(f"<span class='pill'>航班 {flight}</span>")
codes_html = f"<span class='codes'>{codes}</span>" if codes else ""
return (
"<label class='opt'>"
f"<input type='radio' name='index' value='{index}' required />"
"<div class='head'><span class='tick' aria-hidden='true'></span>"
f"<div class='route'><strong>{line}</strong>{codes_html}"
f"<div class='meta'>{''.join(pills)}</div></div></div>"
f"{fee_html}"
f"<p class='total'><span>总销售价</span><b>{total}</b></p>"
"</label>"
)
def _wrap(title: str, body: str) -> str:
"""整页壳:标题、viewport、共用样式。"""
return (
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
f"<title>{html.escape(title)}</title>"
f"<style>{_PAGE_CSS}</style></head><body>"
f"{body}</body></html>"
)
@@ -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
@@ -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 ""),
)
+18
View File
@@ -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(
+49 -1
View File
@@ -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"
+14 -2
View File
@@ -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 = (
+65 -30
View File
@@ -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 {}))
+275 -24
View File
@@ -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(
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 "已报价",
),
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 "已报价",
),
)
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(
self._emit_air_hit(
reply,
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="已报价",
),
)
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="询价确认")
+136 -21
View File
@@ -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,
@@ -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,
@@ -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
+26 -19
View File
@@ -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,6 +707,10 @@ def _resume_tms_quote(
copy.remember_tms_quote(sess, quote)
flow._save(sess)
if biz == "AIR":
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="已报价"),
@@ -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}
@@ -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()
@@ -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
+24 -2
View File
@@ -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 = {
"是否为危险品": "是否为危险品",
"通关口岸": "通关口岸",
"通关口岸(非必填)": "通关口岸",
"货源地": "货源地",
"货好时间": "货好时间",
"提货地址": "提货地址",
"收货地址": "收货地址",
"贸易条款": "贸易条款",
"货源地(非必填)": "货源地",
"货好时间(非必填)": "货好时间",
"提货地址(非必填)": "提货地址",
"收货地址(非必填)": "收货地址",
"贸易条款(非必填)": "贸易条款",
"起运港(非必填)": "起运港",
}
+31 -7
View File
@@ -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",
+53
View File
@@ -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:
"""
@ 当场流式回话群里经常只看得见后面的文件。
@@ -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()
+129 -2
View File
@@ -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()
@@ -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()
+237 -64
View File
@@ -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,
+18 -3
View File
@@ -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,
+3 -1
View File
@@ -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)
+39 -1
View File
@@ -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()
@@ -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(
@@ -399,6 +399,26 @@ public class InquiryAgentController {
return Result.OK(workOrderService.patchCollabFacts(body));
}
@ApiOperation("选定线路后写回询价事实(不改六态)")
@PostMapping("/ticket/updateFacts")
public Result<Map<String, Object>> updateFacts(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.updateInquiryFacts(body));
}
@ApiOperation("采用后改选:作废旧报价单(不改六态)")
@PostMapping("/ticket/voidQuoteFiles")
public Result<Map<String, Object>> voidQuoteFiles(@RequestBody Map<String, Object> 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<Map<String, Object>> byCollabChat(@RequestBody Map<String, Object> body,
@@ -91,6 +91,16 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
*/
void markQuoteFileIssued(String workOrderNo);
/**
* 选定线路后写回询价事实(起运港等)。不改六态、不写报价版本。
*/
Map<String, Object> updateInquiryFacts(Map<String, Object> body);
/**
* 采用后改选:作废旧报价单。不改六态。续办回到待采用。
*/
Map<String, Object> voidQuoteFiles(String workOrderNo);
/**
* 记下海运协同群。不改六态。
*/
@@ -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<InquiryTicketMapper
if (payload == null) {
payload = new JSONObject(true);
}
payload.put("quoteFileIssued", true);
row.setPayloadJson(payload.toJSONString());
row.setPayloadJson(InquiryQuoteFileSupport.markIssued(payload.toJSONString()));
row.setUpdateTime(new Date());
quoteMapper.updateById(row);
}
/**
* 选定线路后写回询价事实。不改六态。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> updateInquiryFacts(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
InquiryTicket ticket = findByWorkOrderNo(no);
Map<String, Object> 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<String, String> 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<String, Object> voidQuoteFiles(String workOrderNo) {
String no = StringUtils.trimToEmpty(workOrderNo);
Map<String, Object> 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<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.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<String, Object> 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<InquiryAttachment>()
.eq(InquiryAttachment::getWorkOrderNo, workOrderNo));
return n != null && n > 0;
return InquiryQuoteFileSupport.isIssued(payload, n != null && n > 0);
}
/**
@@ -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;
/**
* 报价单发出 / 作废标记。
* <p>
* 职责:只改报价载荷里的 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;
}
}
@@ -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;
/**
* 工单询价事实写回:选定线路后覆盖起运港等列。
* <p>
* 职责:合并 factsJson 与列字段。不改六态、不写报价版本。
* 副作用:无。调用方负责 updateById。
* 为何:H5 选线路后工单起运港必须变成所选,改选覆盖。
*/
public final class InquiryTicketFactsSupport {
private InquiryTicketFactsSupport() {
}
/**
* 用销售侧内部中文键覆盖工单。空值不删已有键(改选只覆盖有值项)。
*
* @param ticket 当前工单
* @param facts 完整或增量 facts
*/
public static void apply(InquiryTicket ticket, Map<String, String> facts) {
if (ticket == null || facts == null || facts.isEmpty()) {
return;
}
Map<String, String> merged = parseFacts(ticket.getFactsJson());
for (Map.Entry<String, String> 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<String, String> parseFacts(String factsJson) {
Map<String, String> 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<String, String> 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;
}
}
}
@@ -26,8 +26,10 @@ import java.util.Map;
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
* <p>
* 锁舱按 /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<String, Object> quote = latestQuote(no);
quote = refreshLockQuote(ticket, quote);
String quoteId = resolveQuoteId(quote, no);
String quoteId = lockQuoteId(resolveQuoteId(quote, no), no);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> data = tmsData(tms);
String lockId = firstNonBlank(text(data, "lockId"), text(tms, "lockId"));
@@ -169,7 +176,9 @@ public class InquiryTmsCabinService {
Map<String, Object> 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<String, Object> data = tmsData(tms);
Map<String, Object> out = okBase();
@@ -301,18 +310,49 @@ public class InquiryTmsCabinService {
}
/**
* 没有 TMS 5.2 报价编号时,用工单号生成关联 ID,仍调锁舱。
* 库里的真实 TMS 报价编号。自编 QT-工单号不算:先再查一次 5.2,
* 仍没有才在 {@link #lockQuoteId} 补占位,给无报价锁舱用。
*/
static String resolveQuoteId(Map<String, Object> 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<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
@@ -324,10 +364,10 @@ public class InquiryTmsCabinService {
if (options.size() == 1) {
return options.get(0);
}
return null;
return new LinkedHashMap<>();
}
Map<String, Object> 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<String, Object> tms) {
/**
* 锁舱/释放失败话术:原样用 TMS 的 failReason。
* 禁止按航班日改写成「起飞」,禁止把 2006 映射成「报价选项不存在」——没有报价也可以锁舱。
*/
static String cabinFailReason(int code, Map<String, Object> tms, String flightDate) {
return cabinFailReason(code, tms, flightDate, "");
}
static String cabinFailReason(int code, Map<String, Object> tms, String flightDate, String today) {
// flightDate / today 保留调用签名,不再按日期改写 TMS 原文。
Map<String, Object> 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<String, Object> data) {
if (data == null) {
return "";
}
Object inner = data.get("data");
if (inner instanceof Map) {
return text((Map<String, Object>) inner, "failReason", "fail_reason");
}
return "";
}
private static Map<String, Object> okBase() {
@@ -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<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
/**
* 空运出站齐套检查。地名栏允许空串,禁止再拿 destination 拦查价。
*/
static List<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
List<String> 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<String, Object> 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<String, Object> tech(String message) {
@@ -12,7 +12,8 @@ import java.util.UUID;
* <p>
* 锁舱唯一键: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 {
@@ -55,28 +55,43 @@ public final class TmsV2RequestMapper {
}
List<Map<String, Object>> segments = maps(internal.get("segments"));
List<Map<String, Object>> 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<String, Object> segment(Map<String, Object> source, int fallbackSequence) {
private static Map<String, Object> segment(Map<String, Object> source, int fallbackSequence, Object parentMode) {
Map<String, Object> target = new LinkedHashMap<>();
Object sequence = first(source, "sequence", "segmentSequence");
target.put("sequence", sequence == null ? fallbackSequence : asInteger(sequence));
put(target, "transportMode", first(source, "transportMode"));
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<String, Object> source, Map<String, Object> 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<String, Object> compact(Map<String, Object> 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;
}
@@ -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;
@@ -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;
@@ -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<String, String> 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"));
}
}
@@ -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<String, Object> 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<String, Object> quote = new HashMap<>();
@@ -63,11 +81,10 @@ public class InquiryTmsCabinServiceTest {
}
@Test
public void priceOnlyStillFallsBackToWorkOrder() {
public void priceOnlyDoesNotInventQuoteId() {
Map<String, Object> 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<String, Object> tms = new HashMap<>();
tms.put("message", "报价选项不存在或与工单不匹配");
Map<String, Object> 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<String, Object> tms = new HashMap<>();
tms.put("message", "航班已经起飞或起飞时间已到");
assertEquals("航班已经起飞或起飞时间已到",
InquiryTmsCabinService.cabinFailReason(2006, tms, "2026-09-28", "2026-09-21"));
}
@Test
public void fakeQtWorkOrderQuoteIdIsDropped() {
Map<String, Object> 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);
}
}
@@ -66,4 +66,30 @@ public class InquiryTmsQueryServiceTest {
String line = InquiryTmsQueryService.resolveTransportLine(body, new java.util.HashMap<String, Object>(), "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<String, Object> segment = new java.util.HashMap<String, Object>();
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<String> missing = InquiryTmsQueryService.missingAirQuery(
"WO202609220004", "2026-09-25", segment);
org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing));
}
}
@@ -39,6 +39,31 @@ public class TmsCabinRequestMapperTest {
assertTrue(body.containsKey("requestId"));
}
@Test
public void lockOmitsBlankQuoteAndOption() {
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("workOrderNo", "WO202609210003");
internal.put("quoteId", "");
internal.put("optionNo", "");
internal.put("flightDate", "2026-09-20");
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
assertEquals("WO202609210003", body.get("workOrderNo"));
assertEquals(null, body.get("quoteId"));
assertEquals(null, body.get("optionNo"));
}
@Test
public void lockKeepsNoQuotePlaceholders() {
Map<String, Object> 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<String, Object> sent = TmsCabinRequestMapper.lock(filled);
assertEquals("QT-WO202609210003", sent.get("quoteId"));
assertEquals("AIR-OPT-01", sent.get("optionNo"));
}
@Test
public void releaseKeepsLockId() {
Map<String, Object> internal = new LinkedHashMap<>();
@@ -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<String, Object> 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<String, Object> internal = new HashMap<>();
internal.put("transportMode", "AIR");
internal.put("quoteDate", "2026-09-22");
internal.put("segments", Collections.singletonList(seg));
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
Map<String, Object> 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<String, Object> 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<String, Object> internal = new HashMap<>();
internal.put("transportMode", "AIR");
internal.put("quoteDate", "2026-09-25");
internal.put("segments", Collections.singletonList(seg));
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
Map<String, Object> 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"));
}
}
@@ -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);
@@ -30,15 +30,7 @@ public class SysRolePermissionServiceImpl extends ServiceImpl<SysRolePermissionM
@Override
public void saveRolePermission(String roleId, String permissionIds) {
String ip = "";
try {
//获取request
HttpServletRequest request = SpringContextUtils.getHttpServletRequest();
//获取IP地址
ip = IpUtils.getIpAddr(request);
} catch (Exception e) {
ip = "127.0.0.1";
}
String ip = resolveOperateIp();
LambdaQueryWrapper<SysRolePermission> query = new QueryWrapper<SysRolePermission>().lambda().eq(SysRolePermission::getRoleId, roleId);
this.remove(query);
List<SysRolePermission> list = new ArrayList<SysRolePermission>();
@@ -56,15 +48,13 @@ public class SysRolePermissionServiceImpl extends ServiceImpl<SysRolePermissionM
@Override
public void saveRolePermission(String roleId, String permissionIds, String lastPermissionIds) {
String ip = "";
try {
//获取request
HttpServletRequest request = SpringContextUtils.getHttpServletRequest();
//获取IP地址
ip = IpUtils.getIpAddr(request);
} catch (Exception e) {
ip = "127.0.0.1";
// 询价后台编辑角色时若未传 lastpermissionIds,增量 diff 会把已存在权限当新增插入导致授权失败;
// 与 Jeecg 原生抽屉不同,此处兜底走全量覆盖,保证幂等。
if (oConvertUtils.isEmpty(lastPermissionIds)) {
saveRolePermission(roleId, permissionIds);
return;
}
String ip = resolveOperateIp();
List<String> add = getDiff(lastPermissionIds,permissionIds);
if(add!=null && add.size()>0) {
List<SysRolePermission> list = new ArrayList<SysRolePermission>();
@@ -88,11 +78,30 @@ public class SysRolePermissionServiceImpl extends ServiceImpl<SysRolePermissionM
}
/**
* 从diff中找出main中没有的元素
* @param main
* @param diff
* @return
* 写入 sys_role_permission.operate_ip;反向代理可能带回超长 X-Forwarded-For,截断避免入库失败。
*/
private String resolveOperateIp() {
String ip = "127.0.0.1";
try {
HttpServletRequest request = SpringContextUtils.getHttpServletRequest();
ip = IpUtils.getIpAddr(request);
} catch (Exception e) {
ip = "127.0.0.1";
}
if (oConvertUtils.isEmpty(ip)) {
return "127.0.0.1";
}
int comma = ip.indexOf(',');
if (comma > 0) {
ip = ip.substring(0, comma).trim();
}
if (ip.length() > 45) {
ip = ip.substring(0, 45);
}
return ip;
}
/** 从 diff 中找出 main 中没有的元素,供增量授权增删菜单。 */
private List<String> getDiff(String main,String diff){
if(oConvertUtils.isEmpty(diff)) {
return null;
@@ -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<RoleRow[]>([]);
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 ? "角色已更新" : "角色已创建");
@@ -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(',');
}
@@ -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');
});