diff --git a/inquiry-agent/agent/channel/h5/wecom_close.py b/inquiry-agent/agent/channel/h5/wecom_close.py index c67cb59..22ea8ee 100644 --- a/inquiry-agent/agent/channel/h5/wecom_close.py +++ b/inquiry-agent/agent/channel/h5/wecom_close.py @@ -4,7 +4,7 @@ 本文件职责:给附件上传成功、选线路成功页一段共用脚本。 手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。 再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。 -电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。 +电脑企微即使注入了 WeixinJSBridge,这一下也关不掉当前页,必须按电脑走 JS-SDK。 失败、过期页不要挂这段。 副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。 """ @@ -24,7 +24,8 @@ def wecom_auto_close_script() -> str: 成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。 线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。 - 为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。 + 为何这样写:电脑企微也会带上 WeixinJSBridge,但关不掉当前页。 + 若一发现桥就只走手机关闭,成功页会停在「正在返回企微」。手机仍只关一次。 """ settings = get_settings() corp = escape((settings.wecom_corp_id or "").strip(), quote=True) @@ -49,6 +50,10 @@ def wecom_auto_close_script() -> str: var closed = false; + function isPhone() {{ + return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || ""); + }} + function closeOnce(fn) {{ if (closed) return; closed = true; @@ -60,7 +65,8 @@ def wecom_auto_close_script() -> str: closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }}); }} - // 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。 + // 电脑企微即使有 WeixinJSBridge,也关不掉当前页。注册 JS-SDK 后再关一次。 + // 不要调 window.close:手机会把询价小助手整个退出。 function closeDesktop() {{ closeOnce(function () {{ if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow(); @@ -69,15 +75,16 @@ def wecom_auto_close_script() -> str: }} function boot() {{ - if (typeof WeixinJSBridge !== "undefined") {{ - setTimeout(closeMobile, {delay}); + if (isPhone()) {{ + if (typeof WeixinJSBridge !== "undefined") {{ + setTimeout(closeMobile, {delay}); + return; + }} + document.addEventListener("WeixinJSBridgeReady", function () {{ + setTimeout(closeMobile, {delay}); + }}, false); return; }} - var sawBridge = false; - document.addEventListener("WeixinJSBridgeReady", function () {{ - sawBridge = true; - setTimeout(closeMobile, {delay}); - }}, false); var ready = Promise.resolve(); if (window.ww && ww.register && corpId) {{ ready = ww.register({{ @@ -88,18 +95,14 @@ def wecom_auto_close_script() -> str: getAgentConfigSignature: sign("agent") }}).catch(function () {{ return null; }}); }} - ready.then(function () {{ - setTimeout(function () {{ - if (sawBridge || typeof WeixinJSBridge !== "undefined") {{ - if (!closed) closeMobile(); - return; - }} - closeDesktop(); - }}, {delay}); + var timed = Promise.race([ + ready, + new Promise(function (resolve) {{ setTimeout(resolve, 2500); }}) + ]); + timed.then(function () {{ + setTimeout(closeDesktop, {delay}); }}).catch(function () {{ - setTimeout(function () {{ - if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop(); - }}, {delay}); + setTimeout(closeDesktop, {delay}); }}); }} diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index 2c51985..8946fa2 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -2,7 +2,8 @@ 意图:空运协同群入站(BOT @ 之后的文字或附件)。 本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。 -禁止:存档文字当业务;锁舱用当前工单去猜;套 sea_group_ops 建群。 +多段单已在本群激活后,没带工单号的后续消息跟这张多段单,不再要求重发工单号。 +禁止:存档文字当业务;纯空运锁舱用当前工单去猜;套 sea_group_ops 建群。 """ from __future__ import annotations @@ -56,6 +57,54 @@ from agent.routing.air_group_intent import ( 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: """ 每次入站换一次 BOT 出站客户端。 @@ -116,20 +165,18 @@ def handle_air_group( return "duplicate_inbound" mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text) wo = copy.extract_work_order_no(text) + # 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。 + followed = _follow_activated_multi( + engine, + chat_id=chat_id, + sender_id=message.sender_id, + text=text, + injected_quote=injected_quote, + named_wo=wo, + ) + if followed is not None: + return followed if wo: - from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi - - 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, - sender_id=message.sender_id, - text=text, - injected_quote=injected_quote, - ) 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) @@ -212,6 +259,17 @@ def _handle_file(engine, message: InboundMessage) -> str: wo = copy.extract_work_order_no( " ".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: ticket, phase = activate_ticket( flow=engine, chat_id=chat_id, work_order_no=wo, speak=False diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index 093ffd7..5b93358 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -103,7 +103,7 @@ def handle_group_inbound( or "" ).upper() 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 ( _sender_allowed(ticket, message.sender_id) @@ -111,14 +111,16 @@ def handle_group_inbound( ): logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id) return "group_ignore" - return handle_multi_inbound( - flow=engine, - ticket=ticket, - chat_id=chat_id, - sender_id=message.sender_id, - text=message.content or "", - injected_quote=injected_quote, - ) + # 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。 + with bot_sender_when_air_multi(ticket): + return handle_multi_inbound( + flow=engine, + ticket=ticket, + chat_id=chat_id, + sender_id=message.sender_id, + text=message.content or "", + injected_quote=injected_quote, + ) ticket = hydrate_ticket(engine, ticket) if not _sender_allowed(ticket, message.sender_id): logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 342a55c..9d38c68 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -477,8 +477,14 @@ def harvest_collab_labels(text: str) -> dict[str, str]: 「客户名称张三 包装方式编织袋 货值 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]: @@ -508,7 +514,11 @@ def clip_inquiry_value(raw: str) -> str: if hit and hit.start() > 0: text = text[: hit.start()] 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: @@ -549,9 +559,12 @@ def clip_sheet_field_value(raw: str) -> str: if not parts: return "" # 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。 + # 只剩「4.」也是空的:那是下一行「4.提货地址」被切掉字段名后留下的编号。 token = parts[0].strip().strip("::") if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS: return "" + if re.fullmatch(r"\d+[\.、.]", token): + return "" return parts[0].strip() diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index cd0388b..6962fe9 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -873,6 +873,14 @@ def apply_air_lock( if str(out.get("error") or "") == "no_quote": send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no)) 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( tms_reason=str(out.get("failReason") or out.get("error") or ""), flight_date=_ticket_flight_date(ticket), diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 471731d..f7e0cba 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -2384,8 +2384,14 @@ def adjust_ineligible(reason: str) -> str: return "当前工单还没有可调的报价,不能按这套调价。" -def adjust_only_sales() -> str: - return "只有这张单的销售能调价。航线/产品请继续报价,价格未改。" +def adjust_only_sales(*, air_room: bool = False) -> str: + """ + 群里不是这张单的销售在发调价。价格不改。 + + 空运群称呼航线;海运、陆运称呼产品。两句用句号分开,避免连成一句。 + """ + who = "航线" if air_room else "产品" + return f"只有这张单的销售能调价。{who}请继续报价,价格未改。" 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: """空运锁舱失败。后台 inquiring.air_lock_fail。""" why = (reason or "").strip() or "TMS未返回原因" @@ -3256,7 +3288,7 @@ def system_exception_alert_payload( """ 系统异常通知卡:走询价消息服务,不走询价小助手。 - 五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。 + 五行:异常类型、接口/服务、报错原因、影响范围、发生时间。 标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。 """ no = (work_order_no or "").strip() or "无" @@ -3265,7 +3297,7 @@ def system_exception_alert_payload( rows = [ {"keyname": "异常类型", "value": (exception_type 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": (occurred_at or "").strip() or "-"}, ] @@ -3306,7 +3338,7 @@ def system_exception_alert_text( f"关联工单 {no}", f"异常类型:{(exception_type 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"发生时间:{(occurred_at or '').strip() or '-'}", ] diff --git a/inquiry-agent/agent/policy/multi_group_ops.py b/inquiry-agent/agent/policy/multi_group_ops.py index de9a522..6efc823 100644 --- a/inquiry-agent/agent/policy/multi_group_ops.py +++ b/inquiry-agent/agent/policy/multi_group_ops.py @@ -11,7 +11,8 @@ from __future__ import annotations import json 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.policy import inquiry_copy as copy @@ -20,6 +21,7 @@ from agent.policy.sea_group_ops import ( apply_group_deal, group_client_of, is_sales, + override_group_client, send_filled_sheet, send_group_text, ticket_no, @@ -33,6 +35,24 @@ _ROLE_MODE = {"sea": "SEA", "land": "LAND", "air": "AIR"} 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: """主账业务线是多段联运。""" 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: - """空运段的锁舱、释放走现有空运规则,不挡住其他段报价。""" - 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": 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, instruction=text, ) - return apply_air_lock( - flow=flow, - ticket=ticket, - sender_id=sender_id, - chat_id=chat_id, - instruction=text, + if not is_airline(flow, sender_id): + send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) + return "not_airline" + no = ticket_no(ticket) + send_group_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: diff --git a/inquiry-agent/agent/policy/quote_adjust_ops.py b/inquiry-agent/agent/policy/quote_adjust_ops.py index e97c2f4..7f9bafc 100644 --- a/inquiry-agent/agent/policy/quote_adjust_ops.py +++ b/inquiry-agent/agent/policy/quote_adjust_ops.py @@ -260,7 +260,14 @@ def apply_group_adjust( send_group_text(client, room, copy.adjust_ineligible("no_quote")) return "adjust_blocked" 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" current = _current_quote(view, sess) names = [str(r.get("name") or "") for r in adjustable_rows(current)] diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index e71b376..c0f710f 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -9,7 +9,9 @@ from __future__ import annotations import json 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.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) +# 这一次群出站临时改用的客户端。只在当前线程有效,不改流程单例上的企微客户端。 +_outbound_client: ContextVar[Any] = ContextVar("inquiry_group_outbound_client", default=None) + + def group_client_of(flow: Any) -> Any: - """正式路径没有注入客户端时,走流程自己的默认企微客户端。""" + """ + 取这一次群出站用的客户端。 + + 有临时指定时用指定的(含空运的多段要走机器人桥)。 + 没有时走流程自己的客户端;正式路径默认是应用发群。 + """ + override = _outbound_client.get() + if override is not None: + return override getter = getattr(flow, "group_client", None) if callable(getter): return getter() 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]: """会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。""" seats: set[str] = {"AI001", "询价机器人"} diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 170ba48..295d3f2 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -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] = {} yes_no = ( ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 6625c9e..1b26094 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -614,6 +614,45 @@ class AirGroupCollabTests(unittest.TestCase): ) 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: wo = self._create_air() handle_air_group( diff --git a/inquiry-agent/tests/test_attachment_upload_h5.py b/inquiry-agent/tests/test_attachment_upload_h5.py index e1a9f0f..ae457b9 100644 --- a/inquiry-agent/tests/test_attachment_upload_h5.py +++ b/inquiry-agent/tests/test_attachment_upload_h5.py @@ -108,6 +108,8 @@ class AttachmentUploadH5Tests(unittest.TestCase): self.assertIn("closeWindow", ok) self.assertIn("wecom-jssdk", ok) self.assertIn("jsapi-signature", ok) + self.assertIn("function isPhone()", ok) + self.assertNotIn("window.close()", ok) self.assertNotIn("closeWindow", page) self.assertNotIn("closeWindow", dead) diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py index 3eeaf2b..09ae2bc 100644 --- a/inquiry-agent/tests/test_extract_oral.py +++ b/inquiry-agent/tests/test_extract_oral.py @@ -436,6 +436,29 @@ class ClarifyPasteTests(unittest.TestCase): self.assertNotIn("包装方式", facts) 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): def test_numbered_extra_lines_do_not_enter_fields(self) -> None: diff --git a/inquiry-agent/tests/test_land_group_collab.py b/inquiry-agent/tests/test_land_group_collab.py index 462f14a..2bca8ce 100644 --- a/inquiry-agent/tests/test_land_group_collab.py +++ b/inquiry-agent/tests/test_land_group_collab.py @@ -327,7 +327,13 @@ class LandGroupCollabTests(unittest.TestCase): injected_intent=INTENT_ADJUST, ) 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__": diff --git a/inquiry-agent/tests/test_multi_group.py b/inquiry-agent/tests/test_multi_group.py index e504c25..0f15db9 100644 --- a/inquiry-agent/tests/test_multi_group.py +++ b/inquiry-agent/tests/test_multi_group.py @@ -11,6 +11,10 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if ROOT not in sys.path: 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.policy.multi_group_ops import deal_text, handle_multi_inbound from agent.policy.multi_segments import Segment @@ -387,7 +391,8 @@ class QuoteTests(unittest.TestCase): self.assertEqual(len(self.client.files), 2) 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( sender_id="sales", business_line="MULTI", @@ -412,9 +417,128 @@ class QuoteTests(unittest.TestCase): text=f"{no}锁舱", 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)) +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__": unittest.main() diff --git a/inquiry-agent/tests/test_system_exception.py b/inquiry-agent/tests/test_system_exception.py index be14d8a..ae60708 100644 --- a/inquiry-agent/tests/test_system_exception.py +++ b/inquiry-agent/tests/test_system_exception.py @@ -379,6 +379,9 @@ class TestConfirmSystemException(unittest.TestCase): ) card = queued[0]["payload"]["template_card"] 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(card["main_title"]["title"], "系统异常通知") diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java index d902bc3..73c0aa2 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -51,6 +51,24 @@ public class InquiryTmsCabinService { @Lazy 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) { return fail("ticket_not_found", "找不到工单"); } - if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) { - return fail("not_air", "不是空运单,不能锁舱"); + // 多段整单业务线是 MULTI。含空运段时航线仍会来锁舱,不能再说「不是空运单」。 + 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()) || TicketStatuses.CLOSED.equals(ticket.getStatus())) { diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java index b55c21c..9c97ef1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -15,6 +15,15 @@ import static org.junit.jupiter.api.Assertions.assertNull; */ public class InquiryTmsCabinServiceTest { + @Test + public void multiInquiryCannotLockCabin() { + assertEquals( + "多段询价,暂时不支持锁舱", + InquiryTmsCabinService.lockRejectReason("MULTI")); + assertEquals("不是空运单,不能锁舱", InquiryTmsCabinService.lockRejectReason("SEA")); + assertEquals("", InquiryTmsCabinService.lockRejectReason("AIR")); + } + @Test public void pickNamedOption() { Map quote = new HashMap<>();