diff --git a/.gitignore b/.gitignore index d315a28..50ef1fd 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ Thumbs.db tmp/ temp/ spool/ +inquiry-agent/var/ _trash_*/ *.zip agent-release.zip diff --git a/aibot-bridge/README.md b/aibot-bridge/README.md index 1dcee84..76fbd75 100644 --- a/aibot-bridge/README.md +++ b/aibot-bridge/README.md @@ -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`。该群须先 @ 过小助手,企微才允许主动发言。 diff --git a/aibot-bridge/long_connection.cjs b/aibot-bridge/long_connection.cjs index a8f4895..fad2b90 100644 --- a/aibot-bridge/long_connection.cjs +++ b/aibot-bridge/long_connection.cjs @@ -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 diff --git a/aibot-bridge/package-lock.json b/aibot-bridge/package-lock.json new file mode 100644 index 0000000..bc9e3ca --- /dev/null +++ b/aibot-bridge/package-lock.json @@ -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 + } + } + } + } +} diff --git a/aibot-bridge/package.json b/aibot-bridge/package.json index 92d3ed3..d6c44c0 100644 --- a/aibot-bridge/package.json +++ b/aibot-bridge/package.json @@ -6,5 +6,8 @@ "main": "server.cjs", "scripts": { "start": "node server.cjs" + }, + "dependencies": { + "ws": "^8.18.0" } } diff --git a/aibot-bridge/server.cjs b/aibot-bridge/server.cjs index 4b13809..1518b63 100644 --- a/aibot-bridge/server.cjs +++ b/aibot-bridge/server.cjs @@ -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, }); diff --git a/inquiry-agent/agent/channel/aibot/__init__.py b/inquiry-agent/agent/channel/aibot/__init__.py index af7f8dd..715550b 100644 --- a/inquiry-agent/agent/channel/aibot/__init__.py +++ b/inquiry-agent/agent/channel/aibot/__init__.py @@ -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") diff --git a/inquiry-agent/agent/channel/aibot/reply.py b/inquiry-agent/agent/channel/aibot/reply.py new file mode 100644 index 0000000..cec9226 --- /dev/null +++ b/inquiry-agent/agent/channel/aibot/reply.py @@ -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"} diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py index d198da3..0451ba0 100644 --- a/inquiry-agent/agent/channel/outbox/sender.py +++ b/inquiry-agent/agent/channel/outbox/sender.py @@ -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( diff --git a/inquiry-agent/agent/channel/runtime.py b/inquiry-agent/agent/channel/runtime.py index 4427022..31cb755 100644 --- a/inquiry-agent/agent/channel/runtime.py +++ b/inquiry-agent/agent/channel/runtime.py @@ -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 diff --git a/inquiry-agent/agent/channel/wshoto/parse.py b/inquiry-agent/agent/channel/wshoto/parse.py index 941959b..848880e 100644 --- a/inquiry-agent/agent/channel/wshoto/parse.py +++ b/inquiry-agent/agent/channel/wshoto/parse.py @@ -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: diff --git a/inquiry-agent/agent/channel/wshoto/poll.py b/inquiry-agent/agent/channel/wshoto/poll.py index e74e717..4931a6b 100644 --- a/inquiry-agent/agent/channel/wshoto/poll.py +++ b/inquiry-agent/agent/channel/wshoto/poll.py @@ -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, *, diff --git a/inquiry-agent/agent/config/__init__.py b/inquiry-agent/agent/config/__init__.py index 34773e4..0fb7cf4 100644 --- a/inquiry-agent/agent/config/__init__.py +++ b/inquiry-agent/agent/config/__init__.py @@ -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", diff --git a/inquiry-agent/agent/handlers/__init__.py b/inquiry-agent/agent/handlers/__init__.py index 48bb83f..f99cc82 100644 --- a/inquiry-agent/agent/handlers/__init__.py +++ b/inquiry-agent/agent/handlers/__init__.py @@ -2,7 +2,7 @@ Handler 包:一类意图 / 一类卡片一个模块。 禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。 -当前:文字询价空运/海运闭环 + 海运群协同字段 + echo 联调残留。 +当前:文字询价空运/海运闭环 + 海运应用群协同 + 空运 BOT 群文字/附件 + echo 联调残留。 """ from agent.handlers.echo_text import handle_echo_text diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py new file mode 100644 index 0000000..e604193 --- /dev/null +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -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 "" diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index 6c6e534..516ffc2 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -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" diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 5566859..48c00d5 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -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}) diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index bc177f3..cc105ef 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -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}", + } diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index d6a874e..8caeaa6 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -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[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})" + r"\s*(?:改为|改成|调整为|调整成)?" + r"\s*[::=]?" + r"\s*(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s+)?" + r"(?P\d+(?:\.\d+)?)" + r"\s*(?PUSD|CNY|RMB|美金|美元|人民币|元)?", + re.I, +) +# 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。 +_RE_FEE_CHANGE = re.compile( + r"(?P[\u4e00-\u9fff]{2,16}?)" + r"\s*(?:改为|改成|调整为|调整成)" + r"\s*[::=]?" + r"\s*(?P\d+(?:\.\d+)?)" + r"\s*(?PUSD|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}], } diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py new file mode 100644 index 0000000..97cea1f --- /dev/null +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -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 diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 181ec05..cfa8b28 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -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) diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index f5ad5fb..a63604c 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -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}还没有有效锁舱,不能释放。" diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 35d1e41..2986f96 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -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 = "已报价" diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 4094ff9..0a665c6 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -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: diff --git a/inquiry-agent/agent/redis_coord/air_inbound.py b/inquiry-agent/agent/redis_coord/air_inbound.py new file mode 100644 index 0000000..aa34ebe --- /dev/null +++ b/inquiry-agent/agent/redis_coord/air_inbound.py @@ -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 diff --git a/inquiry-agent/agent/redis_coord/keys.py b/inquiry-agent/agent/redis_coord/keys.py index 56609d3..5d395eb 100644 --- a/inquiry-agent/agent/redis_coord/keys.py +++ b/inquiry-agent/agent/redis_coord/keys.py @@ -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" diff --git a/inquiry-agent/agent/redis_coord/pending_quote.py b/inquiry-agent/agent/redis_coord/pending_quote.py new file mode 100644 index 0000000..c26a017 --- /dev/null +++ b/inquiry-agent/agent/redis_coord/pending_quote.py @@ -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) diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py new file mode 100644 index 0000000..1ee3203 --- /dev/null +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -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 "" diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py index 1600fd7..e293e5f 100644 --- a/inquiry-agent/agent/routing/dispatch.py +++ b/inquiry-agent/agent/routing/dispatch.py @@ -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" diff --git a/inquiry-agent/agent/routing/group_intent.py b/inquiry-agent/agent/routing/group_intent.py index b7d56b4..55ee22a 100644 --- a/inquiry-agent/agent/routing/group_intent.py +++ b/inquiry-agent/agent/routing/group_intent.py @@ -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 "" diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 77b0374..8b50f84 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -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编码": "商品海关编码", diff --git a/inquiry-agent/tests/test_aibot_reply.py b/inquiry-agent/tests/test_aibot_reply.py new file mode 100644 index 0000000..59cf59e --- /dev/null +++ b/inquiry-agent/tests/test_aibot_reply.py @@ -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") diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py new file mode 100644 index 0000000..83a6ec7 --- /dev/null +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -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() diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 0e90d89..35e8253 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -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 diff --git a/inquiry-agent/tests/test_wshoto_archive.py b/inquiry-agent/tests/test_wshoto_archive.py index c5ed4c6..a87b186 100644 --- a/inquiry-agent/tests/test_wshoto_archive.py +++ b/inquiry-agent/tests/test_wshoto_archive.py @@ -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( diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java index fcadab4..393bca5 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java @@ -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> listStaffByRole(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + Map 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> staffByWecom(@RequestBody Map body, @@ -237,6 +252,36 @@ public class InquiryAgentController { return Result.OK(workOrderService.upsertProductQuote(body)); } + @ApiOperation("空运群激活/切换当前工单(一单一群)") + @PostMapping("/ticket/activateAirGroup") + public Result> activateAirGroup(@RequestBody Map 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> lockCabin(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(cabinService.lockCabin(body)); + } + + @ApiOperation("空运释放舱位(已成交本地拦住,不调 TMS)") + @PostMapping("/tms/releaseCabin") + public Result> releaseCabin(@RequestBody Map 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)) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRule.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRule.java new file mode 100644 index 0000000..212e64d --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRule.java @@ -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; + +/** + * 空运群「当前工单」能不能激活。纯内存规则,不写库。 + *

+ * 可激活:业务线 AIR,且六态询价中 / 已报价 / 协商中。 + * 一张单同时只跟一个群;已经绑了别的群就 occupied。 + */ +public final class AirGroupActivateRule { + + public static final Set 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 ""; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java index 66fe194..bd6af91 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java @@ -37,7 +37,12 @@ public interface IInquiryStaffRoleService extends IService { java.util.List> matchSeaStaff(String origin, String destination); /** - * 按企微 userid 取展示名。 + * 列出该岗位启用员工。空运群 @ 航线用 roleCode=air。 + */ + java.util.List> listActiveByRole(String roleCode); + + /** + * 按企微 userid 取展示名、岗位、启用状态。空运群认「航线·空运」靠 roleCode+status。 */ java.util.Map findDisplayByWecomId(String wecomId); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java index cf58e4f..29be276 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java @@ -104,4 +104,9 @@ public interface IInquiryWorkOrderService extends IService { * 产品报价写入新版本并视情况迁「已报价」。 */ Map upsertProductQuote(Map body); + + /** + * 空运群激活/切换当前工单。一单一群,未结案才能绑。不改六态。 + */ + Map activateAirGroup(Map body); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index 23e814c..81ae4c4 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -534,9 +534,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl> listActiveByRole(String roleCode) { + String code = StringUtils.trimToEmpty(roleCode).toLowerCase(); + List> out = new java.util.ArrayList<>(); + if (code.isEmpty()) { + return out; + } + List rows = list(new QueryWrapper() + .eq("role_code", code) + .eq("status", "active")); + for (InquiryStaffRole row : rows) { + if (row == null || StringUtils.isBlank(row.getWecomId())) { + continue; + } + Map 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 findDisplayByWecomId(String wecomId) { InquiryStaffRole row = findByWecomId(wecomId); @@ -154,6 +178,7 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl facts = new LinkedHashMap<>(); @@ -585,6 +587,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl activateAirGroup(Map body) { + String no = text(body, "workOrderNo", "work_order_no"); + String room = text(body, "chatId", "chat_id"); + InquiryTicket ticket = findByWorkOrderNo(no); + Map 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 others = list(new LambdaQueryWrapper() + .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 parseFactsJson(String raw) { Map facts = new LinkedHashMap<>(); if (StringUtils.isBlank(raw)) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java index 5910996..da39c81 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -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"); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java new file mode 100644 index 0000000..5457c0d --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -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。 + *

+ * 锁舱按 /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 lockCabin(Map 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 quote = latestQuote(no); + String quoteId = resolveQuoteId(quote, no); + Map option = resolveOption(quote, optionNo); + if (StringUtils.isBlank(quoteId) || option == null) { + return fail("no_lock_target", "锁舱缺少报价编号或舱位选项"); + } + if (!properties.lockConfigured()) { + return fail("lock_disabled", "主账未开放锁舱"); + } + Map req = new LinkedHashMap<>(); + req.put("workOrderNo", no); + req.put("quoteId", quoteId); + req.put("optionNo", text(option, "optionNo", "option_no")); + req.put("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 releaseCabin(Map 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 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 invokeLock(InquiryTicket ticket, Map req, + String quoteId, String optionNo) { + try { + Map tms = gateway.lockCabin(req); + int code = TmsV2ResponseNormalizer.number(tms.get("code")); + if (code != 1) { + return fail("lock_fail", cabinFailReason(code, tms)); + } + Map 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 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 invokeRelease(InquiryTicket ticket, Map req, String lockId) { + try { + Map tms = gateway.releaseCabin(req); + int code = TmsV2ResponseNormalizer.number(tms.get("code")); + if (code != 1) { + return fail("release_fail", cabinFailReason(code, tms)); + } + Map data = tmsData(tms); + Map 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 body = new HashMap<>(); + body.put("workOrderNo", ticket.getWorkOrderNo()); + Map 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() + .eq(InquiryTicket::getWorkOrderNo, workOrderNo) + .last("LIMIT 1"), false); + } + + private Map latestQuote(String workOrderNo) { + Map quote = new LinkedHashMap<>(); + List rows = quoteMapper.selectList(new LambdaQueryWrapper() + .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 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 resolveOption(Map quote, String optionNo) { + List> options = airOptions(quote); + if (!options.isEmpty()) { + Map picked = pickOption(quote, optionNo); + if (picked != null) { + return picked; + } + if (options.size() == 1) { + return options.get(0); + } + return null; + } + Map 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 pickOption(Map quote, String optionNo) { + List> 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 row : options) { + String no = firstNonBlank(text(row, "optionNo", "option_no")); + if (want.equalsIgnoreCase(no)) { + return row; + } + } + return null; + } + + @SuppressWarnings("unchecked") + static List> airOptions(Map quote) { + List> 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) 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 tmsData(Map tms) { + Object raw = tms == null ? null : tms.get("tmsData"); + if (raw instanceof Map) { + return (Map) raw; + } + return tms == null ? new LinkedHashMap<>() : tms; + } + + private static String cabinFailReason(int code, Map tms) { + Map 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 okBase() { + Map out = new LinkedHashMap<>(); + out.put("ok", true); + return out; + } + + private static Map fail(String error, String reason) { + Map out = new LinkedHashMap<>(); + out.put("ok", false); + out.put("error", error); + out.put("failReason", reason); + return out; + } + + private static String text(Map 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 ""; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index 6f8ea63..b5b1e95 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -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; } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterClient.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterClient.java index 0bf78be..1980076 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterClient.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterClient.java @@ -48,7 +48,7 @@ public class TmsAdapterClient { } /** - * 锁舱壳:默认关闭;未实现。 + * 旧壳保留。正式锁舱走 {@link TmsGatewayClient#lockCabin},受 TMS_V2_LOCK_ENABLED 约束。 * * @return not_implemented */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java new file mode 100644 index 0000000..3e9446b --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java @@ -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 正文。 + *

+ * 锁舱唯一键: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 lock(Map internal) { + Map 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 release(Map internal) { + Map 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 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 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 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 ""; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java index 32bbc54..8bc6310 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java @@ -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 均要)。 *

- * 职责: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 lockCabin(Map request) { + return invokeCabin(properties.getLockPath(), TmsCabinRequestMapper.lock(request)); + } + + /** + * 空运释放舱位。同样受 TMS_V2_LOCK_ENABLED 约束。 + */ + public Map releaseCabin(Map request) { + return invokeCabin(properties.getReleasePath(), TmsCabinRequestMapper.release(request)); + } + + /** + * HMAC 出站:X-Timestamp / X-Nonce / X-Signature 与正文原字节一致。 + * 锁舱/释放不缓存。连续失败走同一熔断。 + */ + private Map invokeCabin(String path, Map 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 response = restTemplate.exchange( + TmsV2TokenProvider.joinUrl(properties.getBaseUrl(), path), + HttpMethod.POST, + new HttpEntity<>(json, headers), + String.class); + Map 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 invokeQuery(Map request) { assertCircuitAvailable(); String json = JSON.toJSONString(request); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsHmacSigner.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsHmacSigner.java new file mode 100644 index 0000000..535c010 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsHmacSigner.java @@ -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。 + *

+ * 对照接口文档 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); + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java index 40d42e4..67f1540 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java @@ -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(); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRuleTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRuleTest.java new file mode 100644 index 0000000..819b029 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/domain/AirGroupActivateRuleTest.java @@ -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; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java new file mode 100644 index 0000000..29f9a40 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -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 quote = new HashMap<>(); + Map first = new HashMap<>(); + first.put("optionNo", "AIR-OPT-01"); + Map 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 quote = new HashMap<>(); + Map only = new HashMap<>(); + only.put("optionNo", "AIR-OPT-01"); + quote.put("airOptions", Arrays.asList(only)); + assertNotNull(InquiryTmsCabinService.pickOption(quote, "")); + } + + @Test + public void missingOptionIsNull() { + Map 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 option = InquiryTmsCabinService.resolveOption(new HashMap<>(), ""); + assertEquals("AIR-OPT-01", option.get("optionNo")); + } + + @Test + public void resolveOptionKeepsExistingWhenPresent() { + Map quote = new HashMap<>(); + Map only = new HashMap<>(); + only.put("optionNo", "AIR-OPT-03"); + quote.put("airOptions", Arrays.asList(only)); + assertEquals("AIR-OPT-03", InquiryTmsCabinService.resolveOption(quote, "").get("optionNo")); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java new file mode 100644 index 0000000..e6f41fc --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java @@ -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 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 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 internal = new LinkedHashMap<>(); + internal.put("lockId", "CA-1"); + internal.put("workOrderNo", "WO202609160001"); + internal.put("flightDate", "2026/09/16"); + internal.put("operatorUserId", "air1"); + Map body = TmsCabinRequestMapper.release(internal); + assertEquals("CA-1", body.get("lockId")); + assertEquals("2026-09-16", body.get("flightDate")); + assertEquals("AIRLINE", body.get("operatorRole")); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsHmacSignerTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsHmacSignerTest.java new file mode 100644 index 0000000..42afe1e --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsHmacSignerTest.java @@ -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", "{}")); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java index 579b01a..24277b7 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java @@ -92,8 +92,10 @@ public class TmsQuoteCurrencyTest { fee.put("remark", "计费重进位+1000KG"); Map 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 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")); } } diff --git a/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml b/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml index 685d589..9efab3d 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml +++ b/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml @@ -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: diff --git a/prompt/02-各端职责.md b/prompt/02-各端职责.md index 813f145..6eef84a 100644 --- a/prompt/02-各端职责.md +++ b/prompt/02-各端职责.md @@ -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. 运维/发布端(人 + 脚本) diff --git a/prompt/05-总控提示词.md b/prompt/05-总控提示词.md index bbc0358..de44712 100644 --- a/prompt/05-总控提示词.md +++ b/prompt/05-总控提示词.md @@ -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. 单一源码可构建、测试、回滚。 ## 架构硬约束 diff --git a/prompt/09-企微通道.md b/prompt/09-企微通道.md index b256989..b83a578 100644 --- a/prompt/09-企微通道.md +++ b/prompt/09-企微通道.md @@ -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`。 diff --git a/prompt/10-TMS对接.md b/prompt/10-TMS对接.md index 442d58d..fcd9671 100644 --- a/prompt/10-TMS对接.md +++ b/prompt/10-TMS对接.md @@ -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。 diff --git a/prompt/12-测试环境部署.md b/prompt/12-测试环境部署.md index 35c4252..f62178c 100644 --- a/prompt/12-测试环境部署.md +++ b/prompt/12-测试环境部署.md @@ -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` 回调 diff --git a/prompt/13-环境对照.md b/prompt/13-环境对照.md index 5570baf..b8a21fe 100644 --- a/prompt/13-环境对照.md +++ b/prompt/13-环境对照.md @@ -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