Compare commits

..
59 changed files with 2011 additions and 1723 deletions
+37 -25
View File
@@ -4,7 +4,8 @@
本文件职责:给附件上传成功、选线路成功页一段共用脚本。 本文件职责:给附件上传成功、选线路成功页一段共用脚本。
手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。 手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。 再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。 电脑企微 5.x 内置页经常加载不了 JS-SDK,也不会打签名接口。
这时 ww.closeWindow 不会执行。电脑改用 WeixinJSBridge.invoke,再补 window.close,只关当前页。
失败、过期页不要挂这段。 失败、过期页不要挂这段。
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。 副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
""" """
@@ -24,7 +25,9 @@ def wecom_auto_close_script() -> str:
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。 成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。 线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。 为何这样写:21:27 的上传日志里,成功页已经返回,但没有 jsapi 签名请求。
电脑企微没跑起 ww.register,只调 ww.closeWindow 等于没关。
电脑改为 invoke + window.close,不依赖签名先成功。手机仍只关一次。
""" """
settings = get_settings() settings = get_settings()
corp = escape((settings.wecom_corp_id or "").strip(), quote=True) corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
@@ -49,6 +52,14 @@ def wecom_auto_close_script() -> str:
var closed = false; var closed = false;
function isPhone() {{
var ua = navigator.userAgent || "";
if (/wxwork/i.test(ua) && /Windows|Macintosh/i.test(ua) && !/Android|iPhone|iPad|iPod/i.test(ua)) {{
return false;
}}
return /Android|iPhone|iPad|iPod/i.test(ua);
}}
function closeOnce(fn) {{ function closeOnce(fn) {{
if (closed) return; if (closed) return;
closed = true; closed = true;
@@ -60,46 +71,47 @@ def wecom_auto_close_script() -> str:
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }}); closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
}} }}
// 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。 // 电脑企微:call 关不掉,JS-SDK 又经常没装上。invoke 关内置页,window.close 关独立窗口。
function closeDesktop() {{ function closeDesktop() {{
closeOnce(function () {{ try {{
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow(); if (typeof WeixinJSBridge !== "undefined" && typeof WeixinJSBridge.invoke === "function") {{
else if (window.wx && typeof wx.closeWindow === "function") wx.closeWindow(); WeixinJSBridge.invoke("closeWindow", {{}}, function () {{}});
}}); }}
}} catch (e) {{}}
try {{
if (window.ww && typeof ww.closeWindow === "function") {{
var ret = ww.closeWindow();
if (ret && ret.catch) ret.catch(function () {{}});
}} else if (window.wx && typeof wx.closeWindow === "function") {{
wx.closeWindow();
}}
}} catch (e) {{}}
try {{ window.close(); }} catch (e) {{}}
}} }}
function boot() {{ function boot() {{
if (isPhone()) {{
if (typeof WeixinJSBridge !== "undefined") {{ if (typeof WeixinJSBridge !== "undefined") {{
setTimeout(closeMobile, {delay}); setTimeout(closeMobile, {delay});
return; return;
}} }}
var sawBridge = false;
document.addEventListener("WeixinJSBridgeReady", function () {{ document.addEventListener("WeixinJSBridgeReady", function () {{
sawBridge = true;
setTimeout(closeMobile, {delay}); setTimeout(closeMobile, {delay});
}}, false); }}, false);
var ready = Promise.resolve(); return;
if (window.ww && ww.register && corpId) {{ }}
ready = ww.register({{ setTimeout(closeDesktop, {delay});
if (!(window.ww && ww.register && corpId)) return;
ww.register({{
corpId: corpId, corpId: corpId,
agentId: agentId, agentId: agentId,
jsApiList: ["closeWindow"], jsApiList: ["closeWindow"],
getConfigSignature: sign("corp"), getConfigSignature: sign("corp"),
getAgentConfigSignature: sign("agent") getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }}); }}).then(function () {{
}} setTimeout(closeDesktop, {delay});
ready.then(function () {{
setTimeout(function () {{
if (sawBridge || typeof WeixinJSBridge !== "undefined") {{
if (!closed) closeMobile();
return;
}}
closeDesktop();
}}, {delay});
}}).catch(function () {{ }}).catch(function () {{
setTimeout(function () {{ setTimeout(closeDesktop, {delay});
if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop();
}}, {delay});
}}); }});
}} }}
+129 -46
View File
@@ -2,7 +2,8 @@
意图:空运协同群入站(BOT @ 之后的文字或附件)。 意图:空运协同群入站(BOT @ 之后的文字或附件)。
本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。 本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。
禁止:存档文字当业务;锁舱用当前工单去猜;套 sea_group_ops 建群。 多段单已在本群激活后,没带工单号的后续消息跟这张多段单,不再要求重发工单号。
禁止:存档文字当业务;纯空运锁舱用当前工单去猜;套 sea_group_ops 建群。
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,6 +22,8 @@ from agent.policy.air_group_ops import (
apply_air_file, apply_air_file,
apply_air_lock, apply_air_lock,
apply_air_quote, apply_air_quote,
lock_params_from_text,
text_has_lock_supplement,
apply_air_release, apply_air_release,
apply_air_tms_choice, apply_air_tms_choice,
current_ticket, current_ticket,
@@ -56,6 +59,54 @@ from agent.routing.air_group_intent import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _follow_activated_multi(
engine,
*,
chat_id: str,
sender_id: str,
text: str,
injected_quote: Optional[dict[str, Any]],
named_wo: str = "",
) -> Optional[str]:
"""
多段单激活后,后续消息跟这张单,不必再带工单号。
句中写了工单号就认那张;没写就认本群已经绑上的多段单。
纯空运、陆运旧单返回 None,仍走空运自己的当前单。
副作用:可能发群消息、写入分段报价。在入站线程调用。
"""
from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi
from agent.policy.sea_group_ops import ticket_no
ticket = None
named = (named_wo or "").strip()
if named:
getter = getattr(engine.ledger, "get_ticket", None)
raw = getter(work_order_no=named) if callable(getter) else None
if raw is not None and ticket_is_multi(raw):
ticket = raw
else:
bound = current_ticket(engine, chat_id)
if bound is not None and ticket_is_multi(bound):
ticket = bound
if ticket is None:
return None
logger.info(
"air_group 多段跟单 wo=%s chat=%s named=%s",
ticket_no(ticket),
chat_id,
"1" if named else "0",
)
return handle_multi_inbound(
flow=engine,
ticket=ticket,
chat_id=chat_id,
sender_id=sender_id,
text=text,
injected_quote=injected_quote,
)
def _attach_bot_client(engine, message: InboundMessage) -> None: def _attach_bot_client(engine, message: InboundMessage) -> None:
""" """
每次入站换一次 BOT 出站客户端。 每次入站换一次 BOT 出站客户端。
@@ -116,20 +167,18 @@ def handle_air_group(
return "duplicate_inbound" return "duplicate_inbound"
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text) mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
wo = copy.extract_work_order_no(text) wo = copy.extract_work_order_no(text)
if wo: # 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。
from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi followed = _follow_activated_multi(
engine,
getter = getattr(engine.ledger, "get_ticket", None)
raw_ticket = getter(work_order_no=wo) if callable(getter) else None
if raw_ticket is not None and ticket_is_multi(raw_ticket):
return handle_multi_inbound(
flow=engine,
ticket=raw_ticket,
chat_id=chat_id, chat_id=chat_id,
sender_id=message.sender_id, sender_id=message.sender_id,
text=text, text=text,
injected_quote=injected_quote, injected_quote=injected_quote,
named_wo=wo,
) )
if followed is not None:
return followed
if wo:
from agent.policy.handoff_human import try_commit_air_group_handoff from agent.policy.handoff_human import try_commit_air_group_handoff
handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo) handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo)
@@ -182,10 +231,18 @@ def handle_air_group(
if is_handoff_ticket(ticket): if is_handoff_ticket(ticket):
logger.info("air_group 转人工不再跟 chat=%s", chat_id) logger.info("air_group 转人工不再跟 chat=%s", chat_id)
return "handoff_ignore" return "handoff_ignore"
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused from agent.policy.system_exception import (
cabin_failure_pause,
reply_paused_ticket,
ticket_is_paused,
)
# 锁舱失败留下的暂停只拦还要打 TMS 的操作。成交、未成交和补原因不调用 TMS。 # 查价失败的暂停仍要拦住。锁舱/释放失败已经告诉过原因,不能挡住后面的报价和成交。
if ticket_is_paused(ticket) and not _deal_ignores_tms_pause(ticket, intent): if (
ticket_is_paused(ticket)
and not cabin_failure_pause(ticket)
and not _deal_ignores_tms_pause(ticket, intent)
):
reply_paused_ticket( reply_paused_ticket(
ticket=ticket, ticket=ticket,
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text), reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
@@ -212,6 +269,17 @@ def _handle_file(engine, message: InboundMessage) -> str:
wo = copy.extract_work_order_no( wo = copy.extract_work_order_no(
" ".join([message.content or "", filename]) " ".join([message.content or "", filename])
) )
# 多段已激活的附件也跟当前单,不能再要求先发工单号。
followed = _follow_activated_multi(
engine,
chat_id=chat_id,
sender_id=message.sender_id,
text=" ".join(part for part in ((message.content or "").strip(), filename) if part),
injected_quote=None,
named_wo=wo,
)
if followed is not None:
return followed
if wo: if wo:
ticket, phase = activate_ticket( ticket, phase = activate_ticket(
flow=engine, chat_id=chat_id, work_order_no=wo, speak=False flow=engine, chat_id=chat_id, work_order_no=wo, speak=False
@@ -252,6 +320,29 @@ def _handle_cabin(
_ = injected_quote _ = injected_quote
chat_id = message.chat_id chat_id = message.chat_id
if not work_order_no: if not work_order_no:
# 本群已经在锁这张单时,补舱位不用再报工单号。
bound = current_ticket(engine, chat_id)
if bound is not None and is_air_ticket(bound):
ticket = bound
if intent == INTENT_RELEASE:
return apply_air_release(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
)
option_no, airline, flight_no = lock_params_from_text(message.content or "")
return apply_air_lock(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
option_no=option_no,
airline=airline,
flight_no=flight_no,
)
send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order()) send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order())
return "cabin_need_work_order" return "cabin_need_work_order"
ticket, phase = resolve_cabin_ticket( ticket, phase = resolve_cabin_ticket(
@@ -270,15 +361,17 @@ def _handle_cabin(
chat_id=chat_id, chat_id=chat_id,
instruction=message.content or "", instruction=message.content or "",
) )
# 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。 # 「锁 AIR-OPT01 CZ 航班CZ327」要带上舱位、航司、航班号。缺了先补,不能直接锁。
option_no, airline, flight_no = lock_params_from_text(message.content or "")
return apply_air_lock( return apply_air_lock(
flow=engine, flow=engine,
ticket=ticket, ticket=ticket,
sender_id=message.sender_id, sender_id=message.sender_id,
chat_id=chat_id, chat_id=chat_id,
instruction=message.content or "", instruction=message.content or "",
option_no=_option_from_text(message.content or ""), option_no=option_no,
airline=_airline_from_text(message.content or ""), airline=airline,
flight_no=flight_no,
) )
@@ -315,19 +408,20 @@ def _handle_bound(
if lost: if lost:
return lost return lost
facts = dict(ticket_view(ticket).get("collab_facts") or {}) facts = dict(ticket_view(ticket).get("collab_facts") or {})
if intent == INTENT_PICK_OPTION or ( pending_lock = str(facts.get("__pending_air_lock") or "") == "1"
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1" # 等补舱位时,报价/选线路等意图让路:话里带了舱位、航司或航班号就继续锁。
): if intent == INTENT_PICK_OPTION or pending_lock or text_has_lock_supplement(message.content or ""):
option = _option_from_text(message.content or "") option_no, airline, flight_no = lock_params_from_text(message.content or "")
if option: if option_no or airline or flight_no:
return apply_air_lock( return apply_air_lock(
flow=engine, flow=engine,
ticket=ticket, ticket=ticket,
sender_id=message.sender_id, sender_id=message.sender_id,
chat_id=chat_id, chat_id=chat_id,
instruction=message.content or "", instruction=message.content or "",
option_no=option, option_no=option_no,
airline=_airline_from_text(message.content or ""), airline=airline,
flight_no=flight_no,
) )
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}: if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
outcome = { outcome = {
@@ -363,6 +457,18 @@ def _handle_bound(
sender_id=message.sender_id, sender_id=message.sender_id,
chat_id=chat_id, chat_id=chat_id,
) )
from agent.policy.sea_group_ops import is_sales
from agent.routing.group_intent import resolve_price_intent
# 航线写出「空运费改为900」是报价。模型若判成调价,这里按说话人改回来。
priced = resolve_price_intent(
intent,
message.content or "",
sales=is_sales(ticket, message.sender_id),
)
if priced != intent:
logger.info("air_group 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id)
intent = priced
if intent == INTENT_ADJUST: if intent == INTENT_ADJUST:
from agent.policy.quote_adjust_ops import apply_group_adjust from agent.policy.quote_adjust_ops import apply_group_adjust
@@ -390,26 +496,3 @@ def _handle_bound(
return "group_ignore" return "group_ignore"
def _option_from_text(text: str) -> str:
"""从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。"""
import re
m = re.search(r"AIR-?OPT-?\d+", (text or "").upper())
if not m:
return ""
raw = m.group(0)
# 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠)
if raw.startswith("AIROPT"):
return "AIR-OPT" + raw[6:]
return raw
def _airline_from_text(text: str) -> str:
"""从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。"""
import re
blob = (text or "").upper()
m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob)
if m:
return m.group(1)
return ""
+22 -3
View File
@@ -103,7 +103,7 @@ def handle_group_inbound(
or "" or ""
).upper() ).upper()
if line == "MULTI": if line == "MULTI":
from agent.policy.multi_group_ops import handle_multi_inbound, sender_mode from agent.policy.multi_group_ops import bot_sender_when_air_multi, handle_multi_inbound, sender_mode
if not ( if not (
_sender_allowed(ticket, message.sender_id) _sender_allowed(ticket, message.sender_id)
@@ -111,6 +111,8 @@ def handle_group_inbound(
): ):
logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id) logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id)
return "group_ignore" return "group_ignore"
# 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。
with bot_sender_when_air_multi(ticket):
return handle_multi_inbound( return handle_multi_inbound(
flow=engine, flow=engine,
ticket=ticket, ticket=ticket,
@@ -124,9 +126,14 @@ def handle_group_inbound(
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
return "group_ignore" return "group_ignore"
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused from agent.policy.system_exception import (
cabin_failure_pause,
reply_paused_ticket,
ticket_is_paused,
)
if ticket_is_paused(ticket): # 锁舱失败不挡海运/陆运群里的后续报价。查价失败的暂停仍要停。
if ticket_is_paused(ticket) and not cabin_failure_pause(ticket):
reply_paused_ticket( reply_paused_ticket(
ticket=ticket, ticket=ticket,
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text), reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
@@ -236,6 +243,18 @@ def handle_group_inbound(
sender_id=message.sender_id, sender_id=message.sender_id,
sess=sess, sess=sess,
) )
from agent.policy.sea_group_ops import is_sales
from agent.routing.group_intent import resolve_price_intent
# 产品写出「报关费改为300」是报价。不是这张单的销售时,不能回调价话术。
priced = resolve_price_intent(
intent,
message.content or "",
sales=is_sales(ticket, message.sender_id),
)
if priced != intent:
logger.info("group_inbound 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id)
intent = priced
if intent == INTENT_ADJUST: if intent == INTENT_ADJUST:
from agent.policy.quote_adjust_ops import apply_group_adjust from agent.policy.quote_adjust_ops import apply_group_adjust
+2 -1
View File
@@ -404,6 +404,7 @@ def handle_text_inquiry(
return info if ok else "" return info if ok else ""
from agent.policy.system_exception import ( from agent.policy.system_exception import (
cabin_failure_pause,
extract_work_order_nos, extract_work_order_nos,
reply_paused_ticket, reply_paused_ticket,
ticket_is_paused, ticket_is_paused,
@@ -420,7 +421,7 @@ def handle_text_inquiry(
getter = getattr(ledger, "get_for_agent", None) getter = getattr(ledger, "get_for_agent", None)
if callable(getter): if callable(getter):
ticket = getter(work_order_no=no, sender_id=message.sender_id) ticket = getter(work_order_no=no, sender_id=message.sender_id)
if ticket_is_paused(ticket): if ticket_is_paused(ticket) and not cabin_failure_pause(ticket):
reply_paused_ticket(ticket=ticket, reply=reply) reply_paused_ticket(ticket=ticket, reply=reply)
logger.info("text_inquiry 暂停工单只回兜底 wo=%s sender=%s", no, message.sender_id) logger.info("text_inquiry 暂停工单只回兜底 wo=%s sender=%s", no, message.sender_id)
return "system_exception_paused" return "system_exception_paused"
+6 -19
View File
@@ -40,18 +40,8 @@ class HttpLedger:
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]: def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]:
resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem) resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
if not resp.ok: if not resp.ok:
# 500 时 Jeecg 仍可能带回 message(如六态非法);勿只留 http_500, logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error)
# 否则成交补「已报价」重试认不出「询价中」(WO023)。 return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
data = resp.data if isinstance(resp.data, dict) else {}
msg = ""
if isinstance(data, dict):
msg = str(data.get("message") or "").strip()
unwrapped = _unwrap(data)
if unwrapped.get("error"):
msg = str(unwrapped.get("error") or msg)
err = msg or resp.error or f"http_{resp.status_code}"
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, err)
return {"ok": False, "error": err}
return _unwrap(resp.data) return _unwrap(resp.data)
def find_similar_open( def find_similar_open(
@@ -176,12 +166,8 @@ class HttpLedger:
""" """
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
src = dict(quote or {}) src = dict(quote or {})
if not no: if not no or not src:
return {"ok": False, "error": "work_order_blank"} return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
# 空报价不打主账(避免抹掉已有查价),但禁止伪装成「已写入已报价」——
# 否则会话以为已报价、主账仍停询价中,成交三态会失败(WO022)。
if not src:
return {"ok": False, "error": "quote_blank", "skipped": True}
out = self._post( out = self._post(
"/inquiry/agent/ticket/replaceTmsQuote", "/inquiry/agent/ticket/replaceTmsQuote",
{"workOrderNo": no, "quote": src, "toStatus": to_status}, {"workOrderNo": no, "quote": src, "toStatus": to_status},
@@ -439,6 +425,7 @@ class HttpLedger:
airline: str = "", airline: str = "",
flight_no: str = "", flight_no: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""锁舱出站。航班号和航司一并带上,主账缺了这两项不能当锁舱失败。"""
body: dict[str, Any] = { body: dict[str, Any] = {
"workOrderNo": work_order_no, "workOrderNo": work_order_no,
"optionNo": option_no, "optionNo": option_no,
@@ -453,7 +440,7 @@ class HttpLedger:
return self._post( return self._post(
"/inquiry/agent/tms/lockCabin", "/inquiry/agent/tms/lockCabin",
body, body,
idem=f"air-lock:{work_order_no}:{option_no}", idem=f"air-lock:{work_order_no}:{option_no}:{(airline or '').strip()}:{(flight_no or '').strip()}",
) )
def release_cabin( def release_cabin(
+2 -27
View File
@@ -829,12 +829,8 @@ class MemoryLedger:
airline: str = "", airline: str = "",
flight_no: str = "", flight_no: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。 """单测可注入 _cabin_lock_result;默认成功。"""
_ = sender_id, operator_name, instruction_text, airline, flight_no
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
"""
_ = sender_id, operator_name, instruction_text
forced = getattr(self, "_cabin_lock_result", None) forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict): if isinstance(forced, dict):
return dict(forced) return dict(forced)
@@ -850,27 +846,6 @@ class MemoryLedger:
quote_id = str(quote.get("quoteId") or "").strip() quote_id = str(quote.get("quoteId") or "").strip()
if quote_id.upper().startswith("QT-WO"): if quote_id.upper().startswith("QT-WO"):
quote_id = "" quote_id = ""
air_opts = quote.get("airOptions") or quote.get("air_options") or []
has_tms_cabin = bool(quote_id) or bool(air_opts)
opt = (option_no or "").strip()
air = (airline or "").strip()
fn = (flight_no or "").strip()
if not has_tms_cabin:
missing: list[str] = []
if not opt:
missing.append("舱位编号optionNo")
if not air:
missing.append("航司airline")
if not fn:
missing.append("航班号flightNo")
if missing:
return {
"ok": False,
"error": "lock_incomplete",
"failReason": "锁舱参数不完整,缺少:"
+ "、".join(missing)
+ "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。",
}
return { return {
"ok": True, "ok": True,
"status": "LOCKED", "status": "LOCKED",
+27 -160
View File
@@ -477,8 +477,14 @@ def harvest_collab_labels(text: str) -> dict[str, str]:
「客户名称张三 包装方式编织袋 货值 10万」切成三项。 「客户名称张三 包装方式编织袋 货值 10万」切成三项。
只说「纸箱」没有标签则不收。值取到下一个标签之前。 只说「纸箱」没有标签则不收。值取到下一个标签之前。
「(如:随时可提)」整行丢掉,避免补问示例被当成已填。
""" """
return _harvest_labels(text, _COLLAB_LABELS) kept = "\n".join(
line
for line in (text or "").splitlines()
if not _CLARIFY_EXAMPLE_LINE.match(line.strip())
)
return _harvest_labels(kept, _COLLAB_LABELS)
def harvest_inquiry_optional_labels(text: str) -> dict[str, str]: def harvest_inquiry_optional_labels(text: str) -> dict[str, str]:
@@ -508,7 +514,11 @@ def clip_inquiry_value(raw: str) -> str:
if hit and hit.start() > 0: if hit and hit.start() > 0:
text = text[: hit.start()] text = text[: hit.start()]
text = re.sub(r"(?:\n|\r)+\s*\d+[\.、.]\s*$", "", text) text = re.sub(r"(?:\n|\r)+\s*\d+[\.、.]\s*$", "", text)
return text.strip() text = text.strip()
# 复制补问清单时空着的下一行是「4.提货地址」。切到下一栏后只剩编号,不能当成货好时间。
if re.fullmatch(r"\d+[\.、.]", text):
return ""
return text
def clip_sheet_field_value(raw: str) -> str: def clip_sheet_field_value(raw: str) -> str:
@@ -549,9 +559,12 @@ def clip_sheet_field_value(raw: str) -> str:
if not parts: if not parts:
return "" return ""
# 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。 # 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。
# 只剩「4.」也是空的:那是下一行「4.提货地址」被切掉字段名后留下的编号。
token = parts[0].strip().strip("::") token = parts[0].strip().strip("::")
if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS: if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS:
return "" return ""
if re.fullmatch(r"\d+[\.、.]", token):
return ""
return parts[0].strip() return parts[0].strip()
@@ -757,30 +770,6 @@ _RE_MONEY = re.compile(
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)", r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
re.I, re.I,
) )
# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。
_RE_WEIGHT_BAND_PRICE = re.compile(
r"(?P<min>\d+)\s*[-~~—–到至]\s*(?P<max>\d+)\s*K\s*G"
r"(?:\s*以上)?"
r"\s*(?:单价)?"
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<price>\d+(?:\.\d+)?)",
re.I,
)
_RE_WEIGHT_BAND_OPEN = re.compile(
r"(?P<min>\d+)\s*K\s*G\s*以上"
r"\s*(?:单价)?"
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<price>\d+(?:\.\d+)?)",
re.I,
)
_RE_FUEL_SURCHARGE_RATE = re.compile(
r"燃油附加费率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
re.I,
)
_RE_TAX_RATE = re.compile(
r"(?<![A-Za-z\u4e00-\u9fff])税率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
re.I,
)
_RE_FEE_LINE = re.compile( _RE_FEE_LINE = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})" r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
r"\s*(?:改为|改成|调整为|调整成)?" r"\s*(?:改为|改成|调整为|调整成)?"
@@ -891,8 +880,6 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
认不出价格返回空 dict,调用方回「没有识别到报价」。 认不出价格返回空 dict,调用方回「没有识别到报价」。
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。 群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。 「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
""" """
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")): if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
out = dict(injected) out = dict(injected)
@@ -905,22 +892,16 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
compact = stripped.replace(" ", "").replace(" ", "") compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I): if re.fullmatch(r"WO\d{12}", compact, re.I):
return {} return {}
band = _parse_weight_band_quote(raw) rows = _parse_fee_rows(raw)
if band:
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
scrubbed = _strip_rate_and_band_noise(raw)
rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
if rows: if rows:
ccy = rows[0].get("currency") or _currency_of(raw) ccy = rows[0].get("currency") or _currency_of(raw)
money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")] total = rows[0]["amount"]
total = (money_rows[0] if money_rows else rows[0])["amount"] if len({x.get("currency") for x in rows}) == 1:
if money_rows and len({x.get("currency") for x in money_rows}) == 1:
try: try:
total = str(sum(float(x["amount"]) for x in money_rows)) total = str(sum(float(x["amount"]) for x in rows))
except ValueError: except ValueError:
total = money_rows[0]["amount"] total = rows[0]["amount"]
out = { return {
"total": total, "total": total,
"currency": ccy, "currency": ccy,
"source_label": "产品报价", "source_label": "产品报价",
@@ -928,12 +909,9 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
"fee_rows": rows, "fee_rows": rows,
"fee_lines": list(rows), "fee_lines": list(rows),
} }
out.update(_extract_rate_fields(raw)) hit = _RE_MONEY.search(_ascii_amount(raw))
return out
hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
if not hit: if not hit:
rates = _extract_rate_fields(raw) return {}
return rates if rates else {}
amount = _ascii_amount(hit.group(1) or hit.group(2) or "") amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
if not amount: if not amount:
return {} return {}
@@ -941,13 +919,12 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
# 句里已经点了费用名(空运费)时,不能退回默认海运费。 # 句里已经点了费用名(空运费)时,不能退回默认海运费。
named = re.search( named = re.search(
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)", r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
scrubbed or raw, raw,
) )
fee_name = (named.group(1) if named else "") or "海运费" fee_name = (named.group(1) if named else "") or "海运费"
if fee_name in {"费用", "燃油附加费"}: if fee_name in {"费用"}:
# 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。 fee_name = "海运费"
fee_name = "海运费" if fee_name == "费用" else "陆运费" return {
out = {
"total": amount, "total": amount,
"currency": ccy, "currency": ccy,
"source_label": "产品报价", "source_label": "产品报价",
@@ -955,113 +932,3 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}], "fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}], "fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
} }
out.update(_extract_rate_fields(raw))
return out
def _format_percent_rate(raw: str) -> str:
"""费率展示统一带 %;已有 % 保留。"""
text = (raw or "").strip()
if not text:
return ""
if text.endswith("%"):
return text
return f"{text}%"
def _extract_rate_fields(text: str) -> dict[str, str]:
"""抽出燃油附加费率 / 税率;只返回出现过的键。"""
out: dict[str, str] = {}
fuel = _RE_FUEL_SURCHARGE_RATE.search(text or "")
if fuel:
out["fuel_surcharge_rate"] = _format_percent_rate(fuel.group("rate") or "")
out["fuelSurchargeRate"] = out["fuel_surcharge_rate"]
tax = _RE_TAX_RATE.search(text or "")
if tax:
out["tax_rate"] = _format_percent_rate(tax.group("rate") or "")
out["taxRate"] = out["tax_rate"]
return out
def _strip_rate_and_band_noise(text: str) -> str:
"""去掉分档价与费率短语,避免后续费用行/金额回退误伤。"""
cleaned = _ascii_amount(text or "")
cleaned = _RE_WEIGHT_BAND_PRICE.sub(" ", cleaned)
cleaned = _RE_WEIGHT_BAND_OPEN.sub(" ", cleaned)
cleaned = _RE_FUEL_SURCHARGE_RATE.sub(" ", cleaned)
cleaned = _RE_TAX_RATE.sub(" ", cleaned)
return cleaned
def _parse_weight_band_quote(text: str) -> dict[str, Any]:
"""
陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。
"""
raw = _ascii_amount(text or "")
tiers: list[dict[str, Any]] = []
rows: list[dict[str, str]] = []
ccy = ""
for hit in _RE_WEIGHT_BAND_PRICE.finditer(raw):
lo = int(hit.group("min") or 0)
hi = int(hit.group("max") or 0)
price = _ascii_amount(hit.group("price") or "")
if not price:
continue
line_ccy = _currency_of(raw, hit.group("ccy") or "")
ccy = ccy or line_ccy
label = f"{lo}-{hi}KG"
tiers.append({"min": lo, "max": hi, "price": price})
rows.append(
{
"name": label,
"amount": price,
"currency": line_ccy,
"unit_price": price,
}
)
for hit in _RE_WEIGHT_BAND_OPEN.finditer(raw):
lo = int(hit.group("min") or 0)
price = _ascii_amount(hit.group("price") or "")
if not price:
continue
line_ccy = _currency_of(raw, hit.group("ccy") or "")
ccy = ccy or line_ccy
label = f"{lo}KG以上"
tiers.append({"min": lo, "max": None, "price": price})
rows.append(
{
"name": label,
"amount": price,
"currency": line_ccy,
"unit_price": price,
}
)
if not tiers:
return {}
rates = _extract_rate_fields(raw)
# 费率行进明细方便群摘要展示;金额带 %,合计时只加数字价。
if rates.get("fuel_surcharge_rate"):
rows.append(
{
"name": "燃油附加费率",
"amount": rates["fuel_surcharge_rate"],
"currency": "",
}
)
if rates.get("tax_rate"):
rows.append({"name": "税率", "amount": rates["tax_rate"], "currency": ""})
# 分档是单价,不能把多档相加冒充费用合计。
out: dict[str, Any] = {
"total": tiers[0]["price"],
"currency": ccy or "CNY",
"source_label": "产品报价",
"raw_text": (text or "").strip(),
"fee_rows": rows,
"fee_lines": list(rows),
"weightPrices": tiers,
"price_per_kg": tiers[0]["price"],
}
out.update(rates)
return out
+418 -238
View File
@@ -3,14 +3,14 @@
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。 本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。 禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也可走 MANUAL。 锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。
禁止编造 optionNo;缺舱位编号/航司/计费重时先群里补问,禁止空壳打 TMS。
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
import re
from typing import Any, Optional from typing import Any, Optional
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
@@ -44,6 +44,9 @@ AIRLINE_ROLE = "air"
_PENDING_QUOTE = "__pending_air_quote" _PENDING_QUOTE = "__pending_air_quote"
_PENDING_LOCK = "__pending_air_lock" _PENDING_LOCK = "__pending_air_lock"
_PENDING_LOCK_OPTION = "__pending_lock_option"
_PENDING_LOCK_AIRLINE = "__pending_lock_airline"
_PENDING_LOCK_FLIGHT = "__pending_lock_flight"
_LOCK_ID = "__air_lock_id" _LOCK_ID = "__air_lock_id"
_LOCK_OPTION = "__air_lock_option" _LOCK_OPTION = "__air_lock_option"
_LOCK_QUOTE = "__air_lock_quote" _LOCK_QUOTE = "__air_lock_quote"
@@ -117,7 +120,7 @@ def activate_ticket(
把本群当前工单切到指定空运未结案单。 把本群当前工单切到指定空运未结案单。
返回 (工单, phase)。失败时工单为 None。 返回 (工单, phase)。失败时工单为 None。
航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。 已经在成交跟进,或航线/产品价已经落账时,再发工单号只回到成交跟进,不再发工单摘要和模板。
""" """
book = flow.ledger book = flow.ledger
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
@@ -175,23 +178,45 @@ def activate_ticket(
def _air_already_quoted_for_deal(ticket: object) -> bool: def _air_already_quoted_for_deal(ticket: object) -> bool:
""" """
航线报价已经落账,再激活应回成交跟进。 再激活应回成交跟进,而不是重发工单摘要和空运模板。
附件报价会把来源标成「航线附件报价」,但报价编号仍可能留着 TMS。 报价单已经发给销售(quoteFileIssued)或主账停在 wait_deal 时,
只看 source 会误当成还没报价。还只有 TMS 标准价时仍发模板。 即使报价来源仍是 TMS 标准价,也已经在成交跟进。
协商中、航线/产品价、销售调价同样只回成交跟进。
报价单还没出、也没在等成交时,仍发模板给航线填。
""" """
status = ticket_status(ticket) status = ticket_status(ticket)
if status == "协商中": if status == "协商中":
return True return True
view = ticket_view(ticket)
phase = str(view.get("wait_phase") or view.get("waitPhase") or "").strip()
if phase == "wait_deal":
return True
quote = dict(view.get("quote") or {})
if _quote_file_issued(quote):
return True
if status != "已报价": if status != "已报价":
return False return False
quote = dict(ticket_view(ticket).get("quote") or {})
label = str(quote.get("source_label") or quote.get("sourceLabel") or "") label = str(quote.get("source_label") or quote.get("sourceLabel") or "")
if "航线" in label or "产品" in label: if "航线" in label or "产品" in label:
return True return True
return copy.quote_is_sales_adjust(quote) return copy.quote_is_sales_adjust(quote)
def _quote_file_issued(quote: dict[str, Any]) -> bool:
"""报价单已发给销售,且没有被作废。作废后重新激活仍要发模板。"""
if _flag_on(quote.get("quoteFileVoided") or quote.get("quote_file_voided")):
return False
return _flag_on(quote.get("quoteFileIssued") or quote.get("quote_file_issued"))
def _flag_on(val: object) -> bool:
"""主账布尔可能是 true / \"true\" / 1。"""
if isinstance(val, str):
return val.strip().lower() in {"1", "true", "yes"}
return bool(val)
def _collab_slot_empty(val: object) -> bool: def _collab_slot_empty(val: object) -> bool:
"""协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。""" """协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。"""
return str(val or "").strip() in {"", "-", "—", "-"} return str(val or "").strip() in {"", "-", "—", "-"}
@@ -777,46 +802,22 @@ def apply_air_deal(
if stopped: if stopped:
return stopped return stopped
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group from agent.policy.sea_group_ops import facts_of_group
view = ticket_view(ticket) view = ticket_view(ticket)
no = ticket_no(ticket)
quote = dict(view.get("quote") or {})
status_now = ticket_status(ticket)
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
# 与海运一致:询价中且无报价时先说清原因,禁止笼统「主账状态更新失败」。
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
)
return "deal_need_quote"
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else "" inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
result = finish_deal_outcome( result = finish_deal_outcome(
ledger=flow.ledger, ledger=flow.ledger,
work_order_no=no, work_order_no=ticket_no(ticket),
outcome=outcome, outcome=outcome,
facts=facts_of_group(view), facts=facts_of_group(view),
quote=quote, quote=dict(view.get("quote") or {}),
business_line="AIR", business_line="AIR",
sales_name=str(view.get("sales_name") or ""), sales_name=str(view.get("sales_name") or ""),
lost_reason=inline, lost_reason=inline,
) )
if not result.get("ok"): if not result.get("ok"):
# 迁态失败且仍无报价时,同样用「没有报价」说明,避免旧笼统句。 send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
err = str(result.get("error") or "")
still_no_quote = not (
usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}
)
if still_no_quote or "询价中" in err:
send_group_text(
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
)
else:
send_group_text(
group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。"
)
return "ledger_fail" return "ledger_fail"
phase = str(result.get("phase") or "done") phase = str(result.get("phase") or "done")
ack_reason = inline if phase == "done" and outcome == "未成交" else "" ack_reason = inline if phase == "done" and outcome == "未成交" else ""
@@ -825,7 +826,7 @@ def apply_air_deal(
chat_id, chat_id,
copy.deal_ack( copy.deal_ack(
outcome, outcome,
no, ticket_no(ticket),
str(result.get("next_followup_at") or ""), str(result.get("next_followup_at") or ""),
lost_reason=ack_reason, lost_reason=ack_reason,
), ),
@@ -842,80 +843,45 @@ def apply_air_lock(
instruction: str, instruction: str,
option_no: str = "", option_no: str = "",
airline: str = "", airline: str = "",
flight_no: str = "",
) -> str: ) -> str:
""" """
空运群锁舱。 航线锁舱。
副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。 舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。
舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。 三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。
主账仍缺则回 lock_incomplete,禁止空壳出站。 上次锁舱失败标过暂停的,航线再锁仍要打 TMS。
""" """
if not is_airline(flow, sender_id): if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline" return "not_airline"
# 锁舱是航线主动再试:即使工单因上次失败标了系统异常,也要再打 TMS,
# 不能只回「TMS接口异常,IT运维排查中」。
no = ticket_no(ticket) no = ticket_no(ticket)
name = operator_name(flow, sender_id) name = operator_name(flow, sender_id)
view = ticket_view(ticket) view_quote = dict(ticket_view(ticket).get("quote") or {})
view_quote = dict(view.get("quote") or {})
air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {}) air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {})
quote = air_quote or view_quote quote = air_quote or view_quote
# 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。 quote_id = _quote_id(quote)
tms_quote = copy.resolve_tms_quote(None, view)
if not tms_quote:
tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
quote_options = _air_options(quote)
tms_options = _air_options(tms_quote)
options = quote_options or tms_options
quote_id = _quote_id(quote) or _quote_id(tms_quote)
picked = (option_no or "").strip()
if not picked and len(options) > 1:
facts = dict(view.get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
)
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
return "wait_lock_option"
if not picked and len(options) == 1:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
# 没有任何可锁报价时先拒绝,不要把「无报价」误说成缺舱位。
if not _quote_ready_for_lock(quote) and not _quote_ready_for_lock(tms_quote):
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
airline_code = (airline or "").strip().upper()
if not airline_code:
airline_code = _airline_from_quote_or_option(quote, quote_options, picked)
if not airline_code:
airline_code = _airline_from_quote_or_option(tms_quote, tms_options, picked)
chargeable = _chargeable_weight_kg(quote, quote_options, picked, ticket)
if not chargeable:
chargeable = _chargeable_weight_kg(tms_quote, tms_options, picked, ticket)
flight_no = _flight_no_from_quote_or_option(quote, quote_options, picked, instruction)
if not flight_no:
flight_no = _flight_no_from_quote_or_option(tms_quote, tms_options, picked, instruction)
# 舱位/航司/航班交给主账:库里无 TMS 时会现拉补全;仍缺才回 lock_incomplete。
# 这里只拦计费重全空(工单毛重也没有)——否则会逼航线手打航班。
need_chargeable = not chargeable
if need_chargeable:
facts = dict(ticket_view(ticket).get("collab_facts") or {}) facts = dict(ticket_view(ticket).get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1": picked, air_code, flight = _resolve_lock_params(
flow.ledger.patch_collab_facts( quote,
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id facts,
option_no=option_no,
airline=airline,
flight_no=flight_no,
instruction=instruction,
) )
send_group_text( missing = _missing_lock_labels(picked, air_code, flight)
group_client_of(flow), if missing and _quote_ready_for_lock(quote):
chat_id, _remember_partial_lock(
copy.air_ask_manual_lock_fields( flow,
need_option=False, work_order_no=no,
need_airline=False, sender_id=sender_id,
need_chargeable=True, option_no=picked,
need_flight=False, airline=air_code,
options=options, flight_no=flight,
),
) )
return "wait_lock_fields" send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(missing))
return "wait_lock_params"
locker = getattr(flow.ledger, "lock_cabin", None) locker = getattr(flow.ledger, "lock_cabin", None)
if not callable(locker): if not callable(locker):
send_group_text( send_group_text(
@@ -932,14 +898,6 @@ def apply_air_lock(
confirm_system_exception, confirm_system_exception,
) )
def _lock_terminal(x: object) -> bool:
"""成功,或业务上不必再重试(缺舱位/无报价)。"""
row = x if isinstance(x, dict) else {}
if bool(row.get("ok")):
return True
err = str(row.get("error") or "")
return err in {"lock_incomplete", "no_quote"}
out = call_with_retries( out = call_with_retries(
lambda: locker( lambda: locker(
work_order_no=no, work_order_no=no,
@@ -947,30 +905,38 @@ def apply_air_lock(
sender_id=sender_id, sender_id=sender_id,
operator_name=name, operator_name=name,
instruction_text=instruction, instruction_text=instruction,
airline=airline_code, airline=air_code,
flight_no=flight_no, flight_no=flight,
) )
or {}, or {},
is_ok=_lock_terminal, is_ok=lambda x: bool((x or {}).get("ok")),
) )
if not out.get("ok"): if not out.get("ok"):
if str(out.get("error") or "") == "no_quote": if str(out.get("error") or "") == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no)) send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote" return "no_quote"
if str(out.get("error") or "") == "lock_incomplete": # 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。
# 主账现拉仍无舱位:业务补问,不算系统异常,不惊动 IT。 reason_text = str(out.get("failReason") or "")
why = str(out.get("failReason") or out.get("error") or "锁舱参数不完整") if str(out.get("error") or "") == "lock_params_incomplete" or "锁舱参数不完整" in reason_text:
facts = dict(ticket_view(ticket).get("collab_facts") or {}) labels = missing or _labels_from_incomplete_reason(reason_text)
if str(facts.get(_PENDING_LOCK) or "") != "1": _remember_partial_lock(
flow.ledger.patch_collab_facts( flow,
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id work_order_no=no,
sender_id=sender_id,
option_no=picked,
airline=air_code,
flight_no=flight,
) )
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(labels))
return "wait_lock_params"
# 多段是产品限制,不是 TMS 故障。只回原因,不标系统异常、不暂停工单。
if str(out.get("error") or "") == "multi_no_lock":
send_group_text( send_group_text(
group_client_of(flow), group_client_of(flow),
chat_id, chat_id,
copy.air_lock_incomplete(work_order_no=no, operator=name, reason=why), copy.multi_air_lock_unsupported(work_order_no=no, operator=name),
) )
return "lock_incomplete" return "multi_lock_unsupported"
why = copy.air_cabin_fail_reason( why = copy.air_cabin_fail_reason(
tms_reason=str(out.get("failReason") or out.get("error") or ""), tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket), flight_date=_ticket_flight_date(ticket),
@@ -993,7 +959,8 @@ def apply_air_lock(
conversation_target=chat_id, conversation_target=chat_id,
extra={ extra={
"option_no": picked, "option_no": picked,
"airline": airline_code, "airline": air_code,
"flight_no": flight,
"instruction": instruction, "instruction": instruction,
"sender_id": sender_id, "sender_id": sender_id,
"operator_name": name, "operator_name": name,
@@ -1013,6 +980,9 @@ def apply_air_lock(
_LOCK_QUOTE: quote_no, _LOCK_QUOTE: quote_no,
_LOCK_EXEC: exec_no, _LOCK_EXEC: exec_no,
_PENDING_LOCK: "", _PENDING_LOCK: "",
_PENDING_LOCK_OPTION: "",
_PENDING_LOCK_AIRLINE: "",
_PENDING_LOCK_FLIGHT: "",
}, },
sender_id=sender_id, sender_id=sender_id,
) )
@@ -1259,142 +1229,352 @@ def _is_product_quote(quote: dict[str, Any]) -> bool:
return "航线" in src or "产品" in src return "航线" in src or "产品" in src
def _quote_id(quote: dict[str, Any]) -> str: _OPTION_RE = re.compile(r"AIR-?OPT-?\d+")
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip() _AIRLINE_AFTER_OPTION_RE = re.compile(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])")
_AIRLINE_LABELED_CODE_RE = re.compile(
r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}|[A-Z]\d|\d[A-Z])(?![A-Z0-9])"
)
_AIRLINE_LABELED_NAME_RE = re.compile(
r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([\u4e00-\u9fffA-Za-z0-9]{2,12})"
)
_FLIGHT_LABELED_RE = re.compile(
r"(?:航班号|航班)\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}\s*\d{2,4}|[A-Z]\d\s*\d{2,4}|\d[A-Z]\s*\d{2,4})"
)
_FLIGHT_TRAIL_RE = re.compile(
r"AIR-?OPT-?\d+\s+(?:[A-Z]{2}\s+)?([A-Z]{2}\s*\d{2,4})"
)
_FLIGHT_LOOSE_RE = re.compile(r"(?<![A-Z0-9])(?!WO)([A-Z]{2}\d{2,4})(?!\d)")
_AIRLINE_CODE_RE = re.compile(r"^(?:[A-Z]{2}|[A-Z]\d|\d[A-Z])$")
_FLIGHT_CODE_RE = re.compile(r"^(?:[A-Z]{2}|[A-Z]\d|\d[A-Z])\d{2,4}$")
# 口语航司名归一成二字码。认不出的原样留下,交给 TMS,不因此再追问。
_AIRLINE_ALIASES = (
("中国南方航空", "CZ"),
("南方航空", "CZ"),
("南航", "CZ"),
("中国国际航空", "CA"),
("国际航空", "CA"),
("国航", "CA"),
("中国东方航空", "MU"),
("东方航空", "MU"),
("东航", "MU"),
("海南航空", "HU"),
("海航", "HU"),
("厦门航空", "MF"),
("厦航", "MF"),
("深圳航空", "ZH"),
("深航", "ZH"),
("四川航空", "3U"),
("川航", "3U"),
("山东航空", "SC"),
("山航", "SC"),
("春秋航空", "9C"),
("吉祥航空", "HO"),
("新加坡航空", "SQ"),
("新航", "SQ"),
("泰国航空", "TG"),
("泰航", "TG"),
("国泰航空", "CX"),
("国泰", "CX"),
("卡塔尔航空", "QR"),
("顺丰航空", "O3"),
)
def _quote_ready_for_lock(quote: dict[str, Any]) -> bool: def lock_params_from_text(text: str) -> tuple[str, str, str]:
""" """
有没有可锁的报价:真实 TMS 编号,或人工总价/费用明细。 从航线原话抽出舱位编号、航司、航班号。
与内存账本口径一致,禁止把空报价当成「缺舱位」去补问。
不要求「AIR-OPT01 CZ 航班CZ327」这一种写法。
带「舱位 / 航司 / 航班」的说法、换顺序、航司用中文名,都认。
本地对不上、话里又在补这三项时,再问模型。没说的不猜。
""" """
src = dict(quote or {}) option, airline, flight = _lock_params_local(text)
label = str(src.get("source_label") or src.get("sourceLabel") or "") if option and airline and flight:
if "确认" in label and "TMS" in label.upper(): return option, airline, flight
qid = str(src.get("quoteId") or "").strip() if not _should_ask_lock_model(text, option, airline, flight):
return bool(qid) and not qid.upper().startswith("QT-") return option, airline, flight
if str(src.get("total") or "").strip() not in {"", "-"}: got_opt, got_air, got_flight = _lock_params_from_model(text)
return True return (
if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"): option or got_opt,
return True airline or _normalize_airline(got_air),
if _air_options(src): flight or _normalize_flight(got_flight),
return True )
qid = str(src.get("quoteId") or src.get("quote_id") or "").strip()
return bool(qid) and not qid.upper().startswith("QT-WO")
def _picked_option( def text_has_lock_supplement(text: str) -> bool:
options: list[dict[str, Any]], option_no: str """这句话是在补锁舱三项,不是报价或闲聊。凑齐两项,或点名了舱位/航司/航班,都算。"""
) -> dict[str, Any]: option, airline, flight = _lock_params_local(text)
"""按 optionNo 取舱位;对不上返回空 dict。""" filled = sum(1 for item in (option, airline, flight) if item)
if filled >= 2:
return True
raw = text or ""
if filled < 1 or not any(word in raw for word in ("舱位", "航司", "航班")):
return False
from agent.routing.group_intent import text_is_staff_fee_quote
return not text_is_staff_fee_quote(raw)
def _lock_params_local(text: str) -> tuple[str, str, str]:
"""按原话里的编号、标签和常见航司名收三项。不调模型。"""
raw = text or ""
blob = raw.upper()
option = _option_from_blob(blob)
flight = _flight_from_blob(blob)
airline = _airline_from_text(raw, blob, option=option, flight=flight)
return option, airline, flight
def _option_from_blob(blob: str) -> str:
found = _OPTION_RE.search(blob or "")
if not found:
return ""
raw = found.group(0)
if raw.startswith("AIROPT"):
return "AIR-OPT" + raw[6:]
return raw
def _flight_from_blob(blob: str) -> str:
labeled = _FLIGHT_LABELED_RE.search(blob or "")
if labeled:
return _normalize_flight(labeled.group(1))
trailed = _FLIGHT_TRAIL_RE.search(blob or "")
if trailed:
return _normalize_flight(trailed.group(1))
loose = _FLIGHT_LOOSE_RE.search(blob or "")
if loose:
return _normalize_flight(loose.group(1))
return ""
def _airline_from_text(raw: str, blob: str, *, option: str, flight: str) -> str:
"""航司可以是二字码,也可以是「航司南航」这种说法。"""
labeled = _AIRLINE_LABELED_CODE_RE.search(blob or "")
if labeled:
return labeled.group(1)
named = _AIRLINE_LABELED_NAME_RE.search(raw or "")
if named:
token = named.group(1).strip()
if token.upper() not in {"AIR", "OPT"} and not token.upper().startswith("AIR-OPT"):
return _normalize_airline(token)
after = _AIRLINE_AFTER_OPTION_RE.search(blob or "")
if after:
return after.group(1)
# 没写「航司」二字时,只有这句话已经带了舱位或航班,才从航司名里认,避免报价句子被误伤。
if option or flight:
found = _alias_in_text(raw)
if found:
return found
return ""
def _normalize_flight(value: str) -> str:
compact = re.sub(r"\s+", "", (value or "").upper())
return compact if _FLIGHT_CODE_RE.match(compact) else ""
def _normalize_airline(value: str) -> str:
text = (value or "").strip()
if not text:
return ""
upper = re.sub(r"\s+", "", text.upper())
if _FLIGHT_CODE_RE.match(upper):
return ""
if _AIRLINE_CODE_RE.match(upper):
return upper
alias = _alias_in_text(text)
if alias:
return alias
if re.fullmatch(r"[\u4e00-\u9fffA-Za-z0-9]{2,12}", text) and not text.upper().startswith("AIR"):
return text
return ""
def _alias_in_text(text: str) -> str:
raw = text or ""
for name, code in _AIRLINE_ALIASES:
if name in raw:
return code
return ""
def _airline_ready(airline: str) -> bool:
text = (airline or "").strip()
if _AIRLINE_CODE_RE.match(text.upper()):
return True
return bool(_normalize_airline(text))
def _should_ask_lock_model(text: str, option: str, airline: str, flight: str) -> bool:
"""三项已经齐,或这句话根本没在说舱位,就不要再打模型。"""
if option and airline and flight:
return False
raw = text or ""
if any(word in raw for word in ("舱位", "航司", "航班")):
return True
blob = raw.upper()
return "AIR-OPT" in blob or "AIROPT" in blob
def _lock_params_from_model(text: str) -> tuple[str, str, str]:
"""
模型只抽舱位编号、航司、航班号。
网络未开或调用失败时当没抽到,群里继续按缺项追问。不在请求里猜。
"""
try:
from agent.config import get_settings
from agent.llm.http_client import LlmHttpClient
from agent.llm.modes import LlmMode
settings = get_settings()
if not (
getattr(settings, "llm_allow_network", False)
and getattr(settings, "llm_data_usage_confirmed", False)
):
return "", "", ""
client = LlmHttpClient.from_settings(settings)
data = client.chat(
mode=LlmMode.EXTRACT_FIELDS,
messages=[
{
"role": "system",
"content": (
"从航线这句话抽出锁舱三项。只回复一行 JSON,不要解释。"
'键为 optionNo、airline、flightNo。没有的填空字符串。'
"optionNo 是舱位编号,如 AIR-OPT01;airline 是航司二字码或航司名;"
"flightNo 是航班号,如 CZ327。不要编造原文没有的值。"
),
},
{"role": "user", "content": (text or "")[:500]},
],
)
content = str((data or {}).get("content") or "")
start = content.find("{")
end = content.rfind("}")
if start < 0 or end <= start:
return "", "", ""
payload = json.loads(content[start : end + 1])
if not isinstance(payload, dict):
return "", "", ""
option = _option_from_blob(str(payload.get("optionNo") or "").upper())
airline = _normalize_airline(str(payload.get("airline") or ""))
flight = _normalize_flight(str(payload.get("flightNo") or ""))
return option, airline, flight
except Exception:
logger.exception("锁舱三项模型抽取失败")
return "", "", ""
def _resolve_lock_params(
quote: dict[str, Any],
facts: dict[str, Any],
*,
option_no: str,
airline: str,
flight_no: str,
instruction: str,
) -> tuple[str, str, str]:
"""
拼出锁舱三项。
这一句里写的优先;没写的用上一句暂存的;再没有才用报价上已有的舱位。
航司写成南航、东方航空这类名字时归一成二字码;归一不了也算已经补充,不再追问。
"""
said_opt, said_air, said_flight = lock_params_from_text(instruction)
option = (option_no or said_opt or str(facts.get(_PENDING_LOCK_OPTION) or "")).strip()
air = _normalize_airline(airline or said_air or str(facts.get(_PENDING_LOCK_AIRLINE) or ""))
flight = _normalize_flight(flight_no or said_flight or str(facts.get(_PENDING_LOCK_FLIGHT) or ""))
chosen = _option_row(quote, option)
if chosen is None and not option:
options = _air_options(quote)
if len(options) == 1:
chosen = options[0]
option = str(chosen.get("optionNo") or chosen.get("option_no") or "").strip()
if not _airline_ready(air) and chosen is not None:
air = _normalize_airline(
str(chosen.get("airlineCode") or chosen.get("airline") or "")
)
if not flight and chosen is not None:
flight = _normalize_flight(str(chosen.get("flightNo") or chosen.get("flight_no") or ""))
if not flight:
flight = _normalize_flight(str(quote.get("flightNo") or quote.get("flight_no") or ""))
if not _airline_ready(air):
air = ""
if not air and len(flight) >= 2 and flight[:2].isalpha():
air = flight[:2]
return option, air, flight
def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any]]:
"""按舱位编号找报价里的那一条;对不上返回空。"""
want = (option_no or "").strip().upper() want = (option_no or "").strip().upper()
if not want: if not want:
return {} return None
for row in options: for row in _air_options(quote):
no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper() no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
if no == want: if no == want:
return row return row
return {} return None
def _airline_from_quote_or_option( def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[str]:
quote: dict[str, Any], """缺的中文名,顺序固定:舱位编号、航司、航班号。"""
options: list[dict[str, Any]], missing: list[str] = []
if not (option_no or "").strip():
missing.append("舱位编号")
if not _airline_ready(airline):
missing.append("航司")
if not _FLIGHT_CODE_RE.match((flight_no or "").strip().upper()):
missing.append("航班号")
return missing
def _labels_from_incomplete_reason(reason: str) -> list[str]:
"""主账回了「缺少:…」时,按中文名收回缺项,丢掉英文字段名。"""
found = [label for label in ("舱位编号", "航司", "航班号") if label in (reason or "")]
return found or ["舱位编号", "航司", "航班号"]
def _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
"""有总价、费用或报价编号才值得让航线补舱位。完全没报价仍走「还没有报价」。"""
if str(quote.get("total") or "").strip() not in {"", "-"}:
return True
if quote.get("fee_lines") or quote.get("feeItems") or quote.get("departureCharges"):
return True
if _air_options(quote):
return True
return bool(str(quote.get("quoteId") or quote.get("quote_id") or "").strip())
def _remember_partial_lock(
flow: Any,
*,
work_order_no: str,
sender_id: str,
option_no: str, option_no: str,
) -> str: airline: str,
""" flight_no: str,
锁舱航司:舱位 airlineCode > 报价顶层 > 航班号前两位。 ) -> None:
只回两位大写字母;猜不到就空,交给群里补问。 """先记下已经说清的项,下一句补齐后接着锁,不用重说。"""
""" patcher = getattr(flow.ledger, "patch_collab_facts", None)
opt = _picked_option(options, option_no) if not callable(patcher):
raw = str( return
opt.get("airlineCode") patcher(
or opt.get("airline") work_order_no=work_order_no,
or quote.get("airlineCode") facts={
or quote.get("airline") _PENDING_LOCK: "1",
or "" _PENDING_LOCK_OPTION: option_no,
).strip().upper() _PENDING_LOCK_AIRLINE: airline,
if len(raw) >= 2 and raw[:2].isalpha(): _PENDING_LOCK_FLIGHT: flight_no,
return raw[:2] },
flight = str( sender_id=sender_id,
opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or ""
).strip().upper()
if len(flight) >= 2 and flight[0].isalpha() and flight[1].isalpha():
return flight[:2]
name = str(opt.get("airlineName") or quote.get("airlineName") or "").strip()
# 名称里不硬猜二字码,避免「南航」编成乱码。
_ = name
return ""
def _flight_no_from_quote_or_option(
quote: dict[str, Any],
options: list[dict[str, Any]],
option_no: str,
instruction: str = "",
) -> str:
"""航班号:舱位/报价字段,或句中「航班CZ327」「CZ327」形态。"""
import re
opt = _picked_option(options, option_no)
raw = str(opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or "").strip()
if raw:
return raw.upper()
blob = (instruction or "").upper()
m = re.search(r"(?:航班|FLIGHT)\s*([A-Z0-9]{2,8})", blob)
if m:
return m.group(1)
m = re.search(r"\b([A-Z]{2}\d{2,4})\b", blob)
if m:
return m.group(1)
return ""
def _chargeable_weight_kg(
quote: dict[str, Any],
options: list[dict[str, Any]],
option_no: str,
ticket: object,
) -> str:
"""
计费重:舱位 > 报价 > 工单事实「计费重」> 毛重。
有正数才返回;否则空串,门禁会补问。
"""
opt = _picked_option(options, option_no)
candidates = [
opt.get("chargeableWeightKg") or opt.get("chargeable_weight_kg"),
quote.get("chargeableWeightKg") or quote.get("chargeable_weight_kg"),
]
view = ticket_view(ticket)
facts = dict(view.get("facts") or {})
candidates.extend(
[
facts.get("计费重"),
facts.get("计费重量"),
view.get("chargeableWeightKg"),
facts.get("毛重"),
view.get("weightKg") or view.get("weight_kg"),
]
) )
for raw in candidates:
text = str(raw or "").strip().replace(",", "")
if not text or text in {"-", "null", "None"}: def _quote_id(quote: dict[str, Any]) -> str:
continue return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
# 去掉「公斤」「KGS」等后缀,只留数字。
num = ""
for ch in text:
if ch.isdigit() or ch == ".":
num += ch
elif num:
break
if not num:
continue
try:
if float(num) > 0:
return num
except ValueError:
continue
return ""
def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]: def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
+29 -26
View File
@@ -1260,13 +1260,39 @@ class AirTextInquiryFlow:
quote = tms["quote"] quote = tms["quote"]
from agent.schema.air_route_options import ( from agent.schema.air_route_options import (
choose_stated_origin_option,
needs_route_pick, needs_route_pick,
options_matching_origin,
parse_air_options, parse_air_options,
project_selected_quote, project_selected_quote,
) )
options = parse_air_options(quote) options = parse_air_options(quote)
if options and not needs_route_pick(quote): stated_origin = str(
(sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or ""
).strip()
# 写了起运地:只认这个起运地的线路,直接报价,不让销售再选。
# 没写起运地:TMS 按目的地回多条时,才进选线页。
if stated_origin:
matched = options_matching_origin(options, stated_origin)
if options and not matched:
logger.info(
"air 起运地对不上回包线路 origin=%s wo=%s options=%s",
stated_origin,
no,
len(options),
)
self._emit_tms_miss(reply, no)
sess.phase = "tms_miss"
sess.status = "询价中"
sess.allowed = ()
self._save(sess)
return "tms_miss"
if matched:
quote = project_selected_quote(
quote, choose_stated_origin_option(matched, quote)
)
elif options and not needs_route_pick(quote):
quote = project_selected_quote(quote, options[0]) quote = project_selected_quote(quote, options[0])
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价") up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"): if not up.get("ok"):
@@ -1278,7 +1304,7 @@ class AirTextInquiryFlow:
copy.remember_tms_quote(sess, quote) copy.remember_tms_quote(sess, quote)
sess.tms_quote = dict(quote) sess.tms_quote = dict(quote)
sess.status = "已报价" sess.status = "已报价"
if needs_route_pick(quote): if not stated_origin and needs_route_pick(quote):
return self._offer_air_routes( return self._offer_air_routes(
sess=sess, sess=sess,
work_order_no=no, work_order_no=no,
@@ -1780,12 +1806,8 @@ class AirTextInquiryFlow:
extra, extra,
) )
from agent.redis_coord.quote_file import mark_quote_file_ready from agent.redis_coord.quote_file import mark_quote_file_ready
from agent.policy.deal_outcome import ensure_ticket_quoted
mark_quote_file_ready(sess.work_order_no) mark_quote_file_ready(sess.work_order_no)
# 文件已出就必须让主账离开「询价中」,否则成交三态会被六态拒绝。
ensure_ticket_quoted(self._ledger, work_order_no=sess.work_order_no)
sess.status = sess.status or "已报价"
sess.phase = "wait_deal" sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1 sess.wait_version += 1
@@ -1930,36 +1952,19 @@ class AirTextInquiryFlow:
"""点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。""" """点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。"""
self._grey_clicked_card(reply, card_meta, replace_name=status) self._grey_clicked_card(reply, card_meta, replace_name=status)
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
from agent.policy.sea_group_ops import _quote_fee_rows
quote = dict(sess.quote or {})
status_now = str(sess.status or "").strip()
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
if not (
usable_total
or _quote_fee_rows(quote)
or status_now in {"已报价", "协商中"}
):
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
return "deal_need_quote"
inline = harvest_inline_lost_reason(source_text) if status == "未成交" else "" inline = harvest_inline_lost_reason(source_text) if status == "未成交" else ""
result = finish_deal_outcome( result = finish_deal_outcome(
ledger=self._ledger, ledger=self._ledger,
work_order_no=sess.work_order_no, work_order_no=sess.work_order_no,
outcome=status, outcome=status,
facts=sess.facts, facts=sess.facts,
quote=quote, quote=sess.quote,
business_line=sess.business_line, business_line=sess.business_line,
sales_name=sess.invited_sales_name, sales_name=sess.invited_sales_name,
lost_reason=inline, lost_reason=inline,
) )
if not result.get("ok"): if not result.get("ok"):
err = str(result.get("error") or "")
if (not usable_total and not _quote_fee_rows(quote)) or "询价中" in err:
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
else:
reply("主账状态更新失败,成交结果未入账。") reply("主账状态更新失败,成交结果未入账。")
return "ledger_fail" return "ledger_fail"
phase = str(result.get("phase") or "done") phase = str(result.get("phase") or "done")
@@ -2016,10 +2021,8 @@ class AirTextInquiryFlow:
if not no: if not no:
return return
from agent.redis_coord.quote_file import mark_quote_file_ready from agent.redis_coord.quote_file import mark_quote_file_ready
from agent.policy.deal_outcome import ensure_ticket_quoted
mark_quote_file_ready(no) mark_quote_file_ready(no)
ensure_ticket_quoted(self._ledger, work_order_no=no)
if not sender_id: if not sender_id:
return return
with self._lock: with self._lock:
+1 -78
View File
@@ -210,11 +210,7 @@ def finish_deal_outcome(
writer = getattr(ledger, "transition", None) writer = getattr(ledger, "transition", None)
if not callable(writer): if not callable(writer):
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"} return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
# 报价文件已出、会话已进入成交跟进,但主账仍停在「询价中」时, result = writer(work_order_no=no, to_status=status, **extra) or {}
# 直接迁「未成交/已成交/协商中」会被六态拒绝。先补「已报价」再迁目标态。
result = _transition_deal_status(
writer, work_order_no=no, to_status=status, extra=extra, ledger=ledger
)
if not result.get("ok"): if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"} return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
@@ -245,79 +241,6 @@ def finish_deal_outcome(
} }
def _transition_deal_status(
writer: Callable[..., dict[str, Any]],
*,
work_order_no: str,
to_status: str,
extra: dict[str, Any],
ledger: Any = None,
) -> dict[str, Any]:
"""
写入成交三态。若主账仍停在询价中,先迁已报价再迁目标态。
运行证据:
- WO022/WO023:文件已发仍停询价中,直迁未成交被六态拒绝;
- HTTP 500 时 http_ledger 常只回 http_500(正文里的「询价中」丢失),
不能只靠错误文案判断,要以工单当前状态为准。
"""
no = (work_order_no or "").strip()
status = (to_status or "").strip()
result = writer(work_order_no=no, to_status=status, **extra) or {}
if result.get("ok"):
return result
if status not in {"已成交", "未成交", "协商中"}:
return result
err = str(result.get("error") or result.get("message") or "")
cur = ""
getter = getattr(ledger, "get_for_agent", None) if ledger is not None else None
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="") or {}
cur = str(snap.get("status") or "").strip()
except Exception:
cur = ""
# 状态确认询价中,或错误文案带询价中,或仅有 http_5xx 时仍尝试补已报价。
should_promote = (
cur == "询价中"
or "询价中" in err
or err.startswith("http_5")
or err in {"transition_failed", "ledger_error"}
)
if not should_promote:
return result
promoted = writer(
work_order_no=no,
to_status="已报价",
remark="成交跟进前补已报价",
) or {}
if not promoted.get("ok"):
return result
return writer(work_order_no=no, to_status=status, **extra) or {}
def ensure_ticket_quoted(ledger: Any, *, work_order_no: str) -> dict[str, Any]:
"""
报价文件发出 / 进入成交跟进时,保证主账至少是「已报价」。
幂等:已离开「询价中」则不再迁态(已报价→已报价会被六态拒绝)。
"""
no = (work_order_no or "").strip()
writer = getattr(ledger, "transition", None)
if not no or not callable(writer):
return {"ok": False, "error": "no_transition"}
getter = getattr(ledger, "get_for_agent", None)
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="") or {}
except Exception:
snap = {}
st = str(snap.get("status") or "").strip()
if st and st != "询价中":
return {"ok": True, "status": st, "skipped": True}
return writer(work_order_no=no, to_status="已报价", remark="报价文件已出") or {}
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]: def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。@询价小助手 不是原因。""" """把销售下一句写进未成交原因。@询价小助手 不是原因。"""
text = strip_bot_mentions(reason or "") text = strip_bot_mentions(reason or "")
+66 -113
View File
@@ -1023,11 +1023,6 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
amount = str(item.get("amount") or item.get("value") or "").strip() amount = str(item.get("amount") or item.get("value") or "").strip()
line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip() line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
if name and amount and amount != "-": if name and amount and amount != "-":
# 燃油附加费率 / 税率是百分比,禁止套成「CNY 1%」。
if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}:
shown = amount if "%" in amount else f"{amount}%"
lines.append((name, shown))
else:
lines.append((name, money_text(amount, line_ccy))) lines.append((name, money_text(amount, line_ccy)))
if lines: if lines:
return lines return lines
@@ -1224,21 +1219,10 @@ def sea_quote_detail_text(
def collab_hint(work_order_no: str) -> str: def collab_hint(work_order_no: str) -> str:
""" """空运价格卡协同说明:请把相关方拉进原有协同群,并 @ 询价小助手。紧挨生成报价单按钮上方。"""
空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。 return (
f"请手动将相关方拉到空运原有协同群,并在空运协同群中发送"
后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。 f"“ {work_order_no}@询价小助手”,谢谢。"
无后台模板时用本默认句(已带工单号)。销售侧示例:
…发送“ WO202610060030@询价小助手”,谢谢。
"""
no = (work_order_no or "").strip()
return _r(
"inquiring.air_collab_hint",
(
f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群,"
f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。"
),
ticket_no=no,
) )
@@ -2400,8 +2384,14 @@ def adjust_ineligible(reason: str) -> str:
return "当前工单还没有可调的报价,不能按这套调价。" return "当前工单还没有可调的报价,不能按这套调价。"
def adjust_only_sales() -> str: def adjust_only_sales(*, air_room: bool = False) -> str:
return "只有这张单的销售能调价。航线/产品请继续报价,价格未改。" """
群里不是这张单的销售在发调价。价格不改。
空运群称呼航线;海运、陆运称呼产品。两句用句号分开,避免连成一句。
"""
who = "航线" if air_room else "产品"
return f"只有这张单的销售能调价。{who}请继续报价,价格未改。"
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str: def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
@@ -2677,24 +2667,8 @@ def group_deal_stale(*, work_order_no: str) -> str:
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。" return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
def group_deal_need_quote(*, work_order_no: str = "") -> str: def group_deal_need_quote() -> str:
""" return "还没有可确认的报价,请先完成报价再确认成交状态。"
群里确认成交/未成交时,主账还没有可确认报价。
空运 WO027:询价中且无 inq_quote,旧句「主账状态更新失败」不说明原因。
"""
no = (work_order_no or "").strip()
if no:
return (
f"工单{no}状态更新失败,原因:没有报价。\n"
f"请先报价,然后再更新工单状态。"
)
return "工单状态更新失败,原因:没有报价。\n请先报价,然后再更新工单状态。"
def deal_fail_no_quote(*, work_order_no: str = "") -> str:
"""成交三态写入失败且判定为没有报价时的销售可见说明。与群门禁同句。"""
return group_deal_need_quote(work_order_no=work_order_no)
def group_deal_sales_only() -> str: def group_deal_sales_only() -> str:
@@ -2778,9 +2752,10 @@ def _followup_month_day(raw: str) -> str:
def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str: def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str:
""" """
收下未成交原因后回显。后台 lost.reason_ack。 收下未成交原因后的回执。后台 lost.reason_ack。
测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。 后台模板里的 {{lost_reason}}、{{ticket_no}} 都在这里填。
不传工单号时占位留空串,避免群里再露出 {{ticket_no}}。
""" """
text = (reason or "").strip() text = (reason or "").strip()
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
@@ -2932,7 +2907,8 @@ def air_group_brief(
空运群激活摘要:与海运进群同一套结构,字段用空运。 空运群激活摘要:与海运进群同一套结构,字段用空运。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。 先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。 TMS 段跟单聊同一套线路分项,总销售价只一行。
已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。
不写「当前工单/询价要点/现在等到哪一步」。 不写「当前工单/询价要点/现在等到哪一步」。
""" """
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
@@ -3059,6 +3035,23 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str:
return air_lock_need_quote(work_order_no) return air_lock_need_quote(work_order_no)
def air_lock_need_params(missing=None) -> str:
"""
舱位编号、航司、航班号没齐时,只请航线补全。
这时还没锁舱,不能说 TMS 锁舱失败,也不能通知 IT。
缺哪项就点哪项;三项都缺时是产品指定的那一句。
"""
labels = [str(x).strip() for x in (missing or []) if str(x).strip()]
if not labels:
labels = ["舱位编号", "航司", "航班号"]
joined = "、".join(labels)
return (
f"锁舱参数不完整,缺少:{joined}。"
"请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"
)
def air_lock_need_quote(work_order_no: str) -> str: def air_lock_need_quote(work_order_no: str) -> str:
"""没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。""" """没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
@@ -3081,53 +3074,6 @@ def air_ask_lock_option(options: list) -> str:
return "\n".join(lines) return "\n".join(lines)
def air_ask_manual_lock_fields(
*,
need_option: bool = False,
need_airline: bool = False,
need_chargeable: bool = False,
need_flight: bool = False,
options: list | None = None,
) -> str:
"""
手动报价锁舱缺 TMS MANUAL 必填项时,群里补问,禁止空壳出站。
例:请回复「AIR-OPT01 CZ 航班CZ327」。
"""
bits: list[str] = []
if need_option:
bits.append("舱位编号(如 AIR-OPT01)")
if need_airline:
bits.append("航司两位码(如 CZ)")
if need_flight:
bits.append("航班号(如 CZ327)")
if need_chargeable:
bits.append("计费重(公斤)")
need = "、".join(bits) if bits else "舱位编号、航司和航班号"
lines = [
f"锁舱还缺:{need}。",
"请按「AIR-OPT01 CZ 航班CZ327」回复;",
]
if need_chargeable:
lines.append("若还缺计费重,请再写「计费重xx公斤」。")
else:
lines.append("例如:AIR-OPT01 CZ 航班CZ327")
opts = options or []
shown = False
for item in opts:
if not isinstance(item, dict):
continue
no = str(item.get("optionNo") or item.get("option_no") or "").strip()
airline = str(item.get("airlineCode") or item.get("airlineName") or "").strip()
if not no:
continue
if not shown:
lines.append("可选舱位:")
shown = True
lines.append(f"{no} {airline}".strip() if airline else no)
return "\n".join(lines)
def air_lock_ok( def air_lock_ok(
*, *,
work_order_no: str, work_order_no: str,
@@ -3155,8 +3101,34 @@ def air_lock_ok(
) )
# 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 TMS 故障。
MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱"
# 后台锁舱失败模板末尾常有「已通知IT…请稍等」。多段这条要整句拿掉。
_IT_NOTICE_LINE = re.compile(r"已通知\s*IT[^\n。]*。?")
def multi_air_lock_unsupported(*, work_order_no: str, operator: str) -> str:
"""
多段含空运、航线锁舱时的群回复。
抬头、操作人、失败原因仍走锁舱失败模板,只去掉通知 IT 那句。
不打主账、不标系统异常。
"""
text = air_lock_fail(
work_order_no=work_order_no,
operator=operator,
reason=MULTI_LOCK_UNSUPPORTED,
)
kept: list[str] = []
for line in text.splitlines():
cleaned = _IT_NOTICE_LINE.sub("", line).strip()
if cleaned:
kept.append(cleaned)
return "\n".join(kept).strip()
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str: def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
"""空运锁舱失败(接口/系统类)。后台 inquiring.air_lock_fail。""" """空运锁舱失败。后台 inquiring.air_lock_fail。"""
why = (reason or "").strip() or "TMS未返回原因" why = (reason or "").strip() or "TMS未返回原因"
default = ( default = (
f"工单{work_order_no} 空运锁舱失败。\n" f"工单{work_order_no} 空运锁舱失败。\n"
@@ -3173,25 +3145,6 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
) )
def air_lock_incomplete(*, work_order_no: str, operator: str, reason: str = "") -> str:
"""
锁舱参数不齐(纯手工价、运价库无舱位等):业务补问,不算系统异常,不惊动 IT。
航线按示例回舱位+航司+航班后即可再锁。
"""
why = (reason or "").strip()
lines = [
f"工单{work_order_no} 还不能锁舱。",
f"操作人:{operator}",
]
if why:
lines.append(f"原因:{why}")
else:
lines.append("原因:本单没有可引用的 TMS 舱位(手工价或运价库无匹配)。")
lines.append("请直接回复「AIR-OPT01 CZ 航班CZ327」(换成实际舱位、航司、航班号)后再锁。")
return "\n".join(lines)
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str: def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
""" """
锁舱/释放失败原因:原样回 TMS 的 failReason。 锁舱/释放失败原因:原样回 TMS 的 failReason。
@@ -3359,7 +3312,7 @@ def system_exception_alert_payload(
""" """
系统异常通知卡:走询价消息服务,不走询价小助手。 系统异常通知卡:走询价消息服务,不走询价小助手。
五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。 五行:异常类型、接口/服务、报错原因、影响范围、发生时间。
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。 标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
""" """
no = (work_order_no or "").strip() or "无" no = (work_order_no or "").strip() or "无"
@@ -3368,7 +3321,7 @@ def system_exception_alert_payload(
rows = [ rows = [
{"keyname": "异常类型", "value": (exception_type or "").strip() or "-"}, {"keyname": "异常类型", "value": (exception_type or "").strip() or "-"},
{"keyname": "接口/服务", "value": (service or "").strip() or "-"}, {"keyname": "接口/服务", "value": (service or "").strip() or "-"},
{"keyname": "错误码", "value": (error_code or "").strip() or "-"}, {"keyname": "报错原因", "value": (error_code or "").strip() or "-"},
{"keyname": "影响范围", "value": system_exception_impact(step)}, {"keyname": "影响范围", "value": system_exception_impact(step)},
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"}, {"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
] ]
@@ -3409,7 +3362,7 @@ def system_exception_alert_text(
f"关联工单 {no}", f"关联工单 {no}",
f"异常类型:{(exception_type or '').strip() or '-'}", f"异常类型:{(exception_type or '').strip() or '-'}",
f"接口/服务:{(service or '').strip() or '-'}", f"接口/服务:{(service or '').strip() or '-'}",
f"错误码:{(error_code or '').strip() or '-'}", f"报错原因:{(error_code or '').strip() or '-'}",
f"影响范围:{system_exception_impact(step)}", f"影响范围:{system_exception_impact(step)}",
f"发生时间:{(occurred_at or '').strip() or '-'}", f"发生时间:{(occurred_at or '').strip() or '-'}",
] ]
+45 -9
View File
@@ -11,7 +11,8 @@ from __future__ import annotations
import json import json
import logging import logging
from typing import Any, Callable, Optional from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy import inquiry_copy as copy from agent.policy import inquiry_copy as copy
@@ -20,6 +21,7 @@ from agent.policy.sea_group_ops import (
apply_group_deal, apply_group_deal,
group_client_of, group_client_of,
is_sales, is_sales,
override_group_client,
send_filled_sheet, send_filled_sheet,
send_group_text, send_group_text,
ticket_no, ticket_no,
@@ -33,6 +35,24 @@ _ROLE_MODE = {"sea": "SEA", "land": "LAND", "air": "AIR"}
SheetFn = Callable[..., dict[str, Any]] SheetFn = Callable[..., dict[str, Any]]
@contextmanager
def bot_sender_when_air_multi(ticket: object) -> Iterator[None]:
"""
含空运的多段用的是机器人原来的群。应用接口发不进这个群,报价和模板都会没反应。
这一次出站改走机器人桥,发完还原。没有空运段的多段仍用应用自己建的群,这里不动。
只影响当前线程,不改流程上缓存的客户端。
"""
if not includes_air(load_segments(ticket)):
yield
return
from agent.channel.aibot.reply import AibotReplyClient
logger.info("multi.outbound 含空运走机器人桥 wo=%s", ticket_no(ticket))
with override_group_client(AibotReplyClient()):
yield
def ticket_is_multi(ticket: object) -> bool: def ticket_is_multi(ticket: object) -> bool:
"""主账业务线是多段联运。""" """主账业务线是多段联运。"""
view = ticket_view(ticket) view = ticket_view(ticket)
@@ -257,8 +277,17 @@ def _save_segment_quote(
def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, intent: str) -> str: def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, intent: str) -> str:
"""空运段的锁舱、释放走现有空运规则,不挡住其他段报价。""" """
from agent.policy.air_group_ops import apply_air_lock, apply_air_release 多段含空运时的锁舱、释放。
锁舱暂不支持:只回复失败原因,不打 TMS,也不标系统异常。
释放仍走空运规则。调用方已确认本单含空运段。群消息在 Worker 线程发出。
"""
from agent.policy.air_group_ops import (
apply_air_release,
is_airline,
operator_name,
)
if intent == "release": if intent == "release":
return apply_air_release( return apply_air_release(
@@ -268,13 +297,20 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i
chat_id=chat_id, chat_id=chat_id,
instruction=text, instruction=text,
) )
return apply_air_lock( if not is_airline(flow, sender_id):
flow=flow, send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
ticket=ticket, return "not_airline"
sender_id=sender_id, no = ticket_no(ticket)
chat_id=chat_id, send_group_text(
instruction=text, group_client_of(flow),
chat_id,
copy.multi_air_lock_unsupported(
work_order_no=no,
operator=operator_name(flow, sender_id),
),
) )
logger.info("multi.lock unsupported wo=%s sender=%s", no, sender_id)
return "multi_lock_unsupported"
def _cabin_intent(text: str) -> str: def _cabin_intent(text: str) -> str:
@@ -260,7 +260,14 @@ def apply_group_adjust(
send_group_text(client, room, copy.adjust_ineligible("no_quote")) send_group_text(client, room, copy.adjust_ineligible("no_quote"))
return "adjust_blocked" return "adjust_blocked"
if not is_sales(ticket, sender_id): if not is_sales(ticket, sender_id):
send_group_text(client, room, copy.adjust_only_sales()) line = str(view.get("business_line") or view.get("businessLine") or "").upper()
air_room = line == "AIR"
if line == "MULTI":
from agent.policy.multi_group_ops import load_segments
from agent.policy.multi_segments import includes_air
air_room = includes_air(load_segments(ticket))
send_group_text(client, room, copy.adjust_only_sales(air_room=air_room))
return "adjust_not_sales" return "adjust_not_sales"
current = _current_quote(view, sess) current = _current_quote(view, sess)
names = [str(r.get("name") or "") for r in adjustable_rows(current)] names = [str(r.get("name") or "") for r in adjustable_rows(current)]
+34 -61
View File
@@ -9,7 +9,9 @@ from __future__ import annotations
import json import json
import logging import logging
from typing import Any, Optional from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Iterator, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy.quote_overlay import overlay_labeled_facts from agent.policy.quote_overlay import overlay_labeled_facts
@@ -244,14 +246,40 @@ def filename_is_excel_pdf(name: str) -> bool:
return any(raw.endswith(ext) for ext in EXCEL_PDF) return any(raw.endswith(ext) for ext in EXCEL_PDF)
# 这一次群出站临时改用的客户端。只在当前线程有效,不改流程单例上的企微客户端。
_outbound_client: ContextVar[Any] = ContextVar("inquiry_group_outbound_client", default=None)
def group_client_of(flow: Any) -> Any: def group_client_of(flow: Any) -> Any:
"""正式路径没有注入客户端时,走流程自己的默认企微客户端。""" """
取这一次群出站用的客户端。
有临时指定时用指定的(含空运的多段要走机器人桥)。
没有时走流程自己的客户端;正式路径默认是应用发群。
"""
override = _outbound_client.get()
if override is not None:
return override
getter = getattr(flow, "group_client", None) getter = getattr(flow, "group_client", None)
if callable(getter): if callable(getter):
return getter() return getter()
return getattr(flow, "_group_client", None) return getattr(flow, "_group_client", None)
@contextmanager
def override_group_client(client: Any) -> Iterator[None]:
"""
这一次群出站改用指定客户端,用完还原。
只影响当前线程。不改流程单例,海运自己建的群不会被别的工单抢走客户端。
"""
token = _outbound_client.set(client)
try:
yield
finally:
_outbound_client.reset(token)
def _archive_seat_ids(flow: Any) -> set[str]: def _archive_seat_ids(flow: Any) -> set[str]:
"""会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。""" """会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。"""
seats: set[str] = {"AI001", "询价机器人"} seats: set[str] = {"AI001", "询价机器人"}
@@ -906,14 +934,12 @@ def apply_product_quote(
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200]) logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
send_group_text(client, chat_id, copy.group_quote_not_recognized()) send_group_text(client, chat_id, copy.group_quote_not_recognized())
return "quote_not_recognized" return "quote_not_recognized"
quote = _attach_land_weight_band_segment(quote, view)
base = _current_quote(view, sess) base = _current_quote(view, sess)
if _has_tms_quote(base): if _has_tms_quote(base):
_park_pending_quote(no, quote, sess, flow) _park_pending_quote(no, quote, sess, flow)
send_group_text(client, chat_id, copy.group_ask_tms_or_product()) send_group_text(client, chat_id, copy.group_ask_tms_or_product())
return "wait_tms_choice" return "wait_tms_choice"
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。 if _quote_fee_rows(base):
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
quote = merge_quote_fees(base, quote) quote = merge_quote_fees(base, quote)
_save_product_quote(flow, no, quote, sess) _save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket ticket2 = _reload(flow, chat_id, no) or ticket
@@ -1233,11 +1259,8 @@ def apply_group_deal(
return "not_sales" return "not_sales"
quote = _current_quote(view, sess) quote = _current_quote(view, sess)
status_now = ticket_status(ticket) or (sess.status if sess else "") status_now = ticket_status(ticket) or (sess.status if sess else "")
usable_total = str(quote.get("total") or "").strip() if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
if usable_total in {"", "-"}: send_group_text(client, chat_id, copy.group_deal_need_quote())
usable_total = ""
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(client, chat_id, copy.group_deal_need_quote(work_order_no=no))
return "deal_need_quote" return "deal_need_quote"
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
@@ -1317,10 +1340,7 @@ def capture_group_lost_reason(
send_group_text( send_group_text(
client, client,
chat_id, chat_id,
copy.lost_reason_ack( copy.lost_reason_ack(str(result.get("lost_reason") or text), work_order_no=no),
str(result.get("lost_reason") or text),
work_order_no=no,
),
) )
return "done" return "done"
@@ -1646,53 +1666,6 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
return stamp_prices_for_sheet(out) return stamp_prices_for_sheet(out)
def _attach_land_weight_band_segment(
quote: dict[str, Any], view: dict[str, Any]
) -> dict[str, Any]:
"""
陆运产品分档价:把 weightPrices / 燃油税率挂到 landSegments,出单才能填「2-500KG」列。
无分档价时原样返回。OD 取工单询价事实,不另猜城市。
"""
src = dict(quote or {})
tiers = src.get("weightPrices")
if not isinstance(tiers, list) or not tiers:
return src
facts = dict(view.get("facts") or {})
origin = str(
facts.get("始发地")
or facts.get("起运地")
or facts.get("origin")
or view.get("origin")
or ""
).strip()
dest = str(
facts.get("目的地")
or facts.get("destination")
or view.get("destination")
or ""
).strip()
seg: dict[str, Any] = {
"origin": origin,
"destination": dest,
"weightPrices": list(tiers),
"currency": str(src.get("currency") or "CNY"),
"salePrice": str(src.get("total") or tiers[0].get("price") or ""),
"price_per_kg": str(src.get("price_per_kg") or tiers[0].get("price") or ""),
}
fuel = str(src.get("fuel_surcharge_rate") or src.get("fuelSurchargeRate") or "").strip()
tax = str(src.get("tax_rate") or src.get("taxRate") or "").strip()
if fuel:
seg["fuel_surcharge_rate"] = fuel
seg["fuelSurchargeRate"] = fuel
if tax:
seg["tax_rate"] = tax
seg["taxRate"] = tax
src["landSegments"] = [seg]
src["route_rows"] = [seg]
return src
def _save_product_quote( def _save_product_quote(
flow: Any, flow: Any,
no: str, no: str,
+31 -8
View File
@@ -95,6 +95,35 @@ def ticket_is_paused(ticket: Any) -> bool:
return bool(ticket_exception_type(ticket)) return bool(ticket_exception_type(ticket))
def ticket_pause_step(ticket: Any) -> str:
"""暂停是哪一步留下的。查价、锁舱、释放各算各的。"""
if ticket is None:
return ""
if isinstance(ticket, dict):
step = str(
ticket.get("system_exception_step") or ticket.get("systemExceptionStep") or ""
).strip()
payload = ticket.get("system_exception_payload") or ticket.get("systemExceptionPayload") or {}
else:
step = str(getattr(ticket, "system_exception_step", "") or "").strip()
payload = getattr(ticket, "system_exception_payload", {}) or {}
if step:
return step
if isinstance(payload, dict):
return str(payload.get("step") or "").strip()
return ""
def cabin_failure_pause(ticket: Any) -> bool:
"""
锁舱或释放失败留下的暂停。
群里已经回过失败原因,并通知了 IT。之后的手工报价、补字段、成交不能再被
「TMS接口异常」挡住。查价失败的暂停不在这里,那种仍要停。
"""
return ticket_pause_step(ticket) in {STEP_TMS_LOCK, STEP_TMS_RELEASE, STEP_TMS_CABIN}
def extract_work_order_nos(text: str) -> list[str]: def extract_work_order_nos(text: str) -> list[str]:
"""从原话抽出正式工单号(WO + 日期流水)。不认虚拟号。""" """从原话抽出正式工单号(WO + 日期流水)。不认虚拟号。"""
import re import re
@@ -850,7 +879,6 @@ def _resume_cabin_step(
work_order_no=no, work_order_no=no,
lock_id=lock_id, lock_id=lock_id,
sender_id=sender, sender_id=sender,
operator_name=str(extra.get("operator_name") or "").strip(),
instruction_text=instruction, instruction_text=instruction,
) )
or {} or {}
@@ -861,20 +889,15 @@ def _resume_cabin_step(
service = "空运舱位" service = "空运舱位"
reason = "空运释放舱位接口调用失败" reason = "空运释放舱位接口调用失败"
else: else:
# 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
locker = getattr(ledger, "lock_cabin", None) locker = getattr(ledger, "lock_cabin", None)
op_name = str(extra.get("operator_name") or "").strip()
airline = str(extra.get("airline") or "").strip()
flight_no = str(extra.get("flight_no") or "").strip()
out = call_with_retries( out = call_with_retries(
lambda: locker( lambda: locker(
work_order_no=no, work_order_no=no,
option_no=option_no, option_no=option_no,
sender_id=sender, sender_id=sender,
operator_name=op_name,
instruction_text=instruction, instruction_text=instruction,
airline=airline, airline=str(extra.get("airline") or ""),
flight_no=flight_no, flight_no=str(extra.get("flight_no") or ""),
) )
or {} or {}
if callable(locker) if callable(locker)
+16 -13
View File
@@ -26,6 +26,8 @@ from agent.routing.group_intent import (
_hint_confirm_tms, _hint_confirm_tms,
_hint_deal_outcome, _hint_deal_outcome,
_hint_tms_choice, _hint_tms_choice,
_prefer_staff_quote,
text_is_staff_fee_quote,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -95,6 +97,11 @@ def classify_air_group_text(
locked = _hint_lock_or_release(raw) locked = _hint_lock_or_release(raw)
if locked: if locked:
return locked return locked
# 补舱位不限写法。凑齐舱位编号、航司、航班号就当选舱,不要先当成报价。
from agent.policy.air_group_ops import text_has_lock_supplement
if text_has_lock_supplement(raw):
return INTENT_PICK_OPTION
# 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。 # 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。
exact = _exact_tms_choice(raw) exact = _exact_tms_choice(raw)
if exact: if exact:
@@ -118,12 +125,15 @@ def classify_air_group_text(
if use_net and getattr(settings, "llm_data_usage_confirmed", False): if use_net and getattr(settings, "llm_data_usage_confirmed", False):
got = _classify_with_model(raw, settings) got = _classify_with_model(raw, settings)
if got in _VALID: if got in _VALID:
return got return _prefer_staff_quote(got, raw)
except Exception: except Exception:
logger.exception("空运群意图模型失败,回退规则") logger.exception("空运群意图模型失败,回退规则")
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
from agent.schema.quote_adjust import extract_adjust from agent.schema.quote_adjust import extract_adjust
# 费用金额先当航线报价。「空运费改为900」不是销售调价。
if text_is_staff_fee_quote(raw):
return INTENT_QUOTE
if extract_adjust(raw).is_adjust: if extract_adjust(raw).is_adjust:
return INTENT_ADJUST return INTENT_ADJUST
if extract_product_quote(raw): if extract_product_quote(raw):
@@ -154,17 +164,10 @@ def _hint_lock_or_release(text: str) -> str:
def _only_work_order(text: str) -> bool: def _only_work_order(text: str) -> bool:
""" """去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。"""
去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。
销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。
运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。
"""
no = copy.extract_work_order_no(text) no = copy.extract_work_order_no(text)
rest = (text or "").replace(no, "").replace(no.lower(), "") rest = (text or "").replace(no, "").replace(no.lower(), "")
rest = rest.replace("@询价小助手", "").replace("询价小助手", "") rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
for noise in ("工单编号", "工单号", "编号"):
rest = rest.replace(noise, "")
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff") rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
return not rest.strip() return not rest.strip()
@@ -183,10 +186,10 @@ def _classify_with_model(text: str, settings) -> str:
"你是空运协同群意图分类。只回复一个英文词,不要解释。" "你是空运协同群意图分类。只回复一个英文词,不要解释。"
"air_lock=航线要锁舱;air_release=航线要释放舱位;" "air_lock=航线要锁舱;air_release=航线要释放舱位;"
"air_activate=只是指定/切换工单号;" "air_activate=只是指定/切换工单号;"
"air_pick_option=在选舱位选项编号;" "air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;"
"collab_fields=在补是否含电/是否含磁;" "collab_fields=在补是否含电/是否含磁;"
"quote_adjust=销售在调已有费用,口令不写死(上升、往上抬、加一成、改到某金额都算),不是新产品报价;" "quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;"
"product_quote=航线在报价格或费用(含具体金额);" "product_quote=航线在报某一项费用的金额。写出「空运费9900」「空运费改为900USD」是报价,不是调价;"
"quote_confirm_tms=确认沿用当前TMS报价;" "quote_confirm_tms=确认沿用当前TMS报价;"
"quote_keep_tms=要在已有TMS报价上改;" "quote_keep_tms=要在已有TMS报价上改;"
"quote_drop_tms=不要TMS、只用刚报的价;" "quote_drop_tms=不要TMS、只用刚报的价;"
@@ -195,7 +198,7 @@ def _classify_with_model(text: str, settings) -> str:
"other=闲聊。" "other=闲聊。"
"一句话里既有锁舱又有工单号,回 air_lock。" "一句话里既有锁舱又有工单号,回 air_lock。"
"一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。" "一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。"
"改已有费用优先 quote_adjust;新报价格才是 product_quote。" "有具体费用金额就是 product_quote。只有比例或只改总价才是 quote_adjust。"
) )
data = client.chat( data = client.chat(
mode=LlmMode.ORDERED_ACTIONS, mode=LlmMode.ORDERED_ACTIONS,
+65 -4
View File
@@ -117,6 +117,63 @@ _DROP_HINTS = (
) )
def text_is_staff_fee_quote(text: str) -> bool:
"""
航线/产品在报某一项费用的金额。
「空运费 9900」「空运费改为900USD」「报关费改为300CNY」都算手工报价。
比例调价和只改总价/总销售价不算,那两句仍留给销售调价。
句里没有费用名、只剩一个数字,也不算,避免把选项编号当成报价。
不打模型。分类和入站改判都会用。
"""
raw = (text or "").strip()
if not raw:
return False
from agent.schema.quote_adjust import text_has_relative_adjust, text_has_total_only_adjust
if text_has_relative_adjust(raw) or text_has_total_only_adjust(raw):
return False
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(raw)
rows = [row for row in (quote.get("fee_rows") or []) if isinstance(row, dict)]
if not rows:
return False
# 「费用合计」里的「费」不能单独把改总价认成报价;上面已经排除只改总价。
if "费" in raw:
return True
names = [str(row.get("name") or "").strip() for row in rows]
return any(name and name != "海运费" for name in names)
def resolve_price_intent(intent: str, text: str, *, sales: bool) -> str:
"""
按说话人把「报价」和「调价」分开。
航线/产品写出费用金额:按报价办,不能回「只有销售能调价」。
销售在做比例、改总价,或点名把某项费用改到某个金额:仍按调价办。
不打模型。群入站分发前调用,避免手工报价被调价话术拦住。
"""
if intent not in {INTENT_ADJUST, INTENT_QUOTE}:
return intent
from agent.schema.quote_adjust import extract_adjust
# 这里只用本地规则。正式调价抽取仍在调价处理里,必要时再问模型。
if sales and extract_adjust(text, allow_model=False).is_adjust:
return INTENT_ADJUST
if text_is_staff_fee_quote(text):
return INTENT_QUOTE
return intent
def _prefer_staff_quote(intent: str, text: str) -> str:
"""模型或本地规则把费用金额判成调价时,改回报价。比例和改总价不动。"""
if intent == INTENT_ADJUST and text_is_staff_fee_quote(text):
logger.info("判成调价,但这是费用报价,改回报价 text=%s", (text or "")[:40])
return INTENT_QUOTE
return intent
def classify_group_text( def classify_group_text(
text: str, text: str,
*, *,
@@ -163,12 +220,15 @@ def classify_group_text(
if hinted_deal and got in {INTENT_DISSOLVE, INTENT_OTHER, INTENT_FIELDS}: if hinted_deal and got in {INTENT_DISSOLVE, INTENT_OTHER, INTENT_FIELDS}:
logger.warning("群意图模型把成交判成%s,改回 %s text=%s", got, hinted_deal, raw[:40]) logger.warning("群意图模型把成交判成%s,改回 %s text=%s", got, hinted_deal, raw[:40])
return hinted_deal return hinted_deal
return got return _prefer_staff_quote(got, raw)
except Exception: except Exception:
logger.exception("群意图模型失败,回退标签字段") logger.exception("群意图模型失败,回退标签字段")
from agent.llm.extract_text import extract_product_quote from agent.llm.extract_text import extract_product_quote
from agent.schema.quote_adjust import extract_adjust from agent.schema.quote_adjust import extract_adjust
# 费用金额先当报价。「改为900」是航线/产品常用写法,不能先被调价规则抢走。
if text_is_staff_fee_quote(raw):
return INTENT_QUOTE
if extract_adjust(raw).is_adjust: if extract_adjust(raw).is_adjust:
return INTENT_ADJUST return INTENT_ADJUST
if extract_product_quote(raw): if extract_product_quote(raw):
@@ -249,8 +309,8 @@ def _classify_with_model(text: str, settings) -> str:
system = ( system = (
"你是海运协同群意图分类。只回复一个英文词,不要解释。" "你是海运协同群意图分类。只回复一个英文词,不要解释。"
"collab_fields=在补货好时间/商品海关编码/含油含电含磁;" "collab_fields=在补货好时间/商品海关编码/含油含电含磁;"
"quote_adjust=销售在调已有费用,口令不写死(上升、上浮、往上抬、加一成、减、改到某金额都算),不是新产品报价;" "quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;"
"product_quote=产品在报价格或费用(含具体金额);" "product_quote=产品在报某一项费用的金额。写出「运费1000」「报关费改为300CNY」是报价,不是调价;"
"quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;" "quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;"
"quote_keep_tms=要在已有TMS报价上改,没有报新金额;" "quote_keep_tms=要在已有TMS报价上改,没有报新金额;"
"quote_drop_tms=不要TMS、只用产品刚报的价,没有报新金额;" "quote_drop_tms=不要TMS、只用产品刚报的价,没有报新金额;"
@@ -260,7 +320,8 @@ def _classify_with_model(text: str, settings) -> str:
"dissolve=要把这个协同群解散;" "dissolve=要把这个协同群解散;"
"other=闲聊、问候、对询价港口的随口更正(不当改单)。" "other=闲聊、问候、对询价港口的随口更正(不当改单)。"
"一句话里既补字段又报价,回 product_quote。" "一句话里既补字段又报价,回 product_quote。"
"有金额就是 product_quote,不要判成 quote_keep_tms。" "有具体费用金额就是 product_quote,不要判成 quote_adjust 或 quote_keep_tms。"
"只有比例或只改总价才是 quote_adjust。"
) )
data = client.chat( data = client.chat(
mode=LlmMode.ORDERED_ACTIONS, mode=LlmMode.ORDERED_ACTIONS,
+64 -10
View File
@@ -45,10 +45,55 @@ def parse_air_options(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
def needs_route_pick(quote: dict[str, Any] | None) -> bool: def needs_route_pick(quote: dict[str, Any] | None) -> bool:
"""多于一条线路才进 H5 选。""" """没写起运地、且多于一条线路,才进 H5 选。有起运地时由调用方先按起运地收口。"""
return len(parse_air_options(quote)) > 1 return len(parse_air_options(quote)) > 1
def options_matching_origin(
options: list[dict[str, Any]],
origin_text: str,
) -> list[dict[str, Any]]:
"""
销售已经写了起运地时,只留起运机场对得上的线路。
杭州对 HGH,珠海对 ZUH。回包里的南京/深圳/郑州不能拿来给这张单选。
对不上返回空列表。不改入参。
"""
text = (origin_text or "").strip()
if not text:
return list(options)
code = resolve_airport_code(text).upper()
raw = text.upper()
matched: list[dict[str, Any]] = []
for opt in options:
origin = str(opt.get("originAirportCode") or opt.get("origin") or "").strip().upper()
if not origin:
continue
if code and origin == code:
matched.append(opt)
elif origin == raw:
matched.append(opt)
return matched
def choose_stated_origin_option(
options: list[dict[str, Any]],
quote: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
起运地已指定时直接用一条,不再让销售选线。
只有一条就用它。多条时优先和报价总价相同的那条,否则用第一条。
"""
if len(options) == 1:
return options[0]
total = (quote or {}).get("total")
for opt in options:
if _same_amount(opt.get("salePrice"), total):
return opt
return options[0]
def _place_name(code: str, fallback: str = "") -> str: def _place_name(code: str, fallback: str = "") -> str:
"""三字码能对上就用中文,否则用原文或 fallback。""" """三字码能对上就用中文,否则用原文或 fallback。"""
raw = (code or "").strip() raw = (code or "").strip()
@@ -331,14 +376,14 @@ def _same_amount(left: Any, right: Any) -> bool:
def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]: def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]:
""" """
整单兜底「空运费=总价」不是线路分项,群摘要禁止再写。 已经有其它费用分项时,去掉「空运费=总价」这一行兜底。
真有空运费且金额不等于总价(例如另有附加费)则保留。 舱位上有运输费、提送货费时,主账快照常再塞一行空运费,金额就是总价,不能和分项叠在一起。
只有空运费这一项时必须留下:TMS 经常只回这一项,金额自然等于总价,群里仍要写出费用名。
不写库。群摘要投影之后调用。
""" """
src = dict(quote or {}) src = dict(quote or {})
total = src.get("total") total = src.get("total")
if _same_amount(src.get("freight"), total):
src["freight"] = "-"
raw = src.get("fee_lines") or src.get("fee_rows") raw = src.get("fee_lines") or src.get("fee_rows")
parsed: Any = raw parsed: Any = raw
if isinstance(raw, str) and raw.strip(): if isinstance(raw, str) and raw.strip():
@@ -348,17 +393,26 @@ def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]
parsed = None parsed = None
if not isinstance(parsed, list): if not isinstance(parsed, list):
return src return src
kept: list[Any] = [] others: list[Any] = []
alias: list[Any] = []
for item in parsed: for item in parsed:
if not isinstance(item, dict): if not isinstance(item, dict):
kept.append(item) others.append(item)
continue continue
name = str(item.get("name") or item.get("label") or "").strip() name = str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
amount = item.get("amount") or item.get("value") amount = item.get("amount") or item.get("value")
if name == "空运费" and _same_amount(amount, total): if name == "空运费" and _same_amount(amount, total):
alias.append(item)
continue continue
kept.append(item) others.append(item)
src["fee_lines"] = kept if others:
# 分项已经在,空运费=总价是重复兜底。运费字段同值也不再补一行。
src["fee_lines"] = others
if _same_amount(src.get("freight"), total):
src["freight"] = "-"
return src
# 明细就是这一条空运费,群摘要要显示费用名,不能只剩费用合计。
src["fee_lines"] = alias
return src return src
+2 -1
View File
@@ -520,9 +520,10 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
群里口语协同:含油/含电/含磁、货好时间、是否为危险品。 群里口语协同:含油/含电/含磁、货好时间、是否为危险品。
只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。 只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。
「(如:随时可提)」这种补问示例行不算销售说了货好时间。
危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。 危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。
""" """
raw = text or "" raw = _without_clarify_examples(text or "")
out: dict[str, str] = {} out: dict[str, str] = {}
yes_no = ( yes_no = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
@@ -493,6 +493,40 @@ def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None)
return out return out
def text_has_relative_adjust(text: str) -> bool:
"""
这句话是在按比例改价:加10%、减5%、上调、上浮、加一成。
这种是销售调价,不是航线/产品报一个新金额。不打模型。
"""
raw = text or ""
if _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw):
return True
blob = raw.replace(" ", "").replace(" ", "")
return any(phrase in blob for phrase in ("加一成", "减一成", "往上抬", "往下压", "上浮", "下浮"))
def text_has_total_only_adjust(text: str) -> bool:
"""
只动总价/总销售价/费用合计,没有点名某一项费用。
「总销售价改为180」仍是销售调价。点了「空运费」「报关费」则不是。不打模型。
"""
raw = text or ""
if not (_TARGET.search(raw) or _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw)):
return False
if not any(word in raw for word in _TOTAL_WORDS) and "报价" not in raw:
return False
real: list[str] = []
for name in _FEE_TOKEN.findall(raw):
if name in {"费用", "费用合计"}:
continue
if any(word in name for word in ("合计", "总价", "汇总")):
continue
real.append(name)
return not real
def extract_adjust( def extract_adjust(
text: str, text: str,
*, *,
+4 -1
View File
@@ -35,6 +35,9 @@ _AIRPORTS = {
"南京": "NKG", "南京": "NKG",
"吉隆坡": "KUL", "吉隆坡": "KUL",
"北京": "PEK", "北京": "PEK",
"杭州": "HGH",
"萧山": "HGH",
"杭州萧山": "HGH",
} }
# 历史字典:只给对照,空运出站不再编成 1/2/3。 # 历史字典:只给对照,空运出站不再编成 1/2/3。
@@ -199,7 +202,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
包装只看销售是否说了,散货等原文原样保留。 包装只看销售是否说了,散货等原文原样保留。
""" """
src = dict(facts or {}) src = dict(facts or {})
origin = str(src.get("起运港") or "").strip() origin = str(src.get("起运港") or src.get("起运地") or "").strip()
dest = str(src.get("目的港") or "").strip() dest = str(src.get("目的港") or "").strip()
cargo = str(src.get("品名") or "").strip() cargo = str(src.get("品名") or "").strip()
pieces = _first_number(str(src.get("件数") or "")) pieces = _first_number(str(src.get("件数") or ""))
+5 -5
View File
@@ -3,8 +3,8 @@
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。 本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。 带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。 出站类型/线路译成 TMS 用的值,线路键名 routeCategory。工单与卡片仍用销售中文。
中港整车、中港零担/集拼出站类型都是 CHINA_HK。中港整车不带车型/数量。 中港整车、中港零担/集拼出站类型都只写「中港」。中港整车不带车型/数量。
禁止:把运输分类放进出站;直连 TMS;改六态。 禁止:把运输分类放进出站;直连 TMS;改六态。
线程:纯函数。 线程:纯函数。
""" """
@@ -29,8 +29,8 @@ _TYPE_ENUM = {
"国内运输整车": "DOMESTIC_FTL", "国内运输整车": "DOMESTIC_FTL",
"跨境集拼": "CROSS_BORDER_LTL", "跨境集拼": "CROSS_BORDER_LTL",
"跨境整车": "CROSS_BORDER_FTL", "跨境整车": "CROSS_BORDER_FTL",
"中港整车": "CHINA_HK", "中港整车": "中港",
"中港零担/集拼": "CHINA_HK", "中港零担/集拼": "中港",
} }
_ROUTE_ENUM = { _ROUTE_ENUM = {
"国内长途/零担": "DOMESTIC_LONG_LTL", "国内长途/零担": "DOMESTIC_LONG_LTL",
@@ -45,7 +45,7 @@ _TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$")
def tms_land_type_code(raw: str) -> str: def tms_land_type_code(raw: str) -> str:
"""销售中文运输类型 → TMS 6.1.1 landTransportType。对不上空串。""" """销售中文运输类型 → 查价 landTransportType。中港两类都是「中港」。对不上空串。"""
text = (raw or "").strip() text = (raw or "").strip()
if text in _TYPE_ENUM: if text in _TYPE_ENUM:
return _TYPE_ENUM[text] return _TYPE_ENUM[text]
+338 -48
View File
@@ -160,7 +160,7 @@ class AirGroupCollabTests(unittest.TestCase):
"source_label": "TMS 标准报价", "source_label": "TMS 标准报价",
"quoteId": "QT-AIR-1", "quoteId": "QT-AIR-1",
"airOptions": [ "airOptions": [
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航"} {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航", "flightNo": "CZ327"}
], ],
} }
if quote: if quote:
@@ -203,6 +203,67 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK) self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK)
self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE) self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE)
def test_airline_fee_wording_is_quote_not_adjust(self) -> None:
"""航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。"""
from agent.routing.air_group_intent import INTENT_ADJUST, INTENT_QUOTE
self.assertEqual(
classify_air_group_text("空运费改为900USD", allow_network=False),
INTENT_QUOTE,
)
self.assertEqual(
classify_air_group_text("报关费改为300CNY", allow_network=False),
INTENT_QUOTE,
)
self.assertEqual(
classify_air_group_text("空运费 9900CNY", allow_network=False),
INTENT_QUOTE,
)
self.assertEqual(
classify_air_group_text("空运费加10%", allow_network=False),
INTENT_ADJUST,
)
self.assertEqual(
classify_air_group_text("总销售价改为180", allow_network=False),
INTENT_ADJUST,
)
def test_airline_rewritten_fee_does_not_say_only_sales(self) -> None:
"""已有报价后,航线再写费用金额,不能提示只有销售能调价。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="fee0"),
flow=self.flow,
ledger=self.ledger,
)
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="fee1"),
flow=self.flow,
ledger=self.ledger,
)
self.assertNotEqual(phase, "adjust_not_sales")
self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts()))
self.assertIn(phase, {"wait_tms_choice", "quoted"})
def test_sales_percent_still_adjusts(self) -> None:
"""销售说加百分之几,仍是调价,不会被改成航线报价。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="adj0"),
flow=self.flow,
ledger=self.ledger,
)
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="sales1", text="空运费加10%", chat="wr_air_1", mid="adj1"),
flow=self.flow,
ledger=self.ledger,
)
self.assertNotEqual(phase, "not_airline")
self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts()))
self.assertIn(phase, {"group_adjusted", "adjust_need_fee", "adjust_none", "adjust_need_lines"})
def test_air_group_brief_matches_sea_sections(self) -> None: def test_air_group_brief_matches_sea_sections(self) -> None:
text = copy.air_group_brief( text = copy.air_group_brief(
work_order_no="WO202609160009", work_order_no="WO202609160009",
@@ -373,6 +434,51 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("费用合计:CNY 200040", tms_block) self.assertIn("费用合计:CNY 200040", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1) self.assertEqual(tms_block.count("费用合计:"), 1)
def test_air_group_brief_keeps_sole_air_freight_line(self) -> None:
"""TMS 只有空运费、金额等于总价时,群摘要必须写出这一行,不能只剩费用合计。"""
tms = {
"source": "TMS",
"source_label": "TMS标准报价",
"total": "1833.7",
"freight": "1833.70",
"currency": "CNY",
"valid_until": "2026-10-21",
"fee_lines": '[{"name":"空运费","amount":"CNY 1833.70"}]',
"feeItems": [
{
"feeName": "空运费",
"feeCode": "AIR_FREIGHT",
"amount": 1833.70,
"unitPrice": 11.00,
"quantity": 166.7,
"currency": "CNY",
}
],
"airOptions": [
{
"originAirportCode": "CGO",
"destinationAirportCode": "KUL",
"salePrice": 1833.70,
"currency": "CNY",
"departureCharges": [],
}
],
}
text = copy.air_group_brief(
work_order_no="WO202610080020",
facts={"起运地": "CGO", "目的地": "吉隆坡", "originCode": "CGO"},
quote=tms,
collab_facts={},
has_price=True,
tms_quote=tms,
)
tms_block = text.split("TMS报价:", 1)[1]
self.assertIn("空运费:CNY 1833.70", tms_block)
self.assertLess(tms_block.find("空运费:"), tms_block.find("费用合计:"))
self.assertIn("费用合计:CNY 1833.7", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1)
self.assertEqual(tms_block.count("费用合计:"), 1)
def test_work_order_plus_lost_reason_is_deal_not_activate(self) -> None: def test_work_order_plus_lost_reason_is_deal_not_activate(self) -> None:
"""WO@助手 未成交,原因… 必须当关单,不能只激活。""" """WO@助手 未成交,原因… 必须当关单,不能只激活。"""
from agent.routing.air_group_intent import INTENT_ACTIVATE, INTENT_LOST from agent.routing.air_group_intent import INTENT_ACTIVATE, INTENT_LOST
@@ -421,21 +527,6 @@ class AirGroupCollabTests(unittest.TestCase):
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False), classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
INTENT_ACTIVATE, INTENT_ACTIVATE,
) )
# 话术前缀「工单编号」不能当成报价(WO026 复现:误走 product_quote)。
self.assertEqual(
classify_air_group_text(
"工单编号WO202610070026@询价小助手",
allow_network=False,
),
INTENT_ACTIVATE,
)
self.assertEqual(
classify_air_group_text(
"工单编号 WO202610060026@询价小助手",
allow_network=False,
),
INTENT_ACTIVATE,
)
from unittest.mock import patch from unittest.mock import patch
with patch( with patch(
@@ -449,13 +540,6 @@ class AirGroupCollabTests(unittest.TestCase):
), ),
INTENT_ACTIVATE, INTENT_ACTIVATE,
) )
self.assertEqual(
classify_air_group_text(
"工单编号WO202610070026@询价小助手",
allow_network=True,
),
INTENT_ACTIVATE,
)
def test_dispatch_aibot_goes_air(self) -> None: def test_dispatch_aibot_goes_air(self) -> None:
from agent.routing.dispatch import dispatch_inbound as _disp from agent.routing.dispatch import dispatch_inbound as _disp
@@ -636,6 +720,45 @@ class AirGroupCollabTests(unittest.TestCase):
) )
self.assertEqual(phase, "need_work_order") self.assertEqual(phase, "need_work_order")
def test_activated_multi_quote_follows_without_work_order(self) -> None:
"""多段激活后,报价不必再带工单号,也不要回「请先 @我」。"""
import json
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="MULTI",
facts={
"segments_json": json.dumps(
[
{"index": 1, "mode": "LAND", "facts": {"起运港": "北京", "目的港": "上海"}},
{"index": 2, "mode": "AIR", "facts": {"起运港": "上海", "目的港": "北宁"}},
],
ensure_ascii=False,
)
},
)
wo = created["work_order_no"]
self.ledger.bind_collab_group(
work_order_no=wo,
chat_id="wr_multi",
member_ids=["sales1", "air1"],
product_ids=["air1"],
product_names=["HangXian"],
)
phase = handle_air_group(
self._msg(sender="air1", text="空运费 100USD", chat="wr_multi", mid="mq1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertEqual(phase, "segment_quoted")
joined = "\n".join(self._texts())
self.assertNotIn("请先 @我", joined)
self.assertNotIn("并发送要跟的工单号", joined)
ticket = self.ledger.get_ticket(work_order_no=wo)
air_quote = (ticket.quote or {}).get("segment_quotes", {}).get("AIR") or {}
self.assertTrue(air_quote.get("total") or air_quote.get("fee_rows"))
def test_sales_can_fill_battery(self) -> None: def test_sales_can_fill_battery(self) -> None:
wo = self._create_air() wo = self._create_air()
handle_air_group( handle_air_group(
@@ -928,8 +1051,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts())) self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("执行成功" in t for t in self._texts())) self.assertFalse(any("执行成功" in t for t in self._texts()))
def test_manual_quote_without_option_asks_fields(self) -> None: def test_manual_quote_without_quote_id_can_lock(self) -> None:
"""纯人工价且口令无 AIR-OPT:交主账后仍缺 → lock_incomplete(不空壳成功)。"""
wo = self._create_air( wo = self._create_air(
quote={ quote={
"total": "100", "total": "100",
@@ -944,35 +1066,146 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger, ledger=self.ledger,
injected_intent="air_lock", injected_intent="air_lock",
) )
self.assertEqual(phase, "lock_incomplete") self.assertEqual(phase, "wait_lock_params")
joined = "\n".join(self._texts())
self.assertIn("舱位编号", joined)
self.assertFalse(any("执行成功" in t for t in self._texts()))
def test_manual_quote_with_option_airline_can_lock(self) -> None:
"""人工价补齐舱位+航司+航班后可锁;计费重用工单毛重兜底。"""
wo = self._create_air(
quote={
"total": "100",
"source": "manual",
"source_label": "产品报价",
"currency": "CNY",
},
status="已报价",
)
phase = handle_air_group( phase = handle_air_group(
self._msg( self._msg(
sender="air1", sender="air1",
text=f"{wo}锁舱,锁AIR-OPT01 CZ 航班CZ327", text="AIR-OPT01 CZ 航班CZ327",
chat="wr_air_lock_manual2", chat="wr_air_lock_manual",
mid="lm2", mid="lm2",
), ),
flow=self.flow, flow=self.flow,
ledger=self.ledger, ledger=self.ledger,
injected_intent="air_pick_option",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None:
"""缺舱位、航司、航班号时只请补,不锁舱、不报 TMS 失败、不通知 IT。"""
wo = self._create_air(
quote={
"total": "CNY 1800",
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-23",
}
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
def spy(**kwargs):
calls.append(kwargs)
return orig(**kwargs)
self.ledger.lock_cabin = spy # type: ignore
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_ask", mid="ask1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "wait_lock_params")
self.assertEqual(calls, [])
asked = self._texts()[-1]
self.assertEqual(
asked,
"锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
)
self.assertNotIn("已通知", asked)
self.assertNotIn("锁舱失败", asked)
phase = handle_air_group(
self._msg(
sender="air1",
text="AIR-OPT01 CZ 航班CZ327",
chat="wr_air_lock_ask",
mid="ask2",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_pick_option",
)
self.assertEqual(phase, "lock_ok")
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("option_no"), "AIR-OPT01")
self.assertEqual(calls[0].get("airline"), "CZ")
self.assertEqual(calls[0].get("flight_no"), "CZ327")
def test_lock_accepts_free_wording_not_only_sample_format(self) -> None:
"""补舱位不限格式。点名舱位、航司、航班号,换顺序、用航司中文名,也要锁。"""
wo = self._create_air(
quote={
"total": "CNY 1800",
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-24",
}
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
def spy(**kwargs):
calls.append(kwargs)
return orig(**kwargs)
self.ledger.lock_cabin = spy # type: ignore
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_free", mid="free1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "wait_lock_params")
self.assertEqual(calls, [])
phase = handle_air_group(
self._msg(
sender="air1",
text="航班号:CZ327,航司南航,舱位编号是AIR-OPT01",
chat="wr_air_lock_free",
mid="free2",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "lock_ok")
self.assertEqual(calls[0].get("option_no"), "AIR-OPT01")
self.assertEqual(calls[0].get("airline"), "CZ")
self.assertEqual(calls[0].get("flight_no"), "CZ327")
def test_lock_sentence_with_labeled_params_locks_immediately(self) -> None:
"""同一句里已经说齐三项,直接锁,不再要求按示例格式重说。"""
wo = self._create_air(
quote={
"total": "CNY 1800",
"source": "manual",
"source_label": "产品报价",
}
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
def spy(**kwargs):
calls.append(kwargs)
return orig(**kwargs)
self.ledger.lock_cabin = spy # type: ignore
phase = handle_air_group(
self._msg(
sender="air1",
text=f"{wo}锁舱,舱位 AIR-OPT02,东方航空,航班号 MU5123",
chat="wr_air_lock_once",
mid="once1",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock", injected_intent="air_lock",
) )
self.assertEqual(phase, "lock_ok") self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts())) self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("option_no"), "AIR-OPT02")
self.assertEqual(calls[0].get("airline"), "MU")
self.assertEqual(calls[0].get("flight_no"), "MU5123")
self.assertFalse(any("锁舱参数不完整" in t for t in self._texts()))
def test_lock_success_and_release_after_deal_blocked(self) -> None: def test_lock_success_and_release_after_deal_blocked(self) -> None:
wo = self._create_air() wo = self._create_air()
@@ -1060,8 +1293,8 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "TMS", "source": "TMS",
"quoteId": "QT-AIR-2", "quoteId": "QT-AIR-2",
"airOptions": [ "airOptions": [
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}, {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"},
{"optionNo": "AIR-OPT-02", "airlineCode": "MU"}, {"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"},
], ],
} }
) )
@@ -1071,7 +1304,7 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger, ledger=self.ledger,
injected_intent="air_lock", injected_intent="air_lock",
) )
self.assertEqual(phase, "wait_lock_option") self.assertEqual(phase, "wait_lock_params")
phase = handle_air_group( phase = handle_air_group(
self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"), self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"),
flow=self.flow, flow=self.flow,
@@ -1166,6 +1399,31 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("请确认成交状态", texts) self.assertIn("请确认成交状态", texts)
self.assertFalse(any(x.get("kind") == "file" for x in added)) self.assertFalse(any(x.get("kind") == "file" for x in added))
def test_reactivate_quoted_tms_file_is_deal_not_sheet(self) -> None:
"""TMS 价的报价单已经发出、停在成交跟进:再发工单号只回成交跟进。"""
wo = self._create_air(
quote={
"total": "CNY 3500.70",
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-FILE",
"quoteFileIssued": True,
"airOptions": [
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"}
],
}
)
phase = handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="react-deal"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "activated_deal")
texts = "\n".join(self._texts())
self.assertIn("请确认成交状态", texts)
self.assertNotIn("询价字段:", texts)
self.assertFalse(any(x.get("kind") == "file" for x in self.groups.sent))
def test_image_rejected(self) -> None: def test_image_rejected(self) -> None:
wo = self._create_air() wo = self._create_air()
handle_air_group( handle_air_group(
@@ -1396,9 +1654,41 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("报价选项不存在", joined) self.assertNotIn("报价选项不存在", joined)
self.assertNotIn("与工单不匹配", joined) self.assertNotIn("与工单不匹配", joined)
def test_lock_fail_does_not_block_airline_quote(self) -> None:
"""锁舱失败后,航线再报手工价必须收下,不能回 TMS 接口异常。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_lock_quote", mid="lq0"),
flow=self.flow,
ledger=self.ledger,
)
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_quote", mid="lq1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "system_exception")
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertNotEqual(phase, "system_exception_paused")
joined = "\n".join(self._texts())
self.assertNotIn("TMS接口异常", joined)
self.assertIn(phase, {"wait_tms_choice", "quoted"})
def test_lock_without_quote_still_shows_tms_reason(self) -> None: def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。""" """TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
wo = self._create_air() wo = self._create_air(quote={}, status="询价中")
self.ledger._cabin_lock_result = { self.ledger._cabin_lock_result = {
"ok": False, "ok": False,
"error": "lock_fail", "error": "lock_fail",
+80 -1
View File
@@ -106,6 +106,82 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertEqual(sess.facts.get("起运港"), "深圳") self.assertEqual(sess.facts.get("起运港"), "深圳")
self.assertIn("SZX-CRK", "\n".join(replies[-2:])) self.assertIn("SZX-CRK", "\n".join(replies[-2:]))
def test_hangzhou_query_uses_airport_and_skips_other_cities(self) -> None:
"""写了杭州就不能按目的地把南京、深圳、郑州拿来选。"""
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.air_text_flow import AirTextInquiryFlow
from agent.schema.tms_air_query import assemble_air_query
ready = assemble_air_query({"起运港": "杭州", "目的港": "吉隆坡"})
self.assertEqual(ready["facts"].get("originCode"), "HGH")
self.assertEqual(ready["facts"].get("destinationCode"), "KUL")
class Ledger(MemoryLedger):
def query_tms(self, **kwargs):
out = super().query_tms(**kwargs)
quote = out["quote"]
quote["total"] = "1750.35"
quote["currency"] = "CNY"
quote["airOptions"] = [
{"originAirportCode": "NKG", "destinationAirportCode": "KUL", "salePrice": 1750.35, "currency": "CNY"},
{"originAirportCode": "SZX", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"},
{"originAirportCode": "CGO", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"},
]
return out
flow = AirTextInquiryFlow(Ledger())
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
facts = {
"起运港": "杭州",
"目的港": "吉隆坡",
"品名": "化妆品",
"件数": "12",
"毛重": "21",
"体积": "1",
"包装方式": "散货",
}
self.assertEqual(
flow.on_text(sender_id="u_hgh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"),
"wait_confirm",
)
phase = flow.on_text(sender_id="u_hgh", text="确定", reply=reply)
self.assertEqual(phase, "tms_miss")
self.assertFalse(any("条线路" in x for x in replies))
self.assertEqual(flow.session_of("u_hgh").facts.get("originCode"), "HGH")
def test_stated_origin_quotes_directly(self) -> None:
"""起运地已经是珠海时,即使 TMS 还带回别的城市,也不再让销售选线。"""
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.air_text_flow import AirTextInquiryFlow
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
facts = {
"起运港": "珠海",
"目的港": "克拉克",
"品名": "茶叶",
"件数": "10",
"毛重": "100",
"体积": "2",
"包装方式": "托盘",
"tms_multi": "1",
}
flow.on_text(sender_id="u_zuh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR")
phase = flow.on_text(sender_id="u_zuh", text="确定", reply=reply)
self.assertEqual(phase, "wait_collab")
self.assertFalse(any("条线路" in x for x in replies))
self.assertEqual(flow.session_of("u_zuh").facts.get("起运港"), "珠海")
def test_reselect_after_adopt_voids_old_file(self) -> None: def test_reselect_after_adopt_voids_old_file(self) -> None:
"""采用后再改选:旧报价单作废,回到方案卡重新采用。""" """采用后再改选:旧报价单作废,回到方案卡重新采用。"""
ledger = MemoryLedger() ledger = MemoryLedger()
@@ -331,7 +407,10 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertIn("closeWindow", ok) self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok) self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok) self.assertIn("jsapi-signature", ok)
self.assertNotIn("window.close()", ok) mobile = ok.split("function closeMobile()")[1].split("function closeDesktop()")[0]
desktop = ok.split("function closeDesktop()")[1].split("function boot()")[0]
self.assertNotIn("window.close()", mobile)
self.assertIn("window.close()", desktop)
self.assertNotIn("closeWindow", dead) self.assertNotIn("closeWindow", dead)
@@ -900,6 +900,10 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertNotIn("引用历史工单号", sub) self.assertNotIn("引用历史工单号", sub)
self.assertNotIn("工单状态", sub) self.assertNotIn("工单状态", sub)
self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001"))) self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
self.assertIn(
"请手动将相关方拉到空运原有协同群,并在空运协同群中发送“ WO202609140001@询价小助手”,谢谢。",
sub,
)
self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False)) self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False))
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None: def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
@@ -108,6 +108,12 @@ class AttachmentUploadH5Tests(unittest.TestCase):
self.assertIn("closeWindow", ok) self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok) self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok) self.assertIn("jsapi-signature", ok)
self.assertIn("function isPhone()", ok)
self.assertIn("WeixinJSBridge.invoke", ok)
mobile = ok.split("function closeMobile()")[1].split("function closeDesktop()")[0]
desktop = ok.split("function closeDesktop()")[1].split("function boot()")[0]
self.assertNotIn("window.close()", mobile)
self.assertIn("window.close()", desktop)
self.assertNotIn("closeWindow", page) self.assertNotIn("closeWindow", page)
self.assertNotIn("closeWindow", dead) self.assertNotIn("closeWindow", dead)
+9 -54
View File
@@ -144,60 +144,15 @@ class TestDealOutcome(unittest.TestCase):
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1") view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "done") self.assertEqual(view.get("wait_phase"), "done")
def test_lost_from_inquiring_promotes_quoted_first(self) -> None:
"""主账仍询价中时点未成交:先补已报价再入未成交,禁止直接失败。"""
class _StrictSix:
def __init__(self) -> None:
self.status = "询价中"
self.lost_reason = ""
self.event_wait_kind = ""
def transition(self, *, work_order_no, to_status, **kwargs):
_ = work_order_no
allowed = {
"询价中": {"已报价", "协商中", "已关闭"},
"已报价": {"协商中", "已成交", "未成交", "已关闭"},
}
if to_status not in allowed.get(self.status, set()):
# 模拟 http_ledger 在 500 时只回 http_500(丢失正文)
return {"ok": False, "error": "http_500"}
self.status = to_status
if to_status == "未成交" and kwargs.get("lost_reason"):
self.lost_reason = str(kwargs.get("lost_reason") or "")
return {"ok": True, "status": self.status}
def patch_lost_reason(self, *, work_order_no, lost_reason):
_ = work_order_no
self.lost_reason = lost_reason
return {"ok": True}
def get_for_agent(self, *, work_order_no, sender_id):
_ = (work_order_no, sender_id)
return {"status": self.status, "lost_reason": self.lost_reason}
ledger = _StrictSix()
out = finish_deal_outcome(
ledger=ledger,
work_order_no="WO202610070023",
outcome="未成交",
facts={},
quote={},
business_line="AIR",
)
self.assertTrue(out.get("ok"), out)
self.assertEqual(ledger.status, "未成交")
self.assertEqual(out.get("phase"), "wait_lost_reason")
def test_lost_reason_ack_repeats_user_text(self) -> None: def test_lost_reason_ack_repeats_user_text(self) -> None:
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。") self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。") self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
def test_lost_reason_ack_fills_ticket_no_from_bot_reply(self) -> None: def test_lost_reason_ack_fills_ticket_no(self) -> None:
"""后台文案带 {{ticket_no}} 时必须填实值,禁止原样打出占位符。""" """后台模板带工单号时,必须换成真实单号,不能把占位符原样发出去。"""
from agent.policy import bot_reply from agent.policy.bot_reply import override_catalog
bot_reply.override_catalog( override_catalog(
{ {
"lost.reason_ack": ( "lost.reason_ack": (
"已将未成交原因“{{lost_reason}}”记录在后台," "已将未成交原因“{{lost_reason}}”记录在后台,"
@@ -206,12 +161,12 @@ class TestDealOutcome(unittest.TestCase):
} }
) )
try: try:
text = copy.lost_reason_ack("换时间", work_order_no="WO202610070018") self.assertEqual(
self.assertIn("WO202610070018", text) copy.lost_reason_ack("客户不需要了", work_order_no="WO202609100001"),
self.assertNotIn("{{ticket_no}}", text) "已将未成交原因“客户不需要了”记录在后台,工单WO202609100001已关闭,智能体不再跟进该工单,请悉知。",
self.assertIn("换时间", text) )
finally: finally:
bot_reply.override_catalog(None) override_catalog(None)
def test_harvest_inline_lost_reason(self) -> None: def test_harvest_inline_lost_reason(self) -> None:
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了") self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
+23
View File
@@ -436,6 +436,29 @@ class ClarifyPasteTests(unittest.TestCase):
self.assertNotIn("包装方式", facts) self.assertNotIn("包装方式", facts)
self.assertNotEqual(facts.get("包装方式"), "托盘、散货") self.assertNotEqual(facts.get("包装方式"), "托盘、散货")
def test_blank_cargo_ready_does_not_take_next_item_number(self) -> None:
"""货好时间空着时,下一行「4.提货地址」的编号不能写进货好时间。示例行也不算填了。"""
text = """
1.包装类型(必填):托盘
(如:托盘、散货)
2.货源地(非必填):
(如:东莞)
3.货好时间(非必填):
(如:随时可提)
4.提货地址(非必填):
(如:东莞仓库)
5.收货地址(非必填):
(如:马尼拉)
6.贸易条款(非必填):
(如:DDP、DAP、CIF、CIP)
"""
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
self.assertEqual(facts.get("包装方式") or facts.get("包装类型"), "托盘")
self.assertNotIn("货好时间", facts)
self.assertNotIn("提货地址", facts)
self.assertNotIn("货源地", facts)
self.assertNotIn("贸易条款", facts)
class ImageOptionalClipTests(unittest.TestCase): class ImageOptionalClipTests(unittest.TestCase):
def test_numbered_extra_lines_do_not_enter_fields(self) -> None: def test_numbered_extra_lines_do_not_enter_fields(self) -> None:
+32 -1
View File
@@ -317,6 +317,31 @@ class LandGroupCollabTests(unittest.TestCase):
ticket = self.ledger.get_ticket(work_order_no=wo) ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "已报价") self.assertEqual(ticket.status, "已报价")
def test_product_fee_change_is_quote_not_adjust(self) -> None:
"""产品写「报关费改为300」是手工报价。已有报价后也不能被拦成只有销售能调价。"""
from agent.routing.group_intent import classify_group_text
self.assertEqual(classify_group_text("报关费改为300CNY", allow_network=False), INTENT_QUOTE)
self.assertEqual(classify_group_text("运费加 10%", allow_network=False), INTENT_ADJUST)
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
handle_group_collab(
self._msg(sender="p_dom", text="运费 1000", mid="q_fee0"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_QUOTE,
injected_quote={"total": "1000", "source_label": "产品报价"},
)
self.groups.sent.clear()
phase = handle_group_collab(
self._msg(sender="p_dom", text="报关费改为300CNY", mid="q_fee1"),
flow=self.flow,
ledger=self.ledger,
)
self.assertNotEqual(phase, "adjust_not_sales")
self.assertNotIn("只有这张单的销售能调价", self._group_texts())
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertTrue(ticket.quote)
def test_product_cannot_adjust(self) -> None: def test_product_cannot_adjust(self) -> None:
self._open(option_text="国内运输拼车 国内长途/零担 急件") self._open(option_text="国内运输拼车 国内长途/零担 急件")
self.groups.sent.clear() self.groups.sent.clear()
@@ -327,7 +352,13 @@ class LandGroupCollabTests(unittest.TestCase):
injected_intent=INTENT_ADJUST, injected_intent=INTENT_ADJUST,
) )
self.assertEqual(phase, "adjust_not_sales") self.assertEqual(phase, "adjust_not_sales")
self.assertIn("只有这张单的销售能调价", self._group_texts()) self.assertIn("只有这张单的销售能调价。产品请继续报价,价格未改。", self._group_texts())
def test_air_room_adjust_names_airline(self) -> None:
self.assertEqual(
copy.adjust_only_sales(air_room=True),
"只有这张单的销售能调价。航线请继续报价,价格未改。",
)
if __name__ == "__main__": if __name__ == "__main__":
+126 -2
View File
@@ -11,6 +11,10 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path: if ROOT not in sys.path:
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
from unittest.mock import patch
from agent.channel.wecom.models import InboundMessage
from agent.handlers.group_collab import handle_group_inbound
from agent.ledger.memory_ledger import MemoryLedger from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.multi_group_ops import deal_text, handle_multi_inbound from agent.policy.multi_group_ops import deal_text, handle_multi_inbound
from agent.policy.multi_segments import Segment from agent.policy.multi_segments import Segment
@@ -387,7 +391,8 @@ class QuoteTests(unittest.TestCase):
self.assertEqual(len(self.client.files), 2) self.assertEqual(len(self.client.files), 2)
self.assertTrue(self.ledger.last_render.get("ok")) self.assertTrue(self.ledger.last_render.get("ok"))
def test_lock_on_air_segment_does_not_wait_for_other_quotes(self) -> None: def test_lock_on_air_segment_says_multi_unsupported(self) -> None:
"""多段含空运时航线锁舱,原因写成暂不支持,不再说不是空运单。"""
created = self.ledger.create_ticket( created = self.ledger.create_ticket(
sender_id="sales", sender_id="sales",
business_line="MULTI", business_line="MULTI",
@@ -412,9 +417,128 @@ class QuoteTests(unittest.TestCase):
text=f"{no}锁舱", text=f"{no}锁舱",
sheet_fn=self.sheet, sheet_fn=self.sheet,
) )
self.assertEqual(phase, "lock_ok") self.assertEqual(phase, "multi_lock_unsupported")
joined = "\n".join(self.client.texts)
self.assertIn(f"工单{no} 空运锁舱失败。", joined)
self.assertIn("操作人:航线甲", joined)
self.assertIn("锁舱失败的原因:多段询价,暂时不支持锁舱", joined)
self.assertNotIn("不是空运单,不能锁舱", joined)
self.assertNotIn("已通知IT", joined)
self.assertNotIn("请稍等", joined)
self.assertFalse(any("其他段报完" in text for text in self.client.texts)) self.assertFalse(any("其他段报完" in text for text in self.client.texts))
class _Bot:
"""替掉真实桥,记下这一次群里实际会发出去的字。"""
made: list["_Bot"] = []
def __init__(self, *args: object, **kwargs: object) -> None:
_ = args, kwargs
self.texts: list[str] = []
_Bot.made.append(self)
def send_group(self, *, chat_id: str, content: str, mention_userids: list[str] | None = None) -> dict:
_ = chat_id, mention_userids
self.texts.append(content)
return {"ok": True}
def send_group_file(self, **kwargs: object) -> dict:
_ = kwargs
return {"ok": True}
class ArchiveSenderTests(unittest.TestCase):
"""会话存档听到的报价:含空运走机器人,海运陆运仍走应用自己建的群。"""
def setUp(self) -> None:
_Bot.made = []
self.ledger = MemoryLedger()
self.app = _Client()
self.flow = _Flow(self.ledger, self.app)
def _message(self, chat_id: str, sender_id: str, text: str) -> InboundMessage:
return InboundMessage(
sender_id=sender_id,
message_id="m1",
content=text,
chat_id=chat_id,
chat_type="group",
)
def test_air_multi_quote_goes_through_bot(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales",
business_line="MULTI",
facts={
"segments_json": json.dumps(
[
{"index": 1, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
{"index": 2, "mode": "AIR", "facts": {"目的港": "北宁"}},
],
ensure_ascii=False,
)
},
)
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
self.ledger.upsert_staff({"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"})
no = created["work_order_no"]
self.ledger.bind_collab_group(
work_order_no=no,
chat_id="bot-room",
member_ids=["sales", "air1"],
product_names=["航线甲"],
product_ids=["air1"],
)
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
phase = handle_group_inbound(
self._message("bot-room", "air1", "空运费 9900CNY"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "segment_quoted")
self.assertFalse(self.app.texts)
self.assertTrue(_Bot.made)
self.assertTrue(any("报价单已发出" in text for text in _Bot.made[-1].texts))
def test_sea_land_quote_stays_on_app_client(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales",
business_line="MULTI",
facts={
"segments_json": json.dumps(
[
{"index": 1, "mode": "SEA", "facts": {"起运港": "广州港", "目的港": "巴生港"}},
{"index": 2, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
],
ensure_ascii=False,
)
},
)
self.ledger.set_sea_staff(
[{"wecomId": "sea1", "name": "海产", "roleCode": "sea", "status": "active", "routes": "海运·全部线路"}]
)
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
no = created["work_order_no"]
self.ledger.bind_collab_group(
work_order_no=no,
chat_id="app-room",
member_ids=["sales", "sea1"],
product_names=["海产"],
product_ids=["sea1"],
)
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
phase = handle_group_inbound(
self._message("app-room", "sea1", "海运费 100CNY"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "segment_quoted")
self.assertFalse(_Bot.made)
self.assertTrue(any("报价单已发出" in text for text in self.app.texts))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -1341,28 +1341,6 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00") self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00") self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
def test_land_weight_band_unit_price_and_rates(self) -> None:
"""陆运集拼:2-500KG 单价 + 燃油附加费率/税率,不能把档下限 2 当成燃油附加费。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"2-500KG 单价CNY990,燃油附加费率1%,税率6%"
)
self.assertEqual(str(quote.get("total") or ""), "990")
self.assertEqual(str(quote.get("fuel_surcharge_rate") or ""), "1%")
self.assertEqual(str(quote.get("tax_rate") or ""), "6%")
tiers = list(quote.get("weightPrices") or [])
self.assertEqual(len(tiers), 1)
self.assertEqual(int(tiers[0].get("min") or 0), 2)
self.assertEqual(int(tiers[0].get("max") or 0), 500)
self.assertEqual(str(tiers[0].get("price") or ""), "990")
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("2-500KG", rows)
self.assertEqual(str(rows["2-500KG"].get("amount") or ""), "990")
self.assertNotEqual(str(rows.get("燃油附加费", {}).get("amount") or ""), "2")
self.assertEqual(str(rows.get("燃油附加费率", {}).get("amount") or ""), "1%")
self.assertEqual(str(rows.get("税率", {}).get("amount") or ""), "6%")
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None: def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。""" """空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
from agent.llm.extract_text import extract_product_quote from agent.llm.extract_text import extract_product_quote
+10 -27
View File
@@ -379,6 +379,9 @@ class TestConfirmSystemException(unittest.TestCase):
) )
card = queued[0]["payload"]["template_card"] card = queued[0]["payload"]["template_card"]
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]} values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
self.assertEqual(values["报错原因"], "LOCK_FAIL")
self.assertNotIn("错误码", values)
self.assertIn("报错原因:LOCK_FAIL", queued[0]["content"])
self.assertEqual(values["影响范围"], "锁舱失败,工单状态保持不变") self.assertEqual(values["影响范围"], "锁舱失败,工单状态保持不变")
self.assertEqual(card["main_title"]["title"], "系统异常通知") self.assertEqual(card["main_title"]["title"], "系统异常通知")
@@ -477,45 +480,27 @@ class TestConfirmSystemException(unittest.TestCase):
self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined) self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined)
def test_admin_retry_cabin_lock_runs_lock(self) -> None: def test_admin_retry_cabin_lock_runs_lock(self) -> None:
# 空运单 + 可锁报价;重试必须带上 operator_name / airline。
air = self.ledger.create_ticket(
sender_id="sales1",
business_line="AIR",
facts={"起运地": "上海", "目的地": "洛杉矶", "毛重": "100"},
)
air_wo = str(air.get("work_order_no") or "")
self.ledger.upsert_quote( self.ledger.upsert_quote(
work_order_no=air_wo, work_order_no=self.wo,
quote={ quote={"total": "100", "quoteId": "QT-AIR-1"},
"total": "800",
"source": "TMS",
"quoteId": "QT-AIR-1",
"airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}],
},
to_status="已报价", to_status="已报价",
) )
self.ledger.mark_system_exception( self.ledger.mark_system_exception(
work_order_no=air_wo, work_order_no=self.wo,
system_exception=TYPE_TMS, system_exception=TYPE_TMS,
system_exception_reason="空运锁舱接口调用失败", system_exception_reason="空运锁舱接口调用失败",
step=STEP_TMS_CABIN, step=STEP_TMS_CABIN,
payload={ payload={
"conversation_kind": "group", "conversation_kind": "group",
"conversation_target": "chat-air", "conversation_target": "chat-air",
"extra": { "extra": {"option_no": "AIR-OPT-01", "instruction": "锁舱"},
"option_no": "AIR-OPT-01",
"instruction": "锁舱",
"operator_name": "HangXian",
"airline": "CZ",
},
}, },
) )
hits = {"n": 0, "kwargs": {}} hits = {"n": 0}
orig_lock = self.ledger.lock_cabin orig_lock = self.ledger.lock_cabin
def counted(**kwargs): def counted(**kwargs):
hits["n"] += 1 hits["n"] += 1
hits["kwargs"] = dict(kwargs)
return orig_lock(**kwargs) return orig_lock(**kwargs)
self.ledger.lock_cabin = counted # type: ignore[method-assign] self.ledger.lock_cabin = counted # type: ignore[method-assign]
@@ -527,14 +512,12 @@ class TestConfirmSystemException(unittest.TestCase):
orig = se._send_to_conversation orig = se._send_to_conversation
se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or "")) se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or ""))
try: try:
out = run_admin_retry(air_wo, ledger=self.ledger) out = run_admin_retry(self.wo, ledger=self.ledger)
finally: finally:
se._send_to_conversation = orig se._send_to_conversation = orig
self.assertTrue(out.get("ok")) self.assertTrue(out.get("ok"))
self.assertGreaterEqual(hits["n"], 1) self.assertGreaterEqual(hits["n"], 1)
self.assertEqual(hits["kwargs"].get("operator_name"), "HangXian") ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(hits["kwargs"].get("airline"), "CZ")
ticket = self.ledger.get_ticket(work_order_no=air_wo)
self.assertFalse(ticket_is_paused(ticket)) self.assertFalse(ticket_is_paused(ticket))
self.assertTrue(any("空运舱位接口已恢复" in x for x in talks)) self.assertTrue(any("空运舱位接口已恢复" in x for x in talks))
+2 -1
View File
@@ -30,7 +30,7 @@ class TmsLandQueryTest(unittest.TestCase):
got = assemble_land_query(facts) got = assemble_land_query(facts)
self.assertTrue(got["ok"]) self.assertTrue(got["ok"])
out = got["outbound"] out = got["outbound"]
self.assertEqual(out["landTransportType"], "CHINA_HK") self.assertEqual(out["landTransportType"], "中港")
self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA") self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA")
self.assertNotIn("landRouteCategory", out) self.assertNotIn("landRouteCategory", out)
self.assertEqual(out["vehicleType"], "9米6") self.assertEqual(out["vehicleType"], "9米6")
@@ -52,6 +52,7 @@ class TmsLandQueryTest(unittest.TestCase):
got = assemble_land_query(facts) got = assemble_land_query(facts)
self.assertTrue(got["ok"]) self.assertTrue(got["ok"])
out = got["outbound"] out = got["outbound"]
self.assertEqual(out["landTransportType"], "中港")
self.assertNotIn("vehicleType", out) self.assertNotIn("vehicleType", out)
self.assertNotIn("quantity", out) self.assertNotIn("quantity", out)
@@ -1171,27 +1171,6 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
one.put("delivery_fee", out.getString("delivery_fee")); one.put("delivery_fee", out.getString("delivery_fee"));
one.put("price_per_cbm", out.getString("price_per_cbm")); one.put("price_per_cbm", out.getString("price_per_cbm"));
one.put("price_per_kg", out.getString("price_per_kg")); one.put("price_per_kg", out.getString("price_per_kg"));
// 产品文字报「2-500KG 单价…」时 weightPrices / 费率在 quote 顶层,无 TMS landSegments。
Object wp = q.get("weightPrices");
if (wp != null) {
one.put("weightPrices", wp);
}
String fuelRate = firstNonBlankLocal(
out.getString("fuel_surcharge_rate"),
first(q, "fuel_surcharge_rate"),
first(q, "fuelSurchargeRate"));
if (StringUtils.isNotBlank(fuelRate)) {
one.put("fuel_surcharge_rate", fuelRate);
one.put("fuelSurchargeRate", fuelRate);
}
String taxRate = firstNonBlankLocal(
out.getString("tax_rate"),
first(q, "tax_rate"),
first(q, "taxRate"));
if (StringUtils.isNotBlank(taxRate)) {
one.put("tax_rate", taxRate);
one.put("taxRate", taxRate);
}
String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm")); String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm"));
for (int i = 0; i < rows.size(); i++) { for (int i = 0; i < rows.size(); i++) {
JSONObject fee = rows.getJSONObject(i); JSONObject fee = rows.getJSONObject(i);
@@ -243,9 +243,6 @@ public final class QuoteTemplateExcelSupport {
LAND_TMS_KEY_TO_CODE.put("transitDays", "transit_days"); LAND_TMS_KEY_TO_CODE.put("transitDays", "transit_days");
LAND_TMS_KEY_TO_CODE.put("effectiveTime", "effective_time"); LAND_TMS_KEY_TO_CODE.put("effectiveTime", "effective_time");
LAND_TMS_KEY_TO_CODE.put("landFreight", "land_freight"); LAND_TMS_KEY_TO_CODE.put("landFreight", "land_freight");
LAND_TMS_KEY_TO_CODE.put("fuelSurchargeRate", "fuel_surcharge_rate");
LAND_TMS_KEY_TO_CODE.put("rate", "tax_rate");
LAND_TMS_KEY_TO_CODE.put("rateFlag", "tax_included");
LAND_TMS_KEY_TO_CODE.put("vehicle42Fee", "vehicle_42_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle42Fee", "vehicle_42_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle50Fee", "vehicle_50_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle50Fee", "vehicle_50_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle76Fee", "vehicle_76_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle76Fee", "vehicle_76_fee");
@@ -2856,17 +2853,15 @@ public final class QuoteTemplateExcelSupport {
writeFeeCol(sheet, ri, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName()); writeFeeCol(sheet, ri, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName());
if (columns.containsKey("tax_rate")) { if (columns.containsKey("tax_rate")) {
writeFeeCol(sheet, ri, columns.getString("tax_rate"), writeFeeCol(sheet, ri, columns.getString("tax_rate"),
formatLandTaxRateDisplay(firstNonBlank(rowObj.getString("tax_rate"), firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"),
rowObj.getString("taxRate"), facts == null ? null : facts.getString("tax_rate")),
rowObj.getString("rate"),
facts == null ? null : facts.getString("tax_rate"))),
yellow, sheet.getSheetName()); yellow, sheet.getSheetName());
} }
if (columns.containsKey("fuel_surcharge_rate")) { if (columns.containsKey("fuel_surcharge_rate")) {
writeFeeCol(sheet, ri, columns.getString("fuel_surcharge_rate"), writeFeeCol(sheet, ri, columns.getString("fuel_surcharge_rate"),
formatLandPercentDisplay(firstNonBlank(rowObj.getString("fuel_surcharge_rate"), firstNonBlank(rowObj.getString("fuel_surcharge_rate"),
rowObj.getString("fuelSurchargeRate"), rowObj.getString("fuelSurchargeRate"),
facts == null ? null : facts.getString("fuel_surcharge_rate"))), facts == null ? null : facts.getString("fuel_surcharge_rate")),
yellow, sheet.getSheetName()); yellow, sheet.getSheetName());
} }
writeLandMatrixSideCols(sheet, ri, region, rowObj, facts, yellow); writeLandMatrixSideCols(sheet, ri, region, rowObj, facts, yellow);
@@ -2942,19 +2937,12 @@ public final class QuoteTemplateExcelSupport {
facts == null ? null : facts.getString("currency")); facts == null ? null : facts.getString("currency"));
} }
if (header.contains("燃油附加")) { if (header.contains("燃油附加")) {
return formatLandPercentDisplay(firstNonBlank(rowObj.getString("fuel_surcharge_rate"), return firstNonBlank(rowObj.getString("fuel_surcharge_rate"), rowObj.getString("fuelSurchargeRate"),
rowObj.getString("fuelSurchargeRate"), facts == null ? null : facts.getString("fuel_surcharge_rate"));
facts == null ? null : facts.getString("fuel_surcharge_rate")));
} }
if ("税率".equals(header)) { if ("税率".equals(header)) {
return formatLandTaxRateDisplay(firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"), return firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"),
rowObj.getString("rate"), facts == null ? null : facts.getString("tax_rate"));
facts == null ? null : facts.getString("tax_rate")));
}
if (header.contains("是否含税") || "含税".equals(header)) {
return firstNonBlank(rowObj.getString("tax_included"),
formatLandTaxIncludedDisplay(firstNonBlank(rowObj.getString("rateFlag"),
rowObj.getString("rate_flag"))));
} }
if (header.contains("含税合计") || header.contains("杂费合计")) { if (header.contains("含税合计") || header.contains("杂费合计")) {
return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"), return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"),
@@ -3191,21 +3179,14 @@ public final class QuoteTemplateExcelSupport {
out.put("transit_time", transit); out.put("transit_time", transit);
out.put("transitDays", transit); out.put("transitDays", transit);
} }
String fuel = formatLandPercentDisplay(firstNonBlank( String fuel = firstNonBlank(row.getString("fuel_surcharge_rate"), row.getString("fuelSurchargeRate"));
row.getString("fuel_surcharge_rate"), row.getString("fuelSurchargeRate")));
if (StringUtils.isNotBlank(fuel)) { if (StringUtils.isNotBlank(fuel)) {
out.put("fuel_surcharge_rate", fuel); out.put("fuel_surcharge_rate", fuel);
} }
String tax = formatLandTaxRateDisplay(firstNonBlank( String tax = firstNonBlank(row.getString("tax_rate"), row.getString("taxRate"));
row.getString("tax_rate"), row.getString("taxRate"), row.getString("rate")));
if (StringUtils.isNotBlank(tax)) { if (StringUtils.isNotBlank(tax)) {
out.put("tax_rate", tax); out.put("tax_rate", tax);
} }
String included = firstNonBlank(row.getString("tax_included"),
formatLandTaxIncludedDisplay(firstNonBlank(row.getString("rateFlag"), row.getString("rate_flag"))));
if (StringUtils.isNotBlank(included)) {
out.put("tax_included", included);
}
return out; return out;
} }
@@ -3848,68 +3829,6 @@ public final class QuoteTemplateExcelSupport {
return ""; return "";
} }
/**
* 陆运燃油附加费率写入格子:数字带 %;已带 % 保留。空值不写。
*/
static String formatLandPercentDisplay(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (text.matches("-?\\d+(\\.\\d+)?")) {
if (text.contains(".")) {
text = text.replaceAll("0+$", "").replaceAll("\\.$", "");
}
return text + "%";
}
return text;
}
/**
* 税率 rate:0.09→9%;大于 1 按百分点(10→10%)。
*/
static String formatLandTaxRateDisplay(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (!text.matches("-?\\d+(\\.\\d+)?")) {
return text;
}
try {
java.math.BigDecimal n = new java.math.BigDecimal(text);
java.math.BigDecimal abs = n.abs();
if (abs.compareTo(java.math.BigDecimal.ZERO) > 0
&& abs.compareTo(java.math.BigDecimal.ONE) <= 0) {
n = n.multiply(new java.math.BigDecimal("100")).stripTrailingZeros();
}
return n.stripTrailingZeros().toPlainString() + "%";
} catch (RuntimeException ex) {
return text;
}
}
/** rateFlag:1=含税,0=不含税。 */
static String formatLandTaxIncludedDisplay(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if ("1".equals(text) || "true".equalsIgnoreCase(text) || "是".equals(text) || "含税".equals(text)) {
return "含税";
}
if ("0".equals(text) || "false".equalsIgnoreCase(text) || "否".equals(text) || "不含税".equals(text)) {
return "不含税";
}
return text;
}
private static void writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set<String> yellow) { private static void writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set<String> yellow) {
JSONObject feeRows = mapping.getJSONObject("fee_rows"); JSONObject feeRows = mapping.getJSONObject("fee_rows");
if (feeRows == null) { if (feeRows == null) {
@@ -51,6 +51,24 @@ public class InquiryTmsCabinService {
@Lazy @Lazy
private InquiryTmsQueryService queryService; private InquiryTmsQueryService queryService;
/** 多段询价锁舱的产品口径。智能体群回复与主账拒绝用同一句。 */
public static final String MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱";
/**
* 还没到 TMS 之前的拒绝原因。空运单返回空,表示可以继续锁。
* 多段整单不是 AIR,但航线会按空运段来锁,单独说明暂不支持。
*/
static String lockRejectReason(String businessLine) {
String line = StringUtils.trimToEmpty(businessLine);
if ("MULTI".equalsIgnoreCase(line)) {
return MULTI_LOCK_UNSUPPORTED;
}
if (!"AIR".equalsIgnoreCase(line)) {
return "不是空运单,不能锁舱";
}
return "";
}
/** /**
* 智能体锁舱入口。 * 智能体锁舱入口。
*/ */
@@ -61,8 +79,11 @@ public class InquiryTmsCabinService {
if (ticket == null) { if (ticket == null) {
return fail("ticket_not_found", "找不到工单"); return fail("ticket_not_found", "找不到工单");
} }
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) { // 多段整单业务线是 MULTI。含空运段时航线仍会来锁舱,不能再说「不是空运单」。
return fail("not_air", "不是空运单,不能锁舱"); String lockReject = lockRejectReason(ticket.getBusinessLine());
if (StringUtils.isNotBlank(lockReject)) {
String code = MULTI_LOCK_UNSUPPORTED.equals(lockReject) ? "multi_no_lock" : "not_air";
return fail(code, lockReject);
} }
if (TicketStatuses.DEAL.equals(ticket.getStatus()) if (TicketStatuses.DEAL.equals(ticket.getStatus())
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) { || TicketStatuses.CLOSED.equals(ticket.getStatus())) {
@@ -75,16 +96,11 @@ public class InquiryTmsCabinService {
Map<String, Object> quote = payloadOf(rows.get(0)); Map<String, Object> quote = payloadOf(rows.get(0));
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。 // 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote); boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
// 库里只有人工价、没有舱位时也要现拉 TMS:补 quoteId/airOptions, if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
// 避免 MANUAL 空壳 1001,也避免逼航线手打航班号。
boolean cabinSparse = StringUtils.isBlank(resolveQuoteId(quote, no))
|| airOptions(quote).isEmpty();
if (cabinSparse) {
quote = refreshLockQuote(ticket, quote); quote = refreshLockQuote(ticket, quote);
} }
String tmsQuoteId = realTmsQuoteId(rows, no); String tmsQuoteId = realTmsQuoteId(rows, no);
// 现拉到的 TMS 编号也算:库里无历史行时仍可 MANUAL+quoteId。 if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
if (StringUtils.isBlank(tmsQuoteId)) {
tmsQuoteId = resolveQuoteId(quote, no); tmsQuoteId = resolveQuoteId(quote, no);
} }
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId); boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
@@ -145,68 +161,26 @@ public class InquiryTmsCabinService {
text(option, "destination"), ticket.getDestination(), text(option, "destination"), ticket.getDestination(),
fact(ticket, "目的地", "目的港"))); fact(ticket, "目的地", "目的港")));
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。 // 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
// airlineName 中文名不能当航司码出站,只取两位码或航班前缀。 req.put("airline", firstNonBlank(
String airlineCode = firstNonBlank(
text(body, "airline", "airlineCode"), text(body, "airline", "airlineCode"),
airlineFromInstruction(text(body, "instructionText", "instruction_text")), airlineFromInstruction(text(body, "instructionText", "instruction_text")),
text(option, "airlineCode", "airline"), text(option, "airlineCode", "airline"),
text(quote, "airlineCode", "airline"), text(option, "airlineName"),
fact(ticket, "航司代码"), text(quote, "airlineCode", "airline", "airlineName"),
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"))), fact(ticket, "航司", "航司代码"),
airlineCodeOrBlank(text(option, "airlineName")), airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")))));
airlineCodeOrBlank(text(quote, "airlineName")),
airlineCodeOrBlank(fact(ticket, "航司")));
req.put("airline", airlineCode);
req.put("weightKg", ticket.getWeightKg()); req.put("weightKg", ticket.getWeightKg());
req.put("volumeCbm", ticket.getVolumeCbm()); req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces()); req.put("pieceQty", ticket.getPieces());
// 计费重:舱位 > 报价 > 工单计费重事实 > 毛重(避免 MANUAL 空壳 1001)。
req.put("chargeableWeightKg", firstNonBlank( req.put("chargeableWeightKg", firstNonBlank(
text(option, "chargeableWeightKg", "chargeable_weight_kg"), text(option, "chargeableWeightKg", "chargeable_weight_kg"),
text(quote, "chargeableWeightKg", "chargeable_weight_kg"), text(quote, "chargeableWeightKg", "chargeable_weight_kg")));
fact(ticket, "计费重", "计费重量"),
ticket.getWeightKg() == null ? "" : String.valueOf(ticket.getWeightKg()),
fact(ticket, "毛重")));
req.put("operatorUserId", text(body, "operatorUserId", "sender_id")); req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
// 重试漏传姓名时用 userid 兜底,仍要有 operatorName 键。 req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("operatorName", firstNonBlank(
text(body, "operatorName", "operator_name"),
text(body, "operatorUserId", "sender_id")));
req.put("instructionText", text(body, "instructionText", "instruction_text")); req.put("instructionText", text(body, "instructionText", "instruction_text"));
if ("MANUAL".equals(lockSource)) { if ("MANUAL".equals(lockSource)) {
// 有 TMS 时保留 quoteId;纯人工才去掉。 // 有 TMS 时保留 quoteId;纯人工才去掉。
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId)); fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
// 人工价常不写有效期:用航班日当天 23:59:59+08:00 兜底,避免空壳 1001。
if (StringUtils.isBlank(text(req, "validUntil"))) {
String isoUntil = normalizeValidUntilIso(text(req, "flightDate"));
if (StringUtils.isNotBlank(isoUntil)) {
req.put("validUntil", isoUntil);
if (StringUtils.isBlank(text(req, "requestedUntil"))) {
req.put("requestedUntil", isoUntil);
}
}
}
// 缺必填仍出站会被 TMS 1001;在此拦住并回清原因。
String incomplete = validateManualLockOutbound(req);
// 现拉仍无舱位时,改成业务补问口径(禁止堆「缺少 optionNo」技术字段名)。
if (StringUtils.isNotBlank(incomplete)) {
String refreshClass = text(quote, "_lockRefreshClass");
String refreshMsg = text(quote, "_lockRefreshMsg");
boolean noMatch = "TMS_NO_QUOTE_1002".equals(refreshClass)
|| (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
if (noMatch) {
incomplete = "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。"
+ "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"
+ "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")";
} else if (StringUtils.isNotBlank(refreshMsg)) {
incomplete = incomplete + "(现拉TMS:" + refreshMsg + ")";
}
}
if (StringUtils.isNotBlank(incomplete)) {
log.info("tms.lock incomplete wo={} optionNo={} missing={}",
no, resolvedOption, incomplete);
return fail("lock_incomplete", incomplete);
}
} else { } else {
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。 // 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
req.put("source", "SYSTEM"); req.put("source", "SYSTEM");
@@ -233,6 +207,26 @@ public class InquiryTmsCabinService {
req.put("requestedUntil", text(body, "requestedUntil")); req.put("requestedUntil", text(body, "requestedUntil"));
} }
} }
// 航线口令里的航班号盖过报价。三项没齐就地拦住,不去调 TMS 锁舱。
String flightFromCaller = firstNonBlank(
text(body, "flightNo", "flight_no"),
flightNoFromInstruction(text(body, "instructionText", "instruction_text")));
if (StringUtils.isNotBlank(flightFromCaller)) {
req.put("flightNo", flightFromCaller);
}
if (StringUtils.isBlank(text(req, "airline"))) {
String fromFlight = airlineFromFlight(text(req, "flightNo"));
if (StringUtils.isNotBlank(fromFlight)) {
req.put("airline", fromFlight);
}
}
String incomplete = missingLockParamReason(
text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
if (StringUtils.isNotBlank(incomplete)) {
log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={}",
no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
return fail("lock_params_incomplete", incomplete);
}
return invokeLock(ticket, req, quoteId, resolvedOption); return invokeLock(ticket, req, quoteId, resolvedOption);
} }
@@ -362,26 +356,14 @@ public class InquiryTmsCabinService {
} }
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("workOrderNo", ticket.getWorkOrderNo()); body.put("workOrderNo", ticket.getWorkOrderNo());
// 锁舱现拉必须用「今天」报价日:历史询价日常已无运价(1002),
// 不能把销售初询那天的日期带出去导致永远补不到舱位。
JSONObject facts = new JSONObject();
if (StringUtils.isNotBlank(ticket.getFactsJson())) { if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject stored = JSON.parseObject(ticket.getFactsJson()); JSONObject facts = JSON.parseObject(ticket.getFactsJson());
if (stored != null) { if (facts != null) {
facts.putAll(stored);
}
}
SimpleDateFormat dayFmt = new SimpleDateFormat("yyyy-MM-dd");
dayFmt.setTimeZone(java.util.TimeZone.getTimeZone("Asia/Shanghai"));
String today = dayFmt.format(new java.util.Date());
facts.put("报价日期", today);
body.put("facts", facts); body.put("facts", facts);
}
}
try { try {
Map<String, Object> live = queryService.queryFromAgent(body); Map<String, Object> live = queryService.queryFromAgent(body);
if (live != null && live.get("classification") != null) {
current.put("_lockRefreshClass", String.valueOf(live.get("classification")));
current.put("_lockRefreshMsg", String.valueOf(live.get("message") == null ? "" : live.get("message")));
}
Object raw = live == null ? null : live.get("quote"); Object raw = live == null ? null : live.get("quote");
if (!(raw instanceof Map)) { if (!(raw instanceof Map)) {
return current; return current;
@@ -394,19 +376,14 @@ public class InquiryTmsCabinService {
if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) { if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) {
current.put("airOptions", fetched.get("airOptions")); current.put("airOptions", fetched.get("airOptions"));
} }
if (airOptions(current).isEmpty() && fetched.get("air_options") != null) {
current.put("airOptions", fetched.get("air_options"));
}
if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg")) if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg"))
&& StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) { && StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) {
current.put("chargeableWeightKg", text(fetched, "chargeableWeightKg", "chargeable_weight_kg")); current.put("chargeableWeightKg", text(fetched, "chargeableWeightKg", "chargeable_weight_kg"));
} }
log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={} class={} quoteDate={}", log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={}",
ticket.getWorkOrderNo(), ticket.getWorkOrderNo(),
StringUtils.isNotBlank(text(current, "quoteId", "quote_id")), StringUtils.isNotBlank(text(current, "quoteId", "quote_id")),
airOptions(current).size(), airOptions(current).size());
live == null ? "" : live.get("classification"),
today);
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage()); log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
} }
@@ -475,16 +452,20 @@ public class InquiryTmsCabinService {
req.remove("quoteId"); req.remove("quoteId");
} }
fillLockQuoteExtras(req, quote, option); fillLockQuoteExtras(req, quote, option);
if (StringUtils.isBlank(text(req, "validUntil"))) {
// 航线手工价经常不写有效期。TMS 人工锁舱必填 validUntil,
// 用工单报价日(调用前已放进 flightDate)的当天结束,不再空着出站。
String iso = normalizeValidUntilIso(text(req, "flightDate"));
if (StringUtils.isNotBlank(iso)) {
req.put("validUntil", iso);
}
}
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"), String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
text(quote, "transferAirport", "transferAirportCode")); text(quote, "transferAirport", "transferAirportCode"));
if (StringUtils.isNotBlank(transfer)) { if (StringUtils.isNotBlank(transfer)) {
req.put("transferAirport", transfer); req.put("transferAirport", transfer);
} }
// 句中/请求体点名的航班号优先,其次舱位与报价。 String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"));
String flightNo = firstNonBlank(
text(body, "flightNo", "flight_no"),
text(option, "flightNo", "flight_no"),
text(quote, "flightNo", "flight_no"));
if (StringUtils.isNotBlank(flightNo)) { if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo); req.put("flightNo", flightNo);
} }
@@ -531,41 +512,11 @@ public class InquiryTmsCabinService {
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges"); List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items"); List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
splitFeeRows(quote, departure, fees); splitFeeRows(quote, departure, fees);
// 运行证据(2026-10-07):只要出站带 departureCharges,TMS 一律 1001「参数格式错误」; if (!departure.isEmpty()) {
// 去掉后变为业务码 2008。feeItems 可保留。故 MANUAL 锁舱不再出站 departureCharges。 req.put("departureCharges", toLockFees(departure, true));
}
if (!fees.isEmpty()) { if (!fees.isEmpty()) {
req.put("feeItems", toLockFees(fees, false)); req.put("feeItems", toLockFees(fees, false));
} else if (!departure.isEmpty()) {
// 仅有起运港费、无 feeItems 时,折进 feeItems,避免再走 departureCharges。
List<Map<String, Object>> asFees = new ArrayList<>();
for (Map<String, Object> row : toLockFees(departure, true)) {
Map<String, Object> fee = new LinkedHashMap<>();
String name = text(row, "chargeName", "feeName");
if (StringUtils.isNotBlank(name)) {
fee.put("feeName", name);
}
if (row.get("unitPrice") != null) {
fee.put("unitPrice", row.get("unitPrice"));
}
if (row.get("quantity") != null) {
fee.put("quantity", row.get("quantity"));
}
if (row.get("amount") != null) {
fee.put("amount", row.get("amount"));
}
if (row.get("currency") != null) {
fee.put("currency", row.get("currency"));
}
if (row.get("unit") != null) {
fee.put("unit", row.get("unit"));
}
if (!fee.isEmpty()) {
asFees.add(fee);
}
}
if (!asFees.isEmpty()) {
req.put("feeItems", asFees);
}
} }
} }
@@ -789,24 +740,10 @@ public class InquiryTmsCabinService {
return true; return true;
} }
/**
* 本单真实 TMS quoteId:优先非人工报价行;人工覆盖后再锁仍要带原编号。
* 跳过 QT-WO 假编号。
*/
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) { static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
if (rows == null) { if (rows == null) {
return ""; return "";
} }
for (InquiryQuote row : rows) {
Map<String, Object> payload = payloadOf(row);
if (isManualQuote(payload) && !isFakeManualLockQuote(payload)) {
continue;
}
String id = resolveQuoteId(payload, workOrderNo);
if (StringUtils.isNotBlank(id)) {
return id;
}
}
for (InquiryQuote row : rows) { for (InquiryQuote row : rows) {
String id = resolveQuoteId(payloadOf(row), workOrderNo); String id = resolveQuoteId(payloadOf(row), workOrderNo);
if (StringUtils.isNotBlank(id)) { if (StringUtils.isNotBlank(id)) {
@@ -816,51 +753,6 @@ public class InquiryTmsCabinService {
return ""; return "";
} }
/**
* MANUAL 出站必填校验。缺字段直接失败,禁止 HMAC 空壳打 TMS(会 1001)。
* 返回中文原因;齐了返回空串。
*/
static String validateManualLockOutbound(Map<String, Object> req) {
if (req == null) {
return "锁舱参数为空";
}
java.util.List<String> missing = new ArrayList<>();
if (StringUtils.isBlank(text(req, "optionNo", "option_no"))) {
missing.add("舱位编号optionNo");
}
if (StringUtils.isBlank(text(req, "airline", "airlineCode"))) {
missing.add("航司airline");
}
if (StringUtils.isBlank(text(req, "chargeableWeightKg", "chargeable_weight_kg"))) {
missing.add("计费重chargeableWeightKg");
}
if (StringUtils.isBlank(text(req, "flightNo", "flight_no"))) {
missing.add("航班号flightNo");
}
if (StringUtils.isBlank(text(req, "operatorName", "operator_name"))) {
missing.add("操作人operatorName");
}
if (StringUtils.isBlank(text(req, "validUntil", "valid_until"))) {
missing.add("有效期validUntil");
}
if (missing.isEmpty()) {
return "";
}
return "锁舱参数不完整,缺少:" + String.join("、", missing)
+ "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。";
}
/**
* 航司名只有恰好两位字母时才当代码;「南航」等中文名不当 airline 出站。
*/
static String airlineCodeOrBlank(String raw) {
String text = StringUtils.trimToEmpty(raw).toUpperCase();
if (text.length() == 2 && Character.isLetter(text.charAt(0)) && Character.isLetter(text.charAt(1))) {
return text;
}
return "";
}
private List<InquiryQuote> quoteRows(String workOrderNo) { private List<InquiryQuote> quoteRows(String workOrderNo) {
if (StringUtils.isBlank(workOrderNo)) { if (StringUtils.isBlank(workOrderNo)) {
return new ArrayList<>(); return new ArrayList<>();
@@ -1185,6 +1077,50 @@ public class InquiryTmsCabinService {
return row; return row;
} }
/**
* 锁舱三项没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。
* 三项都缺时是:锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。
*/
static String missingLockParamReason(String optionNo, String airline, String flightNo) {
java.util.List<String> missing = new ArrayList<>();
if (StringUtils.isBlank(optionNo)) {
missing.add("舱位编号");
}
if (StringUtils.isBlank(airline)) {
missing.add("航司");
}
if (StringUtils.isBlank(flightNo)) {
missing.add("航班号");
}
if (missing.isEmpty()) {
return "";
}
return "锁舱参数不完整,缺少:" + String.join("、", missing)
+ "。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。";
}
/**
* 从「AIR-OPT01 CZ 航班CZ327」抽出航班号。没有就不猜。
*/
static String flightNoFromInstruction(String instruction) {
if (StringUtils.isBlank(instruction)) {
return "";
}
java.util.regex.Matcher named = java.util.regex.Pattern
.compile("(?i)(?:航班号|航班)\\s*[::]?\\s*(?:是|为)?\\s*([A-Za-z]{2}\\s*\\d{2,4})")
.matcher(instruction);
if (named.find()) {
return named.group(1).toUpperCase();
}
java.util.regex.Matcher trailed = java.util.regex.Pattern
.compile("(?i)AIR-?OPT-?\\d+\\s+(?:[A-Za-z]{2}\\s+)?([A-Za-z]{2}\\d{2,4})")
.matcher(instruction);
if (trailed.find()) {
return trailed.group(1).toUpperCase();
}
return "";
}
/** /**
* 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。 * 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。
*/ */
@@ -484,21 +484,6 @@ public class InquiryTmsQueryService {
putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1]))); putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1])));
putExtra(row, pair[0], firstText(seg.get(pair[0]))); putExtra(row, pair[0], firstText(seg.get(pair[0])));
} }
// 陆运 TMS:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
// 只投影 landSegments,禁止拿 fxRates.rate(汇率)冒充税率。
String fuelRate = formatLandPercentRate(firstText(
seg.get("fuelSurchargeRate"), seg.get("fuel_surcharge_rate")));
putExtra(row, "fuel_surcharge_rate", fuelRate);
putExtra(row, "fuelSurchargeRate", fuelRate);
String taxRate = formatLandTaxRate(firstText(
seg.get("rate"), seg.get("tax_rate"), seg.get("taxRate")));
putExtra(row, "tax_rate", taxRate);
putExtra(row, "taxRate", taxRate);
putExtra(row, "rate", firstText(seg.get("rate")));
String rateFlagRaw = firstText(seg.get("rateFlag"), seg.get("rate_flag"));
putExtra(row, "rateFlag", rateFlagRaw);
putExtra(row, "rate_flag", rateFlagRaw);
putExtra(row, "tax_included", formatLandTaxIncluded(rateFlagRaw));
if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination")) if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination"))
|| StringUtils.isNotBlank(row.get("amount"))) { || StringUtils.isNotBlank(row.get("amount"))) {
out.add(row); out.add(row);
@@ -1019,72 +1004,6 @@ public class InquiryTmsQueryService {
} }
} }
/**
* 燃油附加费率展示:数字带 %;已带 % 保留。空值不编造。
*/
static String formatLandPercentRate(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (text.matches("-?\\d+(\\.\\d+)?")) {
if (text.contains(".")) {
text = text.replaceAll("0+$", "").replaceAll("\\.$", "");
}
return text + "%";
}
return text;
}
/**
* 税率 rate 展示:TMS 常回小数(0.09=9%);大于 1 的按百分点(10=10%)。
*/
static String formatLandTaxRate(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (!text.matches("-?\\d+(\\.\\d+)?")) {
return text;
}
try {
java.math.BigDecimal n = new java.math.BigDecimal(text);
java.math.BigDecimal abs = n.abs();
if (abs.compareTo(java.math.BigDecimal.ZERO) > 0
&& abs.compareTo(java.math.BigDecimal.ONE) <= 0) {
n = n.multiply(java.math.BigDecimal.TEN).multiply(java.math.BigDecimal.TEN)
.stripTrailingZeros();
}
String out = n.stripTrailingZeros().toPlainString();
return out + "%";
} catch (RuntimeException ex) {
return text;
}
}
/**
* rateFlag:是否含税。字典 1=是、0=否。
*/
static String formatLandTaxIncluded(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if ("1".equals(text) || "true".equalsIgnoreCase(text) || "是".equals(text) || "含税".equals(text)) {
return "含税";
}
if ("0".equals(text) || "false".equalsIgnoreCase(text) || "否".equals(text) || "不含税".equals(text)) {
return "不含税";
}
return text;
}
private static void addFeeLine(List<Map<String, String>> lines, String name, String amount, private static void addFeeLine(List<Map<String, String>> lines, String name, String amount,
String currency, Map<String, String> extras) { String currency, Map<String, String> extras) {
TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency); TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency);
@@ -4,6 +4,7 @@ package org.jeecg.modules.inquiry.tms;
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。 * 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
* <p> * <p>
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。 * 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
* 中港整车、中港零担/集拼查价只传「中港」,工单仍留细类。
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。 * 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。 * 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
*/ */
@@ -14,10 +15,15 @@ public final class TmsLandEnumMapper {
/** /**
* @param raw 销售中文(或历史英文码) * @param raw 销售中文(或历史英文码)
* @return trim 后原文;空则 "" * @return 中港两类收成「中港」;其余 trim 后原文;空则 ""
*/ */
public static String transportType(String raw) { public static String transportType(String raw) {
return trimOrEmpty(raw); String text = trimOrEmpty(raw);
if ("中港整车".equals(text) || "中港零担/集拼".equals(text)) {
// zhonggang: 两类细类查价都只传「中港」
return "中港";
}
return text;
} }
/** /**
@@ -18,8 +18,8 @@ import java.util.regex.Pattern;
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。 * 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数; * 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
* 中港整车不带车型/数量。通关口岸有值才放。 * 中港整车不带车型/数量。通关口岸有值才放。
* 中港整车、中港零担/集拼:出站类型仍用销售中文「中港整车」「中港零担/集拼」(产品要求不做英文枚举映射)。 * 中港整车、中港零担/集拼:出站 landTransportType 只写「中港」,工单事实仍留细类。
* 类型/线路键名仍是 landTransportType / routeCategory,值用中文原文。 * 类型/线路键名仍是 landTransportType / routeCategory。除中港两类外,值用中文原文。
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。 * 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
*/ */
public final class TmsLandSegmentMapper { public final class TmsLandSegmentMapper {
@@ -151,7 +151,7 @@ public final class TmsV2RequestMapper {
} }
/** /**
* 陆运出站:类型+线路用销售中文原文(产品要求不做英文枚举映射);线路字段名是 routeCategory。 * 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。
* 分类和中港装载即使误放在 source 也不抄走。 * 分类和中港装载即使误放在 source 也不抄走。
*/ */
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) { private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
@@ -69,7 +69,7 @@ VALUES
('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()), ('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()),
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()), ('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()),
('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()), ('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()),
('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。',1,2,'admin',NOW()), ('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已记下未成交原因:{{lost_reason}}。',1,2,'admin',NOW()),
('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()), ('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()),
('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()), ('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()),
('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()), ('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()),
@@ -13,7 +13,13 @@
{"source_name": "北京首都", "source_code": "PEK"}, {"source_name": "北京首都", "source_code": "PEK"},
{"source_name": "北京大兴", "source_code": "PKX"}, {"source_name": "北京大兴", "source_code": "PKX"},
{"source_name": "珠海", "source_code": "ZUH"}, {"source_name": "珠海", "source_code": "ZUH"},
{"source_name": "克拉克", "source_code": "CRK"} {"source_name": "克拉克", "source_code": "CRK"},
{"source_name": "南京", "source_code": "NKG"},
{"source_name": "吉隆坡", "source_code": "KUL"},
{"source_name": "北京", "source_code": "PEK"},
{"source_name": "杭州", "source_code": "HGH"},
{"source_name": "萧山", "source_code": "HGH"},
{"source_name": "杭州萧山", "source_code": "HGH"}
], ],
"air_packaging_type": [ "air_packaging_type": [
{"source_name": "全部", "source_code": "1"}, {"source_name": "全部", "source_code": "1"},
@@ -2231,58 +2231,4 @@ public class QuoteTemplateFillTest {
} }
return cell.getStringCellValue(); return cell.getStringCellValue();
} }
/**
* 陆运运价表:燃油附加费率、税率必须写到表头同列的数据行。
* rate=0.09→9%;rateFlag 是是否含税,不写进税率格。
*/
@Test
public void landGroupageFillsFuelSurchargeAndTaxRateOnMatrix() throws Exception {
Path src = Files.createTempFile("quote-land-fuel-tax-src", ".xlsx");
Path out = Files.createTempFile("quote-land-fuel-tax-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("中越集拼报价");
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
matrix.createCell(1).setCellValue("始发地");
matrix.createCell(2).setCellValue("目的地");
matrix.createCell(9).setCellValue("币种");
matrix.createCell(10).setCellValue("燃油附加费率");
matrix.createCell(11).setCellValue("税率");
row(sheet, 9);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "中越集拼报价");
JSONArray regions = new JSONArray();
JSONObject region = new JSONObject(true);
region.put("headerRow", 8);
region.put("dataStartRow", 10);
JSONObject columns = new JSONObject(true);
columns.put("origin", "B");
columns.put("destination", "C");
region.put("columns", columns);
regions.add(region);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"origin\":\"广州\",\"destination\":\"河内\",\"fuelSurchargeRate\":\"8\",\"rate\":\"0.09\",\"rateFlag\":\"1\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("广州", read(sheet, 9, 1));
assertEquals("河内", read(sheet, 9, 2));
assertEquals("8%", read(sheet, 9, 10));
assertEquals("9%", read(sheet, 9, 11));
assertEquals("燃油附加费率", read(sheet, 7, 10));
assertEquals("税率", read(sheet, 7, 11));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
assertEquals("9%", QuoteTemplateExcelSupport.formatLandTaxRateDisplay("0.09"));
assertEquals("8%", QuoteTemplateExcelSupport.formatLandPercentDisplay("8.0"));
assertEquals("含税", QuoteTemplateExcelSupport.formatLandTaxIncludedDisplay("1"));
}
} }
@@ -15,6 +15,15 @@ import static org.junit.jupiter.api.Assertions.assertNull;
*/ */
public class InquiryTmsCabinServiceTest { public class InquiryTmsCabinServiceTest {
@Test
public void multiInquiryCannotLockCabin() {
assertEquals(
"多段询价,暂时不支持锁舱",
InquiryTmsCabinService.lockRejectReason("MULTI"));
assertEquals("不是空运单,不能锁舱", InquiryTmsCabinService.lockRejectReason("SEA"));
assertEquals("", InquiryTmsCabinService.lockRejectReason("AIR"));
}
@Test @Test
public void pickNamedOption() { public void pickNamedOption() {
Map<String, Object> quote = new HashMap<>(); Map<String, Object> quote = new HashMap<>();
@@ -178,14 +187,44 @@ public class InquiryTmsCabinServiceTest {
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option); InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option);
assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil")); assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil"));
// departureCharges 出站会 TMS 1001,只保留 feeItems。 @SuppressWarnings("unchecked")
org.junit.jupiter.api.Assertions.assertNull(req.get("departureCharges")); java.util.List<Map<String, Object>> dep =
(java.util.List<Map<String, Object>>) req.get("departureCharges");
assertEquals("运输费", dep.get(0).get("chargeName"));
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> fees = java.util.List<Map<String, Object>> fees =
(java.util.List<Map<String, Object>>) req.get("feeItems"); (java.util.List<Map<String, Object>>) req.get("feeItems");
assertEquals("空运费", fees.get(0).get("feeName")); assertEquals("空运费", fees.get(0).get("feeName"));
} }
@Test
public void manualLockFillsValidUntilFromFlightDateWhenQuoteHasNone() {
Map<String, Object> req = new HashMap<>();
req.put("flightDate", "2026-10-11");
InquiryTmsCabinService.fillManualLock(req, new HashMap<>(), new HashMap<>(), new HashMap<>(), false);
assertEquals("2026-10-11T23:59:59+08:00", req.get("validUntil"));
assertEquals("2026-10-11T23:59:59+08:00", req.get("requestedUntil"));
}
@Test
public void missingLockParamsAsksWithoutCallingTmsWording() {
assertEquals(
"锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
InquiryTmsCabinService.missingLockParamReason("", "", ""));
assertEquals(
"锁舱参数不完整,缺少:航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", ""));
assertEquals("", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327"));
}
@Test
public void flightNoFromInstructionReadsSampleFormat() {
assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction(
"AIR-OPT01 CZ 航班CZ327"));
assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction("AIR-OPT01 CZ CZ327"));
assertEquals("", InquiryTmsCabinService.flightNoFromInstruction("WO202610080023锁舱"));
}
@Test @Test
public void airlineFromInstructionReadsCodeAfterOption() { public void airlineFromInstructionReadsCodeAfterOption() {
assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction( assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction(
@@ -259,40 +298,4 @@ public class InquiryTmsCabinServiceTest {
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false); InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false);
org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId")); org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId"));
} }
@Test
public void validateManualLockOutboundReportsMissingFields() {
Map<String, Object> empty = new HashMap<>();
String why = InquiryTmsCabinService.validateManualLockOutbound(empty);
org.junit.jupiter.api.Assertions.assertTrue(why.contains("optionNo"));
org.junit.jupiter.api.Assertions.assertTrue(why.contains("airline"));
org.junit.jupiter.api.Assertions.assertTrue(why.contains("chargeableWeightKg"));
Map<String, Object> ok = new HashMap<>();
ok.put("optionNo", "AIR-OPT01");
ok.put("airline", "CZ");
ok.put("chargeableWeightKg", "100");
ok.put("flightNo", "CZ327");
ok.put("operatorName", "航线");
ok.put("validUntil", "2026-10-01T23:59:59+08:00");
assertEquals("", InquiryTmsCabinService.validateManualLockOutbound(ok));
}
@Test
public void airlineCodeOrBlankRejectsChineseName() {
assertEquals("CZ", InquiryTmsCabinService.airlineCodeOrBlank("CZ"));
assertEquals("", InquiryTmsCabinService.airlineCodeOrBlank("南航"));
assertEquals("", InquiryTmsCabinService.airlineCodeOrBlank(""));
}
@Test
public void realTmsQuoteIdSkipsManualAndKeepsOlderTms() {
org.jeecg.modules.inquiry.entity.InquiryQuote manual = new org.jeecg.modules.inquiry.entity.InquiryQuote();
manual.setPayloadJson("{\"source\":\"PRODUCT\",\"source_label\":\"航线报价\",\"total\":\"200\"}");
org.jeecg.modules.inquiry.entity.InquiryQuote tms = new org.jeecg.modules.inquiry.entity.InquiryQuote();
tms.setPayloadJson("{\"source\":\"TMS\",\"quoteId\":\"AIR-REAL-1\",\"airOptions\":[{\"optionNo\":\"AIR-OPT01\"}]}");
String id = InquiryTmsCabinService.realTmsQuoteId(
java.util.Arrays.asList(manual, tms), "WO202610030009");
assertEquals("AIR-REAL-1", id);
}
} }
@@ -32,4 +32,11 @@ public class TmsExactProjectorTest {
assertTrue(hit.ok()); assertTrue(hit.ok());
assertEquals("SHA", hit.code); assertEquals("SHA", hit.code);
} }
@Test
public void hangzhouMapsToXiaoshan() {
TmsExactProjector.Projection hit = projector.resolveAirport("杭州");
assertTrue(hit.ok());
assertEquals("HGH", hit.code);
}
} }
@@ -27,6 +27,13 @@ public class TmsLandEnumMapperTest {
assertEquals("", TmsLandEnumMapper.routeCategory(" ")); assertEquals("", TmsLandEnumMapper.routeCategory(" "));
} }
@Test
public void hkTypesCollapseToZhonggang() {
assertEquals("中港", TmsLandEnumMapper.transportType("中港整车"));
assertEquals("中港", TmsLandEnumMapper.transportType("中港零担/集拼"));
assertEquals("中港集拼", TmsLandEnumMapper.transportType("中港集拼"));
}
@Test @Test
public void legacyEnglishCodesPassThrough() { public void legacyEnglishCodesPassThrough() {
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL")); assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL"));
@@ -46,7 +46,7 @@ public class TmsLandSegmentMapperTest {
facts.put("车型/数量", "9米6/1"); facts.put("车型/数量", "9米6/1");
facts.put("体积", "99"); facts.put("体积", "99");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts); Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("中港零担/集拼", seg.get("landTransportType")); assertEquals("中港", seg.get("landTransportType"));
assertEquals("9米6", seg.get("vehicleType")); assertEquals("9米6", seg.get("vehicleType"));
assertNull(seg.get("volumeCbm")); assertNull(seg.get("volumeCbm"));
assertFalse(seg.containsKey("hkLoadType")); assertFalse(seg.containsKey("hkLoadType"));
@@ -62,7 +62,7 @@ public class TmsLandSegmentMapperTest {
facts.put("目的港", "香港"); facts.put("目的港", "香港");
facts.put("品名", "衣服"); facts.put("品名", "衣服");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts); Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("中港整车", seg.get("landTransportType")); assertEquals("中港", seg.get("landTransportType"));
assertNull(seg.get("vehicleType")); assertNull(seg.get("vehicleType"));
assertNull(seg.get("quantity")); assertNull(seg.get("quantity"));
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery( java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
@@ -184,42 +184,6 @@ public class TmsQuoteCurrencyTest {
assertTrue(quote.get("fee_lines").contains("2800")); assertTrue(quote.get("fee_lines").contains("2800"));
} }
/**
* 陆运 TMS:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税。
* 禁止用 fxRates.rate(汇率)冒充税率。
*/
@Test
public void toAgentQuoteProjectsLandFuelAndTaxRate() {
Map<String, Object> seg = new HashMap<>();
seg.put("segmentSequence", 1);
seg.put("origin", "广州市");
seg.put("destination", "深圳市");
seg.put("costPrice", "110");
seg.put("currency", "CNY");
seg.put("fuelSurchargeRate", 8);
seg.put("rate", 0.09);
seg.put("rateFlag", 1);
Map<String, Object> data = new HashMap<>();
data.put("quoteFound", true);
data.put("currency", "CNY");
data.put("landSegments", Collections.singletonList(seg));
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
assertTrue(quote.get("route_rows").contains("\"fuel_surcharge_rate\":\"8%\""));
assertTrue(quote.get("route_rows").contains("\"tax_rate\":\"9%\""));
assertTrue(quote.get("route_rows").contains("\"tax_included\":\"含税\""));
assertTrue(quote.get("route_rows").contains("\"rateFlag\":\"1\""));
assertFalse(quote.get("route_rows").contains("fxRates"));
assertEquals("8%", InquiryTmsQueryService.formatLandPercentRate("8"));
assertEquals("9%", InquiryTmsQueryService.formatLandTaxRate("0.09"));
assertEquals("10%", InquiryTmsQueryService.formatLandTaxRate("10"));
assertEquals("含税", InquiryTmsQueryService.formatLandTaxIncluded("1"));
assertEquals("不含税", InquiryTmsQueryService.formatLandTaxIncluded("0"));
assertEquals("", InquiryTmsQueryService.formatLandPercentRate(null));
}
@Test @Test
public void toAgentQuoteKeepsSeaBucketTotals() { public void toAgentQuoteKeepsSeaBucketTotals() {
Map<String, Object> pol = new HashMap<>(); Map<String, Object> pol = new HashMap<>();
@@ -568,22 +568,13 @@ public class LoginController {
} }
/** /**
* 切换菜单表为vue3的表。 * 切换菜单表为vue3的表
* <p>
* 询价新库若已无 {@code sys_permission_v3},服务层会拒绝切换并返回错误,避免把现用菜单表 rename 掉。
* </p>
*/ */
@RequiresRoles({"admin"}) @RequiresRoles({"admin"})
@GetMapping(value = "/switchVue3Menu") @GetMapping(value = "/switchVue3Menu")
public Result<String> switchVue3Menu(HttpServletResponse response) { public Result<String> switchVue3Menu(HttpServletResponse response) {
Result<String> res = new Result<String>(); Result<String> res = new Result<String>();
try {
sysPermissionService.switchVue3Menu(); sysPermissionService.switchVue3Menu();
res.success("切换成功");
} catch (Exception e) {
log.error("switchVue3Menu rejected or failed: {}", e.getMessage(), e);
res.error500(e.getMessage());
}
return res; return res;
} }
@@ -50,18 +50,6 @@ public interface SysPermissionMapper extends BaseMapper<SysPermission> {
@Update("alter table sys_permission_v3 rename to sys_permission") @Update("alter table sys_permission_v3 rename to sys_permission")
public void changeVue3Menu(); public void changeVue3Menu();
/**
* 是否存在 Jeecg 官方 Vue3 菜单备份表 {@code sys_permission_v3}。
* <p>
* 询价新库 {@code inquiry_robot} 的菜单种子本身已是 Vue3 形态,通常没有 v3 表;
* 若此时仍执行 rename 切换,会把正在使用的 {@code sys_permission} 改名后找不到替代表,侧栏全挂。
* </p>
*
* @return 存在则 &gt; 0
*/
@Select("SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'sys_permission_v3'")
Long countVue3MenuTable();
/** /**
* 获取模糊匹配规则的数据权限URL * 获取模糊匹配规则的数据权限URL
* @return List<String> * @return List<String>
@@ -55,22 +55,8 @@ public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, S
@Resource @Resource
private SysDepartRolePermissionMapper sysDepartRolePermissionMapper; private SysDepartRolePermissionMapper sysDepartRolePermissionMapper;
/**
* 将菜单表从 Vue2 备份切换为 Vue3 表(Jeecg 官方升级路径)。
* <p>
* 副作用:{@code sys_permission} rename 为 {@code sys_permission_v2},再把 {@code sys_permission_v3}
* rename 为 {@code sys_permission}。线程:请求线程,需 admin 角色。
* </p>
* <p>
* 为何加护栏:询价测服库菜单已是 Vue3,且没有 {@code sys_permission_v3};盲目切换会弄挂后台菜单。
* </p>
*/
@Override @Override
public void switchVue3Menu() { public void switchVue3Menu() {
Long vue3TableCnt = sysPermissionMapper.countVue3MenuTable();
if (vue3TableCnt == null || vue3TableCnt.longValue() <= 0L) {
throw new JeecgBootException("当前库没有 sys_permission_v3,菜单已是 Vue3 或不可自动切换,已拒绝执行");
}
sysPermissionMapper.backupVue2Menu(); sysPermissionMapper.backupVue2Menu();
sysPermissionMapper.changeVue3Menu(); sysPermissionMapper.changeVue3Menu();
} }
@@ -2,6 +2,7 @@ import type { AppRouteRecordRaw, Menu } from '/@/router/types';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { store } from '/@/store'; import { store } from '/@/store';
import { useI18n } from '/@/hooks/web/useI18n';
import { useUserStore } from './user'; import { useUserStore } from './user';
import { useAppStoreWithOut } from './app'; import { useAppStoreWithOut } from './app';
import { toRaw } from 'vue'; import { toRaw } from 'vue';
@@ -17,9 +18,10 @@ import { ERROR_LOG_ROUTE, PAGE_NOT_FOUND_ROUTE } from '/@/router/routes/basic';
import { filter } from '/@/utils/helper/treeHelper'; import { filter } from '/@/utils/helper/treeHelper';
import { getMenuList } from '/@/api/sys/menu'; import { getMenuList,switchVue3Menu } from '/@/api/sys/menu';
import { getPermCode } from '/@/api/sys/user'; import { getPermCode } from '/@/api/sys/user';
import { useMessage } from '/@/hooks/web/useMessage';
import { PageEnum } from '/@/enums/pageEnum'; import { PageEnum } from '/@/enums/pageEnum';
/** /**
@@ -244,6 +246,7 @@ export const usePermissionStore = defineStore({
this.setAuthData(systemPermission); this.setAuthData(systemPermission);
}, },
async buildRoutesAction(): Promise<AppRouteRecordRaw[]> { async buildRoutesAction(): Promise<AppRouteRecordRaw[]> {
const { t } = useI18n();
const userStore = useUserStore(); const userStore = useUserStore();
const appStore = useAppStoreWithOut(); const appStore = useAppStoreWithOut();
@@ -319,18 +322,54 @@ export const usePermissionStore = defineStore({
// 后台菜单构建 // 后台菜单构建
case PermissionModeEnum.BACK: case PermissionModeEnum.BACK:
const { createMessage, createWarningModal } = useMessage();
// 菜单加载提示
// createMessage.loading({
// content: t('sys.app.menuLoading'),
// duration: 1,
// });
// 从后台获取权限码, // 从后台获取权限码,
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置 // 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
let routeList: AppRouteRecordRaw[] = []; let routeList: AppRouteRecordRaw[] = [];
try { try {
this.changePermissionCode(); this.changePermissionCode();
routeList = (await getMenuList()) as AppRouteRecordRaw[]; routeList = (await getMenuList()) as AppRouteRecordRaw[];
// Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu // update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
// (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。 let hasIndex: boolean = false;
// 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。 let hasIcon: boolean = false;
// 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。 for (let menuItem of routeList) {
// update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 --- // 条件1:判断组件是否是 layouts/default/index
// update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 --- if (!hasIndex) {
hasIndex = menuItem.component === 'layouts/default/index';
}
// 条件2:判断图标是否带有 冒号
if (!hasIcon) {
hasIcon = !!menuItem.meta?.icon?.includes(':');
}
// 满足任何一个条件都直接跳出循环
if (hasIcon || hasIndex) {
break;
}
}
// 两个条件都不满足,就弹出提示框
if (!hasIcon && !hasIndex) {
// 延迟1.5秒之后再出现提示,否则提示框出不来
setTimeout(
() =>
createWarningModal({
title: '检测提示',
content:
'当前菜单表是 <b>Vue2版本</b>,导致菜单加载异常!<br>点击确认,切换到Vue3版菜单!',
onOk:function () {
switchVue3Menu();
location.reload();
}
}),
100
);
}
// update-end----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
} catch (error) { } catch (error) {
console.error(error); console.error(error);
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404 // 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
@@ -325,7 +325,6 @@ function removeAccount(row: AccountRow) {
<th>账号</th> <th>账号</th>
<th>姓名</th> <th>姓名</th>
<th>绑定角色</th> <th>绑定角色</th>
<th>数据范围</th>
<th>手机号</th> <th>手机号</th>
<th>最后登录</th> <th>最后登录</th>
<th>状态</th> <th>状态</th>
@@ -334,10 +333,10 @@ function removeAccount(row: AccountRow) {
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading"> <tr v-if="loading">
<td colspan="8" style="text-align: center; color: #94a3b8">加载中…</td> <td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
</tr> </tr>
<tr v-else-if="!rows.length"> <tr v-else-if="!rows.length">
<td colspan="8" style="text-align: center; color: #94a3b8">暂无账号</td> <td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td>
</tr> </tr>
<tr v-for="row in rows" :key="row.id"> <tr v-for="row in rows" :key="row.id">
<td class="account-username">{{ row.username }}</td> <td class="account-username">{{ row.username }}</td>
@@ -345,20 +344,6 @@ function removeAccount(row: AccountRow) {
<td> <td>
<span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span> <span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span>
</td> </td>
<td>
<!-- 与编辑弹窗「数据范围」同源:dashboardSelfOnly -->
<span
class="tag"
:class="row.dashboardSelfOnly ? 'tag-amber' : 'tag-teal'"
:title="
row.dashboardSelfOnly
? '分析看板仅统计本账号企微归属的工单'
: '分析看板可看权限内全部数据'
"
>
{{ row.dashboardSelfOnly ? "只看自己数据" : "全部数据" }}
</span>
</td>
<td>{{ row.phoneMasked }}</td> <td>{{ row.phoneMasked }}</td>
<td>{{ row.lastLogin }}</td> <td>{{ row.lastLogin }}</td>
<td> <td>
@@ -666,9 +666,11 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
} }
.dash-route-menu { .dash-route-menu {
/* 盖过图表区;宽度在 dashboard-prototype.css 里写成 360px,避免按按钮宽度收缩 */
position: absolute; position: absolute;
right: 0; right: 0;
top: calc(100% + 8px); top: calc(100% + 8px);
z-index: 120;
} }
.dash-cost-footnote { .dash-cost-footnote {
@@ -2,9 +2,6 @@
/** /**
* 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。 * 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。
* 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。 * 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。
*
* 禁止写死 admin/123456:退出后再进登录页会误显示测试账号。
* 仅记住上次成功登录的账号(不存密码),方便换人登录时改账号。
*/ */
import { reactive, ref, toRaw } from 'vue'; import { reactive, ref, toRaw } from 'vue';
import { ConfigProvider, message } from 'ant-design-vue'; import { ConfigProvider, message } from 'ant-design-vue';
@@ -13,29 +10,11 @@
import '/@/views/inquiry/styles/admin-prototype.css'; import '/@/views/inquiry/styles/admin-prototype.css';
import '/@/views/inquiry/styles/admin-fidelity.css'; import '/@/views/inquiry/styles/admin-fidelity.css';
const LAST_USERNAME_KEY = 'inquiry_admin_last_username';
function readLastUsername(): string {
try {
return String(localStorage.getItem(LAST_USERNAME_KEY) || '').trim();
} catch {
return '';
}
}
function rememberUsername(username: string) {
try {
localStorage.setItem(LAST_USERNAME_KEY, username);
} catch {
/* 隐私模式等写失败时忽略 */
}
}
const userStore = useUserStore(); const userStore = useUserStore();
const loading = ref(false); const loading = ref(false);
const form = reactive({ const form = reactive({
username: readLastUsername(), username: 'admin',
password: '', password: '123456',
}); });
const themeToken = { const themeToken = {
@@ -57,10 +36,9 @@
} }
loading.value = true; loading.value = true;
try { try {
const username = form.username.trim();
const { userInfo } = await userStore.login( const { userInfo } = await userStore.login(
toRaw({ toRaw({
username, username: form.username.trim(),
password: form.password, password: form.password,
// 主账已关验证码;仍传空字段以兼容 LoginParams 类型 // 主账已关验证码;仍传空字段以兼容 LoginParams 类型
captcha: '', captcha: '',
@@ -70,9 +48,6 @@
}) })
); );
if (userInfo) { if (userInfo) {
rememberUsername(username);
// 成功后清空密码,避免浏览器后退/再进登录页时残留明文
form.password = '';
message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`); message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`);
} }
} catch (e: any) { } catch (e: any) {
@@ -342,7 +342,7 @@ export const mockReplyCategories: ReplyCategory[] = [
id: "brv10000000000000000000000000041", id: "brv10000000000000000000000000041",
replyCode: "lost.reason_ack", replyCode: "lost.reason_ack",
trigger: "销售回复未成交原因", trigger: "销售回复未成交原因",
text: "已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。", text: "已记下未成交原因:{{lost_reason}}。",
enabled: true, enabled: true,
} }
], ],
@@ -23,16 +23,13 @@ body {
grid-template-columns: 220px minmax(0, 1fr) !important; grid-template-columns: 220px minmax(0, 1fr) !important;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
/* 锁视口:避免 html overflow:hidden 裁切后整页既无文档滚、内容区又不内滚 */ overflow-x: hidden;
height: 100vh !important;
max-height: 100vh !important;
min-height: 0 !important;
overflow: hidden !important;
} }
.main-stage { .main-stage {
background: #f7f8fb !important; background: #f7f8fb !important;
min-width: 0 !important; min-width: 0 !important;
overflow-x: hidden;
/* 主区锁视口高度:列表页表格区内滚;看板等非列表页在 .admin-content 内滚 */ /* 主区锁视口高度:列表页表格区内滚;看板等非列表页在 .admin-content 内滚 */
height: 100vh !important; height: 100vh !important;
max-height: 100vh !important; max-height: 100vh !important;
@@ -47,15 +44,15 @@ body {
gap: 12px !important; gap: 12px !important;
min-width: 0 !important; min-width: 0 !important;
/* /*
* flex-basis:0:先按剩余视口占高,再在区内滚动。 * 主区被锁成一屏。flex-basis 用 auto 时,格子会按内容把内容区撑高,
* 若用 1 1 auto,内容区会先被看板撑满,再被 .main-stage 裁掉 → 右侧无滚动条、滚轮无效。 * 再被主区裁掉,页面上就没有滚动条,鼠标也滚不动。
* basis 取 0、高度先归零,再由 flex 撑满顶栏下方,多出来的内容在这里滚。
*/ */
flex: 1 1 0 !important; flex: 1 1 0% !important;
min-height: 0 !important;
height: 0 !important; height: 0 !important;
min-height: 0 !important;
overflow-x: hidden !important; overflow-x: hidden !important;
overflow-y: auto !important; overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
} }
/* /*
@@ -136,6 +133,7 @@ body {
padding: 0 14px !important; padding: 0 14px !important;
gap: 12px !important; gap: 12px !important;
min-width: 0 !important; min-width: 0 !important;
flex-shrink: 0 !important;
/* 允许用户下拉露出;业务 Teleport 区单独 overflow:hidden 防幽灵点击层 */ /* 允许用户下拉露出;业务 Teleport 区单独 overflow:hidden 防幽灵点击层 */
overflow: visible !important; overflow: visible !important;
} }
@@ -1079,9 +1077,7 @@ h6 {
.admin-content:has(.ref-surface--metrics) { .admin-content:has(.ref-surface--metrics) {
padding: 14px 14px 36px !important; padding: 14px 14px 36px !important;
gap: 10px !important; gap: 10px !important;
/* 分析看板:明确在内容区竖滚,避免被列表页 overflow:hidden 规则误伤 */ align-content: start !important;
overflow-x: hidden !important;
overflow-y: auto !important;
} }
/* ---- 话术 ---- */ /* ---- 话术 ---- */
@@ -1535,3 +1531,37 @@ textarea.field.field-tall {
background: #f8fbff !important; background: #f8fbff !important;
} }
/*
* 分析看板比一屏高。整页被 html / 主区锁死时,内容区即使设了 overflow,
* 格子也会跟着内容长高,再被主区裁掉,滚轮没有可滚的对象。
* 看板单独放开文档滚动;列表页仍锁在一屏里,表格自己滚。
*/
html:has(.ref-surface--metrics),
body:has(.ref-surface--metrics) {
height: auto !important;
min-height: 100% !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
#app:has(.ref-surface--metrics),
.admin-shell:has(.ref-surface--metrics) {
height: auto !important;
min-height: 100vh !important;
overflow: visible !important;
}
.main-stage:has(.ref-surface--metrics) {
height: auto !important;
max-height: none !important;
min-height: 100vh !important;
overflow: visible !important;
}
.admin-content:has(.ref-surface--metrics) {
height: auto !important;
max-height: none !important;
flex: none !important;
overflow: visible !important;
}
@@ -776,9 +776,13 @@
color: #155eef; color: #155eef;
} }
.ref-route-filter__menu { .ref-route-filter__menu {
position: fixed; /* 必须用固定宽度。百分比会按「线路筛选」按钮的宽度算,面板只剩一字宽,中文被挤成竖排,全选也被裁掉。 */
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 120; z-index: 120;
width: min(360px, calc(100% - 24px)); width: 360px;
max-width: calc(100vw - 48px);
border: 1px solid #dfe5ee; border: 1px solid #dfe5ee;
border-radius: 12px; border-radius: 12px;
background: #fff; background: #fff;
@@ -797,12 +801,14 @@
color: #0f172a; color: #0f172a;
font-size: 11px; font-size: 11px;
font-weight: 900; font-weight: 900;
white-space: nowrap;
} }
.ref-route-filter__menu-sub { .ref-route-filter__menu-sub {
margin-top: 2px; margin-top: 2px;
color: #64748b; color: #64748b;
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
white-space: nowrap;
} }
.ref-route-filter__actions.tabs-pill { .ref-route-filter__actions.tabs-pill {
flex-shrink: 0; flex-shrink: 0;
@@ -861,6 +867,7 @@
} }
.ref-route-chip__text { .ref-route-chip__text {
line-height: 1.2; line-height: 1.2;
white-space: nowrap;
} }
.month-range-picker { .month-range-picker {
position: relative; position: relative;
@@ -253,88 +253,9 @@ function parseQuoteFees(q: any): TicketQuoteFee[] {
pushRows(asFeeArray(payload[key] || payload[bucket])); pushRows(asFeeArray(payload[key] || payload[bucket]));
} }
} }
// 陆运 TMS:燃油附加费率 / 税率挂在线路段上,不是金额费目行。
for (const extra of parseLandRateFees(payload)) {
if (!out.some((x) => x.name === extra.name && x.amount === extra.amount)) {
out.push(extra);
}
}
return out; return out;
} }
/**
* 陆运线路:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
* 只读 payload,不写库。海运/空运没有这些键则不出行。
*/
function parseLandRateFees(payload: Record<string, any>): TicketQuoteFee[] {
const rows = asFeeArray(payload.route_rows || payload.landSegments || payload.land_segments);
const out: TicketQuoteFee[] = [];
const seen = new Set<string>();
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const fuel = formatLandPercent(row.fuel_surcharge_rate ?? row.fuelSurchargeRate);
const tax = formatLandTaxRate(row.tax_rate ?? row.taxRate ?? row.rate);
const included = formatLandTaxIncluded(
row.tax_included ?? row.rateFlag ?? row.rate_flag
);
if (fuel) {
const key = `fuel:${fuel}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "燃油附加费率", amount: fuel });
}
}
if (tax) {
const key = `tax:${tax}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "税率", amount: tax });
}
}
if (included) {
const key = `inc:${included}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "是否含税", amount: included });
}
}
}
return out;
}
/** 燃油附加费率:数字带 %。 */
function formatLandPercent(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s.includes("%")) return s;
if (/^-?\d+(\.\d+)?$/.test(s)) {
return `${s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
}
return s;
}
/** 税率 rate:0.09→9%;大于 1 按百分点。 */
function formatLandTaxRate(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s.includes("%")) return s;
if (!/^-?\d+(\.\d+)?$/.test(s)) return s;
const n = Number(s);
if (!Number.isFinite(n)) return s;
const abs = Math.abs(n);
const pct = abs > 0 && abs <= 1 ? n * 100 : n;
return `${String(pct).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
}
/** rateFlag:1=含税,0=不含税。 */
function formatLandTaxIncluded(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s === "1" || s.toLowerCase() === "true" || s === "是" || s === "含税") return "含税";
if (s === "0" || s.toLowerCase() === "false" || s === "否" || s === "不含税") return "不含税";
return s;
}
/** /**
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。 * 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。 * transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。