落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -57,6 +57,7 @@ Thumbs.db
|
||||
tmp/
|
||||
temp/
|
||||
spool/
|
||||
inquiry-agent/var/
|
||||
_trash_*/
|
||||
*.zip
|
||||
agent-release.zip
|
||||
|
||||
@@ -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`。该群须先 @ 过小助手,企微才允许主动发言。
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,8 @@
|
||||
"main": "server.cjs",
|
||||
"scripts": {
|
||||
"start": "node server.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
+130
-6
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
|
||||
@@ -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}],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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}还没有有效锁舱,不能释放。"
|
||||
|
||||
@@ -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 = "已报价"
|
||||
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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"
|
||||
|
||||
@@ -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编码": "商品海关编码",
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
+45
@@ -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)) {
|
||||
|
||||
+46
@@ -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 "";
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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);
|
||||
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
|
||||
+15
-3
@@ -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);
|
||||
|
||||
+26
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+64
-3
@@ -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)) {
|
||||
|
||||
+5
@@ -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");
|
||||
|
||||
+425
@@ -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 "";
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ public class TmsAdapterClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱壳:默认关闭;未实现。
|
||||
* 旧壳保留。正式锁舱走 {@link TmsGatewayClient#lockCabin},受 TMS_V2_LOCK_ENABLED 约束。
|
||||
*
|
||||
* @return not_implemented
|
||||
*/
|
||||
|
||||
+113
@@ -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 "";
|
||||
}
|
||||
}
|
||||
+87
-3
@@ -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);
|
||||
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
@@ -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"));
|
||||
}
|
||||
}
|
||||
+54
@@ -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"));
|
||||
}
|
||||
}
|
||||
+33
@@ -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", "{}"));
|
||||
}
|
||||
}
|
||||
+4
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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` 回调
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user