合并 feat/continue-deal-card-20261009:报价发出后继续工单重发成交卡;空运锁舱补参与看板滚动一并收下。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-09 09:06:43 +08:00
co-authored by Cursor
50 changed files with 2173 additions and 320 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"
@@ -18,6 +18,11 @@ from agent.policy.event_exception import (
card_title, card_title,
conversation_of, conversation_of,
exception_type, exception_type,
WAIT_ADOPT,
WAIT_AIR_QUOTE,
WAIT_DEAL,
WAIT_LOST_REASON,
mentions_for_role,
pick_app_targets, pick_app_targets,
pick_mentions, pick_mentions,
timeout_code, timeout_code,
@@ -25,6 +30,9 @@ from agent.policy.event_exception import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 采用、成交跟进、未成交原因都是在等销售。点名只找本单销售。
_SALES_WAIT_KINDS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON}
SendGroupFn = Callable[..., bool] SendGroupFn = Callable[..., bool]
EnqueueFn = Callable[..., tuple[bool, str]] EnqueueFn = Callable[..., tuple[bool, str]]
TimeoutLookupFn = Callable[..., dict[str, Any]] TimeoutLookupFn = Callable[..., dict[str, Any]]
@@ -149,19 +157,17 @@ def _handle_one(
if workday_elapsed_hours(started, now) < hours: if workday_elapsed_hours(started, now) < hours:
return False return False
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)] watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
sales = next((dict(w) for w in watchers if str(w.get("roleCode") or "").lower() in {"sales", ""}), None) sales = _ticket_sales(row, watchers)
if sales is None:
sales = {
"wecomId": str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip(),
"name": str(row.get("sales_name") or row.get("salesName") or "销售").strip() or "销售",
"roleCode": "sales",
"notifyEventException": 0,
}
people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line) people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line)
# 空运群往往只绑了群号,成员列表是空的,群里就找不到航线岗,话术会落成「相关人员」。
# 只补航线超时。销售超时不能改去 @ 航线,只 @ 本单销售。
if kind == WAIT_AIR_QUOTE and not people:
people = _airline_staff_mentions(book)
names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()] names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()]
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip() chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
where = conversation_of(wait_kind=kind, collab_chat_id=chat_id) where = conversation_of(wait_kind=kind, collab_chat_id=chat_id)
mention = where == "group" # 群里用 @。销售超时即使发到私聊,也写成 @销售姓名,不写「您」或「相关人员」。
mention = where == "group" or kind in _SALES_WAIT_KINDS
text = copy.event_exception_session_text( text = copy.event_exception_session_text(
exception_type=exception_type(kind), exception_type=exception_type(kind),
work_order_no=no, work_order_no=no,
@@ -208,6 +214,59 @@ def _handle_one(
return True return True
def _ticket_sales(row: dict[str, Any], watchers: list[dict[str, Any]]) -> dict[str, Any]:
"""
这张工单的销售。销售超时只 @ 这个人。
先按工单上的销售企微对上关注人,对不上再用岗位是销售的那条。
姓名用工单上的销售名,避免点成「销售」两个字或点到航线。
"""
sid = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip()
sname = str(row.get("sales_name") or row.get("salesName") or "").strip()
matched: dict[str, Any] | None = None
for raw in watchers:
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower()
if sid and uid == sid:
matched = dict(raw)
break
if matched is None and role == "sales":
matched = dict(raw)
if matched is None:
matched = {
"wecomId": sid,
"roleCode": "sales",
"notifyEventException": 0,
}
name = str(matched.get("name") or "").strip()
if sname and (not name or name in {sid, "销售"}):
matched["name"] = sname
elif not name:
matched["name"] = sname or "销售"
if sid and not str(matched.get("wecomId") or "").strip():
matched["wecomId"] = sid
matched.setdefault("roleCode", "sales")
return matched
def _airline_staff_mentions(book: Any) -> list[dict[str, Any]]:
"""
主账按岗位列出启用的航线同事。
只在群成员里没有航线岗时调用。主账失败或没有这个接口时返回空,话术仍用「相关人员」。
不写工单。可与巡检线程并发,每次只读这一单的员工名单。
"""
lister = getattr(book, "list_staff_by_role", None)
if not callable(lister):
return []
try:
rows = list(lister(role_code="air") or [])
except Exception:
logger.exception("事件异常列航线岗失败")
return []
return mentions_for_role(rows, role_code="air")
def _send_app_cards( def _send_app_cards(
*, *,
people: list[dict[str, Any]], people: list[dict[str, Any]],
+2 -1
View File
@@ -439,6 +439,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 +454,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(
+1 -1
View File
@@ -834,7 +834,7 @@ class MemoryLedger:
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete, 纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。 对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
""" """
_ = sender_id, operator_name, instruction_text _ = sender_id, operator_name, instruction_text, airline, flight_no
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)
+15 -2
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()
+414 -87
View File
@@ -11,6 +11,7 @@ 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 +45,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 +121,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 +179,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 {"", "-", "—", "-"}
@@ -842,80 +868,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 +923,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 +930,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 +984,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 +1005,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,
) )
@@ -1036,7 +1031,6 @@ def apply_air_lock(
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {})) emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
return "lock_ok" return "lock_ok"
def _release_should_push_deal(ticket: object) -> bool: def _release_should_push_deal(ticket: object) -> bool:
""" """
放舱成功后要不要再推成交跟进。 放舱成功后要不要再推成交跟进。
@@ -1259,6 +1253,339 @@ def _is_product_quote(quote: dict[str, Any]) -> bool:
return "航线" in src or "产品" in src return "航线" in src or "产品" in src
_OPTION_RE = re.compile(r"AIR-?OPT-?\d+")
_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 lock_params_from_text(text: str) -> tuple[str, str, str]:
"""
从航线原话抽出舱位编号、航司、航班号。
不要求「AIR-OPT01 CZ 航班CZ327」这一种写法。
带「舱位 / 航司 / 航班」的说法、换顺序、航司用中文名,都认。
本地对不上、话里又在补这三项时,再问模型。没说的不猜。
"""
option, airline, flight = _lock_params_local(text)
if option and airline and flight:
return option, airline, flight
if not _should_ask_lock_model(text, option, airline, flight):
return option, airline, flight
got_opt, got_air, got_flight = _lock_params_from_model(text)
return (
option or got_opt,
airline or _normalize_airline(got_air),
flight or _normalize_flight(got_flight),
)
def text_has_lock_supplement(text: str) -> bool:
"""这句话是在补锁舱三项,不是报价或闲聊。凑齐两项,或点名了舱位/航司/航班,都算。"""
option, airline, flight = _lock_params_local(text)
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()
if not want:
return None
for row in _air_options(quote):
no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
if no == want:
return row
return None
def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[str]:
"""缺的中文名,顺序固定:舱位编号、航司、航班号。"""
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 _remember_partial_lock(
flow: Any,
*,
work_order_no: str,
sender_id: str,
option_no: str,
airline: str,
flight_no: str,
) -> None:
"""先记下已经说清的项,下一句补齐后接着锁,不用重说。"""
patcher = getattr(flow.ledger, "patch_collab_facts", None)
if not callable(patcher):
return
patcher(
work_order_no=work_order_no,
facts={
_PENDING_LOCK: "1",
_PENDING_LOCK_OPTION: option_no,
_PENDING_LOCK_AIRLINE: airline,
_PENDING_LOCK_FLIGHT: flight_no,
},
sender_id=sender_id,
)
def _quote_id(quote: dict[str, Any]) -> str: def _quote_id(quote: dict[str, Any]) -> str:
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip() return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
+33 -4
View File
@@ -882,8 +882,11 @@ class AirTextInquiryFlow:
reply(copy.quote_generating()) reply(copy.quote_generating())
return "wait_file" return "wait_file"
if sess.phase == "wait_deal": if sess.phase == "wait_deal":
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。 # 成交卡原来挂在报价单那条消息上,聊天一上滑就找不到。
# 原话写了已成交/未成交/协商中时,由 _close_spoken_deal 先入账,不会走到这里。 # 销售再说继续工单、又没写成交结果时,再发一次成交/未成交/协商中。
# 原话已经写了结果时,_close_spoken_deal 会先入账,不会走到这里。
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
self._emit_deal(reply, sess)
return "wait_deal" return "wait_deal"
if sess.phase == "wait_lost_reason": if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no)) reply(copy.deal_ack("未成交", sess.work_order_no))
@@ -1260,13 +1263,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 +1307,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,
+3 -1
View File
@@ -46,6 +46,7 @@ _HOLIDAYS = frozenset(
date(2026, 9, 25), date(2026, 9, 25),
date(2026, 9, 26), date(2026, 9, 26),
date(2026, 9, 27), date(2026, 9, 27),
# 国庆只休到 10 月 7 日。10 月 8 日周四正常上班,不能算假期。
date(2026, 10, 1), date(2026, 10, 1),
date(2026, 10, 2), date(2026, 10, 2),
date(2026, 10, 3), date(2026, 10, 3),
@@ -53,7 +54,6 @@ _HOLIDAYS = frozenset(
date(2026, 10, 5), date(2026, 10, 5),
date(2026, 10, 6), date(2026, 10, 6),
date(2026, 10, 7), date(2026, 10, 7),
date(2026, 10, 8),
# 2027 元旦预留 # 2027 元旦预留
date(2027, 1, 1), date(2027, 1, 1),
date(2027, 1, 2), date(2027, 1, 2),
@@ -68,6 +68,8 @@ _MAKEUP_WORKDAYS = frozenset(
date(2026, 2, 14), date(2026, 2, 14),
date(2026, 2, 28), date(2026, 2, 28),
date(2026, 5, 9), date(2026, 5, 9),
# 国庆调休:9 月 20 日周日、10 月 10 日周六上班。
date(2026, 9, 20),
date(2026, 10, 10), date(2026, 10, 10),
} }
) )
+43 -1
View File
@@ -164,7 +164,7 @@ def pick_mentions(
""" """
本单这次应回而未回、需要被点名的人。 本单这次应回而未回、需要被点名的人。
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。 航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售,不点航线。
不要询价机器人、不要群里其他人。 不要询价机器人、不要群里其他人。
""" """
kind = (wait_kind or "").strip() kind = (wait_kind or "").strip()
@@ -206,6 +206,48 @@ def pick_mentions(
return out return out
def mentions_for_role(
rows: list[dict[str, Any]] | None,
*,
role_code: str,
) -> list[dict[str, Any]]:
"""
从员工行里取出启用、有企微、有姓名的指定岗位。
空运群激活只记群号,协同成员经常是空的。超时巡检在群成员里找不到航线岗时,
用本函数点名员工管理里的航线·空运,和进群摘要 @ 的是同一批人。
不发消息、不写主账。
"""
want = (role_code or "").strip().lower()
if not want:
return []
out: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in rows or []:
if not isinstance(raw, dict):
continue
role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower()
if role != want:
continue
status = str(raw.get("status") or "active").strip().lower()
if status in {"disabled", "0", "停用", "inactive"}:
continue
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
name = str(raw.get("name") or "").strip()
if not uid or not name or uid in seen:
continue
seen.add(uid)
out.append(
{
"wecomId": uid,
"name": name,
"roleCode": want,
"notifyEventException": raw.get("notifyEventException", 0),
}
)
return out
def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]: def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""勾了「接收事件异常消息提醒」且绑了企微的人。""" """勾了「接收事件异常消息提醒」且绑了企微的人。"""
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
+59 -8
View File
@@ -2400,8 +2400,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:
@@ -2778,9 +2784,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 +2939,8 @@ def air_group_brief(
空运群激活摘要:与海运进群同一套结构,字段用空运。 空运群激活摘要:与海运进群同一套结构,字段用空运。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。 先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。 TMS 段跟单聊同一套线路分项,总销售价只一行。
已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。
不写「当前工单/询价要点/现在等到哪一步」。 不写「当前工单/询价要点/现在等到哪一步」。
""" """
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
@@ -3059,6 +3067,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()
@@ -3155,6 +3180,32 @@ 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未返回原因"
@@ -3359,7 +3410,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 +3419,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 +3460,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)]
+31 -6
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", "询价机器人"}
@@ -1317,10 +1345,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"
@@ -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
@@ -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):
@@ -183,10 +193,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 +205,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]
+336 -23
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
@@ -636,6 +742,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(
@@ -944,35 +1089,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 +1316,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 +1327,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 +1422,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,6 +1677,38 @@ 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()
+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)
+6 -1
View File
@@ -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:
@@ -1555,7 +1559,8 @@ class AirTextInquiryTests(unittest.TestCase):
source="test", source="test",
) )
self.assertEqual(replay, "wait_deal") self.assertEqual(replay, "wait_deal")
self.assertNotIn("咨询成交状态", "\n".join(again)) self.assertIn("咨询成交状态", "\n".join(again))
self.assertNotIn("请稍候", "\n".join(again))
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal") self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal")
def test_deal_click_works_while_http_still_wait_file(self) -> None: def test_deal_click_works_while_http_still_wait_file(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)
+1 -1
View File
@@ -228,7 +228,7 @@ class ContinueTicketTests(unittest.TestCase):
sender_id="u6", work_order_no=wo, reply=reply sender_id="u6", work_order_no=wo, reply=reply
) )
self.assertEqual(phase, "wait_deal") self.assertEqual(phase, "wait_deal")
self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies)) self.assertTrue(any("【成交】【未成交】【协商中】" in x for x in replies))
def test_resume_spoken_deal_closes(self) -> None: def test_resume_spoken_deal_closes(self) -> None:
"""报价文件已出后,原话写工单号加已成交,要入账并回复,不能没反应。""" """报价文件已出后,原话写工单号加已成交,要入账并回复,不能没反应。"""
+22 -12
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
from agent.policy.deal_followup import ( from agent.policy.deal_followup import (
SHANGHAI, SHANGHAI,
air_route_keys, air_route_keys,
is_workday,
leader_matches, leader_matches,
next_followup_at, next_followup_at,
shift_workdays, shift_workdays,
@@ -20,33 +21,42 @@ from agent.policy.deal_followup import (
class TestWorkdays(unittest.TestCase): class TestWorkdays(unittest.TestCase):
def test_skip_weekend(self) -> None: def test_skip_weekend(self) -> None:
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24 # 普通周末仍跳过。2026-11-13 周五 → 16、17、18 三个工作日,第四个是 19 日。
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23)) self.assertEqual(shift_workdays(date(2026, 11, 13), 3), date(2026, 11, 18))
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24)) self.assertEqual(shift_workdays(date(2026, 11, 13), 4), date(2026, 11, 19))
def test_sept20_makeup_pulls_followup_forward(self) -> None:
# 2026-09-18 周五。9 月 20 日周日调休上班,第四个工作日是 23 日,不是 24 日。
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 23))
def test_skip_national_day(self) -> None: def test_skip_national_day(self) -> None:
# 2026-09-30 周三,国庆休 → 第四个工作日落到节后 # 2026-09-30 周三。10 月 1–7 日放假,8 日上班,10 日周六调休。
# 节后第 1 个工作日 8 日,第 4 个是 12 日周一。
fourth = shift_workdays(date(2026, 9, 30), 4) fourth = shift_workdays(date(2026, 9, 30), 4)
self.assertGreaterEqual(fourth, date(2026, 10, 9)) self.assertEqual(fourth, date(2026, 10, 12))
self.assertNotEqual(fourth.weekday(), 5)
self.assertNotEqual(fourth.weekday(), 6) def test_oct8_works_and_sept20_is_makeup(self) -> None:
# 国办发明电〔2025〕7号:10 月 8 日正常上班,9 月 20 日周日调休上班。
self.assertTrue(is_workday(date(2026, 10, 8)))
self.assertTrue(is_workday(date(2026, 9, 20)))
self.assertFalse(is_workday(date(2026, 10, 7)))
def test_next_followup_is_9am(self) -> None: def test_next_followup_is_9am(self) -> None:
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI)) when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
self.assertEqual(when.hour, 9) self.assertEqual(when.hour, 9)
self.assertEqual(when.minute, 0) self.assertEqual(when.minute, 0)
self.assertEqual(when.tzinfo, SHANGHAI) self.assertEqual(when.tzinfo, SHANGHAI)
self.assertEqual(when.date(), date(2026, 9, 24)) self.assertEqual(when.date(), date(2026, 9, 23))
def test_skip_weekend_keep_weekday_night(self) -> None: def test_skip_weekend_keep_weekday_night(self) -> None:
start = datetime(2026, 9, 18, 17, 0, tzinfo=SHANGHAI) # 周五 start = datetime(2026, 11, 13, 17, 0, tzinfo=SHANGHAI) # 周五
now = datetime(2026, 9, 21, 17, 0, tzinfo=SHANGHAI) # 周一 now = datetime(2026, 11, 16, 17, 0, tzinfo=SHANGHAI) # 周一
# 周五 17–24 = 7h,周末不算,周一 0–17 = 17h # 周五 17–24 = 7h,周末不算,周一 0–17 = 17h
self.assertAlmostEqual(workday_elapsed_hours(start, now), 24.0, places=4) self.assertAlmostEqual(workday_elapsed_hours(start, now), 24.0, places=4)
def test_rest_day_start_counts_from_next_workday(self) -> None: def test_rest_day_start_counts_from_next_workday(self) -> None:
start = datetime(2026, 9, 19, 10, 0, tzinfo=SHANGHAI) # 周六 start = datetime(2026, 11, 14, 10, 0, tzinfo=SHANGHAI) # 周六
now = datetime(2026, 9, 21, 10, 0, tzinfo=SHANGHAI) # 周一 now = datetime(2026, 11, 16, 10, 0, tzinfo=SHANGHAI) # 周一
self.assertAlmostEqual(workday_elapsed_hours(start, now), 10.0, places=4) self.assertAlmostEqual(workday_elapsed_hours(start, now), 10.0, places=4)
def test_fractional_quarter_hour(self) -> None: def test_fractional_quarter_hour(self) -> None:
+9 -9
View File
@@ -193,11 +193,11 @@ class TestDealOutcome(unittest.TestCase):
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 +206,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("未成交,原因客户不想要了"), "客户不想要了")
+158
View File
@@ -20,6 +20,7 @@ from agent.policy.event_exception import (
WAIT_PRODUCT_QUOTE, WAIT_PRODUCT_QUOTE,
conversation_of, conversation_of,
format_timeout_hours, format_timeout_hours,
mentions_for_role,
pick_app_targets, pick_app_targets,
pick_mentions, pick_mentions,
timeout_code, timeout_code,
@@ -270,6 +271,163 @@ class TestPoll(unittest.TestCase):
) )
self.assertEqual(n2, 0) self.assertEqual(n2, 0)
def test_air_group_names_airline_when_members_missing(self) -> None:
"""空运群没记下成员时,超时要 @ 航线姓名,不能写成相关人员。"""
import importlib.util
from datetime import datetime, timedelta
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.deal_followup import SHANGHAI
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
spec = importlib.util.spec_from_file_location("ee_poll_air", path)
self.assertIsNotNone(spec)
self.assertIsNotNone(spec.loader)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
book = MemoryLedger()
book.set_sea_staff(
[
{
"wecomId": "sales1",
"name": "罗小华",
"roleCode": "sales",
"status": "active",
},
{
"wecomId": "air1",
"name": "吴季林",
"roleCode": "air",
"status": "active",
},
]
)
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"})
wo = created["work_order_no"]
book.start_event_wait(work_order_no=wo, wait_kind="air_quote")
ticket = book.get_ticket(work_order_no=wo)
ticket.collab_chat_id = "wr-air"
ticket.collab_member_ids = []
ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime(
"%Y-%m-%d %H:%M:%S"
)
book._timeouts["air_route"] = 0.25
groups: list[dict] = []
def send_group(*, chat_id: str, content: str, business_line: str = "") -> bool:
groups.append({"chat_id": chat_id, "content": content, "line": business_line})
return True
n = mod.poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
send_group=send_group,
notify_ready=False,
)
self.assertEqual(n, 1)
self.assertEqual(len(groups), 1)
self.assertEqual(
groups[0]["content"],
f"航线响应超时\n关联询价工单 {wo}。@吴季林已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
)
self.assertNotIn("相关人员", groups[0]["content"])
def test_sales_timeout_mentions_sales_not_airline(self) -> None:
"""销售超时只 @ 本单销售。私聊也带 @,不点航线,不写相关人员。"""
import importlib.util
from datetime import datetime, timedelta
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.deal_followup import SHANGHAI
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
spec = importlib.util.spec_from_file_location("ee_poll_sales", path)
self.assertIsNotNone(spec)
self.assertIsNotNone(spec.loader)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
book = MemoryLedger()
book.set_sea_staff(
[
{"wecomId": "sales1", "name": "罗小华", "roleCode": "sales", "status": "active"},
{"wecomId": "air1", "name": "吴季林", "roleCode": "air", "status": "active"},
]
)
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={})
wo = created["work_order_no"]
book.start_event_wait(work_order_no=wo, wait_kind="adopt")
ticket = book.get_ticket(work_order_no=wo)
ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime(
"%Y-%m-%d %H:%M:%S"
)
book._timeouts["air_sales"] = 0.25
private: list[dict] = []
def enqueue(**kwargs):
private.append(kwargs)
return True, ""
def send_group(**kwargs) -> bool:
raise AssertionError("销售采用超时不应发到群")
n = mod.poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
enqueue=enqueue,
send_group=send_group,
notify_ready=False,
)
self.assertEqual(n, 1)
self.assertEqual(len(private), 1)
self.assertEqual(
private[0]["content"],
f"销售响应超时\n关联询价工单 {wo}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
)
self.assertNotIn("吴季林", private[0]["content"])
self.assertNotIn("相关人员", private[0]["content"])
created_deal = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"目的港": "胡志明"})
deal_no = created_deal["work_order_no"]
book.start_event_wait(work_order_no=deal_no, wait_kind="deal")
deal = book.get_ticket(work_order_no=deal_no)
deal.collab_chat_id = "wr-air"
deal.event_wait_started_at = ticket.event_wait_started_at
groups: list[dict] = []
def send_deal_group(*, chat_id: str, content: str, business_line: str = "") -> bool:
groups.append({"chat_id": chat_id, "content": content})
return True
n_deal = mod.poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
enqueue=enqueue,
send_group=send_deal_group,
notify_ready=False,
)
self.assertEqual(n_deal, 1)
self.assertEqual(
groups[0]["content"],
f"销售响应超时\n关联询价工单 {deal_no}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
)
self.assertNotIn("吴季林", groups[0]["content"])
class TestMentionsForRole(unittest.TestCase):
def test_keeps_active_named_role_only(self) -> None:
rows = mentions_for_role(
[
{"wecomId": "a1", "name": "吴季林", "roleCode": "air", "status": "active"},
{"wecomId": "s1", "name": "罗小华", "roleCode": "sales", "status": "active"},
{"wecomId": "a2", "name": "停用航线", "roleCode": "air", "status": "disabled"},
{"wecomId": "a3", "name": "", "roleCode": "air", "status": "active"},
],
role_code="air",
)
self.assertEqual([row["name"] for row in rows], ["吴季林"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+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()
+1 -1
View File
@@ -1602,7 +1602,7 @@ class SeaContinueTests(unittest.TestCase):
phase = handle_continue_thread(msg, thread_id=wo, store=store) phase = handle_continue_thread(msg, thread_id=wo, store=store)
self.assertEqual(phase, "wait_deal") self.assertEqual(phase, "wait_deal")
joined = "\n".join(item.content for item in store._outbox.values()) joined = "\n".join(item.content for item in store._outbox.values())
self.assertFalse(any(x in joined for x in (copy.BTN_DEAL, "成交"))) self.assertIn("【成交】【未成交】【协商中】", joined)
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined) self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
@@ -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"], "系统异常通知")
+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)
@@ -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())) {
@@ -233,6 +254,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);
} }
@@ -475,6 +516,14 @@ 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)) {
@@ -1185,6 +1234,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。
*/ */
@@ -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) {
@@ -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"},
@@ -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<>();
@@ -186,6 +195,34 @@ public class InquiryTmsCabinServiceTest {
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(
@@ -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(
@@ -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 {
@@ -181,6 +181,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;
} }
@@ -1127,6 +1128,7 @@ h6 {
/* 分析看板:内容区不裁切,滚动交给 .main-stage */ /* 分析看板:内容区不裁切,滚动交给 .main-stage */
overflow: visible !important; overflow: visible !important;
max-height: none !important; max-height: none !important;
align-content: start !important;
} }
/* 看板缩窄时允许卡片/图表收缩,避免右侧被裁且无横滚 */ /* 看板缩窄时允许卡片/图表收缩,避免右侧被裁且无横滚 */
@@ -1589,3 +1591,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;