落地空运单聊询价字段与线路选择。
必填核对后再建单;多条线路走 H5 点选;费用只展示该线路 TMS 回包,不套用整单空运费。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 空运群出站报文:禁止 stream(企微会加「内容由AI生成」和赞踩)。
|
||||
* 只发 markdown,且不带 feedback。
|
||||
* 有这次 @ 的 req_id 才走 aibot_respond_msg,群里才会引用原消息。
|
||||
*/
|
||||
function pickReplyReqId({ fromCaller, fromChat, lastAny }) {
|
||||
const caller = String(fromCaller || "").trim();
|
||||
if (caller) {
|
||||
return caller;
|
||||
}
|
||||
const chat = String(fromChat || "").trim();
|
||||
if (chat) {
|
||||
return chat;
|
||||
}
|
||||
return String(lastAny || "").trim();
|
||||
}
|
||||
|
||||
function buildGroupMessageFrame({ chatId, content, callbackReqId, reqId }) {
|
||||
const text = String(content || "");
|
||||
const markdown = { content: text };
|
||||
if (callbackReqId) {
|
||||
return {
|
||||
cmd: "aibot_respond_msg",
|
||||
headers: { req_id: callbackReqId },
|
||||
body: {
|
||||
chatid: chatId,
|
||||
chat_type: 2,
|
||||
msgtype: "markdown",
|
||||
markdown,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
cmd: "aibot_send_msg",
|
||||
headers: { req_id: reqId || "aibot_send_msg" },
|
||||
body: {
|
||||
chatid: chatId,
|
||||
chat_type: 2,
|
||||
msgtype: "markdown",
|
||||
markdown,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildGroupMessageFrame, pickReplyReqId };
|
||||
@@ -12,6 +12,7 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("crypto");
|
||||
const { buildGroupMessageFrame, pickReplyReqId } = require("./group_message_frame.cjs");
|
||||
|
||||
function generateReqId(prefix) {
|
||||
const timestamp = Date.now();
|
||||
@@ -49,6 +50,7 @@ function startLongConnectionLoop(opts) {
|
||||
lastError: "",
|
||||
pending: new Map(),
|
||||
lastCallback: new Map(),
|
||||
lastAnyReq: "",
|
||||
heartbeatTimer: null,
|
||||
reconnectTimer: null,
|
||||
stop: () => {},
|
||||
@@ -83,38 +85,34 @@ function startLongConnectionLoop(opts) {
|
||||
});
|
||||
}
|
||||
|
||||
function sendGroupMessage({ chatId, content }) {
|
||||
function sendGroupMessage({ chatId, content, callbackReqId }) {
|
||||
const text = String(content || "");
|
||||
const callbackReqId = state.lastCallback.get(String(chatId || "").trim()) || "";
|
||||
if (callbackReqId) {
|
||||
return sendWs(
|
||||
{
|
||||
cmd: "aibot_respond_msg",
|
||||
headers: { req_id: callbackReqId },
|
||||
body: {
|
||||
msgtype: "stream",
|
||||
stream: { id: crypto.randomUUID(), finish: true, content: text },
|
||||
},
|
||||
},
|
||||
callbackReqId
|
||||
).finally(() => {
|
||||
state.lastCallback.delete(String(chatId || "").trim());
|
||||
const fromCaller = String(callbackReqId || "").trim();
|
||||
const chatKey = String(chatId || "").trim();
|
||||
const fromChat = state.lastCallback.get(chatKey) || "";
|
||||
const replyReqId = pickReplyReqId({
|
||||
fromCaller,
|
||||
fromChat,
|
||||
lastAny: state.lastAnyReq || "",
|
||||
});
|
||||
}
|
||||
const reqId = generateReqId("aibot_send_msg");
|
||||
return sendWs(
|
||||
{
|
||||
cmd: "aibot_send_msg",
|
||||
headers: { req_id: reqId },
|
||||
body: {
|
||||
chatid: chatId,
|
||||
chat_type: 2,
|
||||
msgtype: "markdown",
|
||||
markdown: { content: text },
|
||||
},
|
||||
},
|
||||
reqId
|
||||
// 用这次 @ 的 req_id 回 aibot_respond_msg,群里才会引用原消息。
|
||||
const reqId = replyReqId || generateReqId("aibot_send_msg");
|
||||
const frame = buildGroupMessageFrame({
|
||||
chatId,
|
||||
content: text,
|
||||
callbackReqId: replyReqId,
|
||||
reqId,
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"[aibot-long-connection] 出站 cmd=",
|
||||
frame.cmd,
|
||||
"chat=",
|
||||
chatKey || "-",
|
||||
"hasReq=",
|
||||
Boolean(replyReqId)
|
||||
);
|
||||
return sendWs(frame, reqId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,8 +301,10 @@ function startLongConnectionLoop(opts) {
|
||||
return;
|
||||
}
|
||||
const reqId = frame.headers && frame.headers.req_id;
|
||||
// 出站 ACK 带 chatid,不能当成入站,否则会盖掉这次 @ 的 req_id,群里就没有引用。
|
||||
if (reqId && state.pending.has(reqId)) {
|
||||
settlePending(reqId, frame.errcode, frame.errmsg, frame);
|
||||
return;
|
||||
}
|
||||
if (frame.cmd === "aibot_event_callback") {
|
||||
const ev = (frame.body && frame.body.event && frame.body.event.eventtype) || "";
|
||||
@@ -318,9 +318,7 @@ function startLongConnectionLoop(opts) {
|
||||
const bodyHint = frame.body || {};
|
||||
const looksLikeInbound = Boolean(
|
||||
frame.cmd === "aibot_msg_callback" ||
|
||||
bodyHint.msgid ||
|
||||
bodyHint.chatid ||
|
||||
(bodyHint.text && bodyHint.text.content)
|
||||
(bodyHint.msgid && (bodyHint.from || (bodyHint.text && bodyHint.text.content)))
|
||||
);
|
||||
if (looksLikeInbound) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -333,8 +331,12 @@ function startLongConnectionLoop(opts) {
|
||||
bodyHint.chatid || "-"
|
||||
);
|
||||
const inbound = normalizeInbound(frame);
|
||||
if (reqId && inbound.chat_id) {
|
||||
state.lastCallback.set(inbound.chat_id, reqId);
|
||||
const chatKey = inbound.chat_id || String(bodyHint.chatid || "").trim();
|
||||
if (reqId && chatKey) {
|
||||
state.lastCallback.set(chatKey, reqId);
|
||||
}
|
||||
if (reqId) {
|
||||
state.lastAnyReq = reqId;
|
||||
}
|
||||
const officialBody = Object.assign({}, frame.body || {}, {
|
||||
aibot_direct: true,
|
||||
|
||||
+11
-3
@@ -4,7 +4,7 @@
|
||||
* 本文件职责:
|
||||
* - /health 存活
|
||||
* - POST /forward 把已解析消息转到智能体 WECOM_AIBOT_INGRESS_URL(默认 8910/internal/aibot/inbound)
|
||||
* - POST /send-group 走长连接 aibot_send_msg(发文件后马上在群里说话)
|
||||
* - POST /send、/send-group:有 aibot_req_id 走 aibot_respond_msg(引用这次 @),没有则主动发言
|
||||
* - 默认不连企微(并存期禁止双连);WECOM_AIBOT_ENABLED=true 时订阅长连接
|
||||
*
|
||||
* 禁止:在本进程写工单/六态/Graph。
|
||||
@@ -165,7 +165,11 @@ const server = http.createServer(async (req, res) => {
|
||||
sendJson(res, 400, { error: "missing_chat_or_content" });
|
||||
return;
|
||||
}
|
||||
await longConn.sendGroupMessage({ chatId, content });
|
||||
await longConn.sendGroupMessage({
|
||||
chatId,
|
||||
content,
|
||||
callbackReqId: String(body.aibot_req_id || body.callback_req_id || "").trim(),
|
||||
});
|
||||
sendJson(res, 200, { status: "sent", chatId });
|
||||
} catch (e) {
|
||||
const code = e && e.code ? e.code : "send_failed";
|
||||
@@ -232,7 +236,11 @@ const server = http.createServer(async (req, res) => {
|
||||
sendJson(res, 400, { error: "missing_chat_or_content" });
|
||||
return;
|
||||
}
|
||||
await longConn.sendGroupMessage({ chatId, content });
|
||||
await longConn.sendGroupMessage({
|
||||
chatId,
|
||||
content,
|
||||
callbackReqId: String(body.aibot_req_id || body.callback_req_id || "").trim(),
|
||||
});
|
||||
sendJson(res, 200, { status: "sent", chatId });
|
||||
} catch (e) {
|
||||
const code = e && e.code ? e.code : "send_failed";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { buildGroupMessageFrame, pickReplyReqId } = require("../group_message_frame.cjs");
|
||||
|
||||
test("group reply never uses stream", () => {
|
||||
const withCb = buildGroupMessageFrame({
|
||||
chatId: "wr_1",
|
||||
content: "工单号:WO202609210001",
|
||||
callbackReqId: "cb_1",
|
||||
});
|
||||
assert.notEqual(withCb.body.msgtype, "stream");
|
||||
assert.equal(withCb.body.msgtype, "markdown");
|
||||
assert.equal(withCb.cmd, "aibot_respond_msg");
|
||||
assert.equal(withCb.body.chatid, "wr_1");
|
||||
assert.equal(withCb.body.markdown.feedback, undefined);
|
||||
|
||||
const proactive = buildGroupMessageFrame({
|
||||
chatId: "wr_1",
|
||||
content: "附件已保存",
|
||||
callbackReqId: "",
|
||||
});
|
||||
assert.equal(proactive.cmd, "aibot_send_msg");
|
||||
assert.equal(proactive.body.msgtype, "markdown");
|
||||
assert.equal(proactive.body.markdown.feedback, undefined);
|
||||
});
|
||||
|
||||
test("reply req id falls back to last inbound when chat map misses", () => {
|
||||
assert.equal(pickReplyReqId({ fromCaller: "cb_1", fromChat: "old", lastAny: "any" }), "cb_1");
|
||||
assert.equal(pickReplyReqId({ fromCaller: "", fromChat: "chat_cb", lastAny: "any" }), "chat_cb");
|
||||
assert.equal(pickReplyReqId({ fromCaller: "", fromChat: "", lastAny: "jeD_msF" }), "jeD_msF");
|
||||
assert.equal(pickReplyReqId({ fromCaller: "", fromChat: "", lastAny: "" }), "");
|
||||
});
|
||||
|
||||
@@ -56,6 +56,9 @@ def normalize_aibot_payload(body: dict[str, Any]) -> AibotInboundBody:
|
||||
raw.setdefault("source", "aibot")
|
||||
if data.get("response_url"):
|
||||
raw.setdefault("response_url", data.get("response_url"))
|
||||
req_id = str(data.get("aibot_req_id") or raw.get("aibot_req_id") or "").strip()
|
||||
if req_id:
|
||||
raw["aibot_req_id"] = req_id
|
||||
return AibotInboundBody(
|
||||
sender_id=sender,
|
||||
message_id=mid,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""
|
||||
空运群 BOT 回话。
|
||||
|
||||
本文件职责:有即时 `response_url` 就流式回;没有(发文件走存档)就打本机桥 `/send-group`,
|
||||
由长连接 `aibot_send_msg` 主动往群里说。
|
||||
禁止:走海运 appchat/send;在回调线程等 LLM。
|
||||
本文件职责:打本机桥 `/send`(骨架 `/send-group`)。
|
||||
有这次 @ 的 `aibot_req_id` 时,桥走 `aibot_respond_msg`,群里才会引用原消息;
|
||||
没有 req_id(典型:会话存档听到附件)才走 `aibot_send_msg` 主动发言。
|
||||
禁止:走海运 appchat/send;用企微 stream 回复(会带「内容由AI生成」和赞踩);在回调线程等 LLM。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -23,13 +23,14 @@ class AibotReplyClient:
|
||||
"""
|
||||
空运群出站客户端。
|
||||
|
||||
@ 带了 response_url:流式只收口这次 @;正文走桥主动发言,和发模板同一条通道。
|
||||
存档听到附件时没有 url:直接走桥。该群须先被 @ 过,企微才允许主动说。
|
||||
空运群出站只走桥。带上这次 @ 的 req_id,桥才能引用原消息。
|
||||
禁止 POST 企微 stream(群里会显示「内容由AI生成」和赞踩)。
|
||||
"""
|
||||
|
||||
def __init__(self, response_url: str = "", timeout_sec: float = 8.0) -> None:
|
||||
def __init__(self, response_url: str = "", timeout_sec: float = 8.0, callback_req_id: str = "") -> None:
|
||||
self._url = (response_url or "").strip()
|
||||
self._timeout = timeout_sec
|
||||
self._callback_req_id = str(callback_req_id or "").strip()
|
||||
self.sent: list[dict[str, Any]] = []
|
||||
|
||||
def send_group(
|
||||
@@ -45,41 +46,15 @@ class AibotReplyClient:
|
||||
if not text:
|
||||
return {"ok": False, "error": "empty"}
|
||||
room = str(chat_id or "").strip()
|
||||
# @ 当场流式回话群里经常看不见长摘要,模板却走主动发言,就会只剩一张表。
|
||||
# 工单信息必须再走桥,和模板同一条通道;流式只收口这次 @。
|
||||
if self._url:
|
||||
self._send_stream(room, text.split("\n", 1)[0][:80])
|
||||
# 企微 stream 会自带「内容由AI生成」和赞踩,空运群业务话术不要这条通道。
|
||||
# 工单正文只走桥主动发言(markdown),和发模板同一条通道。
|
||||
if room:
|
||||
out = self._send_proactive(room, text)
|
||||
if out.get("ok") or not self._url:
|
||||
return out
|
||||
if self._url:
|
||||
return self._send_stream(room, text)
|
||||
return self._send_proactive(room, text)
|
||||
return {"ok": False, "error": "no_chat_id"}
|
||||
|
||||
def _send_stream(self, chat_id: str, text: str) -> dict[str, Any]:
|
||||
"""@ 当场回话:POST 企微给的 response_url。"""
|
||||
body = {
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": uuid.uuid4().hex,
|
||||
"finish": True,
|
||||
"content": text,
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = httpx.post(self._url, json=body, timeout=self._timeout)
|
||||
ok = 200 <= resp.status_code < 300
|
||||
if not ok:
|
||||
logger.warning("aibot 回话 HTTP=%s chat=%s", resp.status_code, chat_id)
|
||||
return {"ok": ok, "status": resp.status_code}
|
||||
except Exception:
|
||||
logger.exception("aibot 回话网络失败 chat=%s", chat_id)
|
||||
return {"ok": False, "error": "network"}
|
||||
|
||||
def _send_proactive(self, chat_id: str, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
没有 response_url(典型:会话存档听到附件)时,让桥主动往群里说。
|
||||
把正文交给桥。有 callback_req_id 时桥引用这次 @;没有则主动发言。
|
||||
|
||||
副作用:回环 HTTP 打 8913,不直连企微。桥未连上或该群从未 @ 过,企微不会投递。
|
||||
"""
|
||||
@@ -100,14 +75,24 @@ class AibotReplyClient:
|
||||
# 现网官方桥是 POST /send;本仓库骨架桥是 /send-group。两个都试,先打官方口径。
|
||||
resp = httpx.post(
|
||||
f"{base}/send",
|
||||
json={"chatid": chat_id, "operation": "text", "content": text},
|
||||
json={
|
||||
"chatid": chat_id,
|
||||
"operation": "text",
|
||||
"content": text,
|
||||
"aibot_req_id": self._callback_req_id,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
resp = httpx.post(
|
||||
f"{base}/send-group",
|
||||
json={"chat_id": chat_id, "content": text, "chat_type": 2},
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"content": text,
|
||||
"chat_type": 2,
|
||||
"aibot_req_id": self._callback_req_id,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -112,6 +112,52 @@ async def h5_upload_submit(
|
||||
return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK))
|
||||
|
||||
|
||||
@router.get("/air-routes/{token}", response_class=HTMLResponse)
|
||||
def h5_air_route_open(token: str) -> HTMLResponse:
|
||||
"""打开空运选线路页。身份只认 token。"""
|
||||
from agent.channel.h5.air_route_page import expired_air_route_page, render_air_route_page
|
||||
from agent.channel.h5.air_route_store import lookup_air_route_token
|
||||
|
||||
rec = lookup_air_route_token(token)
|
||||
if rec is None:
|
||||
return HTMLResponse(expired_air_route_page(), status_code=410)
|
||||
return HTMLResponse(render_air_route_page(rec))
|
||||
|
||||
|
||||
@router.post("/air-routes/{token}", response_class=HTMLResponse)
|
||||
def h5_air_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse:
|
||||
"""
|
||||
选定线路。请求线程只验 token、入出站队列,不查 TMS、不跑整图。
|
||||
"""
|
||||
from agent.channel.h5.air_route_page import (
|
||||
expired_air_route_page,
|
||||
render_air_route_ok,
|
||||
render_air_route_page,
|
||||
)
|
||||
from agent.channel.h5.air_route_store import lookup_air_route_token
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
rec = lookup_air_route_token(token)
|
||||
if rec is None:
|
||||
return HTMLResponse(expired_air_route_page(), status_code=410)
|
||||
sender_id = str(rec.get("sender_id") or "").strip()
|
||||
if not sender_id:
|
||||
return HTMLResponse(expired_air_route_page(), status_code=410)
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
store = get_message_store()
|
||||
store.enqueue_outbound(touser=sender_id, content=text, payload=dict(extra or {}))
|
||||
|
||||
phase = get_air_text_flow().select_air_route(
|
||||
sender_id=sender_id, index=int(index), reply=reply
|
||||
)
|
||||
logger.info("h5.air_route pick sender=%s phase=%s idx=%s", sender_id, phase, index)
|
||||
if phase in {"route_expired", "route_invalid"}:
|
||||
return HTMLResponse(render_air_route_page(rec), status_code=400)
|
||||
return HTMLResponse(render_air_route_ok())
|
||||
|
||||
|
||||
@router.get("/{token}")
|
||||
def h5_open(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
空运选线路 H5 页。
|
||||
|
||||
本文件职责:列出 TMS 线路(航线+费用+总价),提交所选下标。
|
||||
样式对齐附件询价页:企微内嵌、手机竖屏、卡片点选。
|
||||
禁止:从 URL 读 userid;禁止本页查 TMS;禁止编造费用行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from typing import Any
|
||||
|
||||
from agent.schema.air_route_options import (
|
||||
option_fee_lines,
|
||||
option_total,
|
||||
parse_air_options,
|
||||
route_codes,
|
||||
route_line,
|
||||
)
|
||||
|
||||
# 与 upload_page 同一套企微蓝、浅底、白卡片。禁止在页面写 userid。
|
||||
_PAGE_CSS = """
|
||||
:root {
|
||||
--bg: #eef3f9;
|
||||
--card: #ffffff;
|
||||
--text: #17233d;
|
||||
--muted: #5b6b7c;
|
||||
--line: #c5d4ea;
|
||||
--brand: #1677ff;
|
||||
--brand-press: #0f62d9;
|
||||
--ok: #0f8a5f;
|
||||
--shadow: 0 10px 28px rgba(23, 51, 91, 0.10);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px calc(96px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.hero { margin: 4px 0 16px; }
|
||||
.wo {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: #2b5aa8;
|
||||
background: #eef4ff;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
margin: 0 0 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
h1 { font-size: 22px; line-height: 1.3; margin: 0 0 6px; font-weight: 600; }
|
||||
.lead { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
|
||||
.opt {
|
||||
display: block;
|
||||
position: relative;
|
||||
background: var(--card);
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: 16px;
|
||||
padding: 16px 16px 14px;
|
||||
margin: 0 0 12px;
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.opt:has(input:checked), .opt.is-on {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.16), var(--shadow);
|
||||
}
|
||||
.opt input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.tick {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 2px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.opt:has(input:checked) .tick, .opt.is-on .tick {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand);
|
||||
box-shadow: inset 0 0 0 4px #fff;
|
||||
}
|
||||
.route { flex: 1; min-width: 0; }
|
||||
.route strong {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
line-height: 1.4;
|
||||
font-weight: 650;
|
||||
}
|
||||
.codes { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; letter-spacing: 0.04em; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.pill {
|
||||
font-size: 12px;
|
||||
color: #2b5aa8;
|
||||
background: #eef4ff;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.pill.via { color: #8a5a00; background: #fff6e0; }
|
||||
.fees { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||
.fees li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid #eef2f6;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fees .n { color: var(--muted); }
|
||||
.fees .a { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.empty {
|
||||
margin: 12px 0 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eef2f6;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin: 12px 0 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--line);
|
||||
font-size: 15px;
|
||||
}
|
||||
.total b { color: var(--brand); font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
background: rgba(255,255,255,0.96);
|
||||
box-shadow: 0 -8px 24px rgba(23, 51, 91, 0.08);
|
||||
}
|
||||
.bar-inner { max-width: 480px; margin: 0 auto; }
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
button[type="submit"]:active { background: var(--brand-press); }
|
||||
.msg {
|
||||
max-width: 480px;
|
||||
margin: 48px auto;
|
||||
padding: 0 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.msg h1 { font-size: 20px; margin: 0 0 8px; }
|
||||
.msg p { margin: 0; color: var(--muted); font-size: 15px; line-height: 1.6; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def expired_air_route_page() -> str:
|
||||
"""token 过期页。"""
|
||||
return _wrap(
|
||||
"已过期",
|
||||
"<div class='msg'><h1>链接已过期</h1><p>请回企微重新打开选线路卡片。</p></div>",
|
||||
)
|
||||
|
||||
|
||||
def render_air_route_page(payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
渲染选线路页。payload 来自 token:quote / facts / work_order_no。
|
||||
"""
|
||||
quote = dict(payload.get("quote") or {})
|
||||
facts = dict(payload.get("facts") or {})
|
||||
wo = html.escape(str(payload.get("work_order_no") or ""))
|
||||
options = parse_air_options(quote)
|
||||
rows: list[str] = []
|
||||
for i, option in enumerate(options):
|
||||
rows.append(_option_card(i, option, quote, facts))
|
||||
count = len(options)
|
||||
body = (
|
||||
"<div class='wrap'>"
|
||||
"<header class='hero'>"
|
||||
f"<p class='wo'>{wo}</p>"
|
||||
"<h1>选择线路</h1>"
|
||||
f"<p class='lead'>共 {count} 条线路,点选一条后确定。费用以 TMS 回包为准。</p>"
|
||||
"</header>"
|
||||
"<form method='post'>"
|
||||
+ "".join(rows)
|
||||
+ "<div class='bar'><div class='bar-inner'>"
|
||||
"<button type='submit'>选定这条</button>"
|
||||
"</div></div></form></div>"
|
||||
"<script>(function(){var f=document.querySelector('form');if(!f)return;"
|
||||
"function sync(){f.querySelectorAll('.opt').forEach(function(el){"
|
||||
"el.classList.toggle('is-on',!!(el.querySelector('input')||{}).checked);});}"
|
||||
"f.addEventListener('change',sync);})();</script>"
|
||||
)
|
||||
return _wrap("选择线路", body)
|
||||
|
||||
|
||||
def render_air_route_ok() -> str:
|
||||
"""选定成功。"""
|
||||
return _wrap(
|
||||
"已选定",
|
||||
"<div class='msg'><h1>已选定</h1><p>请回企微查看价格明细。</p></div>",
|
||||
)
|
||||
|
||||
|
||||
def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str:
|
||||
"""一张线路卡:航线、TMS 费用、总价。没有费用行就留空说明,不编造。"""
|
||||
src = dict(option or {})
|
||||
line = html.escape(route_line(src, facts))
|
||||
codes = html.escape(route_codes(src, facts, quote))
|
||||
fees = option_fee_lines(src, quote)
|
||||
if fees:
|
||||
fee_html = (
|
||||
"<ul class='fees'>"
|
||||
+ "".join(
|
||||
f"<li><span class='n'>{html.escape(name)}</span>"
|
||||
f"<span class='a'>{html.escape(amount)}</span></li>"
|
||||
for name, amount in fees
|
||||
)
|
||||
+ "</ul>"
|
||||
)
|
||||
else:
|
||||
fee_html = "<p class='empty'>本线路未返回费用明细</p>"
|
||||
total = html.escape(option_total(src, quote))
|
||||
transfer = str(src.get("transferAirportCode") or src.get("transfer") or "").strip()
|
||||
tag = "中转" if transfer else "直达"
|
||||
tag_cls = "pill via" if transfer else "pill"
|
||||
flight = html.escape(str(src.get("flightNo") or "").strip())
|
||||
pills = [f"<span class='{tag_cls}'>{tag}</span>"]
|
||||
if flight:
|
||||
pills.append(f"<span class='pill'>航班 {flight}</span>")
|
||||
codes_html = f"<span class='codes'>{codes}</span>" if codes else ""
|
||||
return (
|
||||
"<label class='opt'>"
|
||||
f"<input type='radio' name='index' value='{index}' required />"
|
||||
"<div class='head'><span class='tick' aria-hidden='true'></span>"
|
||||
f"<div class='route'><strong>{line}</strong>{codes_html}"
|
||||
f"<div class='meta'>{''.join(pills)}</div></div></div>"
|
||||
f"{fee_html}"
|
||||
f"<p class='total'><span>总销售价</span><b>{total}</b></p>"
|
||||
"</label>"
|
||||
)
|
||||
|
||||
|
||||
def _wrap(title: str, body: str) -> str:
|
||||
"""整页壳:标题、viewport、共用样式。"""
|
||||
return (
|
||||
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
|
||||
f"<title>{html.escape(title)}</title>"
|
||||
f"<style>{_PAGE_CSS}</style></head><body>"
|
||||
f"{body}</body></html>"
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
空运选线路 H5 的短命 token。
|
||||
|
||||
本文件职责:token 绑定 sender + 工单 + 线路报价;URL 禁止 userid。
|
||||
优先 Redis;单测走内存。
|
||||
禁止:在本包查 TMS、改六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.channel.h5.copy_store import inquiry_form_base
|
||||
from agent.config import get_settings
|
||||
from agent.redis_coord.keys import TOKEN_COPY_TICKET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AIR_ROUTE_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
_KEY = TOKEN_COPY_TICKET + ":air_route"
|
||||
_MEM: dict[str, tuple[str, float]] = {}
|
||||
_MEM_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def issue_air_route_token(
|
||||
*,
|
||||
sender_id: str,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
facts: dict[str, str] | None = None,
|
||||
ttl_seconds: int = AIR_ROUTE_TTL_SECONDS,
|
||||
) -> str:
|
||||
"""签发选线路 token。副作用:写 Redis 或内存。"""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"sender_id": sender_id,
|
||||
"work_order_no": work_order_no,
|
||||
"quote": quote,
|
||||
"facts": dict(facts or {}),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
token = secrets.token_urlsafe(24)
|
||||
if _redis_set(token, payload, ttl_seconds):
|
||||
return token
|
||||
expires = time.time() + ttl_seconds
|
||||
with _MEM_LOCK:
|
||||
_MEM[token] = (payload, expires)
|
||||
logger.info("air route token 走内存 wo=%s", work_order_no)
|
||||
return token
|
||||
|
||||
|
||||
def lookup_air_route_token(token: str) -> Optional[dict[str, Any]]:
|
||||
"""读 token。过期或不存在返回 None。"""
|
||||
raw = (token or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
text = _redis_get(raw)
|
||||
if text is None:
|
||||
with _MEM_LOCK:
|
||||
hit = _MEM.get(raw)
|
||||
if not hit:
|
||||
return None
|
||||
payload, expires = hit
|
||||
if expires < time.time():
|
||||
_MEM.pop(raw, None)
|
||||
return None
|
||||
text = payload
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def build_air_route_url(token: str) -> str:
|
||||
"""拼选线路页:{inquiry-form}/air-routes/{token}。"""
|
||||
raw = (token or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
return f"{inquiry_form_base().rstrip('/')}/air-routes/{raw}"
|
||||
|
||||
|
||||
def _redis_set(token: str, payload: str, ttl: int) -> bool:
|
||||
client = _try_redis()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
client.raw.set(client.key(_KEY, token), payload, ex=ttl)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("air route token Redis 写入失败:%s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _redis_get(token: str) -> Optional[str]:
|
||||
client = _try_redis()
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
raw = client.raw.get(client.key(_KEY, token))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("air route token Redis 读取失败:%s", exc)
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
|
||||
|
||||
|
||||
def _try_redis():
|
||||
try:
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
|
||||
return get_redis_runtime(get_settings()).client
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
@@ -60,7 +60,8 @@ def _attach_bot_client(engine, message: InboundMessage) -> None:
|
||||
每次入站换一次 BOT 出站客户端。
|
||||
|
||||
测试注入的 FakeBotGroup 不动。
|
||||
有 response_url 走当场流式回;没有(存档附件)走桥主动发言,马上在群里说。
|
||||
有 response_url 也不走 stream;空运群正文一律走桥。
|
||||
把这次入站的 aibot_req_id 带给桥,群里才能引用原 @。
|
||||
不能复用上一次 @ 的过期 url。
|
||||
"""
|
||||
from agent.channel.aibot.reply import AibotReplyClient
|
||||
@@ -69,7 +70,8 @@ def _attach_bot_client(engine, message: InboundMessage) -> None:
|
||||
if existing is not None and not isinstance(existing, AibotReplyClient):
|
||||
return
|
||||
engine._group_client = AibotReplyClient(
|
||||
str((message.raw or {}).get("response_url") or "")
|
||||
str((message.raw or {}).get("response_url") or ""),
|
||||
callback_req_id=str((message.raw or {}).get("aibot_req_id") or ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -217,6 +217,24 @@ class HttpLedger:
|
||||
out["ok"] = True
|
||||
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"}
|
||||
|
||||
def update_facts(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
|
||||
"""选定线路后写回起运港等询价事实。不改六态。"""
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/updateFacts",
|
||||
{"workOrderNo": work_order_no, "facts": facts},
|
||||
idem=f"update-facts:{work_order_no}:{sorted((facts or {}).items())}",
|
||||
)
|
||||
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "update_facts_failed"}
|
||||
|
||||
def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]:
|
||||
"""采用后改选:作废旧报价单。不改六态。"""
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/voidQuoteFiles",
|
||||
{"workOrderNo": work_order_no},
|
||||
idem=f"void-quote:{work_order_no}",
|
||||
)
|
||||
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "void_quote_failed"}
|
||||
|
||||
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
||||
"""未成交后记下原因。不改六态。"""
|
||||
out = self._post(
|
||||
|
||||
@@ -192,6 +192,30 @@ class MemoryLedger:
|
||||
{"name": "其他附加费", "amount": "USD 80"},
|
||||
],
|
||||
}
|
||||
if str(facts.get("tms_multi") or "") == "1":
|
||||
quote["isMulti"] = "2"
|
||||
quote["airOptions"] = [
|
||||
{
|
||||
"originAirportCode": "ZUH",
|
||||
"transferAirportCode": "COC",
|
||||
"destinationAirportCode": "CRK",
|
||||
"salePrice": 183370,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11}
|
||||
],
|
||||
},
|
||||
{
|
||||
"originAirportCode": "SZX",
|
||||
"transferAirportCode": "",
|
||||
"destinationAirportCode": "CRK",
|
||||
"salePrice": 193370,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 12}
|
||||
],
|
||||
},
|
||||
]
|
||||
return {
|
||||
"ok": True,
|
||||
"classification": "HAS_PRICE",
|
||||
@@ -220,6 +244,28 @@ class MemoryLedger:
|
||||
"status": ticket.status,
|
||||
}
|
||||
|
||||
def update_facts(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
|
||||
"""选定线路后写回起运港等询价事实。"""
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(work_order_no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
ticket.facts = dict(facts)
|
||||
return {"ok": True}
|
||||
|
||||
def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]:
|
||||
"""采用后改选:旧报价单不再当本单有效文件。"""
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(work_order_no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
ticket.quote_file_ready = False
|
||||
if ticket.quote:
|
||||
ticket.quote = dict(ticket.quote)
|
||||
ticket.quote["quoteFileIssued"] = False
|
||||
ticket.quote["quoteFileVoided"] = True
|
||||
return {"ok": True}
|
||||
|
||||
def transition(
|
||||
self,
|
||||
*,
|
||||
@@ -359,7 +405,9 @@ class MemoryLedger:
|
||||
wait_phase = "wait_deal"
|
||||
elif status == "已报价" and quote:
|
||||
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
|
||||
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
|
||||
if quote.get("quoteFileVoided"):
|
||||
wait_phase = "wait_adopt"
|
||||
elif ticket.quote_file_ready or quote.get("quoteFileIssued"):
|
||||
wait_phase = "wait_deal"
|
||||
elif (ticket.business_line or "").upper() == "SEA":
|
||||
wait_phase = "wait_collab"
|
||||
|
||||
@@ -23,19 +23,31 @@ _MODE_WORDS = (
|
||||
_LABEL_LINE = re.compile(
|
||||
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"贸易条款|货好时间|货源地|提货地址|收货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
|
||||
)
|
||||
_LABEL_INLINE = re.compile(
|
||||
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"贸易条款|货好时间|货源地|提货地址|收货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
|
||||
)
|
||||
# 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。
|
||||
_OPTIONAL_INQUIRY_LABELS = (
|
||||
"通关口岸(非必填)",
|
||||
"通关口岸",
|
||||
"货源地(非必填)",
|
||||
"货好时间(非必填)",
|
||||
"提货地址(非必填)",
|
||||
"收货地址(非必填)",
|
||||
"贸易条款(非必填)",
|
||||
"起运港(非必填)",
|
||||
"货源地",
|
||||
"货好时间",
|
||||
"提货地址",
|
||||
"收货地址",
|
||||
"贸易条款",
|
||||
"起运港",
|
||||
)
|
||||
# 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。
|
||||
_COLLAB_LABELS = (
|
||||
|
||||
@@ -49,6 +49,18 @@ _LOCK_QUOTE = "__air_lock_quote"
|
||||
_LOCK_EXEC = "__air_lock_exec"
|
||||
|
||||
|
||||
def _ticket_flight_date(ticket: object) -> str:
|
||||
"""锁舱 flightDate 用工单报价日期,和主账出站一致。"""
|
||||
view = ticket_view(ticket)
|
||||
facts = dict(view.get("facts") or {})
|
||||
return str(
|
||||
facts.get("报价日期")
|
||||
or view.get("quoteDate")
|
||||
or view.get("quote_date")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def is_airline(flow: Any, sender_id: str) -> bool:
|
||||
"""员工管理岗位=航线·空运且启用。"""
|
||||
uid = (sender_id or "").strip()
|
||||
@@ -595,21 +607,13 @@ def apply_air_lock(
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
reply_paused_ticket(
|
||||
ticket=ticket,
|
||||
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
|
||||
)
|
||||
return "system_exception_paused"
|
||||
# 锁舱是航线主动再试:即使工单因上次失败标了系统异常,也要再打 TMS,
|
||||
# 不能只回「TMS接口异常,IT运维排查中」。
|
||||
no = ticket_no(ticket)
|
||||
name = operator_name(flow, sender_id)
|
||||
quote = ticket_view(ticket).get("quote") or {}
|
||||
options = _air_options(quote)
|
||||
quote_id = _quote_id(quote) or f"QT-{no}"
|
||||
if not options:
|
||||
options = [{"optionNo": "AIR-OPT-01"}]
|
||||
quote_id = _quote_id(quote)
|
||||
picked = (option_no or "").strip()
|
||||
if not picked and len(options) > 1:
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
@@ -619,8 +623,10 @@ def apply_air_lock(
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
|
||||
return "wait_lock_option"
|
||||
if not picked:
|
||||
if not picked and options:
|
||||
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
|
||||
if not picked:
|
||||
picked = "AIR-OPT-01"
|
||||
locker = getattr(flow.ledger, "lock_cabin", None)
|
||||
if not callable(locker):
|
||||
send_group_text(
|
||||
@@ -630,7 +636,7 @@ def apply_air_lock(
|
||||
)
|
||||
return "lock_unavailable"
|
||||
from agent.policy.system_exception import (
|
||||
STEP_TMS_CABIN,
|
||||
STEP_TMS_LOCK,
|
||||
TYPE_TMS,
|
||||
SystemExceptionEvent,
|
||||
call_with_retries,
|
||||
@@ -649,21 +655,35 @@ def apply_air_lock(
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
if not out.get("ok"):
|
||||
why = copy.air_cabin_fail_reason(
|
||||
tms_reason=str(out.get("failReason") or out.get("error") or ""),
|
||||
flight_date=_ticket_flight_date(ticket),
|
||||
)
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_lock_fail(work_order_no=no, operator=name, reason=why),
|
||||
)
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_CABIN,
|
||||
step=STEP_TMS_LOCK,
|
||||
reason="空运锁舱接口调用失败",
|
||||
service="空运锁舱",
|
||||
error_code=str(out.get("failReason") or out.get("error") or "LOCK_FAIL"),
|
||||
error_code=why or "LOCK_FAIL",
|
||||
work_order_no=no,
|
||||
ticket_status=ticket_status(ticket),
|
||||
conversation_kind="group",
|
||||
conversation_target=chat_id,
|
||||
extra={"option_no": picked, "instruction": instruction, "sender_id": sender_id},
|
||||
extra={
|
||||
"option_no": picked,
|
||||
"instruction": instruction,
|
||||
"sender_id": sender_id,
|
||||
"operator_name": name,
|
||||
"flight_date": _ticket_flight_date(ticket),
|
||||
},
|
||||
),
|
||||
ledger=flow.ledger,
|
||||
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
|
||||
)
|
||||
return "system_exception"
|
||||
exec_no = str(out.get("lockId") or out.get("tmsRequestId") or "").strip()
|
||||
@@ -690,6 +710,10 @@ def apply_air_lock(
|
||||
quote_no=quote_no,
|
||||
),
|
||||
)
|
||||
from agent.policy.system_exception import clear_system_exception, ticket_is_paused
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
clear_system_exception(ledger=flow.ledger, work_order_no=no)
|
||||
view = ticket_view(ticket)
|
||||
view["collab_chat_id"] = chat_id
|
||||
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
|
||||
@@ -707,14 +731,7 @@ def apply_air_release(
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
reply_paused_ticket(
|
||||
ticket=ticket,
|
||||
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
|
||||
)
|
||||
return "system_exception_paused"
|
||||
# 释放同理:暂停单也允许再打 TMS,回话用释放失败原因而不是接口异常兜底。
|
||||
no = ticket_no(ticket)
|
||||
if ticket_status(ticket) == "已成交":
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no))
|
||||
@@ -734,7 +751,7 @@ def apply_air_release(
|
||||
)
|
||||
return "release_unavailable"
|
||||
from agent.policy.system_exception import (
|
||||
STEP_TMS_CABIN,
|
||||
STEP_TMS_RELEASE,
|
||||
TYPE_TMS,
|
||||
SystemExceptionEvent,
|
||||
call_with_retries,
|
||||
@@ -753,21 +770,35 @@ def apply_air_release(
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
if not out.get("ok"):
|
||||
why = copy.air_cabin_fail_reason(
|
||||
tms_reason=str(out.get("failReason") or out.get("error") or ""),
|
||||
flight_date=_ticket_flight_date(ticket),
|
||||
)
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_release_fail(work_order_no=no, operator=name, reason=why),
|
||||
)
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_CABIN,
|
||||
step=STEP_TMS_RELEASE,
|
||||
reason="空运释放舱位接口调用失败",
|
||||
service="空运舱位",
|
||||
error_code=str(out.get("failReason") or out.get("error") or "RELEASE_FAIL"),
|
||||
error_code=why or "RELEASE_FAIL",
|
||||
work_order_no=no,
|
||||
ticket_status=ticket_status(ticket),
|
||||
conversation_kind="group",
|
||||
conversation_target=chat_id,
|
||||
extra={"lock_id": lock_id, "instruction": instruction, "sender_id": sender_id},
|
||||
extra={
|
||||
"lock_id": lock_id,
|
||||
"instruction": instruction,
|
||||
"sender_id": sender_id,
|
||||
"operator_name": name,
|
||||
"flight_date": _ticket_flight_date(ticket),
|
||||
},
|
||||
),
|
||||
ledger=flow.ledger,
|
||||
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
|
||||
)
|
||||
return "system_exception"
|
||||
exec_no = str(out.get("releaseId") or out.get("tmsRequestId") or lock_id).strip()
|
||||
@@ -782,6 +813,10 @@ def apply_air_release(
|
||||
quote_no=str(facts.get(_LOCK_QUOTE) or ""),
|
||||
),
|
||||
)
|
||||
from agent.policy.system_exception import clear_system_exception, ticket_is_paused
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
clear_system_exception(ledger=flow.ledger, work_order_no=no)
|
||||
view = ticket_view(ticket)
|
||||
view["collab_chat_id"] = chat_id
|
||||
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。
|
||||
|
||||
本文件职责:补问到询价必填齐全 → 建单出卡片 → 必须打主账查 TMS(有价/暂无报价)。
|
||||
本文件职责:补问到必填齐 → 核对确定后建单 → 查 TMS(一条出卡,多条 H5 选线路)。
|
||||
对不上机场也不能跳过查价、本地说暂无报价。
|
||||
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
|
||||
禁止:直连 TMS;用图状态冒充主账成功;用户文字冒充文件已生成。
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
@@ -20,6 +21,7 @@ from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.llm.extract_text import extract_inquiry_snapshot
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
from agent.schema.land_options import looks_like_confirm
|
||||
from agent.schema.tms_air_query import assemble_air_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -114,6 +116,8 @@ class AirTextInquiryFlow:
|
||||
"""
|
||||
|
||||
bookmark_kind = "air"
|
||||
# 明细文本真正发出后再发卡,避免销售先看到半截引用条。
|
||||
_detail_before_card_gap_sec = 0.8
|
||||
|
||||
def __init__(self, ledger: Any = None, group_client: Any = None, *, bookmark_store: Any = None) -> None:
|
||||
self._ledger = ledger or build_ledger()
|
||||
@@ -410,7 +414,7 @@ class AirTextInquiryFlow:
|
||||
phase = (sess.phase or "").strip()
|
||||
if not no or is_virtual_no(no):
|
||||
return sess
|
||||
if phase in {"clarify", "need_mode", "handoff_offer", "wait_lost_reason"}:
|
||||
if phase in {"clarify", "need_mode", "handoff_offer", "wait_lost_reason", "wait_air_route"}:
|
||||
return sess
|
||||
logger.info(
|
||||
"%s.new_over_old phase=%s wo=%s",
|
||||
@@ -621,21 +625,13 @@ class AirTextInquiryFlow:
|
||||
)
|
||||
return "wait_adopt"
|
||||
if sess.phase == "wait_collab":
|
||||
reply(
|
||||
copy.tms_hit_card(
|
||||
self._emit_air_hit(
|
||||
reply,
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=sess.quote,
|
||||
first_or_same=sess.first_or_same,
|
||||
history_work_order_no=sess.history_work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
),
|
||||
copy.tms_hit_wecom_payload(
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=sess.quote,
|
||||
first_or_same=sess.first_or_same,
|
||||
history_work_order_no=sess.history_work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
),
|
||||
)
|
||||
return "wait_collab"
|
||||
if sess.phase == "tms_miss":
|
||||
@@ -716,6 +712,19 @@ class AirTextInquiryFlow:
|
||||
adjusted = try_private_adjust(self, sess, text, reply)
|
||||
if adjusted:
|
||||
return adjusted
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
|
||||
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
|
||||
logger.info("air.confirm sender=%s", sender_id)
|
||||
return self._confirm_or_hold(
|
||||
sender_id=sender_id,
|
||||
facts=dict(sess.facts),
|
||||
text=sess.immutable_text or text,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
if (not sess or sess.phase != "wait_confirm") and looks_like_confirm(text) and not sess:
|
||||
reply(copy.AIR_CONFIRM_EXPIRED)
|
||||
return "confirm_expired"
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
@@ -739,7 +748,7 @@ class AirTextInquiryFlow:
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode"}:
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
|
||||
# 补问轮:旧字段打底;运输方式沿用上一句(飞=空运)
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
@@ -833,9 +842,78 @@ class AirTextInquiryFlow:
|
||||
),
|
||||
)
|
||||
|
||||
return self._create_and_quote(
|
||||
return self._hold_for_confirm(
|
||||
sender_id=sender_id,
|
||||
facts=facts,
|
||||
text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
|
||||
def _hold_for_confirm(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
facts: dict[str, str],
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
sess: Optional[FlowSession],
|
||||
) -> str:
|
||||
"""必填已齐:出核对卡,等确定再建单。"""
|
||||
reply(copy.air_confirm_card(facts))
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="wait_confirm",
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
immutable_text=text,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
)
|
||||
return "wait_confirm"
|
||||
|
||||
def _confirm_or_hold(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
facts: dict[str, str],
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
sess: FlowSession,
|
||||
) -> str:
|
||||
"""确定意图:字段仍齐则建单查价。"""
|
||||
check = validate_required_fields(facts=facts, business_line="AIR")
|
||||
tms_ready = assemble_air_query(check["facts"])
|
||||
if not check["ok"] or not tms_ready.get("ok"):
|
||||
keys = list(check["missing"] or [])
|
||||
for key in tms_ready.get("missing") or []:
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
new_sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="AIR",
|
||||
facts=dict(tms_ready.get("facts") or facts),
|
||||
immutable_text=sess.immutable_text or text,
|
||||
wait_version=sess.wait_version + 1,
|
||||
)
|
||||
return self._clarify_or_handoff(
|
||||
sess=sess,
|
||||
keys=keys,
|
||||
new_sess=new_sess,
|
||||
reply=reply,
|
||||
clarify_text=copy.ask_clarify(
|
||||
facts=new_sess.facts,
|
||||
missing_keys=keys,
|
||||
transport_mode="空运",
|
||||
),
|
||||
)
|
||||
return self._create_and_quote(
|
||||
sender_id=sender_id,
|
||||
facts=dict(tms_ready["facts"]),
|
||||
text=text,
|
||||
reply=reply,
|
||||
)
|
||||
@@ -925,6 +1003,15 @@ class AirTextInquiryFlow:
|
||||
return "tms_miss"
|
||||
|
||||
quote = tms["quote"]
|
||||
from agent.schema.air_route_options import (
|
||||
needs_route_pick,
|
||||
parse_air_options,
|
||||
project_selected_quote,
|
||||
)
|
||||
|
||||
options = parse_air_options(quote)
|
||||
if options and not needs_route_pick(quote):
|
||||
quote = project_selected_quote(quote, options[0])
|
||||
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
|
||||
if not up.get("ok"):
|
||||
reply("主账写入报价失败,不能当作已报价。")
|
||||
@@ -933,29 +1020,193 @@ class AirTextInquiryFlow:
|
||||
return "ledger_fail"
|
||||
sess.quote = quote
|
||||
copy.remember_tms_quote(sess, quote)
|
||||
sess.tms_quote = dict(quote)
|
||||
sess.status = "已报价"
|
||||
if needs_route_pick(quote):
|
||||
return self._offer_air_routes(
|
||||
sess=sess,
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
reply=reply,
|
||||
)
|
||||
sess.phase = "wait_collab"
|
||||
sess.allowed = (copy.BTN_SKIP_COLLAB,)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
reply(
|
||||
copy.tms_hit_card(
|
||||
self._emit_air_hit(
|
||||
reply,
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
first_or_same=first,
|
||||
history_work_order_no=history_wo,
|
||||
status="已报价",
|
||||
),
|
||||
copy.tms_hit_wecom_payload(
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
first_or_same=first,
|
||||
history_work_order_no=history_wo,
|
||||
status="已报价",
|
||||
),
|
||||
)
|
||||
return "wait_collab"
|
||||
|
||||
def _offer_air_routes(
|
||||
self,
|
||||
*,
|
||||
sess: FlowSession,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
reply: ReplyFn,
|
||||
) -> str:
|
||||
"""多条线路:企微只发入口卡,选线在 H5。"""
|
||||
from agent.channel.h5.air_route_store import build_air_route_url, issue_air_route_token
|
||||
from agent.schema.air_route_options import parse_air_options
|
||||
|
||||
token = issue_air_route_token(
|
||||
sender_id=sess.sender_id,
|
||||
work_order_no=work_order_no,
|
||||
quote=quote,
|
||||
facts=dict(sess.facts),
|
||||
)
|
||||
url = build_air_route_url(token)
|
||||
count = len(parse_air_options(quote))
|
||||
sess.phase = "wait_air_route"
|
||||
sess.allowed = ()
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
reply(
|
||||
copy.air_route_entry_text(work_order_no=work_order_no, count=count),
|
||||
copy.air_route_entry_card(work_order_no=work_order_no, count=count, url=url),
|
||||
)
|
||||
return "wait_air_route"
|
||||
|
||||
def select_air_route(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
index: int,
|
||||
reply: ReplyFn,
|
||||
) -> str:
|
||||
"""
|
||||
H5 选定一条线路:写回起运港,出价格明细卡。
|
||||
|
||||
已采用过则作废旧报价单后再出新卡。
|
||||
"""
|
||||
sess = self.session_of(sender_id)
|
||||
if sess is None or not (sess.work_order_no or "").strip():
|
||||
reply("线路选择已过期,请回企微重新询价。")
|
||||
return "route_expired"
|
||||
from agent.schema.air_route_options import (
|
||||
apply_selected_option,
|
||||
parse_air_options,
|
||||
project_selected_quote,
|
||||
)
|
||||
|
||||
raw_quote = dict(sess.tms_quote or sess.quote or {})
|
||||
options = parse_air_options(raw_quote)
|
||||
if index < 0 or index >= len(options):
|
||||
reply("没有这条线路,请重新打开页面选择。")
|
||||
return "route_invalid"
|
||||
option = options[index]
|
||||
facts = apply_selected_option(dict(sess.facts), option)
|
||||
sess.facts = facts
|
||||
quote = project_selected_quote(raw_quote, option)
|
||||
prev_phase = (sess.phase or "").strip()
|
||||
already_adopted = prev_phase in {"wait_deal", "wait_file", "wait_adopt"}
|
||||
if already_adopted:
|
||||
clearer = getattr(self._ledger, "void_quote_files", None)
|
||||
if callable(clearer):
|
||||
clearer(work_order_no=sess.work_order_no)
|
||||
from agent.redis_coord.quote_file import clear_quote_file_ready
|
||||
|
||||
clear_quote_file_ready(sess.work_order_no)
|
||||
patcher = getattr(self._ledger, "update_facts", None)
|
||||
if callable(patcher):
|
||||
patcher(work_order_no=sess.work_order_no, facts=facts)
|
||||
self._ledger.upsert_quote(work_order_no=sess.work_order_no, quote=quote, to_status="已报价")
|
||||
sess.quote = quote
|
||||
copy.remember_tms_quote(sess, quote)
|
||||
sess.status = "已报价"
|
||||
sess.wait_version += 1
|
||||
if already_adopted:
|
||||
# 旧报价单已作废,直接出方案卡让销售重新采用。
|
||||
sess.phase = "wait_adopt"
|
||||
sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
|
||||
self._save(sess)
|
||||
mode = copy.scheme_transport_mode(sess.business_line)
|
||||
reply(
|
||||
copy.scheme_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status="已报价",
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
transport_mode=mode,
|
||||
),
|
||||
copy.scheme_wecom_payload(
|
||||
work_order_no=sess.work_order_no,
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
status="已报价",
|
||||
transport_mode=mode,
|
||||
),
|
||||
)
|
||||
return "wait_adopt"
|
||||
sess.phase = "wait_collab"
|
||||
sess.allowed = (copy.BTN_SKIP_COLLAB,)
|
||||
self._save(sess)
|
||||
self._emit_air_hit(
|
||||
reply,
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=quote,
|
||||
first_or_same=sess.first_or_same,
|
||||
history_work_order_no=sess.history_work_order_no,
|
||||
status="已报价",
|
||||
)
|
||||
return "wait_collab"
|
||||
|
||||
def _emit_air_hit(
|
||||
self,
|
||||
reply: ReplyFn,
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> None:
|
||||
"""
|
||||
先发完整价格明细文本,等它真正发出,再发带按钮的卡。
|
||||
|
||||
企微引用条超长会截成省略号。海运已走这条;空运多项费用同样不能塞进卡。
|
||||
只等这一条出站,不锁其它工单。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
out_id = reply(
|
||||
copy.sea_quote_detail_text(
|
||||
work_order_no=work_order_no,
|
||||
quote=src,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
business_line="AIR",
|
||||
)
|
||||
) or ""
|
||||
self._wait_outbound_sent(
|
||||
str(out_id), work_order_no=work_order_no, what="价格明细文本"
|
||||
)
|
||||
gap = float(getattr(self, "_detail_before_card_gap_sec", 0.8) or 0)
|
||||
if out_id and gap > 0:
|
||||
time.sleep(gap)
|
||||
reply(
|
||||
copy.tms_hit_card(
|
||||
work_order_no=work_order_no,
|
||||
quote=src,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
),
|
||||
copy.tms_hit_wecom_payload(
|
||||
work_order_no=work_order_no,
|
||||
quote=src,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
),
|
||||
)
|
||||
|
||||
def _wait_inquiry_sent(self, out_id: str, *, work_order_no: str) -> None:
|
||||
"""等询价确认出站完成后再查价。"""
|
||||
self._wait_outbound_sent(out_id, work_order_no=work_order_no, what="询价确认")
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any, Iterable
|
||||
|
||||
from agent.schema.field_validate import (
|
||||
AIR_COLLAB_FIELDS,
|
||||
AIR_OPTIONAL_FIELDS,
|
||||
AIR_REQUIRED_FIELDS,
|
||||
LAND_DISPLAY,
|
||||
SEA_COLLAB_FIELDS,
|
||||
@@ -27,7 +28,7 @@ from agent.schema.land_options import option_prompt as _land_option_prompt
|
||||
from agent.schema.quote_adjust import total_price_label
|
||||
from agent.schema.sea_options import sea_class_prompt
|
||||
|
||||
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
|
||||
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
"起运港": "南京",
|
||||
"目的港": "吉隆坡",
|
||||
@@ -37,10 +38,20 @@ _AIR_EXAMPLES = {
|
||||
"体积": "2CBM",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-09-15",
|
||||
"货源地": "东莞",
|
||||
"货好时间": "随时可提",
|
||||
"提货地址": "东莞仓库",
|
||||
"收货地址": "马尼拉",
|
||||
"贸易条款": "FOB",
|
||||
}
|
||||
_AIR_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
|
||||
)
|
||||
_AIR_OPTIONAL_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS
|
||||
)
|
||||
AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
|
||||
AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。"
|
||||
|
||||
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
|
||||
_SEA_EXAMPLES = {
|
||||
@@ -373,6 +384,46 @@ def _sea_known_value(facts: dict[str, str], key: str) -> str:
|
||||
return (src.get(key) or "").strip()
|
||||
|
||||
|
||||
def _air_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
|
||||
"""空运销售侧一行:名称(必填/非必填):值或示例。"""
|
||||
label = AIR_DISPLAY_LABEL(key)
|
||||
mark = "必填" if required else "非必填"
|
||||
text = (value or "").strip()
|
||||
if text:
|
||||
return f"{label}({mark}):{text}"
|
||||
if example:
|
||||
return f"{label}({mark}):(如:{example})"
|
||||
return f"{label}({mark}):"
|
||||
|
||||
|
||||
def AIR_DISPLAY_LABEL(key: str) -> str:
|
||||
"""核对/补问对外名。起运港按产品口径写起运港。"""
|
||||
if key == "起运港":
|
||||
return "起运港"
|
||||
return display_name(key, "AIR")
|
||||
|
||||
|
||||
def air_confirm_card(facts: dict[str, str] | None) -> str:
|
||||
"""
|
||||
确定查价前的核对卡。没有工单号。
|
||||
|
||||
必填 7 项 + 6 个非必填都列出;空着的非必填留空行。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
lines = ["运输方式(必填):空运"]
|
||||
for key, _label in AIR_REQUIRED_FIELDS:
|
||||
if key == "报价日期":
|
||||
val = _air_known_value(src, key) or (src.get("报价日期") or "").strip()
|
||||
lines.append(_air_field_line(key, val, required=True))
|
||||
continue
|
||||
lines.append(_air_field_line(key, _air_known_value(src, key), required=True))
|
||||
for key, _label in AIR_OPTIONAL_FIELDS:
|
||||
lines.append(_air_field_line(key, _air_known_value(src, key), required=False))
|
||||
lines.append("")
|
||||
lines.append(AIR_CONFIRM_TAIL)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ask_clarify(
|
||||
*,
|
||||
facts: dict[str, str] | None = None,
|
||||
@@ -383,6 +434,7 @@ def ask_clarify(
|
||||
缺项补问:先回显已识别,再列待补充并给示例。
|
||||
|
||||
报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
|
||||
空运非必填一并列出,没填进待补充,不挡查价。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
skip = {"报价日期"}
|
||||
@@ -398,6 +450,16 @@ def ask_clarify(
|
||||
val = known(src, key)
|
||||
if val:
|
||||
lines.append(f"{label}:{val}")
|
||||
if not sea:
|
||||
origin = known(src, "起运港")
|
||||
if origin:
|
||||
lines.append(f"起运地:{origin}")
|
||||
for key, label, _example in _AIR_OPTIONAL_CLARIFY:
|
||||
if key == "起运港":
|
||||
continue
|
||||
val = known(src, key)
|
||||
if val:
|
||||
lines.append(f"{label}:{val}")
|
||||
lines.append("")
|
||||
lines.append("待补充:")
|
||||
any_missing = False
|
||||
@@ -411,6 +473,16 @@ def ask_clarify(
|
||||
lines.append(sea_class_prompt())
|
||||
else:
|
||||
lines.append(f"{label}:(如:{example})")
|
||||
if not sea:
|
||||
optional_pending = [
|
||||
(key, label, example)
|
||||
for key, label, example in _AIR_OPTIONAL_CLARIFY
|
||||
if not known(src, key)
|
||||
]
|
||||
for key, label, example in optional_pending:
|
||||
any_missing = True
|
||||
shown = "起运港" if key == "起运港" else label
|
||||
lines.append(f"{shown}(非必填):(如:{example})")
|
||||
if not any_missing:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
@@ -861,18 +933,24 @@ def quote_area_safe_text(
|
||||
transport_mode: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
卡片引用条:短报价原样;超长只留总价名称,避免半截费用名。
|
||||
卡片引用条:空运有费用行就只留总价,禁止再塞费用。
|
||||
|
||||
完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。
|
||||
三项短费用也会被企微裁成「人工费:CNY 123...」。完整明细走文本。
|
||||
海运/陆运仍按字数:短的原样,超长同样只留总价。
|
||||
"""
|
||||
full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode)
|
||||
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
|
||||
return full
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
n = len(quote_fee_lines(src))
|
||||
label = _total_label(src, business_line, transport_mode)
|
||||
line = (business_line or "").upper()
|
||||
mode = (transport_mode or "").strip()
|
||||
is_air = line == "AIR" or mode == "空运"
|
||||
if is_air and n:
|
||||
return f"{label}:{total}\n共{n}项费用,详见上一条明细"
|
||||
full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode)
|
||||
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
|
||||
return full
|
||||
if n:
|
||||
return f"{label}:{total}\n共{n}项费用,详见上一条明细"
|
||||
return f"{label}:{total}"
|
||||
@@ -888,7 +966,7 @@ def sea_quote_detail_text(
|
||||
business_line: str = "SEA",
|
||||
) -> str:
|
||||
"""
|
||||
海运/陆运完整价格明细文本:来源、工单号、费用行、总销售价。
|
||||
完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。
|
||||
|
||||
时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。
|
||||
不受卡片引用条字数限制。first_or_same 只兼容调用方。
|
||||
@@ -896,13 +974,15 @@ def sea_quote_detail_text(
|
||||
_ = first_or_same, history_work_order_no, status
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
return "\n".join(
|
||||
[
|
||||
source,
|
||||
f"{work_order_no}价格明细",
|
||||
quote_area_fee_text(src, business_line=business_line or "SEA"),
|
||||
]
|
||||
)
|
||||
lines = [source, f"{work_order_no}价格明细"]
|
||||
if (business_line or "").upper() == "AIR":
|
||||
from agent.schema.air_route_options import route_codes
|
||||
|
||||
route = route_codes(None, {}, src)
|
||||
if route:
|
||||
lines.append(route)
|
||||
lines.append(quote_area_fee_text(src, business_line=business_line or "SEA"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def collab_hint(work_order_no: str) -> str:
|
||||
@@ -1007,6 +1087,11 @@ def tms_hit_card(
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
lines = [source, f"{work_order_no}价格明细"]
|
||||
from agent.schema.air_route_options import route_codes
|
||||
|
||||
route = route_codes(None, {}, src) or str(src.get("route_line") or "").strip()
|
||||
if route:
|
||||
lines.append(route)
|
||||
lines.extend(
|
||||
quote_detail_lines(
|
||||
src,
|
||||
@@ -1022,6 +1107,32 @@ def tms_hit_card(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def air_route_entry_card(*, work_order_no: str, count: int, url: str) -> dict[str, Any]:
|
||||
"""
|
||||
多条线路入口卡:短文案跳转 H5,避免企微截断。
|
||||
"""
|
||||
n = max(int(count or 0), 2)
|
||||
fallback = "https://ai.ytd-scm.com/inquiry-form"
|
||||
action_url = (url or "").strip() or fallback
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
"card_type": "text_notice",
|
||||
"source": {"desc": "TMS线路报价", "desc_color": 0},
|
||||
"main_title": {"title": f"{work_order_no}已查到{n}条线路"},
|
||||
"sub_title_text": "点击查看并选择一条线路,选定后回企微看价格明细。",
|
||||
"card_action": {"type": 1, "url": action_url},
|
||||
"jump_list": [{"type": 1, "title": "查看并选择线路", "url": action_url}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def air_route_entry_text(*, work_order_no: str, count: int) -> str:
|
||||
"""入口卡失败时的文字兜底。"""
|
||||
n = max(int(count or 0), 2)
|
||||
return f"{work_order_no}已查到{n}条线路,请点击卡片查看并选择。"
|
||||
|
||||
|
||||
def tms_hit_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
@@ -1062,7 +1173,7 @@ def tms_hit_wecom_payload(
|
||||
"main_title": {"title": f"{work_order_no}价格明细"},
|
||||
"quote_area": {
|
||||
"type": 0,
|
||||
"quote_text": quote_area_fee_text(src, business_line="AIR"),
|
||||
"quote_text": quote_area_safe_text(src, business_line="AIR"),
|
||||
},
|
||||
"sub_title_text": meta,
|
||||
"task_id": card_task_id("quote", work_order_no),
|
||||
@@ -1675,6 +1786,13 @@ def _clip(raw: str, limit: int) -> str:
|
||||
return text if len(text) <= limit else text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def _air_scheme_route(quote: dict[str, Any], facts: dict[str, str]) -> str:
|
||||
"""方案卡航线:空运ZUH→中转→CRK。没有三字码才退回地名。"""
|
||||
from agent.schema.air_route_options import scheme_route_codes
|
||||
|
||||
return scheme_route_codes(quote, facts)
|
||||
|
||||
|
||||
def scheme_transport_mode(business_line: str = "", fallback: str = "空运") -> str:
|
||||
"""会话业务线 → 方案卡运输方式。海运不得再写成空运。"""
|
||||
raw = (business_line or "").strip()
|
||||
@@ -1736,7 +1854,7 @@ def scheme_horizontal_rows(
|
||||
pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-"
|
||||
return [
|
||||
{"keyname": "工单号", "value": _clip(work_order_no, 26)},
|
||||
{"keyname": "航线", "value": _clip(f"空运 {origin}→{dest}", 26)},
|
||||
{"keyname": "航线", "value": _clip(_air_scheme_route(q, src), 26)},
|
||||
{"keyname": "货物", "value": _clip(f"{cargo};{pieces};{weight};{volume}", 26)},
|
||||
{"keyname": "包装", "value": _clip(pack, 26)},
|
||||
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
|
||||
@@ -1798,11 +1916,8 @@ def scheme_wecom_payload(
|
||||
"main_title": {"title": f"{work_order_no}报价方案"},
|
||||
"quote_area": {
|
||||
"type": 0,
|
||||
"quote_text": (
|
||||
quote_area_safe_text(src, transport_mode=transport_mode)
|
||||
if (transport_mode or "").strip() in {"海运", "陆运"}
|
||||
else quote_area_fee_text(src, transport_mode=transport_mode)
|
||||
),
|
||||
# 空运/海运/陆运引用条一律只留总价,费用在上一条明细。
|
||||
"quote_text": quote_area_safe_text(src, transport_mode=transport_mode),
|
||||
},
|
||||
"horizontal_content_list": scheme_horizontal_rows(
|
||||
work_order_no=work_order_no,
|
||||
|
||||
@@ -107,6 +107,17 @@ def _emit_private_card(flow: Any, sess: Any, reply) -> None:
|
||||
)
|
||||
return
|
||||
sess.allowed = (copy.BTN_SKIP_COLLAB,)
|
||||
emit_air = getattr(flow, "_emit_air_hit", None)
|
||||
if callable(emit_air):
|
||||
emit_air(
|
||||
reply,
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
first_or_same=first,
|
||||
history_work_order_no=history,
|
||||
status="已报价",
|
||||
)
|
||||
return
|
||||
reply(
|
||||
copy.tms_hit_card(
|
||||
work_order_no=no,
|
||||
|
||||
@@ -95,6 +95,12 @@ def ticket_view(ticket: object) -> dict[str, Any]:
|
||||
"product_names": list(getattr(ticket, "product_names", []) or []),
|
||||
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
|
||||
"lost_reason": str(getattr(ticket, "lost_reason", "") or ""),
|
||||
"system_exception": str(getattr(ticket, "system_exception", "") or ""),
|
||||
"systemException": str(getattr(ticket, "system_exception", "") or ""),
|
||||
"system_exception_reason": str(getattr(ticket, "system_exception_reason", "") or ""),
|
||||
"systemExceptionReason": str(getattr(ticket, "system_exception_reason", "") or ""),
|
||||
"system_exception_step": str(getattr(ticket, "system_exception_step", "") or ""),
|
||||
"system_exception_payload": dict(getattr(ticket, "system_exception_payload", {}) or {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -418,6 +424,16 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
|
||||
merged["collab_facts"] = collab
|
||||
if view.get("collab_chat_id"):
|
||||
merged["collab_chat_id"] = view.get("collab_chat_id")
|
||||
for key in (
|
||||
"system_exception",
|
||||
"systemException",
|
||||
"system_exception_reason",
|
||||
"systemExceptionReason",
|
||||
"system_exception_step",
|
||||
"system_exception_payload",
|
||||
):
|
||||
if full_view.get(key) and not merged.get(key):
|
||||
merged[key] = full_view.get(key)
|
||||
return merged
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ STEP_LLM_NO_TICKET = copy.STEP_LLM_NO_TICKET
|
||||
STEP_LLM = copy.STEP_LLM
|
||||
STEP_TMS_QUOTE = copy.STEP_TMS_QUOTE
|
||||
STEP_TMS_CABIN = copy.STEP_TMS_CABIN
|
||||
STEP_TMS_LOCK = copy.STEP_TMS_LOCK
|
||||
STEP_TMS_RELEASE = copy.STEP_TMS_RELEASE
|
||||
STEP_GROUP = copy.STEP_GROUP
|
||||
|
||||
MAX_AUTO_RETRIES = 3
|
||||
@@ -140,15 +142,6 @@ def _now_text(now: datetime | None) -> str:
|
||||
return stamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _test_prefix() -> bool:
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
|
||||
return (get_settings().ytd_env or "").strip().lower() == "test"
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def notify_app_ready() -> bool:
|
||||
"""询价消息服务已配才推告警。"""
|
||||
from agent.policy.deal_outcome import notify_app_ready as deal_ready
|
||||
@@ -244,7 +237,6 @@ def confirm_system_exception(
|
||||
users = _list_notify_users(ledger) if ledger is not None else []
|
||||
if ready and users:
|
||||
occurred = _now_text(now)
|
||||
test_prefix = _test_prefix()
|
||||
payload = copy.system_exception_alert_payload(
|
||||
work_order_no=no,
|
||||
exception_type=event.exception_type,
|
||||
@@ -252,7 +244,6 @@ def confirm_system_exception(
|
||||
error_code=event.error_code,
|
||||
step=event.step,
|
||||
occurred_at=occurred,
|
||||
test_prefix=test_prefix,
|
||||
)
|
||||
text = copy.system_exception_alert_text(
|
||||
work_order_no=no,
|
||||
@@ -261,7 +252,6 @@ def confirm_system_exception(
|
||||
error_code=event.error_code,
|
||||
step=event.step,
|
||||
occurred_at=occurred,
|
||||
test_prefix=test_prefix,
|
||||
)
|
||||
sender = enqueue or _enqueue_default
|
||||
for row in users:
|
||||
@@ -626,7 +616,7 @@ def run_admin_retry(work_order_no: str, *, ledger: Any = None) -> dict[str, Any]
|
||||
reply("请把刚才那句话再发一次。")
|
||||
return {"ok": True, "step": step}
|
||||
|
||||
if step == STEP_TMS_CABIN:
|
||||
if step in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}:
|
||||
return _resume_cabin_step(
|
||||
ledger=book,
|
||||
work_order_no=no,
|
||||
@@ -634,6 +624,7 @@ def run_admin_retry(work_order_no: str, *, ledger: Any = None) -> dict[str, Any]
|
||||
target=target,
|
||||
status=status,
|
||||
reply=reply,
|
||||
step=step,
|
||||
)
|
||||
|
||||
return {"ok": False, "error": "unknown_step", "step": step}
|
||||
@@ -716,6 +707,10 @@ def _resume_tms_quote(
|
||||
copy.remember_tms_quote(sess, quote)
|
||||
flow._save(sess)
|
||||
if biz == "AIR":
|
||||
emit_air = getattr(flow, "_emit_air_hit", None)
|
||||
if callable(emit_air):
|
||||
emit_air(reply, work_order_no=no, quote=quote, status="已报价")
|
||||
else:
|
||||
reply(
|
||||
copy.tms_hit_card(work_order_no=no, quote=quote, status="已报价"),
|
||||
copy.tms_hit_wecom_payload(work_order_no=no, quote=quote, status="已报价"),
|
||||
@@ -742,13 +737,17 @@ def _resume_cabin_step(
|
||||
target: str,
|
||||
status: str,
|
||||
reply: ReplyFn,
|
||||
step: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""重试空运锁舱或释放:payload 里有 lock_id 就是释放。"""
|
||||
no = (work_order_no or "").strip()
|
||||
lock_id = str(extra.get("lock_id") or "").strip()
|
||||
option_no = str(extra.get("option_no") or "").strip() or "AIR-OPT-01"
|
||||
option_no = str(extra.get("option_no") or "").strip()
|
||||
instruction = str(extra.get("instruction") or "")
|
||||
sender = str(extra.get("sender_id") or target or "").strip()
|
||||
cabin_step = STEP_TMS_RELEASE if lock_id else STEP_TMS_LOCK
|
||||
if (step or "").strip() in {STEP_TMS_LOCK, STEP_TMS_RELEASE}:
|
||||
cabin_step = (step or "").strip()
|
||||
if lock_id:
|
||||
releaser = getattr(ledger, "release_cabin", None)
|
||||
out = call_with_retries(
|
||||
@@ -782,10 +781,19 @@ def _resume_cabin_step(
|
||||
service = "空运锁舱"
|
||||
reason = "空运锁舱接口调用失败"
|
||||
if not (out or {}).get("ok"):
|
||||
why = copy.air_cabin_fail_reason(
|
||||
tms_reason=str((out or {}).get("failReason") or (out or {}).get("error") or ""),
|
||||
flight_date=str(extra.get("flight_date") or ""),
|
||||
)
|
||||
operator = str(extra.get("operator_name") or "同事").strip() or "同事"
|
||||
if cabin_step == STEP_TMS_RELEASE or lock_id:
|
||||
reply(copy.air_release_fail(work_order_no=no, operator=operator, reason=why))
|
||||
else:
|
||||
reply(copy.air_lock_fail(work_order_no=no, operator=operator, reason=why))
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_CABIN,
|
||||
step=cabin_step,
|
||||
reason=reason,
|
||||
service=service,
|
||||
error_code=str((out or {}).get("failReason") or (out or {}).get("error") or "CABIN_FAIL"),
|
||||
@@ -796,10 +804,9 @@ def _resume_cabin_step(
|
||||
extra=dict(extra),
|
||||
),
|
||||
ledger=ledger,
|
||||
reply=reply,
|
||||
)
|
||||
return {"ok": False, "error": "still_fail", "step": STEP_TMS_CABIN}
|
||||
reply(copy.system_exception_recovery(STEP_TMS_CABIN))
|
||||
return {"ok": False, "error": "still_fail", "step": cabin_step}
|
||||
reply(copy.system_exception_recovery(cabin_step))
|
||||
clear_system_exception(ledger=ledger, work_order_no=no)
|
||||
if not lock_id:
|
||||
exec_no = str((out or {}).get("lockId") or (out or {}).get("tmsRequestId") or "").strip()
|
||||
@@ -813,4 +820,4 @@ def _resume_cabin_step(
|
||||
reply(copy.air_lock_ok(work_order_no=no, operator="同事", option_no=option_no, exec_no=exec_no, quote_no=""))
|
||||
else:
|
||||
reply(copy.air_release_ok(work_order_no=no, operator="同事", option_no="", exec_no=str((out or {}).get("releaseId") or lock_id), quote_no=""))
|
||||
return {"ok": True, "step": STEP_TMS_CABIN}
|
||||
return {"ok": True, "step": cabin_step}
|
||||
|
||||
@@ -30,6 +30,19 @@ def mark_quote_file_ready(work_order_no: str) -> None:
|
||||
logger.exception("写报价文件就绪键失败 wo=%s", no)
|
||||
|
||||
|
||||
def clear_quote_file_ready(work_order_no: str) -> None:
|
||||
"""采用后改选:清掉就绪键,避免续办再进成交跟进。"""
|
||||
no = (work_order_no or "").strip()
|
||||
if not no:
|
||||
return
|
||||
try:
|
||||
client = get_redis_runtime().client
|
||||
client.raw.delete(client.key(QUOTE_FILE_READY, no))
|
||||
logger.info("报价文件就绪键已清 wo=%s", no)
|
||||
except Exception:
|
||||
logger.exception("清报价文件就绪键失败 wo=%s", no)
|
||||
|
||||
|
||||
def is_quote_file_ready(work_order_no: str) -> bool:
|
||||
"""HTTP 读:文件是否已经发出。读失败当未完成,继续请稍候。"""
|
||||
no = (work_order_no or "").strip()
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
空运 TMS 线路报价:从 airOptions 拆出可选线路。
|
||||
|
||||
本文件职责:判断一条还是多条、拼航线(含中转)、抽出该线路费用。
|
||||
选定后把起运港写回 facts。中转只用于展示,不写进报价单模板。
|
||||
禁止:猜没回的机场名;把贸易条款当线路;改六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.schema.tms_air_query import _AIRPORTS, resolve_airport_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 三字码 → 销售侧地名;同码多个中文时取较短的常用名。
|
||||
_CODE_NAME: dict[str, str] = {}
|
||||
for name, code in _AIRPORTS.items():
|
||||
prev = _CODE_NAME.get(code)
|
||||
if prev is None or len(name) < len(prev):
|
||||
_CODE_NAME[code] = name
|
||||
|
||||
|
||||
def parse_air_options(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""把 quote.airOptions(list 或 JSON 串)收成字典列表。"""
|
||||
src = dict(quote or {})
|
||||
raw = src.get("airOptions") or src.get("air_options")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("air.routes airOptions 不是 JSON")
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
out.append(dict(item))
|
||||
return out
|
||||
|
||||
|
||||
def needs_route_pick(quote: dict[str, Any] | None) -> bool:
|
||||
"""多于一条线路才进 H5 选。"""
|
||||
return len(parse_air_options(quote)) > 1
|
||||
|
||||
|
||||
def _place_name(code: str, fallback: str = "") -> str:
|
||||
"""三字码能对上就用中文,否则用原文或 fallback。"""
|
||||
raw = (code or "").strip()
|
||||
if not raw:
|
||||
return (fallback or "").strip()
|
||||
mapped = _CODE_NAME.get(raw.upper())
|
||||
if mapped:
|
||||
return mapped
|
||||
if resolve_airport_code(raw):
|
||||
return _CODE_NAME.get(resolve_airport_code(raw), raw)
|
||||
return raw
|
||||
|
||||
|
||||
def route_line(option: dict[str, Any] | None, facts: dict[str, str] | None = None) -> str:
|
||||
"""
|
||||
销售侧航线:有中转写成 起运-中转-目的。
|
||||
对不上字典就展示三字码,禁止编城市。
|
||||
"""
|
||||
src = dict(option or {})
|
||||
facts = facts or {}
|
||||
origin = _place_name(
|
||||
str(src.get("originAirportCode") or src.get("origin") or ""),
|
||||
str(facts.get("起运港") or ""),
|
||||
)
|
||||
dest = _place_name(
|
||||
str(src.get("destinationAirportCode") or src.get("destination") or ""),
|
||||
str(facts.get("目的港") or ""),
|
||||
)
|
||||
transfer = _place_name(str(src.get("transferAirportCode") or src.get("transfer") or ""))
|
||||
bits = [x for x in (origin, transfer, dest) if x]
|
||||
return "-".join(bits)
|
||||
|
||||
|
||||
def _iata_code(raw: str, fallback: str = "") -> str:
|
||||
"""只要三字码。已是代码就用;中文能投影就投影。禁止编假码。"""
|
||||
text = (raw or "").strip()
|
||||
if len(text) == 3 and text.isalpha():
|
||||
return text.upper()
|
||||
mapped = resolve_airport_code(text) or resolve_airport_code(fallback)
|
||||
return (mapped or "").upper()
|
||||
|
||||
|
||||
def route_codes(
|
||||
option: dict[str, Any] | None,
|
||||
facts: dict[str, str] | None = None,
|
||||
quote: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
价格明细/方案卡用的航线三字码:有中转写成 ZUH-CAN-CRK。
|
||||
对不上就不写假中转。
|
||||
"""
|
||||
src = dict(option or {})
|
||||
if not src:
|
||||
q = dict(quote or {})
|
||||
src = dict(q.get("selected_air_option") or {})
|
||||
if not src:
|
||||
opts = parse_air_options(q)
|
||||
src = dict(opts[0]) if opts else {}
|
||||
facts = facts or {}
|
||||
origin = _iata_code(
|
||||
str(src.get("originAirportCode") or src.get("origin") or facts.get("originCode") or ""),
|
||||
str(facts.get("起运港") or ""),
|
||||
)
|
||||
dest = _iata_code(
|
||||
str(src.get("destinationAirportCode") or src.get("destination") or facts.get("destinationCode") or ""),
|
||||
str(facts.get("目的港") or ""),
|
||||
)
|
||||
transfer = _iata_code(
|
||||
str(
|
||||
src.get("transferAirportCode")
|
||||
or src.get("transfer")
|
||||
or (quote or {}).get("transferAirportCode")
|
||||
or ""
|
||||
)
|
||||
)
|
||||
bits = [x for x in (origin, transfer, dest) if x]
|
||||
return "-".join(bits)
|
||||
|
||||
|
||||
def scheme_route_codes(
|
||||
quote: dict[str, Any] | None,
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""方案卡航线:空运ZUH→中转→CRK。没有代码则退回地名。"""
|
||||
codes = route_codes(None, facts, quote)
|
||||
if codes:
|
||||
return "空运" + codes.replace("-", "→")
|
||||
facts = facts or {}
|
||||
origin = (facts.get("起运港") or "-").strip() or "-"
|
||||
dest = (facts.get("目的港") or "-").strip() or "-"
|
||||
return f"空运{origin}→{dest}"
|
||||
|
||||
|
||||
def _fee_row(item: dict[str, Any]) -> tuple[str, str] | None:
|
||||
"""一条 TMS 费用。有名称才算;金额空则用单价。禁止编费目。"""
|
||||
name = str(
|
||||
item.get("chargeName") or item.get("feeName") or item.get("name") or ""
|
||||
).strip()
|
||||
if not name:
|
||||
return None
|
||||
ccy = str(item.get("currency") or "").strip()
|
||||
amount = item.get("amount")
|
||||
if amount is None or str(amount).strip() == "":
|
||||
amount = item.get("unitPrice") or item.get("unit_price")
|
||||
text = str(amount or "").strip()
|
||||
if ccy and text and ccy not in text:
|
||||
text = f"{ccy} {text}"
|
||||
return (name, text or "-")
|
||||
|
||||
|
||||
def _rows_from_list(raw: Any) -> list[tuple[str, str]]:
|
||||
"""把 TMS 费用数组收成展示行。不是列表就当没有。"""
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[tuple[str, str]] = []
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
row = _fee_row(item)
|
||||
if row:
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]:
|
||||
"""
|
||||
该线路 TMS 回了哪些费用就列哪些。
|
||||
|
||||
只读本条 departureCharges / feeItems。多条线路时,整单 fee_lines 是最低价方案,
|
||||
禁止套到没回费用的线路。仅一条线路时,整单 fee_lines 才算这条的明细。
|
||||
"""
|
||||
src = dict(option or {})
|
||||
rows = _rows_from_list(src.get("departureCharges") or src.get("departure_charges"))
|
||||
rows.extend(_rows_from_list(src.get("feeItems") or src.get("fee_items")))
|
||||
if rows:
|
||||
return rows
|
||||
q = dict(quote or {})
|
||||
options = parse_air_options(q)
|
||||
if len(options) > 1:
|
||||
return []
|
||||
return _rows_from_list(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems"))
|
||||
|
||||
|
||||
def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> str:
|
||||
"""该线路总销售价。"""
|
||||
src = dict(option or {})
|
||||
q = dict(quote or {})
|
||||
raw = src.get("salePrice")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
raw = q.get("total") or q.get("salePrice")
|
||||
ccy = str(src.get("currency") or q.get("currency") or "").strip()
|
||||
text = str(raw or "").strip() or "-"
|
||||
if ccy and text != "-" and ccy not in text:
|
||||
return f"{ccy} {text}"
|
||||
return text
|
||||
|
||||
|
||||
def apply_selected_option(facts: dict[str, str] | None, option: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""选定线路后写回起运港(及能对上的三字码)。改选覆盖。"""
|
||||
merged = dict(facts or {})
|
||||
src = dict(option or {})
|
||||
code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper()
|
||||
name = _place_name(code)
|
||||
if name:
|
||||
merged["起运港"] = name
|
||||
if code and len(code) == 3 and code.isalpha():
|
||||
merged["originCode"] = code
|
||||
dest_code = str(src.get("destinationAirportCode") or "").strip().upper()
|
||||
dest_name = _place_name(dest_code, str(merged.get("目的港") or ""))
|
||||
if dest_name:
|
||||
merged["目的港"] = dest_name
|
||||
if dest_code and len(dest_code) == 3 and dest_code.isalpha():
|
||||
merged["destinationCode"] = dest_code
|
||||
return merged
|
||||
|
||||
|
||||
def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""把选中线路投影成价格卡用的 quote。"""
|
||||
base = dict(quote or {})
|
||||
src = dict(option or {})
|
||||
lines = [{"name": n, "amount": a} for n, a in option_fee_lines(src, base)]
|
||||
# 必须覆盖整单 fee_lines:空列表也要写回去,否则会残留别的线路空运费。
|
||||
base["fee_lines"] = lines
|
||||
if not lines:
|
||||
base["freight"] = "-"
|
||||
base["other_fee"] = "-"
|
||||
total = src.get("salePrice")
|
||||
if total is not None and str(total).strip() != "":
|
||||
base["total"] = str(total).strip()
|
||||
if src.get("currency"):
|
||||
base["currency"] = src.get("currency")
|
||||
if src.get("validUntil"):
|
||||
base["valid_until"] = src.get("validUntil")
|
||||
base["selected_air_option"] = src
|
||||
base["route_line"] = route_line(src, {})
|
||||
return base
|
||||
@@ -19,9 +19,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# 空运询价必填 8 项:销售侧名称。内部仍用合同键。
|
||||
# 空运询价必填:起运港改为选填。销售侧名称。内部仍用合同键。
|
||||
AIR_REQUIRED_FIELDS = (
|
||||
("起运港", "起运地"),
|
||||
("目的港", "目的地"),
|
||||
("品名", "品名"),
|
||||
("件数", "件数"),
|
||||
@@ -30,7 +29,19 @@ AIR_REQUIRED_FIELDS = (
|
||||
("包装方式", "包装类型"),
|
||||
("报价日期", "报价日期"),
|
||||
)
|
||||
# 空运询价非必填:有值收下;贸易条款只记工单,不挡查价。
|
||||
AIR_OPTIONAL_FIELDS = (
|
||||
("起运港", "起运港"),
|
||||
("货源地", "货源地"),
|
||||
("货好时间", "货好时间"),
|
||||
("提货地址", "提货地址"),
|
||||
("收货地址", "收货地址"),
|
||||
("贸易条款", "贸易条款"),
|
||||
)
|
||||
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
|
||||
AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS})
|
||||
# 补问回显仍用「起运地」,与历史卡片一致。
|
||||
AIR_DISPLAY["起运港"] = "起运地"
|
||||
|
||||
# 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。
|
||||
SEA_REQUIRED_FIELDS = (
|
||||
@@ -193,6 +204,17 @@ _ALIASES = {
|
||||
"是否为危险品": "是否为危险品",
|
||||
"通关口岸": "通关口岸",
|
||||
"通关口岸(非必填)": "通关口岸",
|
||||
"货源地": "货源地",
|
||||
"货好时间": "货好时间",
|
||||
"提货地址": "提货地址",
|
||||
"收货地址": "收货地址",
|
||||
"贸易条款": "贸易条款",
|
||||
"货源地(非必填)": "货源地",
|
||||
"货好时间(非必填)": "货好时间",
|
||||
"提货地址(非必填)": "提货地址",
|
||||
"收货地址(非必填)": "收货地址",
|
||||
"贸易条款(非必填)": "贸易条款",
|
||||
"起运港(非必填)": "起运港",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
空运查价参数:对照 TMS V2 /quote/v2/query 是否带全。
|
||||
|
||||
本文件职责:询价卡片出现前,确认销售侧空运必填能拼出查价正文。
|
||||
起运港非必填。货源地/货好时间/提货地址/收货地址有值才带出站键。
|
||||
贸易条款只留中文键给工单,禁止投影成 transportTerms。
|
||||
三字码能投影就带;对不上不拦建单、不拦查价。
|
||||
包装:销售原文非空即收下,禁止白名单拦补问。
|
||||
禁止:猜没写过的港口;因「对不上字典」跳过 TMS 或对销售说教;改六态。
|
||||
@@ -35,8 +37,7 @@ _AIRPORTS = {
|
||||
"北京": "PEK",
|
||||
}
|
||||
|
||||
# TMS air_packaging_type 实测只认 1/2/3。1=全部,这条航线无价;
|
||||
# 2/3 能出与现网相同的标准价。托盘走 2,纸箱/木箱走 3。
|
||||
# 历史字典:只给对照,空运出站不再编成 1/2/3。
|
||||
_PACKS = {
|
||||
"全部": "1",
|
||||
"托盘": "2",
|
||||
@@ -170,10 +171,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
missing: list[str] = []
|
||||
missing_display: list[str] = []
|
||||
# 销售侧必填只看 8 项是否非空。三字码是出站投影,对不上不拿来再补问起运地/目的地。
|
||||
if not origin:
|
||||
missing.append("起运港")
|
||||
missing_display.append("起运地")
|
||||
# 起运港改为非必填:空着仍可查价。三字码是出站投影,对不上不拿来再补问。
|
||||
if not dest:
|
||||
missing.append("目的港")
|
||||
missing_display.append("目的地")
|
||||
@@ -204,6 +202,8 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
out_facts["originCode"] = origin_code
|
||||
else:
|
||||
out_facts.pop("originCode", None)
|
||||
else:
|
||||
out_facts.pop("originCode", None)
|
||||
if dest:
|
||||
out_facts["目的港"] = dest
|
||||
if dest_code:
|
||||
@@ -220,10 +220,34 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if str(src.get("体积") or "").strip():
|
||||
out_facts["体积"] = str(src.get("体积")).strip()
|
||||
if pack_raw:
|
||||
# 包装出站跟销售原文:输入托盘就传托盘,禁止编成 2。
|
||||
out_facts["包装方式"] = pack_raw
|
||||
out_facts["packageType"] = pack_code
|
||||
out_facts["packageType"] = pack_raw
|
||||
if quote_date:
|
||||
out_facts["报价日期"] = quote_date
|
||||
# 非必填有值才投影出站键;贸易条款禁止写成 TMS 条款字段。
|
||||
source_place = str(src.get("货源地") or "").strip()
|
||||
ready = str(src.get("货好时间") or "").strip()
|
||||
pickup = str(src.get("提货地址") or "").strip()
|
||||
delivery = str(src.get("收货地址") or "").strip()
|
||||
trade = str(src.get("贸易条款") or "").strip()
|
||||
if source_place:
|
||||
out_facts["货源地"] = source_place
|
||||
out_facts["cargoSource"] = source_place
|
||||
if ready:
|
||||
out_facts["货好时间"] = ready
|
||||
out_facts["cargoReadyDate"] = ready
|
||||
if pickup:
|
||||
out_facts["提货地址"] = pickup
|
||||
out_facts["placeOfReceipt"] = pickup
|
||||
if delivery:
|
||||
out_facts["收货地址"] = delivery
|
||||
out_facts["deliveryAddress"] = delivery
|
||||
if trade:
|
||||
out_facts["贸易条款"] = trade
|
||||
out_facts.pop("tradeTerms", None)
|
||||
out_facts.pop("transportTerms", None)
|
||||
out_facts.pop("trade_terms", None)
|
||||
|
||||
logger.info(
|
||||
"tms.air.assemble ok=%s origin=%s dest=%s pack=%s missing=%s",
|
||||
|
||||
@@ -37,6 +37,18 @@ class AibotInboundNormalizeTests(unittest.TestCase):
|
||||
self.assertIn("WO202609160009", parsed.content)
|
||||
self.assertEqual(parsed.raw.get("source"), "aibot")
|
||||
|
||||
def test_keeps_aibot_req_id_for_quote_reply(self) -> None:
|
||||
parsed = normalize_aibot_payload(
|
||||
{
|
||||
"sender_id": "air1",
|
||||
"message_id": "m2",
|
||||
"content": "锁舱",
|
||||
"chat_id": "wr_air_1",
|
||||
"aibot_req_id": "cb_lock_1",
|
||||
}
|
||||
)
|
||||
self.assertEqual(parsed.raw.get("aibot_req_id"), "cb_lock_1")
|
||||
|
||||
|
||||
class AibotReplyTests(unittest.TestCase):
|
||||
def test_empty_content_skips(self) -> None:
|
||||
@@ -64,6 +76,7 @@ class AibotReplyTests(unittest.TestCase):
|
||||
self.assertEqual(payload["chatid"], "wr_air_1")
|
||||
self.assertEqual(payload["operation"], "text")
|
||||
self.assertEqual(payload["content"], "附件已保存")
|
||||
self.assertEqual(payload.get("aibot_req_id"), "")
|
||||
self.assertEqual(
|
||||
fake_http.post.call_args.kwargs["headers"]["X-Aibot-Bridge-Token"],
|
||||
"tok",
|
||||
@@ -92,6 +105,46 @@ class AibotReplyTests(unittest.TestCase):
|
||||
self.assertEqual(payload["chatid"], "wr_air_1")
|
||||
self.assertEqual(payload["filename"], "WO1_quote.xlsx")
|
||||
|
||||
def test_at_reply_does_not_post_stream_payload(self) -> None:
|
||||
"""
|
||||
企微 stream 回复会自带「内容由AI生成」和赞踩。
|
||||
@ 当场也只走桥主动发言,不往 response_url 推 stream。
|
||||
"""
|
||||
client = AibotReplyClient("https://example.com/reply")
|
||||
fake_http = MagicMock()
|
||||
fake_http.post.return_value.status_code = 200
|
||||
fake_http.post.return_value.text = '{"status":"sent"}'
|
||||
settings = MagicMock()
|
||||
settings.wecom_aibot_bridge_url = "http://127.0.0.1:8913"
|
||||
settings.wecom_aibot_bridge_token = "tok"
|
||||
with patch("agent.channel.aibot.reply.httpx", fake_http), patch(
|
||||
"agent.channel.aibot.reply.get_settings", return_value=settings
|
||||
):
|
||||
out = client.send_group(chat_id="wr_air_1", content="工单号:WO202609210001")
|
||||
self.assertTrue(out["ok"])
|
||||
payloads = [c.kwargs.get("json") or {} for c in fake_http.post.call_args_list]
|
||||
self.assertFalse(any(p.get("msgtype") == "stream" for p in payloads))
|
||||
self.assertFalse(
|
||||
any(isinstance(p.get("stream"), dict) for p in payloads)
|
||||
)
|
||||
urls = [c.args[0] for c in fake_http.post.call_args_list]
|
||||
self.assertFalse(any("example.com/reply" in str(u) for u in urls))
|
||||
|
||||
def test_at_reply_passes_callback_req_id(self) -> None:
|
||||
client = AibotReplyClient("", callback_req_id="cb_lock_1")
|
||||
fake_http = MagicMock()
|
||||
fake_http.post.return_value.status_code = 200
|
||||
fake_http.post.return_value.text = '{"status":"sent"}'
|
||||
settings = MagicMock()
|
||||
settings.wecom_aibot_bridge_url = "http://127.0.0.1:8913"
|
||||
settings.wecom_aibot_bridge_token = "tok"
|
||||
with patch("agent.channel.aibot.reply.httpx", fake_http), patch(
|
||||
"agent.channel.aibot.reply.get_settings", return_value=settings
|
||||
):
|
||||
client.send_group(chat_id="wr_air_1", content="工单WO1 空运锁舱失败。")
|
||||
payload = fake_http.post.call_args.kwargs["json"]
|
||||
self.assertEqual(payload["aibot_req_id"], "cb_lock_1")
|
||||
|
||||
def test_at_reply_puts_brief_on_same_channel_as_file(self) -> None:
|
||||
"""
|
||||
@ 当场流式回话群里经常只看得见后面的文件。
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
空运询价字段合同:起运港非必填,5 个新非必填,贸易条款不进出站。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
from agent.schema.tms_air_query import assemble_air_query
|
||||
|
||||
|
||||
class AirFieldContractTests(unittest.TestCase):
|
||||
def test_origin_optional_still_ok(self) -> None:
|
||||
result = validate_required_fields(
|
||||
facts={
|
||||
"目的港": "克拉克",
|
||||
"品名": "茶叶",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
},
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertNotIn("起运港", result["missing"])
|
||||
|
||||
def test_dest_still_required(self) -> None:
|
||||
result = validate_required_fields(
|
||||
facts={
|
||||
"品名": "茶叶",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
},
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertIn("目的港", result["missing"])
|
||||
|
||||
def test_assemble_without_origin_ok_and_keeps_optional(self) -> None:
|
||||
out = assemble_air_query(
|
||||
{
|
||||
"目的港": "克拉克",
|
||||
"品名": "茶叶",
|
||||
"件数": "10件",
|
||||
"毛重": "100KGS",
|
||||
"体积": "2CBM",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-09-22",
|
||||
"货源地": "东莞",
|
||||
"货好时间": "随时",
|
||||
"提货地址": "东莞仓库",
|
||||
"收货地址": "马尼拉",
|
||||
"贸易条款": "FOB",
|
||||
}
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertNotIn("起运港", out["missing"])
|
||||
facts = out["facts"]
|
||||
self.assertEqual(facts["货源地"], "东莞")
|
||||
self.assertEqual(facts["货好时间"], "随时")
|
||||
self.assertEqual(facts["提货地址"], "东莞仓库")
|
||||
self.assertEqual(facts["收货地址"], "马尼拉")
|
||||
self.assertEqual(facts["贸易条款"], "FOB")
|
||||
self.assertNotIn("tradeTerms", facts)
|
||||
self.assertNotIn("transportTerms", facts)
|
||||
|
||||
def test_confirm_card_lists_optional_and_has_no_work_order(self) -> None:
|
||||
text = copy.air_confirm_card(
|
||||
{
|
||||
"目的港": "克拉克",
|
||||
"品名": "茶叶",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-09-22",
|
||||
}
|
||||
)
|
||||
self.assertNotIn("工单号", text)
|
||||
self.assertIn("目的地(必填):克拉克", text)
|
||||
self.assertIn("起运港(非必填):", text)
|
||||
self.assertIn("货源地(非必填):", text)
|
||||
self.assertIn("贸易条款(非必填):", text)
|
||||
self.assertIn(copy.AIR_CONFIRM_TAIL, text)
|
||||
|
||||
def test_empty_origin_confirm_then_quote(self) -> None:
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
|
||||
phase = flow.on_text(
|
||||
sender_id="u_no_pol",
|
||||
text="空运",
|
||||
reply=reply,
|
||||
injected_facts={
|
||||
"目的港": "克拉克",
|
||||
"品名": "茶叶",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
},
|
||||
injected_mode="AIR",
|
||||
)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertFalse(flow.session_of("u_no_pol").work_order_no)
|
||||
self.assertIn(copy.AIR_CONFIRM_TAIL, replies[-1])
|
||||
phase = flow.on_text(sender_id="u_no_pol", text="确定", reply=reply)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
self.assertTrue(flow.session_of("u_no_pol").work_order_no)
|
||||
|
||||
def test_assemble_keeps_raw_package_not_code(self) -> None:
|
||||
"""包装出站跟销售原文,禁止编成 1/2/3。"""
|
||||
out = assemble_air_query(
|
||||
{
|
||||
"起运港": "珠海",
|
||||
"目的港": "克拉克",
|
||||
"品名": "茶叶",
|
||||
"件数": "10件",
|
||||
"毛重": "100KGS",
|
||||
"体积": "2CBM",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-09-22",
|
||||
}
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
facts = out["facts"]
|
||||
self.assertEqual(facts["包装方式"], "托盘")
|
||||
self.assertEqual(facts["packageType"], "托盘")
|
||||
self.assertEqual(facts["originCode"], "ZUH")
|
||||
self.assertEqual(facts["destinationCode"], "CRK")
|
||||
self.assertNotIn("origin", facts)
|
||||
self.assertNotIn("destination", facts)
|
||||
|
||||
def test_assemble_missing_dest_still_blocks(self) -> None:
|
||||
out = assemble_air_query(
|
||||
{
|
||||
"品名": "茶叶",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-09-22",
|
||||
}
|
||||
)
|
||||
self.assertFalse(out["ok"])
|
||||
self.assertIn("目的港", out["missing"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -79,7 +79,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
]
|
||||
)
|
||||
|
||||
def _create_air(self, *, sender="sales1", quote=None, status="已报价") -> str:
|
||||
def _create_air(self, *, sender="sales1", quote=None, status="已报价", quote_date="2026-09-16") -> str:
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id=sender,
|
||||
business_line="AIR",
|
||||
@@ -91,7 +91,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装类型": "纸箱",
|
||||
"报价日期": "2026-09-16",
|
||||
"报价日期": quote_date,
|
||||
},
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
@@ -122,6 +122,25 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
def _texts(self) -> list[str]:
|
||||
return [x.get("content") or "" for x in self.groups.sent]
|
||||
|
||||
def test_attach_bot_client_keeps_req_id_for_quote(self) -> None:
|
||||
"""这次 @ 的 req_id 要带给桥,群里才能引用原消息。"""
|
||||
from agent.channel.aibot.reply import AibotReplyClient
|
||||
from agent.handlers.air_group_collab import _attach_bot_client
|
||||
|
||||
engine = AirTextInquiryFlow(ledger=self.ledger, group_client=None)
|
||||
msg = InboundMessage(
|
||||
sender_id="air1",
|
||||
message_id="m_quote",
|
||||
content="锁舱",
|
||||
chat_id="wr_air_1",
|
||||
chat_type="group",
|
||||
raw={"source": "aibot", "aibot_req_id": "cb_lock_1"},
|
||||
)
|
||||
_attach_bot_client(engine, msg)
|
||||
client = engine.group_client()
|
||||
self.assertIsInstance(client, AibotReplyClient)
|
||||
self.assertEqual(client._callback_req_id, "cb_lock_1")
|
||||
|
||||
def test_intent_lock_is_not_hardcoded(self) -> None:
|
||||
self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK)
|
||||
self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE)
|
||||
@@ -495,6 +514,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
self.assertTrue(any("舱位选项:AIR-OPT-01" in t for t in self._texts()))
|
||||
self.assertFalse(any("还没有可锁的 TMS 报价" in t for t in self._texts()))
|
||||
|
||||
def test_manual_quote_without_quote_id_can_lock(self) -> None:
|
||||
@@ -884,6 +904,113 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "not_air")
|
||||
|
||||
def test_lock_fail_tells_operator_and_tms_reason(self) -> None:
|
||||
"""锁舱失败群里要回操作人和 TMS 原因,不能被系统异常兜底盖掉。"""
|
||||
wo = self._create_air()
|
||||
self.ledger._cabin_lock_result = {
|
||||
"ok": False,
|
||||
"error": "lock_fail",
|
||||
"failReason": "该航班日期不存在可锁定的配载记录",
|
||||
}
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_fail", mid="lf1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "system_exception")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn(f"工单{wo} 空运锁舱失败。", joined)
|
||||
self.assertIn("操作人:HangXian", joined)
|
||||
self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined)
|
||||
self.assertIn("已通知IT运维排查,请稍等。", joined)
|
||||
self.assertNotIn("TMS接口异常,IT运维排查中", joined)
|
||||
self.assertNotIn("报价选项不存在", joined)
|
||||
self.assertNotIn("与工单不匹配", joined)
|
||||
|
||||
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
|
||||
"""没有报价也可以锁舱;群里只回 TMS 原因,不要说选项不匹配。"""
|
||||
wo = self._create_air(quote={}, status="询价中")
|
||||
self.ledger._cabin_lock_result = {
|
||||
"ok": False,
|
||||
"error": "lock_fail",
|
||||
"failReason": "该航班日期不存在可锁定的配载记录",
|
||||
}
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_no_quote", mid="lnq1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "system_exception")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined)
|
||||
self.assertNotIn("报价选项不存在", joined)
|
||||
self.assertNotIn("与工单不匹配", joined)
|
||||
|
||||
def test_paused_lock_retries_and_shows_tms_reason(self) -> None:
|
||||
"""上次锁舱失败标了系统异常后,再锁仍要打 TMS,不能只回兜底。"""
|
||||
wo = self._create_air()
|
||||
self.ledger.mark_system_exception(
|
||||
work_order_no=wo,
|
||||
system_exception="TMS",
|
||||
system_exception_reason="空运锁舱接口调用失败",
|
||||
step="tms_lock",
|
||||
payload={},
|
||||
)
|
||||
hits = {"n": 0}
|
||||
orig = self.ledger.lock_cabin
|
||||
|
||||
def counted(**kwargs):
|
||||
hits["n"] += 1
|
||||
return orig(**kwargs)
|
||||
|
||||
self.ledger.lock_cabin = counted # type: ignore[method-assign]
|
||||
self.ledger._cabin_lock_result = {
|
||||
"ok": False,
|
||||
"error": "lock_fail",
|
||||
"failReason": "该航班日期不存在可锁定的配载记录",
|
||||
}
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_paused", mid="lp1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertNotEqual(phase, "system_exception_paused")
|
||||
self.assertGreaterEqual(hits["n"], 1)
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined)
|
||||
self.assertNotIn("TMS接口异常,IT运维排查中", joined)
|
||||
|
||||
def test_release_fail_tells_operator_and_tms_reason(self) -> None:
|
||||
"""释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。"""
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_rl_fail", mid="rf0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.ledger._cabin_release_result = {
|
||||
"ok": False,
|
||||
"error": "release_fail",
|
||||
"failReason": "舱位不存在",
|
||||
}
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_rl_fail", mid="rf1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_release",
|
||||
)
|
||||
self.assertEqual(phase, "system_exception")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn(f"工单{wo} 空运释放舱位失败。", joined)
|
||||
self.assertIn("操作人:HangXian", joined)
|
||||
self.assertIn("释放舱位失败的原因:舱位不存在", joined)
|
||||
self.assertIn("已通知IT运维排查,请稍等。", joined)
|
||||
self.assertNotIn("TMS接口异常,IT运维排查中", joined)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
空运线路报价:一条直接展示,多条要选;航线带中转;费用取该线路回包。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
from agent.channel.h5.air_route_page import render_air_route_page
|
||||
from agent.schema.air_route_options import (
|
||||
apply_selected_option,
|
||||
needs_route_pick,
|
||||
option_fee_lines,
|
||||
parse_air_options,
|
||||
project_selected_quote,
|
||||
route_codes,
|
||||
route_line,
|
||||
)
|
||||
|
||||
|
||||
class AirRouteOptionsTests(unittest.TestCase):
|
||||
def test_single_with_transfer_line(self) -> None:
|
||||
quote = {
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "CAN",
|
||||
"transferAirportCode": "SHA",
|
||||
"destinationAirportCode": "LAX",
|
||||
"salePrice": 110,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 110}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
opts = parse_air_options(quote)
|
||||
self.assertEqual(len(opts), 1)
|
||||
self.assertFalse(needs_route_pick(quote))
|
||||
self.assertEqual(route_line(opts[0], {}), "广州-上海-洛杉矶")
|
||||
self.assertEqual(route_codes(opts[0], {}), "CAN-SHA-LAX")
|
||||
fees = option_fee_lines(opts[0], quote)
|
||||
self.assertEqual(fees[0][0], "运输费")
|
||||
self.assertIn("110", fees[0][1])
|
||||
|
||||
def test_multi_needs_pick(self) -> None:
|
||||
quote = {
|
||||
"isMulti": "2",
|
||||
"airOptions": [
|
||||
{"originAirportCode": "ZUH", "transferAirportCode": "COC", "destinationAirportCode": "CRK", "salePrice": 183370},
|
||||
{"originAirportCode": "ZUH", "transferAirportCode": "ABC", "destinationAirportCode": "CRK", "salePrice": 193370},
|
||||
],
|
||||
}
|
||||
self.assertTrue(needs_route_pick(quote))
|
||||
self.assertEqual(len(parse_air_options(quote)), 2)
|
||||
|
||||
def test_select_writes_origin(self) -> None:
|
||||
option = {"originAirportCode": "SZX", "destinationAirportCode": "KUL"}
|
||||
facts = apply_selected_option({"目的港": "吉隆坡"}, option)
|
||||
self.assertEqual(facts["起运港"], "深圳")
|
||||
self.assertEqual(facts["originCode"], "SZX")
|
||||
|
||||
def test_multi_quote_opens_h5_then_select_writes_origin(self) -> None:
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
replies: list[str] = []
|
||||
extras: list[dict] = []
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
replies.append(text)
|
||||
extras.append(dict(extra or {}))
|
||||
|
||||
facts = {
|
||||
"目的港": "克拉克",
|
||||
"品名": "茶叶",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
"tms_multi": "1",
|
||||
}
|
||||
phase = flow.on_text(
|
||||
sender_id="u_multi",
|
||||
text="空运",
|
||||
reply=reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="AIR",
|
||||
)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
phase = flow.on_text(sender_id="u_multi", text="确定", reply=reply)
|
||||
self.assertEqual(phase, "wait_air_route")
|
||||
self.assertTrue(any("已查到" in x and "条线路" in x for x in replies))
|
||||
phase = flow.select_air_route(sender_id="u_multi", index=1, reply=reply)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
sess = flow.session_of("u_multi")
|
||||
self.assertEqual(sess.facts.get("起运港"), "深圳")
|
||||
self.assertIn("SZX-CRK", "\n".join(replies[-2:]))
|
||||
|
||||
def test_reselect_after_adopt_voids_old_file(self) -> None:
|
||||
"""采用后再改选:旧报价单作废,回到方案卡重新采用。"""
|
||||
ledger = MemoryLedger()
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
|
||||
facts = {
|
||||
"目的港": "克拉克",
|
||||
"品名": "茶叶",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
"tms_multi": "1",
|
||||
}
|
||||
phase = flow.on_text(
|
||||
sender_id="u_void",
|
||||
text="空运",
|
||||
reply=reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="AIR",
|
||||
)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertEqual(
|
||||
flow.on_text(sender_id="u_void", text="确定", reply=reply),
|
||||
"wait_air_route",
|
||||
)
|
||||
self.assertEqual(flow.select_air_route(sender_id="u_void", index=0, reply=reply), "wait_collab")
|
||||
self.assertEqual(
|
||||
flow.on_button(sender_id="u_void", action=copy.BTN_SKIP_COLLAB, reply=reply),
|
||||
"wait_adopt",
|
||||
)
|
||||
self.assertEqual(
|
||||
flow.on_button(sender_id="u_void", action=copy.BTN_ADOPT_EXCEL, reply=reply),
|
||||
"wait_deal",
|
||||
)
|
||||
wo = flow.session_of("u_void").work_order_no
|
||||
self.assertTrue(ledger.get_ticket(work_order_no=wo).quote_file_ready)
|
||||
phase = flow.select_air_route(sender_id="u_void", index=1, reply=reply)
|
||||
self.assertEqual(phase, "wait_adopt")
|
||||
self.assertFalse(ledger.get_ticket(work_order_no=wo).quote_file_ready)
|
||||
view = ledger.get_for_agent(work_order_no=wo, sender_id="u_void")
|
||||
self.assertEqual(view.get("wait_phase"), "wait_adopt")
|
||||
self.assertEqual(flow.session_of("u_void").facts.get("起运港"), "深圳")
|
||||
self.assertTrue(any("报价方案" in x or "生成Excel" in x for x in replies[-3:]))
|
||||
|
||||
def test_empty_route_fees_do_not_borrow_other_quote_lines(self) -> None:
|
||||
"""TMS 该线路费用列表为空时,禁止套整单或其他线路的空运费。"""
|
||||
quote = {
|
||||
"total": "183370.00",
|
||||
"freight": "183370.00",
|
||||
"currency": "CNY",
|
||||
"fee_lines": [
|
||||
{
|
||||
"name": "空运费",
|
||||
"amount": "183370.00",
|
||||
"currency": "CNY",
|
||||
"unit_price": "11.00",
|
||||
}
|
||||
],
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "ZUH",
|
||||
"transferAirportCode": "CHG",
|
||||
"destinationAirportCode": "CRK",
|
||||
"salePrice": 183370,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11},
|
||||
{"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6},
|
||||
{"chargeName": "人工费", "currency": "CNY", "unitPrice": 123},
|
||||
],
|
||||
},
|
||||
{
|
||||
"originAirportCode": "SZX",
|
||||
"transferAirportCode": "",
|
||||
"destinationAirportCode": "CRK",
|
||||
"salePrice": 200040,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
opts = parse_air_options(quote)
|
||||
zuh = option_fee_lines(opts[0], quote)
|
||||
self.assertEqual([name for name, _ in zuh], ["运输费", "提送货费", "人工费"])
|
||||
szx = option_fee_lines(opts[1], quote)
|
||||
self.assertEqual(szx, [])
|
||||
html = render_air_route_page(
|
||||
{"work_order_no": "WO202609220009", "facts": {"目的港": "克拉克"}, "quote": quote}
|
||||
)
|
||||
self.assertIn("运输费", html)
|
||||
self.assertIn("深圳-克拉克", html)
|
||||
self.assertNotIn("空运费", html)
|
||||
self.assertIn("200040", html)
|
||||
self.assertIn("本线路未返回费用明细", html)
|
||||
self.assertIn("直达", html)
|
||||
self.assertIn("中转", html)
|
||||
projected = project_selected_quote(quote, opts[1])
|
||||
self.assertEqual(projected.get("fee_lines"), [])
|
||||
self.assertIn("200040", str(projected.get("total") or ""))
|
||||
self.assertEqual(copy.quote_fee_lines(projected), [])
|
||||
detail = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202609220009",
|
||||
quote=projected,
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertNotIn("空运费", detail)
|
||||
self.assertIn("总销售价", detail)
|
||||
self.assertIn("200040", detail)
|
||||
|
||||
def test_h5_page_lists_route_and_fees(self) -> None:
|
||||
from agent.channel.h5.air_route_page import render_air_route_page
|
||||
from agent.channel.h5.air_route_store import build_air_route_url
|
||||
|
||||
html = render_air_route_page(
|
||||
{
|
||||
"work_order_no": "WO202609220001",
|
||||
"facts": {"目的港": "克拉克"},
|
||||
"quote": {
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "ZUH",
|
||||
"transferAirportCode": "CAN",
|
||||
"destinationAirportCode": "CRK",
|
||||
"salePrice": 183370,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 180000}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertIn("珠海-广州-克拉克", html)
|
||||
self.assertIn("运输费", html)
|
||||
self.assertIn("总销售价", html)
|
||||
self.assertIn("class='opt'", html)
|
||||
self.assertIn("name='index'", html)
|
||||
self.assertNotIn("userid", html.lower())
|
||||
self.assertNotIn("sender_id", html)
|
||||
url = build_air_route_url("tok-abc")
|
||||
self.assertIn("/air-routes/tok-abc", url)
|
||||
self.assertNotIn("userid", url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -32,6 +32,14 @@ from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
|
||||
|
||||
def _drive_until_quoted(flow, *, sender_id: str, reply, **kwargs) -> str:
|
||||
"""必填齐时先出核对卡,再自动回确定。"""
|
||||
phase = flow.on_text(sender_id=sender_id, reply=reply, **kwargs)
|
||||
if phase == "wait_confirm":
|
||||
phase = flow.on_text(sender_id=sender_id, text="确定", reply=reply)
|
||||
return phase
|
||||
|
||||
|
||||
COMPLETE_AIR = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
@@ -56,6 +64,19 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.replies.append(text)
|
||||
self.extras.append(dict(extra) if extra else {})
|
||||
|
||||
def _on_text(self, **kwargs):
|
||||
"""单测默认:核对卡出现后自动回确定,对齐销售点确定后的旧断言。"""
|
||||
reply = kwargs.get("reply") or self._reply
|
||||
kwargs["reply"] = reply
|
||||
phase = self.flow.on_text(**kwargs)
|
||||
if phase == "wait_confirm":
|
||||
phase = self.flow.on_text(
|
||||
sender_id=kwargs.get("sender_id"),
|
||||
text="确定",
|
||||
reply=reply,
|
||||
)
|
||||
return phase
|
||||
|
||||
def test_clarify_shows_known_and_examples(self) -> None:
|
||||
text = copy.ask_clarify(
|
||||
facts={
|
||||
@@ -69,24 +90,17 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
missing_keys=["毛重", "体积", "包装方式"],
|
||||
transport_mode="空运",
|
||||
)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"\n".join(
|
||||
[
|
||||
"请确认并补充以下信息:",
|
||||
"运输方式:空运",
|
||||
"起运地:珠海",
|
||||
"目的地:克拉克",
|
||||
"品名:茶叶",
|
||||
"件数:10件",
|
||||
"",
|
||||
"待补充:",
|
||||
"重量:(如:100KGS)",
|
||||
"体积:(如:2CBM)",
|
||||
"包装类型:(如:托盘)",
|
||||
]
|
||||
),
|
||||
)
|
||||
self.assertIn("请确认并补充以下信息:", text)
|
||||
self.assertIn("运输方式:空运", text)
|
||||
self.assertIn("起运地:珠海", text)
|
||||
self.assertIn("目的地:克拉克", text)
|
||||
self.assertIn("品名:茶叶", text)
|
||||
self.assertIn("件数:10件", text)
|
||||
self.assertIn("待补充:", text)
|
||||
self.assertIn("重量:(如:100KGS)", text)
|
||||
self.assertIn("体积:(如:2CBM)", text)
|
||||
self.assertIn("包装类型:(如:托盘)", text)
|
||||
self.assertIn("货源地(非必填)", text)
|
||||
|
||||
def test_inquiry_card_matches_sales_format(self) -> None:
|
||||
text = copy.inquiry_card(
|
||||
@@ -130,7 +144,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("报价日期", result["missing"])
|
||||
|
||||
def test_fly_is_air_and_followup_keeps_mode(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u_fly",
|
||||
text="从南京飞吉隆坡,9月14日茶叶,托盘,4.2cbm,1.5KG",
|
||||
reply=self._reply,
|
||||
@@ -139,7 +153,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("运输方式:空运", self.replies[-1])
|
||||
self.assertNotIn("请补充运输方式", self.replies[-1])
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u_fly",
|
||||
text="10件",
|
||||
reply=self._reply,
|
||||
@@ -177,7 +191,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(sess.facts.get("包装方式"), "托盘")
|
||||
|
||||
def test_missing_mode_asks_transport(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u1",
|
||||
text="帮我看看价格",
|
||||
reply=self._reply,
|
||||
@@ -190,7 +204,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
def test_pack_bulk_cargo_accepted_as_user_said(self) -> None:
|
||||
facts = dict(COMPLETE_AIR)
|
||||
facts["包装方式"] = "散货"
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u_bulk",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -204,7 +218,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(self.flow.session_of("u_bulk").facts["包装方式"], "散货")
|
||||
|
||||
def test_oral_kgs_cbm_not_asked_again(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u_kgs",
|
||||
text="空运 茶叶 10件 100KGS 2CBM",
|
||||
reply=self._reply,
|
||||
@@ -222,10 +236,10 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(sess.facts["体积"], "2CBM")
|
||||
self.assertEqual(sess.facts["originCode"], "ZUH")
|
||||
self.assertEqual(sess.facts["destinationCode"], "CRK")
|
||||
self.assertEqual(sess.facts["packageType"], "2")
|
||||
self.assertEqual(sess.facts["packageType"], "托盘")
|
||||
|
||||
def test_missing_fields_prompt(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u1",
|
||||
text="空运询价",
|
||||
reply=self._reply,
|
||||
@@ -245,7 +259,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("报价日期:(如", last)
|
||||
|
||||
def test_complete_air_hit_then_skip_adopt_excel_deal(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u1",
|
||||
text="空运完整",
|
||||
reply=self._reply,
|
||||
@@ -299,7 +313,10 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
]
|
||||
self.assertEqual(len(scheme_cards), 1)
|
||||
card = scheme_cards[0]["template_card"]
|
||||
self.assertIn("空运费:USD 1,200", card["quote_area"]["quote_text"])
|
||||
self.assertEqual(
|
||||
card["quote_area"]["quote_text"],
|
||||
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
|
||||
)
|
||||
self.assertEqual(len(card["button_list"]), 3)
|
||||
self.assertEqual(
|
||||
[x["text"] for x in card["button_list"]],
|
||||
@@ -371,7 +388,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
"包装方式": "散货",
|
||||
"报价日期": "2026-09-16",
|
||||
}
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u_nkg",
|
||||
text="南京飞吉隆坡,9月16日茶叶,散货,4.2cbm,1.5KG,10件",
|
||||
reply=self._reply,
|
||||
@@ -414,7 +431,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
facts["起运港"] = "阿拉伯"
|
||||
facts["目的港"] = "广州"
|
||||
flow = AirTextInquiryFlow(_Spy())
|
||||
phase = flow.on_text(
|
||||
phase = _drive_until_quoted(
|
||||
flow,
|
||||
sender_id="u_arab",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -439,7 +457,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
def test_unknown_airport_no_card(self) -> None:
|
||||
facts = dict(COMPLETE_AIR)
|
||||
facts["目的港"] = "歧义港"
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u2c",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -454,7 +472,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
def test_tms_miss_copy(self) -> None:
|
||||
facts = dict(COMPLETE_AIR)
|
||||
facts["tms_force"] = "1002"
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u2",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -535,7 +553,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
try:
|
||||
with patch("agent.channel.queue.get_message_store", return_value=store):
|
||||
flow = AirTextInquiryFlow(_Led())
|
||||
phase = flow.on_text(
|
||||
phase = _drive_until_quoted(
|
||||
flow,
|
||||
sender_id="u_ord",
|
||||
text="空运",
|
||||
reply=reply,
|
||||
@@ -566,7 +585,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
}
|
||||
|
||||
flow = AirTextInquiryFlow(_Skip())
|
||||
phase = flow.on_text(
|
||||
phase = _drive_until_quoted(
|
||||
flow,
|
||||
sender_id="u_skip",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -580,7 +600,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("查价参数尚未齐全", joined)
|
||||
|
||||
def test_same_content_second_ticket(self) -> None:
|
||||
self.flow.on_text(
|
||||
self._on_text(
|
||||
sender_id="u3",
|
||||
text="第一次",
|
||||
reply=self._reply,
|
||||
@@ -589,7 +609,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
)
|
||||
no1 = self.flow.session_of("u3").work_order_no
|
||||
self.replies.clear()
|
||||
self.flow.on_text(
|
||||
self._on_text(
|
||||
sender_id="u4",
|
||||
text="第二次相同",
|
||||
reply=self._reply,
|
||||
@@ -606,7 +626,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotEqual(no1, no2)
|
||||
|
||||
def test_reject_then_adjust_prompt(self) -> None:
|
||||
self.flow.on_text(
|
||||
self._on_text(
|
||||
sender_id="u5",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -619,7 +639,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("提货费加 10%", self.replies[-1])
|
||||
|
||||
def test_private_adjust_percent_refreshes_card(self) -> None:
|
||||
self.flow.on_text(
|
||||
self._on_text(
|
||||
sender_id="u-adj",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -627,7 +647,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
injected_mode="AIR",
|
||||
)
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="u-adj",
|
||||
text="空运费加 10%",
|
||||
reply=self._reply,
|
||||
@@ -647,7 +667,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("1320", scheme)
|
||||
|
||||
def test_adopt_pdf(self) -> None:
|
||||
self.flow.on_text(
|
||||
self._on_text(
|
||||
sender_id="u6",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -676,6 +696,17 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
flow=self.flow,
|
||||
store=store,
|
||||
)
|
||||
if phase == "wait_confirm":
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(
|
||||
sender_id="u7",
|
||||
message_id="m7b",
|
||||
content="确定",
|
||||
msg_type="text",
|
||||
),
|
||||
flow=self.flow,
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
pending = [i for i in store._outbox.values()]
|
||||
self.assertGreaterEqual(len(pending), 2)
|
||||
@@ -684,7 +715,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertFalse(any("【询价卡片】" in i.content for i in pending))
|
||||
|
||||
def test_wrong_button_discarded(self) -> None:
|
||||
self.flow.on_text(
|
||||
self._on_text(
|
||||
sender_id="u8",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -705,7 +736,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
|
||||
flow = AirTextInquiryFlow(_FailCreate())
|
||||
replies: list[str] = []
|
||||
phase = flow.on_text(
|
||||
phase = _drive_until_quoted(
|
||||
flow,
|
||||
sender_id="u9",
|
||||
text="空运",
|
||||
reply=replies.append,
|
||||
@@ -775,8 +807,10 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("horizontal_content_list", card)
|
||||
quote_text = card["quote_area"]["quote_text"]
|
||||
self.assertNotIn("title", card["quote_area"])
|
||||
self.assertIn("空运费:USD 1,200", quote_text)
|
||||
self.assertIn("总销售价:USD 1,280", quote_text)
|
||||
self.assertEqual(
|
||||
quote_text,
|
||||
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
|
||||
)
|
||||
self.assertNotIn("时效:", quote_text)
|
||||
numeric = copy.tms_hit_wecom_payload(
|
||||
work_order_no="WO202609140048",
|
||||
@@ -788,8 +822,10 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
"fee_lines": [{"name": "空运费", "amount": "14669.60", "currency": "CNY"}],
|
||||
},
|
||||
)["template_card"]["quote_area"]["quote_text"]
|
||||
self.assertIn("空运费:CNY 14669.60", numeric)
|
||||
self.assertIn("总销售价:CNY 14669.60", numeric)
|
||||
self.assertEqual(
|
||||
numeric,
|
||||
"总销售价:CNY 14669.60\n共1项费用,详见上一条明细",
|
||||
)
|
||||
self.assertEqual(copy.money_text("14669.60", ""), "14669.60")
|
||||
self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text)
|
||||
sub = card["sub_title_text"]
|
||||
@@ -801,6 +837,132 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
|
||||
self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False))
|
||||
|
||||
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
|
||||
"""空运多项费用跟海运一样:完整明细走文本,卡片引用条不得半截。"""
|
||||
long_quote = {
|
||||
"total": "142998.53",
|
||||
"currency": "CNY",
|
||||
"eta": "3天",
|
||||
"valid_until": "2026-09-30",
|
||||
"source_label": "TMS标准报价",
|
||||
"fee_lines": [
|
||||
{"name": "操作费", "amount": "1220.00", "currency": "CNY"},
|
||||
{"name": "箱单费", "amount": "50.00", "currency": "CNY"},
|
||||
{"name": "文件费", "amount": "450.00", "currency": "CNY"},
|
||||
{"name": "电放费", "amount": "100.00", "currency": "CNY"},
|
||||
{"name": "更改费", "amount": "450.00", "currency": "CNY"},
|
||||
{"name": "空运费", "amount": "1250.00", "currency": "USD"},
|
||||
{"name": "目的港操作费", "amount": "240.00", "currency": "USD"},
|
||||
],
|
||||
}
|
||||
text = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202609220005",
|
||||
quote=long_quote,
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertIn("TMS标准报价", text)
|
||||
self.assertIn("WO202609220005价格明细", text)
|
||||
self.assertIn("操作费:CNY 1220.00", text)
|
||||
self.assertIn("空运费:USD 1250.00", text)
|
||||
self.assertIn("总销售价:CNY 142998.53", text)
|
||||
self.assertNotIn("时效", text)
|
||||
card_text = copy.quote_area_safe_text(long_quote, business_line="AIR")
|
||||
self.assertIn("总销售价:CNY 142998.53", card_text)
|
||||
self.assertIn("详见上一条明细", card_text)
|
||||
self.assertNotIn("操作费:CNY 1220.00", card_text)
|
||||
payload = copy.tms_hit_wecom_payload(
|
||||
work_order_no="WO202609220005", quote=long_quote
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["template_card"]["quote_area"]["quote_text"], card_text
|
||||
)
|
||||
|
||||
def test_air_quote_area_always_summary_and_transfer_on_detail_and_scheme(self) -> None:
|
||||
"""
|
||||
空运引用条即使只有 3 项也不能塞费用,企微仍会裁成半截。
|
||||
明细和方案卡航线必须带中转三字码。
|
||||
"""
|
||||
quote = {
|
||||
"total": "183370",
|
||||
"currency": "CNY",
|
||||
"source_label": "TMS标准报价",
|
||||
"selected_air_option": {
|
||||
"originAirportCode": "ZUH",
|
||||
"transferAirportCode": "CAN",
|
||||
"destinationAirportCode": "CRK",
|
||||
},
|
||||
"fee_lines": [
|
||||
{"name": "运输费", "amount": "11.0", "currency": "CNY"},
|
||||
{"name": "提送货费", "amount": "6.0", "currency": "CNY"},
|
||||
{"name": "人工费", "amount": "123.0", "currency": "CNY"},
|
||||
],
|
||||
}
|
||||
card_text = copy.quote_area_safe_text(quote, business_line="AIR")
|
||||
self.assertEqual(
|
||||
card_text,
|
||||
"总销售价:CNY 183370\n共3项费用,详见上一条明细",
|
||||
)
|
||||
payload = copy.tms_hit_wecom_payload(
|
||||
work_order_no="WO202609220006", quote=quote
|
||||
)
|
||||
self.assertEqual(payload["template_card"]["quote_area"]["quote_text"], card_text)
|
||||
self.assertNotIn("运输费", card_text)
|
||||
detail = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202609220006",
|
||||
quote=quote,
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertIn("ZUH-CAN-CRK", detail)
|
||||
self.assertIn("运输费:CNY 11.0", detail)
|
||||
self.assertIn("总销售价:CNY 183370", detail)
|
||||
rows = {
|
||||
x["keyname"]: x["value"]
|
||||
for x in copy.scheme_horizontal_rows(
|
||||
work_order_no="WO202609220006",
|
||||
facts={"起运港": "珠海", "目的港": "克拉克", "品名": "茶叶"},
|
||||
quote=quote,
|
||||
transport_mode="空运",
|
||||
)
|
||||
}
|
||||
self.assertEqual(rows["航线"], "空运ZUH→CAN→CRK")
|
||||
scheme = copy.scheme_wecom_payload(
|
||||
work_order_no="WO202609220006",
|
||||
facts={"起运港": "珠海", "目的港": "克拉克", "品名": "茶叶"},
|
||||
quote=quote,
|
||||
transport_mode="空运",
|
||||
)
|
||||
self.assertEqual(
|
||||
scheme["template_card"]["quote_area"]["quote_text"],
|
||||
"总销售价:CNY 183370\n共3项费用,详见上一条明细",
|
||||
)
|
||||
|
||||
def test_air_hit_sends_detail_text_before_card(self) -> None:
|
||||
"""有价必须先发出价格明细文字,再发卡,避免卡上半截费用。"""
|
||||
steps: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
kind = "card" if extra else "text"
|
||||
steps.append(kind)
|
||||
return f"out-{len(steps)}"
|
||||
|
||||
def wait(out_id: str, *, work_order_no: str, what: str) -> None:
|
||||
steps.append(f"wait:{out_id}")
|
||||
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
flow._wait_outbound_sent = wait # type: ignore[method-assign]
|
||||
flow._detail_before_card_gap_sec = 0.0
|
||||
flow._emit_air_hit(
|
||||
reply,
|
||||
work_order_no="WO202609220005",
|
||||
quote={
|
||||
"total": "142998.53",
|
||||
"currency": "CNY",
|
||||
"source_label": "TMS标准报价",
|
||||
"fee_lines": [{"name": "操作费", "amount": "1220", "currency": "CNY"}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(steps[:3], ["text", "wait:out-1", "card"])
|
||||
|
||||
def test_scheme_card_payload_has_quote_and_buttons(self) -> None:
|
||||
quote = {
|
||||
"total": "USD 1,280",
|
||||
@@ -843,13 +1005,14 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
)
|
||||
quote_text = card["quote_area"]["quote_text"]
|
||||
self.assertNotIn("title", card["quote_area"])
|
||||
self.assertIn("空运费:USD 1,200", quote_text)
|
||||
self.assertIn("总销售价:USD 1,280", quote_text)
|
||||
self.assertEqual(
|
||||
quote_text,
|
||||
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
|
||||
)
|
||||
self.assertNotIn("sub_title_text", card)
|
||||
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
|
||||
self.assertEqual(rows["工单号"], "WO202609140001")
|
||||
self.assertIn("上海", rows["航线"])
|
||||
self.assertIn("洛杉矶", rows["航线"])
|
||||
self.assertEqual(rows["航线"], "空运SHA→LAX")
|
||||
self.assertIn("普货", rows["货物"])
|
||||
self.assertIn("2.5", rows["货物"])
|
||||
self.assertEqual(rows["时效"], "3-5 天")
|
||||
@@ -953,7 +1116,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
replies_a.append(text)
|
||||
_ = extra
|
||||
|
||||
flow.on_text(
|
||||
_drive_until_quoted(
|
||||
flow,
|
||||
sender_id="hist-a",
|
||||
text="空运",
|
||||
reply=_keep_a,
|
||||
@@ -969,7 +1133,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
replies_b.append(text)
|
||||
_ = extra
|
||||
|
||||
flow.on_text(
|
||||
_drive_until_quoted(
|
||||
flow,
|
||||
sender_id="hist-b",
|
||||
text="空运",
|
||||
reply=_keep_b,
|
||||
@@ -988,7 +1153,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
replies_c.append(text)
|
||||
_ = extra
|
||||
|
||||
flow.on_text(
|
||||
_drive_until_quoted(
|
||||
flow,
|
||||
sender_id="hist-c",
|
||||
text="空运",
|
||||
reply=_keep_c,
|
||||
@@ -1023,7 +1189,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
copy.work_order_from_card_meta(msg.raw, text=msg.content),
|
||||
"WO202609140099",
|
||||
)
|
||||
self.flow.on_text(
|
||||
self._on_text(
|
||||
sender_id="WuJiLin",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -1033,7 +1199,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
wo = self.flow.session_of("WuJiLin").work_order_no
|
||||
xml_live = xml.replace("WO202609140099", wo)
|
||||
live = parse_plain_xml(xml_live, default_agent_id="1000010")
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="WuJiLin",
|
||||
text=live.content,
|
||||
reply=self._reply,
|
||||
@@ -1047,7 +1213,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
facts_a["品名"] = "茶叶"
|
||||
facts_b = dict(COMPLETE_AIR)
|
||||
facts_b["品名"] = "服装"
|
||||
self.flow.on_text(
|
||||
self._on_text(
|
||||
sender_id="card-owner",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -1056,7 +1222,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
)
|
||||
wo1 = self.flow.session_of("card-owner").work_order_no
|
||||
self.assertTrue(wo1)
|
||||
self.flow.on_text(
|
||||
self._on_text(
|
||||
sender_id="card-owner",
|
||||
text="空运",
|
||||
reply=self._reply,
|
||||
@@ -1068,7 +1234,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotEqual(wo1, wo2)
|
||||
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
phase = self._on_text(
|
||||
sender_id="card-owner",
|
||||
text=f"skip_collab:{wo1}",
|
||||
reply=self._reply,
|
||||
@@ -1096,7 +1262,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
|
||||
def _open(uid: str) -> str:
|
||||
flow.on_text(
|
||||
_drive_until_quoted(
|
||||
flow,
|
||||
sender_id=uid,
|
||||
text="空运",
|
||||
reply=lambda *_a, **_k: None,
|
||||
@@ -1136,7 +1303,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
ledger = MemoryLedger()
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
replies: list[str] = []
|
||||
flow.on_text(
|
||||
_drive_until_quoted(
|
||||
flow,
|
||||
sender_id="u-tpl-0",
|
||||
text="空运",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
@@ -1181,7 +1349,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
)
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
replies: list[str] = []
|
||||
flow.on_text(
|
||||
_drive_until_quoted(
|
||||
flow,
|
||||
sender_id="u-tpl-k",
|
||||
text="空运",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
@@ -1207,7 +1376,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
ny["目的港"] = "纽约"
|
||||
flow2 = AirTextInquiryFlow(ledger)
|
||||
replies2: list[str] = []
|
||||
flow2.on_text(
|
||||
_drive_until_quoted(
|
||||
flow2,
|
||||
sender_id="u-tpl-d",
|
||||
text="空运",
|
||||
reply=lambda t, extra=None: replies2.append(t),
|
||||
@@ -1244,7 +1414,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
ledger.render_quote = _render # type: ignore[method-assign]
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
replies: list[str] = []
|
||||
flow.on_text(
|
||||
_drive_until_quoted(
|
||||
flow,
|
||||
sender_id="u-pdf-q",
|
||||
text="空运",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
@@ -1287,7 +1458,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
ledger.render_quote = _render # type: ignore[method-assign]
|
||||
http = AirTextInquiryFlow(ledger)
|
||||
replies: list[str] = []
|
||||
http.on_text(
|
||||
_drive_until_quoted(
|
||||
http,
|
||||
sender_id="u-pdf-x",
|
||||
text="空运",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
@@ -1334,7 +1506,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
|
||||
ledger.render_quote = _render # type: ignore[method-assign]
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
flow.on_text(
|
||||
_drive_until_quoted(
|
||||
flow,
|
||||
sender_id="u-pdf-d",
|
||||
text="空运",
|
||||
reply=lambda t, extra=None: None,
|
||||
|
||||
@@ -40,6 +40,18 @@ COMPLETE_AIR = {
|
||||
}
|
||||
|
||||
|
||||
def _until_quoted(flow, **kwargs) -> str:
|
||||
"""必填齐先出核对卡,单测自动回确定再建单查价。"""
|
||||
phase = flow.on_text(**kwargs)
|
||||
if phase == "wait_confirm":
|
||||
phase = flow.on_text(
|
||||
sender_id=kwargs["sender_id"],
|
||||
text="确定",
|
||||
reply=kwargs["reply"],
|
||||
)
|
||||
return phase
|
||||
|
||||
|
||||
class ContinueTicketTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
from agent.redis_coord.runtime import shutdown_redis_runtime
|
||||
@@ -134,7 +146,8 @@ class ContinueTicketTests(unittest.TestCase):
|
||||
replies.append(text)
|
||||
extras.append(dict(extra) if extra else {})
|
||||
|
||||
flow.on_text(
|
||||
_until_quoted(
|
||||
flow,
|
||||
sender_id="u1",
|
||||
text="空运",
|
||||
reply=reply,
|
||||
@@ -266,7 +279,8 @@ class ContinueTicketTests(unittest.TestCase):
|
||||
sink.append(text)
|
||||
_ = extra
|
||||
|
||||
live.on_text(
|
||||
_until_quoted(
|
||||
live,
|
||||
sender_id="u5",
|
||||
text="空运",
|
||||
reply=reply,
|
||||
@@ -277,7 +291,8 @@ class ContinueTicketTests(unittest.TestCase):
|
||||
live.on_button(sender_id="u5", action=copy.BTN_SKIP_COLLAB, reply=reply)
|
||||
other = dict(COMPLETE_AIR)
|
||||
other["品名"] = "服装"
|
||||
live.on_text(
|
||||
_until_quoted(
|
||||
live,
|
||||
sender_id="u5",
|
||||
text="空运",
|
||||
reply=reply,
|
||||
|
||||
@@ -99,7 +99,7 @@ class OralExtractTests(unittest.TestCase):
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["facts"]["originCode"], "ZUH")
|
||||
self.assertEqual(out["facts"]["destinationCode"], "CRK")
|
||||
self.assertEqual(out["facts"]["packageType"], "2")
|
||||
self.assertEqual(out["facts"]["packageType"], "托盘")
|
||||
self.assertEqual(out["facts"]["件数"], "10件")
|
||||
|
||||
bulk = assemble_air_query(
|
||||
@@ -317,6 +317,8 @@ class OralExtractTests(unittest.TestCase):
|
||||
reply=reply,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
if phase == "wait_confirm":
|
||||
phase = flow.on_text(sender_id="oral1", text="确定", reply=reply)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
joined = "\n".join(replies)
|
||||
self.assertIn("工单号:", joined)
|
||||
|
||||
@@ -12,10 +12,27 @@ if ROOT not in sys.path:
|
||||
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.ledger.http_ledger import _unwrap
|
||||
from agent.ledger import LedgerResponse
|
||||
from agent.ledger.http_ledger import HttpLedger, _unwrap
|
||||
from agent.schema.fingerprint import ascii_idempotency_key, inquiry_fingerprint
|
||||
|
||||
|
||||
class _FakeLedgerClient:
|
||||
"""只记路径,不打 8180。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, dict]] = []
|
||||
self.timeout_seconds = 10
|
||||
|
||||
def request_json(self, method, path, *, json_body=None, idempotency_key=""):
|
||||
self.calls.append((method, path, dict(json_body or {})))
|
||||
_ = idempotency_key
|
||||
return LedgerResponse(ok=True, status_code=200, data={"success": True, "result": {"ok": True}})
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class HttpLedgerParseTests(unittest.TestCase):
|
||||
def test_unwrap_jeecg_ok(self) -> None:
|
||||
inner = _unwrap({"success": True, "result": {"ok": True, "has_price": True, "classification": "HAS_PRICE"}})
|
||||
@@ -45,6 +62,27 @@ class HttpLedgerParseTests(unittest.TestCase):
|
||||
self.assertFalse(headers.get("X-Access-Token"))
|
||||
client.close()
|
||||
|
||||
def test_update_facts_posts_agent_path(self) -> None:
|
||||
fake = _FakeLedgerClient()
|
||||
out = HttpLedger(client=fake).update_facts(
|
||||
work_order_no="WO202609220001", facts={"起运港": "深圳"}
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
method, path, body = fake.calls[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertEqual(path, "/inquiry/agent/ticket/updateFacts")
|
||||
self.assertEqual(body["workOrderNo"], "WO202609220001")
|
||||
self.assertEqual(body["facts"]["起运港"], "深圳")
|
||||
|
||||
def test_void_quote_files_posts_agent_path(self) -> None:
|
||||
fake = _FakeLedgerClient()
|
||||
out = HttpLedger(client=fake).void_quote_files(work_order_no="WO202609220001")
|
||||
self.assertTrue(out.get("ok"))
|
||||
method, path, body = fake.calls[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertEqual(path, "/inquiry/agent/ticket/voidQuoteFiles")
|
||||
self.assertEqual(body["workOrderNo"], "WO202609220001")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -43,6 +43,8 @@ class TestSystemExceptionCopy(unittest.TestCase):
|
||||
self.assertEqual(copy.system_exception_recovery(STEP_TMS_QUOTE), copy.SYS_EXC_RECOVERY_TMS)
|
||||
self.assertEqual(copy.system_exception_recovery(STEP_LLM), copy.SYS_EXC_RECOVERY_LLM)
|
||||
self.assertEqual(copy.system_exception_recovery(STEP_TMS_CABIN), copy.SYS_EXC_RECOVERY_CABIN)
|
||||
self.assertEqual(copy.system_exception_recovery(copy.STEP_TMS_LOCK), copy.SYS_EXC_RECOVERY_CABIN)
|
||||
self.assertEqual(copy.system_exception_recovery(copy.STEP_TMS_RELEASE), copy.SYS_EXC_RECOVERY_CABIN)
|
||||
self.assertEqual(copy.system_exception_recovery(STEP_GROUP), copy.SYS_EXC_RECOVERY_GROUP)
|
||||
|
||||
def test_impact_by_step(self) -> None:
|
||||
@@ -52,6 +54,99 @@ class TestSystemExceptionCopy(unittest.TestCase):
|
||||
self.assertEqual(copy.system_exception_impact(STEP_TMS_CABIN), copy.IMPACT_TMS_CABIN)
|
||||
self.assertEqual(copy.system_exception_impact(STEP_GROUP), copy.IMPACT_GROUP)
|
||||
|
||||
def test_cabin_impact_lock_and_release_are_separate(self) -> None:
|
||||
"""锁舱失败和释放失败要分开说,不能再写成「锁舱/释放暂停」。"""
|
||||
self.assertEqual(
|
||||
copy.system_exception_impact(copy.STEP_TMS_LOCK),
|
||||
"锁舱失败,工单状态保持不变",
|
||||
)
|
||||
self.assertEqual(
|
||||
copy.system_exception_impact(copy.STEP_TMS_RELEASE),
|
||||
"释放舱位失败,工单状态保持不变",
|
||||
)
|
||||
|
||||
def test_alert_title_never_has_test_prefix(self) -> None:
|
||||
"""系统异常通知标题不要 [test] / 【test】。"""
|
||||
payload = copy.system_exception_alert_payload(
|
||||
work_order_no="WO202609210002",
|
||||
exception_type=TYPE_TMS,
|
||||
service="空运锁舱",
|
||||
error_code="LOCK_FAIL",
|
||||
step=copy.STEP_TMS_LOCK,
|
||||
occurred_at="2026-09-21 16:00:00",
|
||||
test_prefix=True,
|
||||
)
|
||||
title = payload["template_card"]["main_title"]["title"]
|
||||
self.assertEqual(title, "系统异常通知")
|
||||
self.assertNotIn("[test]", title)
|
||||
self.assertNotIn("【test】", title)
|
||||
text = copy.system_exception_alert_text(
|
||||
work_order_no="WO202609210002",
|
||||
exception_type=TYPE_TMS,
|
||||
service="空运锁舱",
|
||||
error_code="LOCK_FAIL",
|
||||
step=copy.STEP_TMS_LOCK,
|
||||
occurred_at="2026-09-21 16:00:00",
|
||||
test_prefix=True,
|
||||
)
|
||||
self.assertTrue(text.startswith("询价机器人系统告警"))
|
||||
self.assertNotIn("[test]", text)
|
||||
self.assertNotIn("【test】", text)
|
||||
|
||||
def test_lock_fail_copy_has_operator_reason_and_it_notice(self) -> None:
|
||||
text = copy.air_lock_fail(
|
||||
work_order_no="WO202609210001",
|
||||
operator="吴季林",
|
||||
reason="该航班日期不存在可锁定的配载记录",
|
||||
)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"工单WO202609210001 空运锁舱失败。\n"
|
||||
"操作人:吴季林\n"
|
||||
"锁舱失败的原因:该航班日期不存在可锁定的配载记录\n"
|
||||
"已通知IT运维排查,请稍等。",
|
||||
)
|
||||
|
||||
def test_release_fail_copy_has_operator_reason_and_it_notice(self) -> None:
|
||||
text = copy.air_release_fail(
|
||||
work_order_no="WO202609210001",
|
||||
operator="吴季林",
|
||||
reason="舱位不存在",
|
||||
)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"工单WO202609210001 空运释放舱位失败。\n"
|
||||
"操作人:吴季林\n"
|
||||
"释放舱位失败的原因:舱位不存在\n"
|
||||
"已通知IT运维排查,请稍等。",
|
||||
)
|
||||
|
||||
def test_cabin_fail_reason_keeps_tms_text(self) -> None:
|
||||
self.assertEqual(
|
||||
copy.air_cabin_fail_reason(
|
||||
tms_reason="该航班日期不存在可锁定的配载记录",
|
||||
flight_date="2026-09-20",
|
||||
today="2026-09-21",
|
||||
),
|
||||
"该航班日期不存在可锁定的配载记录",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"报价选项不存在",
|
||||
copy.air_cabin_fail_reason(
|
||||
tms_reason="该航班日期不存在可锁定的配载记录",
|
||||
flight_date="2026-09-20",
|
||||
today="2026-09-21",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
copy.air_cabin_fail_reason(
|
||||
tms_reason="",
|
||||
flight_date="2026-09-20",
|
||||
today="2026-09-21",
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
class TestSystemExceptionRules(unittest.TestCase):
|
||||
def test_tms_no_price_is_not_tech(self) -> None:
|
||||
@@ -145,6 +240,9 @@ class TestConfirmSystemException(unittest.TestCase):
|
||||
self.assertEqual(card["main_title"]["desc"], "关联工单 无")
|
||||
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
|
||||
self.assertEqual(values["影响范围"], copy.IMPACT_LLM_NO_TICKET)
|
||||
self.assertEqual(card["main_title"]["title"], "系统异常通知")
|
||||
self.assertNotIn("[test]", card["main_title"]["title"])
|
||||
self.assertNotIn("[test]", queued[0]["content"])
|
||||
|
||||
def test_mark_ticket_and_pause(self) -> None:
|
||||
replies: list[str] = []
|
||||
@@ -171,6 +269,59 @@ class TestConfirmSystemException(unittest.TestCase):
|
||||
self.assertTrue(ticket_is_paused(ticket))
|
||||
self.assertEqual(replies[0], copy.SYS_EXC_TMS_FALLBACK)
|
||||
|
||||
def test_air_lock_alert_impact_keeps_status(self) -> None:
|
||||
queued: list[dict] = []
|
||||
|
||||
def enqueue(*, touser, content, dedupe_key, payload):
|
||||
queued.append({"payload": payload, "content": content})
|
||||
return True, "ok"
|
||||
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=copy.STEP_TMS_LOCK,
|
||||
reason="空运锁舱接口调用失败",
|
||||
service="空运锁舱",
|
||||
error_code="LOCK_FAIL",
|
||||
work_order_no=self.wo,
|
||||
ticket_status="已报价",
|
||||
),
|
||||
ledger=self.ledger,
|
||||
reply=lambda _t: None,
|
||||
enqueue=enqueue,
|
||||
notify_ready=True,
|
||||
)
|
||||
card = queued[0]["payload"]["template_card"]
|
||||
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
|
||||
self.assertEqual(values["影响范围"], "锁舱失败,工单状态保持不变")
|
||||
self.assertEqual(card["main_title"]["title"], "系统异常通知")
|
||||
|
||||
def test_air_release_alert_impact_keeps_status(self) -> None:
|
||||
queued: list[dict] = []
|
||||
|
||||
def enqueue(*, touser, content, dedupe_key, payload):
|
||||
queued.append({"payload": payload})
|
||||
return True, "ok"
|
||||
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=copy.STEP_TMS_RELEASE,
|
||||
reason="空运释放舱位接口调用失败",
|
||||
service="空运舱位",
|
||||
error_code="RELEASE_FAIL",
|
||||
work_order_no=self.wo,
|
||||
ticket_status="已锁舱",
|
||||
),
|
||||
ledger=self.ledger,
|
||||
reply=lambda _t: None,
|
||||
enqueue=enqueue,
|
||||
notify_ready=True,
|
||||
)
|
||||
card = queued[0]["payload"]["template_card"]
|
||||
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
|
||||
self.assertEqual(values["影响范围"], "释放舱位失败,工单状态保持不变")
|
||||
|
||||
def test_closed_ticket_not_marked(self) -> None:
|
||||
self.ledger.transition(work_order_no=self.wo, to_status="已关闭")
|
||||
out = confirm_system_exception(
|
||||
|
||||
+20
@@ -399,6 +399,26 @@ public class InquiryAgentController {
|
||||
return Result.OK(workOrderService.patchCollabFacts(body));
|
||||
}
|
||||
|
||||
@ApiOperation("选定线路后写回询价事实(不改六态)")
|
||||
@PostMapping("/ticket/updateFacts")
|
||||
public Result<Map<String, Object>> updateFacts(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.updateInquiryFacts(body));
|
||||
}
|
||||
|
||||
@ApiOperation("采用后改选:作废旧报价单(不改六态)")
|
||||
@PostMapping("/ticket/voidQuoteFiles")
|
||||
public Result<Map<String, Object>> voidQuoteFiles(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.voidQuoteFiles(firstStr(body, "workOrderNo", "work_order_no")));
|
||||
}
|
||||
|
||||
@ApiOperation("按协同群 chat_id 找回工单(只读)")
|
||||
@PostMapping("/ticket/byCollabChat")
|
||||
public Result<Map<String, Object>> byCollabChat(@RequestBody Map<String, Object> body,
|
||||
|
||||
+10
@@ -91,6 +91,16 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
*/
|
||||
void markQuoteFileIssued(String workOrderNo);
|
||||
|
||||
/**
|
||||
* 选定线路后写回询价事实(起运港等)。不改六态、不写报价版本。
|
||||
*/
|
||||
Map<String, Object> updateInquiryFacts(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 采用后改选:作废旧报价单。不改六态。续办回到待采用。
|
||||
*/
|
||||
Map<String, Object> voidQuoteFiles(String workOrderNo);
|
||||
|
||||
/**
|
||||
* 记下海运协同群。不改六态。
|
||||
*/
|
||||
|
||||
+78
-7
@@ -27,7 +27,9 @@ import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||
import org.jeecg.modules.inquiry.support.AgentSystemExceptionRetryClient;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
||||
import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketFactsSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketPriceSupport;
|
||||
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -858,23 +860,92 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (payload == null) {
|
||||
payload = new JSONObject(true);
|
||||
}
|
||||
payload.put("quoteFileIssued", true);
|
||||
row.setPayloadJson(payload.toJSONString());
|
||||
row.setPayloadJson(InquiryQuoteFileSupport.markIssued(payload.toJSONString()));
|
||||
row.setUpdateTime(new Date());
|
||||
quoteMapper.updateById(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选定线路后写回询价事实。不改六态。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> updateInquiryFacts(Map<String, Object> body) {
|
||||
String no = text(body, "workOrderNo", "work_order_no");
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
if (TicketStatuses.isFrozen(ticket.getStatus())) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "handoff_frozen");
|
||||
return out;
|
||||
}
|
||||
Map<String, String> facts = new LinkedHashMap<>();
|
||||
Object raw = body == null ? null : body.get("facts");
|
||||
if (raw instanceof Map) {
|
||||
for (Map.Entry<?, ?> e : ((Map<?, ?>) raw).entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
facts.put(String.valueOf(e.getKey()), String.valueOf(e.getValue()));
|
||||
}
|
||||
}
|
||||
InquiryTicketFactsSupport.apply(ticket, facts);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", no);
|
||||
out.put("facts", InquiryTicketFactsSupport.parseFacts(ticket.getFactsJson()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采用后改选:作废旧报价单。不改六态。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> voidQuoteFiles(String workOrderNo) {
|
||||
String no = StringUtils.trimToEmpty(workOrderNo);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (StringUtils.isBlank(no)) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "work_order_required");
|
||||
return out;
|
||||
}
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, no)
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (quotes != null && !quotes.isEmpty()) {
|
||||
InquiryQuote row = quotes.get(0);
|
||||
row.setPayloadJson(InquiryQuoteFileSupport.applyVoid(row.getPayloadJson()));
|
||||
row.setUpdateTime(new Date());
|
||||
quoteMapper.updateById(row);
|
||||
}
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", no);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 续办停点:文件已发出或已有附件,就不再回到出方案/协同。
|
||||
* 作废优先:改选后即使还有旧附件也不进成交跟进。
|
||||
*/
|
||||
private boolean quoteFileAlreadyIssued(String workOrderNo, Map<String, Object> quote) {
|
||||
Object flag = quote == null ? null : quote.get("quoteFileIssued");
|
||||
if (Boolean.TRUE.equals(flag) || "true".equalsIgnoreCase(String.valueOf(flag))) {
|
||||
return true;
|
||||
}
|
||||
String payload = quote == null ? "" : JSON.toJSONString(quote);
|
||||
Long n = attachmentMapper.selectCount(new LambdaQueryWrapper<InquiryAttachment>()
|
||||
.eq(InquiryAttachment::getWorkOrderNo, workOrderNo));
|
||||
return n != null && n > 0;
|
||||
return InquiryQuoteFileSupport.isIssued(payload, n != null && n > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
/**
|
||||
* 报价单发出 / 作废标记。
|
||||
* <p>
|
||||
* 职责:只改报价载荷里的 quoteFileIssued / quoteFileVoided,不改六态。
|
||||
* 副作用:无。调用方写回 inq_quote.payload_json。
|
||||
* 为何:采用后改选必须作废旧单,续办不能再走进成交跟进。
|
||||
*/
|
||||
public final class InquiryQuoteFileSupport {
|
||||
|
||||
public static final String ISSUED = "quoteFileIssued";
|
||||
public static final String VOIDED = "quoteFileVoided";
|
||||
|
||||
private InquiryQuoteFileSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废:清发出标记,写下作废。有附件也不能再当有效报价单。
|
||||
*/
|
||||
public static String applyVoid(String payloadJson) {
|
||||
JSONObject payload = parse(payloadJson);
|
||||
payload.put(ISSUED, false);
|
||||
payload.put(VOIDED, true);
|
||||
return payload.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新采用:打发出标记,去掉作废。
|
||||
*/
|
||||
public static String markIssued(String payloadJson) {
|
||||
JSONObject payload = parse(payloadJson);
|
||||
payload.put(ISSUED, true);
|
||||
payload.remove(VOIDED);
|
||||
return payload.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 续办是否按「文件已发出」进成交跟进。
|
||||
* 作废优先:即使库里还有附件也不算有效报价单。
|
||||
*/
|
||||
public static boolean isIssued(String payloadJson, boolean hasAttachments) {
|
||||
JSONObject payload = parse(payloadJson);
|
||||
if (Boolean.TRUE.equals(payload.getBoolean(VOIDED))
|
||||
|| "true".equalsIgnoreCase(String.valueOf(payload.get(VOIDED)))) {
|
||||
return false;
|
||||
}
|
||||
Object flag = payload.get(ISSUED);
|
||||
if (Boolean.TRUE.equals(flag) || "true".equalsIgnoreCase(String.valueOf(flag))) {
|
||||
return true;
|
||||
}
|
||||
return hasAttachments;
|
||||
}
|
||||
|
||||
private static JSONObject parse(String payloadJson) {
|
||||
if (StringUtils.isBlank(payloadJson)) {
|
||||
return new JSONObject(true);
|
||||
}
|
||||
JSONObject parsed = JSON.parseObject(payloadJson);
|
||||
return parsed == null ? new JSONObject(true) : parsed;
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工单询价事实写回:选定线路后覆盖起运港等列。
|
||||
* <p>
|
||||
* 职责:合并 factsJson 与列字段。不改六态、不写报价版本。
|
||||
* 副作用:无。调用方负责 updateById。
|
||||
* 为何:H5 选线路后工单起运港必须变成所选,改选覆盖。
|
||||
*/
|
||||
public final class InquiryTicketFactsSupport {
|
||||
|
||||
private InquiryTicketFactsSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用销售侧内部中文键覆盖工单。空值不删已有键(改选只覆盖有值项)。
|
||||
*
|
||||
* @param ticket 当前工单
|
||||
* @param facts 完整或增量 facts
|
||||
*/
|
||||
public static void apply(InquiryTicket ticket, Map<String, String> facts) {
|
||||
if (ticket == null || facts == null || facts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> merged = parseFacts(ticket.getFactsJson());
|
||||
for (Map.Entry<String, String> e : facts.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
String val = e.getValue().trim();
|
||||
if (val.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
merged.put(e.getKey(), val);
|
||||
}
|
||||
ticket.setFactsJson(JSON.toJSONString(merged));
|
||||
if (StringUtils.isNotBlank(merged.get("起运港"))) {
|
||||
ticket.setOrigin(merged.get("起运港"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(merged.get("目的港"))) {
|
||||
ticket.setDestination(merged.get("目的港"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(merged.get("品名"))) {
|
||||
ticket.setGoodsName(merged.get("品名"));
|
||||
}
|
||||
Integer pieces = parseInt(merged.get("件数"));
|
||||
if (pieces != null) {
|
||||
ticket.setPieces(pieces);
|
||||
}
|
||||
BigDecimal weight = parseDecimal(first(merged, "重量", "毛重"));
|
||||
if (weight != null) {
|
||||
ticket.setWeightKg(weight);
|
||||
}
|
||||
BigDecimal volume = parseDecimal(merged.get("体积"));
|
||||
if (volume != null) {
|
||||
ticket.setVolumeCbm(volume);
|
||||
}
|
||||
Date quoteDate = parseDate(merged.get("报价日期"));
|
||||
if (quoteDate != null) {
|
||||
ticket.setQuoteDate(quoteDate);
|
||||
}
|
||||
String origin = StringUtils.defaultString(ticket.getOrigin());
|
||||
String dest = StringUtils.defaultString(ticket.getDestination());
|
||||
if (StringUtils.isNotBlank(origin) || StringUtils.isNotBlank(dest)) {
|
||||
ticket.setDemandTitle(origin + " → " + dest);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解开 factsJson。坏 JSON 当空。
|
||||
*/
|
||||
public static Map<String, String> parseFacts(String factsJson) {
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
if (StringUtils.isBlank(factsJson)) {
|
||||
return out;
|
||||
}
|
||||
try {
|
||||
JSONObject parsed = JSON.parseObject(factsJson);
|
||||
if (parsed == null) {
|
||||
return out;
|
||||
}
|
||||
for (String key : parsed.keySet()) {
|
||||
if (parsed.get(key) != null) {
|
||||
out.put(key, String.valueOf(parsed.get(key)));
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String first(Map<String, String> facts, String... keys) {
|
||||
if (facts == null || keys == null) {
|
||||
return null;
|
||||
}
|
||||
for (String key : keys) {
|
||||
String val = facts.get(key);
|
||||
if (StringUtils.isNotBlank(val)) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Integer parseInt(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
String digits = raw.replaceAll("[^0-9-]", "");
|
||||
if (digits.isEmpty() || "-".equals(digits)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(digits);
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal parseDecimal(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
String norm = raw.replaceAll("[^0-9.\\-]", "");
|
||||
if (norm.isEmpty() || ".".equals(norm) || "-".equals(norm)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(norm);
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Date parseDate(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new SimpleDateFormat("yyyy-MM-dd").parse(raw.trim());
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
-25
@@ -26,8 +26,10 @@ import java.util.Map;
|
||||
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
|
||||
* <p>
|
||||
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
|
||||
* 锁舱必须带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
|
||||
* 仍没有编号才补 QT-{工单号} + AIR-OPT-01 出站;不因「没 TMS 报价」本地拦住。
|
||||
* 锁舱优先带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
|
||||
* 没有 5.2 报价也可以锁舱:TMS 锁舱这两项必填,缺了会 1001「参数格式错误」。
|
||||
* 此时补 QT-{工单号} + AIR-OPT-01,禁止拿空字段出站。
|
||||
* 有真实 TMS 编号时不准用自编号盖掉,否则会回 2006。
|
||||
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
|
||||
* 禁止:按 userid 伪造报价;单测打真实 TMS。
|
||||
*/
|
||||
@@ -67,18 +69,20 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
Map<String, Object> quote = latestQuote(no);
|
||||
quote = refreshLockQuote(ticket, quote);
|
||||
String quoteId = resolveQuoteId(quote, no);
|
||||
String quoteId = lockQuoteId(resolveQuoteId(quote, no), no);
|
||||
Map<String, Object> option = resolveOption(quote, optionNo);
|
||||
if (StringUtils.isBlank(quoteId) || option == null) {
|
||||
return fail("no_lock_target", "锁舱缺少报价编号或舱位选项");
|
||||
if (option == null) {
|
||||
option = new LinkedHashMap<>();
|
||||
}
|
||||
String resolvedOption = lockOptionNo(text(option, "optionNo", "option_no"));
|
||||
option.put("optionNo", resolvedOption);
|
||||
if (!properties.lockConfigured()) {
|
||||
return fail("lock_disabled", "主账未开放锁舱");
|
||||
}
|
||||
Map<String, Object> req = new LinkedHashMap<>();
|
||||
req.put("workOrderNo", no);
|
||||
req.put("quoteId", quoteId);
|
||||
req.put("optionNo", text(option, "optionNo", "option_no"));
|
||||
req.put("optionNo", resolvedOption);
|
||||
req.put("flightDate", quoteDate(ticket));
|
||||
req.put("origin", firstNonBlank(text(option, "originAirportCode"), ticket.getOrigin(),
|
||||
fact(ticket, "起运地", "起运港")));
|
||||
@@ -97,7 +101,7 @@ public class InquiryTmsCabinService {
|
||||
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
|
||||
req.put("operatorName", text(body, "operatorName", "operator_name"));
|
||||
req.put("instructionText", text(body, "instructionText", "instruction_text"));
|
||||
return invokeLock(ticket, req, quoteId, text(option, "optionNo", "option_no"));
|
||||
return invokeLock(ticket, req, quoteId, resolvedOption);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,7 +144,10 @@ public class InquiryTmsCabinService {
|
||||
Map<String, Object> tms = gateway.lockCabin(req);
|
||||
int code = TmsV2ResponseNormalizer.number(tms.get("code"));
|
||||
if (code != 1) {
|
||||
return fail("lock_fail", cabinFailReason(code, tms));
|
||||
String why = cabinFailReason(code, tms, quoteDate(ticket));
|
||||
log.info("tms.lock fail wo={} quoteId={} optionNo={} code={} reason={}",
|
||||
ticket.getWorkOrderNo(), quoteId, optionNo, code, why);
|
||||
return fail("lock_fail", why);
|
||||
}
|
||||
Map<String, Object> data = tmsData(tms);
|
||||
String lockId = firstNonBlank(text(data, "lockId"), text(tms, "lockId"));
|
||||
@@ -169,7 +176,9 @@ public class InquiryTmsCabinService {
|
||||
Map<String, Object> tms = gateway.releaseCabin(req);
|
||||
int code = TmsV2ResponseNormalizer.number(tms.get("code"));
|
||||
if (code != 1) {
|
||||
return fail("release_fail", cabinFailReason(code, tms));
|
||||
String why = cabinFailReason(code, tms, quoteDate(ticket));
|
||||
log.info("tms.release fail wo={} code={} reason={}", ticket.getWorkOrderNo(), code, why);
|
||||
return fail("release_fail", why);
|
||||
}
|
||||
Map<String, Object> data = tmsData(tms);
|
||||
Map<String, Object> out = okBase();
|
||||
@@ -301,18 +310,49 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有 TMS 5.2 报价编号时,用工单号生成关联 ID,仍调锁舱。
|
||||
* 库里的真实 TMS 报价编号。自编 QT-工单号不算:先再查一次 5.2,
|
||||
* 仍没有才在 {@link #lockQuoteId} 补占位,给无报价锁舱用。
|
||||
*/
|
||||
static String resolveQuoteId(Map<String, Object> quote, String workOrderNo) {
|
||||
String id = text(quote, "quoteId", "quote_id");
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
return id;
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return "";
|
||||
}
|
||||
return StringUtils.isBlank(workOrderNo) ? "" : "QT-" + workOrderNo.trim();
|
||||
String wo = StringUtils.trimToEmpty(workOrderNo);
|
||||
if (id.equalsIgnoreCase("QT-" + wo) || id.toUpperCase().startsWith("QT-WO")) {
|
||||
return "";
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 有舱位选项按名称挑;一张都没有时补 AIR-OPT-01,让人工价也能出站。
|
||||
* 锁舱出站用的报价编号。TMS /air/cabin/lock 的 quoteId 必填,缺了回 1001。
|
||||
* 有 5.2 真编号就用真编号;没有才补 QT-{工单号},允许无报价锁舱。
|
||||
*/
|
||||
static String lockQuoteId(String resolved, String workOrderNo) {
|
||||
if (StringUtils.isNotBlank(resolved)) {
|
||||
return resolved.trim();
|
||||
}
|
||||
String wo = StringUtils.trimToEmpty(workOrderNo);
|
||||
if (wo.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return "QT-" + wo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱出站用的舱位选项。TMS optionNo 必填,缺了回 1001。
|
||||
* 有选项用选项;没有补 AIR-OPT-01。
|
||||
*/
|
||||
static String lockOptionNo(String resolved) {
|
||||
if (StringUtils.isNotBlank(resolved)) {
|
||||
return resolved.trim();
|
||||
}
|
||||
return "AIR-OPT-01";
|
||||
}
|
||||
|
||||
/**
|
||||
* 有舱位选项按名称挑;没有就补 AIR-OPT-01,无报价锁舱也要带上必填项。
|
||||
*/
|
||||
static Map<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
|
||||
List<Map<String, Object>> options = airOptions(quote);
|
||||
@@ -324,10 +364,10 @@ public class InquiryTmsCabinService {
|
||||
if (options.size() == 1) {
|
||||
return options.get(0);
|
||||
}
|
||||
return null;
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("optionNo", StringUtils.isNotBlank(optionNo) ? optionNo.trim() : "AIR-OPT-01");
|
||||
row.put("optionNo", lockOptionNo(optionNo));
|
||||
String airline = text(quote, "airlineCode", "airline", "airlineName");
|
||||
if (StringUtils.isNotBlank(airline)) {
|
||||
row.put("airlineCode", airline);
|
||||
@@ -417,25 +457,51 @@ public class InquiryTmsCabinService {
|
||||
return tms == null ? new LinkedHashMap<>() : tms;
|
||||
}
|
||||
|
||||
private static String cabinFailReason(int code, Map<String, Object> tms) {
|
||||
/**
|
||||
* 锁舱/释放失败话术:原样用 TMS 的 failReason。
|
||||
* 禁止按航班日改写成「起飞」,禁止把 2006 映射成「报价选项不存在」——没有报价也可以锁舱。
|
||||
*/
|
||||
static String cabinFailReason(int code, Map<String, Object> tms, String flightDate) {
|
||||
return cabinFailReason(code, tms, flightDate, "");
|
||||
}
|
||||
|
||||
static String cabinFailReason(int code, Map<String, Object> tms, String flightDate, String today) {
|
||||
// flightDate / today 保留调用签名,不再按日期改写 TMS 原文。
|
||||
Map<String, Object> data = tmsData(tms);
|
||||
String detail = firstNonBlank(text(data, "failReason"), text(tms, "message"));
|
||||
if (code == 2006) {
|
||||
return firstNonBlank(detail, "舱位选项不存在或与报价不匹配");
|
||||
String detail = firstNonBlank(
|
||||
text(data, "failReason", "fail_reason"),
|
||||
text(tms, "failReason", "fail_reason"),
|
||||
nestedFailReason(data),
|
||||
text(data, "message"),
|
||||
text(tms, "message"));
|
||||
if (StringUtils.isNotBlank(detail)) {
|
||||
return detail;
|
||||
}
|
||||
if (code == 2007) {
|
||||
return firstNonBlank(detail, "报价已过期,请重新询价");
|
||||
return "报价已过期,请重新询价";
|
||||
}
|
||||
if (code == 2008) {
|
||||
return firstNonBlank(detail, "舱位不存在");
|
||||
return "舱位不存在";
|
||||
}
|
||||
if (code == 2009) {
|
||||
return firstNonBlank(detail, "锁舱被业务规则拒绝");
|
||||
return "锁舱被业务规则拒绝";
|
||||
}
|
||||
if (code == 1005) {
|
||||
return firstNonBlank(detail, "TMS 签名失败");
|
||||
return "TMS 签名失败";
|
||||
}
|
||||
return firstNonBlank(detail, "TMS 业务码=" + code);
|
||||
return "TMS 业务码=" + code;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String nestedFailReason(Map<String, Object> data) {
|
||||
if (data == null) {
|
||||
return "";
|
||||
}
|
||||
Object inner = data.get("data");
|
||||
if (inner instanceof Map) {
|
||||
return text((Map<String, Object>) inner, "failReason", "fail_reason");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static Map<String, Object> okBase() {
|
||||
|
||||
+37
-27
@@ -81,11 +81,11 @@ public class InquiryTmsQueryService {
|
||||
segment.put("transportMode", line);
|
||||
String origin = text(facts.get("起运港"));
|
||||
String destination = text(facts.get("目的港"));
|
||||
segment.put("origin", origin);
|
||||
segment.put("destination", destination);
|
||||
segment.put("cargoName", text(facts.get("品名")));
|
||||
if ("SEA".equals(line)) {
|
||||
// 海运不拦空运件数/重量/体积/包装;港口只带名称,没有港口字典也不跳过。
|
||||
segment.put("origin", origin);
|
||||
segment.put("destination", destination);
|
||||
segment.put("cargoQty", first(facts.get("货量"), facts.get("货物数量")));
|
||||
segment.put("loadType", facts.get("整柜或拼柜"));
|
||||
segment.put("containerSpec", facts.get("箱型箱量"));
|
||||
@@ -100,14 +100,24 @@ public class InquiryTmsQueryService {
|
||||
facts.get("tradeTerms")));
|
||||
} else if ("LAND".equals(line)) {
|
||||
// 陆运不套空运包装/件数;分类和中港装载只在工单,mapper 不会放进出站。
|
||||
segment.put("origin", origin);
|
||||
segment.put("destination", destination);
|
||||
segment.putAll(TmsLandSegmentMapper.build(facts));
|
||||
} else {
|
||||
// 空运:地名栏留空,三字码只放 originCode / destinationCode。
|
||||
segment.put("origin", "");
|
||||
segment.put("destination", "");
|
||||
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
|
||||
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
|
||||
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
|
||||
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
|
||||
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
|
||||
fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型"))));
|
||||
// 包装优先销售原文,避免旧工单 packageType=2 盖住「托盘」。
|
||||
fillPackageType(segment, text(first(facts.get("包装方式"), facts.get("包装类型"), facts.get("packageType"))));
|
||||
segment.put("cargoSource", first(facts.get("cargoSource"), facts.get("货源地")));
|
||||
segment.put("cargoReadyDate", first(facts.get("cargoReadyDate"), facts.get("货好时间")));
|
||||
segment.put("placeOfReceipt", first(facts.get("placeOfReceipt"), facts.get("提货地址")));
|
||||
segment.put("deliveryAddress", first(facts.get("deliveryAddress"), facts.get("收货地址")));
|
||||
}
|
||||
|
||||
String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期"));
|
||||
@@ -333,12 +343,12 @@ public class InquiryTmsQueryService {
|
||||
? String.valueOf(airOptions)
|
||||
: JSON.toJSONString(airOptions));
|
||||
}
|
||||
Object isMulti = first(data.get("isMulti"), data.get("is_multi"), tms.get("isMulti"));
|
||||
if (isMulti != null && !"".equals(String.valueOf(isMulti))) {
|
||||
quote.put("isMulti", String.valueOf(isMulti));
|
||||
}
|
||||
// 陆运线路段:与空运 airOptions 同级透出,供模板 routeRegions 填项目一
|
||||
putLandSegments(quote, data, tms);
|
||||
String isMulti = firstText(data.get("isMulti"), data.get("is_multi"));
|
||||
if (StringUtils.isNotBlank(isMulti)) {
|
||||
quote.put("isMulti", isMulti);
|
||||
}
|
||||
String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg"));
|
||||
if (StringUtils.isNotBlank(chargeable)) {
|
||||
quote.put("chargeableWeightKg", chargeable);
|
||||
@@ -669,7 +679,10 @@ public class InquiryTmsQueryService {
|
||||
return raw == null || String.valueOf(raw).trim().isEmpty();
|
||||
}
|
||||
|
||||
private static List<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
|
||||
/**
|
||||
* 空运出站齐套检查。地名栏允许空串,禁止再拿 destination 拦查价。
|
||||
*/
|
||||
static List<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) {
|
||||
missing.add("workOrderNo");
|
||||
@@ -678,7 +691,6 @@ public class InquiryTmsQueryService {
|
||||
missing.add("quoteDate");
|
||||
}
|
||||
for (String key : new String[] {
|
||||
"origin", "destination",
|
||||
"cargoName", "quantity", "weightKg", "volumeCbm", "packageType"
|
||||
}) {
|
||||
if (segment.get(key) == null || "".equals(segment.get(key))) {
|
||||
@@ -711,28 +723,26 @@ public class InquiryTmsQueryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* TMS 出站要 packageType。销售原文非空即收下,不对销售做包装白名单。
|
||||
* 认得出代码用代码;未对上默认 2。不要默认 1(全部),现网航线 1 常无价、2/3 有价。
|
||||
* 空运包装出站:销售输入什么就传什么,禁止编成 1/2/3。
|
||||
*/
|
||||
private void fillPackageType(Map<String, Object> segment, String raw) {
|
||||
Object pkg = airPackageType(raw);
|
||||
if (pkg != null) {
|
||||
segment.put("packageType", pkg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装原文原样。空串不写,避免主账再编一个默认码。
|
||||
*
|
||||
* @param raw 销售写的包装,例如「托盘」
|
||||
* @return 去空白后的原文;空则 null
|
||||
*/
|
||||
static Object airPackageType(String raw) {
|
||||
if (raw == null || raw.trim().isEmpty()) {
|
||||
segment.put("packageType", 2);
|
||||
return;
|
||||
}
|
||||
if (raw.matches("\\d+")) {
|
||||
segment.put("packageType", Integer.valueOf(raw));
|
||||
return;
|
||||
}
|
||||
TmsExactProjector.Projection hit = projector.resolvePackage(raw);
|
||||
if (!hit.ok()) {
|
||||
segment.put("packageType", 2);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
segment.put("packageType", Integer.valueOf(hit.code));
|
||||
} catch (NumberFormatException ex) {
|
||||
segment.put("packageType", hit.code);
|
||||
return null;
|
||||
}
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
private static Map<String, Object> tech(String message) {
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@ import java.util.UUID;
|
||||
* <p>
|
||||
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
|
||||
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
|
||||
* quoteId / optionNo 由主账补齐:有 TMS 报价用原值,没有则 QT-工单号 + AIR-OPT-01。
|
||||
* quoteId / optionNo 必填:有 TMS 报价用真编号;没有时由 CabinService 补 QT-{工单号} / AIR-OPT-01。
|
||||
* mapper 仍跳过空字符串,避免把 null 写成 JSON。
|
||||
*/
|
||||
public final class TmsCabinRequestMapper {
|
||||
|
||||
|
||||
+41
-4
@@ -55,28 +55,43 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
List<Map<String, Object>> segments = maps(internal.get("segments"));
|
||||
List<Map<String, Object>> external = new ArrayList<>();
|
||||
Object parentMode = first(internal, "transportMode");
|
||||
for (int i = 0; i < segments.size(); i++) {
|
||||
external.add(segment(segments.get(i), i + 1));
|
||||
external.add(segment(segments.get(i), i + 1, parentMode));
|
||||
}
|
||||
result.put("segments", external);
|
||||
return compact(result);
|
||||
}
|
||||
|
||||
private static Map<String, Object> segment(Map<String, Object> source, int fallbackSequence) {
|
||||
private static Map<String, Object> segment(Map<String, Object> source, int fallbackSequence, Object parentMode) {
|
||||
Map<String, Object> target = new LinkedHashMap<>();
|
||||
Object sequence = first(source, "sequence", "segmentSequence");
|
||||
target.put("sequence", sequence == null ? fallbackSequence : asInteger(sequence));
|
||||
put(target, "transportMode", first(source, "transportMode"));
|
||||
Object mode = first(source, "transportMode");
|
||||
if (mode == null) {
|
||||
mode = parentMode;
|
||||
}
|
||||
put(target, "transportMode", mode);
|
||||
if ("AIR".equalsIgnoreCase(String.valueOf(mode == null ? "" : mode))) {
|
||||
// 空运地名栏必须空串,三字码只放 originCode / destinationCode。
|
||||
target.put("origin", "");
|
||||
target.put("destination", "");
|
||||
put(target, "originCode", first(source, "originCode"));
|
||||
put(target, "destinationCode", first(source, "destinationCode"));
|
||||
} else {
|
||||
put(target, "origin", first(source, "origin"));
|
||||
put(target, "originCode", first(source, "originCode"));
|
||||
put(target, "destination", first(source, "destination"));
|
||||
put(target, "destinationCode", first(source, "destinationCode"));
|
||||
}
|
||||
put(target, "cargoName", first(source, "cargoName", "goodsName"));
|
||||
// 5.2:quantity=int,weightKg/volumeCbm=decimal>0,禁止带单位字符串
|
||||
put(target, "quantity", asInteger(first(source, "quantity", "pieces")));
|
||||
put(target, "weightKg", asPositiveDecimal(first(source, "weightKg")));
|
||||
put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm")));
|
||||
put(target, "packageType", first(source, "packageType", "packagingType"));
|
||||
// 空运选填:有值才带。贸易条款不进出站,只给后台模板关键词匹配。
|
||||
applyAirOptionalFields(source, target);
|
||||
// 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL,
|
||||
// cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。
|
||||
applySeaFields(source, target);
|
||||
@@ -84,6 +99,17 @@ public final class TmsV2RequestMapper {
|
||||
return compact(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运选填出站。货源地/货好时间/提货/收货有值才写。
|
||||
* 禁止把贸易条款写成 transportTerms。
|
||||
*/
|
||||
private static void applyAirOptionalFields(Map<String, Object> source, Map<String, Object> target) {
|
||||
put(target, "cargoSource", first(source, "cargoSource", "货源地"));
|
||||
put(target, "cargoReadyDate", first(source, "cargoReadyDate", "货好时间"));
|
||||
put(target, "placeOfReceipt", first(source, "placeOfReceipt", "提货地址"));
|
||||
put(target, "deliveryAddress", first(source, "deliveryAddress", "收货地址"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把海运口语字段写成 TMS 查询字段。空运段没有这些键则不写。
|
||||
*/
|
||||
@@ -223,7 +249,18 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
|
||||
private static Map<String, Object> compact(Map<String, Object> value) {
|
||||
value.entrySet().removeIf(e -> e.getValue() == null || "".equals(e.getValue()));
|
||||
// 空运地名栏要留空串给 TMS,禁止把 origin / destination 当成「没值」删掉。
|
||||
value.entrySet().removeIf(e -> {
|
||||
Object current = e.getValue();
|
||||
if (current == null) {
|
||||
return true;
|
||||
}
|
||||
if ("".equals(current)) {
|
||||
String key = String.valueOf(e.getKey());
|
||||
return !"origin".equals(key) && !"destination".equals(key);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
-- 角色菜单授权:补齐 Jeecg sys_role_permission 标准列。
|
||||
-- 根因:inquiry_robot 建表时只留了 id/role_id/permission_id,
|
||||
-- MyBatis-Plus 按实体写入 operate_date 导致「授权失败」。
|
||||
-- 库:inquiry_robot;幂等。
|
||||
|
||||
SET @has_data_rule := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'sys_role_permission'
|
||||
AND column_name = 'data_rule_ids'
|
||||
);
|
||||
SET @sql := IF(@has_data_rule = 0,
|
||||
'ALTER TABLE sys_role_permission ADD COLUMN data_rule_ids varchar(1000) NULL COMMENT ''数据规则id'' AFTER permission_id',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @has_operate_date := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'sys_role_permission'
|
||||
AND column_name = 'operate_date'
|
||||
);
|
||||
SET @sql := IF(@has_operate_date = 0,
|
||||
'ALTER TABLE sys_role_permission ADD COLUMN operate_date datetime NULL COMMENT ''操作时间'' AFTER data_rule_ids',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @has_operate_ip := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'sys_role_permission'
|
||||
AND column_name = 'operate_ip'
|
||||
);
|
||||
SET @sql := IF(@has_operate_ip = 0,
|
||||
'ALTER TABLE sys_role_permission ADD COLUMN operate_ip varchar(45) NULL COMMENT ''操作ip'' AFTER operate_date',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
-- 账号绑定角色后列表仍显示「未绑定」:补齐 sys_user_role.tenant_id。
|
||||
-- 根因:绑定写入成功,但 queryUserRole 按 Jeecg 实体 SELECT tenant_id,测库缺列导致查询失败。
|
||||
-- 库:inquiry_robot;幂等。
|
||||
|
||||
SET @has_col := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'sys_user_role'
|
||||
AND column_name = 'tenant_id'
|
||||
);
|
||||
SET @sql := IF(@has_col = 0,
|
||||
'ALTER TABLE sys_user_role ADD COLUMN tenant_id int NULL COMMENT ''租户ID'' AFTER role_id',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 选定线路后写回询价事实:起运港覆盖工单列,不改六态。
|
||||
*/
|
||||
public class InquiryTicketFactsSupportTest {
|
||||
|
||||
@Test
|
||||
public void applyWritesOriginAndKeepsOtherFacts() {
|
||||
InquiryTicket ticket = new InquiryTicket();
|
||||
ticket.setStatus("已报价");
|
||||
ticket.setDestination("克拉克");
|
||||
ticket.setFactsJson("{\"目的港\":\"克拉克\",\"品名\":\"茶叶\"}");
|
||||
Map<String, String> facts = new LinkedHashMap<>();
|
||||
facts.put("目的港", "克拉克");
|
||||
facts.put("品名", "茶叶");
|
||||
facts.put("起运港", "深圳");
|
||||
facts.put("贸易条款", "FOB");
|
||||
InquiryTicketFactsSupport.apply(ticket, facts);
|
||||
assertEquals("深圳", ticket.getOrigin());
|
||||
assertEquals("克拉克", ticket.getDestination());
|
||||
assertEquals("茶叶", ticket.getGoodsName());
|
||||
assertTrue(ticket.getFactsJson().contains("\"起运港\":\"深圳\""));
|
||||
assertTrue(ticket.getFactsJson().contains("\"贸易条款\":\"FOB\""));
|
||||
assertEquals("已报价", ticket.getStatus());
|
||||
assertEquals("深圳 → 克拉克", ticket.getDemandTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void voidedQuoteIsNotIssuedEvenWithAttachments() {
|
||||
String raw = InquiryQuoteFileSupport.applyVoid("{\"quoteFileIssued\":true,\"total\":\"1\"}");
|
||||
assertTrue(raw.contains("\"quoteFileVoided\":true"));
|
||||
assertFalse(InquiryQuoteFileSupport.isIssued(raw, true));
|
||||
String issued = InquiryQuoteFileSupport.markIssued(raw);
|
||||
assertTrue(InquiryQuoteFileSupport.isIssued(issued, false));
|
||||
assertFalse(issued.contains("quoteFileVoided"));
|
||||
}
|
||||
}
|
||||
+56
-7
@@ -43,17 +43,35 @@ public class InquiryTmsCabinServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveQuoteIdWithoutTmsUsesWorkOrder() {
|
||||
assertEquals("QT-WO202609160012",
|
||||
InquiryTmsCabinService.resolveQuoteId(new HashMap<>(), "WO202609160012"));
|
||||
public void resolveQuoteIdWithoutTmsStaysBlank() {
|
||||
assertEquals("", InquiryTmsCabinService.resolveQuoteId(new HashMap<>(), "WO202609160012"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveOptionWithoutTmsSynthesizesDefault() {
|
||||
public void resolveOptionWithoutTmsUsesAirOpt01() {
|
||||
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "");
|
||||
assertEquals("AIR-OPT-01", option.get("optionNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockQuoteIdWithoutTmsUsesWorkOrderPlaceholder() {
|
||||
assertEquals("QT-WO202609210003",
|
||||
InquiryTmsCabinService.lockQuoteId("", "WO202609210003"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockQuoteIdKeepsRealAirId() {
|
||||
assertEquals("AIR-2094721692181016578-2094721692654972929",
|
||||
InquiryTmsCabinService.lockQuoteId(
|
||||
"AIR-2094721692181016578-2094721692654972929",
|
||||
"WO202609160015"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockOptionNoWithoutTmsUsesAirOpt01() {
|
||||
assertEquals("AIR-OPT-01", InquiryTmsCabinService.lockOptionNo(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveQuoteIdKeepsRealAirId() {
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
@@ -63,11 +81,10 @@ public class InquiryTmsCabinServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void priceOnlyStillFallsBackToWorkOrder() {
|
||||
public void priceOnlyDoesNotInventQuoteId() {
|
||||
Map<String, Object> onlyPrice = new HashMap<>();
|
||||
onlyPrice.put("total", "11792.00");
|
||||
assertEquals("QT-WO202609160015",
|
||||
InquiryTmsCabinService.resolveQuoteId(onlyPrice, "WO202609160015"));
|
||||
assertEquals("", InquiryTmsCabinService.resolveQuoteId(onlyPrice, "WO202609160015"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,4 +95,36 @@ public class InquiryTmsCabinServiceTest {
|
||||
quote.put("airOptions", Arrays.asList(only));
|
||||
assertEquals("AIR-OPT-03", InquiryTmsCabinService.resolveOption(quote, "").get("optionNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockFailShowsTmsFailReasonNotMapped2006() {
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("message", "报价选项不存在或与工单不匹配");
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("failReason", "该航班日期不存在可锁定的配载记录");
|
||||
tms.put("tmsData", data);
|
||||
assertEquals("该航班日期不存在可锁定的配载记录",
|
||||
InquiryTmsCabinService.cabinFailReason(2006, tms, "2026-09-20", "2026-09-21"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockFailKeepsTmsMessageWhenNoFailReason() {
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("message", "航班已经起飞或起飞时间已到");
|
||||
assertEquals("航班已经起飞或起飞时间已到",
|
||||
InquiryTmsCabinService.cabinFailReason(2006, tms, "2026-09-28", "2026-09-21"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fakeQtWorkOrderQuoteIdIsDropped() {
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("quoteId", "QT-WO202609210003");
|
||||
assertEquals("", InquiryTmsCabinService.resolveQuoteId(quote, "WO202609210003"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockFail2006WithoutTextDoesNotInventQuoteMismatch() {
|
||||
String why = InquiryTmsCabinService.cabinFailReason(2006, new HashMap<>(), "2026-09-20", "2026-09-21");
|
||||
assertEquals("TMS 业务码=2006", why);
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -66,4 +66,30 @@ public class InquiryTmsQueryServiceTest {
|
||||
String line = InquiryTmsQueryService.resolveTransportLine(body, new java.util.HashMap<String, Object>(), "LAND");
|
||||
assertEquals("LAND", line);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void airPackageTypeKeepsUserText() {
|
||||
// 销售写托盘就传托盘,禁止编成 2。
|
||||
assertEquals("托盘", InquiryTmsQueryService.airPackageType("托盘"));
|
||||
assertEquals("纸箱", InquiryTmsQueryService.airPackageType("纸箱"));
|
||||
assertEquals("2", InquiryTmsQueryService.airPackageType("2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void airQueryAllowsEmptyPlaceNames() {
|
||||
// 地名栏空串不能拦出站;三字码和包装原文齐了就要打 TMS。
|
||||
java.util.Map<String, Object> segment = new java.util.HashMap<String, Object>();
|
||||
segment.put("origin", "");
|
||||
segment.put("destination", "");
|
||||
segment.put("originCode", "ZUH");
|
||||
segment.put("destinationCode", "CRK");
|
||||
segment.put("cargoName", "茶叶");
|
||||
segment.put("quantity", 1);
|
||||
segment.put("weightKg", "1.5");
|
||||
segment.put("volumeCbm", "100");
|
||||
segment.put("packageType", "托盘");
|
||||
java.util.List<String> missing = InquiryTmsQueryService.missingAirQuery(
|
||||
"WO202609220004", "2026-09-25", segment);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing));
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -39,6 +39,31 @@ public class TmsCabinRequestMapperTest {
|
||||
assertTrue(body.containsKey("requestId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockOmitsBlankQuoteAndOption() {
|
||||
Map<String, Object> internal = new LinkedHashMap<>();
|
||||
internal.put("workOrderNo", "WO202609210003");
|
||||
internal.put("quoteId", "");
|
||||
internal.put("optionNo", "");
|
||||
internal.put("flightDate", "2026-09-20");
|
||||
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
|
||||
assertEquals("WO202609210003", body.get("workOrderNo"));
|
||||
assertEquals(null, body.get("quoteId"));
|
||||
assertEquals(null, body.get("optionNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockKeepsNoQuotePlaceholders() {
|
||||
Map<String, Object> filled = new LinkedHashMap<>();
|
||||
filled.put("workOrderNo", "WO202609210003");
|
||||
filled.put("quoteId", "QT-WO202609210003");
|
||||
filled.put("optionNo", "AIR-OPT-01");
|
||||
filled.put("flightDate", "2026-09-20");
|
||||
Map<String, Object> sent = TmsCabinRequestMapper.lock(filled);
|
||||
assertEquals("QT-WO202609210003", sent.get("quoteId"));
|
||||
assertEquals("AIR-OPT-01", sent.get("optionNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void releaseKeepsLockId() {
|
||||
Map<String, Object> internal = new LinkedHashMap<>();
|
||||
|
||||
+66
@@ -9,6 +9,7 @@ import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* 请求映射单测:不打真实 TMS。
|
||||
@@ -156,4 +157,69 @@ public class TmsV2RequestMapperTest {
|
||||
assertFalse(segs.get(0).containsKey("运输分类"));
|
||||
assertFalse(body.containsKey("运输分类"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void airOptionalFieldsPassThroughWithoutTradeTerms() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("destination", "克拉克");
|
||||
seg.put("destinationCode", "CRK");
|
||||
seg.put("cargoName", "茶叶");
|
||||
seg.put("quantity", "10");
|
||||
seg.put("weightKg", "100");
|
||||
seg.put("volumeCbm", "2");
|
||||
seg.put("packageType", "2");
|
||||
seg.put("cargoSource", "东莞");
|
||||
seg.put("cargoReadyDate", "2026-09-22");
|
||||
seg.put("placeOfReceipt", "东莞仓库");
|
||||
seg.put("deliveryAddress", "马尼拉");
|
||||
seg.put("贸易条款", "FOB");
|
||||
seg.put("tradeTerms", "FOB");
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "AIR");
|
||||
internal.put("quoteDate", "2026-09-22");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
Map<String, Object> out = segs.get(0);
|
||||
assertEquals("东莞", out.get("cargoSource"));
|
||||
assertEquals("2026-09-22", out.get("cargoReadyDate"));
|
||||
assertEquals("东莞仓库", out.get("placeOfReceipt"));
|
||||
assertEquals("马尼拉", out.get("deliveryAddress"));
|
||||
assertEquals("", out.get("origin"));
|
||||
assertEquals("", out.get("destination"));
|
||||
assertEquals("CRK", out.get("destinationCode"));
|
||||
assertFalse(out.containsKey("transportTerms"));
|
||||
assertFalse(out.containsKey("tradeTerms"));
|
||||
assertFalse(out.containsKey("贸易条款"));
|
||||
assertNull(out.get("originCode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void airPlaceNamesStayEmptyCodesOnlyAndRawPackage() {
|
||||
// 地名栏必须空串;三字码只放 *Code。包装原文原样,禁止编成 2。
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("origin", "ZUH");
|
||||
seg.put("originCode", "ZUH");
|
||||
seg.put("destination", "CRK");
|
||||
seg.put("destinationCode", "CRK");
|
||||
seg.put("cargoName", "茶叶");
|
||||
seg.put("quantity", "1");
|
||||
seg.put("weightKg", "1.5");
|
||||
seg.put("volumeCbm", "100");
|
||||
seg.put("packageType", "托盘");
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "AIR");
|
||||
internal.put("quoteDate", "2026-09-25");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
Map<String, Object> out = segs.get(0);
|
||||
assertEquals("", out.get("origin"));
|
||||
assertEquals("ZUH", out.get("originCode"));
|
||||
assertEquals("", out.get("destination"));
|
||||
assertEquals("CRK", out.get("destinationCode"));
|
||||
assertEquals("托盘", out.get("packageType"));
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -142,6 +142,8 @@ public class SysRoleController {
|
||||
try {
|
||||
role.setCreateTime(new Date());
|
||||
sysRoleService.save(role);
|
||||
// 询价后台新增角色后需立即授权菜单,必须把雪花 id 回传前端
|
||||
result.setResult(role);
|
||||
result.success("添加成功!");
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
|
||||
+30
-21
@@ -30,15 +30,7 @@ public class SysRolePermissionServiceImpl extends ServiceImpl<SysRolePermissionM
|
||||
|
||||
@Override
|
||||
public void saveRolePermission(String roleId, String permissionIds) {
|
||||
String ip = "";
|
||||
try {
|
||||
//获取request
|
||||
HttpServletRequest request = SpringContextUtils.getHttpServletRequest();
|
||||
//获取IP地址
|
||||
ip = IpUtils.getIpAddr(request);
|
||||
} catch (Exception e) {
|
||||
ip = "127.0.0.1";
|
||||
}
|
||||
String ip = resolveOperateIp();
|
||||
LambdaQueryWrapper<SysRolePermission> query = new QueryWrapper<SysRolePermission>().lambda().eq(SysRolePermission::getRoleId, roleId);
|
||||
this.remove(query);
|
||||
List<SysRolePermission> list = new ArrayList<SysRolePermission>();
|
||||
@@ -56,15 +48,13 @@ public class SysRolePermissionServiceImpl extends ServiceImpl<SysRolePermissionM
|
||||
|
||||
@Override
|
||||
public void saveRolePermission(String roleId, String permissionIds, String lastPermissionIds) {
|
||||
String ip = "";
|
||||
try {
|
||||
//获取request
|
||||
HttpServletRequest request = SpringContextUtils.getHttpServletRequest();
|
||||
//获取IP地址
|
||||
ip = IpUtils.getIpAddr(request);
|
||||
} catch (Exception e) {
|
||||
ip = "127.0.0.1";
|
||||
// 询价后台编辑角色时若未传 lastpermissionIds,增量 diff 会把已存在权限当新增插入导致授权失败;
|
||||
// 与 Jeecg 原生抽屉不同,此处兜底走全量覆盖,保证幂等。
|
||||
if (oConvertUtils.isEmpty(lastPermissionIds)) {
|
||||
saveRolePermission(roleId, permissionIds);
|
||||
return;
|
||||
}
|
||||
String ip = resolveOperateIp();
|
||||
List<String> add = getDiff(lastPermissionIds,permissionIds);
|
||||
if(add!=null && add.size()>0) {
|
||||
List<SysRolePermission> list = new ArrayList<SysRolePermission>();
|
||||
@@ -88,11 +78,30 @@ public class SysRolePermissionServiceImpl extends ServiceImpl<SysRolePermissionM
|
||||
}
|
||||
|
||||
/**
|
||||
* 从diff中找出main中没有的元素
|
||||
* @param main
|
||||
* @param diff
|
||||
* @return
|
||||
* 写入 sys_role_permission.operate_ip;反向代理可能带回超长 X-Forwarded-For,截断避免入库失败。
|
||||
*/
|
||||
private String resolveOperateIp() {
|
||||
String ip = "127.0.0.1";
|
||||
try {
|
||||
HttpServletRequest request = SpringContextUtils.getHttpServletRequest();
|
||||
ip = IpUtils.getIpAddr(request);
|
||||
} catch (Exception e) {
|
||||
ip = "127.0.0.1";
|
||||
}
|
||||
if (oConvertUtils.isEmpty(ip)) {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
int comma = ip.indexOf(',');
|
||||
if (comma > 0) {
|
||||
ip = ip.substring(0, comma).trim();
|
||||
}
|
||||
if (ip.length() > 45) {
|
||||
ip = ip.substring(0, 45);
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
/** 从 diff 中找出 main 中没有的元素,供增量授权增删菜单。 */
|
||||
private List<String> getDiff(String main,String diff){
|
||||
if(oConvertUtils.isEmpty(diff)) {
|
||||
return null;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "/@/views/inquiry/shared/dataPermCatalog";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import { joinPermissionIds } from "/@/views/inquiry/role/rolePermission";
|
||||
|
||||
const rows = ref<RoleRow[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -37,6 +38,8 @@ const form = reactive({
|
||||
roleCode: "",
|
||||
status: "active" as RoleRow["status"],
|
||||
checkedKeys: [] as string[],
|
||||
/** 打开弹窗时已授权菜单,供 saveRolePermission 增量对比 */
|
||||
lastPermissionIds: [] as string[],
|
||||
dataPerms: [] as string[],
|
||||
/** 旧后台独立项:系统异常消息通知 */
|
||||
systemExceptionNotify: false,
|
||||
@@ -157,6 +160,7 @@ function openCreate() {
|
||||
roleCode: "",
|
||||
status: "active",
|
||||
checkedKeys: [],
|
||||
lastPermissionIds: [],
|
||||
dataPerms: [],
|
||||
systemExceptionNotify: false,
|
||||
});
|
||||
@@ -171,14 +175,18 @@ async function openEdit(row: RoleRow) {
|
||||
roleCode: row.roleCode,
|
||||
status: row.status,
|
||||
checkedKeys: [] as string[],
|
||||
lastPermissionIds: [] as string[],
|
||||
dataPerms: routeDataPermsOnly(allPerms),
|
||||
systemExceptionNotify: allPerms.includes(SYSTEM_EXCEPTION_NOTIFY_KEY),
|
||||
});
|
||||
try {
|
||||
const perms: any = await queryRolePermission({ roleId: row.id });
|
||||
form.checkedKeys = (Array.isArray(perms) ? perms : perms?.result || []).map(String);
|
||||
const ids = (Array.isArray(perms) ? perms : perms?.result || []).map(String);
|
||||
form.checkedKeys = ids;
|
||||
form.lastPermissionIds = [...ids];
|
||||
} catch {
|
||||
form.checkedKeys = [];
|
||||
form.lastPermissionIds = [];
|
||||
}
|
||||
modalOpen.value = true;
|
||||
}
|
||||
@@ -201,12 +209,12 @@ async function save() {
|
||||
},
|
||||
Boolean(editing.value),
|
||||
);
|
||||
const roleId = editing.value?.id || saved?.id || saved?.result?.id;
|
||||
if (roleId && form.checkedKeys.length) {
|
||||
const roleId = String(editing.value?.id || saved?.id || saved?.result?.id || "");
|
||||
if (roleId) {
|
||||
await saveRolePermission({
|
||||
roleId,
|
||||
permissionIds: form.checkedKeys.join(","),
|
||||
lastpermissionIds: "",
|
||||
permissionIds: joinPermissionIds(form.checkedKeys),
|
||||
lastpermissionIds: form.lastPermissionIds.join(","),
|
||||
});
|
||||
}
|
||||
message.success(editing.value ? "角色已更新" : "角色已创建");
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 角色菜单权限保存辅助:统一 a-tree 勾选值,并拼成 Jeecg saveRolePermission 所需字符串。
|
||||
* 询价角色页与 Jeecg 原生 RolePermissionDrawer 对齐 lastpermissionIds 增量语义。
|
||||
*/
|
||||
|
||||
/** a-tree / BasicTree 勾选结果可能是数组,或 checkStrictly 下的 { checked, halfChecked } */
|
||||
export function normalizeMenuCheckedKeys(checked: unknown): string[] {
|
||||
if (Array.isArray(checked)) {
|
||||
return checked.map(String).filter(Boolean);
|
||||
}
|
||||
if (checked && typeof checked === 'object' && 'checked' in checked) {
|
||||
const box = checked as { checked?: unknown[] };
|
||||
return (box.checked || []).map(String).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 提交 saveRolePermission.permissionIds */
|
||||
export function joinPermissionIds(checked: unknown): string {
|
||||
return normalizeMenuCheckedKeys(checked).join(',');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
joinPermissionIds,
|
||||
normalizeMenuCheckedKeys,
|
||||
} from '/@/views/inquiry/role/rolePermission';
|
||||
|
||||
test('normalizeMenuCheckedKeys accepts flat array', () => {
|
||||
expect(normalizeMenuCheckedKeys(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('normalizeMenuCheckedKeys accepts checkStrictly object', () => {
|
||||
expect(
|
||||
normalizeMenuCheckedKeys({ checked: ['x', 'y'], halfChecked: ['p'] }),
|
||||
).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
test('joinPermissionIds builds comma list', () => {
|
||||
expect(joinPermissionIds(['id1', 'id2'])).toBe('id1,id2');
|
||||
});
|
||||
Reference in New Issue
Block a user