修好多段含空运的群跟单、锁舱话术、相册报价通道,以及附件上传成功后电脑端关不掉 H5。
多段激活后无需再带工单号;含空运群回复走机器人;复制补问清单时空货好时间不再吃成下一行编号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 '-'}",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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", "询价机器人"}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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"], "系统异常通知")
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -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())) {
|
||||||
|
|||||||
+9
@@ -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<>();
|
||||||
|
|||||||
Reference in New Issue
Block a user