落地空运群内协同与锁舱出站。

群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 17:25:42 +08:00
co-authored by Cursor
parent 12219cacb1
commit d9029dec89
62 changed files with 6772 additions and 159 deletions
+1
View File
@@ -57,6 +57,7 @@ Thumbs.db
tmp/
temp/
spool/
inquiry-agent/var/
_trash_*/
*.zip
agent-release.zip
+8 -2
View File
@@ -13,7 +13,13 @@ curl http://127.0.0.1:8913/health
curl -X POST http://127.0.0.1:8913/forward -H "Content-Type: application/json" -d "{\"sender_id\":\"userid\",\"message_id\":\"m1\",\"content\":\"hi\",\"chat_type\":\"group\"}"
```
环境变量:`WECOM_AIBOT_INGRESS_URL`(默认 `http://127.0.0.1:8910/internal/aibot/inbound`)、`WECOM_AIBOT_BRIDGE_TOKEN`。
环境变量:`WECOM_AIBOT_INGRESS_URL`(默认 `http://127.0.0.1:8910/internal/aibot/inbound`)、`WECOM_AIBOT_BRIDGE_TOKEN`、`WECOM_AIBOT_BOT_ID`、`WECOM_AIBOT_SECRET`、`WECOM_AIBOT_WS_URL`(默认企微长连接地址)。
主动往群里说话(智能体 Worker 在发文件入账后调用):
```text
curl -X POST http://127.0.0.1:8913/send-group -H "Content-Type: application/json" -d "{\"chat_id\":\"CHATID\",\"content\":\"附件已保存\"}"
```
并存期请保持 `WECOM_AIBOT_ENABLED=false`,不要与现网 8813 同时连同一 Bot。
`long_connection.cjs`:启用时只跑帧循环/心跳壳(默认不连 Bot SDK);正式 SDK 只替换传输段。
启用后订阅长连接:入站转发智能体,出站 `/send-group` 走 `aibot_send_msg`。该群须先 @ 过小助手,企微才允许主动发言。
+463 -24
View File
@@ -1,16 +1,32 @@
/**
* AIBOT 长连接传输壳(无业务)。
* AIBOT 长连接(只传输,无工单状态)。
*
* 本文件职责:
* - 当 WECOM_AIBOT_ENABLED=true 时启动「帧循环」占位(默认只心跳日志,不连真 Bot SDK)
* - 收到已规范化的入站帧时转发到同进程 forwardToAgent(由 server.cjs 注入)
* - WECOM_AIBOT_ENABLED=true 时连 wss://openws.work.weixin.qq.com
* - 订阅后:入站 aibot_msg_callback 转发智能体;出站 /send-group 走 aibot_send_msg
* - 同一 Bot 只能一条长连接;并存期默认关,勿与现网 8813 双连
*
* 禁止:写工单/六态/Graph;并存期勿与现网 8813 双连同一 Bot。
* 正式 SDK 接入:替换 startLongConnectionLoop 内的 connect 段,保持只做传输。
* 禁止:写工单/六态/Graph。密钥只读环境变量,不打日志。
*/
"use strict";
const crypto = require("crypto");
function generateReqId(prefix) {
const timestamp = Date.now();
const random = crypto.randomBytes(4).toString("hex");
return `${prefix}_${timestamp}_${random}`;
}
function loadWs() {
try {
return require("ws");
} catch (_) {
return null;
}
}
/**
* @param {object} opts
* @param {boolean} opts.enabled
@@ -21,38 +37,461 @@ function startLongConnectionLoop(opts) {
const enabled = !!opts.enabled;
const forwardToAgent = opts.forwardToAgent;
const shouldStop = opts.shouldStop || (() => false);
const botId = String(process.env.WECOM_AIBOT_BOT_ID || "").trim();
const secret = String(process.env.WECOM_AIBOT_SECRET || "").trim();
const wsUrl =
process.env.WECOM_AIBOT_WS_URL || "wss://openws.work.weixin.qq.com";
const state = {
ws: null,
connected: false,
subscribed: false,
lastError: "",
pending: new Map(),
lastCallback: new Map(),
heartbeatTimer: null,
reconnectTimer: null,
stop: () => {},
};
function status() {
return {
enabled,
connected: state.connected,
subscribed: state.subscribed,
lastError: state.lastError,
hasCredentials: Boolean(botId && secret),
};
}
function sendWs(payload, reqId) {
return new Promise((resolve, reject) => {
if (!state.ws || state.ws.readyState !== 1 || !state.subscribed) {
const err = new Error("ws_offline");
err.code = "ws_offline";
reject(err);
return;
}
const timer = setTimeout(() => {
state.pending.delete(reqId);
const err = new Error("send_timeout");
err.code = "send_timeout";
reject(err);
}, 8000);
state.pending.set(reqId, { resolve, reject, timer });
state.ws.send(JSON.stringify(payload));
});
}
function sendGroupMessage({ chatId, content }) {
const text = String(content || "");
const callbackReqId = state.lastCallback.get(String(chatId || "").trim()) || "";
if (callbackReqId) {
return sendWs(
{
cmd: "aibot_respond_msg",
headers: { req_id: callbackReqId },
body: {
msgtype: "stream",
stream: { id: crypto.randomUUID(), finish: true, content: text },
},
},
callbackReqId
).finally(() => {
state.lastCallback.delete(String(chatId || "").trim());
});
}
const reqId = generateReqId("aibot_send_msg");
return sendWs(
{
cmd: "aibot_send_msg",
headers: { req_id: reqId },
body: {
chatid: chatId,
chat_type: 2,
msgtype: "markdown",
markdown: { content: text },
},
},
reqId
);
}
/**
* 空运群发报价模板:分片上传后 aibot_send_msg file。
* 单个分片不超过 512KB(编码前)。
*/
async function sendGroupFile({ chatId, filename, fileB64, fileBytes }) {
const buf = Buffer.isBuffer(fileBytes)
? fileBytes
: Buffer.from(String(fileB64 || ""), "base64");
const name = String(filename || "quote.xlsx").trim() || "quote.xlsx";
if (!chatId || !buf.length) {
const err = new Error("file_empty");
err.code = "file_empty";
throw err;
}
const chunkSize = 400 * 1024;
const totalChunks = Math.max(1, Math.ceil(buf.length / chunkSize));
const md5 = crypto.createHash("md5").update(buf).digest("hex");
const initReq = generateReqId("aibot_upload_media_init");
const init = await sendWs(
{
cmd: "aibot_upload_media_init",
headers: { req_id: initReq },
body: {
type: "file",
filename: name,
total_size: buf.length,
total_chunks: totalChunks,
md5,
},
},
initReq
);
const uploadId = String((init.body && init.body.upload_id) || "").trim();
if (!uploadId) {
const err = new Error("upload_init_failed");
err.code = "upload_init_failed";
throw err;
}
for (let i = 0; i < totalChunks; i += 1) {
const slice = buf.subarray(i * chunkSize, Math.min(buf.length, (i + 1) * chunkSize));
const chunkReq = generateReqId("aibot_upload_media_chunk");
await sendWs(
{
cmd: "aibot_upload_media_chunk",
headers: { req_id: chunkReq },
body: {
upload_id: uploadId,
chunk_index: i,
base64_data: slice.toString("base64"),
},
},
chunkReq
);
}
const finishReq = generateReqId("aibot_upload_media_finish");
const finished = await sendWs(
{
cmd: "aibot_upload_media_finish",
headers: { req_id: finishReq },
body: { upload_id: uploadId },
},
finishReq
);
const mediaId = String((finished.body && finished.body.media_id) || "").trim();
if (!mediaId) {
const err = new Error("upload_finish_failed");
err.code = "upload_finish_failed";
throw err;
}
const sendReq = generateReqId("aibot_send_msg");
return sendWs(
{
cmd: "aibot_send_msg",
headers: { req_id: sendReq },
body: {
chatid: chatId,
chat_type: 2,
msgtype: "file",
file: { media_id: mediaId },
},
},
sendReq
);
}
if (!enabled) {
// eslint-disable-next-line no-console
console.log("[aibot-long-connection] 未启用(WECOM_AIBOT_ENABLED=false),跳过帧循环壳");
return { stop: () => {} };
console.log("[aibot-long-connection] 未启用(WECOM_AIBOT_ENABLED=false),不连企微");
return { stop: () => {}, sendGroupMessage, sendGroupFile, status };
}
if (!botId || !secret) {
state.lastError = "missing_bot_credentials";
// eslint-disable-next-line no-console
console.warn(
"[aibot-long-connection] 已启用但缺少 WECOM_AIBOT_BOT_ID / WECOM_AIBOT_SECRET,无法订阅"
);
return { stop: () => {}, sendGroupMessage, sendGroupFile, status };
}
// eslint-disable-next-line no-console
console.warn(
"[aibot-long-connection] 帧循环壳已启动:当前不连企微 Bot SDK,仅心跳;" +
"请确认现网桥已停,避免双连"
);
const WebSocket = loadWs();
if (!WebSocket) {
state.lastError = "ws_package_missing";
// eslint-disable-next-line no-console
console.warn("[aibot-long-connection] 未安装 ws 包,请在 aibot-bridge 目录执行 npm install");
return { stop: () => {}, sendGroupMessage, sendGroupFile, status };
}
let timer = null;
const tick = () => {
function startHeartbeat() {
stopHeartbeat();
state.heartbeatTimer = setInterval(() => {
if (!state.ws || state.ws.readyState !== 1) {
return;
}
try {
state.ws.send(
JSON.stringify({
cmd: "ping",
headers: { req_id: generateReqId("ping") },
})
);
} catch (_) {
/* ignore */
}
}, 25000);
}
function stopHeartbeat() {
if (state.heartbeatTimer) {
clearInterval(state.heartbeatTimer);
state.heartbeatTimer = null;
}
}
function scheduleReconnect() {
if (shouldStop() || state.reconnectTimer) {
return;
}
state.reconnectTimer = setTimeout(() => {
state.reconnectTimer = null;
connect();
}, 5000);
}
function settlePending(reqId, errcode, errmsg, frame) {
const wait = state.pending.get(reqId);
if (!wait) {
return;
}
clearTimeout(wait.timer);
state.pending.delete(reqId);
if (Number(errcode) === 0) {
wait.resolve({ ok: true, body: (frame && frame.body) || {}, frame: frame || {} });
return;
}
const err = new Error(errmsg || "send_failed");
err.code = "wecom_error";
err.errcode = errcode;
wait.reject(err);
}
function normalizeInbound(frame) {
const body = frame.body || {};
const from = body.from || {};
const text = (body.text && body.text.content) || "";
return {
sender_id: String(from.userid || "").trim(),
message_id: String(body.msgid || (frame.headers && frame.headers.req_id) || "").trim(),
content: text,
msg_type: String(body.msgtype || "text"),
chat_id: String(body.chatid || "").trim(),
chat_type: body.chattype === "single" ? "c2c" : "group",
raw: {
source: "aibot",
aibot_req_id: frame.headers && frame.headers.req_id,
},
};
}
function handleFrame(raw) {
let frame;
try {
frame = JSON.parse(raw);
} catch (_) {
return;
}
const reqId = frame.headers && frame.headers.req_id;
if (reqId && state.pending.has(reqId)) {
settlePending(reqId, frame.errcode, frame.errmsg, frame);
}
if (frame.cmd === "aibot_event_callback") {
const ev = (frame.body && frame.body.event && frame.body.event.eventtype) || "";
// eslint-disable-next-line no-console
console.warn("[aibot-long-connection] 事件", ev || "-");
if (ev === "disconnected_event") {
state.lastError = "kicked_by_new_connection";
}
return;
}
const bodyHint = frame.body || {};
const looksLikeInbound = Boolean(
frame.cmd === "aibot_msg_callback" ||
bodyHint.msgid ||
bodyHint.chatid ||
(bodyHint.text && bodyHint.text.content)
);
if (looksLikeInbound) {
// eslint-disable-next-line no-console
console.log(
"[aibot-long-connection] 收到入站 cmd=",
frame.cmd || "-",
"msgid=",
bodyHint.msgid || "-",
"chat=",
bodyHint.chatid || "-"
);
const inbound = normalizeInbound(frame);
if (reqId && inbound.chat_id) {
state.lastCallback.set(inbound.chat_id, reqId);
}
const officialBody = Object.assign({}, frame.body || {}, {
aibot_direct: true,
aibot_req_id: reqId || "",
});
const payload = inbound.sender_id
? Object.assign({}, officialBody, {
sender_id: inbound.sender_id,
message_id: inbound.message_id,
content: inbound.content,
chat_id: inbound.chat_id,
chat_type: inbound.chat_type,
})
: officialBody;
if (!payload.sender_id && !payload.from) {
return;
}
Promise.resolve(forwardToAgent(JSON.stringify(payload))).catch((e) => {
// eslint-disable-next-line no-console
console.warn("[aibot-long-connection] 转发入站失败", String(e && e.message ? e.message : e));
});
}
}
function connect() {
if (shouldStop()) {
return;
}
// 心跳:证明传输壳在跑;无帧可转发
// eslint-disable-next-line no-console
console.log("[aibot-long-connection] heartbeat shell (no SDK frame)");
console.warn(
"[aibot-long-connection] 即将连接企微长连接;请确认现网 8813 已停,避免双连"
);
const ws = new WebSocket(wsUrl);
state.ws = ws;
ws.on("open", () => {
state.connected = true;
state.lastError = "";
const reqId = generateReqId("aibot_subscribe");
ws.send(
JSON.stringify({
cmd: "aibot_subscribe",
headers: { req_id: reqId },
body: { bot_id: botId, secret },
})
);
// eslint-disable-next-line no-console
console.log("[aibot-long-connection] 已发订阅 req_id前缀=aibot_subscribe");
});
ws.on("message", (buf) => {
const text = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
let frame;
try {
frame = JSON.parse(text);
} catch (_) {
// eslint-disable-next-line no-console
console.warn("[aibot-long-connection] 非JSON帧 len=", text.length);
handleFrame(text);
return;
}
// eslint-disable-next-line no-console
const reqPrefix = String((frame.headers && frame.headers.req_id) || "").split("_").slice(0, 2).join("_");
console.log(
"[aibot-long-connection] 帧 cmd=",
frame.cmd || "-",
"req=",
reqPrefix || "-",
"keys=",
Object.keys(frame).join(","),
"errcode=",
frame.errcode,
"errmsg=",
frame.errmsg || ""
);
if (frame.cmd === "ping") {
try {
ws.send(
JSON.stringify({
cmd: "pong",
headers: frame.headers || {},
})
);
} catch (_) {
/* ignore */
}
}
const ackReq = String((frame.headers && frame.headers.req_id) || "");
if (ackReq.startsWith("aibot_subscribe") && Number(frame.errcode) === 0 && !state.subscribed) {
state.subscribed = true;
startHeartbeat();
// eslint-disable-next-line no-console
console.log("[aibot-long-connection] 订阅成功");
} else if (
ackReq.startsWith("aibot_subscribe") &&
frame.errcode !== undefined &&
Number(frame.errcode) !== 0
) {
state.lastError = "subscribe_failed_" + String(frame.errcode);
// eslint-disable-next-line no-console
console.warn("[aibot-long-connection] 订阅失败 errcode=", frame.errcode);
}
handleFrame(text);
});
ws.on("close", (code, reason) => {
stopHeartbeat();
state.connected = false;
state.subscribed = false;
state.ws = null;
// eslint-disable-next-line no-console
console.warn(
"[aibot-long-connection] 连接关闭 code=",
code,
"reason=",
reason ? String(reason).slice(0, 120) : ""
);
if (!shouldStop()) {
scheduleReconnect();
}
});
ws.on("error", (e) => {
state.lastError = "ws_error";
// eslint-disable-next-line no-console
console.warn("[aibot-long-connection] 连接错误", String(e && e.message ? e.message : e));
});
ws.on("ping", () => {
try {
ws.pong();
} catch (_) {
/* ignore */
}
});
}
connect();
state.stop = () => {
stopHeartbeat();
if (state.reconnectTimer) {
clearTimeout(state.reconnectTimer);
state.reconnectTimer = null;
}
if (state.ws) {
try {
state.ws.close();
} catch (_) {
/* ignore */
}
state.ws = null;
}
state.connected = false;
state.subscribed = false;
};
timer = setInterval(tick, 60000);
tick();
return {
stop: () => {
if (timer) {
clearInterval(timer);
timer = null;
}
},
stop: state.stop,
sendGroupMessage,
sendGroupFile,
status,
/**
* 测试注入:模拟 SDK 收到一帧已解析 JSON,只做转发。
* @param {object|string} frame
+36
View File
@@ -0,0 +1,36 @@
{
"name": "ytd-aibot-bridge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ytd-aibot-bridge",
"version": "0.1.0",
"dependencies": {
"ws": "^8.18.0"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+3
View File
@@ -6,5 +6,8 @@
"main": "server.cjs",
"scripts": {
"start": "node server.cjs"
},
"dependencies": {
"ws": "^8.18.0"
}
}
+130 -6
View File
@@ -4,7 +4,8 @@
* 本文件职责:
* - /health 存活
* - POST /forward 把已解析消息转到智能体 WECOM_AIBOT_INGRESS_URL(默认 8910/internal/aibot/inbound)
* - 默认不连企微 Bot 长连接(并存期禁止双连);WECOM_AIBOT_ENABLED=true 时仅打警告
* - POST /send-group 走长连接 aibot_send_msg(发文件后马上在群里说话)
* - 默认不连企微(并存期禁止双连);WECOM_AIBOT_ENABLED=true 时订阅长连接
*
* 禁止:在本进程写工单/六态/Graph。
*/
@@ -15,6 +16,20 @@ const http = require("http");
const { URL } = require("url");
const { startLongConnectionLoop } = require("./long_connection.cjs");
let longConn = {
sendGroupMessage: async () => {
const err = new Error("ws_offline");
err.code = "ws_offline";
throw err;
},
sendGroupFile: async () => {
const err = new Error("ws_offline");
err.code = "ws_offline";
throw err;
},
status: () => ({ enabled, connected: false, subscribed: false }),
};
const port = Number(process.env.AIBOT_BRIDGE_PORT || 8913);
const bind = process.env.AIBOT_BRIDGE_BIND || "127.0.0.1";
const enabled = String(process.env.WECOM_AIBOT_ENABLED || "false").toLowerCase() === "true";
@@ -94,10 +109,10 @@ const server = http.createServer(async (req, res) => {
port,
wecomAibotEnabled: enabled,
ingressUrl,
longConnectionShell: true,
longConnection: longConn.status(),
note: enabled
? "enabled:长连接帧循环壳已起(默认无 Bot SDK);勿与现网双连"
: "Bot 长连接默认关闭;可用 POST /forward 测转发",
? "enabled:长连接已尝试订阅;POST /send-group 可主动往群里说话;勿与现网双连"
: "Bot 长连接默认关闭;可用 POST /forward 测转发。主动发言需 WECOM_AIBOT_ENABLED=true",
});
return;
}
@@ -130,9 +145,118 @@ const server = http.createServer(async (req, res) => {
return;
}
// 官方桥口径:POST /send {chatid, operation:"text", content}
if (req.method === "POST" && (url === "/send" || url === "/send/")) {
if (bridgeToken) {
const got = String(
req.headers["x-aibot-bridge-token"] || req.headers["x-ytd-aibot-bridge-token"] || ""
);
if (got !== bridgeToken) {
sendJson(res, 401, { error: "unauthorized" });
return;
}
}
try {
const raw = await readBody(req);
const body = raw ? JSON.parse(raw) : {};
const chatId = String(body.chatid || body.chat_id || "").trim();
const content = String(body.content || "").trim();
if (!chatId || !content) {
sendJson(res, 400, { error: "missing_chat_or_content" });
return;
}
await longConn.sendGroupMessage({ chatId, content });
sendJson(res, 200, { status: "sent", chatId });
} catch (e) {
const code = e && e.code ? e.code : "send_failed";
const httpCode = code === "ws_offline" ? 503 : 502;
sendJson(res, httpCode, {
error: code,
message: String(e && e.message ? e.message : e),
});
}
return;
}
// 空运群发填好的报价模板:上传素材后 aibot_send_msg file
if (req.method === "POST" && (url === "/send-file" || url === "/send-file/")) {
if (bridgeToken) {
const got = String(
req.headers["x-aibot-bridge-token"] || req.headers["x-ytd-aibot-bridge-token"] || ""
);
if (got !== bridgeToken) {
sendJson(res, 401, { error: "unauthorized" });
return;
}
}
try {
const raw = await readBody(req);
const body = raw ? JSON.parse(raw) : {};
const chatId = String(body.chat_id || body.chatid || "").trim();
const filename = String(body.filename || "quote.xlsx").trim() || "quote.xlsx";
const fileB64 = String(body.file_b64 || body.fileBase64 || "").trim();
if (!chatId || !fileB64) {
sendJson(res, 400, { error: "missing_chat_or_file" });
return;
}
await longConn.sendGroupFile({ chatId, filename, fileB64 });
sendJson(res, 200, { status: "sent", chatId, filename });
} catch (e) {
const code = e && e.code ? e.code : "send_failed";
const httpCode = code === "ws_offline" ? 503 : 502;
sendJson(res, httpCode, {
error: code,
message: String(e && e.message ? e.message : e),
});
}
return;
}
// 智能体 Worker:存档听到附件后马上在群里说话(aibot_send_msg)
if (req.method === "POST" && (url === "/send-group" || url === "/send-group/")) {
if (bridgeToken) {
const got = String(
req.headers["x-aibot-bridge-token"] || req.headers["x-ytd-aibot-bridge-token"] || ""
);
if (got !== bridgeToken) {
sendJson(res, 401, { error: "unauthorized" });
return;
}
}
try {
const raw = await readBody(req);
const body = raw ? JSON.parse(raw) : {};
const chatId = String(body.chat_id || body.chatid || "").trim();
const content = String(body.content || "").trim();
if (!chatId || !content) {
sendJson(res, 400, { error: "missing_chat_or_content" });
return;
}
await longConn.sendGroupMessage({ chatId, content });
sendJson(res, 200, { status: "sent", chatId });
} catch (e) {
const code = e && e.code ? e.code : "send_failed";
const httpCode = code === "ws_offline" ? 503 : 502;
sendJson(res, httpCode, {
error: code,
message: String(e && e.message ? e.message : e),
});
}
return;
}
sendJson(res, 404, { error: "not_found", path: req.url });
});
process.on("uncaughtException", (e) => {
// eslint-disable-next-line no-console
console.warn("[aibot-bridge] uncaught", String(e && e.stack ? e.stack : e).slice(0, 400));
});
process.on("unhandledRejection", (e) => {
// eslint-disable-next-line no-console
console.warn("[aibot-bridge] rejection", String(e && e.stack ? e.stack : e).slice(0, 400));
});
server.listen(port, bind, () => {
// eslint-disable-next-line no-console
console.log(
@@ -144,8 +268,8 @@ server.listen(port, bind, () => {
"[aibot-bridge] WECOM_AIBOT_ENABLED=true:请确认已停现网桥且不会双连同一 Bot"
);
}
// 传输壳:enabled 时跑心跳循环;未接 SDK 前不会产生入站帧
startLongConnectionLoop({
// 启用时订阅企微长连接;关闭时 /send-group 会回 ws_offline
longConn = startLongConnectionLoop({
enabled,
forwardToAgent,
});
+52 -14
View File
@@ -26,8 +26,8 @@ router = APIRouter(prefix="/internal/aibot", tags=["aibot"])
class AibotInboundBody(BaseModel):
"""桥转发的归一字段(桥侧已解析,智能体不再调企微换 ID)。"""
sender_id: str = Field(description="企微 userid,必须来自 AIBOT 报文")
message_id: str
sender_id: str = Field(default="", description="企微 userid,必须来自 AIBOT 报文")
message_id: str = ""
content: str = ""
msg_type: str = "text"
chat_id: str = ""
@@ -38,6 +38,38 @@ class AibotInboundBody(BaseModel):
raw: dict[str, Any] = Field(default_factory=dict)
def normalize_aibot_payload(body: dict[str, Any]) -> AibotInboundBody:
"""
同时吃两套入站:智能体骨架字段,以及现网官方桥转发的企微 body
(msgid/chatid/from.userid/text.content)。
"""
data = dict(body or {})
from_user = data.get("from") if isinstance(data.get("from"), dict) else {}
text_obj = data.get("text") if isinstance(data.get("text"), dict) else {}
sender = str(data.get("sender_id") or from_user.get("userid") or "").strip()
mid = str(data.get("message_id") or data.get("msgid") or "").strip()
content = str(data.get("content") or text_obj.get("content") or "")
chat_id = str(data.get("chat_id") or data.get("chatid") or "").strip()
chattype = str(data.get("chat_type") or data.get("chattype") or "group").strip().lower()
chat_type = "c2c" if chattype in {"single", "c2c", "private"} else "group"
raw = dict(data.get("raw") or {})
raw.setdefault("source", "aibot")
if data.get("response_url"):
raw.setdefault("response_url", data.get("response_url"))
return AibotInboundBody(
sender_id=sender,
message_id=mid,
content=content,
msg_type=str(data.get("msg_type") or data.get("msgtype") or "text"),
chat_id=chat_id,
chat_type=chat_type,
agent_id=str(data.get("agent_id") or data.get("aibotid") or ""),
create_time=int(data.get("create_time") or 0),
media=dict(data.get("media") or {}),
raw=raw,
)
def _check_bridge_token(x_token: Optional[str]) -> None:
s = get_settings()
expected = (getattr(s, "wecom_aibot_bridge_token", None) or "").strip()
@@ -57,26 +89,32 @@ def aibot_ping() -> dict[str, str]:
@router.post("/inbound")
def aibot_inbound(
body: AibotInboundBody,
body: dict[str, Any],
x_aibot_bridge_token: Optional[str] = Header(
default=None, alias="X-Aibot-Bridge-Token"
),
x_ytd_aibot_bridge_token: Optional[str] = Header(
default=None, alias="X-Ytd-Aibot-Bridge-Token"
),
) -> dict[str, Any]:
"""
接收桥转发入站:无 sender_id 拒绝;其余入 inbox。
"""
_check_bridge_token(x_aibot_bridge_token)
_check_bridge_token(x_aibot_bridge_token or x_ytd_aibot_bridge_token)
parsed = normalize_aibot_payload(body)
raw = dict(parsed.raw or {})
raw.setdefault("source", "aibot")
msg = InboundMessage(
sender_id=body.sender_id.strip(),
message_id=body.message_id.strip(),
content=body.content or "",
msg_type=body.msg_type or "text",
chat_id=body.chat_id or "",
chat_type=body.chat_type or "group",
agent_id=body.agent_id or "",
create_time=int(body.create_time or 0),
media=body.media or {},
raw=body.raw or {},
sender_id=parsed.sender_id.strip(),
message_id=parsed.message_id.strip(),
content=parsed.content or "",
msg_type=parsed.msg_type or "text",
chat_id=parsed.chat_id or "",
chat_type=parsed.chat_type or "group",
agent_id=parsed.agent_id or "",
create_time=int(parsed.create_time or 0),
media=parsed.media or {},
raw=raw,
)
if not msg.has_sender():
raise HTTPException(status_code=400, detail="missing_sender_id")
+186
View File
@@ -0,0 +1,186 @@
"""
空运群 BOT 回话。
本文件职责:有即时 `response_url` 就流式回;没有(发文件走存档)就打本机桥 `/send-group`,
由长连接 `aibot_send_msg` 主动往群里说。
禁止:走海运 appchat/send;在回调线程等 LLM。
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
import httpx
from agent.config import get_settings
logger = logging.getLogger(__name__)
class AibotReplyClient:
"""
空运群出站客户端。
一次 @ 若带了 response_url,优先走流式回复。
存档听到附件时没有 url:改走桥主动发言。该群须先被 @ 过,企微才允许主动说。
"""
def __init__(self, response_url: str = "", timeout_sec: float = 8.0) -> None:
self._url = (response_url or "").strip()
self._timeout = timeout_sec
self.sent: list[dict[str, Any]] = []
def send_group(
self,
*,
chat_id: str,
content: str,
mention_userids: list[str] | None = None,
) -> dict[str, Any]:
_ = mention_userids
text = str(content or "").strip()
self.sent.append({"chat_id": chat_id, "content": text, "kind": "text"})
if not text:
return {"ok": False, "error": "empty"}
if self._url:
return self._send_stream(chat_id, text)
return self._send_proactive(str(chat_id or "").strip(), text)
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(典型:会话存档听到附件)时,让桥主动往群里说。
副作用:回环 HTTP 打 8913,不直连企微。桥未连上或该群从未 @ 过,企微不会投递。
"""
if not chat_id:
logger.warning("aibot 主动发言缺少 chat_id")
return {"ok": False, "error": "no_chat_id"}
settings = get_settings()
base = str(getattr(settings, "wecom_aibot_bridge_url", "") or "").rstrip("/")
if not base:
logger.warning("aibot 主动发言未配置 WECOM_AIBOT_BRIDGE_URL chat=%s", chat_id)
return {"ok": False, "error": "no_bridge"}
headers = {"Content-Type": "application/json"}
token = str(getattr(settings, "wecom_aibot_bridge_token", "") or "").strip()
if token:
headers["X-Aibot-Bridge-Token"] = token
headers["X-Ytd-Aibot-Bridge-Token"] = token
try:
# 现网官方桥是 POST /send;本仓库骨架桥是 /send-group。两个都试,先打官方口径。
resp = httpx.post(
f"{base}/send",
json={"chatid": chat_id, "operation": "text", "content": text},
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},
headers=headers,
timeout=self._timeout,
)
ok = 200 <= resp.status_code < 300
if not ok:
logger.warning(
"aibot 主动发言 HTTP=%s chat=%s body=%s",
resp.status_code,
chat_id,
(resp.text or "")[:200],
)
return {"ok": ok, "status": resp.status_code}
except Exception:
logger.exception("aibot 主动发言网络失败 chat=%s", chat_id)
return {"ok": False, "error": "network"}
def send_group_file(
self,
*,
chat_id: str,
filename: str,
filepath: str = "",
file_b64: str = "",
) -> dict[str, Any]:
"""
空运群发填好的报价模板。走桥 /send-file,由长连接上传素材再 aibot_send_msg。
不走海运 appchat。该群须先 @ 过小助手,企微才投递。
"""
import base64
from pathlib import Path
room = str(chat_id or "").strip()
name = str(filename or "quote.xlsx").strip() or "quote.xlsx"
raw = b""
if filepath:
try:
raw = Path(filepath).read_bytes()
except OSError as exc:
logger.warning("aibot 读模板失败 chat=%s err=%s", room, exc)
return {"ok": False, "error": f"read_file:{exc}"}
elif file_b64:
try:
raw = base64.b64decode(file_b64)
except Exception:
logger.warning("aibot 模板解码失败 chat=%s", room)
return {"ok": False, "error": "b64"}
self.sent.append({"chat_id": room, "filename": name, "kind": "file"})
if not room or not raw:
return {"ok": False, "error": "file_empty"}
settings = get_settings()
base = str(getattr(settings, "wecom_aibot_bridge_url", "") or "").rstrip("/")
if not base:
logger.warning("aibot 发文件未配置 WECOM_AIBOT_BRIDGE_URL chat=%s", room)
return {"ok": False, "error": "no_bridge"}
headers = {"Content-Type": "application/json"}
token = str(getattr(settings, "wecom_aibot_bridge_token", "") or "").strip()
if token:
headers["X-Aibot-Bridge-Token"] = token
headers["X-Ytd-Aibot-Bridge-Token"] = token
payload = {
"chatid": room,
"chat_id": room,
"filename": name,
"file_b64": base64.b64encode(raw).decode("ascii"),
}
try:
resp = httpx.post(
f"{base}/send-file",
json=payload,
headers=headers,
timeout=max(self._timeout, 45.0),
)
ok = 200 <= resp.status_code < 300
if not ok:
logger.warning(
"aibot 发文件 HTTP=%s chat=%s body=%s",
resp.status_code,
room,
(resp.text or "")[:200],
)
return {"ok": ok, "status": resp.status_code}
except Exception:
logger.exception("aibot 发文件网络失败 chat=%s", room)
return {"ok": False, "error": "network"}
+2 -1
View File
@@ -356,8 +356,9 @@ class WeComAppClient:
return {"ok": False, "error": str(exc)}
errcode = int(data.get("errcode") or 0)
if errcode != 0:
logger.warning("企微群消息失败 errcode=%s", errcode)
logger.warning("企微群消息失败 errcode=%s chat=%s", errcode, chat_id)
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
logger.info("企微群文本已发送 chat=%s chars=%s", chat_id, len(content))
return {"ok": True, "error": ""}
def send_group_file(
+12 -6
View File
@@ -27,7 +27,7 @@ class ChannelRuntime:
企微通道运行时:inbox/outbox 各一条守护线程。
启动时机:FastAPI lifespan;停止:进程退出。
inbox:自由文字走 DeepSeek A 认意图(本线程,非回调);点卡不进模型。
inbox:私聊自由文字走 DeepSeek A 认意图(本线程,非回调);群消息和点卡不进模型。
"""
def __init__(self, settings: Settings) -> None:
@@ -70,11 +70,17 @@ class ChannelRuntime:
continue
try:
message = item.message
decision = self._router.route_inbound(
text=message.content or "",
sender_id=message.sender_id or "",
event=str((message.raw or {}).get("event") or ""),
)
# 群消息由 dispatch 按 BOT/存档分流,不消耗 DeepSeek A,避免空运群先卡 2~3 秒。
if (message.chat_type or "") == "group":
from agent.routing.decision import RouteDecision
decision = RouteDecision(intent="ordinary_text_other")
else:
decision = self._router.route_inbound(
text=message.content or "",
sender_id=message.sender_id or "",
event=str((message.raw or {}).get("event") or ""),
)
dispatch_inbound(message, decision)
self._store.complete_inbound(item.id)
except Exception as exc: # noqa: BLE001
+42 -1
View File
@@ -41,6 +41,47 @@ def _create_time(msgtime: Any) -> int:
return n
def _unwrap_text(raw: Any) -> str:
"""
从字符串或嵌套对象取出正文。
管家 content 经常是摘要;企微官方结构是 text.content。空、null 不当正文。
"""
if raw is None:
return ""
if isinstance(raw, str):
text = raw.strip()
if not text or text.lower() in {"null", "none"}:
return ""
if text.startswith("{") or text.startswith("["):
try:
return _unwrap_text(json.loads(text))
except json.JSONDecodeError:
return text
return text
if isinstance(raw, Mapping):
for key in ("content", "text", "msg"):
got = _unwrap_text(raw.get(key))
if got:
return got
return ""
return str(raw).strip()
def _best_text(record: Mapping[str, Any], payload: Mapping[str, Any]) -> str:
"""多处正文里取最长的一份,避免摘要盖掉带金额的原文。"""
candidates = [
_unwrap_text(record.get("content")),
_unwrap_text(payload.get("content")),
_unwrap_text(payload.get("text")),
_unwrap_text(payload.get("msg")),
]
texts = [x for x in candidates if x]
if not texts:
return ""
return max(texts, key=len)
def _filename(msg_type: str, content: str, payload: Mapping[str, Any]) -> str:
for key in ("filename", "file_name", "FileName", "title", "name"):
val = str(payload.get(key) or "").strip()
@@ -75,7 +116,7 @@ def archive_record_to_inbound(
return None
payload = _as_mapping(record.get("msg_content") or record.get("msgcontent"))
content = str(record.get("content") or payload.get("content") or "").strip()
content = _best_text(record, payload)
msgid = str(record.get("msgid") or record.get("msg_id") or "").strip()
seq = record.get("seq")
if not msgid:
@@ -123,6 +123,21 @@ def poll_once(
page_size = int(getattr(cfg, "wshoto_page_size", 50) or 50)
clock = now or time.time
rooms = list_watched_rooms(redis=redis)
scan_ids = [
x.strip()
for x in str(getattr(cfg, "wshoto_scan_user_ids", "") or "").replace(";", ",").split(",")
if x.strip()
]
extra: list[dict[str, Any]] = []
if scan_ids:
extra = _poll_by_scan_users(
api,
userids=scan_ids,
redis=redis,
page_size=page_size,
lookback=lookback,
clock=clock,
)
if rooms:
# 应用群必须带房间号、不要带存档席 userid,否则管家返回空页。
records, mode, last = _poll_by_rooms(
@@ -132,6 +147,8 @@ def poll_once(
lookback=lookback,
clock=clock,
)
if extra:
records = list(records) + extra
else:
seat = seats[0] if seats else ""
last = load_last_seq(f"seat:{seat or 'default'}", redis=redis)
@@ -160,6 +177,9 @@ def poll_once(
lookback=lookback,
clock=clock,
)
if extra:
records = list(records) + extra
mode = "rooms"
# 按房间拉时各群自己的 last_seq 已经过滤过,这里不要再用席游标误丢。
ingest_min = 0 if mode == "rooms" else last
enqueued, skipped = ingest_records(
@@ -194,6 +214,61 @@ def poll_once(
}
def _poll_by_scan_users(
api: WshotoOpenClient,
*,
userids: Sequence[str],
redis: Optional[RedisClient],
page_size: int,
lookback: int,
clock: Callable[[], float],
) -> list[dict[str, Any]]:
"""
按人扫新群。空运群是人自建的,存档席没进群时房间名单里没有它。
副作用:看到带 @询价小助手 的新房间会登记监听。
"""
from agent.channel.wshoto.rooms import watch_collab_room
merged: list[dict[str, Any]] = []
now = int(clock())
for uid in userids:
scope = f"scan:{uid}"
last = load_last_seq(scope, redis=redis)
time_start = 0
time_end = 0
if last <= 0:
time_end = now
time_start = time_end - min(lookback, 8 * 3600)
try:
rows = api.list_group_messages(
userid=uid,
last_seq=last,
page_size=page_size,
msg_time_start=time_start,
msg_time_end=time_end,
)
except WshotoError as exc:
logger.warning("微盛按人扫群失败 userid=%s code=%s", uid, exc.code)
continue
max_seq = last
for rec in rows:
seq = _record_seq(rec)
if seq > max_seq:
max_seq = seq
room = str(rec.get("room_id") or rec.get("roomid") or "").strip()
blob = str(rec.get("content") or "")
if room and "询价小助手" in blob:
try:
watch_collab_room(room, redis=redis)
except Exception:
logger.exception("扫到空运群后登记监听失败 chat=%s", room)
if max_seq > last:
save_last_seq(scope, max_seq, redis=redis)
merged.extend(rows)
return merged
def _poll_by_rooms(
api: WshotoOpenClient,
*,
+6
View File
@@ -77,6 +77,8 @@ class Settings(BaseSettings):
alias="WSHOTO_BOOTSTRAP_LOOKBACK_SEC",
)
wshoto_page_size: int = Field(default=50, alias="WSHOTO_PAGE_SIZE")
# 空运群是人自建的,存档席可能没拉进群。按这些 userid 扫新群(逗号分隔)。
wshoto_scan_user_ids: str = Field(default="", alias="WSHOTO_SCAN_USER_IDS")
wshoto_attachment_min_interval_ms: int = Field(
default=1050,
alias="WSHOTO_ATTACHMENT_MIN_INTERVAL_MS",
@@ -109,6 +111,10 @@ class Settings(BaseSettings):
# auto | memory | postgres;auto=有 PG_DSN 则尝试 PG
message_store_backend: str = Field(default="auto", alias="MESSAGE_STORE_BACKEND")
wecom_aibot_bridge_token: str = Field(default="", alias="WECOM_AIBOT_BRIDGE_TOKEN")
wecom_aibot_bridge_url: str = Field(
default="http://127.0.0.1:8913",
alias="WECOM_AIBOT_BRIDGE_URL",
)
wecom_aibot_ingress_url: str = Field(
default="http://127.0.0.1:8910/internal/aibot/inbound",
alias="WECOM_AIBOT_INGRESS_URL",
+1 -1
View File
@@ -2,7 +2,7 @@
Handler 包:一类意图 / 一类卡片一个模块。
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
当前:文字询价空运/海运闭环 + 海运群协同字段 + echo 联调残留。
当前:文字询价空运/海运闭环 + 海运应用群协同 + 空运 BOT 群文字/附件 + echo 联调残留。
"""
from agent.handlers.echo_text import handle_echo_text
@@ -0,0 +1,310 @@
"""
意图:空运协同群入站(BOT @ 之后的文字或附件)。
本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。
禁止:存档文字当业务;锁舱用当前工单去猜;套 sea_group_ops 建群。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.channel.wecom.models import InboundMessage
from agent.policy import inquiry_copy as copy
from agent.policy.air_group_ops import (
activate_ticket,
apply_air_confirm_tms,
apply_air_deal,
apply_air_fields,
apply_air_file,
apply_air_lock,
apply_air_quote,
apply_air_release,
apply_air_tms_choice,
current_ticket,
peek_air_pending,
resolve_cabin_ticket,
)
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.sea_group_ops import group_client_of, send_group_text, ticket_view
from agent.routing.air_group_intent import (
INTENT_ACTIVATE,
INTENT_CONFIRM_TMS,
INTENT_DEAL,
INTENT_DROP_TMS,
INTENT_FIELDS,
INTENT_KEEP_TMS,
INTENT_LOCK,
INTENT_LOST,
INTENT_NEGOTIATE,
INTENT_OTHER,
INTENT_PICK_OPTION,
INTENT_QUOTE,
INTENT_RELEASE,
classify_air_group_text,
)
logger = logging.getLogger(__name__)
def _attach_bot_client(engine, message: InboundMessage) -> None:
"""
每次入站换一次 BOT 出站客户端。
测试注入的 FakeBotGroup 不动。
有 response_url 走当场流式回;没有(存档附件)走桥主动发言,马上在群里说。
不能复用上一次 @ 的过期 url。
"""
from agent.channel.aibot.reply import AibotReplyClient
existing = engine.group_client()
if existing is not None and not isinstance(existing, AibotReplyClient):
return
engine._group_client = AibotReplyClient(
str((message.raw or {}).get("response_url") or "")
)
def handle_air_group(
message: InboundMessage,
*,
flow=None,
ledger=None,
injected_intent: str = "",
injected_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
空运群入站:文字必须 @ BOT;附件走会话存档,不能 @。
航线 Excel/PDF 当报价;别人的只存档。无 sender_id 拒绝。
BOT 与存档 msgid 对不上:同一句只办一遍,存档后到就跳过,再 @ 仍回。
"""
if (message.chat_type or "") != "group":
return "group_ignore"
if not message.has_sender():
return "reject_no_sender"
engine = flow or get_air_text_flow()
if ledger is not None:
engine.ledger = ledger
_attach_bot_client(engine, message)
chat_id = (message.chat_id or "").strip()
if not chat_id:
return "group_ignore"
kind = (message.msg_type or "text").lower()
if kind in {"file", "image", "video"}:
return _handle_file(engine, message)
text = message.content or ""
from agent.redis_coord.air_inbound import mark_air_text, should_skip_air_text
if should_skip_air_text(
chat_id=chat_id,
sender_id=message.sender_id,
text=text,
raw=message.raw,
):
return "duplicate_inbound"
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
wo = copy.extract_work_order_no(text)
intent = classify_air_group_text(text, injected=injected_intent)
logger.info(
"air_group 文字 chat=%s sender=%s intent=%s wo=%s",
chat_id,
message.sender_id,
intent,
wo or "-",
)
if intent in {INTENT_LOCK, INTENT_RELEASE}:
return _handle_cabin(
engine,
message,
intent=intent,
work_order_no=wo,
injected_quote=injected_quote,
)
ticket = None
if wo:
ticket, phase = activate_ticket(
flow=engine,
chat_id=chat_id,
work_order_no=wo,
speak=intent == INTENT_ACTIVATE or _should_speak_activate(intent),
)
if ticket is None:
return phase
if intent == INTENT_ACTIVATE:
return phase
else:
ticket = current_ticket(engine, chat_id)
if ticket is None:
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
return "need_work_order"
return _handle_bound(
engine,
message,
ticket=ticket,
intent=intent,
injected_quote=injected_quote,
)
def _handle_file(engine, message: InboundMessage) -> str:
"""
存档听到的附件(发文件不能 @)。句中/文件名有工单号先切单;没有就跟当前单。
"""
chat_id = message.chat_id
media = dict(message.media or {})
filename = str(
media.get("filename") or media.get("FileName") or (message.content or "")
).strip()
wo = copy.extract_work_order_no(
" ".join([message.content or "", filename])
)
if wo:
ticket, phase = activate_ticket(
flow=engine, chat_id=chat_id, work_order_no=wo, speak=False
)
if ticket is None:
return phase
else:
ticket = current_ticket(engine, chat_id)
if ticket is None:
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
return "need_work_order"
return apply_air_file(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
filename=filename,
media=media,
chat_id=chat_id,
)
def _should_speak_activate(intent: str) -> bool:
"""带工单号又办事时,先切单再办事;切单摘要只在纯激活时说,避免刷屏。"""
return intent in {INTENT_ACTIVATE, INTENT_OTHER}
def _handle_cabin(
engine,
message: InboundMessage,
*,
intent: str,
work_order_no: str,
injected_quote: Optional[dict[str, Any]],
) -> str:
_ = injected_quote
chat_id = message.chat_id
if not work_order_no:
send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order())
return "cabin_need_work_order"
ticket, phase = resolve_cabin_ticket(
flow=engine,
chat_id=chat_id,
work_order_no=work_order_no,
allow_terminal=intent == INTENT_RELEASE,
)
if ticket is None:
return phase
if intent == INTENT_RELEASE:
return apply_air_release(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
)
return apply_air_lock(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
)
def _handle_bound(
engine,
message: InboundMessage,
*,
ticket: object,
intent: str,
injected_quote: Optional[dict[str, Any]],
) -> str:
chat_id = message.chat_id
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if intent == INTENT_PICK_OPTION or (
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1"
):
option = _option_from_text(message.content or "")
if option:
return apply_air_lock(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
option_no=option,
)
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
outcome = {
INTENT_DEAL: "已成交",
INTENT_LOST: "未成交",
INTENT_NEGOTIATE: "协商中",
}[intent]
return apply_air_deal(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
outcome=outcome,
chat_id=chat_id,
)
if intent == INTENT_CONFIRM_TMS:
return apply_air_confirm_tms(
flow=engine, ticket=ticket, sender_id=message.sender_id, chat_id=chat_id
)
if intent in {INTENT_KEEP_TMS, INTENT_DROP_TMS}:
return apply_air_tms_choice(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
keep_tms=intent == INTENT_KEEP_TMS,
)
if intent == INTENT_FIELDS:
return apply_air_fields(
flow=engine,
ticket=ticket,
text=message.content or "",
sender_id=message.sender_id,
chat_id=chat_id,
)
if intent == INTENT_QUOTE:
return apply_air_quote(
flow=engine,
ticket=ticket,
text=message.content or "",
sender_id=message.sender_id,
chat_id=chat_id,
injected_quote=injected_quote,
)
if peek_air_pending(ticket):
send_group_text(group_client_of(engine), chat_id, copy.group_tms_choice_unclear())
return "wait_tms_choice"
logger.info("air_group 其它意图 chat=%s intent=%s", chat_id, intent)
return "group_ignore"
def _option_from_text(text: str) -> str:
blob = (text or "").replace(" ", "").upper()
if "AIR-OPT" in blob or "AIROPT" in blob:
start = blob.find("AIR")
token = []
for ch in blob[start:]:
if ch.isalnum() or ch == "-":
token.append(ch)
else:
break
return "".join(token)
return ""
@@ -13,10 +13,15 @@ from typing import Any, Optional
from agent.channel.wecom.models import InboundMessage
from agent.policy.sea_group_ops import (
apply_collab_update,
apply_confirm_tms_quote,
apply_group_deal,
apply_product_quote,
apply_tms_quote_choice,
dissolve_group,
group_client_of,
handle_group_file,
hydrate_ticket,
peek_pending_product_quote,
send_group_text,
ticket_no,
ticket_view,
@@ -76,6 +81,7 @@ def handle_group_inbound(
if ticket is None:
logger.info("group_inbound 对不上工单 chat=%s", chat_id)
return "group_ignore"
ticket = hydrate_ticket(engine, ticket)
if not _sender_allowed(ticket, message.sender_id):
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
return "group_ignore"
@@ -108,13 +114,54 @@ def handle_group_inbound(
)
from agent.routing.group_intent import (
INTENT_CONFIRM_TMS,
INTENT_DEAL,
INTENT_DISSOLVE,
INTENT_DROP_TMS,
INTENT_FIELDS,
INTENT_KEEP_TMS,
INTENT_LOST,
INTENT_NEGOTIATE,
INTENT_QUOTE,
classify_group_text,
)
intent = classify_group_text(message.content or "", injected=injected_intent)
logger.info(
"group_inbound 文字 chat=%s sender=%s intent=%s chars=%s",
chat_id,
message.sender_id,
intent,
len((message.content or "").strip()),
)
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
outcome = {
INTENT_DEAL: "已成交",
INTENT_LOST: "未成交",
INTENT_NEGOTIATE: "协商中",
}[intent]
return apply_group_deal(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
outcome=outcome,
sess=sess,
)
if intent == INTENT_CONFIRM_TMS:
return apply_confirm_tms_quote(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
sess=sess,
)
if intent in {INTENT_KEEP_TMS, INTENT_DROP_TMS}:
return apply_tms_quote_choice(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
keep_tms=intent == INTENT_KEEP_TMS,
sess=sess,
)
if intent == INTENT_FIELDS:
return apply_collab_update(
flow=engine,
@@ -134,6 +181,12 @@ def handle_group_inbound(
)
if intent == INTENT_DISSOLVE:
return dissolve_group(flow=engine, ticket=ticket, sess=sess)
parked = peek_pending_product_quote(ticket_no(ticket), sess)
if parked:
from agent.policy import inquiry_copy as copy
send_group_text(group_client_of(engine), chat_id, copy.group_tms_choice_unclear())
return "wait_tms_choice"
logger.info("group_inbound 其它意图 chat=%s intent=%s", chat_id, intent)
return "group_ignore"
+58
View File
@@ -219,6 +219,14 @@ class HttpLedger:
return out
return {}
def list_staff_by_role(self, *, role_code: str) -> list[dict[str, Any]]:
"""按岗位列出启用员工。空运群 @ 航线。"""
out = self._post("/inquiry/agent/staff/listByRole", {"roleCode": role_code})
rows = out.get("staff") or out.get("result") or []
if isinstance(rows, list):
return [dict(x) for x in rows if isinstance(x, dict)]
return []
def find_staff_by_name(self, *, name: str) -> dict[str, Any]:
"""按后台员工姓名取企微 userid。会话存档账号「询价机器人」走这里。"""
out = self._post("/inquiry/agent/staff/byName", {"name": name})
@@ -266,6 +274,56 @@ class HttpLedger:
idem=f"collab-facts:{work_order_no}:{sorted(facts)}",
)
def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
"""空运群激活/切换当前工单。一单一群。"""
return self._post(
"/inquiry/agent/ticket/activateAirGroup",
{"chatId": chat_id, "workOrderNo": work_order_no},
idem=f"air-activate:{chat_id}:{work_order_no}",
)
def lock_cabin(
self,
*,
work_order_no: str,
option_no: str,
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
) -> dict[str, Any]:
return self._post(
"/inquiry/agent/tms/lockCabin",
{
"workOrderNo": work_order_no,
"optionNo": option_no,
"operatorUserId": sender_id,
"operatorName": operator_name,
"instructionText": instruction_text,
},
idem=f"air-lock:{work_order_no}:{option_no}",
)
def release_cabin(
self,
*,
work_order_no: str,
lock_id: str,
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
) -> dict[str, Any]:
return self._post(
"/inquiry/agent/tms/releaseCabin",
{
"workOrderNo": work_order_no,
"lockId": lock_id,
"operatorUserId": sender_id,
"operatorName": operator_name,
"instructionText": instruction_text,
},
idem=f"air-release:{work_order_no}:{lock_id}",
)
def find_by_collab_chat(self, *, chat_id: str) -> dict[str, Any] | None:
"""用协同群 chat_id 找回工单。对不上返回 None。"""
out = self._post("/inquiry/agent/ticket/byCollabChat", {"chatId": chat_id})
+101 -4
View File
@@ -248,7 +248,7 @@ class MemoryLedger:
quote=quote,
)
if picked is None:
out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE}
out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE, "facts": dict(facts or {})}
self.last_render = out
return out
ext = "pdf" if str(format).lower() == "pdf" else "xlsx"
@@ -260,6 +260,7 @@ class MemoryLedger:
"fileName": f"{work_order_no}_quote.{ext}",
"fileBase64": "UEsDBAoAAAAAA",
"needsPdf": False,
"facts": dict(facts or {}),
}
with self._lock:
ticket = self._tickets.get((work_order_no or "").strip())
@@ -359,6 +360,18 @@ class MemoryLedger:
with self._lock:
return dict(self._staff_by_wecom.get((wecom_id or "").strip()) or {})
def list_staff_by_role(self, *, role_code: str) -> list[dict[str, Any]]:
"""按岗位列出启用员工。空运群 @ 航线用。"""
want = (role_code or "").strip().lower()
hits: list[dict[str, Any]] = []
with self._lock:
for row in self._staff_by_wecom.values():
code = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
status = str(row.get("status") or "active").strip().lower()
if code == want and status not in {"disabled", "0", "停用"}:
hits.append(dict(row))
return hits
def find_staff_by_name(self, *, name: str) -> dict[str, Any]:
"""按员工姓名取企微 userid。"""
with self._lock:
@@ -442,9 +455,15 @@ class MemoryLedger:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.collab_facts.update(
{k: v for k, v in (facts or {}).items() if str(v or "").strip()}
)
for key, raw in (facts or {}).items():
name = str(key or "").strip()
if not name:
continue
val = str(raw or "").strip()
if val:
ticket.collab_facts[name] = val
else:
ticket.collab_facts.pop(name, None)
return {"ok": True, "facts": dict(ticket.collab_facts)}
def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None:
@@ -457,3 +476,81 @@ class MemoryLedger:
if ticket.collab_chat_id == raw:
return ticket
return None
def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
"""
空运群当前工单:一单一群,未结案才能激活。
不改六态。
"""
no = (work_order_no or "").strip()
room = (chat_id or "").strip()
with self._lock:
ticket = self._tickets.get(no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
if (ticket.business_line or "").upper() != "AIR":
return {"ok": False, "error": "not_air", "status": ticket.status}
if ticket.status in {"已成交", "未成交", "已关闭"}:
return {"ok": False, "error": "terminal", "status": ticket.status}
if ticket.collab_chat_id and ticket.collab_chat_id != room:
return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id}
for other in self._tickets.values():
if other.work_order_no != no and other.collab_chat_id == room:
if (other.business_line or "").upper() == "AIR":
other.collab_chat_id = ""
ticket.collab_chat_id = room
return {
"ok": True,
"work_order_no": no,
"chat_id": room,
"status": ticket.status,
"business_line": ticket.business_line,
}
def lock_cabin(
self,
*,
work_order_no: str,
option_no: str,
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。"""
_ = sender_id, operator_name, instruction_text
forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict):
return dict(forced)
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
quote = dict(ticket.quote or {}) if ticket else {}
return {
"ok": True,
"status": "LOCKED",
"lockId": f"CA-{no}-LOCK",
"quoteId": str(quote.get("quoteId") or f"QT-{no}"),
"optionNo": option_no,
"tmsRequestId": f"TMS-LOCK-{no}",
}
def release_cabin(
self,
*,
work_order_no: str,
lock_id: str,
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
) -> dict[str, Any]:
_ = lock_id, sender_id, operator_name, instruction_text
forced = getattr(self, "_cabin_release_result", None)
if isinstance(forced, dict):
return dict(forced)
no = (work_order_no or "").strip()
return {
"ok": True,
"status": "RELEASED",
"releaseId": f"CA-{no}-RLSE",
"tmsRequestId": f"TMS-RLSE-{no}",
}
+124 -6
View File
@@ -155,18 +155,117 @@ def extract_collab_fields(
return out
def extract_air_collab_fields(
text: str,
*,
allow_b: bool = False,
injected: Optional[dict[str, str]] = None,
) -> dict[str, str]:
"""
空运群只认是否含电、是否含磁。
复用海运口语收割,再滤成这两项。
"""
keys = ("是否含电", "是否含磁")
if injected:
return {
k: str(v).strip()
for k, v in (injected or {}).items()
if k in keys and str(v or "").strip()
}
got = extract_collab_fields(text, allow_b=allow_b)
return {k: got[k] for k in keys if str(got.get(k) or "").strip()}
_RE_MONEY = re.compile(
r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?"
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
re.I,
)
_RE_FEE_LINE = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
r"\s*(?:改为|改成|调整为|调整成)?"
r"\s*[::=]?"
r"\s*(?:(?P<ccy1>USD|CNY|RMB|美金|美元|人民币)\s+)?"
r"(?P<amount>\d+(?:\.\d+)?)"
r"\s*(?P<ccy2>USD|CNY|RMB|美金|美元|人民币|元)?",
re.I,
)
# 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。
_RE_FEE_CHANGE = re.compile(
r"(?P<name>[\u4e00-\u9fff]{2,16}?)"
r"\s*(?:改为|改成|调整为|调整成)"
r"\s*[::=]?"
r"\s*(?P<amount>\d+(?:\.\d+)?)"
r"\s*(?P<ccy>USD|CNY|RMB|美金|美元|人民币|元)?",
re.I,
)
_SKIP_FEE_NAMES = frozenset({"usd", "cny", "rmb", "美金", "美元", "人民币", "元"})
_FULLWIDTH_NUM = str.maketrans("0123456789.", "0123456789.")
def _ascii_amount(raw: str) -> str:
"""全角数字折成半角,方便和模板金额列对齐。"""
return (raw or "").translate(_FULLWIDTH_NUM).strip()
def _currency_of(text: str, hint: str = "") -> str:
blob = f"{hint} {text}".upper()
if any(x in blob for x in ("CNY", "RMB", "人民币", "元")):
return "CNY"
if any(x in blob for x in ("USD", "美金", "美元")):
return "USD"
return hint or "USD"
def _parse_fee_rows(text: str) -> list[dict[str, str]]:
"""
按行抽费用:码头操作费100CNY、报关费改为300CNY。
只认带「费」的中文名或短英文费用码,避免把工单号当金额。
"""
raw = _ascii_amount(text or "")
rows: list[dict[str, str]] = []
seen: set[str] = set()
def _add(name: str, amount: str, ccy: str) -> None:
if not name or not amount or name.lower() in _SKIP_FEE_NAMES:
return
# 「WO202609160001」会被 [A-Za-z]{2,10}+数字 抽成费用名 WO,群里发工单号不能当报价。
if name.upper() == "WO":
return
if name in seen:
return
seen.add(name)
rows.append(
{
"name": name,
"amount": amount,
"currency": _currency_of(raw, ccy),
}
)
for hit in _RE_FEE_LINE.finditer(raw):
_add(
(hit.group("name") or "").strip(),
_ascii_amount(hit.group("amount") or ""),
hit.group("ccy1") or hit.group("ccy2") or "",
)
for hit in _RE_FEE_CHANGE.finditer(raw):
_add(
(hit.group("name") or "").strip(),
_ascii_amount(hit.group("amount") or ""),
hit.group("ccy") or "",
)
return rows
def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]:
"""
产品文字报价:抽出合计/币别。注入优先。
产品文字报价:先按费用行抽,再退回合计/币别。注入优先。
认不出价格返回空 dict,调用方回「没有识别到报价」。
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
"""
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
out = dict(injected)
@@ -175,19 +274,38 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
raw = (text or "").strip()
if not raw:
return {}
hit = _RE_MONEY.search(raw)
compact = raw.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I):
return {}
rows = _parse_fee_rows(raw)
if rows:
ccy = rows[0].get("currency") or _currency_of(raw)
total = rows[0]["amount"]
if len({x.get("currency") for x in rows}) == 1:
try:
total = str(sum(float(x["amount"]) for x in rows))
except ValueError:
total = rows[0]["amount"]
return {
"total": total,
"currency": ccy,
"source_label": "产品报价",
"raw_text": raw,
"fee_rows": rows,
"fee_lines": list(rows),
}
hit = _RE_MONEY.search(_ascii_amount(raw))
if not hit:
return {}
amount = (hit.group(1) or hit.group(2) or "").strip()
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
if not amount:
return {}
ccy = "USD"
if any(x in raw.upper() for x in ("CNY", "RMB", "人民币", "元")):
ccy = "CNY"
ccy = _currency_of(raw)
return {
"total": amount,
"currency": ccy,
"source_label": "产品报价",
"raw_text": raw,
"fee_rows": [{"name": "海运费", "amount": amount, "currency": ccy}],
"fee_lines": [{"name": "海运费", "amount": amount, "currency": ccy}],
}
+829
View File
@@ -0,0 +1,829 @@
"""
空运协同群业务:激活当前工单、补含电含磁、航线报价/附件、锁舱/释放、成交跟进。
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
from agent.policy import inquiry_copy as copy
from agent.policy.sea_group_ops import (
_archive_file,
_persist_quote_sheet,
_staff_display_name,
emit_group_deal,
filename_is_excel_pdf,
group_client_of,
hydrate_ticket,
is_sales,
is_terminal,
merge_quote_fees,
notify_quote_attachment_saved,
send_filled_sheet,
send_group_text,
ticket_no,
ticket_status,
ticket_view,
)
logger = logging.getLogger(__name__)
TERMINAL = {"已成交", "未成交", "已关闭"}
OPEN_ACTIVATE = {"询价中", "已报价", "协商中"}
AIRLINE_ROLE = "air"
_PENDING_QUOTE = "__pending_air_quote"
_PENDING_LOCK = "__pending_air_lock"
_LOCK_ID = "__air_lock_id"
_LOCK_OPTION = "__air_lock_option"
_LOCK_QUOTE = "__air_lock_quote"
_LOCK_EXEC = "__air_lock_exec"
def is_airline(flow: Any, sender_id: str) -> bool:
"""员工管理岗位=航线·空运且启用。"""
uid = (sender_id or "").strip()
if not uid:
return False
book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None)
getter = getattr(book, "get_staff_by_wecom_id", None) if book else None
if not callable(getter):
return False
try:
row = getter(wecom_id=uid) or {}
except Exception:
logger.exception("查航线岗失败 wecom=%s", uid)
return False
code = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
status = str(row.get("status") or "active").strip().lower()
return code == AIRLINE_ROLE and status not in {"disabled", "0", "停用"}
def operator_name(flow: Any, sender_id: str) -> str:
book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None)
getter = getattr(book, "get_staff_by_wecom_id", None) if book else None
if callable(getter):
try:
row = getter(wecom_id=sender_id) or {}
name = str(row.get("name") or "").strip()
if name:
return name
except Exception:
logger.warning("取操作人姓名失败 wecom=%s", sender_id)
return (sender_id or "").strip() or "航线"
def can_fill_fields(flow: Any, ticket: object, sender_id: str) -> bool:
return is_sales(ticket, sender_id) or is_airline(flow, sender_id)
def activate_ticket(
*,
flow: Any,
chat_id: str,
work_order_no: str,
speak: bool = True,
) -> tuple[Optional[object], str]:
"""
把本群当前工单切到指定空运未结案单。
返回 (工单, phase)。失败时工单为 None。
"""
book = flow.ledger
no = (work_order_no or "").strip()
client = group_client_of(flow)
getter = getattr(book, "activate_air_group", None)
if callable(getter):
out = getter(chat_id=chat_id, work_order_no=no) or {}
else:
out = _activate_fallback(book, chat_id=chat_id, work_order_no=no)
err = str(out.get("error") or "")
if err == "ticket_not_found":
if speak:
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
return None, "ticket_missing"
if err == "not_air":
if speak:
send_group_text(client, chat_id, copy.air_ticket_not_air(no))
return None, "not_air"
if err == "terminal":
if speak:
send_group_text(
client, chat_id, copy.air_ticket_closed(no, str(out.get("status") or ""))
)
return None, "ticket_closed"
if err == "occupied":
if speak:
send_group_text(client, chat_id, copy.air_ticket_occupied(no))
return None, "occupied"
if not out.get("ok"):
if speak:
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
return None, "ticket_missing"
ticket = hydrate_ticket(flow, _ticket_from_activate(book, no, out))
if speak:
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
_watch_archive_room(chat_id, no)
return ticket, "activated"
def send_air_brief_and_sheet(
*,
flow: Any,
ticket: object,
chat_id: str = "",
quote_override: Optional[dict[str, Any]] = None,
tms_quote: Optional[dict[str, Any]] = None,
product_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
空运群再发一版摘要 + 匹配到的空运模板。对齐海运 send_brief_and_sheet。
必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
no = ticket_no(ticket)
room = str(chat_id or view.get("collab_chat_id") or view.get("collabChatId") or "").strip()
if room and not view.get("collab_chat_id"):
view["collab_chat_id"] = room
facts = dict(view.get("facts") or {})
collab = dict(view.get("collab_facts") or {})
view["collab_facts"] = collab
view_q = dict(view.get("quote") or {})
override = dict(quote_override or {})
tms = dict(tms_quote) if tms_quote is not None else {}
manual = dict(product_quote or {})
if tms_quote is None:
if view_q and not copy.quote_is_product(view_q):
tms = view_q
if not manual and copy.quote_is_product(override or view_q):
manual = override or view_q
quote = dict(override or view_q or tms or manual)
sales_name, airline_names, mention_ids = resolve_air_mentions(flow, view)
send_group_text(
group_client_of(flow),
room,
copy.air_group_brief(
work_order_no=no,
facts=facts,
quote=quote,
collab_facts=collab,
has_price=bool(tms.get("total") or tms.get("quoteId")),
tms_quote=tms,
product_quote=manual,
sales_name=sales_name,
airline_names=airline_names,
),
mention_userids=mention_ids,
)
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=quote)
return "brief_and_sheet"
def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]:
"""
空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。
"""
view = ticket_view(ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
sales_name = str(view.get("sales_name") or view.get("salesName") or "").strip()
if not sales_name or sales_name == "销售":
sales_name = _staff_display_name(flow, sales_id) if sales_id else ""
if not sales_name:
sales_name = sales_id
air_ids: list[str] = []
air_names: list[str] = []
book = getattr(flow, "ledger", None)
lister = getattr(book, "list_staff_by_role", None) if book is not None else None
rows: list[dict[str, Any]] = []
if callable(lister):
try:
rows = list(lister(role_code="air") or [])
except Exception:
logger.exception("列航线岗失败")
for row in rows:
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
name = str(row.get("name") or "").strip() or _staff_display_name(flow, uid)
if not uid:
continue
air_ids.append(uid)
if name and name not in air_names:
air_names.append(name)
mention_ids: list[str] = []
if sales_id:
mention_ids.append(sales_id)
for uid in air_ids:
if uid not in mention_ids:
mention_ids.append(uid)
return sales_name, air_names, mention_ids
def _send_air_activate_sheet(
*,
flow: Any,
ticket: object,
chat_id: str,
quote: dict[str, Any],
) -> None:
"""填匹配到的空运模板并往群里发。失败只补一句,不回滚激活。"""
client = group_client_of(flow)
try:
painted = send_filled_sheet(
flow=flow,
ticket=ticket,
business_line="AIR",
chat_id=chat_id,
quote_override=quote,
) or {}
except Exception:
logger.exception("空运群发模板失败 chat=%s", chat_id)
send_group_text(client, chat_id, copy.group_sheet_send_fail())
return
if painted.get("ok"):
return
err = str(painted.get("error") or "")
if err == "NO_TEMPLATE":
send_group_text(client, chat_id, painted.get("message") or copy.QUOTE_TEMPLATE_MISSING)
return
if err in {"no_renderer_or_chat", "no_file_client"}:
send_group_text(client, chat_id, copy.group_sheet_send_fail())
def _watch_archive_room(chat_id: str, work_order_no: str) -> None:
"""激活成功后听这个群的存档,否则发文件(不能 @)系统听不见。"""
try:
from agent.channel.wshoto.rooms import watch_collab_room
watch_collab_room(chat_id, work_order_no=work_order_no)
except Exception:
logger.exception("空运群登记存档监听失败 chat=%s wo=%s", chat_id, work_order_no)
def resolve_cabin_ticket(
*,
flow: Any,
chat_id: str,
work_order_no: str,
allow_terminal: bool,
) -> tuple[Optional[object], str]:
"""
锁舱/释放按句中工单号取单,不猜当前单。
未结案:激活并切到本群。已在别的群:occupied,不锁不切。
已成交/未成交:不能再激活,但释放仍要读到这张单(成交拦住、未成交放舱)。
"""
book = flow.ledger
no = (work_order_no or "").strip()
client = group_client_of(flow)
getter = getattr(book, "get_ticket", None)
raw = getter(work_order_no=no) if callable(getter) else None
if raw is None:
send_group_text(client, chat_id, copy.air_ticket_not_found(no))
return None, "ticket_missing"
ticket = hydrate_ticket(flow, raw)
view = ticket_view(ticket)
line = str(view.get("business_line") or view.get("businessLine") or "").upper()
if line and line != "AIR":
send_group_text(client, chat_id, copy.air_ticket_not_air(no))
return None, "not_air"
other = str(view.get("collab_chat_id") or view.get("collabChatId") or "").strip()
if other and other != chat_id:
send_group_text(client, chat_id, copy.air_ticket_occupied(no))
return None, "occupied"
if is_terminal(ticket):
if allow_terminal:
return ticket, "terminal_loaded"
send_group_text(
client, chat_id, copy.air_ticket_closed(no, ticket_status(ticket))
)
return None, "ticket_closed"
return activate_ticket(flow=flow, chat_id=chat_id, work_order_no=no, speak=False)
def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, chat_id: str) -> str:
"""
补含电/含磁。对齐海运:写入后重发群摘要 + 空运模板,不当一句「已记下」。
"""
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
if not can_fill_fields(flow, ticket, sender_id):
send_group_text(client, chat_id, copy.air_fields_denied())
return "fields_denied"
fields = extract_air_collab_fields(text, allow_b=True)
if not fields:
send_group_text(client, chat_id, copy.air_fields_not_recognized())
return "fields_empty"
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
ticket2 = current_ticket(flow, chat_id) or ticket
view = ticket_view(ticket2)
collab = dict(view.get("collab_facts") or {})
collab.update(fields)
view["collab_facts"] = collab
view["collab_chat_id"] = chat_id
send_air_brief_and_sheet(flow=flow, ticket=view, chat_id=chat_id)
return "fields_saved"
def apply_air_quote(
*,
flow: Any,
ticket: object,
text: str,
sender_id: str,
chat_id: str,
injected_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
航线文字报价。对齐海运产品报价:同句可带含电含磁;有 TMS 价先问怎么用;
落定后重发摘要 + 空运模板 + 成交跟进。
"""
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
if not is_airline(flow, sender_id):
send_group_text(client, chat_id, copy.air_airline_only_quote())
return "not_airline"
fields = extract_air_collab_fields(text)
if fields:
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
quote = injected_quote or extract_product_quote(text) or {}
if not quote:
send_group_text(client, chat_id, copy.air_quote_not_recognized())
return "quote_empty"
view = ticket_view(ticket)
current = dict(view.get("quote") or {})
if _has_tms_quote(current) and not _is_product_quote(current):
_park_air_pending(flow, no, quote, sender_id)
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
return "wait_tms_choice"
return _commit_quote(flow, ticket, quote, chat_id, sender_id)
def apply_air_file(
*,
flow: Any,
ticket: object,
sender_id: str,
filename: str,
media: dict[str, Any],
chat_id: str,
) -> str:
"""
空运群附件:对齐海运。只收 Excel/PDF。航线的当正式报价并出成交跟进;别人的只存档。
发文件不能 @,马上回「附件已保存」。不拆文件金额。已有 TMS 报价编号要留下。
"""
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked_file"
if not filename_is_excel_pdf(filename):
send_group_text(client, chat_id, copy.group_only_excel_pdf())
return "file_type_rejected"
_archive_file(flow, no, filename, media, sender_id)
notify_quote_attachment_saved(flow, chat_id, no, count=1)
if not is_airline(flow, sender_id):
return "file_archived"
view = ticket_view(ticket)
quote = dict(view.get("quote") or {})
quote["source_label"] = "航线附件报价"
quote["file_name"] = filename
if not quote.get("total"):
quote["total"] = "-"
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
if not saved.get("ok"):
send_group_text(client, chat_id, "主账写入报价失败。")
return "ledger_fail"
view["collab_chat_id"] = chat_id
view["quote"] = quote
emit_group_deal(flow=flow, ticket=view, quote=quote)
return "airline_file_quote"
def bound_air_ticket(flow: Any, chat_id: str) -> Optional[object]:
"""这群当前绑的是空运单才算空运协同群。海运应用群不要走这里。"""
hit = current_ticket(flow, chat_id)
if hit is None:
return None
line = str(ticket_view(hit).get("business_line") or "").upper()
return hit if line == "AIR" else None
def apply_air_tms_choice(
*,
flow: Any,
ticket: object,
sender_id: str,
chat_id: str,
keep_tms: bool,
) -> str:
"""
航线选:在 TMS 上改,或不用 TMS。对齐海运:用刚记下的价出摘要 + 空运模板 + 成交跟进。
"""
ticket = hydrate_ticket(flow, ticket)
client = group_client_of(flow)
no = ticket_no(ticket)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
if not is_airline(flow, sender_id):
send_group_text(client, chat_id, copy.air_airline_only_quote())
return "not_airline"
pending = _load_air_pending(ticket, no)
if not pending:
send_group_text(client, chat_id, copy.group_need_price_before_choice())
return "quote_choice_no_pending"
view = ticket_view(ticket)
base = dict(view.get("quote") or {})
if keep_tms:
if pending.get("fee_rows") or pending.get("fee_lines"):
merged = merge_quote_fees(base, pending)
else:
merged = dict(base)
merged.update(pending)
if base.get("quoteId") and not pending.get("quoteId"):
merged["quoteId"] = base.get("quoteId")
if base.get("airOptions") and not pending.get("airOptions"):
merged["airOptions"] = base.get("airOptions")
merged["source_label"] = "航线在TMS上改"
else:
merged = dict(pending)
merged["source_label"] = "航线报价"
_clear_air_pending(flow, no, sender_id)
return _commit_quote(flow, ticket, merged, chat_id, sender_id, tms_quote=base if keep_tms else {})
def apply_air_confirm_tms(*, flow: Any, ticket: object, sender_id: str, chat_id: str) -> str:
"""航线确认沿用 TMS:模板入后台,回附件已保存,再出成交跟进。对齐海运。"""
ticket = hydrate_ticket(flow, ticket)
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_quote())
return "not_airline"
view = ticket_view(ticket)
quote = dict(view.get("quote") or {})
if not _has_tms_quote(quote):
send_group_text(group_client_of(flow), chat_id, copy.group_confirm_need_tms())
return "need_tms"
quote["source_label"] = "航线确认TMS"
no = ticket_no(ticket)
_clear_air_pending(flow, no, sender_id)
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
if not saved.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
return "ledger_fail"
view["collab_chat_id"] = chat_id
_persist_quote_sheet(
flow=flow,
ticket=view,
sess=None,
quote=quote,
sender_id=sender_id,
business_line="AIR",
)
notify_quote_attachment_saved(flow, chat_id, no, count=1)
emit_group_deal(flow=flow, ticket=view, quote=quote)
return "quote_confirmed"
def apply_air_deal(
*,
flow: Any,
ticket: object,
sender_id: str,
outcome: str,
chat_id: str,
) -> str:
if not is_sales(ticket, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal())
return "not_sales"
if is_terminal(ticket) and ticket_status(ticket) != "协商中":
if ticket_status(ticket) in {"已成交", "未成交", "已关闭"}:
send_group_text(
group_client_of(flow),
chat_id,
copy.ticket_locked(work_order_no=ticket_no(ticket), status=ticket_status(ticket)),
)
return "ticket_locked"
result = flow.ledger.transition(work_order_no=ticket_no(ticket), to_status=outcome) or {}
if not result.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
send_group_text(group_client_of(flow), chat_id, copy.deal_ack(outcome, ticket_no(ticket)))
return "done"
def apply_air_lock(
*,
flow: Any,
ticket: object,
sender_id: str,
chat_id: str,
instruction: str,
option_no: str = "",
) -> str:
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
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"}]
picked = (option_no or "").strip()
if not picked and len(options) > 1:
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
)
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
return "wait_lock_option"
if not picked:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
locker = getattr(flow.ledger, "lock_cabin", None)
if not callable(locker):
send_group_text(
group_client_of(flow),
chat_id,
copy.air_lock_fail(work_order_no=no, operator=name, reason="主账未开放锁舱"),
)
return "lock_unavailable"
out = locker(
work_order_no=no,
option_no=picked,
sender_id=sender_id,
operator_name=name,
instruction_text=instruction,
) or {}
if not out.get("ok"):
send_group_text(
group_client_of(flow),
chat_id,
copy.air_lock_fail(
work_order_no=no,
operator=name,
reason=str(out.get("failReason") or out.get("error") or "锁舱失败"),
),
)
return "lock_fail"
exec_no = str(out.get("lockId") or out.get("tmsRequestId") or "").strip()
quote_no = str(out.get("quoteId") or quote_id).strip()
flow.ledger.patch_collab_facts(
work_order_no=no,
facts={
_LOCK_ID: exec_no,
_LOCK_OPTION: picked,
_LOCK_QUOTE: quote_no,
_LOCK_EXEC: exec_no,
_PENDING_LOCK: "",
},
sender_id=sender_id,
)
send_group_text(
group_client_of(flow),
chat_id,
copy.air_lock_ok(
work_order_no=no,
operator=name,
option_no=picked,
exec_no=exec_no,
quote_no=quote_no,
),
)
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
return "lock_ok"
def apply_air_release(
*,
flow: Any,
ticket: object,
sender_id: str,
chat_id: str,
instruction: str,
) -> str:
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
no = ticket_no(ticket)
if ticket_status(ticket) == "已成交":
send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no))
return "release_deal_blocked"
facts = dict(ticket_view(ticket).get("collab_facts") or {})
lock_id = str(facts.get(_LOCK_ID) or "").strip()
name = operator_name(flow, sender_id)
if not lock_id:
send_group_text(group_client_of(flow), chat_id, copy.air_release_need_lock(no))
return "release_need_lock"
releaser = getattr(flow.ledger, "release_cabin", None)
if not callable(releaser):
send_group_text(
group_client_of(flow),
chat_id,
copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放释放"),
)
return "release_unavailable"
out = releaser(
work_order_no=no,
lock_id=lock_id,
sender_id=sender_id,
operator_name=name,
instruction_text=instruction,
) or {}
if not out.get("ok"):
send_group_text(
group_client_of(flow),
chat_id,
copy.air_release_fail(
work_order_no=no,
operator=name,
reason=str(out.get("failReason") or out.get("error") or "释放失败"),
),
)
return "release_fail"
exec_no = str(out.get("releaseId") or out.get("tmsRequestId") or lock_id).strip()
send_group_text(
group_client_of(flow),
chat_id,
copy.air_release_ok(
work_order_no=no,
operator=name,
option_no=str(facts.get(_LOCK_OPTION) or ""),
exec_no=exec_no,
quote_no=str(facts.get(_LOCK_QUOTE) or ""),
),
)
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
return "release_ok"
def current_ticket(flow: Any, chat_id: str) -> Optional[object]:
book = flow.ledger
hit = book.find_by_collab_chat(chat_id=chat_id)
if hit is None:
return None
return hydrate_ticket(flow, hit)
def _commit_quote(
flow: Any,
ticket: object,
quote: dict[str, Any],
chat_id: str,
sender_id: str,
tms_quote: Optional[dict[str, Any]] = None,
) -> str:
"""报价落定:写入主账,重发摘要 + 空运模板,再出成交跟进。"""
_ = sender_id
no = ticket_no(ticket)
up = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
return "ledger_fail"
ticket2 = current_ticket(flow, chat_id) or ticket
view = ticket_view(ticket2)
view["collab_chat_id"] = chat_id
view["quote"] = quote
send_air_brief_and_sheet(
flow=flow,
ticket=view,
chat_id=chat_id,
quote_override=quote,
tms_quote=tms_quote if tms_quote is not None else {},
product_quote=quote,
)
emit_group_deal(flow=flow, ticket=view, quote=quote)
return "quoted"
def peek_air_pending(ticket: object) -> dict[str, Any]:
"""这张单是否还在等航线选怎么用 TMS。"""
return _load_air_pending(ticket, ticket_no(ticket))
def _park_air_pending(flow: Any, work_order_no: str, quote: dict[str, Any], sender_id: str) -> None:
"""先记下航线刚报的价,不写主账报价版本。Redis 正式;协同字段兜底给单测。"""
try:
from agent.redis_coord.pending_quote import save_pending_product_quote
save_pending_product_quote(work_order_no, quote)
except Exception:
logger.exception("待确认航线报价写入失败 wo=%s", work_order_no)
flow.ledger.patch_collab_facts(
work_order_no=work_order_no,
facts={_PENDING_QUOTE: json.dumps(quote, ensure_ascii=False)},
sender_id=sender_id,
)
def _load_air_pending(ticket: object, work_order_no: str) -> dict[str, Any]:
try:
from agent.redis_coord.pending_quote import load_pending_product_quote
parked = load_pending_product_quote(work_order_no)
if parked:
return parked
except Exception:
logger.exception("待确认航线报价读取失败 wo=%s", work_order_no)
raw = str((ticket_view(ticket).get("collab_facts") or {}).get(_PENDING_QUOTE) or "").strip()
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
return dict(data) if isinstance(data, dict) else {}
def _clear_air_pending(flow: Any, work_order_no: str, sender_id: str) -> None:
try:
from agent.redis_coord.pending_quote import clear_pending_product_quote
clear_pending_product_quote(work_order_no)
except Exception:
logger.exception("待确认航线报价清理失败 wo=%s", work_order_no)
flow.ledger.patch_collab_facts(
work_order_no=work_order_no,
facts={_PENDING_QUOTE: ""},
sender_id=sender_id,
)
def _has_tms_quote(quote: dict[str, Any]) -> bool:
"""当前价来自 TMS 才要问;已经是航线/产品价不再替用户选。"""
src = dict(quote or {})
label = str(src.get("source_label") or src.get("source") or "")
if "航线" in label or "产品" in label:
return False
if not (src.get("total") or src.get("quoteId") or src.get("quote_id")):
return False
return "TMS" in label.upper() or "标准报价" in label or bool(src.get("quoteId") or src.get("quote_id"))
def _is_product_quote(quote: dict[str, Any]) -> bool:
src = str(quote.get("source_label") or "")
return "航线" in src or "产品" in src
def _quote_id(quote: dict[str, Any]) -> str:
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
raw = quote.get("airOptions") or quote.get("air_options")
if isinstance(raw, str) and raw.strip().startswith("["):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
raw = []
if isinstance(raw, list):
return [x for x in raw if isinstance(x, dict)]
blob = str(quote.get("air_options_json") or "").strip()
if blob.startswith("["):
try:
parsed = json.loads(blob)
except json.JSONDecodeError:
return []
return [x for x in parsed if isinstance(x, dict)]
return []
def _activate_fallback(book: Any, *, chat_id: str, work_order_no: str) -> dict[str, Any]:
"""内存账本未实现 activate 时的兜底,单测应走正式方法。"""
getter = getattr(book, "get_ticket", None)
ticket = getter(work_order_no=work_order_no) if callable(getter) else None
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
return {"ok": True, "work_order_no": work_order_no}
def _ticket_from_activate(book: Any, no: str, out: dict[str, Any]) -> object:
getter = getattr(book, "get_ticket", None)
if callable(getter):
hit = getter(work_order_no=no)
if hit is not None:
return hit
return out
+11 -1
View File
@@ -52,6 +52,7 @@ class FlowSession:
invited_product_names: tuple[str, ...] = ()
deal_version: int = 0
collab_ended: bool = False
pending_product_quote: dict[str, Any] = field(default_factory=dict)
class AirTextInquiryFlow:
@@ -62,16 +63,25 @@ class AirTextInquiryFlow:
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
"""
def __init__(self, ledger: Any = None) -> None:
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
self._ledger = ledger or build_ledger()
self._lock = threading.Lock()
self._by_sender: dict[str, FlowSession] = {}
self._by_ticket: dict[str, FlowSession] = {}
self._group_client = group_client
@property
def ledger(self) -> Any:
return self._ledger
@ledger.setter
def ledger(self, value: Any) -> None:
self._ledger = value
def group_client(self) -> Any:
"""空运群 BOT 出站;单测注入假客户端。"""
return self._group_client
def session_of(self, sender_id: str) -> Optional[FlowSession]:
with self._lock:
return self._by_sender.get(sender_id)
+330 -11
View File
@@ -14,6 +14,7 @@ import uuid
from typing import Any, Iterable
from agent.schema.field_validate import (
AIR_COLLAB_FIELDS,
AIR_REQUIRED_FIELDS,
SEA_COLLAB_DISPLAY,
SEA_COLLAB_FIELDS,
@@ -238,8 +239,8 @@ def inquiry_field_lines(
起运/目的展示销售原文(南京/北京),三字码只给出站 TMS,不对销售展示。
"""
origin = (facts.get("起运港") or "-").strip()
dest = (facts.get("目的港") or "-").strip()
origin = (facts.get("起运港") or facts.get("起运地") or "-").strip()
dest = (facts.get("目的港") or facts.get("目的地") or "-").strip()
cargo = (facts.get("品名") or "-").strip() or "-"
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
quote_date = (facts.get("报价日期") or "-").strip() or "-"
@@ -452,7 +453,7 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
"""
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
raw = src.get("fee_lines")
raw = src.get("fee_lines") or src.get("fee_rows")
parsed: Any = raw
if isinstance(raw, str) and raw.strip():
try:
@@ -988,6 +989,12 @@ def sea_group_create_fail(*, reason: str = "") -> str:
return "建群失败,请稍后重试。"
def quote_is_product(quote: dict[str, Any] | None) -> bool:
"""来源是产品/航线人工,不能写进 TMS 报价段。"""
label = str((quote or {}).get("source_label") or (quote or {}).get("source") or "")
return "产品" in label or "航线" in label
def sea_group_brief(
*,
work_order_no: str,
@@ -997,12 +1004,13 @@ def sea_group_brief(
sales_name: str,
product_names: list[str],
has_price: bool,
tms_quote: dict[str, Any] | None = None,
product_quote: dict[str, Any] | None = None,
) -> str:
"""
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。
三段之间空一行,方便群里扫。协同没填写 -。
HS编码对外写成商品海关编码。无价只写「TMS 暂无匹配报价」。
TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,不能混进 TMS。
"""
no = (work_order_no or "").strip()
sales = (sales_name or "").strip() or "销售"
@@ -1019,12 +1027,30 @@ def sea_group_brief(
label = SEA_COLLAB_DISPLAY.get(key, key)
val = (collab.get(key) or collab.get(label) or "").strip() or "-"
lines.append(f"{label}:{val}")
tms = dict(tms_quote) if tms_quote is not None else {}
manual = dict(product_quote or {})
if tms_quote is None:
src = dict(quote or {})
if has_price and src and not quote_is_product(src):
tms = src
elif quote_is_product(src):
manual = manual or src
lines.append("")
lines.append("TMS报价:")
if has_price and quote:
lines.extend(quote_detail_lines(quote))
if tms and (tms.get("total") or quote_fee_lines(tms)):
lines.extend(quote_detail_lines(tms))
lines.append(group_tms_confirm_hint())
else:
lines.append("TMS 暂无匹配报价")
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
fee_lines = quote_fee_lines(manual)
if fee_lines:
lines.extend(f"{name}:{amount}" for name, amount in fee_lines)
else:
header_ccy = str(manual.get("currency") or "").strip()
lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}")
return "\n".join(lines)
@@ -1335,6 +1361,46 @@ def group_only_excel_pdf() -> str:
return "群里只收 Excel 和 PDF,其它文件不会存入工单"
def group_only_product_can_quote() -> str:
return "只有产品能报价格,请产品在群里发价格或 Excel/PDF"
def group_tms_confirm_hint() -> str:
"""报价有效期下方:有 TMS 价时告诉产品还可以确认沿用。"""
return "有TMS报价时可回复“确认报价”沿用该价;也可直接回复协同报价或上传报价附件。"
def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str:
"""群里告诉大家:模板或产品附件已进后台。"""
no = (work_order_no or "").strip() or "WO"
n = count if count > 0 else 1
return f"工单{no}的{n}个附件已保存到后台工单"
def group_confirm_need_tms() -> str:
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
def group_ask_tms_or_product() -> str:
return (
"本单已有 TMS 报价。\n"
"1. 要在 TMS 基础上改,请回复:在TMS基础上改(或直接回 1 / 第一个)\n"
"2. 不要 TMS 报价、只用你刚说的价格,请回复:不用TMS报价(或直接回 2 / 第二个)"
)
def group_tms_choice_unclear() -> str:
return "还没确定怎么用 TMS 报价。请回复:在TMS基础上改 / 不用TMS报价,也可以直接回 1 或 2。"
def group_need_price_before_choice() -> str:
return "请产品先发价格,再说在TMS基础上改或不用TMS报价"
def group_file_saved() -> str:
return "附件已保存后台"
def group_quote_not_recognized() -> str:
return "没有识别到报价,请产品再发一次价格或 Excel/PDF"
@@ -1349,6 +1415,10 @@ def group_deal_stale(*, work_order_no: str) -> str:
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
def group_deal_need_quote() -> str:
return "还没有可确认的报价,请先完成报价再确认成交状态。"
def group_deal_sales_only() -> str:
return "只有负责该工单的销售可以确认成交状态。"
@@ -1364,14 +1434,263 @@ def group_cannot_recreate(*, work_order_no: str) -> str:
return f"工单{no}协同已结束,不能再拉群。"
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
def group_deal_followup(*, sales_name: str, work_order_no: str) -> str:
"""
群里文字成交跟进。只提示输入,不含报价合计、不含点选按钮。
私聊成交卡仍走 deal_card,不改这里。
"""
name = (sales_name or "").strip() or "销售"
return f"@{name}\n{deal_card(work_order_no=work_order_no)}"
no = (work_order_no or "").strip()
return (
f"@{name}\n"
f"询价工单 {no}\n"
"报价单已发送,请确认成交状态\n"
"咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)"
)
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
return group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
def deal_ack(outcome: str, work_order_no: str) -> str:
if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
if outcome == "未成交":
return f"已记录为「未成交」。请补充未成交原因,工单 {work_order_no} 将关闭智能体跟进。"
return "已记录为「未成交」。请补充未成交原因。"
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
def air_need_work_order() -> str:
"""空运群还没有当前单,且这句话里没有工单号。"""
return "请先 @我 并发送要跟的工单号,例如 WO202609160001。"
def air_lock_need_work_order() -> str:
"""锁舱/释放必须自带工单号,不猜当前单。"""
return "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。"
def air_ticket_not_found(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"找不到工单{no},请核对工单号后再试。"
def air_ticket_not_air(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}不是空运单,不能在空运群里跟。"
def air_ticket_closed(work_order_no: str, status: str) -> str:
no = (work_order_no or "").strip()
st = (status or "").strip() or "已关闭"
return f"工单{no}已是「{st}」,不能再激活。"
def air_ticket_occupied(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}已经在另一个群跟了,请到那个群继续。"
def air_group_brief(
*,
work_order_no: str,
facts: dict[str, str],
quote: dict[str, Any] | None,
collab_facts: dict[str, str] | None,
has_price: bool,
tms_quote: dict[str, Any] | None = None,
product_quote: dict[str, Any] | None = None,
sales_name: str = "",
airline_names: list[str] | None = None,
) -> str:
"""
空运群激活摘要:与海运进群同一套结构,字段用空运。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
不写「当前工单/询价要点/现在等到哪一步」。
"""
no = (work_order_no or "").strip()
at_bits: list[str] = []
sales = (sales_name or "").strip()
if sales:
at_bits.append(f"@{sales}")
for name in airline_names or []:
token = f"@{(name or '').strip()}"
if (name or "").strip() and token not in at_bits:
at_bits.append(token)
lines = ([(" ".join(at_bits))] if at_bits else []) + [f"工单号:{no}", "询价字段:"]
lines.extend(
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="空运")[1:]
)
lines.append("")
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
for key in AIR_COLLAB_FIELDS:
val = (collab.get(key) or "").strip() or "-"
lines.append(f"{key}:{val}")
tms = dict(tms_quote) if tms_quote is not None else {}
manual = dict(product_quote or {})
if tms_quote is None:
src = dict(quote or {})
if has_price and src and not quote_is_product(src):
tms = src
elif quote_is_product(src):
manual = manual or src
lines.append("")
lines.append("TMS报价:")
if tms and (tms.get("total") or quote_fee_lines(tms)):
lines.extend(quote_detail_lines(tms))
lines.append(group_tms_confirm_hint())
else:
lines.append("TMS 暂无匹配报价")
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
fee_lines = quote_fee_lines(manual)
if fee_lines:
lines.extend(f"{name}:{amount}" for name, amount in fee_lines)
else:
header_ccy = str(manual.get("currency") or "").strip()
lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}")
return "\n".join(lines)
def air_activate_summary(
*,
work_order_no: str,
facts: dict,
status: str = "",
wait_hint: str = "",
quote: dict[str, Any] | None = None,
collab_facts: dict[str, str] | None = None,
) -> str:
"""兼容旧名:激活成功改走海运同款群摘要。"""
_ = status, wait_hint
src = dict(quote or {})
return air_group_brief(
work_order_no=work_order_no,
facts=dict(facts or {}),
quote=src,
collab_facts=collab_facts,
has_price=bool(src.get("total") or src.get("quoteId")),
tms_quote=src if src and not quote_is_product(src) else {},
product_quote=src if quote_is_product(src) else {},
)
def air_wait_hint(*, status: str, has_quote: bool, pending_lock: bool = False) -> str:
"""激活摘要里的「现在等到哪一步」。"""
if pending_lock:
return "等待航线选择要锁的舱位选项"
st = (status or "").strip()
if st == "协商中":
return "协商中,可继续改价或确认成交"
if has_quote or st == "已报价":
return "已有报价,可补含电/含磁、航线改价、锁舱或确认成交"
return "等待航线报价或锁舱(须先有 TMS 报价)"
def air_airline_only_quote() -> str:
return "只有岗位为「航线·空运」的人能报价格。"
def air_airline_only_cabin() -> str:
return "只有岗位为「航线·空运」的人能锁舱或释放舱位。"
def air_sales_only_deal() -> str:
return "只有负责该工单的销售可以确认成交状态。"
def air_fields_not_recognized() -> str:
return "听到了。空运群里可补充是否含电、是否含磁。"
def air_quote_not_recognized() -> str:
return "没有识别到报价,请航线再发一次价格或 Excel/PDF。"
def air_fields_denied() -> str:
return "只有这张单的销售或航线可以补含电/含磁。"
def air_no_tms_quote_to_lock(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}还没有可锁的 TMS 报价,不能锁舱。"
def air_ask_lock_option(options: list) -> str:
lines = ["这张单有多条舱位选项,请回复要锁哪一条,例如 AIR-OPT-01:"]
for item in options:
no = str(item.get("optionNo") or item.get("option_no") or "").strip()
airline = str(item.get("airlineName") or item.get("airlineCode") or "").strip()
if no and airline:
lines.append(f"{no} {airline}")
elif no:
lines.append(no)
return "\n".join(lines)
def air_lock_ok(
*,
work_order_no: str,
operator: str,
option_no: str,
exec_no: str,
quote_no: str,
) -> str:
return (
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}"
)
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
why = (reason or "").strip() or "TMS未返回原因"
return (
f"工单{work_order_no} 空运锁舱失败。\n"
f"操作人:{operator}\n"
f"锁舱失败的原因:{why}\n"
"已通知IT运维排查,请稍等。"
)
def air_release_ok(
*,
work_order_no: str,
operator: str,
option_no: str,
exec_no: str,
quote_no: str,
) -> str:
return (
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}"
)
def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
why = (reason or "").strip() or "TMS未返回原因"
return (
f"工单{work_order_no} 空运释放舱位失败\n"
f"操作人:{operator}\n"
f"释放舱位失败的原因:{why}"
)
def air_release_deal_blocked(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"释放舱位失败,工单{no}已成交。"
def air_release_need_lock(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}还没有有效锁舱,不能释放。"
+620 -43
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import json
import logging
from typing import Any, Optional
@@ -101,19 +102,44 @@ def is_sales(ticket: object, sender_id: str) -> bool:
return bool(uid) and uid == sales
def is_product(ticket: object, sender_id: str) -> bool:
"""产品岗:成员里且不是销售。"""
def is_product(ticket: object, sender_id: str, flow: Any = None) -> bool:
"""
产品岗:工单产品名单,或后台员工管理挂了产品·海运。
同一个人可以既是拉群销售又是产品。先当销售挡掉的话,自己报价群里会没反应。
"""
uid = (sender_id or "").strip()
if not uid or is_sales(ticket, uid):
if not uid:
return False
view = ticket_view(ticket)
products = {str(x) for x in (view.get("product_ids") or view.get("productIds") or [])}
if uid in products:
return True
if _staff_is_sea_product(flow, uid):
return True
if is_sales(ticket, uid):
return False
members = {str(x) for x in (view.get("collab_member_ids") or view.get("memberIds") or [])}
products = {str(x) for x in (view.get("product_ids") or [])}
if products:
return uid in products
return uid in members
def _staff_is_sea_product(flow: Any, wecom_id: str) -> bool:
"""后台员工管理:role_code=sea 且能查到,才算产品岗。"""
book = getattr(flow, "ledger", None) if flow is not None else None
finder = getattr(book, "get_staff_by_wecom_id", None) if book is not None else None
if not callable(finder):
return False
try:
row = finder(wecom_id=wecom_id) or {}
except Exception:
logger.exception("查产品岗失败 wecom=%s", wecom_id)
return False
role = str(row.get("roleCode") or row.get("role_code") or "").strip().lower()
return role == "sea"
def filename_is_excel_pdf(name: str) -> bool:
raw = (name or "").strip().lower()
return any(raw.endswith(ext) for ext in EXCEL_PDF)
@@ -249,14 +275,21 @@ def send_group_text(
def hydrate_ticket(flow: Any, ticket: object) -> object:
"""
按群找回的工单若缺询价七项,用工单号+销售再读主账补全。
按群找回的工单补全询价字段、报价、六态。
测服 byCollabChat 可能只回协同字段;续办 get 空 sender 也不回 facts。
禁止用空 facts 把已确认港口冲成「-」。
byCollabChat 可能有港口却没有报价/状态;有字段就提前返回的话,
在TMS基础上改会丢掉原价,未成交也会被误判成还没报价。
"""
view = ticket_view(ticket)
facts = dict(view.get("facts") or {})
if any(str(facts.get(k) or "").strip() for k in ("起运港", "目的港", "品名", "货量")):
has_facts = any(
str(facts.get(k) or "").strip()
for k in ("起运港", "目的港", "品名", "货量", "起运地", "目的地", "件数", "毛重")
)
quote = dict(view.get("quote") or {})
has_quote = bool(quote.get("total") or _quote_fee_rows(quote))
has_status = bool(ticket_status(ticket))
if has_facts and has_quote and has_status:
return ticket
no = ticket_no(ticket)
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
@@ -267,7 +300,9 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
getter = getattr(book, "get_for_agent", None)
if callable(getter) and sales:
full = getter(work_order_no=no, sender_id=sales)
if not isinstance(full, dict) or not dict(full.get("facts") or {}):
if not isinstance(full, dict) or not (
dict(full.get("facts") or {}) or dict(full.get("quote") or {}) or full.get("status")
):
getter = getattr(book, "get_ticket", None)
if callable(getter):
full = getter(work_order_no=no)
@@ -275,12 +310,12 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
return ticket
merged = dict(view)
full_view = ticket_view(full)
if full_view.get("facts"):
if (not has_facts) and full_view.get("facts"):
merged["facts"] = dict(full_view.get("facts") or {})
if full_view.get("quote") and not merged.get("quote"):
if (not has_quote) and full_view.get("quote"):
merged["quote"] = dict(full_view.get("quote") or {})
if full_view.get("status"):
merged.setdefault("status", full_view.get("status"))
if (not has_status) and full_view.get("status"):
merged["status"] = full_view.get("status")
collab = dict(full_view.get("collab_facts") or {})
collab.update(dict(view.get("collab_facts") or {}))
merged["collab_facts"] = collab
@@ -295,11 +330,13 @@ def send_brief_and_sheet(
ticket: object,
sess: Any = None,
quote_override: Optional[dict[str, Any]] = None,
tms_quote: Optional[dict[str, Any]] = None,
product_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
群里再发一版摘要 + 填好的海运模板。旧消息不撤。
无价也发表,询价和协同能填的先填。
TMS 和人工报价分段写,无价也发表。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
@@ -312,20 +349,39 @@ def send_brief_and_sheet(
collab = dict(view.get("collab_facts") or {})
if sess is not None and getattr(sess, "collab_facts", None):
collab.update(dict(sess.collab_facts or {}))
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
view_q = dict(view.get("quote") or {})
sess_q = dict(sess.quote) if sess is not None and getattr(sess, "quote", None) else {}
override = dict(quote_override or {})
tms = dict(tms_quote) if tms_quote is not None else {}
manual = dict(product_quote or {})
if tms_quote is None:
if _has_tms_quote(view_q) and not copy.quote_is_product(view_q):
tms = view_q
elif _has_tms_quote(sess_q) and not copy.quote_is_product(sess_q):
tms = sess_q
if not manual:
if copy.quote_is_product(override):
manual = override
elif copy.quote_is_product(view_q):
manual = view_q
elif copy.quote_is_product(sess_q):
manual = sess_q
quote = dict(override or sess_q or view_q or tms or manual)
sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess)
has_price = bool(quote.get("total") or quote.get("fee_rows"))
has_tms = bool(tms.get("total") or _quote_fee_rows(tms))
brief = copy.sea_group_brief(
work_order_no=no,
facts=facts,
quote=quote,
quote=tms or None,
collab_facts=collab,
sales_name=sales_name,
product_names=products,
has_price=has_price,
has_price=has_tms,
tms_quote=tms,
product_quote=manual,
)
send_group_text(client, chat_id, brief, mention_userids=mention_ids)
send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote_override)
send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote)
return "brief_and_sheet"
@@ -335,12 +391,23 @@ def send_filled_sheet(
ticket: object,
sess: Any = None,
quote_override: Optional[dict[str, Any]] = None,
) -> None:
"""只发填好的模板文件,不再发文字摘要。"""
business_line: str = "SEA",
chat_id: str = "",
) -> dict[str, Any]:
"""
只发填好的模板文件,不再发文字摘要。
business_line 决定匹配哪套后台模板;空运必须传 AIR,禁止落到海运默认模板。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket) if ticket is not None else {}
no = (sess.work_order_no if sess else "") or ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip()
room = str(
chat_id
or (sess.collab_chat_id if sess else "")
or view.get("collab_chat_id")
or ""
).strip()
facts = dict(view.get("facts") or {})
if sess is not None and getattr(sess, "facts", None):
facts.update(dict(sess.facts or {}))
@@ -348,9 +415,10 @@ def send_filled_sheet(
if sess is not None and getattr(sess, "collab_facts", None):
collab.update(dict(sess.collab_facts or {}))
quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {})
_render_and_send_sheet(
line = (business_line or str(view.get("business_line") or view.get("businessLine") or "SEA")).strip()
return _render_and_send_sheet(
flow=flow,
chat_id=chat_id,
chat_id=room,
work_order_no=no,
facts=_facts_for_fill(facts, collab),
quote=quote,
@@ -360,6 +428,7 @@ def send_filled_sheet(
or view.get("salesWecomId")
or ""
),
business_line=line or "SEA",
)
@@ -416,27 +485,34 @@ def _render_and_send_sheet(
facts: dict[str, Any],
quote: dict[str, Any],
sales_id: str,
) -> None:
business_line: str = "SEA",
) -> dict[str, Any]:
"""
按业务线匹配后台模板、填表、发群文件。
空运必须 business_line=AIR,否则会套上海运默认模板。
"""
renderer = getattr(flow.ledger, "render_quote", None)
client = group_client_of(flow)
if not callable(renderer) or not chat_id:
return
return {"ok": False, "error": "no_renderer_or_chat"}
line = (business_line or "SEA").strip() or "SEA"
result = renderer(
work_order_no=work_order_no,
business_line="SEA",
business_line=line,
facts=facts,
quote=quote,
format="xlsx",
sales_wecom_id=sales_id,
) or {}
if not result.get("ok"):
logger.warning("群填表失败 wo=%s err=%s", work_order_no, result.get("error"))
return
logger.warning("群填表失败 wo=%s line=%s err=%s", work_order_no, line, result.get("error"))
return result
filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx")
sender = getattr(client, "send_group_file", None)
if not callable(sender):
logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id)
return
return {"ok": False, "error": "no_file_client", "message": copy.group_sheet_send_fail()}
painted = overlay_labeled_facts(
filepath=str(result.get("filePath") or ""),
file_b64=str(result.get("fileBase64") or ""),
@@ -451,8 +527,9 @@ def _render_and_send_sheet(
if sent.get("ok") is False:
logger.warning("群发文件失败 wo=%s err=%s", work_order_no, sent.get("error"))
send_group_text(client, chat_id, copy.group_sheet_send_fail())
return
logger.info("群已发模板 wo=%s file=%s", work_order_no, filename)
return sent
logger.info("群已发模板 wo=%s line=%s file=%s", work_order_no, line, filename)
return {"ok": True, "fileName": filename, "templateId": result.get("templateId")}
def apply_collab_update(
@@ -464,6 +541,7 @@ def apply_collab_update(
sess: Any = None,
) -> str:
"""补协同六项。终态要说明原因。"""
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "")
client = group_client_of(flow)
@@ -501,10 +579,11 @@ def apply_product_quote(
injected_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
产品文字报价:盖 TMS 价、填表发出、群里成交跟进。
产品文字报价。TMS 已有价时先问怎么用,不擅自清掉或盖上。
一句话里的协同字段一并写入。非产品或终态要回话。
"""
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
@@ -512,8 +591,9 @@ def apply_product_quote(
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
if not is_product(ticket, sender_id):
return "group_ignore"
if not is_product(ticket, sender_id, flow=flow):
send_group_text(client, chat_id, copy.group_only_product_can_quote())
return "quote_not_product"
fields = extract_collab_fields(text)
if fields:
flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
@@ -523,15 +603,182 @@ def apply_product_quote(
sess.collab_facts = merged
quote = extract_product_quote(text, injected=injected_quote)
if not quote:
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
send_group_text(client, chat_id, copy.group_quote_not_recognized())
return "quote_not_recognized"
base = _current_quote(view, sess)
if _has_tms_quote(base):
_park_pending_quote(no, quote, sess, flow)
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
return "wait_tms_choice"
_save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket
send_brief_and_sheet(flow=flow, ticket=ticket2, sess=sess, quote_override=quote)
send_brief_and_sheet(
flow=flow,
ticket=ticket2,
sess=sess,
quote_override=quote,
tms_quote={},
product_quote=quote,
)
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
return "product_quote_sent"
def apply_tms_quote_choice(
*,
flow: Any,
ticket: object,
sender_id: str,
keep_tms: bool,
sess: Any = None,
) -> str:
"""
任一产品选:在 TMS 上改,或不用 TMS。
用刚才记下的那份价出 Excel,不再问第二遍价格。
"""
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
if not is_product(ticket, sender_id, flow=flow):
send_group_text(client, chat_id, copy.group_only_product_can_quote())
return "quote_not_product"
pending = _load_pending_quote(no, sess)
if not pending:
send_group_text(client, chat_id, copy.group_need_price_before_choice())
return "quote_choice_no_pending"
base = _current_quote(view, sess)
if keep_tms:
quote = merge_quote_fees(base, pending)
else:
quote = dict(pending)
quote.setdefault("source_label", "产品报价")
overlay = dict(pending)
_clear_pending_quote(no, sess, flow)
_save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket
send_brief_and_sheet(
flow=flow,
ticket=ticket2,
sess=sess,
quote_override=quote,
tms_quote=base if keep_tms else {},
product_quote=overlay if keep_tms else quote,
)
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
return "product_quote_sent"
def apply_confirm_tms_quote(
*,
flow: Any,
ticket: object,
sender_id: str,
sess: Any = None,
) -> str:
"""
产品确认沿用当前 TMS 价:模板入后台,不再问第二遍价格。
群里告诉大家附件已保存,再发成交跟进。
"""
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket):
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket)))
return "ticket_locked"
if not is_product(ticket, sender_id, flow=flow):
send_group_text(client, chat_id, copy.group_only_product_can_quote())
return "quote_not_product"
quote = _current_quote(view, sess)
if not (quote.get("total") or _quote_fee_rows(quote)):
send_group_text(client, chat_id, copy.group_confirm_need_tms())
return "quote_confirm_no_tms"
_clear_pending_quote(no, sess, flow)
confirmed = dict(quote)
confirmed["source_label"] = "产品确认TMS"
_save_product_quote(flow, no, confirmed, sess)
_persist_quote_sheet(
flow=flow,
ticket=ticket,
sess=sess,
quote=confirmed,
sender_id=sender_id,
)
notify_quote_attachment_saved(flow, chat_id, no, count=1)
emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=confirmed)
return "quote_confirmed"
def notify_quote_attachment_saved(
flow: Any,
chat_id: str,
work_order_no: str,
count: int = 1,
) -> None:
"""群里告诉大家:模板或产品附件已进后台。"""
send_group_text(
group_client_of(flow),
chat_id,
copy.quote_attachment_saved_app(work_order_no, count),
)
def _persist_quote_sheet(
*,
flow: Any,
ticket: object,
sess: Any,
quote: dict[str, Any],
sender_id: str,
business_line: str = "",
) -> None:
"""把当前价填进匹配模板并写入主账附件,不重复往群里丢文件。"""
view = ticket_view(ticket)
no = ticket_no(ticket) or (sess.work_order_no if sess else "")
facts = dict(view.get("facts") or {})
if sess is not None and getattr(sess, "facts", None):
facts.update(dict(sess.facts or {}))
collab = dict(view.get("collab_facts") or {})
if sess is not None and getattr(sess, "collab_facts", None):
collab.update(dict(sess.collab_facts or {}))
renderer = getattr(flow.ledger, "render_quote", None)
filename = f"{no}_quote.xlsx"
object_key = filename
line = str(
business_line
or view.get("business_line")
or view.get("businessLine")
or "SEA"
).strip() or "SEA"
if callable(renderer) and no:
result = renderer(
work_order_no=no,
business_line=line,
facts=_facts_for_fill(facts, collab),
quote=quote,
format="xlsx",
sales_wecom_id=str(
(sess.sender_id if sess else "")
or view.get("sales_wecom_id")
or view.get("salesWecomId")
or ""
),
) or {}
if result.get("ok"):
filename = str(result.get("fileName") or filename)
object_key = str(result.get("objectKey") or result.get("fileName") or filename)
_archive_file(flow, no, filename, {"media_id": object_key}, sender_id)
def handle_group_file(
*,
flow: Any,
@@ -556,13 +803,14 @@ def handle_group_file(
send_group_text(client, chat_id, copy.group_only_excel_pdf())
return "file_type_rejected"
_archive_file(flow, no, filename, media, sender_id)
if is_product(ticket, sender_id):
if is_product(ticket, sender_id, flow=flow):
quote = {
"source_label": "产品附件报价",
"file_name": filename,
"total": str((view.get("quote") or {}).get("total") or "-"),
}
_save_product_quote(flow, no, quote, sess)
notify_quote_attachment_saved(flow, chat_id, no, count=1)
emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=quote)
return "product_file_quote"
return "file_archived"
@@ -621,17 +869,61 @@ def emit_group_deal(
sess.quote = q
sess.wait_version += 1
flow._save(sess)
sales_name, _products, mention_ids = resolve_group_mentions(flow, ticket, sess)
text = f"@{sales_name}\n" + copy.deal_card(work_order_no=no, quote=q, facts=facts)
# 成交跟进只点销售:产品不能点这张卡。
sales_mention = mention_ids[:1] if mention_ids else []
send_group_text(client, chat_id, text, mention_userids=sales_mention)
sales_name, _products, _mention_ids = resolve_group_mentions(flow, ticket, sess)
# 群里文字版只 @ 销售并请输入,不把私聊那套合计/请选择贴过来。
text = copy.group_deal_followup(sales_name=sales_name, work_order_no=no)
send_group_text(client, chat_id, text)
carder = getattr(client, "send_group_card", None)
payload = copy.deal_wecom_payload(work_order_no=no, quote=q, facts=facts, card_version=ver)
if callable(carder):
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
def apply_group_deal(
*,
flow: Any,
ticket: object,
sender_id: str,
outcome: str,
sess: Any = None,
) -> str:
"""
群里文字确认成交/未成交/协商中。口令不写死,意图在 group_intent。
只有这单销售能确认。没有报价要说明,不能装没听见。
"""
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if is_terminal(ticket) or (sess is not None and getattr(sess, "status", "") in TERMINAL):
status = ticket_status(ticket) or (sess.status if sess else "")
send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=status))
return "ticket_locked"
if not is_sales(ticket, sender_id):
send_group_text(client, chat_id, copy.group_deal_sales_only())
return "not_sales"
quote = _current_quote(view, sess)
status_now = ticket_status(ticket) or (sess.status if sess else "")
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(client, chat_id, copy.group_deal_need_quote())
return "deal_need_quote"
writer = getattr(flow.ledger, "transition", None)
if callable(writer):
result = writer(work_order_no=no, to_status=outcome) or {}
if not result.get("ok"):
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
if sess is not None:
sess.status = outcome
sess.phase = "done"
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.deal_ack(outcome, no))
return "done"
def explain_deal_click(
*,
flow: Any,
@@ -666,12 +958,297 @@ def explain_deal_click(
return ""
def _current_quote(view: dict[str, Any], sess: Any) -> dict[str, Any]:
"""主账报价优先;书签只在主账还没费用行时兜底。"""
base = dict(view.get("quote") or {})
if not _quote_fee_rows(base) and sess is not None and getattr(sess, "quote", None):
return dict(sess.quote)
return base
def _has_tms_quote(quote: dict[str, Any] | None) -> bool:
"""当前价来自 TMS 才要问;已经是产品价不再替用户选。"""
src = dict(quote or {})
label = str(src.get("source_label") or src.get("source") or "")
if "产品" in label:
return False
if not (src.get("total") or _quote_fee_rows(src)):
return False
return "TMS" in label.upper() or "标准报价" in label or not label
def _park_pending_quote(no: str, quote: dict[str, Any], sess: Any, flow: Any) -> None:
"""先记下产品刚报的价,不写主账。"""
if sess is not None:
sess.pending_product_quote = dict(quote)
if hasattr(flow, "_save"):
flow._save(sess)
try:
from agent.redis_coord.pending_quote import save_pending_product_quote
save_pending_product_quote(no, quote)
except Exception:
logger.exception("待确认报价写入失败 wo=%s", no)
def peek_pending_product_quote(no: str, sess: Any = None) -> dict[str, Any]:
"""群里还在等选 TMS 时,给入口判断要不要回话。"""
return _load_pending_quote(no, sess)
def _load_pending_quote(no: str, sess: Any) -> dict[str, Any]:
if sess is not None and isinstance(getattr(sess, "pending_product_quote", None), dict):
parked = dict(sess.pending_product_quote or {})
if parked:
return parked
try:
from agent.redis_coord.pending_quote import load_pending_product_quote
return load_pending_product_quote(no)
except Exception:
logger.exception("待确认报价读取失败 wo=%s", no)
return {}
def _clear_pending_quote(no: str, sess: Any, flow: Any) -> None:
if sess is not None:
sess.pending_product_quote = {}
if hasattr(flow, "_save"):
flow._save(sess)
try:
from agent.redis_coord.pending_quote import clear_pending_product_quote
clear_pending_product_quote(no)
except Exception:
logger.exception("待确认报价清理失败 wo=%s", no)
def _fee_name_key(name: str) -> str:
"""费用名对齐:去空格和括号,斜杠后的英文不参与比较。"""
raw = (name or "").replace(" ", "").replace("(", "(").replace(")", ")")
for sep in ("/", "/"):
if sep in raw:
raw = raw.split(sep, 1)[0]
return raw.strip()
def _fee_name_same(left: str, right: str) -> bool:
"""
和主账填表同一口径:海运费=海运费用;操作费 ≠ 码头操作费。
模板里码头操作费是单独一行,不能用「包含」对上操作费。
"""
a = _fee_name_key(left)
b = _fee_name_key(right)
if not a or not b:
return False
if a == b:
return True
if a == b + "用" or a == b + "费用" or b == a + "用" or b == a + "费用":
return True
if a + "费" == b or b + "费" == a:
return True
# 文件发 ≈ 文件费:末字口误,前面相同。
if len(a) >= 3 and len(a) == len(b) and a[:-1] == b[:-1] and (a.endswith("费") or b.endswith("费")):
return True
return False
def _quote_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
抽出可盖价的费用行。TMS 常把 fee_lines 存成 JSON 字符串,不当空明细。
"""
src = dict(quote or {})
raw = src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or []
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
raw = []
if not isinstance(raw, list):
return []
rows: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
name = str(
item.get("name")
or item.get("label")
or item.get("feeName")
or item.get("chargeName")
or ""
).strip()
amount = str(
item.get("amount")
or item.get("value")
or item.get("salePrice")
or item.get("price")
or ""
).strip()
if name and amount:
row = dict(item)
row["name"] = name
row["amount"] = amount
rows.append(row)
return rows
def _match_fee_row(name: str, rows: list[dict[str, Any]]) -> int:
"""找到同名费用行。对不上返回 -1,不按第一行瞎盖。"""
for idx, row in enumerate(rows):
if _fee_name_same(name, str(row.get("name") or "")):
return idx
return -1
def _apply_fee_amount(row: dict[str, Any], amount: str) -> None:
"""
产品改的是这一行卖价。金额、单价、salePrice 一起覆盖,避免报价单单价列还留着 TMS 旧价。
"""
row["amount"] = amount
row["unit_price"] = amount
if row.get("salePrice") not in (None, ""):
row["salePrice"] = amount
if row.get("price") not in (None, ""):
row["price"] = amount
if row.get("value") not in (None, ""):
row["value"] = amount
def _parse_fee_amount(raw: Any) -> float | None:
"""抽出金额数字。带 USD/CNY 前缀或千分位也能读,读不了当没有。"""
text = str(raw or "").strip().replace(",", "")
if not text or text == "-":
return None
upper = text.upper()
for prefix in ("USD", "CNY", "EUR", "HKD", "JPY", "GBP", "RMB"):
if upper.startswith(prefix):
text = text[len(prefix) :].strip()
break
try:
return float(text)
except ValueError:
return None
def _row_currency(row: dict[str, Any] | None, fallback: str = "") -> str:
src = dict(row or {})
return str(src.get("currency") or src.get("currencyCode") or fallback or "").strip().upper()
def _format_fee_total(value: float, sample: str = "") -> str:
"""合计写法跟原 TMS 对齐:原来有小数就留两位。"""
if "." in str(sample or ""):
return f"{value:.2f}"
if abs(value - round(value)) < 1e-9:
return str(int(round(value)))
return f"{value:.2f}"
def _merged_total(
base: dict[str, Any] | None,
rows: list[dict[str, Any]],
incoming: dict[str, Any],
) -> tuple[str, str]:
"""
总销售价:有 TMS 原合计就按改价差额加减,不能只加产品改的那几行。
美元行已含在 TMS 人民币合计里,不擅自换汇;只调整和合计同币种的差额。
"""
src = dict(base or {})
header_ccy = str(src.get("currency") or incoming.get("currency") or "").strip()
base_total = _parse_fee_amount(src.get("total"))
if base_total is not None:
total_ccy = (header_ccy or "CNY").upper()
if total_ccy == "RMB":
total_ccy = "CNY"
old_rows = _quote_fee_rows(src)
delta = 0.0
for new_row in rows:
new_amt = _parse_fee_amount(new_row.get("amount"))
if new_amt is None:
continue
line_ccy = _row_currency(new_row, total_ccy)
if line_ccy == "RMB":
line_ccy = "CNY"
if line_ccy and line_ccy != total_ccy:
continue
idx = _match_fee_row(str(new_row.get("name") or ""), old_rows)
if idx < 0:
delta += new_amt
continue
old_amt = _parse_fee_amount(old_rows[idx].get("amount"))
if old_amt is None:
continue
delta += new_amt - old_amt
return _format_fee_total(base_total + delta, str(src.get("total") or "")), header_ccy
amounts: list[float] = []
ccys: set[str] = set()
for row in rows:
amt = _parse_fee_amount(row.get("amount"))
if amt is None:
continue
amounts.append(amt)
code = _row_currency(row, header_ccy)
if code:
ccys.add("CNY" if code == "RMB" else code)
if amounts and len(ccys) <= 1:
ccy = next(iter(ccys)) if ccys else header_ccy
return _format_fee_total(sum(amounts)), ccy
return str(src.get("total") or ""), header_ccy
def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]:
"""
产品按行改价盖到原 TMS 明细上。没点名的费用原样保留。
单价和金额一起改:模板先写 unit_price,只改 amount 的话报价单上看着还是旧价。
合计按原 TMS 总价加减改过的行,不用产品两行之和冒充全单。
不编时效和有效期。
"""
incoming = dict(overlay or {})
rows = _quote_fee_rows(base)
for item in incoming.get("fee_rows") or incoming.get("fee_lines") or []:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
amount = str(item.get("amount") or "").strip()
if not name or not amount:
continue
idx = _match_fee_row(name, rows)
if idx < 0:
rows.append(dict(item))
continue
old = dict(rows[idx])
_apply_fee_amount(old, amount)
if item.get("currency"):
old["currency"] = item.get("currency")
rows[idx] = old
out = dict(base or {})
for key, val in incoming.items():
if key in {"fee_rows", "fee_lines", "total"}:
continue
out[key] = val
out["fee_rows"] = rows
out["fee_lines"] = rows
out["source_label"] = incoming.get("source_label") or "产品报价"
total, currency = _merged_total(base, rows, incoming)
if total:
out["total"] = total
if currency:
out["currency"] = currency
return out
def _save_product_quote(flow: Any, no: str, quote: dict[str, Any], sess: Any) -> None:
writer = getattr(flow.ledger, "upsert_product_quote", None) or getattr(
flow.ledger, "upsert_quote", None
)
if callable(writer):
writer(work_order_no=no, quote=quote, to_status="已报价")
saved = writer(work_order_no=no, quote=quote, to_status="已报价") or {}
if not saved.get("ok") and saved.get("error"):
logger.warning("产品报价未入主账 wo=%s err=%s", no, saved.get("error"))
if sess is not None:
sess.quote = dict(quote)
sess.status = "已报价"
+4 -1
View File
@@ -461,8 +461,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
for row in staff:
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
name = str(row.get("name") or "").strip() or uid
if uid and uid not in userids:
if not uid:
continue
if uid not in userids:
userids.append(uid)
if uid not in product_ids:
product_ids.append(uid)
product_names.append(name)
if not product_ids:
@@ -0,0 +1,128 @@
"""
空运群同一句去重:BOT 长连接和会话存档补听不要各发一遍。
BOT 与存档的 msgid 经常对不上,按「群 + 人 + 去掉@后的原文」认同一句。
HTTP 和 Worker 是两个进程:Redis 记一份,发布目录再落一份文件,避免 Memory Redis 互看不见。
短协调,不当六态、不当 checkpoint。
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Any, Optional
from agent.redis_coord.keys import AIR_INBOUND_TEXT
from agent.redis_coord.runtime import get_redis_runtime
logger = logging.getLogger(__name__)
_TTL_SEC = 3 * 60
def _norm_text(text: str) -> str:
body = (text or "").replace("@询价小助手", "").replace("询价小助手", "")
return "".join(body.split())
def text_fingerprint(chat_id: str, sender_id: str, text: str) -> str:
"""同一群同一人同一句(去掉 @询价小助手)。"""
return f"{(chat_id or '').strip()}|{(sender_id or '').strip()}|{_norm_text(text)}"
def _file_path(fp: str) -> Path:
root = Path(__file__).resolve().parents[2] / "var" / "air_inbound"
root.mkdir(parents=True, exist_ok=True)
safe = "".join(ch if ch.isalnum() else "_" for ch in fp)[:160]
return root / f"{safe}.claim"
def _redis_get(fp: str) -> bool:
try:
client = get_redis_runtime().client
raw = client.raw.get(client.key(AIR_INBOUND_TEXT, fp))
return bool(raw)
except Exception:
logger.exception("空运群入站去重读 Redis 失败")
return False
def _redis_mark(fp: str) -> None:
try:
client = get_redis_runtime().client
client.raw.set(client.key(AIR_INBOUND_TEXT, fp), "1", ex=_TTL_SEC)
except Exception:
logger.exception("空运群入站去重写 Redis 失败")
def _file_fresh(fp: str) -> bool:
path = _file_path(fp)
try:
if not path.exists():
return False
return (time.time() - path.stat().st_mtime) < _TTL_SEC
except OSError:
return False
def _file_mark(fp: str) -> None:
path = _file_path(fp)
try:
path.write_text("1", encoding="ascii")
except OSError:
logger.exception("空运群入站去重写文件失败")
def already_handled_air_text(*, chat_id: str, sender_id: str, text: str) -> bool:
"""这句是否刚被 BOT 办过。给存档补听用。"""
fp = text_fingerprint(chat_id, sender_id, text)
if not _norm_text(text):
return False
return _redis_get(fp) or _file_fresh(fp)
def mark_air_text(*, chat_id: str, sender_id: str, text: str) -> None:
"""BOT 或首次存档办完后打标,后到的那路跳过。"""
fp = text_fingerprint(chat_id, sender_id, text)
if not _norm_text(text):
return
_redis_mark(fp)
_file_mark(fp)
def is_archive_source(raw: Optional[dict[str, Any]]) -> bool:
src = str((raw or {}).get("source") or "").strip().lower()
return src in {"wshoto_archive", "wshoto", "archive"}
def reset_air_inbound_for_tests() -> None:
"""单测清本机标记,避免上一例的同一句挡住这一例。生产不要调。"""
root = Path(__file__).resolve().parents[2] / "var" / "air_inbound"
if not root.exists():
return
for path in root.glob("*.claim"):
try:
path.unlink()
except OSError:
logger.exception("空运群入站去重清测试文件失败")
def should_skip_air_text(
*,
chat_id: str,
sender_id: str,
text: str,
raw: Optional[dict[str, Any]] = None,
) -> bool:
"""
存档补听到的 @ 句,若 BOT 已经回过就跳过。
BOT 自己再 @ 同一工单号仍要再发一版摘要,不在这里拦。
"""
if not is_archive_source(raw):
return False
if already_handled_air_text(chat_id=chat_id, sender_id=sender_id, text=text):
logger.info("空运群存档补听跳过,BOT 已办 chat=%s", chat_id)
return True
return False
+5
View File
@@ -53,3 +53,8 @@ WSHOTO_ROOMS = "wshoto:rooms"
WSHOTO_ROOM_TICKET = "wshoto:room_ticket"
# Worker 出完报价文件后写给 HTTP:短协调,不当六态
QUOTE_FILE_READY = "quote_file_ready"
# 产品刚报价、等选「在TMS上改 / 不用TMS」:短协调,不当六态
PENDING_PRODUCT_QUOTE = "pending_product_quote"
# 空运群同一句:BOT 与存档补听去重,不当六态
AIR_INBOUND_MSG = "air_inbound_msg"
AIR_INBOUND_TEXT = "air_inbound_text"
@@ -0,0 +1,65 @@
"""
产品文字报价待确认:等选在 TMS 上改还是不用 TMS。
短协调,不当六态、不当 checkpoint。Worker 重启后仍能读到刚才那份价。
"""
from __future__ import annotations
import json
import logging
from typing import Any
from agent.redis_coord.keys import PENDING_PRODUCT_QUOTE
from agent.redis_coord.runtime import get_redis_runtime
logger = logging.getLogger(__name__)
_TTL_SEC = 2 * 24 * 3600
def save_pending_product_quote(work_order_no: str, quote: dict[str, Any]) -> None:
"""记下刚抽出的产品价,等产品选怎么用 TMS。"""
no = (work_order_no or "").strip()
if not no or not isinstance(quote, dict) or not quote:
return
try:
client = get_redis_runtime().client
client.raw.set(
client.key(PENDING_PRODUCT_QUOTE, no),
json.dumps(quote, ensure_ascii=False),
ex=_TTL_SEC,
)
except Exception:
logger.exception("写待确认产品报价失败 wo=%s", no)
def load_pending_product_quote(work_order_no: str) -> dict[str, Any]:
"""取出待确认产品价。没有或过期返回空。"""
no = (work_order_no or "").strip()
if not no:
return {}
try:
client = get_redis_runtime().client
raw = client.raw.get(client.key(PENDING_PRODUCT_QUOTE, no))
if not raw:
return {}
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
data = json.loads(raw)
return dict(data) if isinstance(data, dict) else {}
except Exception:
logger.exception("读待确认产品报价失败 wo=%s", no)
return {}
def clear_pending_product_quote(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(PENDING_PRODUCT_QUOTE, no))
except Exception:
logger.exception("清待确认产品报价失败 wo=%s", no)
@@ -0,0 +1,184 @@
"""
空运协同群文字意图:激活 / 补含电含磁 / 航线报价 / 锁舱释放 / 成交。
本文件职责:只分类,不写主账、不发企微。
禁止:把「锁舱」写成唯一口令;海运解散不走这里。
"""
from __future__ import annotations
import logging
from typing import Optional
from agent.policy import inquiry_copy as copy
from agent.routing.group_intent import (
INTENT_CONFIRM_TMS,
INTENT_DEAL,
INTENT_DROP_TMS,
INTENT_FIELDS,
INTENT_KEEP_TMS,
INTENT_LOST,
INTENT_NEGOTIATE,
INTENT_OTHER,
INTENT_QUOTE,
_hint_confirm_tms,
_hint_deal_outcome,
_hint_tms_choice,
)
logger = logging.getLogger(__name__)
INTENT_ACTIVATE = "air_activate"
INTENT_LOCK = "air_lock"
INTENT_RELEASE = "air_release"
INTENT_PICK_OPTION = "air_pick_option"
_VALID = {
INTENT_ACTIVATE,
INTENT_LOCK,
INTENT_RELEASE,
INTENT_PICK_OPTION,
INTENT_FIELDS,
INTENT_QUOTE,
INTENT_KEEP_TMS,
INTENT_DROP_TMS,
INTENT_CONFIRM_TMS,
INTENT_DEAL,
INTENT_LOST,
INTENT_NEGOTIATE,
INTENT_OTHER,
}
# 相近说法回退,不是唯一口令。
_LOCK_HINTS = ("锁舱", "锁仓", "锁定舱位", "锁住舱位", "帮我锁舱")
_RELEASE_HINTS = ("释放舱位", "放舱", "解锁舱", "取消锁舱", "释放舱")
def is_missed_air_bot_at(text: str, *, room_already_air: bool = False) -> bool:
"""
存档里看到用户已经 @ 询价小助手。BOT 长连接没推到时,用这一刀补听。
海运拉人卡片也带「询价小助手」,必须排除。闲聊没有 @ 仍不当业务。
"""
raw = (text or "").strip()
if "询价小助手" not in raw:
return False
if "运输方式:海运" in raw or "运输方式:海运" in raw:
return False
wo = copy.extract_work_order_no(raw)
if wo:
compact = "".join(raw.split())
return len(compact) < 80
return bool(room_already_air)
def classify_air_group_text(
text: str,
*,
injected: str = "",
allow_network: Optional[bool] = None,
) -> str:
"""
空运群一句文字的意图。单测可注入;正式走模型。
锁舱/释放优先于成交,避免「锁舱成交」误伤。
"""
hint = (injected or "").strip()
if hint in _VALID:
return hint
raw = (text or "").strip()
if not raw:
return INTENT_OTHER
locked = _hint_lock_or_release(raw)
if locked:
return locked
# 只发工单号先当激活,不要先打模型;WO + 12 位数字也会被费用行误认成金额。
if copy.extract_work_order_no(raw) and _only_work_order(raw):
return INTENT_ACTIVATE
try:
from agent.config import get_settings
settings = get_settings()
use_net = (
allow_network
if allow_network is not None
else bool(getattr(settings, "llm_allow_network", False))
)
if use_net and getattr(settings, "llm_data_usage_confirmed", False):
got = _classify_with_model(raw, settings)
if got in _VALID:
return got
except Exception:
logger.exception("空运群意图模型失败,回退规则")
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
if extract_product_quote(raw):
return INTENT_QUOTE
confirmed = _hint_confirm_tms(raw)
if confirmed:
return confirmed
hinted = _hint_tms_choice(raw)
if hinted:
return hinted
closed = _hint_deal_outcome(raw)
if closed:
return closed
if extract_air_collab_fields(raw):
return INTENT_FIELDS
if _looks_like_option(raw):
return INTENT_PICK_OPTION
return INTENT_OTHER
def _hint_lock_or_release(text: str) -> str:
blob = (text or "").replace(" ", "").replace(" ", "")
if any(x in blob for x in _RELEASE_HINTS):
return INTENT_RELEASE
if any(x in blob for x in _LOCK_HINTS):
return INTENT_LOCK
return ""
def _only_work_order(text: str) -> bool:
"""去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。"""
no = copy.extract_work_order_no(text)
rest = (text or "").replace(no, "").replace(no.lower(), "")
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
return not rest.strip()
def _looks_like_option(text: str) -> bool:
blob = (text or "").replace(" ", "").upper()
return blob.startswith("AIR-OPT") or blob.startswith("AIROPT")
def _classify_with_model(text: str, settings) -> str:
from agent.llm.http_client import LlmHttpClient
from agent.llm.modes import LlmMode
client = LlmHttpClient.from_settings(settings)
system = (
"你是空运协同群意图分类。只回复一个英文词,不要解释。"
"air_lock=航线要锁舱;air_release=航线要释放舱位;"
"air_activate=只是指定/切换工单号;"
"air_pick_option=在选舱位选项编号;"
"collab_fields=在补是否含电/是否含磁;"
"product_quote=航线在报价格或费用(含具体金额);"
"quote_confirm_tms=确认沿用当前TMS报价;"
"quote_keep_tms=要在已有TMS报价上改;"
"quote_drop_tms=不要TMS、只用刚报的价;"
"deal_won=销售确认成交;deal_lost=销售确认未成交;"
"deal_negotiate=销售要继续协商;"
"other=闲聊。"
"一句话里既有锁舱又有工单号,回 air_lock。"
"有金额就是 product_quote。"
)
data = client.chat(
mode=LlmMode.ORDERED_ACTIONS,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": text[:500]},
],
)
got = str((data or {}).get("content") or "").strip().split()[0] if data else ""
return got if got in _VALID else ""
+48 -6
View File
@@ -128,6 +128,18 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = {
}
def _is_air_collab_room(message: InboundMessage) -> bool:
"""当前群已激活空运工单。查不到或海运群返回 False。"""
try:
from agent.handlers.air_group_collab import get_air_text_flow
from agent.policy.air_group_ops import bound_air_ticket
return bound_air_ticket(get_air_text_flow(), message.chat_id or "") is not None
except Exception:
logger.exception("判断空运协同群失败 chat=%s", getattr(message, "chat_id", ""))
return False
def resolve_handler(intent: str) -> Optional[HandlerFn]:
"""查表;未知意图返回 None。"""
return HANDLER_REGISTRY.get(intent)
@@ -144,14 +156,44 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
from agent.policy import inquiry_copy as copy
event = str((message.raw or {}).get("event") or "").lower()
action = copy.canonical_button(message.content or "")
if event in {"template_card_event", "click", "templatecardevent"} or action in {
copy.BTN_DEAL,
copy.BTN_LOST,
copy.BTN_NEGOTIATE,
}:
raw_text = (message.content or "").strip()
# 只有真点卡才走成交卡;群里打「成交」两个字要当文字成交,不能当私聊点卡丢掉。
is_card_event = event in {"template_card_event", "click", "templatecardevent"}
is_card_key = raw_text.startswith(
(copy.BTN_DEAL_KEY + ":", copy.BTN_LOST_KEY + ":", copy.BTN_NEGOTIATE_KEY + ":")
)
if is_card_event or is_card_key:
_wrap_card(message)
return "card_action"
source = str((message.raw or {}).get("source") or "").lower()
if source in {"aibot", "aibot_bridge"}:
from agent.handlers.air_group_collab import handle_air_group
phase = handle_air_group(message)
logger.info("dispatch air_group phase=%s chat=%s", phase, message.chat_id)
return "air_group_collab"
# 发文件不能 @:空运群附件靠会话存档听见。
# 文字本应走 BOT;BOT 没推到时,存档里已经带 @询价小助手 的短句按空运补听。
kind = (message.msg_type or "text").lower()
air_room = _is_air_collab_room(message)
if source == "wshoto_archive" and kind in {"text", ""}:
from agent.routing.air_group_intent import is_missed_air_bot_at
if is_missed_air_bot_at(raw_text, room_already_air=air_room):
from agent.handlers.air_group_collab import handle_air_group
phase = handle_air_group(message)
logger.info("dispatch air_group archive_at phase=%s chat=%s", phase, message.chat_id)
return "air_group_collab"
if air_room:
if kind in {"file", "image", "video"}:
from agent.handlers.air_group_collab import handle_air_group
phase = handle_air_group(message)
logger.info("dispatch air_group archive_file phase=%s chat=%s", phase, message.chat_id)
return "air_group_collab"
logger.info("dispatch 空运群存档文字忽略,须 @ BOT chat=%s", message.chat_id)
return "air_archive_text_ignore"
phase = handle_group_collab(message)
logger.info("dispatch group_collab phase=%s chat=%s", phase, message.chat_id)
return "group_collab"
+178 -5
View File
@@ -17,9 +17,102 @@ logger = logging.getLogger(__name__)
INTENT_FIELDS = "collab_fields"
INTENT_QUOTE = "product_quote"
INTENT_DISSOLVE = "dissolve"
INTENT_KEEP_TMS = "quote_keep_tms"
INTENT_DROP_TMS = "quote_drop_tms"
INTENT_CONFIRM_TMS = "quote_confirm_tms"
INTENT_DEAL = "deal_won"
INTENT_LOST = "deal_lost"
INTENT_NEGOTIATE = "deal_negotiate"
INTENT_OTHER = "other"
_VALID = {INTENT_FIELDS, INTENT_QUOTE, INTENT_DISSOLVE, INTENT_OTHER}
_VALID = {
INTENT_FIELDS,
INTENT_QUOTE,
INTENT_DISSOLVE,
INTENT_KEEP_TMS,
INTENT_DROP_TMS,
INTENT_CONFIRM_TMS,
INTENT_DEAL,
INTENT_LOST,
INTENT_NEGOTIATE,
INTENT_OTHER,
}
# 口令不写死:这两句是建议说法,相近意思也认。有金额的仍当报价。
# 问句列了 1/2 时,产品常回「第一个」而不是整句口令。
_KEEP_HINTS = (
"在tms基础上",
"在tms上改",
"保留tms",
"按tms改",
"按tms的改",
"留着tms",
"在原来基础上",
"在原报价上",
"在原基础上",
)
_KEEP_CHOICE = {
"1",
"1.",
"1、",
"一",
"第一",
"第一个",
"第一条",
"选项1",
"选项一",
"选1",
"选一",
"第1",
"第1个",
}
_DROP_CHOICE = {
"2",
"2.",
"2、",
"二",
"第二",
"第二个",
"第二条",
"选项2",
"选项二",
"选2",
"选二",
"第2",
"第2个",
}
# 先认未成交,避免「未成交」被「成交」误伤。
_LOST_HINTS = ("未成交", "没成交", "没有成交", "没做成", "黄了")
_NEGOTIATE_HINTS = ("协商中", "继续谈", "再谈谈", "还在谈")
_DEAL_HINTS = ("成交", "成了", "做成了", "已经成交", "搞定了")
_CONFIRM_CHOICE = {
"确认报价",
"确认该报价",
"确认这个报价",
"确认tms报价",
"沿用该价",
"沿用tms",
"沿用tms报价",
}
_CONFIRM_HINTS = (
"确认报价",
"确认该报价",
"确认这个报价",
"确认tms报价",
"沿用该价",
"沿用tms",
)
_DROP_HINTS = (
"不用tms报价",
"不要tms报价",
"不用tms",
"不要tms",
"不要tms的价",
"按我的报价",
"按我刚才报",
"只用我的",
"全部按我",
)
def classify_group_text(
@@ -51,18 +144,80 @@ def classify_group_text(
if use_net and getattr(settings, "llm_data_usage_confirmed", False):
got = _classify_with_model(raw, settings)
if got in _VALID:
hinted_confirm = _hint_confirm_tms(raw)
if hinted_confirm and got in {INTENT_OTHER, INTENT_FIELDS}:
logger.info("群意图模型判成%s,按口令改回 %s text=%s", got, hinted_confirm, raw[:40])
return hinted_confirm
hinted_tms = _hint_tms_choice(raw)
if hinted_tms and got in {INTENT_OTHER, INTENT_FIELDS}:
logger.info("群意图模型判成%s,按口令改回 %s text=%s", got, hinted_tms, raw[:40])
return hinted_tms
hinted_deal = _hint_deal_outcome(raw)
# 销售说成交时,模型不能把群拆了。
if hinted_deal and got == INTENT_DISSOLVE:
logger.warning("群意图模型把成交判成解散,改回 %s text=%s", hinted_deal, raw[:40])
return hinted_deal
return got
except Exception:
logger.exception("群意图模型失败,回退标签字段")
if extract_collab_fields(raw):
return INTENT_FIELDS
from agent.llm.extract_text import extract_product_quote
if extract_product_quote(raw):
return INTENT_QUOTE
confirmed = _hint_confirm_tms(raw)
if confirmed:
return confirmed
hinted = _hint_tms_choice(raw)
if hinted:
return hinted
closed = _hint_deal_outcome(raw)
if closed:
return closed
if extract_collab_fields(raw):
return INTENT_FIELDS
return INTENT_OTHER
def _norm_intent_text(text: str) -> str:
return (text or "").replace(" ", "").replace(" ", "").lower()
def _hint_confirm_tms(text: str) -> str:
"""沿用当前 TMS 价。口令不写死,确认报价 / 沿用该价都认。"""
blob = _norm_intent_text(text)
if blob in _CONFIRM_CHOICE:
return INTENT_CONFIRM_TMS
if any(x in blob for x in _CONFIRM_HINTS):
return INTENT_CONFIRM_TMS
return ""
def _hint_tms_choice(text: str) -> str:
"""相近口令回退。有价格的句子不会走到这里。整句是 1/第一个 也认。"""
blob = _norm_intent_text(text)
if blob in _KEEP_CHOICE:
return INTENT_KEEP_TMS
if blob in _DROP_CHOICE:
return INTENT_DROP_TMS
if any(x in blob for x in _KEEP_HINTS):
return INTENT_KEEP_TMS
if any(x in blob for x in _DROP_HINTS):
return INTENT_DROP_TMS
return ""
def _hint_deal_outcome(text: str) -> str:
"""成交/未成交/协商中:建议说法 + 相近口语。"""
blob = _norm_intent_text(text)
if any(x in blob for x in _LOST_HINTS):
return INTENT_LOST
if any(x in blob for x in _NEGOTIATE_HINTS):
return INTENT_NEGOTIATE
if any(x in blob for x in _DEAL_HINTS):
return INTENT_DEAL
return ""
def _classify_with_model(text: str, settings) -> str:
"""DeepSeek 只回一个意图词。失败空串。"""
from agent.llm.http_client import LlmHttpClient
@@ -72,10 +227,17 @@ def _classify_with_model(text: str, settings) -> str:
system = (
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
"collab_fields=在补贸易条款/货好时间/商品海关编码/含油含电含磁;"
"product_quote=产品在报价格或费用;"
"product_quote=产品在报价格或费用(含具体金额);"
"quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;"
"quote_keep_tms=要在已有TMS报价上改,没有报新金额;"
"quote_drop_tms=不要TMS、只用产品刚报的价,没有报新金额;"
"deal_won=销售确认这单成交了;"
"deal_lost=销售确认这单未成交;"
"deal_negotiate=销售要继续协商;"
"dissolve=要把这个协同群解散;"
"other=闲聊、问候、对询价港口的随口更正(不当改单)。"
"一句话里既补字段又报价,回 product_quote。"
"有金额就是 product_quote,不要判成 quote_keep_tms。"
)
data = client.chat(
mode=LlmMode.ORDERED_ACTIONS,
@@ -85,7 +247,18 @@ def _classify_with_model(text: str, settings) -> str:
],
)
content = str(getattr(data, "content", "") or "").strip().lower()
for token in (INTENT_QUOTE, INTENT_DISSOLVE, INTENT_FIELDS, INTENT_OTHER):
for token in (
INTENT_QUOTE,
INTENT_CONFIRM_TMS,
INTENT_KEEP_TMS,
INTENT_DROP_TMS,
INTENT_LOST,
INTENT_NEGOTIATE,
INTENT_DEAL,
INTENT_DISSOLVE,
INTENT_FIELDS,
INTENT_OTHER,
):
if token in content:
return token
return ""
@@ -46,6 +46,8 @@ SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
# 空运群只补含电/含磁,摘要不要把海运六项搬过去。
AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
SEA_COLLAB_DISPLAY = {
"HS编码": "商品海关编码",
+103
View File
@@ -0,0 +1,103 @@
"""
空运 BOT 出站:没有当场回话地址时,打桥主动往群里说。
"""
from __future__ import annotations
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from agent.channel.aibot import normalize_aibot_payload
from agent.channel.aibot.reply import AibotReplyClient
class AibotInboundNormalizeTests(unittest.TestCase):
def test_official_wecom_body(self) -> None:
parsed = normalize_aibot_payload(
{
"msgid": "m1",
"chatid": "wr_air_1",
"chattype": "group",
"from": {"userid": "WuJiLin"},
"msgtype": "text",
"text": {"content": "@询价小助手 WO202609160009"},
"aibot_direct": True,
}
)
self.assertEqual(parsed.sender_id, "WuJiLin")
self.assertEqual(parsed.message_id, "m1")
self.assertEqual(parsed.chat_id, "wr_air_1")
self.assertEqual(parsed.chat_type, "group")
self.assertIn("WO202609160009", parsed.content)
self.assertEqual(parsed.raw.get("source"), "aibot")
class AibotReplyTests(unittest.TestCase):
def test_empty_content_skips(self) -> None:
client = AibotReplyClient("")
out = client.send_group(chat_id="wr_1", content=" ")
self.assertFalse(out["ok"])
self.assertEqual(out["error"], "empty")
def test_proactive_posts_send_group(self) -> None:
client = AibotReplyClient("")
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="附件已保存")
self.assertTrue(out["ok"])
url, = fake_http.post.call_args.args
self.assertEqual(url, "http://127.0.0.1:8913/send")
payload = fake_http.post.call_args.kwargs["json"]
self.assertEqual(payload["chatid"], "wr_air_1")
self.assertEqual(payload["operation"], "text")
self.assertEqual(payload["content"], "附件已保存")
self.assertEqual(
fake_http.post.call_args.kwargs["headers"]["X-Aibot-Bridge-Token"],
"tok",
)
def test_send_group_file_posts_send_file(self) -> None:
client = AibotReplyClient("")
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_file(
chat_id="wr_air_1",
filename="WO1_quote.xlsx",
file_b64="QQ==",
)
self.assertTrue(out["ok"])
url, = fake_http.post.call_args.args
self.assertEqual(url, "http://127.0.0.1:8913/send-file")
payload = fake_http.post.call_args.kwargs["json"]
self.assertEqual(payload["chatid"], "wr_air_1")
self.assertEqual(payload["filename"], "WO1_quote.xlsx")
def test_stream_url_does_not_hit_bridge(self) -> None:
client = AibotReplyClient("https://example.com/reply")
fake_http = MagicMock()
fake_http.post.return_value.status_code = 200
with patch("agent.channel.aibot.reply.httpx", fake_http):
out = client.send_group(chat_id="wr_air_1", content="已激活")
self.assertTrue(out["ok"])
url, = fake_http.post.call_args.args
self.assertEqual(url, "https://example.com/reply")
@@ -0,0 +1,853 @@
"""
空运群内协同:激活当前工单、补字段、航线报价、锁舱/释放、成交。
"""
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)
from agent.channel.wecom.models import InboundMessage
from agent.handlers.air_group_collab import handle_air_group
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.air_text_flow import AirTextInquiryFlow
from agent.policy import inquiry_copy as copy
from agent.routing.air_group_intent import (
INTENT_LOCK,
INTENT_RELEASE,
classify_air_group_text,
)
from agent.routing.dispatch import dispatch_inbound
from agent.routing.decision import RouteDecision
class FakeBotGroup:
def __init__(self) -> None:
self.sent: list[dict] = []
def send_group(self, *, chat_id: str, content: str, mention_userids=None):
self.sent.append({"chat_id": chat_id, "content": content, "kind": "text"})
def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = ""):
self.sent.append({"chat_id": chat_id, "filename": filename, "kind": "file"})
return {"ok": True}
class AirGroupCollabTests(unittest.TestCase):
def setUp(self) -> None:
from agent.redis_coord.air_inbound import reset_air_inbound_for_tests
reset_air_inbound_for_tests()
self.ledger = MemoryLedger()
self.groups = FakeBotGroup()
self.flow = AirTextInquiryFlow(ledger=self.ledger, group_client=self.groups)
self.ledger.upsert_staff(
{
"name": "WuJiLin",
"wecomId": "sales1",
"roleCode": "sales",
"status": "active",
}
)
self.ledger.upsert_staff(
{
"name": "HangXian",
"wecomId": "air1",
"roleCode": "air",
"status": "active",
}
)
self.ledger.set_quote_templates(
[
{
"templateId": "air-default",
"name": "空运报价",
"bizType": "空运",
"isDefault": True,
},
{
"templateId": "sea-default",
"name": "海运报价",
"bizType": "海运",
"isDefault": True,
},
]
)
def _create_air(self, *, sender="sales1", quote=None, status="已报价") -> str:
created = self.ledger.create_ticket(
sender_id=sender,
business_line="AIR",
facts={
"起运地": "上海",
"目的地": "洛杉矶",
"品名": "配件",
"件数": "3",
"毛重": "100",
"体积": "2",
"包装类型": "纸箱",
"报价日期": "2026-09-16",
},
)
wo = created["work_order_no"]
if quote is None:
quote = {
"total": "USD 800",
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-1",
"airOptions": [
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航"}
],
}
if quote:
self.ledger.upsert_quote(work_order_no=wo, quote=quote, to_status=status)
return wo
def _msg(self, *, sender: str, text: str, chat: str, mid: str) -> InboundMessage:
return InboundMessage(
sender_id=sender,
message_id=mid,
content=text,
chat_id=chat,
chat_type="group",
raw={"source": "aibot"},
)
def _texts(self) -> list[str]:
return [x.get("content") or "" for x in self.groups.sent]
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)
def test_air_group_brief_matches_sea_sections(self) -> None:
text = copy.air_group_brief(
work_order_no="WO202609160009",
facts={
"起运地": "ZUH",
"目的地": "广州",
"品名": "服务器",
"件数": "1",
"毛重": "10",
"体积": "1",
"包装类型": "纸箱",
"报价日期": "2026-09-16",
},
quote=None,
collab_facts={},
has_price=False,
)
self.assertIn("工单号:WO202609160009", text)
self.assertIn("询价字段:\n运输方式:空运", text)
self.assertIn("起运地:ZUH", text)
self.assertIn("\n\n协同补充字段:\n是否含电:-", text)
self.assertIn("是否含磁:-", text)
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
self.assertNotIn("当前工单:", text)
self.assertNotIn("贸易条款", text)
def test_work_order_only_is_activate_not_quote(self) -> None:
from agent.routing.air_group_intent import INTENT_ACTIVATE
self.assertEqual(
classify_air_group_text("WO202609160002", allow_network=False),
INTENT_ACTIVATE,
)
self.assertEqual(
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
INTENT_ACTIVATE,
)
from unittest.mock import patch
with patch(
"agent.routing.air_group_intent._classify_with_model",
side_effect=AssertionError("纯工单号不应再打模型"),
):
self.assertEqual(
classify_air_group_text(
"@询价小助手 WO202609160002",
allow_network=True,
),
INTENT_ACTIVATE,
)
def test_dispatch_aibot_goes_air(self) -> None:
from agent.routing.dispatch import dispatch_inbound as _disp
wo = self._create_air()
msg = self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="d1")
# 单测注入 flow,避免打到进程单例
import agent.handlers.air_group_collab as air_mod
orig = air_mod.get_air_text_flow
def _fake():
return self.flow
air_mod.get_air_text_flow = _fake # type: ignore
try:
name = _disp(msg, RouteDecision(intent="ordinary_text_other"))
finally:
air_mod.get_air_text_flow = orig # type: ignore
self.assertEqual(name, "air_group_collab")
def test_activate_and_switch(self) -> None:
wo1 = self._create_air()
wo2 = self._create_air()
phase = handle_air_group(
self._msg(sender="sales1", text=wo1, chat="wr_air_1", mid="a1"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "activated")
joined = "\n".join(self._texts())
self.assertIn(f"工单号:{wo1}", joined)
self.assertIn("询价字段:", joined)
self.assertIn("运输方式:空运", joined)
self.assertIn("协同补充字段:", joined)
self.assertIn("TMS报价:", joined)
self.assertNotIn("当前工单:", joined)
self.assertNotIn("询价要点:", joined)
self.assertTrue(any("@WuJiLin" in t and "@HangXian" in t for t in self._texts()))
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
kinds = [x.get("kind") for x in self.groups.sent]
self.assertLess(kinds.index("text"), kinds.index("file"))
self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1)
phase = handle_air_group(
self._msg(sender="air1", text=wo2, chat="wr_air_1", mid="a2"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "activated")
self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo2)
def test_second_group_occupied(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="o1"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_2", mid="o2"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "occupied")
self.assertTrue(any("另一个群" in t for t in self._texts()))
def test_need_work_order_when_empty(self) -> None:
phase = handle_air_group(
self._msg(sender="sales1", text="空运费100USD", chat="wr_air_1", mid="n1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertEqual(phase, "need_work_order")
def test_sales_can_fill_battery(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="f0"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._msg(sender="sales1", text="含电 不含磁", chat="wr_air_1", mid="f1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="collab_fields",
)
self.assertEqual(phase, "fields_saved")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("是否含电"), "是")
self.assertEqual(ticket.collab_facts.get("是否含磁"), "否")
joined = "\n".join(self._texts())
self.assertIn("工单号:", joined)
self.assertIn("询价字段:", joined)
self.assertIn("是否含电:是", joined)
self.assertIn("是否含磁:否", joined)
self.assertNotIn("已记下", joined)
self.assertTrue(any("@WuJiLin" in t and "@HangXian" in t for t in self._texts()))
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
fill = dict(getattr(self.ledger, "last_render", {}).get("facts") or {})
self.assertEqual(fill.get("是否含电") or fill.get("has_battery"), "是")
self.assertEqual(fill.get("是否含磁") or fill.get("has_magnet"), "否")
texts = [i for i, x in enumerate(self.groups.sent) if x.get("kind") == "text" and "工单号:" in (x.get("content") or "")]
files = [i for i, x in enumerate(self.groups.sent) if x.get("kind") == "file"]
self.assertTrue(texts and files)
self.assertLess(texts[-1], files[-1])
self.assertEqual(len(files), 2)
def test_sales_quote_rejected(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="q0"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._msg(sender="sales1", text="空运费 100USD", chat="wr_air_1", mid="q1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertEqual(phase, "not_airline")
def test_airline_quote_asks_tms_choice(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="t0"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="t1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
injected_quote={"total": "USD 900", "source_label": "航线报价"},
)
self.assertEqual(phase, "wait_tms_choice")
self.assertTrue(any("在 TMS 基础上改" in t or "在TMS基础上改" in t for t in self._texts()))
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.quote.get("total"), "USD 800")
self.assertNotEqual(ticket.quote.get("source_label"), "航线报价")
phase = handle_air_group(
self._msg(sender="air1", text="在TMS基础上改", chat="wr_air_1", mid="t2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="quote_keep_tms",
)
self.assertEqual(phase, "quoted")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.quote.get("total"), "USD 900")
self.assertEqual(ticket.quote.get("quoteId"), "QT-AIR-1")
self.assertEqual(ticket.quote.get("source_label"), "航线在TMS上改")
self.assertFalse(ticket.collab_facts.get("__pending_air_quote"))
joined = "\n".join(self._texts())
self.assertIn("工单号:", joined)
self.assertIn("询价字段:", joined)
self.assertIn("人工报价:", joined)
self.assertIn("报价单已发送,请确认成交状态", joined)
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
def test_airline_quote_without_tms_sends_brief_sheet_deal(self) -> None:
wo = self._create_air(quote={}, status="询价中")
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="nq0"),
flow=self.flow,
ledger=self.ledger,
)
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text="空运费 900USD", chat="wr_air_1", mid="nq1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
injected_quote={"total": "USD 900", "source_label": "航线报价"},
)
self.assertEqual(phase, "quoted")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "已报价")
self.assertEqual(ticket.quote.get("total"), "USD 900")
joined = "\n".join(self._texts())
self.assertIn("工单号:", joined)
self.assertIn("询价字段:", joined)
self.assertIn("TMS 暂无匹配报价", joined)
self.assertIn("人工报价:", joined)
self.assertIn("报价单已发送,请确认成交状态", joined)
self.assertNotIn("航线报价已记下", joined)
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
def test_quote_same_sentence_saves_battery(self) -> None:
wo = self._create_air(quote={}, status="询价中")
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="qs0"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._msg(sender="air1", text="含电 空运费900USD", chat="wr_air_1", mid="qs1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
injected_quote={"total": "USD 900", "source_label": "航线报价"},
)
self.assertEqual(phase, "quoted")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("是否含电"), "是")
self.assertIn("是否含电:是", "\n".join(self._texts()))
def test_confirm_tms_saves_sheet_and_deal(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="ct0"),
flow=self.flow,
ledger=self.ledger,
)
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text="确认报价", chat="wr_air_1", mid="ct1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="quote_confirm_tms",
)
self.assertEqual(phase, "quote_confirmed")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.quote.get("source_label"), "航线确认TMS")
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts()))
def test_stranger_cannot_fill_fields(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="sf0"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._msg(sender="other1", text="含电 不含磁", chat="wr_air_1", mid="sf1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="collab_fields",
)
self.assertEqual(phase, "fields_denied")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertFalse(ticket.collab_facts.get("是否含电"))
def test_sales_deal(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="c0"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="c1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_won",
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已成交")
def test_lock_requires_work_order(self) -> None:
phase = handle_air_group(
self._msg(sender="air1", text="锁舱", chat="wr_air_1", mid="l0"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "cabin_need_work_order")
self.assertTrue(any("带上工单号" in t for t in self._texts()))
def test_lock_without_tms_quote(self) -> None:
wo = self._create_air(quote={}, status="询价中")
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" 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:
wo = self._create_air(
quote={
"total": "100",
"source": "manual",
"source_label": "产品报价",
},
status="已报价",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_manual", mid="lm1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
def test_lock_success_and_release_after_deal_blocked(self) -> None:
wo = self._create_air()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
handle_air_group(
self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="l3"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_won",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="l4"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_release",
)
self.assertEqual(phase, "release_deal_blocked")
self.assertTrue(any("已成交" in t for t in self._texts()))
def test_lost_can_release(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="r0"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
handle_air_group(
self._msg(sender="sales1", text="未成交", chat="wr_air_1", mid="r1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_lost",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="r2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_release",
)
self.assertEqual(phase, "release_ok")
def test_sales_cannot_lock(self) -> None:
wo = self._create_air()
phase = handle_air_group(
self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "not_airline")
def test_dual_role_can_lock(self) -> None:
self.ledger.upsert_staff(
{
"name": "WuJiLin",
"wecomId": "sales1",
"roleCode": "air",
"status": "active",
}
)
wo = self._create_air()
phase = handle_air_group(
self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
def test_multi_option_asks(self) -> None:
wo = self._create_air(
quote={
"total": "USD 800",
"source": "TMS",
"quoteId": "QT-AIR-2",
"airOptions": [
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"},
{"optionNo": "AIR-OPT-02", "airlineCode": "MU"},
],
}
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="m1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "wait_lock_option")
phase = handle_air_group(
self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_pick_option",
)
self.assertEqual(phase, "lock_ok")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-02")
def _file(self, *, sender: str, filename: str, chat: str, mid: str, text: str = "") -> InboundMessage:
return InboundMessage(
sender_id=sender,
message_id=mid,
content=text,
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": filename, "media_id": f"mid-{mid}"},
raw={"source": "aibot"},
)
def test_file_needs_work_order(self) -> None:
phase = handle_air_group(
self._file(sender="air1", filename="航线报价.xlsx", chat="wr_air_1", mid="af0"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "need_work_order")
def test_sales_excel_archived_not_quote(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="af1"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._file(sender="sales1", filename="对账单.xlsx", chat="wr_air_1", mid="af2"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "file_archived")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.attachments[0]["file_name"], "对账单.xlsx")
self.assertNotEqual(ticket.quote.get("source_label"), "航线附件报价")
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
def test_airline_excel_quotes_and_keeps_tms_id(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af3"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._file(sender="air1", filename="航线报价.pdf", chat="wr_air_1", mid="af4"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "airline_file_quote")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.attachments[0]["file_name"], "航线报价.pdf")
self.assertEqual(ticket.quote.get("source_label"), "航线附件报价")
self.assertEqual(ticket.quote.get("quoteId"), "QT-AIR-1")
self.assertEqual(ticket.status, "已报价")
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts()))
def test_image_rejected(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af5"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
InboundMessage(
sender_id="air1",
message_id="af6",
content="",
msg_type="image",
chat_id="wr_air_1",
chat_type="group",
media={"filename": "现场.jpg", "media_id": "mid-img"},
raw={"source": "aibot"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "file_type_rejected")
self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments)
def test_archive_file_without_at_quotes(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="ar0"),
flow=self.flow,
ledger=self.ledger,
)
msg = self._file(sender="air1", filename="航线报价.xlsx", chat="wr_air_1", mid="ar1")
msg = InboundMessage(
sender_id=msg.sender_id,
message_id=msg.message_id,
content=msg.content,
msg_type="file",
chat_id=msg.chat_id,
chat_type="group",
media=dict(msg.media),
raw={"source": "wshoto_archive"},
)
import agent.handlers.air_group_collab as air_mod
orig = air_mod.get_air_text_flow
def _fake():
return self.flow
air_mod.get_air_text_flow = _fake # type: ignore
try:
name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other"))
finally:
air_mod.get_air_text_flow = orig # type: ignore
self.assertEqual(name, "air_group_collab")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.quote.get("source_label"), "航线附件报价")
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
self.assertTrue(any("成交" in t or "未成交" in t or "协商" in t for t in self._texts()))
def test_archive_text_ignored_until_at(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="ar2"),
flow=self.flow,
ledger=self.ledger,
)
msg = self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="ar3")
msg = InboundMessage(
sender_id=msg.sender_id,
message_id=msg.message_id,
content=msg.content,
chat_id=msg.chat_id,
chat_type="group",
raw={"source": "wshoto_archive"},
)
import agent.handlers.air_group_collab as air_mod
orig = air_mod.get_air_text_flow
def _fake():
return self.flow
air_mod.get_air_text_flow = _fake # type: ignore
try:
name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other"))
finally:
air_mod.get_air_text_flow = orig # type: ignore
self.assertEqual(name, "air_archive_text_ignore")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已报价")
def test_archive_at_work_order_activates(self) -> None:
wo = self._create_air()
msg = InboundMessage(
sender_id="sales1",
message_id="ar-at-1",
content=f"@询价小助手 {wo}",
chat_id="wr_air_new",
chat_type="group",
raw={"source": "wshoto_archive"},
)
import agent.handlers.air_group_collab as air_mod
import agent.policy.air_text_flow as flow_mod
orig = air_mod.get_air_text_flow
orig2 = flow_mod.get_air_text_flow
def _fake():
return self.flow
air_mod.get_air_text_flow = _fake # type: ignore
flow_mod.get_air_text_flow = _fake # type: ignore
try:
name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other"))
finally:
air_mod.get_air_text_flow = orig # type: ignore
flow_mod.get_air_text_flow = orig2 # type: ignore
self.assertEqual(name, "air_group_collab")
self.assertTrue(any(wo in t for t in self._texts()))
def test_file_with_work_order_activates(self) -> None:
wo = self._create_air()
phase = handle_air_group(
self._file(
sender="air1",
filename="航线报价.xlsx",
chat="wr_air_1",
mid="af7",
text=wo,
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "airline_file_quote")
self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo)
def test_archive_same_sentence_different_msgid_sends_one_sheet(self) -> None:
wo = self._create_air()
phase = handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_dup2", mid="bot-mid-012"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "activated")
self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1)
phase = handle_air_group(
InboundMessage(
sender_id="sales1",
message_id="wshoto:archive-mid-999",
content=f"@询价小助手 {wo}",
chat_id="wr_air_dup2",
chat_type="group",
raw={"source": "wshoto_archive", "msgid": "archive-mid-999"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "duplicate_inbound")
self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1)
def test_aibot_and_archive_same_msgid_sends_one_sheet(self) -> None:
wo = self._create_air()
import agent.handlers.air_group_collab as air_mod
import agent.policy.air_text_flow as flow_mod
orig = air_mod.get_air_text_flow
orig2 = flow_mod.get_air_text_flow
def _fake():
return self.flow
air_mod.get_air_text_flow = _fake # type: ignore
flow_mod.get_air_text_flow = _fake # type: ignore
try:
aibot = self._msg(sender="sales1", text=wo, chat="wr_air_dup", mid="same-mid-011")
name = dispatch_inbound(aibot, RouteDecision(intent="ordinary_text_other"))
self.assertEqual(name, "air_group_collab")
files_after_bot = sum(1 for x in self.groups.sent if x.get("kind") == "file")
self.assertEqual(files_after_bot, 1)
archive = InboundMessage(
sender_id="sales1",
message_id="wshoto:same-mid-011",
content=f"@询价小助手 {wo}",
chat_id="wr_air_dup",
chat_type="group",
raw={"source": "wshoto_archive", "msgid": "same-mid-011"},
)
name = dispatch_inbound(archive, RouteDecision(intent="ordinary_text_other"))
self.assertEqual(name, "air_group_collab")
self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1)
finally:
air_mod.get_air_text_flow = orig # type: ignore
flow_mod.get_air_text_flow = orig2 # type: ignore
def test_sea_ticket_rejected(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="SEA",
facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "配件", "货量": "1x40HQ", "整柜或拼柜": "整柜", "箱型箱量": "1x40HQ", "报价日期": "2026-09-16"},
)
wo = created["work_order_no"]
phase = handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="sea1"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "not_air")
if __name__ == "__main__":
unittest.main()
+591 -1
View File
@@ -83,6 +83,10 @@ class FakeGroupClient:
self.sent.append({"chat_id": chat_id, "kind": "card", "template_card": template_card})
return {"ok": True}
def send_text(self, *, touser: str, content: str) -> dict:
self.sent.append({"touser": touser, "content": content, "kind": "app_text"})
return {"ok": True}
def dismiss_group(self, *, chat_id: str, userids: list) -> dict:
self.sent.append({"chat_id": chat_id, "kind": "dismiss", "userids": list(userids)})
return {"ok": True}
@@ -591,6 +595,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
self.assertNotIn("未查到合适报价", group_text)
self.assertIn("\n\n协同补充字段:\n", group_text)
self.assertIn("\n\nTMS报价:\n", group_text)
self.assertNotIn("确认报价", group_text)
class SeaGroupCollabFieldTests(unittest.TestCase):
@@ -694,11 +699,115 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "wait_tms_choice")
self.assertNotEqual(str(ticket.quote.get("total") or ""), "1200")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertTrue(any("在TMS基础上改" in t and "不用TMS报价" in t for t in texts))
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="g2b",
content="按TMS改一下就行",
chat_id=ticket.collab_chat_id,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "product_quote_sent")
self.assertEqual(str(ticket.quote.get("total") or ""), "1200")
ticket = self.ledger.get_ticket(work_order_no=wo)
names = [str(x.get("name") or "") for x in (ticket.quote.get("fee_rows") or ticket.quote.get("fee_lines") or [])]
self.assertTrue(any("空运费" in n or "海运费" in n or "附加" in n for n in names) or "1280" in str(ticket.quote.get("total") or ""))
deal_texts = [x["content"] for x in self.groups.sent if x.get("kind") == "text" and "成交" in (x.get("content") or "")]
self.assertTrue(deal_texts)
def test_keep_tms_reads_json_string_fee_lines(self) -> None:
"""TMS fee_lines 常是 JSON 字符串,在TMS基础上改不能当成没明细。"""
from agent.policy.sea_group_ops import merge_quote_fees
base = {
"source_label": "TMS 标准报价",
"total": "2287.65",
"currency": "CNY",
"fee_lines": (
'[{"name":"海运费","amount":"100","currency":"USD","unit_price":"100"},'
'{"name":"操作费","amount":"680","currency":"CNY","unit_price":"680"},'
'{"name":"文件费","amount":"450","currency":"CNY","unit_price":"450"}]'
),
}
overlay = {
"source_label": "产品报价",
"total": "1200",
"currency": "CNY",
"fee_rows": [
{"name": "操作费", "amount": "700", "currency": "CNY"},
{"name": "文件发", "amount": "500", "currency": "CNY"},
],
}
merged = merge_quote_fees(base, overlay)
by_name = {str(x.get("name") or ""): x for x in (merged.get("fee_rows") or [])}
self.assertEqual(str(by_name["海运费"].get("amount") or ""), "100")
self.assertEqual(str(by_name["操作费"].get("amount") or ""), "700")
self.assertEqual(str(by_name["操作费"].get("unit_price") or ""), "700")
self.assertEqual(str(by_name["文件费"].get("amount") or ""), "500")
self.assertEqual(str(by_name["文件费"].get("unit_price") or ""), "500")
self.assertEqual(str(by_name["海运费"].get("unit_price") or ""), "100")
self.assertNotIn("文件发", by_name)
self.assertEqual(str(merged.get("total") or ""), "2357.65")
self.assertNotEqual(str(merged.get("total") or ""), "1200")
def test_usd_prefix_fee_lines_are_both_kept(self) -> None:
"""海运费:USD 100.00 / 燃油附加费:USD 15.00 两行都要抽出,不能只剩海运费。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote("海运费:USD 100.00\n燃油附加费:USD 15.00")
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("海运费", rows)
self.assertIn("燃油附加费", rows)
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
def test_fee_line_text_is_a_product_quote(self) -> None:
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote("码头操作费100CNY\n报关费改为300CNY")
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("码头操作费", rows)
self.assertIn("报关费", rows)
self.assertEqual(str(rows["码头操作费"].get("amount") or ""), "100")
self.assertEqual(str(rows["报关费"].get("amount") or ""), "300")
self.assertNotEqual(
[str(x.get("name") or "") for x in (quote.get("fee_rows") or [])],
["海运费"],
)
def test_fee_change_typo_and_colon(self) -> None:
"""操作费改为700 + 文件发改为:500,两行都要抽出,文件发要对上文件费。"""
from agent.llm.extract_text import extract_product_quote
from agent.policy.sea_group_ops import _fee_name_same, merge_quote_fees
quote = extract_product_quote("操作费改为700CNY\n文件发改为:500CNY")
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertEqual(str(rows["操作费"].get("amount") or ""), "700")
self.assertEqual(str(rows["文件发"].get("amount") or ""), "500")
self.assertTrue(_fee_name_same("文件发", "文件费"))
self.assertFalse(_fee_name_same("操作费", "码头操作费"))
merged = merge_quote_fees(
{
"source_label": "TMS标准报价",
"fee_rows": [
{"name": "操作费", "amount": "680", "currency": "CNY"},
{"name": "文件费", "amount": "450", "currency": "CNY"},
],
},
quote,
)
by_name = {str(x.get("name") or ""): x for x in (merged.get("fee_rows") or [])}
self.assertEqual(str(by_name["操作费"].get("amount") or ""), "700")
self.assertEqual(str(by_name["文件费"].get("amount") or ""), "500")
self.assertNotIn("文件发", by_name)
class SeaContinueTests(unittest.TestCase):
def setUp(self) -> None:
@@ -912,6 +1021,53 @@ class SeaGroupCopyTests(unittest.TestCase):
self.assertNotIn("HS编码", text)
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
self.assertNotIn("未查到合适报价", text)
self.assertNotIn("确认报价", text)
def test_group_brief_has_confirm_hint_when_tms_priced(self) -> None:
text = copy.sea_group_brief(
work_order_no="WO202609160004",
facts={"起运港": "DONGGUAN", "目的港": "海防", "品名": "机器设备"},
quote={"total": "2287.65", "currency": "CNY", "valid_until": "2026-09-30"},
collab_facts={},
sales_name="吴季林",
product_names=["林绵勋"],
has_price=True,
)
self.assertIn("报价有效期:2026-09-30", text)
self.assertIn("有TMS报价时可回复“确认报价”沿用该价", text)
self.assertIn("也可直接回复协同报价或上传报价附件", text)
self.assertNotIn("人工报价:", text)
def test_group_brief_manual_quote_stays_off_tms(self) -> None:
text = copy.sea_group_brief(
work_order_no="WO202609160006",
facts={"起运港": "上海港", "目的港": "海防", "品名": "机器设备"},
quote={
"source_label": "产品报价",
"total": "115",
"currency": "USD",
"fee_rows": [
{"name": "海运费", "amount": "100.00", "currency": "USD"},
{"name": "燃油附加费", "amount": "15.00", "currency": "USD"},
],
},
collab_facts={},
sales_name="吴季林",
product_names=["林绵勋", "罗小华", "吴季林"],
has_price=True,
tms_quote={},
product_quote={
"source_label": "产品报价",
"fee_rows": [
{"name": "海运费", "amount": "100.00", "currency": "USD"},
{"name": "燃油附加费", "amount": "15.00", "currency": "USD"},
],
},
)
self.assertIn("TMS报价:\nTMS 暂无匹配报价", text)
self.assertIn("人工报价:\n海运费:USD 100.00\n燃油附加费:USD 15.00", text)
self.assertNotIn("确认报价", text)
self.assertNotIn("总销售价合计:USD 100.00", text)
def test_scheme_card_uses_sea_fields(self) -> None:
facts = {
@@ -950,6 +1106,11 @@ class SeaGroupCopyTests(unittest.TestCase):
self.assertIn("1x40HQ", rows["箱型"])
self.assertNotIn("包装", rows)
def test_lost_ack_asks_reason_only(self) -> None:
text = copy.deal_ack("未成交", "WO202609160004")
self.assertEqual(text, "已记录为「未成交」。请补充未成交原因。")
self.assertNotIn("将关闭智能体跟进", text)
def test_invited_names_carry_role(self) -> None:
text = copy.sea_group_created(
work_order_no="WO202609150019",
@@ -1080,6 +1241,11 @@ class SeaGroupOpsTests(unittest.TestCase):
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.attachments[0]["file_name"], "产品报价.pdf")
self.assertEqual(ticket.status, "已报价")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertIn(f"工单{wo}的1个附件已保存到后台工单", texts)
self.assertNotIn("附件已保存后台", texts)
ask = [t for t in texts if "在TMS基础上改" in t]
self.assertFalse(ask)
self.assertTrue(any(x.get("kind") == "card" for x in self.groups.sent))
def test_terminal_file_speaks(self) -> None:
@@ -1236,6 +1402,135 @@ class SeaGroupOpsTests(unittest.TestCase):
self.assertEqual(sheet["B2"].value, "8479")
self.assertIn("油:否", str(sheet["B3"].value or ""))
def test_group_deal_text_asks_to_type(self) -> None:
"""群里成交跟进是文字版:@销售、请确认、请输入,不要报价合计和请选择。"""
text = copy.group_deal_followup(sales_name="吴季林", work_order_no="WO202609160001")
self.assertEqual(
text,
(
"@吴季林\n"
"询价工单 WO202609160001\n"
"报价单已发送,请确认成交状态\n"
"咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)"
),
)
wo, chat = self._open_group()
handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="xls-deal-copy",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "产品报价.pdf", "media_id": "mid-deal-copy"},
),
flow=self.flow,
ledger=self.ledger,
)
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
follow = [t for t in texts if "请确认成交状态" in t and "请输入成交" in t]
self.assertTrue(follow)
self.assertTrue(any(t.startswith("@") and f"询价工单 {wo}" in t for t in follow))
self.assertFalse(any("请选择成交" in t or "请跟进成交状态" in t or "总销售价合计" in t for t in follow))
def test_group_text_deal_does_not_go_to_private_card(self) -> None:
"""群里打「成交」不能当私聊点卡丢掉,必须走群成交。"""
from unittest.mock import patch
from agent.routing.decision import RouteDecision
from agent.routing.dispatch import dispatch_inbound
wo, chat = self._open_group()
with patch("agent.handlers.group_collab.get_sea_text_flow", return_value=self.flow):
result = dispatch_inbound(
InboundMessage(
sender_id="sales1",
message_id="deal-dispatch-1",
content="成交",
chat_id=chat,
chat_type="group",
),
RouteDecision(intent="ordinary_text_other"),
)
self.assertEqual(result, "group_collab")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "已成交")
def test_sales_can_close_deal_after_skinny_snapshot(self) -> None:
"""按群找回的工单只有字段、没有报价时,未成交也要能入账。"""
wo, chat = self._open_group()
real_find = self.ledger.find_by_collab_chat
def skinny(*, chat_id: str):
ticket = real_find(chat_id=chat_id)
if ticket is None:
return None
return {
"found": True,
"work_order_no": ticket.work_order_no,
"sales_wecom_id": ticket.sales_wecom_id,
"collab_chat_id": ticket.collab_chat_id,
"collab_member_ids": list(ticket.collab_member_ids),
"facts": dict(ticket.facts),
}
self.ledger.find_by_collab_chat = skinny # type: ignore[method-assign]
self.flow._by_ticket.clear()
self.flow._by_sender.clear()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-skinny-1",
content="未成交",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "未成交")
def test_sales_can_close_deal_by_text(self) -> None:
"""群里销售说成交,要入账并回话,不能装没听见。相近说法也认。"""
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="deal-text-1",
content="成交",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "已成交")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertTrue(any("已记录为「已成交」" in t for t in texts))
def test_product_cannot_close_deal_by_text(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="deal-text-2",
content="这个成了",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "not_sales")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertNotEqual(ticket.status, "已成交")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertTrue(any("只有负责该工单的销售" in t for t in texts))
def test_stale_deal_card_speaks(self) -> None:
from agent.policy.sea_group_ops import explain_deal_click
@@ -1254,6 +1549,301 @@ class SeaGroupOpsTests(unittest.TestCase):
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertTrue(any("已过期" in t for t in texts))
def test_sales_who_is_also_product_can_quote(self) -> None:
"""拉群销售若后台也是产品·海运,群里按行改价必须收下,不能装没看见。"""
self.ledger.set_sea_staff(
[
{
"name": "吴吉林",
"wecomId": "sales1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
},
{
"name": "赵产品",
"wecomId": "prod4",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
},
]
)
wo, chat = self._open_group()
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertIn("sales1", ticket.product_ids)
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="dual-role-quote",
content="码头操作费100CNY\n报关费改为300CNY",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertEqual(phase, "wait_tms_choice")
def test_existing_ticket_sales_later_product_role_can_quote(self) -> None:
"""已建群后销售才挂上产品岗:仍按后台岗位认,不能只看建群时的产品名单。"""
wo, chat = self._open_group()
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertNotIn("sales1", ticket.product_ids)
self.ledger.set_sea_staff(
[
{
"name": "吴吉林",
"wecomId": "sales1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="later-product-quote",
content="码头操作费100CNY\n报关费改为300CNY",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertEqual(phase, "wait_tms_choice")
def test_product_fee_line_quote_asks_then_keeps_tms(self) -> None:
"""有 TMS 价时先问;选「在基础上改」才出单,没点名的 TMS 费用保留。"""
wo, chat = self._open_group()
ticket = self.ledger.get_ticket(work_order_no=wo)
ticket.quote = {
"total": "2287.65",
"currency": "CNY",
"source_label": "TMS 标准报价",
"fee_lines": [
{"name": "海运费", "amount": "100.00", "currency": "USD"},
{"name": "操作费", "amount": "680.00", "currency": "CNY"},
{"name": "报关费", "amount": "250.00", "currency": "CNY"},
],
}
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="fee-line-1",
content="码头操作费100CNY\n报关费改为300CNY",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertEqual(phase, "wait_tms_choice")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertTrue(any("在TMS基础上改" in t and "不用TMS报价" in t for t in texts))
self.assertFalse(any("没有识别到报价" in t for t in texts))
ticket = self.ledger.get_ticket(work_order_no=wo)
before = {str(x.get("name") or ""): x for x in (ticket.quote.get("fee_lines") or [])}
self.assertEqual(str(before["报关费"].get("amount") or ""), "250.00")
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="fee-line-2",
content="在TMS基础上改",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "product_quote_sent")
ticket = self.ledger.get_ticket(work_order_no=wo)
names = {
str(x.get("name") or ""): x
for x in (ticket.quote.get("fee_lines") or ticket.quote.get("fee_rows") or [])
}
self.assertEqual(str(names["报关费"].get("amount") or ""), "300")
self.assertEqual(str(names["操作费"].get("amount") or ""), "680.00")
self.assertEqual(str(names["码头操作费"].get("amount") or ""), "100")
self.assertEqual(str(names["海运费"].get("amount") or ""), "100.00")
def test_product_text_on_miss_is_manual_not_tms(self) -> None:
"""TMS 无价时产品文字报价:摘要 TMS 仍是暂无,人工报价两行齐全。"""
facts = dict(COMPLETE_SEA)
facts["目的港"] = "无价港"
self.flow.on_text(
sender_id="sales1",
text="海运无价",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
sess = self.flow.session_of("sales1")
chat = sess.collab_chat_id
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="miss-manual-1",
content="海运费:USD 100.00\n燃油附加费:USD 15.00",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "product_quote_sent")
briefs = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
text = "\n".join(briefs)
self.assertIn("TMS 暂无匹配报价", text)
self.assertIn("人工报价:", text)
self.assertIn("海运费:USD 100.00", text)
self.assertIn("燃油附加费:USD 15.00", text)
self.assertNotIn("确认报价", text)
def test_confirm_tms_saves_sheet_and_notifies_app(self) -> None:
"""有 TMS 价时产品回确认报价:模板进后台,群里告诉大家已保存。"""
from agent.routing.group_intent import INTENT_CONFIRM_TMS, classify_group_text
self.assertEqual(classify_group_text("确认报价", allow_network=False), INTENT_CONFIRM_TMS)
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="confirm-tms-1",
content="确认报价",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "quote_confirmed")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "已报价")
self.assertTrue(ticket.attachments)
self.assertIn("quote", str(ticket.attachments[0].get("file_name") or "").lower())
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertIn(f"工单{wo}的1个附件已保存到后台工单", texts)
self.assertTrue(any("成交" in t for t in texts))
def test_first_option_text_keeps_tms(self) -> None:
"""问句列了 1/2 时,回「第一个」要按在 TMS 基础上改,不能装没听见。"""
from agent.routing.group_intent import INTENT_KEEP_TMS, classify_group_text
self.assertEqual(classify_group_text("第一个", allow_network=False), INTENT_KEEP_TMS)
wo, chat = self._open_group()
ticket = self.ledger.get_ticket(work_order_no=wo)
ticket.quote = {
"total": "2287.65",
"currency": "CNY",
"source_label": "TMS 标准报价",
"fee_lines": [
{"name": "操作费", "amount": "680", "currency": "CNY", "unit_price": "680"},
{"name": "文件费", "amount": "450", "currency": "CNY", "unit_price": "450"},
],
}
handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="first-opt-1",
content="操作费改为700CNY / 文件费改为500CNY",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="first-opt-2",
content="第一个",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "product_quote_sent")
ticket = self.ledger.get_ticket(work_order_no=wo)
names = {
str(x.get("name") or ""): x
for x in (ticket.quote.get("fee_lines") or ticket.quote.get("fee_rows") or [])
}
self.assertEqual(str(names["操作费"].get("amount") or ""), "700")
self.assertEqual(str(names["文件费"].get("amount") or ""), "500")
def test_other_product_can_drop_tms(self) -> None:
"""任一产品都能选;说不用 TMS 就只留刚报的价。"""
self.ledger.set_sea_staff(
[
{
"name": "赵产品",
"wecomId": "prod4",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
},
{
"name": "钱产品",
"wecomId": "prod5",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
},
]
)
wo, chat = self._open_group()
ticket = self.ledger.get_ticket(work_order_no=wo)
ticket.quote = {
"source_label": "TMS 标准报价",
"fee_lines": [
{"name": "海运费", "amount": "100.00", "currency": "USD"},
{"name": "报关费", "amount": "250.00", "currency": "CNY"},
],
}
handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="drop-1",
content="报关费改为300CNY",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
phase = handle_group_collab(
InboundMessage(
sender_id="prod5",
message_id="drop-2",
content="不要TMS的价,按我刚才报的",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "product_quote_sent")
ticket = self.ledger.get_ticket(work_order_no=wo)
names = {
str(x.get("name") or ""): x
for x in (ticket.quote.get("fee_lines") or ticket.quote.get("fee_rows") or [])
}
self.assertEqual(list(names), ["报关费"])
self.assertEqual(str(names["报关费"].get("amount") or ""), "300")
def test_parse_group_file_fills_media(self) -> None:
from agent.channel.wecom.parse import parse_plain_xml
@@ -86,6 +86,26 @@ class WshotoParseTests(unittest.TestCase):
self.assertEqual(msg.message_id, "wshoto:m1")
self.assertEqual(msg.create_time, 1_700_000_000)
def test_official_text_content_beats_short_preview(self) -> None:
"""管家 content 常是摘要或空,正文在企微官方 text.content。必须取更长的那份。"""
msg = archive_record_to_inbound(
{
"msgid": "m1b",
"seq": 11,
"from": "LuoXiaoHua",
"room_id": "wr_room_1",
"msgtype": "text",
"content": "码头操作费",
"msg_content": {
"text": {"content": "码头操作费100CNY\n报关费改为300CNY"}
},
},
seat_userids=["AI001"],
)
self.assertIsNotNone(msg)
assert msg is not None
self.assertEqual(msg.content, "码头操作费100CNY\n报关费改为300CNY")
def test_skip_archive_seat_and_display_name(self) -> None:
self.assertIsNone(
archive_record_to_inbound(
@@ -37,6 +37,9 @@ public class InquiryAgentController {
@Autowired
private org.jeecg.modules.inquiry.service.IInquiryStaffRoleService staffRoleService;
@Autowired
private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService;
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
private String agentToken;
@@ -157,6 +160,18 @@ public class InquiryAgentController {
return Result.OK(out);
}
@ApiOperation("按岗位列出启用员工(空运群 @ 航线·空运)")
@PostMapping("/staff/listByRole")
public Result<Map<String, Object>> listStaffByRole(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
Map<String, Object> out = new java.util.LinkedHashMap<>();
out.put("staff", staffRoleService.listActiveByRole(firstStr(body, "roleCode", "role_code")));
return Result.OK(out);
}
@ApiOperation("按企微 userid 取员工展示名")
@PostMapping("/staff/byWecom")
public Result<Map<String, Object>> staffByWecom(@RequestBody Map<String, Object> body,
@@ -237,6 +252,36 @@ public class InquiryAgentController {
return Result.OK(workOrderService.upsertProductQuote(body));
}
@ApiOperation("空运群激活/切换当前工单(一单一群)")
@PostMapping("/ticket/activateAirGroup")
public Result<Map<String, Object>> activateAirGroup(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.activateAirGroup(body));
}
@ApiOperation("空运锁舱(主账 HMAC 出站 TMS,智能体不直连)")
@PostMapping("/tms/lockCabin")
public Result<Map<String, Object>> lockCabin(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(cabinService.lockCabin(body));
}
@ApiOperation("空运释放舱位(已成交本地拦住,不调 TMS)")
@PostMapping("/tms/releaseCabin")
public Result<Map<String, Object>> releaseCabin(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(cabinService.releaseCabin(body));
}
private boolean authed(HttpServletRequest http) {
String got = http.getHeader("X-Agent-Token");
if (StringUtils.isBlank(got)) {
@@ -0,0 +1,46 @@
package org.jeecg.modules.inquiry.domain;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* 空运群「当前工单」能不能激活。纯内存规则,不写库。
* <p>
* 可激活:业务线 AIR,且六态询价中 / 已报价 / 协商中。
* 一张单同时只跟一个群;已经绑了别的群就 occupied。
*/
public final class AirGroupActivateRule {
public static final Set<String> OPEN = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
TicketStatuses.INQUIRING, TicketStatuses.QUOTED, TicketStatuses.NEGOTIATING
)));
private AirGroupActivateRule() {
}
/**
* @return 空串表示可以激活;否则是给智能体的错误码
*/
public static String rejectReason(InquiryTicket ticket, String chatId) {
if (ticket == null) {
return "ticket_not_found";
}
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
return "not_air";
}
if (!OPEN.contains(StringUtils.trimToEmpty(ticket.getStatus()))) {
return "terminal";
}
String bound = StringUtils.trimToEmpty(ticket.getCollabChatId());
String room = StringUtils.trimToEmpty(chatId);
if (StringUtils.isNotBlank(bound) && !bound.equals(room)) {
return "occupied";
}
return "";
}
}
@@ -37,7 +37,12 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
java.util.List<java.util.Map<String, Object>> matchSeaStaff(String origin, String destination);
/**
* 按企微 userid 取展示名。
* 列出该岗位启用员工。空运群 @ 航线用 roleCode=air。
*/
java.util.List<java.util.Map<String, Object>> listActiveByRole(String roleCode);
/**
* 按企微 userid 取展示名、岗位、启用状态。空运群认「航线·空运」靠 roleCode+status。
*/
java.util.Map<String, Object> findDisplayByWecomId(String wecomId);
@@ -104,4 +104,9 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
* 产品报价写入新版本并视情况迁「已报价」。
*/
Map<String, Object> upsertProductQuote(Map<String, Object> body);
/**
* 空运群激活/切换当前工单。一单一群,未结案才能绑。不改六态。
*/
Map<String, Object> activateAirGroup(Map<String, Object> body);
}
@@ -534,9 +534,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
out.put("quote_no", StringUtils.defaultString(workOrderNo));
out.put("pol", first(src, "起运港", "pol", "origin"));
out.put("origin", first(src, "起运港", "origin", "pol"));
out.put("pod", first(src, "目的港", "pod", "destination"));
out.put("destination", first(src, "目的港", "destination", "pod"));
out.put("origin", first(src, "起运地", "起运港", "origin", "pol"));
out.put("pod", first(src, "目的地", "目的港", "pod", "destination"));
out.put("destination", first(src, "目的地", "目的港", "destination", "pod"));
out.put("commodity", first(src, "品名", "commodity"));
// 海运七项:合同键是 quantity / fcl_or_lcl / container_type_qty,工单事实是中文键
out.put("quantity", first(src, "货量", "货物数量", "quantity"));
@@ -558,6 +558,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
+ ";电:" + (StringUtils.isNotBlank(elec) ? elec : "-")
+ ";磁:" + (StringUtils.isNotBlank(mag) ? mag : "-");
}
if (StringUtils.isNotBlank(elec)) {
out.put("has_battery", elec);
out.put("是否含电", elec);
}
if (StringUtils.isNotBlank(mag)) {
out.put("has_magnet", mag);
out.put("是否含磁", mag);
}
if (StringUtils.isNotBlank(oil)) {
out.put("has_oil", oil);
out.put("是否含油", oil);
}
if (StringUtils.isNotBlank(oem)) {
out.put("oil_elec_mag", oem);
out.put("是否含油,电,磁", oem);
@@ -144,6 +144,30 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
return org.jeecg.modules.inquiry.staff.SeaStaffMatcher.match(rows, origin, destination);
}
@Override
public List<Map<String, Object>> listActiveByRole(String roleCode) {
String code = StringUtils.trimToEmpty(roleCode).toLowerCase();
List<Map<String, Object>> out = new java.util.ArrayList<>();
if (code.isEmpty()) {
return out;
}
List<InquiryStaffRole> rows = list(new QueryWrapper<InquiryStaffRole>()
.eq("role_code", code)
.eq("status", "active"));
for (InquiryStaffRole row : rows) {
if (row == null || StringUtils.isBlank(row.getWecomId())) {
continue;
}
Map<String, Object> one = new HashMap<>();
one.put("name", row.getName());
one.put("wecomId", row.getWecomId());
one.put("roleCode", row.getRoleCode());
one.put("status", row.getStatus());
out.add(one);
}
return out;
}
@Override
public Map<String, Object> findDisplayByWecomId(String wecomId) {
InquiryStaffRole row = findByWecomId(wecomId);
@@ -154,6 +178,7 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
out.put("name", row.getName());
out.put("wecomId", row.getWecomId());
out.put("roleCode", row.getRoleCode());
out.put("status", row.getStatus());
return out;
}
@@ -175,6 +200,7 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
out.put("name", row.getName());
out.put("wecomId", row.getWecomId());
out.put("roleCode", row.getRoleCode());
out.put("status", row.getStatus());
return out;
}
}
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.domain.AirGroupActivateRule;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.dto.AgentTicketCreateRequest;
import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
@@ -516,7 +517,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
boolean owned = StringUtils.isNotBlank(salesWecomId)
&& salesWecomId.equals(ticket.getSalesWecomId());
out.put("owned", owned);
if (!owned) {
// 服务账号读单(群协同/锁舱)不带销售 id,仍回快照;续办带了销售 id 才拦别人的单。
if (StringUtils.isNotBlank(salesWecomId) && !owned) {
return out;
}
Map<String, String> facts = new LinkedHashMap<>();
@@ -585,6 +587,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("ok", true);
out.put("status", status);
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
out.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
out.put("salesName", StringUtils.defaultString(ticket.getSalesName()));
out.put("facts", facts);
out.put("quote", quote);
out.put("wait_phase", waitPhase);
@@ -706,8 +710,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
continue;
}
String val = String.valueOf(e.getValue()).trim();
if (!val.isEmpty()) {
merged.put(String.valueOf(e.getKey()), val);
String factKey = String.valueOf(e.getKey());
if (val.isEmpty()) {
// 空值表示删掉该协同键,例如清掉待确认报价。
merged.remove(factKey);
} else {
merged.put(factKey, val);
}
}
}
@@ -741,6 +749,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("work_order_no", ticket.getWorkOrderNo());
out.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
out.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
out.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
out.put("salesName", StringUtils.defaultString(ticket.getSalesName()));
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds()));
out.put("memberIds", parseMemberIds(ticket.getCollabMemberIds()));
@@ -870,6 +880,57 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return out;
}
/**
* 空运群当前工单:清掉同群其它空运单绑定,一单一群。不改六态。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> activateAirGroup(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
String room = text(body, "chatId", "chat_id");
InquiryTicket ticket = findByWorkOrderNo(no);
Map<String, Object> out = new LinkedHashMap<>();
String reject = AirGroupActivateRule.rejectReason(ticket, room);
if (StringUtils.isNotBlank(reject)) {
out.put("ok", false);
out.put("error", reject);
if (ticket != null) {
out.put("status", ticket.getStatus());
out.put("other_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
}
return out;
}
List<InquiryTicket> others = list(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getCollabChatId, room)
.ne(InquiryTicket::getWorkOrderNo, no));
if (others != null) {
for (InquiryTicket other : others) {
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(other.getBusinessLine()))) {
continue;
}
other.setCollabChatId("");
other.setUpdateTime(new Date());
updateById(other);
}
}
ticket.setCollabChatId(room);
ticket.setUpdateTime(new Date());
updateById(ticket);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "activate_air_group", "agent", room);
out.put("ok", true);
out.put("work_order_no", no);
out.put("chat_id", room);
out.put("status", ticket.getStatus());
out.put("business_line", ticket.getBusinessLine());
out.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
out.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
out.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
out.put("facts", parseFactsJson(ticket.getFactsJson()));
out.put("quote", latestQuotePayload(no));
out.put("collab_chat_id", room);
return out;
}
private static Map<String, String> parseFactsJson(String raw) {
Map<String, String> facts = new LinkedHashMap<>();
if (StringUtils.isBlank(raw)) {
@@ -60,6 +60,11 @@ public final class QuoteTemplateExcelSupport {
FILL_BY_LABEL.put("HS编码", "hs_code");
FILL_BY_LABEL.put("海关编码", "hs_code");
FILL_BY_LABEL.put("是否含油,电,磁", "oil_elec_mag");
FILL_BY_LABEL.put("是否含电", "has_battery");
FILL_BY_LABEL.put("是否含磁", "has_magnet");
FILL_BY_LABEL.put("是否含油", "has_oil");
FILL_BY_LABEL.put("起运地", "origin");
FILL_BY_LABEL.put("目的地", "destination");
FILL_BY_LABEL.put("件数", "pieces");
FILL_BY_LABEL.put("重量", "weight_kg");
FILL_BY_LABEL.put("体积", "volume_cbm");
@@ -0,0 +1,425 @@
package org.jeecg.modules.inquiry.tms;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.entity.InquiryQuote;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
* <p>
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
* 当前报价没有 quoteId / optionNo 时补 QT-{工单号} + AIR-OPT-01,仍出站;不因「没 TMS 报价」本地拦住。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@Slf4j
@Service
public class InquiryTmsCabinService {
@Autowired
private TmsProperties properties;
@Autowired
private TmsGatewayClient gateway;
@Autowired
private InquiryQuoteMapper quoteMapper;
@Autowired
@Lazy
private IInquiryWorkOrderService workOrderService;
/**
* 智能体锁舱入口。
*/
public Map<String, Object> lockCabin(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
String optionNo = text(body, "optionNo", "option_no");
InquiryTicket ticket = findTicket(no);
if (ticket == null) {
return fail("ticket_not_found", "找不到工单");
}
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
return fail("not_air", "不是空运单,不能锁舱");
}
if (TicketStatuses.DEAL.equals(ticket.getStatus())
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
}
Map<String, Object> quote = latestQuote(no);
String quoteId = resolveQuoteId(quote, no);
Map<String, Object> option = resolveOption(quote, optionNo);
if (StringUtils.isBlank(quoteId) || option == null) {
return fail("no_lock_target", "锁舱缺少报价编号或舱位选项");
}
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("flightDate", quoteDate(ticket));
req.put("origin", firstNonBlank(text(option, "originAirportCode"), ticket.getOrigin(),
fact(ticket, "起运地", "起运港")));
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"), ticket.getDestination(),
fact(ticket, "目的地", "目的港")));
req.put("airline", firstNonBlank(text(option, "airlineCode", "airline"),
text(option, "airlineName"),
text(quote, "airlineCode", "airline", "airlineName"),
fact(ticket, "航司", "航司代码")));
req.put("weightKg", ticket.getWeightKg());
req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces());
req.put("chargeableWeightKg", firstNonBlank(
text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
text(option, "chargeableWeightKg", "chargeable_weight_kg")));
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"));
}
/**
* 智能体释放入口。已成交不调 TMS。
*/
public Map<String, Object> releaseCabin(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
String lockId = text(body, "lockId", "lock_id");
InquiryTicket ticket = findTicket(no);
if (ticket == null) {
return fail("ticket_not_found", "找不到工单");
}
if (TicketStatuses.DEAL.equals(ticket.getStatus())) {
return fail("deal_blocked", "工单已成交");
}
if (StringUtils.isBlank(lockId)) {
return fail("release_need_lock", "本单还没有成功锁舱记录");
}
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放释放");
}
Map<String, Object> req = new LinkedHashMap<>();
req.put("lockId", lockId);
req.put("workOrderNo", no);
req.put("flightDate", quoteDate(ticket));
req.put("origin", ticket.getOrigin());
req.put("destination", ticket.getDestination());
req.put("originCode", ticket.getOrigin());
req.put("destinationCode", ticket.getDestination());
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("instructionText", text(body, "instructionText", "instruction_text"));
req.put("releaseReason", text(body, "instructionText", "instruction_text"));
return invokeRelease(ticket, req, lockId);
}
private Map<String, Object> invokeLock(InquiryTicket ticket, Map<String, Object> req,
String quoteId, String optionNo) {
try {
Map<String, Object> tms = gateway.lockCabin(req);
int code = TmsV2ResponseNormalizer.number(tms.get("code"));
if (code != 1) {
return fail("lock_fail", cabinFailReason(code, tms));
}
Map<String, Object> data = tmsData(tms);
String lockId = firstNonBlank(text(data, "lockId"), text(tms, "lockId"));
String status = firstNonBlank(text(data, "status"), "LOCKED");
if (StringUtils.isBlank(lockId) || "REJECTED".equalsIgnoreCase(status)) {
return fail("lock_fail", firstNonBlank(text(data, "failReason"), "锁舱失败"));
}
persistCabinFact(ticket, "__air_lock_id", lockId);
persistCabinFact(ticket, "__air_lock_option", optionNo);
persistCabinFact(ticket, "__air_lock_quote", quoteId);
Map<String, Object> out = okBase();
out.put("lockId", lockId);
out.put("quoteId", firstNonBlank(text(data, "quoteId"), quoteId));
out.put("optionNo", firstNonBlank(text(data, "optionNo"), optionNo));
out.put("status", status);
out.put("tmsRequestId", firstNonBlank(text(data, "tmsRequestId"), text(tms, "tmsRequestId")));
return out;
} catch (RuntimeException ex) {
log.warn("tms.lock fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
return fail("lock_fail", firstNonBlank(ex.getMessage(), "锁舱失败"));
}
}
private Map<String, Object> invokeRelease(InquiryTicket ticket, Map<String, Object> req, String lockId) {
try {
Map<String, Object> tms = gateway.releaseCabin(req);
int code = TmsV2ResponseNormalizer.number(tms.get("code"));
if (code != 1) {
return fail("release_fail", cabinFailReason(code, tms));
}
Map<String, Object> data = tmsData(tms);
Map<String, Object> out = okBase();
out.put("lockId", firstNonBlank(text(data, "lockId"), lockId));
out.put("releaseId", firstNonBlank(text(data, "releaseId"), lockId));
out.put("status", firstNonBlank(text(data, "status"), "RELEASED"));
out.put("alreadyReleased", data.get("alreadyReleased"));
out.put("tmsRequestId", firstNonBlank(text(data, "tmsRequestId"), text(tms, "tmsRequestId")));
return out;
} catch (RuntimeException ex) {
log.warn("tms.release fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
return fail("release_fail", firstNonBlank(ex.getMessage(), "释放失败"));
}
}
private void persistCabinFact(InquiryTicket ticket, String key, String value) {
if (ticket == null || StringUtils.isBlank(key) || StringUtils.isBlank(value)) {
return;
}
Map<String, Object> body = new HashMap<>();
body.put("workOrderNo", ticket.getWorkOrderNo());
Map<String, String> facts = new LinkedHashMap<>();
facts.put(key, value);
body.put("facts", facts);
workOrderService.patchCollabFacts(body);
}
private InquiryTicket findTicket(String workOrderNo) {
if (StringUtils.isBlank(workOrderNo)) {
return null;
}
return workOrderService.getOne(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
.last("LIMIT 1"), false);
}
private Map<String, Object> latestQuote(String workOrderNo) {
Map<String, Object> quote = new LinkedHashMap<>();
List<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByDesc(InquiryQuote::getCreateTime)
.last("LIMIT 8"));
if (rows == null || rows.isEmpty()) {
return quote;
}
JSONObject latest = JSON.parseObject(StringUtils.defaultString(rows.get(0).getPayloadJson()));
if (latest != null) {
quote.putAll(latest);
}
if (StringUtils.isNotBlank(text(quote, "quoteId", "quote_id")) && !airOptions(quote).isEmpty()) {
return quote;
}
for (int i = 1; i < rows.size(); i++) {
if (StringUtils.isBlank(rows.get(i).getPayloadJson())) {
continue;
}
JSONObject older = JSON.parseObject(rows.get(i).getPayloadJson());
if (older == null) {
continue;
}
if (StringUtils.isBlank(text(quote, "quoteId", "quote_id"))) {
String olderId = firstNonBlank(
older.getString("quoteId"),
older.getString("quote_id"));
if (StringUtils.isNotBlank(olderId)) {
quote.put("quoteId", olderId);
}
}
if (airOptions(quote).isEmpty()) {
Object olderOpts = older.get("airOptions");
if (olderOpts == null) {
olderOpts = older.get("air_options");
}
if (olderOpts != null) {
quote.put("airOptions", olderOpts);
}
}
}
return quote;
}
/**
* 没有 TMS 5.2 报价编号时,用工单号生成关联 ID,仍调锁舱。
*/
static String resolveQuoteId(Map<String, Object> quote, String workOrderNo) {
String id = text(quote, "quoteId", "quote_id");
if (StringUtils.isNotBlank(id)) {
return id;
}
return StringUtils.isBlank(workOrderNo) ? "" : "QT-" + workOrderNo.trim();
}
/**
* 有舱位选项按名称挑;一张都没有时补 AIR-OPT-01,让人工价也能出站。
*/
static Map<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
if (!options.isEmpty()) {
Map<String, Object> picked = pickOption(quote, optionNo);
if (picked != null) {
return picked;
}
if (options.size() == 1) {
return options.get(0);
}
return null;
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("optionNo", StringUtils.isNotBlank(optionNo) ? optionNo.trim() : "AIR-OPT-01");
String airline = text(quote, "airlineCode", "airline", "airlineName");
if (StringUtils.isNotBlank(airline)) {
row.put("airlineCode", airline);
}
return row;
}
@SuppressWarnings("unchecked")
static Map<String, Object> pickOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
if (options.isEmpty()) {
return null;
}
String want = StringUtils.trimToEmpty(optionNo);
if (want.isEmpty() && options.size() == 1) {
return options.get(0);
}
for (Map<String, Object> row : options) {
String no = firstNonBlank(text(row, "optionNo", "option_no"));
if (want.equalsIgnoreCase(no)) {
return row;
}
}
return null;
}
@SuppressWarnings("unchecked")
static List<Map<String, Object>> airOptions(Map<String, Object> quote) {
List<Map<String, Object>> out = new ArrayList<>();
if (quote == null) {
return out;
}
Object raw = quote.get("airOptions");
if (raw == null) {
raw = quote.get("air_options");
}
if (raw instanceof String && String.valueOf(raw).trim().startsWith("[")) {
try {
raw = JSON.parseArray(String.valueOf(raw));
} catch (Exception ignore) {
raw = null;
}
}
if (raw instanceof JSONArray) {
raw = ((JSONArray) raw).toJavaList(Object.class);
}
if (raw instanceof List) {
for (Object item : (List<?>) raw) {
if (item instanceof Map) {
out.add((Map<String, Object>) item);
}
}
}
return out;
}
private static String quoteDate(InquiryTicket ticket) {
if (ticket == null || ticket.getQuoteDate() == null) {
return "";
}
return new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate());
}
private static String fact(InquiryTicket ticket, String... keys) {
if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) {
return "";
}
JSONObject parsed = JSON.parseObject(ticket.getFactsJson());
if (parsed == null) {
return "";
}
for (String key : keys) {
Object val = parsed.get(key);
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
return String.valueOf(val).trim();
}
}
return "";
}
@SuppressWarnings("unchecked")
private static Map<String, Object> tmsData(Map<String, Object> tms) {
Object raw = tms == null ? null : tms.get("tmsData");
if (raw instanceof Map) {
return (Map<String, Object>) raw;
}
return tms == null ? new LinkedHashMap<>() : tms;
}
private static String cabinFailReason(int code, Map<String, Object> tms) {
Map<String, Object> data = tmsData(tms);
String detail = firstNonBlank(text(data, "failReason"), text(tms, "message"));
if (code == 2006) {
return firstNonBlank(detail, "舱位选项不存在或与报价不匹配");
}
if (code == 2007) {
return firstNonBlank(detail, "报价已过期,请重新询价");
}
if (code == 2008) {
return firstNonBlank(detail, "舱位不存在");
}
if (code == 2009) {
return firstNonBlank(detail, "锁舱被业务规则拒绝");
}
if (code == 1005) {
return firstNonBlank(detail, "TMS 签名失败");
}
return firstNonBlank(detail, "TMS 业务码=" + code);
}
private static Map<String, Object> okBase() {
Map<String, Object> out = new LinkedHashMap<>();
out.put("ok", true);
return out;
}
private static Map<String, Object> fail(String error, String reason) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("ok", false);
out.put("error", error);
out.put("failReason", reason);
return out;
}
private static String text(Map<String, Object> body, String... keys) {
if (body == null || keys == null) {
return "";
}
for (String key : keys) {
Object val = body.get(key);
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
return String.valueOf(val).trim();
}
}
return "";
}
private static String firstNonBlank(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
if (StringUtils.isNotBlank(value)) {
return value.trim();
}
}
return "";
}
}
@@ -32,7 +32,8 @@ import java.util.TimeZone;
* 业务线以工单为准,禁止海运请求被默认成 AIR。
* 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价;
* HTTP 超时/熔断/5xx → 技术失败。
* 禁止:因 originCode 对不上就 skip;fixture 假价;LOCK/RELEASE;Python 直连 TMS。
* 禁止:因 originCode 对不上就 skip;fixture 假价;Python 直连 TMS。
* 锁舱/释放不在本类,见 {@link InquiryTmsCabinService}。
*/
@Slf4j
@Service
@@ -282,6 +283,20 @@ public class InquiryTmsQueryService {
quote.put("source", "TMS");
quote.put("source_label", "TMS 标准报价");
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
// 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。
String quoteId = firstText(data.get("quoteId"), data.get("quote_id"));
if (StringUtils.isNotBlank(quoteId)) {
quote.put("quoteId", quoteId);
}
Object airOptions = data.get("airOptions");
if (airOptions == null) {
airOptions = data.get("air_options");
}
if (airOptions != null) {
quote.put("airOptions", airOptions instanceof String
? String.valueOf(airOptions)
: JSON.toJSONString(airOptions));
}
return quote;
}
@@ -48,7 +48,7 @@ public class TmsAdapterClient {
}
/**
* 锁舱壳:默认关闭;未实现。
* 旧壳保留。正式锁舱走 {@link TmsGatewayClient#lockCabin},受 TMS_V2_LOCK_ENABLED 约束。
*
* @return not_implemented
*/
@@ -0,0 +1,113 @@
package org.jeecg.modules.inquiry.tms;
import org.apache.commons.lang3.StringUtils;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* 把主账工单 + 当前报价拼成 TMS /air/cabin/lock、/air/cabin/release 正文。
* <p>
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
* quoteId / optionNo 由主账补齐:有 TMS 报价用原值,没有则 QT-工单号 + AIR-OPT-01。
*/
public final class TmsCabinRequestMapper {
private TmsCabinRequestMapper() {
}
/**
* 锁舱正文。字段顺序固定,签名按这份 JSON 原文计算。
*/
public static Map<String, Object> lock(Map<String, Object> internal) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("requestId", firstText(internal.get("requestId"), UUID.randomUUID().toString()));
put(body, "workOrderNo", internal.get("workOrderNo"));
put(body, "quoteId", internal.get("quoteId"));
put(body, "optionNo", internal.get("optionNo"));
Object until = internal.get("requestedUntil");
if (until != null && StringUtils.isNotBlank(String.valueOf(until))) {
body.put("requestedUntil", String.valueOf(until).trim());
}
put(body, "origin", first(internal, "origin", "originCode"));
put(body, "destination", first(internal, "destination", "destinationCode"));
put(body, "flightDate", TmsV2RequestMapper.normalizeQuoteDate(internal.get("flightDate")));
put(body, "airline", first(internal, "airline", "airlineCode"));
putNumber(body, "weightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("weightKg")));
putNumber(body, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(internal.get("volumeCbm")));
putNumber(body, "pieceQty", TmsV2RequestMapper.asInteger(internal.get("pieceQty")));
putNumber(body, "chargeableWeightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("chargeableWeightKg")));
put(body, "operatorUserId", internal.get("operatorUserId"));
put(body, "operatorName", internal.get("operatorName"));
body.put("operatorRole", "AIRLINE");
put(body, "instructionText", internal.get("instructionText"));
return body;
}
/**
* 释放正文:按 lockId 精确释放,workOrderNo 必须与 lock 时一致。
*/
public static Map<String, Object> release(Map<String, Object> internal) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("requestId", firstText(internal.get("requestId"), UUID.randomUUID().toString()));
put(body, "lockId", internal.get("lockId"));
put(body, "workOrderNo", internal.get("workOrderNo"));
put(body, "originCode", first(internal, "originCode", "origin"));
put(body, "destinationCode", first(internal, "destinationCode", "destination"));
put(body, "flightDate", TmsV2RequestMapper.normalizeQuoteDate(internal.get("flightDate")));
put(body, "operatorUserId", internal.get("operatorUserId"));
put(body, "operatorName", internal.get("operatorName"));
body.put("operatorRole", "AIRLINE");
put(body, "releaseReason", first(internal, "releaseReason", "instructionText"));
put(body, "instructionText", internal.get("instructionText"));
return body;
}
private static void put(Map<String, Object> target, String key, Object value) {
if (value == null) {
return;
}
String text = String.valueOf(value).trim();
if (!text.isEmpty() && !"null".equals(text)) {
target.put(key, text);
}
}
private static void putNumber(Map<String, Object> target, String key, Number value) {
if (value != null) {
if (value instanceof BigDecimal) {
target.put(key, ((BigDecimal) value).stripTrailingZeros());
} else {
target.put(key, value);
}
}
}
private static Object first(Map<String, Object> src, String... keys) {
if (src == null || keys == null) {
return null;
}
for (String key : keys) {
Object val = src.get(key);
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
return val;
}
}
return null;
}
private static String firstText(Object... values) {
if (values == null) {
return "";
}
for (Object value : values) {
if (value != null && StringUtils.isNotBlank(String.valueOf(value))) {
return String.valueOf(value).trim();
}
}
return "";
}
}
@@ -22,11 +22,12 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* TMS V2 查询网关(只做 query / token;锁舱默认关闭)。
* TMS V2 网关:查询 Bearer;锁舱/释放 Bearer + HMAC(文档 Header 均要)。
* <p>
* 职责:Bearer 出站、超时、最多 3 次、code 1/1002 短缓存、连续失败熔断。
* 职责:出站、超时、最多 3 次、query 对 code 1/1002 短缓存、连续失败熔断。
* 锁舱默认关(TMS_V2_LOCK_ENABLED=false);打开后才 HMAC 出站,单测禁止打真实 TMS。
* 线程:无全局工单大锁;熔断计数是进程级协调,不是六态。
* 禁止:LOCK/RELEASE 出站;按 userid 伪造报价。
* 禁止:按 userid 伪造报价;密钥写入日志。
*/
@Slf4j
@Component
@@ -92,6 +93,89 @@ public class TmsGatewayClient {
return body;
}
/**
* 空运锁舱。开关关闭或密钥不全时直接失败,不出站。
*
* @param request 已按文档排好的 lock 正文
* @return 规范化 envelope(含 code / tmsData)
*/
public Map<String, Object> lockCabin(Map<String, Object> request) {
return invokeCabin(properties.getLockPath(), TmsCabinRequestMapper.lock(request));
}
/**
* 空运释放舱位。同样受 TMS_V2_LOCK_ENABLED 约束。
*/
public Map<String, Object> releaseCabin(Map<String, Object> request) {
return invokeCabin(properties.getReleasePath(), TmsCabinRequestMapper.release(request));
}
/**
* HMAC 出站:X-Timestamp / X-Nonce / X-Signature 与正文原字节一致。
* 锁舱/释放不缓存。连续失败走同一熔断。
*/
private Map<String, Object> invokeCabin(String path, Map<String, Object> request) {
if (!properties.lockConfigured()) {
throw new IllegalStateException("TMS 锁舱未开放或签名未配置");
}
assertCircuitAvailable();
String json = JSON.toJSONString(request);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = java.util.UUID.randomUUID().toString();
String signature = TmsHmacSigner.sign(properties.getSignatureSecret(), timestamp, nonce, json);
int max = Math.max(1, Math.min(3, properties.getMaxAttempts()));
RuntimeException last = new IllegalStateException("TMS 锁舱未尝试");
for (int attempt = 1; attempt <= max; attempt++) {
long started = System.currentTimeMillis();
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(tokens.accessToken());
headers.set("X-Timestamp", timestamp);
headers.set("X-Nonce", nonce);
headers.set("X-Signature", signature);
ResponseEntity<String> response = restTemplate.exchange(
TmsV2TokenProvider.joinUrl(properties.getBaseUrl(), path),
HttpMethod.POST,
new HttpEntity<>(json, headers),
String.class);
Map<String, Object> body = TmsV2ResponseNormalizer.normalize(response.getBody());
consecutiveFailures.set(0);
body.put("formal", true);
body.put("source", "TMS");
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={}",
path, request.get("workOrderNo"), request.get("lockId"), request.get("quoteId"),
body.get("code"), response.getStatusCodeValue(),
System.currentTimeMillis() - started, attempt);
return body;
} catch (HttpStatusCodeException ex) {
int status = ex.getRawStatusCode();
log.warn("tms.cabin http={} path={} attempt={} costMs={}",
status, path, attempt, System.currentTimeMillis() - started);
last = new IllegalStateException("TMS HTTP=" + status);
if ((status == 429 || status >= 500) && attempt < max) {
sleepQuiet(200L * (1L << (attempt - 1)));
continue;
}
failCircuit();
throw last;
} catch (ResourceAccessException ex) {
log.warn("tms.cabin network path={} attempt={} err={}", path, attempt, ex.getMessage());
last = new IllegalStateException("TMS 网络或超时");
if (attempt < max) {
sleepQuiet(200L * (1L << (attempt - 1)));
continue;
}
failCircuit();
throw last;
} catch (IllegalArgumentException ex) {
throw new IllegalStateException("TMS 回包无法解析");
}
}
failCircuit();
throw last;
}
private Map<String, Object> invokeQuery(Map<String, Object> request) {
assertCircuitAvailable();
String json = JSON.toJSONString(request);
@@ -0,0 +1,43 @@
package org.jeecg.modules.inquiry.tms;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* TMS 锁舱/释放 HMAC-SHA256。
* <p>
* 对照接口文档 3.2:canonical = timestamp + "\\n" + nonce + "\\n" + 原始 JSON 正文;
* 密钥和正文都按 UTF-8;X-Signature = Base64(HMAC-SHA256)。
* timestamp / nonce 必须和请求头原文完全一致,禁止重排 JSON 后再签。
* 禁止:把密钥打进日志;禁止智能体侧复制本类去直连 TMS。
*/
public final class TmsHmacSigner {
private TmsHmacSigner() {
}
/**
* @param secret TMS_V2_SIGNATURE_SECRET,不能写进代码默认值
* @param timestamp 请求头 X-Timestamp 原文
* @param nonce 请求头 X-Nonce 原文
* @param rawBody 即将发出的 HTTP 原始 JSON 文本
* @return Base64 签名,写入 X-Signature
*/
public static String sign(String secret, String timestamp, String nonce, String rawBody) {
if (secret == null || secret.isEmpty()) {
throw new IllegalArgumentException("TMS 签名密钥未配置");
}
String canonical = String.valueOf(timestamp) + "\n"
+ String.valueOf(nonce) + "\n"
+ (rawBody == null ? "" : rawBody);
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return Base64.getEncoder().encodeToString(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (Exception ex) {
throw new IllegalStateException("TMS HMAC 计算失败", ex);
}
}
}
@@ -38,10 +38,16 @@ public class TmsProperties {
@Value("${TMS_V2_HEALTH_PATH:/health}")
private String healthPath;
/** 与现网共用 Client 期间禁止出站锁舱。 */
/** 与现网共用 Client 期间禁止出站锁舱。测服联调再开。 */
@Value("${TMS_V2_LOCK_ENABLED:false}")
private boolean lockEnabled;
@Value("${TMS_V2_LOCK_PATH:/air/cabin/lock}")
private String lockPath;
@Value("${TMS_V2_RELEASE_PATH:/air/cabin/release}")
private String releasePath;
@Value("${TMS_V2_CONNECT_TIMEOUT_MS:3000}")
private int connectTimeoutMs;
@@ -69,4 +75,14 @@ public class TmsProperties {
&& clientId != null && !clientId.trim().isEmpty()
&& clientSecret != null && !clientSecret.trim().isEmpty();
}
/**
* 锁舱/释放出站最低配置:开关打开 + 地址 + HMAC 密钥。
* 现网验收期保持 lockEnabled=false,避免和新系统抢同一票舱。
*/
public boolean lockConfigured() {
return lockEnabled
&& baseUrl != null && !baseUrl.trim().isEmpty()
&& signatureSecret != null && !signatureSecret.trim().isEmpty();
}
}
@@ -0,0 +1,50 @@
package org.jeecg.modules.inquiry.domain;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 空运群激活规则:不写库、不打 TMS。
*/
public class AirGroupActivateRuleTest {
@Test
public void openAirCanActivate() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.QUOTED, "");
assertEquals("", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
}
@Test
public void seaRejected() {
InquiryTicket ticket = ticket("SEA", TicketStatuses.QUOTED, "");
assertEquals("not_air", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
}
@Test
public void dealRejected() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.DEAL, "wr_air_1");
assertEquals("terminal", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
}
@Test
public void otherGroupOccupied() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.QUOTED, "wr_air_1");
assertEquals("occupied", AirGroupActivateRule.rejectReason(ticket, "wr_air_2"));
}
@Test
public void sameGroupAgainOk() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.INQUIRING, "wr_air_1");
assertEquals("", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
}
private static InquiryTicket ticket(String line, String status, String chat) {
InquiryTicket row = new InquiryTicket();
row.setBusinessLine(line);
row.setStatus(status);
row.setCollabChatId(chat);
return row;
}
}
@@ -0,0 +1,65 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* 舱位选项挑选:不打真实 TMS。
*/
public class InquiryTmsCabinServiceTest {
@Test
public void pickNamedOption() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> first = new HashMap<>();
first.put("optionNo", "AIR-OPT-01");
Map<String, Object> second = new HashMap<>();
second.put("optionNo", "AIR-OPT-02");
quote.put("airOptions", Arrays.asList(first, second));
assertEquals("AIR-OPT-02", InquiryTmsCabinService.pickOption(quote, "AIR-OPT-02").get("optionNo"));
}
@Test
public void singleOptionWhenBlank() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> only = new HashMap<>();
only.put("optionNo", "AIR-OPT-01");
quote.put("airOptions", Arrays.asList(only));
assertNotNull(InquiryTmsCabinService.pickOption(quote, ""));
}
@Test
public void missingOptionIsNull() {
Map<String, Object> quote = new HashMap<>();
quote.put("airOptions", "[]");
assertNull(InquiryTmsCabinService.pickOption(quote, "AIR-OPT-09"));
}
@Test
public void resolveQuoteIdWithoutTmsUsesWorkOrder() {
assertEquals("QT-WO202609160012",
InquiryTmsCabinService.resolveQuoteId(new HashMap<>(), "WO202609160012"));
}
@Test
public void resolveOptionWithoutTmsSynthesizesDefault() {
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "");
assertEquals("AIR-OPT-01", option.get("optionNo"));
}
@Test
public void resolveOptionKeepsExistingWhenPresent() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> only = new HashMap<>();
only.put("optionNo", "AIR-OPT-03");
quote.put("airOptions", Arrays.asList(only));
assertEquals("AIR-OPT-03", InquiryTmsCabinService.resolveOption(quote, "").get("optionNo"));
}
}
@@ -0,0 +1,54 @@
package org.jeecg.modules.inquiry.tms;
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.assertTrue;
/**
* 锁舱/释放正文:日期走报价日期,角色固定 AIRLINE。不打真实 TMS。
*/
public class TmsCabinRequestMapperTest {
@Test
public void lockUsesQuoteDateAndAirlineRole() {
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("workOrderNo", "WO202609160001");
internal.put("quoteId", "QT-AIR-1");
internal.put("optionNo", "AIR-OPT-01");
internal.put("flightDate", "2026-09-16");
internal.put("origin", "上海");
internal.put("destination", "LAX");
internal.put("airline", "CZ");
internal.put("weightKg", "100");
internal.put("volumeCbm", "2");
internal.put("pieceQty", "3");
internal.put("operatorUserId", "air1");
internal.put("operatorName", "航线");
internal.put("instructionText", "WO202609160001锁舱");
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
assertEquals("WO202609160001", body.get("workOrderNo"));
assertEquals("QT-AIR-1", body.get("quoteId"));
assertEquals("AIR-OPT-01", body.get("optionNo"));
assertEquals("2026-09-16", body.get("flightDate"));
assertEquals("AIRLINE", body.get("operatorRole"));
assertEquals("CZ", body.get("airline"));
assertTrue(body.containsKey("requestId"));
}
@Test
public void releaseKeepsLockId() {
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("lockId", "CA-1");
internal.put("workOrderNo", "WO202609160001");
internal.put("flightDate", "2026/09/16");
internal.put("operatorUserId", "air1");
Map<String, Object> body = TmsCabinRequestMapper.release(internal);
assertEquals("CA-1", body.get("lockId"));
assertEquals("2026-09-16", body.get("flightDate"));
assertEquals("AIRLINE", body.get("operatorRole"));
}
}
@@ -0,0 +1,33 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* HMAC 签名单测:不打真实 TMS。改正文或时间戳必须换签名。
*/
public class TmsHmacSignerTest {
@Test
public void sameInputSameSignature() {
String first = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", "{\"a\":1}");
String second = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", "{\"a\":1}");
assertEquals(first, second);
}
@Test
public void bodyChangeChangesSignature() {
String first = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", "{\"a\":1}");
String second = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", "{\"a\":2}");
assertNotEquals(first, second);
}
@Test
public void blankSecretRejected() {
assertThrows(IllegalArgumentException.class,
() -> TmsHmacSigner.sign("", "1", "n", "{}"));
}
}
@@ -92,8 +92,10 @@ public class TmsQuoteCurrencyTest {
fee.put("remark", "计费重进位+1000KG");
Map<String, Object> data = new HashMap<>();
data.put("quoteFound", true);
data.put("quoteId", "QT-AIR-KEEP");
data.put("totalSalePrice", "14669.60");
data.put("currency", "CNY");
data.put("airOptions", Collections.singletonList(Collections.singletonMap("optionNo", "AIR-OPT-01")));
data.put("feeItems", Collections.singletonList(fee));
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
@@ -107,5 +109,7 @@ public class TmsQuoteCurrencyTest {
assertTrue(quote.get("fee_lines").contains("\"quantity\":\"1333.6\""));
assertTrue(quote.get("fee_lines").contains("\"unit_price\":\"11.0\""));
assertTrue(quote.get("fee_lines").contains("计费重进位+1000KG"));
assertEquals("QT-AIR-KEEP", quote.get("quoteId"));
assertTrue(quote.get("airOptions").contains("AIR-OPT-01"));
}
}
@@ -155,7 +155,7 @@ justauth:
# TMS V2:地址与开关来自环境变量,密钥不写本文件
inquiry:
tms:
note: YTD_TMS_V2_ENABLED / TMS_V2_BASE_URL / TMS_V2_CLIENT_* 从进程环境注入;LOCK 默认关
note: YTD_TMS_V2_ENABLED / TMS_V2_BASE_URL / TMS_V2_CLIENT_* 从进程环境注入;LOCK 默认关。测服联调空运锁舱再设 TMS_V2_LOCK_ENABLED=true,现网验收期保持关。
logging:
level:
+4 -4
View File
@@ -13,9 +13,9 @@
## 2. 企业微信协同群(人 + 产品岗)
- 销售/产品在群里报文字价、发报价 Excel/PDF/图片。
- 空运可复用固定群(环境变量 `WECOM_FIXED_AIR_GROUP_CHAT_IDS`)。
- 存档席用户只为会话存档可见,不能当报价应答人。
- 海运:销售/产品在应用自动建的群里报文字价、发报价附件;不用 @。
- 空运:人自建群,拉销售、航线、询价小助手 BOT、询价机器人(存档)。文字必须 @ BOT。发文件不能 @,靠存档听见,航线 Excel/PDF 当报价,发完马上回「附件已保存」。一群多单,先激活当前工单。锁舱/释放见空运群定稿。
- 存档席「询价机器人」只为会话存档可见,不能当报价应答人,不能当 @ 对象。
## 3. H5 补问 / 附件页(浏览器,挂在智能体 8810)
@@ -102,7 +102,7 @@ REL25 候选路由 **未接管**现网企微执行链。新项目不必沿用该
- 现网为 Node 长连接脚本,新项目可仍用 Node 或等价实现
- 端口 8813 仅 127.0.0.1(测试环境 8913)
- **只传输**,不拥有业务状态
- **只传输**,不拥有业务状态。入站 `/forward`;出站 `/send-group` 走 `aibot_send_msg`(空运群发文件后马上说话)
## 10. 运维/发布端(人 + 脚本)
+2 -2
View File
@@ -10,11 +10,11 @@
2. 海运 / 空运 / 陆运(含陆运二级类型)按唯一字段合同补问:完整输入不误补、不漏字段。
3. 随时新开询价并保存旧工单;可激活历史工单从精确断点继续;不重放已提交副作用。
4. TMS:询价必填齐了就查价;有价返回报价,没有返回暂无报价;技术异常单独分支。不做本地字典对应判断。
5. 需要协同时建群或复用空运固定群;群文字与群附件都能形成正确报价版本。
5. 需要协同时:海运由应用自动建群;空运由人自建群、@ 询价小助手 BOT、激活当前工单。激活成功按海运进群同一套发工单号/询价字段/协同补充/TMS报价,并发送匹配到的空运模板。文字必须 @;发文件不能 @,靠存档听见,BOT 马上回「附件已保存」(该群须先 @ 过)。空运群细则见 `docs/superpowers/specs/2026-09-16-air-group-collab-design.md`。
6. 采用、调价、Excel/PDF、对象存储、附件登记、企微发送、流程唤醒共用同一 quoteVersion。
7. 正式报价后:成交、未成交、协商中;未成交原因、协商调价、终止、超时不串路由。
8. 关键异常可恢复;重复事件幂等;过期卡片/晚到消息零错误写入。
9. 多段联运、空运锁舱/释放若本迭代不做,必须关闭入口,禁止半可用。
9. 多段联运本迭代关闭入口,禁止半可用。空运锁舱/释放在空运群内做;测服打真实 TMS,验收期间现网锁舱先停,禁止两套同时抢舱。
10. 单一源码可构建、测试、回滚。
## 架构硬约束
+1 -1
View File
@@ -6,7 +6,7 @@
1. 应用回调(明文/密文 XML 或 JSON):挂在**智能体唯一 HTTP**(现网 8811,新系统不再单独端口)。文字、图片、文件、模板卡点击、进入会话。验签失败不入队。
2. 群机器人:现网 8812 **默认不实现**(生产群机器人 Token 为空、走 AIBOT)。产品未书面要求不要加端口。
3. AIBOT 长连接:独立 Node(或等价)进程听微信,转给智能体,**桥上无业务状态**,8813 仅回环。
3. AIBOT 长连接:独立 Node(或等价)进程听微信,转给智能体,**桥上无业务状态**,8813 仅回环(测试 8913)。出站 `/send-group` 走 `aibot_send_msg` 文字;`/send-file` 走上传素材后再发文件(空运激活发匹配模板)。
4. 微盛 Archive:由**后台 Worker** 轮询群消息与附件、限速下载,不要堵 HTTP 回调。
测试与正式 H5 同一地址:`https://ai.ytd-scm.com/inquiry-form`。切流前公网仍进现网进程,见 `13`。
+2 -2
View File
@@ -29,7 +29,7 @@
- 1002 / 无 quoteFound / TMS 说缺项 → **暂无报价**
- HTTP 5xx/超时/熔断 → 技术失败(不是「暂无报价」)
锁舱/释放:**与现网共用同一套 TMS Client 时,新系统默认关闭**(API 返回未开放,企微/后台不展示)。只做 query / detail / template-fields / 字典同步。切流接管前再打开 lock/release,避免两套应用抢舱。
锁舱/释放:空运群内由航线 @ BOT 触发,主账 HMAC 出站 `/air/cabin/lock`、`/air/cabin/release`(入参以 TMS 文档为准;日期用工单报价日期填 `flightDate`)。**测服打开真实出站**;验收期间现网锁舱必须先停,禁止两套同时抢舱。正式现网切流前不要让现网和新系统同时 lock。单测用 stub,禁止单测打真实 TMS。
## 观测
@@ -37,4 +37,4 @@
## 测试
用 WireMock/本地 stub:token 成功、query 1/1002/1003、签名错误、超时。禁止单测打真实 TMS。联调查价可以打现网 TMS(同一 Client);**禁止**联调 lock/release。不要做 Redis「TMS 测试开关」。无价和技术失败看真实 query 响应或 stub。
用 WireMock/本地 stub:token 成功、query 1/1002/1003、签名错误、超时、lock/release 成功与 2006/2009。禁止单测打真实 TMS。联调查价可以打现网 TMS(同一 Client)。测服联调 lock/release 只打测试工单,且现网锁舱已停。不要做 Redis「TMS 测试开关」。无价和技术失败看真实 query 响应或 stub。
+4 -3
View File
@@ -48,19 +48,20 @@ BIZ_API_BASE_URL=http://127.0.0.1:8180/jeecg-boot
AGENT_CALLBACK_BASE_URL=http://127.0.0.1:8910
WECOM_AIBOT_BRIDGE_URL=http://127.0.0.1:8913
WECOM_AIBOT_INGRESS_URL=http://127.0.0.1:8910/internal/aibot/inbound
# 空运群发文件后马上回话:先停现网 8813,再 WECOM_AIBOT_ENABLED=true,并填 BOT_ID/SECRET。该群须先 @ 过小助手。
```
前端 `VITE_GLOB_API_URL=/jeecgboot`,`VITE_GLOB_DOMAIN_URL=https://ai.ytd-scm.com`。不要加 `ai-test.conf`。
## TMS
现网 BaseURL + Client 三件套(`03`)。只 QUERY / 详情 / 字典。默认关闭 LOCK/RELEASE。智能体只打测试主账 8180,不直连 TMS、不打现网 8080。
现网 BaseURL + Client 三件套(`03`)。QUERY / 详情 / 字典照旧。空运群锁舱/释放:测服主账打开出站打真实 TMS;验收前必须先停现网锁舱,禁止两套抢舱。智能体只打测试主账 8180,不直连 TMS、不打现网 8080。
## 库表(测试库可直接改)
开发需要加表、加字段、改字段时:**直接对 `inquiry_robot` / `inquiry_robot_runtime` 做版本化迁移**,不必先等 Task 批准。
仍须批准的:改现网库、改现网 Secret、TMS LOCK/RELEASE、对非测试人员发企微。
仍须批准的:改现网库、改现网 Secret、对非测试人员发企微。测服 LOCK/RELEASE 已纳入空运群定稿,但必须现网锁舱已停。
## 发布原则
@@ -81,7 +82,7 @@ WECOM_AIBOT_INGRESS_URL=http://127.0.0.1:8910/internal/aibot/inbound
## 未经 Task 批准不得
- TMS LOCK/RELEASE(QUERY 按任务批准)
- 现网与测服同时 LOCK/RELEASE(测服锁舱须现网已停;QUERY 按任务批准)
- 给测试可见范围以外的人或生产客户群发企微
- 写现网库
- 改现网 Secret、防火墙、`1000010` 回调
+4
View File
@@ -93,6 +93,10 @@ BIZ_API_BASE_URL=http://127.0.0.1:8180
AGENT_CALLBACK_BASE_URL=http://127.0.0.1:8910
WECOM_AIBOT_BRIDGE_URL=http://127.0.0.1:8913
WECOM_AIBOT_INGRESS_URL=http://127.0.0.1:8910/internal/aibot/inbound
WECOM_AIBOT_ENABLED=false
WECOM_AIBOT_BOT_ID=
WECOM_AIBOT_SECRET=
WECOM_AIBOT_WS_URL=wss://openws.work.weixin.qq.com
REDIS_KEY_PREFIX=inquiry_robot:
PG_DATABASE=inquiry_robot_runtime
MYSQL_DB=inquiry_robot