修好多段含空运的群跟单、锁舱话术、相册报价通道,以及附件上传成功后电脑端关不掉 H5。

多段激活后无需再带工单号;含空运群回复走机器人;复制补问清单时空货好时间不再吃成下一行编号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-08 15:23:06 +08:00
co-authored by Cursor
parent 419b4a1bf7
commit 43e633f359
18 changed files with 484 additions and 69 deletions
+19 -16
View File
@@ -4,7 +4,7 @@
本文件职责:给附件上传成功、选线路成功页一段共用脚本。 本文件职责:给附件上传成功、选线路成功页一段共用脚本。
手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。 手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。 再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。 电脑企微即使注入了 WeixinJSBridge,这一下也关不掉当前页,必须按电脑走 JS-SDK。
失败、过期页不要挂这段。 失败、过期页不要挂这段。
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。 副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
""" """
@@ -24,7 +24,8 @@ def wecom_auto_close_script() -> str:
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。 成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。 线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。 为何这样写:电脑企微也会带上 WeixinJSBridge,但关不掉当前页。
若一发现桥就只走手机关闭,成功页会停在「正在返回企微」。手机仍只关一次。
""" """
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 +50,10 @@ def wecom_auto_close_script() -> str:
var closed = false; var closed = false;
function isPhone() {{
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "");
}}
function closeOnce(fn) {{ function closeOnce(fn) {{
if (closed) return; if (closed) return;
closed = true; closed = true;
@@ -60,7 +65,8 @@ def wecom_auto_close_script() -> str:
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }}); closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
}} }}
// 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。 // 电脑企微即使有 WeixinJSBridge,也关不掉当前页。注册 JS-SDK 后再关一次。
// 不要调 window.close:手机会把询价小助手整个退出。
function closeDesktop() {{ function closeDesktop() {{
closeOnce(function () {{ closeOnce(function () {{
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow(); if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
@@ -69,15 +75,16 @@ def wecom_auto_close_script() -> str:
}} }}
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);
return;
}}
var ready = Promise.resolve(); var ready = Promise.resolve();
if (window.ww && ww.register && corpId) {{ if (window.ww && ww.register && corpId) {{
ready = ww.register({{ ready = ww.register({{
@@ -88,18 +95,14 @@ def wecom_auto_close_script() -> str:
getAgentConfigSignature: sign("agent") getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }}); }}).catch(function () {{ return null; }});
}} }}
ready.then(function () {{ var timed = Promise.race([
setTimeout(function () {{ ready,
if (sawBridge || typeof WeixinJSBridge !== "undefined") {{ new Promise(function (resolve) {{ setTimeout(resolve, 2500); }})
if (!closed) closeMobile(); ]);
return; timed.then(function () {{
}} setTimeout(closeDesktop, {delay});
closeDesktop();
}}, {delay});
}}).catch(function () {{ }}).catch(function () {{
setTimeout(function () {{ setTimeout(closeDesktop, {delay});
if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop();
}}, {delay});
}}); }});
}} }}
@@ -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
@@ -56,6 +57,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 +165,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)
@@ -212,6 +259,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
+3 -1
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,
+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()
@@ -873,6 +873,14 @@ def apply_air_lock(
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"
# 多段是产品限制,不是 TMS 故障。只回原因,不标系统异常、不暂停工单。
if str(out.get("error") or "") == "multi_no_lock":
send_group_text(
group_client_of(flow),
chat_id,
copy.multi_air_lock_unsupported(work_order_no=no, operator=name),
)
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),
+37 -5
View File
@@ -2384,8 +2384,14 @@ def adjust_ineligible(reason: str) -> str:
return "当前工单还没有可调的报价,不能按这套调价。" return "当前工单还没有可调的报价,不能按这套调价。"
def adjust_only_sales() -> str: def adjust_only_sales(*, air_room: bool = False) -> str:
return "只有这张单的销售能调价。航线/产品请继续报价,价格未改。" """
群里不是这张单的销售在发调价。价格不改。
空运群称呼航线;海运、陆运称呼产品。两句用句号分开,避免连成一句。
"""
who = "航线" if air_room else "产品"
return f"只有这张单的销售能调价。{who}请继续报价,价格未改。"
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str: def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
@@ -3071,6 +3077,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未返回原因"
@@ -3256,7 +3288,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 "无"
@@ -3265,7 +3297,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 "-"},
] ]
@@ -3306,7 +3338,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)]
+30 -2
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", "询价机器人"}
+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 = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
@@ -614,6 +614,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(
@@ -108,6 +108,8 @@ 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.assertNotIn("window.close()", ok)
self.assertNotIn("closeWindow", page) self.assertNotIn("closeWindow", page)
self.assertNotIn("closeWindow", dead) self.assertNotIn("closeWindow", dead)
+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:
@@ -327,7 +327,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()
@@ -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"], "系统异常通知")
@@ -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())) {
@@ -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<>();