diff --git a/inquiry-agent/agent/channel/h5/wecom_close.py b/inquiry-agent/agent/channel/h5/wecom_close.py index c67cb59..492b7e2 100644 --- a/inquiry-agent/agent/channel/h5/wecom_close.py +++ b/inquiry-agent/agent/channel/h5/wecom_close.py @@ -4,7 +4,8 @@ 本文件职责:给附件上传成功、选线路成功页一段共用脚本。 手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。 再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。 -电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。 +电脑企微 5.x 内置页经常加载不了 JS-SDK,也不会打签名接口。 +这时 ww.closeWindow 不会执行。电脑改用 WeixinJSBridge.invoke,再补 window.close,只关当前页。 失败、过期页不要挂这段。 副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。 """ @@ -24,7 +25,9 @@ def wecom_auto_close_script() -> str: 成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。 线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。 - 为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。 + 为何这样写:21:27 的上传日志里,成功页已经返回,但没有 jsapi 签名请求。 + 电脑企微没跑起 ww.register,只调 ww.closeWindow 等于没关。 + 电脑改为 invoke + window.close,不依赖签名先成功。手机仍只关一次。 """ settings = get_settings() corp = escape((settings.wecom_corp_id or "").strip(), quote=True) @@ -49,6 +52,14 @@ def wecom_auto_close_script() -> str: var closed = false; + function isPhone() {{ + var ua = navigator.userAgent || ""; + if (/wxwork/i.test(ua) && /Windows|Macintosh/i.test(ua) && !/Android|iPhone|iPad|iPod/i.test(ua)) {{ + return false; + }} + return /Android|iPhone|iPad|iPod/i.test(ua); + }} + function closeOnce(fn) {{ if (closed) return; closed = true; @@ -60,46 +71,47 @@ def wecom_auto_close_script() -> str: closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }}); }} - // 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。 + // 电脑企微:call 关不掉,JS-SDK 又经常没装上。invoke 关内置页,window.close 关独立窗口。 function closeDesktop() {{ - closeOnce(function () {{ - if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow(); - else if (window.wx && typeof wx.closeWindow === "function") wx.closeWindow(); - }}); + try {{ + if (typeof WeixinJSBridge !== "undefined" && typeof WeixinJSBridge.invoke === "function") {{ + WeixinJSBridge.invoke("closeWindow", {{}}, function () {{}}); + }} + }} catch (e) {{}} + try {{ + if (window.ww && typeof ww.closeWindow === "function") {{ + var ret = ww.closeWindow(); + if (ret && ret.catch) ret.catch(function () {{}}); + }} else if (window.wx && typeof wx.closeWindow === "function") {{ + wx.closeWindow(); + }} + }} catch (e) {{}} + try {{ window.close(); }} catch (e) {{}} }} function boot() {{ - 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({{ - corpId: corpId, - agentId: agentId, - jsApiList: ["closeWindow"], - getConfigSignature: sign("corp"), - getAgentConfigSignature: sign("agent") - }}).catch(function () {{ return null; }}); - }} - ready.then(function () {{ - setTimeout(function () {{ - if (sawBridge || typeof WeixinJSBridge !== "undefined") {{ - if (!closed) closeMobile(); - return; - }} - closeDesktop(); - }}, {delay}); + setTimeout(closeDesktop, {delay}); + if (!(window.ww && ww.register && corpId)) return; + ww.register({{ + corpId: corpId, + agentId: agentId, + jsApiList: ["closeWindow"], + getConfigSignature: sign("corp"), + getAgentConfigSignature: sign("agent") + }}).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..e182377 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 @@ -21,6 +22,8 @@ from agent.policy.air_group_ops import ( apply_air_file, apply_air_lock, apply_air_quote, + lock_params_from_text, + text_has_lock_supplement, apply_air_release, apply_air_tms_choice, current_ticket, @@ -56,6 +59,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 +167,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) @@ -182,10 +231,18 @@ def handle_air_group( if is_handoff_ticket(ticket): logger.info("air_group 转人工不再跟 chat=%s", chat_id) return "handoff_ignore" - from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused + from agent.policy.system_exception import ( + cabin_failure_pause, + reply_paused_ticket, + ticket_is_paused, + ) - # 锁舱失败留下的暂停只拦还要打 TMS 的操作。成交、未成交和补原因不调用 TMS。 - if ticket_is_paused(ticket) and not _deal_ignores_tms_pause(ticket, intent): + # 查价失败的暂停仍要拦住。锁舱/释放失败已经告诉过原因,不能挡住后面的报价和成交。 + if ( + ticket_is_paused(ticket) + and not cabin_failure_pause(ticket) + and not _deal_ignores_tms_pause(ticket, intent) + ): reply_paused_ticket( ticket=ticket, reply=lambda text: send_group_text(group_client_of(engine), chat_id, text), @@ -212,6 +269,17 @@ def _handle_file(engine, message: InboundMessage) -> str: wo = copy.extract_work_order_no( " ".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 @@ -252,6 +320,29 @@ def _handle_cabin( _ = injected_quote chat_id = message.chat_id if not work_order_no: + # 本群已经在锁这张单时,补舱位不用再报工单号。 + bound = current_ticket(engine, chat_id) + if bound is not None and is_air_ticket(bound): + ticket = bound + if intent == INTENT_RELEASE: + return apply_air_release( + flow=engine, + ticket=ticket, + sender_id=message.sender_id, + chat_id=chat_id, + instruction=message.content or "", + ) + option_no, airline, flight_no = lock_params_from_text(message.content or "") + return apply_air_lock( + flow=engine, + ticket=ticket, + sender_id=message.sender_id, + chat_id=chat_id, + instruction=message.content or "", + option_no=option_no, + airline=airline, + flight_no=flight_no, + ) send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order()) return "cabin_need_work_order" ticket, phase = resolve_cabin_ticket( @@ -270,15 +361,17 @@ def _handle_cabin( chat_id=chat_id, instruction=message.content or "", ) - # 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。 + # 「锁 AIR-OPT01 CZ 航班CZ327」要带上舱位、航司、航班号。缺了先补,不能直接锁。 + option_no, airline, flight_no = lock_params_from_text(message.content or "") return apply_air_lock( flow=engine, ticket=ticket, sender_id=message.sender_id, chat_id=chat_id, instruction=message.content or "", - option_no=_option_from_text(message.content or ""), - airline=_airline_from_text(message.content or ""), + option_no=option_no, + airline=airline, + flight_no=flight_no, ) @@ -315,19 +408,20 @@ def _handle_bound( if lost: return lost facts = dict(ticket_view(ticket).get("collab_facts") or {}) - if intent == INTENT_PICK_OPTION or ( - intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1" - ): - option = _option_from_text(message.content or "") - if option: + pending_lock = str(facts.get("__pending_air_lock") or "") == "1" + # 等补舱位时,报价/选线路等意图让路:话里带了舱位、航司或航班号就继续锁。 + if intent == INTENT_PICK_OPTION or pending_lock or text_has_lock_supplement(message.content or ""): + option_no, airline, flight_no = lock_params_from_text(message.content or "") + if option_no or airline or flight_no: return apply_air_lock( flow=engine, ticket=ticket, sender_id=message.sender_id, chat_id=chat_id, instruction=message.content or "", - option_no=option, - airline=_airline_from_text(message.content or ""), + option_no=option_no, + airline=airline, + flight_no=flight_no, ) if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}: outcome = { @@ -363,6 +457,18 @@ def _handle_bound( sender_id=message.sender_id, chat_id=chat_id, ) + from agent.policy.sea_group_ops import is_sales + from agent.routing.group_intent import resolve_price_intent + + # 航线写出「空运费改为900」是报价。模型若判成调价,这里按说话人改回来。 + priced = resolve_price_intent( + intent, + message.content or "", + sales=is_sales(ticket, message.sender_id), + ) + if priced != intent: + logger.info("air_group 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id) + intent = priced if intent == INTENT_ADJUST: from agent.policy.quote_adjust_ops import apply_group_adjust @@ -390,26 +496,3 @@ def _handle_bound( return "group_ignore" -def _option_from_text(text: str) -> str: - """从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。""" - import re - - m = re.search(r"AIR-?OPT-?\d+", (text or "").upper()) - if not m: - return "" - raw = m.group(0) - # 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠) - if raw.startswith("AIROPT"): - return "AIR-OPT" + raw[6:] - return raw - - -def _airline_from_text(text: str) -> str: - """从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。""" - import re - - blob = (text or "").upper() - m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob) - if m: - return m.group(1) - return "" diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index 093ffd7..fd7b65b 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,22 +111,29 @@ 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) return "group_ignore" - from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused + from agent.policy.system_exception import ( + cabin_failure_pause, + reply_paused_ticket, + ticket_is_paused, + ) - if ticket_is_paused(ticket): + # 锁舱失败不挡海运/陆运群里的后续报价。查价失败的暂停仍要停。 + if ticket_is_paused(ticket) and not cabin_failure_pause(ticket): reply_paused_ticket( ticket=ticket, reply=lambda text: send_group_text(group_client_of(engine), chat_id, text), @@ -236,6 +243,18 @@ def handle_group_inbound( sender_id=message.sender_id, sess=sess, ) + from agent.policy.sea_group_ops import is_sales + from agent.routing.group_intent import resolve_price_intent + + # 产品写出「报关费改为300」是报价。不是这张单的销售时,不能回调价话术。 + priced = resolve_price_intent( + intent, + message.content or "", + sales=is_sales(ticket, message.sender_id), + ) + if priced != intent: + logger.info("group_inbound 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id) + intent = priced if intent == INTENT_ADJUST: from agent.policy.quote_adjust_ops import apply_group_adjust diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index a14dccc..3ae6365 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -404,6 +404,7 @@ def handle_text_inquiry( return info if ok else "" from agent.policy.system_exception import ( + cabin_failure_pause, extract_work_order_nos, reply_paused_ticket, ticket_is_paused, @@ -420,7 +421,7 @@ def handle_text_inquiry( getter = getattr(ledger, "get_for_agent", None) if callable(getter): ticket = getter(work_order_no=no, sender_id=message.sender_id) - if ticket_is_paused(ticket): + if ticket_is_paused(ticket) and not cabin_failure_pause(ticket): reply_paused_ticket(ticket=ticket, reply=reply) logger.info("text_inquiry 暂停工单只回兜底 wo=%s sender=%s", no, message.sender_id) return "system_exception_paused" diff --git a/inquiry-agent/agent/jobs/event_exception_poll.py b/inquiry-agent/agent/jobs/event_exception_poll.py index 371f194..359fd7e 100644 --- a/inquiry-agent/agent/jobs/event_exception_poll.py +++ b/inquiry-agent/agent/jobs/event_exception_poll.py @@ -18,6 +18,11 @@ from agent.policy.event_exception import ( card_title, conversation_of, exception_type, + WAIT_ADOPT, + WAIT_AIR_QUOTE, + WAIT_DEAL, + WAIT_LOST_REASON, + mentions_for_role, pick_app_targets, pick_mentions, timeout_code, @@ -25,6 +30,9 @@ from agent.policy.event_exception import ( logger = logging.getLogger(__name__) +# 采用、成交跟进、未成交原因都是在等销售。点名只找本单销售。 +_SALES_WAIT_KINDS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON} + SendGroupFn = Callable[..., bool] EnqueueFn = Callable[..., tuple[bool, str]] TimeoutLookupFn = Callable[..., dict[str, Any]] @@ -149,19 +157,17 @@ def _handle_one( if workday_elapsed_hours(started, now) < hours: return False watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)] - sales = next((dict(w) for w in watchers if str(w.get("roleCode") or "").lower() in {"sales", ""}), None) - if sales is None: - sales = { - "wecomId": str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip(), - "name": str(row.get("sales_name") or row.get("salesName") or "销售").strip() or "销售", - "roleCode": "sales", - "notifyEventException": 0, - } + sales = _ticket_sales(row, watchers) people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line) + # 空运群往往只绑了群号,成员列表是空的,群里就找不到航线岗,话术会落成「相关人员」。 + # 只补航线超时。销售超时不能改去 @ 航线,只 @ 本单销售。 + if kind == WAIT_AIR_QUOTE and not people: + people = _airline_staff_mentions(book) names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()] chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip() where = conversation_of(wait_kind=kind, collab_chat_id=chat_id) - mention = where == "group" + # 群里用 @。销售超时即使发到私聊,也写成 @销售姓名,不写「您」或「相关人员」。 + mention = where == "group" or kind in _SALES_WAIT_KINDS text = copy.event_exception_session_text( exception_type=exception_type(kind), work_order_no=no, @@ -208,6 +214,59 @@ def _handle_one( return True +def _ticket_sales(row: dict[str, Any], watchers: list[dict[str, Any]]) -> dict[str, Any]: + """ + 这张工单的销售。销售超时只 @ 这个人。 + + 先按工单上的销售企微对上关注人,对不上再用岗位是销售的那条。 + 姓名用工单上的销售名,避免点成「销售」两个字或点到航线。 + """ + sid = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip() + sname = str(row.get("sales_name") or row.get("salesName") or "").strip() + matched: dict[str, Any] | None = None + for raw in watchers: + uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip() + role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower() + if sid and uid == sid: + matched = dict(raw) + break + if matched is None and role == "sales": + matched = dict(raw) + if matched is None: + matched = { + "wecomId": sid, + "roleCode": "sales", + "notifyEventException": 0, + } + name = str(matched.get("name") or "").strip() + if sname and (not name or name in {sid, "销售"}): + matched["name"] = sname + elif not name: + matched["name"] = sname or "销售" + if sid and not str(matched.get("wecomId") or "").strip(): + matched["wecomId"] = sid + matched.setdefault("roleCode", "sales") + return matched + + +def _airline_staff_mentions(book: Any) -> list[dict[str, Any]]: + """ + 主账按岗位列出启用的航线同事。 + + 只在群成员里没有航线岗时调用。主账失败或没有这个接口时返回空,话术仍用「相关人员」。 + 不写工单。可与巡检线程并发,每次只读这一单的员工名单。 + """ + lister = getattr(book, "list_staff_by_role", None) + if not callable(lister): + return [] + try: + rows = list(lister(role_code="air") or []) + except Exception: + logger.exception("事件异常列航线岗失败") + return [] + return mentions_for_role(rows, role_code="air") + + def _send_app_cards( *, people: list[dict[str, Any]], diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 1c6dcaf..ac205a8 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -439,6 +439,7 @@ class HttpLedger: airline: str = "", flight_no: str = "", ) -> dict[str, Any]: + """锁舱出站。航班号和航司一并带上,主账缺了这两项不能当锁舱失败。""" body: dict[str, Any] = { "workOrderNo": work_order_no, "optionNo": option_no, @@ -453,7 +454,7 @@ class HttpLedger: return self._post( "/inquiry/agent/tms/lockCabin", body, - idem=f"air-lock:{work_order_no}:{option_no}", + idem=f"air-lock:{work_order_no}:{option_no}:{(airline or '').strip()}:{(flight_no or '').strip()}", ) def release_cabin( diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 0bb49b3..d89a756 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -834,7 +834,7 @@ class MemoryLedger: 纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete, 对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。 """ - _ = sender_id, operator_name, instruction_text + _ = sender_id, operator_name, instruction_text, airline, flight_no forced = getattr(self, "_cabin_lock_result", None) if isinstance(forced, dict): return dict(forced) diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index b3fcfba..712c530 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 d9f160c..dc7d9ec 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import logging +import re from typing import Any, Optional from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote @@ -44,6 +45,9 @@ AIRLINE_ROLE = "air" _PENDING_QUOTE = "__pending_air_quote" _PENDING_LOCK = "__pending_air_lock" +_PENDING_LOCK_OPTION = "__pending_lock_option" +_PENDING_LOCK_AIRLINE = "__pending_lock_airline" +_PENDING_LOCK_FLIGHT = "__pending_lock_flight" _LOCK_ID = "__air_lock_id" _LOCK_OPTION = "__air_lock_option" _LOCK_QUOTE = "__air_lock_quote" @@ -117,7 +121,7 @@ def activate_ticket( 把本群当前工单切到指定空运未结案单。 返回 (工单, phase)。失败时工单为 None。 - 航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。 + 已经在成交跟进,或航线/产品价已经落账时,再发工单号只回到成交跟进,不再发工单摘要和模板。 """ book = flow.ledger no = (work_order_no or "").strip() @@ -175,23 +179,45 @@ def activate_ticket( def _air_already_quoted_for_deal(ticket: object) -> bool: """ - 航线报价已经落账,再激活应回成交跟进。 + 再激活应回成交跟进,而不是重发工单摘要和空运模板。 - 附件报价会把来源标成「航线附件报价」,但报价编号仍可能留着 TMS。 - 只看 source 会误当成还没报价。还只有 TMS 标准价时仍发模板。 + 报价单已经发给销售(quoteFileIssued)或主账停在 wait_deal 时, + 即使报价来源仍是 TMS 标准价,也已经在成交跟进。 + 协商中、航线/产品价、销售调价同样只回成交跟进。 + 报价单还没出、也没在等成交时,仍发模板给航线填。 """ status = ticket_status(ticket) if status == "协商中": return True + view = ticket_view(ticket) + phase = str(view.get("wait_phase") or view.get("waitPhase") or "").strip() + if phase == "wait_deal": + return True + quote = dict(view.get("quote") or {}) + if _quote_file_issued(quote): + return True if status != "已报价": return False - quote = dict(ticket_view(ticket).get("quote") or {}) label = str(quote.get("source_label") or quote.get("sourceLabel") or "") if "航线" in label or "产品" in label: return True return copy.quote_is_sales_adjust(quote) +def _quote_file_issued(quote: dict[str, Any]) -> bool: + """报价单已发给销售,且没有被作废。作废后重新激活仍要发模板。""" + if _flag_on(quote.get("quoteFileVoided") or quote.get("quote_file_voided")): + return False + return _flag_on(quote.get("quoteFileIssued") or quote.get("quote_file_issued")) + + +def _flag_on(val: object) -> bool: + """主账布尔可能是 true / \"true\" / 1。""" + if isinstance(val, str): + return val.strip().lower() in {"1", "true", "yes"} + return bool(val) + + def _collab_slot_empty(val: object) -> bool: """协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。""" return str(val or "").strip() in {"", "-", "—", "-"} @@ -842,80 +868,45 @@ def apply_air_lock( instruction: str, option_no: str = "", airline: str = "", + flight_no: str = "", ) -> str: """ - 空运群锁舱。 + 航线锁舱。 - 副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。 - 舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。 - 主账仍缺则回 lock_incomplete,禁止空壳出站。 + 舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。 + 三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。 + 上次锁舱失败标过暂停的,航线再锁仍要打 TMS。 """ if not is_airline(flow, sender_id): send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) return "not_airline" - # 锁舱是航线主动再试:即使工单因上次失败标了系统异常,也要再打 TMS, - # 不能只回「TMS接口异常,IT运维排查中」。 no = ticket_no(ticket) name = operator_name(flow, sender_id) - view = ticket_view(ticket) - view_quote = dict(view.get("quote") or {}) + view_quote = dict(ticket_view(ticket).get("quote") or {}) air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {}) quote = air_quote or view_quote - # 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。 - tms_quote = copy.resolve_tms_quote(None, view) - if not tms_quote: - tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {}) - quote_options = _air_options(quote) - tms_options = _air_options(tms_quote) - options = quote_options or tms_options - quote_id = _quote_id(quote) or _quote_id(tms_quote) - picked = (option_no or "").strip() - if not picked and len(options) > 1: - facts = dict(view.get("collab_facts") or {}) - if str(facts.get(_PENDING_LOCK) or "") != "1": - flow.ledger.patch_collab_facts( - work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id - ) - send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options)) - return "wait_lock_option" - if not picked and len(options) == 1: - picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip() - # 没有任何可锁报价时先拒绝,不要把「无报价」误说成缺舱位。 - if not _quote_ready_for_lock(quote) and not _quote_ready_for_lock(tms_quote): - send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no)) - return "no_quote" - airline_code = (airline or "").strip().upper() - if not airline_code: - airline_code = _airline_from_quote_or_option(quote, quote_options, picked) - if not airline_code: - airline_code = _airline_from_quote_or_option(tms_quote, tms_options, picked) - chargeable = _chargeable_weight_kg(quote, quote_options, picked, ticket) - if not chargeable: - chargeable = _chargeable_weight_kg(tms_quote, tms_options, picked, ticket) - flight_no = _flight_no_from_quote_or_option(quote, quote_options, picked, instruction) - if not flight_no: - flight_no = _flight_no_from_quote_or_option(tms_quote, tms_options, picked, instruction) - # 舱位/航司/航班交给主账:库里无 TMS 时会现拉补全;仍缺才回 lock_incomplete。 - # 这里只拦计费重全空(工单毛重也没有)——否则会逼航线手打航班。 - need_chargeable = not chargeable - if need_chargeable: - facts = dict(ticket_view(ticket).get("collab_facts") or {}) - if str(facts.get(_PENDING_LOCK) or "") != "1": - flow.ledger.patch_collab_facts( - work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id - ) - send_group_text( - group_client_of(flow), - chat_id, - copy.air_ask_manual_lock_fields( - need_option=False, - need_airline=False, - need_chargeable=True, - need_flight=False, - options=options, - ), + quote_id = _quote_id(quote) + facts = dict(ticket_view(ticket).get("collab_facts") or {}) + picked, air_code, flight = _resolve_lock_params( + quote, + facts, + option_no=option_no, + airline=airline, + flight_no=flight_no, + instruction=instruction, + ) + missing = _missing_lock_labels(picked, air_code, flight) + if missing and _quote_ready_for_lock(quote): + _remember_partial_lock( + flow, + work_order_no=no, + sender_id=sender_id, + option_no=picked, + airline=air_code, + flight_no=flight, ) - return "wait_lock_fields" + send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(missing)) + return "wait_lock_params" locker = getattr(flow.ledger, "lock_cabin", None) if not callable(locker): send_group_text( @@ -932,14 +923,6 @@ def apply_air_lock( confirm_system_exception, ) - def _lock_terminal(x: object) -> bool: - """成功,或业务上不必再重试(缺舱位/无报价)。""" - row = x if isinstance(x, dict) else {} - if bool(row.get("ok")): - return True - err = str(row.get("error") or "") - return err in {"lock_incomplete", "no_quote"} - out = call_with_retries( lambda: locker( work_order_no=no, @@ -947,30 +930,38 @@ def apply_air_lock( sender_id=sender_id, operator_name=name, instruction_text=instruction, - airline=airline_code, - flight_no=flight_no, + airline=air_code, + flight_no=flight, ) or {}, - is_ok=_lock_terminal, + is_ok=lambda x: bool((x or {}).get("ok")), ) if not out.get("ok"): 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" - if str(out.get("error") or "") == "lock_incomplete": - # 主账现拉仍无舱位:业务补问,不算系统异常,不惊动 IT。 - why = str(out.get("failReason") or out.get("error") or "锁舱参数不完整") - facts = dict(ticket_view(ticket).get("collab_facts") or {}) - if str(facts.get(_PENDING_LOCK) or "") != "1": - flow.ledger.patch_collab_facts( - work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id - ) + # 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。 + reason_text = str(out.get("failReason") or "") + if str(out.get("error") or "") == "lock_params_incomplete" or "锁舱参数不完整" in reason_text: + labels = missing or _labels_from_incomplete_reason(reason_text) + _remember_partial_lock( + flow, + work_order_no=no, + sender_id=sender_id, + option_no=picked, + airline=air_code, + flight_no=flight, + ) + send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(labels)) + return "wait_lock_params" + # 多段是产品限制,不是 TMS 故障。只回原因,不标系统异常、不暂停工单。 + if str(out.get("error") or "") == "multi_no_lock": send_group_text( group_client_of(flow), chat_id, - copy.air_lock_incomplete(work_order_no=no, operator=name, reason=why), + copy.multi_air_lock_unsupported(work_order_no=no, operator=name), ) - return "lock_incomplete" + return "multi_lock_unsupported" why = copy.air_cabin_fail_reason( tms_reason=str(out.get("failReason") or out.get("error") or ""), flight_date=_ticket_flight_date(ticket), @@ -993,7 +984,8 @@ def apply_air_lock( conversation_target=chat_id, extra={ "option_no": picked, - "airline": airline_code, + "airline": air_code, + "flight_no": flight, "instruction": instruction, "sender_id": sender_id, "operator_name": name, @@ -1013,6 +1005,9 @@ def apply_air_lock( _LOCK_QUOTE: quote_no, _LOCK_EXEC: exec_no, _PENDING_LOCK: "", + _PENDING_LOCK_OPTION: "", + _PENDING_LOCK_AIRLINE: "", + _PENDING_LOCK_FLIGHT: "", }, sender_id=sender_id, ) @@ -1036,7 +1031,6 @@ def apply_air_lock( emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {})) return "lock_ok" - def _release_should_push_deal(ticket: object) -> bool: """ 放舱成功后要不要再推成交跟进。 @@ -1259,6 +1253,339 @@ def _is_product_quote(quote: dict[str, Any]) -> bool: return "航线" in src or "产品" in src +_OPTION_RE = re.compile(r"AIR-?OPT-?\d+") +_AIRLINE_AFTER_OPTION_RE = re.compile(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])") +_AIRLINE_LABELED_CODE_RE = re.compile( + r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}|[A-Z]\d|\d[A-Z])(?![A-Z0-9])" +) +_AIRLINE_LABELED_NAME_RE = re.compile( + r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([\u4e00-\u9fffA-Za-z0-9]{2,12})" +) +_FLIGHT_LABELED_RE = re.compile( + r"(?:航班号|航班)\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}\s*\d{2,4}|[A-Z]\d\s*\d{2,4}|\d[A-Z]\s*\d{2,4})" +) +_FLIGHT_TRAIL_RE = re.compile( + r"AIR-?OPT-?\d+\s+(?:[A-Z]{2}\s+)?([A-Z]{2}\s*\d{2,4})" +) +_FLIGHT_LOOSE_RE = re.compile(r"(? tuple[str, str, str]: + """ + 从航线原话抽出舱位编号、航司、航班号。 + + 不要求「AIR-OPT01 CZ 航班CZ327」这一种写法。 + 带「舱位 / 航司 / 航班」的说法、换顺序、航司用中文名,都认。 + 本地对不上、话里又在补这三项时,再问模型。没说的不猜。 + """ + option, airline, flight = _lock_params_local(text) + if option and airline and flight: + return option, airline, flight + if not _should_ask_lock_model(text, option, airline, flight): + return option, airline, flight + got_opt, got_air, got_flight = _lock_params_from_model(text) + return ( + option or got_opt, + airline or _normalize_airline(got_air), + flight or _normalize_flight(got_flight), + ) + + +def text_has_lock_supplement(text: str) -> bool: + """这句话是在补锁舱三项,不是报价或闲聊。凑齐两项,或点名了舱位/航司/航班,都算。""" + option, airline, flight = _lock_params_local(text) + filled = sum(1 for item in (option, airline, flight) if item) + if filled >= 2: + return True + raw = text or "" + if filled < 1 or not any(word in raw for word in ("舱位", "航司", "航班")): + return False + from agent.routing.group_intent import text_is_staff_fee_quote + + return not text_is_staff_fee_quote(raw) + + +def _lock_params_local(text: str) -> tuple[str, str, str]: + """按原话里的编号、标签和常见航司名收三项。不调模型。""" + raw = text or "" + blob = raw.upper() + option = _option_from_blob(blob) + flight = _flight_from_blob(blob) + airline = _airline_from_text(raw, blob, option=option, flight=flight) + return option, airline, flight + + +def _option_from_blob(blob: str) -> str: + found = _OPTION_RE.search(blob or "") + if not found: + return "" + raw = found.group(0) + if raw.startswith("AIROPT"): + return "AIR-OPT" + raw[6:] + return raw + + +def _flight_from_blob(blob: str) -> str: + labeled = _FLIGHT_LABELED_RE.search(blob or "") + if labeled: + return _normalize_flight(labeled.group(1)) + trailed = _FLIGHT_TRAIL_RE.search(blob or "") + if trailed: + return _normalize_flight(trailed.group(1)) + loose = _FLIGHT_LOOSE_RE.search(blob or "") + if loose: + return _normalize_flight(loose.group(1)) + return "" + + +def _airline_from_text(raw: str, blob: str, *, option: str, flight: str) -> str: + """航司可以是二字码,也可以是「航司南航」这种说法。""" + labeled = _AIRLINE_LABELED_CODE_RE.search(blob or "") + if labeled: + return labeled.group(1) + named = _AIRLINE_LABELED_NAME_RE.search(raw or "") + if named: + token = named.group(1).strip() + if token.upper() not in {"AIR", "OPT"} and not token.upper().startswith("AIR-OPT"): + return _normalize_airline(token) + after = _AIRLINE_AFTER_OPTION_RE.search(blob or "") + if after: + return after.group(1) + # 没写「航司」二字时,只有这句话已经带了舱位或航班,才从航司名里认,避免报价句子被误伤。 + if option or flight: + found = _alias_in_text(raw) + if found: + return found + return "" + + +def _normalize_flight(value: str) -> str: + compact = re.sub(r"\s+", "", (value or "").upper()) + return compact if _FLIGHT_CODE_RE.match(compact) else "" + + +def _normalize_airline(value: str) -> str: + text = (value or "").strip() + if not text: + return "" + upper = re.sub(r"\s+", "", text.upper()) + if _FLIGHT_CODE_RE.match(upper): + return "" + if _AIRLINE_CODE_RE.match(upper): + return upper + alias = _alias_in_text(text) + if alias: + return alias + if re.fullmatch(r"[\u4e00-\u9fffA-Za-z0-9]{2,12}", text) and not text.upper().startswith("AIR"): + return text + return "" + + +def _alias_in_text(text: str) -> str: + raw = text or "" + for name, code in _AIRLINE_ALIASES: + if name in raw: + return code + return "" + + +def _airline_ready(airline: str) -> bool: + text = (airline or "").strip() + if _AIRLINE_CODE_RE.match(text.upper()): + return True + return bool(_normalize_airline(text)) + + +def _should_ask_lock_model(text: str, option: str, airline: str, flight: str) -> bool: + """三项已经齐,或这句话根本没在说舱位,就不要再打模型。""" + if option and airline and flight: + return False + raw = text or "" + if any(word in raw for word in ("舱位", "航司", "航班")): + return True + blob = raw.upper() + return "AIR-OPT" in blob or "AIROPT" in blob + + +def _lock_params_from_model(text: str) -> tuple[str, str, str]: + """ + 模型只抽舱位编号、航司、航班号。 + + 网络未开或调用失败时当没抽到,群里继续按缺项追问。不在请求里猜。 + """ + try: + from agent.config import get_settings + from agent.llm.http_client import LlmHttpClient + from agent.llm.modes import LlmMode + + settings = get_settings() + if not ( + getattr(settings, "llm_allow_network", False) + and getattr(settings, "llm_data_usage_confirmed", False) + ): + return "", "", "" + client = LlmHttpClient.from_settings(settings) + data = client.chat( + mode=LlmMode.EXTRACT_FIELDS, + messages=[ + { + "role": "system", + "content": ( + "从航线这句话抽出锁舱三项。只回复一行 JSON,不要解释。" + '键为 optionNo、airline、flightNo。没有的填空字符串。' + "optionNo 是舱位编号,如 AIR-OPT01;airline 是航司二字码或航司名;" + "flightNo 是航班号,如 CZ327。不要编造原文没有的值。" + ), + }, + {"role": "user", "content": (text or "")[:500]}, + ], + ) + content = str((data or {}).get("content") or "") + start = content.find("{") + end = content.rfind("}") + if start < 0 or end <= start: + return "", "", "" + payload = json.loads(content[start : end + 1]) + if not isinstance(payload, dict): + return "", "", "" + option = _option_from_blob(str(payload.get("optionNo") or "").upper()) + airline = _normalize_airline(str(payload.get("airline") or "")) + flight = _normalize_flight(str(payload.get("flightNo") or "")) + return option, airline, flight + except Exception: + logger.exception("锁舱三项模型抽取失败") + return "", "", "" + + +def _resolve_lock_params( + quote: dict[str, Any], + facts: dict[str, Any], + *, + option_no: str, + airline: str, + flight_no: str, + instruction: str, +) -> tuple[str, str, str]: + """ + 拼出锁舱三项。 + + 这一句里写的优先;没写的用上一句暂存的;再没有才用报价上已有的舱位。 + 航司写成南航、东方航空这类名字时归一成二字码;归一不了也算已经补充,不再追问。 + """ + said_opt, said_air, said_flight = lock_params_from_text(instruction) + option = (option_no or said_opt or str(facts.get(_PENDING_LOCK_OPTION) or "")).strip() + air = _normalize_airline(airline or said_air or str(facts.get(_PENDING_LOCK_AIRLINE) or "")) + flight = _normalize_flight(flight_no or said_flight or str(facts.get(_PENDING_LOCK_FLIGHT) or "")) + chosen = _option_row(quote, option) + if chosen is None and not option: + options = _air_options(quote) + if len(options) == 1: + chosen = options[0] + option = str(chosen.get("optionNo") or chosen.get("option_no") or "").strip() + if not _airline_ready(air) and chosen is not None: + air = _normalize_airline( + str(chosen.get("airlineCode") or chosen.get("airline") or "") + ) + if not flight and chosen is not None: + flight = _normalize_flight(str(chosen.get("flightNo") or chosen.get("flight_no") or "")) + if not flight: + flight = _normalize_flight(str(quote.get("flightNo") or quote.get("flight_no") or "")) + if not _airline_ready(air): + air = "" + if not air and len(flight) >= 2 and flight[:2].isalpha(): + air = flight[:2] + return option, air, flight + + +def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any]]: + """按舱位编号找报价里的那一条;对不上返回空。""" + want = (option_no or "").strip().upper() + if not want: + return None + for row in _air_options(quote): + no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper() + if no == want: + return row + return None + + +def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[str]: + """缺的中文名,顺序固定:舱位编号、航司、航班号。""" + missing: list[str] = [] + if not (option_no or "").strip(): + missing.append("舱位编号") + if not _airline_ready(airline): + missing.append("航司") + if not _FLIGHT_CODE_RE.match((flight_no or "").strip().upper()): + missing.append("航班号") + return missing + + +def _labels_from_incomplete_reason(reason: str) -> list[str]: + """主账回了「缺少:…」时,按中文名收回缺项,丢掉英文字段名。""" + found = [label for label in ("舱位编号", "航司", "航班号") if label in (reason or "")] + return found or ["舱位编号", "航司", "航班号"] + + +def _remember_partial_lock( + flow: Any, + *, + work_order_no: str, + sender_id: str, + option_no: str, + airline: str, + flight_no: str, +) -> None: + """先记下已经说清的项,下一句补齐后接着锁,不用重说。""" + patcher = getattr(flow.ledger, "patch_collab_facts", None) + if not callable(patcher): + return + patcher( + work_order_no=work_order_no, + facts={ + _PENDING_LOCK: "1", + _PENDING_LOCK_OPTION: option_no, + _PENDING_LOCK_AIRLINE: airline, + _PENDING_LOCK_FLIGHT: flight_no, + }, + sender_id=sender_id, + ) + + def _quote_id(quote: dict[str, Any]) -> str: return str(quote.get("quoteId") or quote.get("quote_id") or "").strip() diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 7f3c282..92d97d8 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -882,8 +882,11 @@ class AirTextInquiryFlow: reply(copy.quote_generating()) return "wait_file" if sess.phase == "wait_deal": - # 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。 - # 原话写了已成交/未成交/协商中时,由 _close_spoken_deal 先入账,不会走到这里。 + # 成交卡原来挂在报价单那条消息上,聊天一上滑就找不到。 + # 销售再说继续工单、又没写成交结果时,再发一次成交/未成交/协商中。 + # 原话已经写了结果时,_close_spoken_deal 会先入账,不会走到这里。 + sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) + self._emit_deal(reply, sess) return "wait_deal" if sess.phase == "wait_lost_reason": reply(copy.deal_ack("未成交", sess.work_order_no)) @@ -1260,13 +1263,39 @@ class AirTextInquiryFlow: quote = tms["quote"] from agent.schema.air_route_options import ( + choose_stated_origin_option, needs_route_pick, + options_matching_origin, parse_air_options, project_selected_quote, ) options = parse_air_options(quote) - if options and not needs_route_pick(quote): + stated_origin = str( + (sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or "" + ).strip() + # 写了起运地:只认这个起运地的线路,直接报价,不让销售再选。 + # 没写起运地:TMS 按目的地回多条时,才进选线页。 + if stated_origin: + matched = options_matching_origin(options, stated_origin) + if options and not matched: + logger.info( + "air 起运地对不上回包线路 origin=%s wo=%s options=%s", + stated_origin, + no, + len(options), + ) + self._emit_tms_miss(reply, no) + sess.phase = "tms_miss" + sess.status = "询价中" + sess.allowed = () + self._save(sess) + return "tms_miss" + if matched: + quote = project_selected_quote( + quote, choose_stated_origin_option(matched, quote) + ) + elif options and not needs_route_pick(quote): quote = project_selected_quote(quote, options[0]) up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价") if not up.get("ok"): @@ -1278,7 +1307,7 @@ class AirTextInquiryFlow: copy.remember_tms_quote(sess, quote) sess.tms_quote = dict(quote) sess.status = "已报价" - if needs_route_pick(quote): + if not stated_origin and needs_route_pick(quote): return self._offer_air_routes( sess=sess, work_order_no=no, diff --git a/inquiry-agent/agent/policy/deal_followup.py b/inquiry-agent/agent/policy/deal_followup.py index f7d2783..14b91b0 100644 --- a/inquiry-agent/agent/policy/deal_followup.py +++ b/inquiry-agent/agent/policy/deal_followup.py @@ -46,6 +46,7 @@ _HOLIDAYS = frozenset( date(2026, 9, 25), date(2026, 9, 26), date(2026, 9, 27), + # 国庆只休到 10 月 7 日。10 月 8 日周四正常上班,不能算假期。 date(2026, 10, 1), date(2026, 10, 2), date(2026, 10, 3), @@ -53,7 +54,6 @@ _HOLIDAYS = frozenset( date(2026, 10, 5), date(2026, 10, 6), date(2026, 10, 7), - date(2026, 10, 8), # 2027 元旦预留 date(2027, 1, 1), date(2027, 1, 2), @@ -68,6 +68,8 @@ _MAKEUP_WORKDAYS = frozenset( date(2026, 2, 14), date(2026, 2, 28), date(2026, 5, 9), + # 国庆调休:9 月 20 日周日、10 月 10 日周六上班。 + date(2026, 9, 20), date(2026, 10, 10), } ) diff --git a/inquiry-agent/agent/policy/event_exception.py b/inquiry-agent/agent/policy/event_exception.py index 3bcdd6e..e2be884 100644 --- a/inquiry-agent/agent/policy/event_exception.py +++ b/inquiry-agent/agent/policy/event_exception.py @@ -164,7 +164,7 @@ def pick_mentions( """ 本单这次应回而未回、需要被点名的人。 - 航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。 + 航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售,不点航线。 不要询价机器人、不要群里其他人。 """ kind = (wait_kind or "").strip() @@ -206,6 +206,48 @@ def pick_mentions( return out +def mentions_for_role( + rows: list[dict[str, Any]] | None, + *, + role_code: str, +) -> list[dict[str, Any]]: + """ + 从员工行里取出启用、有企微、有姓名的指定岗位。 + + 空运群激活只记群号,协同成员经常是空的。超时巡检在群成员里找不到航线岗时, + 用本函数点名员工管理里的航线·空运,和进群摘要 @ 的是同一批人。 + 不发消息、不写主账。 + """ + want = (role_code or "").strip().lower() + if not want: + return [] + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for raw in rows or []: + if not isinstance(raw, dict): + continue + role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower() + if role != want: + continue + status = str(raw.get("status") or "active").strip().lower() + if status in {"disabled", "0", "停用", "inactive"}: + continue + uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip() + name = str(raw.get("name") or "").strip() + if not uid or not name or uid in seen: + continue + seen.add(uid) + out.append( + { + "wecomId": uid, + "name": name, + "roleCode": want, + "notifyEventException": raw.get("notifyEventException", 0), + } + ) + return out + + def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]: """勾了「接收事件异常消息提醒」且绑了企微的人。""" out: list[dict[str, Any]] = [] diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index fc2df8f..97f3860 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -2400,8 +2400,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: @@ -2778,9 +2784,10 @@ def _followup_month_day(raw: str) -> str: def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str: """ - 收下未成交原因后回显。后台 lost.reason_ack。 + 收下未成交原因后的回执。后台 lost.reason_ack。 - 测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。 + 后台模板里的 {{lost_reason}}、{{ticket_no}} 都在这里填。 + 不传工单号时占位留空串,避免群里再露出 {{ticket_no}}。 """ text = (reason or "").strip() no = (work_order_no or "").strip() @@ -2932,7 +2939,8 @@ def air_group_brief( 空运群激活摘要:与海运进群同一套结构,字段用空运。 先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。 - TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。 + TMS 段跟单聊同一套线路分项,总销售价只一行。 + 已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。 不写「当前工单/询价要点/现在等到哪一步」。 """ no = (work_order_no or "").strip() @@ -3059,6 +3067,23 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str: return air_lock_need_quote(work_order_no) +def air_lock_need_params(missing=None) -> str: + """ + 舱位编号、航司、航班号没齐时,只请航线补全。 + + 这时还没锁舱,不能说 TMS 锁舱失败,也不能通知 IT。 + 缺哪项就点哪项;三项都缺时是产品指定的那一句。 + """ + labels = [str(x).strip() for x in (missing or []) if str(x).strip()] + if not labels: + labels = ["舱位编号", "航司", "航班号"] + joined = "、".join(labels) + return ( + f"锁舱参数不完整,缺少:{joined}。" + "请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。" + ) + + def air_lock_need_quote(work_order_no: str) -> str: """没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。""" no = (work_order_no or "").strip() @@ -3155,6 +3180,32 @@ def air_lock_ok( ) +# 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 TMS 故障。 +MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱" +# 后台锁舱失败模板末尾常有「已通知IT…请稍等」。多段这条要整句拿掉。 +_IT_NOTICE_LINE = re.compile(r"已通知\s*IT[^\n。]*。?") + + +def multi_air_lock_unsupported(*, work_order_no: str, operator: str) -> str: + """ + 多段含空运、航线锁舱时的群回复。 + + 抬头、操作人、失败原因仍走锁舱失败模板,只去掉通知 IT 那句。 + 不打主账、不标系统异常。 + """ + text = air_lock_fail( + work_order_no=work_order_no, + operator=operator, + reason=MULTI_LOCK_UNSUPPORTED, + ) + kept: list[str] = [] + for line in text.splitlines(): + cleaned = _IT_NOTICE_LINE.sub("", line).strip() + if cleaned: + kept.append(cleaned) + return "\n".join(kept).strip() + + def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str: """空运锁舱失败(接口/系统类)。后台 inquiring.air_lock_fail。""" why = (reason or "").strip() or "TMS未返回原因" @@ -3359,7 +3410,7 @@ def system_exception_alert_payload( """ 系统异常通知卡:走询价消息服务,不走询价小助手。 - 五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。 + 五行:异常类型、接口/服务、报错原因、影响范围、发生时间。 标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。 """ no = (work_order_no or "").strip() or "无" @@ -3368,7 +3419,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 "-"}, ] @@ -3409,7 +3460,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 fda9640..8152f95 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", "询价机器人"} @@ -1317,10 +1345,7 @@ def capture_group_lost_reason( send_group_text( client, chat_id, - copy.lost_reason_ack( - str(result.get("lost_reason") or text), - work_order_no=no, - ), + copy.lost_reason_ack(str(result.get("lost_reason") or text), work_order_no=no), ) return "done" diff --git a/inquiry-agent/agent/policy/system_exception.py b/inquiry-agent/agent/policy/system_exception.py index 735a257..3d2a8ec 100644 --- a/inquiry-agent/agent/policy/system_exception.py +++ b/inquiry-agent/agent/policy/system_exception.py @@ -95,6 +95,35 @@ def ticket_is_paused(ticket: Any) -> bool: return bool(ticket_exception_type(ticket)) +def ticket_pause_step(ticket: Any) -> str: + """暂停是哪一步留下的。查价、锁舱、释放各算各的。""" + if ticket is None: + return "" + if isinstance(ticket, dict): + step = str( + ticket.get("system_exception_step") or ticket.get("systemExceptionStep") or "" + ).strip() + payload = ticket.get("system_exception_payload") or ticket.get("systemExceptionPayload") or {} + else: + step = str(getattr(ticket, "system_exception_step", "") or "").strip() + payload = getattr(ticket, "system_exception_payload", {}) or {} + if step: + return step + if isinstance(payload, dict): + return str(payload.get("step") or "").strip() + return "" + + +def cabin_failure_pause(ticket: Any) -> bool: + """ + 锁舱或释放失败留下的暂停。 + + 群里已经回过失败原因,并通知了 IT。之后的手工报价、补字段、成交不能再被 + 「TMS接口异常」挡住。查价失败的暂停不在这里,那种仍要停。 + """ + return ticket_pause_step(ticket) in {STEP_TMS_LOCK, STEP_TMS_RELEASE, STEP_TMS_CABIN} + + def extract_work_order_nos(text: str) -> list[str]: """从原话抽出正式工单号(WO + 日期流水)。不认虚拟号。""" import re diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py index cf28d75..e14a7b5 100644 --- a/inquiry-agent/agent/routing/air_group_intent.py +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -26,6 +26,8 @@ from agent.routing.group_intent import ( _hint_confirm_tms, _hint_deal_outcome, _hint_tms_choice, + _prefer_staff_quote, + text_is_staff_fee_quote, ) logger = logging.getLogger(__name__) @@ -95,6 +97,11 @@ def classify_air_group_text( locked = _hint_lock_or_release(raw) if locked: return locked + # 补舱位不限写法。凑齐舱位编号、航司、航班号就当选舱,不要先当成报价。 + from agent.policy.air_group_ops import text_has_lock_supplement + + if text_has_lock_supplement(raw): + return INTENT_PICK_OPTION # 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。 exact = _exact_tms_choice(raw) if exact: @@ -118,12 +125,15 @@ def classify_air_group_text( if use_net and getattr(settings, "llm_data_usage_confirmed", False): got = _classify_with_model(raw, settings) if got in _VALID: - return got + return _prefer_staff_quote(got, raw) except Exception: logger.exception("空运群意图模型失败,回退规则") from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote from agent.schema.quote_adjust import extract_adjust + # 费用金额先当航线报价。「空运费改为900」不是销售调价。 + if text_is_staff_fee_quote(raw): + return INTENT_QUOTE if extract_adjust(raw).is_adjust: return INTENT_ADJUST if extract_product_quote(raw): @@ -183,10 +193,10 @@ def _classify_with_model(text: str, settings) -> str: "你是空运协同群意图分类。只回复一个英文词,不要解释。" "air_lock=航线要锁舱;air_release=航线要释放舱位;" "air_activate=只是指定/切换工单号;" - "air_pick_option=在选舱位选项编号;" + "air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;" "collab_fields=在补是否含电/是否含磁;" - "quote_adjust=销售在调已有费用,口令不写死(上升、往上抬、加一成、改到某金额都算),不是新产品报价;" - "product_quote=航线在报价格或费用(含具体金额);" + "quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;" + "product_quote=航线在报某一项费用的金额。写出「空运费9900」「空运费改为900USD」是报价,不是调价;" "quote_confirm_tms=确认沿用当前TMS报价;" "quote_keep_tms=要在已有TMS报价上改;" "quote_drop_tms=不要TMS、只用刚报的价;" @@ -195,7 +205,7 @@ def _classify_with_model(text: str, settings) -> str: "other=闲聊。" "一句话里既有锁舱又有工单号,回 air_lock。" "一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。" - "改已有费用优先 quote_adjust;新报价格才是 product_quote。" + "有具体费用金额就是 product_quote。只有比例或只改总价才是 quote_adjust。" ) data = client.chat( mode=LlmMode.ORDERED_ACTIONS, diff --git a/inquiry-agent/agent/routing/group_intent.py b/inquiry-agent/agent/routing/group_intent.py index 25cea54..2689381 100644 --- a/inquiry-agent/agent/routing/group_intent.py +++ b/inquiry-agent/agent/routing/group_intent.py @@ -117,6 +117,63 @@ _DROP_HINTS = ( ) +def text_is_staff_fee_quote(text: str) -> bool: + """ + 航线/产品在报某一项费用的金额。 + + 「空运费 9900」「空运费改为900USD」「报关费改为300CNY」都算手工报价。 + 比例调价和只改总价/总销售价不算,那两句仍留给销售调价。 + 句里没有费用名、只剩一个数字,也不算,避免把选项编号当成报价。 + 不打模型。分类和入站改判都会用。 + """ + raw = (text or "").strip() + if not raw: + return False + from agent.schema.quote_adjust import text_has_relative_adjust, text_has_total_only_adjust + + if text_has_relative_adjust(raw) or text_has_total_only_adjust(raw): + return False + from agent.llm.extract_text import extract_product_quote + + quote = extract_product_quote(raw) + rows = [row for row in (quote.get("fee_rows") or []) if isinstance(row, dict)] + if not rows: + return False + # 「费用合计」里的「费」不能单独把改总价认成报价;上面已经排除只改总价。 + if "费" in raw: + return True + names = [str(row.get("name") or "").strip() for row in rows] + return any(name and name != "海运费" for name in names) + + +def resolve_price_intent(intent: str, text: str, *, sales: bool) -> str: + """ + 按说话人把「报价」和「调价」分开。 + + 航线/产品写出费用金额:按报价办,不能回「只有销售能调价」。 + 销售在做比例、改总价,或点名把某项费用改到某个金额:仍按调价办。 + 不打模型。群入站分发前调用,避免手工报价被调价话术拦住。 + """ + if intent not in {INTENT_ADJUST, INTENT_QUOTE}: + return intent + from agent.schema.quote_adjust import extract_adjust + + # 这里只用本地规则。正式调价抽取仍在调价处理里,必要时再问模型。 + if sales and extract_adjust(text, allow_model=False).is_adjust: + return INTENT_ADJUST + if text_is_staff_fee_quote(text): + return INTENT_QUOTE + return intent + + +def _prefer_staff_quote(intent: str, text: str) -> str: + """模型或本地规则把费用金额判成调价时,改回报价。比例和改总价不动。""" + if intent == INTENT_ADJUST and text_is_staff_fee_quote(text): + logger.info("判成调价,但这是费用报价,改回报价 text=%s", (text or "")[:40]) + return INTENT_QUOTE + return intent + + def classify_group_text( text: str, *, @@ -163,12 +220,15 @@ def classify_group_text( if hinted_deal and got in {INTENT_DISSOLVE, INTENT_OTHER, INTENT_FIELDS}: logger.warning("群意图模型把成交判成%s,改回 %s text=%s", got, hinted_deal, raw[:40]) return hinted_deal - return got + return _prefer_staff_quote(got, raw) except Exception: logger.exception("群意图模型失败,回退标签字段") from agent.llm.extract_text import extract_product_quote from agent.schema.quote_adjust import extract_adjust + # 费用金额先当报价。「改为900」是航线/产品常用写法,不能先被调价规则抢走。 + if text_is_staff_fee_quote(raw): + return INTENT_QUOTE if extract_adjust(raw).is_adjust: return INTENT_ADJUST if extract_product_quote(raw): @@ -249,8 +309,8 @@ def _classify_with_model(text: str, settings) -> str: system = ( "你是海运协同群意图分类。只回复一个英文词,不要解释。" "collab_fields=在补货好时间/商品海关编码/含油含电含磁;" - "quote_adjust=销售在调已有费用,口令不写死(上升、上浮、往上抬、加一成、减、改到某金额都算),不是新产品报价;" - "product_quote=产品在报价格或费用(含具体金额);" + "quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;" + "product_quote=产品在报某一项费用的金额。写出「运费1000」「报关费改为300CNY」是报价,不是调价;" "quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;" "quote_keep_tms=要在已有TMS报价上改,没有报新金额;" "quote_drop_tms=不要TMS、只用产品刚报的价,没有报新金额;" @@ -260,7 +320,8 @@ def _classify_with_model(text: str, settings) -> str: "dissolve=要把这个协同群解散;" "other=闲聊、问候、对询价港口的随口更正(不当改单)。" "一句话里既补字段又报价,回 product_quote。" - "有金额就是 product_quote,不要判成 quote_keep_tms。" + "有具体费用金额就是 product_quote,不要判成 quote_adjust 或 quote_keep_tms。" + "只有比例或只改总价才是 quote_adjust。" ) data = client.chat( mode=LlmMode.ORDERED_ACTIONS, diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py index dea0ad2..871e41c 100644 --- a/inquiry-agent/agent/schema/air_route_options.py +++ b/inquiry-agent/agent/schema/air_route_options.py @@ -45,10 +45,55 @@ def parse_air_options(quote: dict[str, Any] | None) -> list[dict[str, Any]]: def needs_route_pick(quote: dict[str, Any] | None) -> bool: - """多于一条线路才进 H5 选。""" + """没写起运地、且多于一条线路,才进 H5 选。有起运地时由调用方先按起运地收口。""" return len(parse_air_options(quote)) > 1 +def options_matching_origin( + options: list[dict[str, Any]], + origin_text: str, +) -> list[dict[str, Any]]: + """ + 销售已经写了起运地时,只留起运机场对得上的线路。 + + 杭州对 HGH,珠海对 ZUH。回包里的南京/深圳/郑州不能拿来给这张单选。 + 对不上返回空列表。不改入参。 + """ + text = (origin_text or "").strip() + if not text: + return list(options) + code = resolve_airport_code(text).upper() + raw = text.upper() + matched: list[dict[str, Any]] = [] + for opt in options: + origin = str(opt.get("originAirportCode") or opt.get("origin") or "").strip().upper() + if not origin: + continue + if code and origin == code: + matched.append(opt) + elif origin == raw: + matched.append(opt) + return matched + + +def choose_stated_origin_option( + options: list[dict[str, Any]], + quote: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + 起运地已指定时直接用一条,不再让销售选线。 + + 只有一条就用它。多条时优先和报价总价相同的那条,否则用第一条。 + """ + if len(options) == 1: + return options[0] + total = (quote or {}).get("total") + for opt in options: + if _same_amount(opt.get("salePrice"), total): + return opt + return options[0] + + def _place_name(code: str, fallback: str = "") -> str: """三字码能对上就用中文,否则用原文或 fallback。""" raw = (code or "").strip() @@ -331,14 +376,14 @@ def _same_amount(left: Any, right: Any) -> bool: def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]: """ - 整单兜底「空运费=总价」不是线路分项,群摘要禁止再写。 + 已经有其它费用分项时,去掉「空运费=总价」这一行兜底。 - 真有空运费且金额不等于总价(例如另有附加费)则保留。 + 舱位上有运输费、提送货费时,主账快照常再塞一行空运费,金额就是总价,不能和分项叠在一起。 + 只有空运费这一项时必须留下:TMS 经常只回这一项,金额自然等于总价,群里仍要写出费用名。 + 不写库。群摘要投影之后调用。 """ src = dict(quote or {}) total = src.get("total") - if _same_amount(src.get("freight"), total): - src["freight"] = "-" raw = src.get("fee_lines") or src.get("fee_rows") parsed: Any = raw if isinstance(raw, str) and raw.strip(): @@ -348,17 +393,26 @@ def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any] parsed = None if not isinstance(parsed, list): return src - kept: list[Any] = [] + others: list[Any] = [] + alias: list[Any] = [] for item in parsed: if not isinstance(item, dict): - kept.append(item) + others.append(item) continue - name = str(item.get("name") or item.get("label") or "").strip() + name = str(item.get("name") or item.get("label") or item.get("feeName") or "").strip() amount = item.get("amount") or item.get("value") if name == "空运费" and _same_amount(amount, total): + alias.append(item) continue - kept.append(item) - src["fee_lines"] = kept + others.append(item) + if others: + # 分项已经在,空运费=总价是重复兜底。运费字段同值也不再补一行。 + src["fee_lines"] = others + if _same_amount(src.get("freight"), total): + src["freight"] = "-" + return src + # 明细就是这一条空运费,群摘要要显示费用名,不能只剩费用合计。 + src["fee_lines"] = alias return src 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/agent/schema/quote_adjust.py b/inquiry-agent/agent/schema/quote_adjust.py index a376f51..e6fdd52 100644 --- a/inquiry-agent/agent/schema/quote_adjust.py +++ b/inquiry-agent/agent/schema/quote_adjust.py @@ -493,6 +493,40 @@ def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None) return out +def text_has_relative_adjust(text: str) -> bool: + """ + 这句话是在按比例改价:加10%、减5%、上调、上浮、加一成。 + + 这种是销售调价,不是航线/产品报一个新金额。不打模型。 + """ + raw = text or "" + if _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw): + return True + blob = raw.replace(" ", "").replace(" ", "") + return any(phrase in blob for phrase in ("加一成", "减一成", "往上抬", "往下压", "上浮", "下浮")) + + +def text_has_total_only_adjust(text: str) -> bool: + """ + 只动总价/总销售价/费用合计,没有点名某一项费用。 + + 「总销售价改为180」仍是销售调价。点了「空运费」「报关费」则不是。不打模型。 + """ + raw = text or "" + if not (_TARGET.search(raw) or _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw)): + return False + if not any(word in raw for word in _TOTAL_WORDS) and "报价" not in raw: + return False + real: list[str] = [] + for name in _FEE_TOKEN.findall(raw): + if name in {"费用", "费用合计"}: + continue + if any(word in name for word in ("合计", "总价", "汇总")): + continue + real.append(name) + return not real + + def extract_adjust( text: str, *, diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py index d21adfd..afba3c6 100644 --- a/inquiry-agent/agent/schema/tms_air_query.py +++ b/inquiry-agent/agent/schema/tms_air_query.py @@ -35,6 +35,9 @@ _AIRPORTS = { "南京": "NKG", "吉隆坡": "KUL", "北京": "PEK", + "杭州": "HGH", + "萧山": "HGH", + "杭州萧山": "HGH", } # 历史字典:只给对照,空运出站不再编成 1/2/3。 @@ -199,7 +202,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: 包装只看销售是否说了,散货等原文原样保留。 """ src = dict(facts or {}) - origin = str(src.get("起运港") or "").strip() + origin = str(src.get("起运港") or src.get("起运地") or "").strip() dest = str(src.get("目的港") or "").strip() cargo = str(src.get("品名") or "").strip() pieces = _first_number(str(src.get("件数") or "")) diff --git a/inquiry-agent/agent/schema/tms_land_query.py b/inquiry-agent/agent/schema/tms_land_query.py index 4880785..c283fc7 100644 --- a/inquiry-agent/agent/schema/tms_land_query.py +++ b/inquiry-agent/agent/schema/tms_land_query.py @@ -3,8 +3,8 @@ 本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。 带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。 -出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。 -中港整车、中港零担/集拼出站类型都是 CHINA_HK。中港整车不带车型/数量。 +出站类型/线路译成 TMS 用的值,线路键名 routeCategory。工单与卡片仍用销售中文。 +中港整车、中港零担/集拼出站类型都只写「中港」。中港整车不带车型/数量。 禁止:把运输分类放进出站;直连 TMS;改六态。 线程:纯函数。 """ @@ -29,8 +29,8 @@ _TYPE_ENUM = { "国内运输整车": "DOMESTIC_FTL", "跨境集拼": "CROSS_BORDER_LTL", "跨境整车": "CROSS_BORDER_FTL", - "中港整车": "CHINA_HK", - "中港零担/集拼": "CHINA_HK", + "中港整车": "中港", + "中港零担/集拼": "中港", } _ROUTE_ENUM = { "国内长途/零担": "DOMESTIC_LONG_LTL", @@ -45,7 +45,7 @@ _TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$") def tms_land_type_code(raw: str) -> str: - """销售中文运输类型 → TMS 6.1.1 landTransportType。对不上空串。""" + """销售中文运输类型 → 查价 landTransportType。中港两类都是「中港」。对不上空串。""" text = (raw or "").strip() if text in _TYPE_ENUM: return _TYPE_ENUM[text] diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 51ec3a7..1b4eed8 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -160,7 +160,7 @@ class AirGroupCollabTests(unittest.TestCase): "source_label": "TMS 标准报价", "quoteId": "QT-AIR-1", "airOptions": [ - {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航"} + {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航", "flightNo": "CZ327"} ], } if quote: @@ -203,6 +203,67 @@ class AirGroupCollabTests(unittest.TestCase): self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK) self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE) + def test_airline_fee_wording_is_quote_not_adjust(self) -> None: + """航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。""" + from agent.routing.air_group_intent import INTENT_ADJUST, INTENT_QUOTE + + self.assertEqual( + classify_air_group_text("空运费改为900USD", allow_network=False), + INTENT_QUOTE, + ) + self.assertEqual( + classify_air_group_text("报关费改为300CNY", allow_network=False), + INTENT_QUOTE, + ) + self.assertEqual( + classify_air_group_text("空运费 9900CNY", allow_network=False), + INTENT_QUOTE, + ) + self.assertEqual( + classify_air_group_text("空运费加10%", allow_network=False), + INTENT_ADJUST, + ) + self.assertEqual( + classify_air_group_text("总销售价改为180", allow_network=False), + INTENT_ADJUST, + ) + + def test_airline_rewritten_fee_does_not_say_only_sales(self) -> None: + """已有报价后,航线再写费用金额,不能提示只有销售能调价。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="air1", text=wo, chat="wr_air_1", mid="fee0"), + flow=self.flow, + ledger=self.ledger, + ) + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="fee1"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertNotEqual(phase, "adjust_not_sales") + self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts())) + self.assertIn(phase, {"wait_tms_choice", "quoted"}) + + def test_sales_percent_still_adjusts(self) -> None: + """销售说加百分之几,仍是调价,不会被改成航线报价。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="adj0"), + flow=self.flow, + ledger=self.ledger, + ) + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="sales1", text="空运费加10%", chat="wr_air_1", mid="adj1"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertNotEqual(phase, "not_airline") + self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts())) + self.assertIn(phase, {"group_adjusted", "adjust_need_fee", "adjust_none", "adjust_need_lines"}) + def test_air_group_brief_matches_sea_sections(self) -> None: text = copy.air_group_brief( work_order_no="WO202609160009", @@ -373,6 +434,51 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("费用合计:CNY 200040", tms_block) self.assertEqual(tms_block.count("费用合计:"), 1) + def test_air_group_brief_keeps_sole_air_freight_line(self) -> None: + """TMS 只有空运费、金额等于总价时,群摘要必须写出这一行,不能只剩费用合计。""" + tms = { + "source": "TMS", + "source_label": "TMS标准报价", + "total": "1833.7", + "freight": "1833.70", + "currency": "CNY", + "valid_until": "2026-10-21", + "fee_lines": '[{"name":"空运费","amount":"CNY 1833.70"}]', + "feeItems": [ + { + "feeName": "空运费", + "feeCode": "AIR_FREIGHT", + "amount": 1833.70, + "unitPrice": 11.00, + "quantity": 166.7, + "currency": "CNY", + } + ], + "airOptions": [ + { + "originAirportCode": "CGO", + "destinationAirportCode": "KUL", + "salePrice": 1833.70, + "currency": "CNY", + "departureCharges": [], + } + ], + } + text = copy.air_group_brief( + work_order_no="WO202610080020", + facts={"起运地": "CGO", "目的地": "吉隆坡", "originCode": "CGO"}, + quote=tms, + collab_facts={}, + has_price=True, + tms_quote=tms, + ) + tms_block = text.split("TMS报价:", 1)[1] + self.assertIn("空运费:CNY 1833.70", tms_block) + self.assertLess(tms_block.find("空运费:"), tms_block.find("费用合计:")) + self.assertIn("费用合计:CNY 1833.7", tms_block) + self.assertEqual(tms_block.count("费用合计:"), 1) + self.assertEqual(tms_block.count("费用合计:"), 1) + def test_work_order_plus_lost_reason_is_deal_not_activate(self) -> None: """WO@助手 未成交,原因… 必须当关单,不能只激活。""" from agent.routing.air_group_intent import INTENT_ACTIVATE, INTENT_LOST @@ -636,6 +742,45 @@ class AirGroupCollabTests(unittest.TestCase): ) self.assertEqual(phase, "need_work_order") + 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( @@ -944,35 +1089,146 @@ class AirGroupCollabTests(unittest.TestCase): ledger=self.ledger, injected_intent="air_lock", ) - self.assertEqual(phase, "lock_incomplete") - joined = "\n".join(self._texts()) - self.assertIn("舱位编号", joined) - self.assertFalse(any("执行成功" in t for t in self._texts())) - - def test_manual_quote_with_option_airline_can_lock(self) -> None: - """人工价补齐舱位+航司+航班后可锁;计费重用工单毛重兜底。""" - wo = self._create_air( - quote={ - "total": "100", - "source": "manual", - "source_label": "产品报价", - "currency": "CNY", - }, - status="已报价", - ) + self.assertEqual(phase, "wait_lock_params") phase = handle_air_group( self._msg( sender="air1", - text=f"{wo}锁舱,锁AIR-OPT01 CZ 航班CZ327", - chat="wr_air_lock_manual2", + text="AIR-OPT01 CZ 航班CZ327", + chat="wr_air_lock_manual", mid="lm2", ), flow=self.flow, ledger=self.ledger, + injected_intent="air_pick_option", + ) + self.assertEqual(phase, "lock_ok") + self.assertTrue(any("执行成功" in t for t in self._texts())) + + def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None: + """缺舱位、航司、航班号时只请补,不锁舱、不报 TMS 失败、不通知 IT。""" + wo = self._create_air( + quote={ + "total": "CNY 1800", + "source": "TMS", + "source_label": "TMS 标准报价", + "quoteId": "QT-AIR-23", + } + ) + calls: list[dict] = [] + orig = self.ledger.lock_cabin + + def spy(**kwargs): + calls.append(kwargs) + return orig(**kwargs) + + self.ledger.lock_cabin = spy # type: ignore + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_ask", mid="ask1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "wait_lock_params") + self.assertEqual(calls, []) + asked = self._texts()[-1] + self.assertEqual( + asked, + "锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。", + ) + self.assertNotIn("已通知", asked) + self.assertNotIn("锁舱失败", asked) + phase = handle_air_group( + self._msg( + sender="air1", + text="AIR-OPT01 CZ 航班CZ327", + chat="wr_air_lock_ask", + mid="ask2", + ), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_pick_option", + ) + self.assertEqual(phase, "lock_ok") + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].get("option_no"), "AIR-OPT01") + self.assertEqual(calls[0].get("airline"), "CZ") + self.assertEqual(calls[0].get("flight_no"), "CZ327") + + def test_lock_accepts_free_wording_not_only_sample_format(self) -> None: + """补舱位不限格式。点名舱位、航司、航班号,换顺序、用航司中文名,也要锁。""" + wo = self._create_air( + quote={ + "total": "CNY 1800", + "source": "TMS", + "source_label": "TMS 标准报价", + "quoteId": "QT-AIR-24", + } + ) + calls: list[dict] = [] + orig = self.ledger.lock_cabin + + def spy(**kwargs): + calls.append(kwargs) + return orig(**kwargs) + + self.ledger.lock_cabin = spy # type: ignore + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_free", mid="free1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "wait_lock_params") + self.assertEqual(calls, []) + phase = handle_air_group( + self._msg( + sender="air1", + text="航班号:CZ327,航司南航,舱位编号是AIR-OPT01", + chat="wr_air_lock_free", + mid="free2", + ), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "lock_ok") + self.assertEqual(calls[0].get("option_no"), "AIR-OPT01") + self.assertEqual(calls[0].get("airline"), "CZ") + self.assertEqual(calls[0].get("flight_no"), "CZ327") + + def test_lock_sentence_with_labeled_params_locks_immediately(self) -> None: + """同一句里已经说齐三项,直接锁,不再要求按示例格式重说。""" + wo = self._create_air( + quote={ + "total": "CNY 1800", + "source": "manual", + "source_label": "产品报价", + } + ) + calls: list[dict] = [] + orig = self.ledger.lock_cabin + + def spy(**kwargs): + calls.append(kwargs) + return orig(**kwargs) + + self.ledger.lock_cabin = spy # type: ignore + phase = handle_air_group( + self._msg( + sender="air1", + text=f"{wo}锁舱,舱位 AIR-OPT02,东方航空,航班号 MU5123", + chat="wr_air_lock_once", + mid="once1", + ), + flow=self.flow, + ledger=self.ledger, injected_intent="air_lock", ) self.assertEqual(phase, "lock_ok") - self.assertTrue(any("执行成功" in t for t in self._texts())) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].get("option_no"), "AIR-OPT02") + self.assertEqual(calls[0].get("airline"), "MU") + self.assertEqual(calls[0].get("flight_no"), "MU5123") + self.assertFalse(any("锁舱参数不完整" in t for t in self._texts())) def test_lock_success_and_release_after_deal_blocked(self) -> None: wo = self._create_air() @@ -1060,8 +1316,8 @@ class AirGroupCollabTests(unittest.TestCase): "source": "TMS", "quoteId": "QT-AIR-2", "airOptions": [ - {"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}, - {"optionNo": "AIR-OPT-02", "airlineCode": "MU"}, + {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"}, + {"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"}, ], } ) @@ -1071,7 +1327,7 @@ class AirGroupCollabTests(unittest.TestCase): ledger=self.ledger, injected_intent="air_lock", ) - self.assertEqual(phase, "wait_lock_option") + self.assertEqual(phase, "wait_lock_params") phase = handle_air_group( self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"), flow=self.flow, @@ -1166,6 +1422,31 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("请确认成交状态", texts) self.assertFalse(any(x.get("kind") == "file" for x in added)) + def test_reactivate_quoted_tms_file_is_deal_not_sheet(self) -> None: + """TMS 价的报价单已经发出、停在成交跟进:再发工单号只回成交跟进。""" + wo = self._create_air( + quote={ + "total": "CNY 3500.70", + "source": "TMS", + "source_label": "TMS 标准报价", + "quoteId": "QT-AIR-FILE", + "quoteFileIssued": True, + "airOptions": [ + {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"} + ], + } + ) + phase = handle_air_group( + self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="react-deal"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "activated_deal") + texts = "\n".join(self._texts()) + self.assertIn("请确认成交状态", texts) + self.assertNotIn("询价字段:", texts) + self.assertFalse(any(x.get("kind") == "file" for x in self.groups.sent)) + def test_image_rejected(self) -> None: wo = self._create_air() handle_air_group( @@ -1396,6 +1677,38 @@ class AirGroupCollabTests(unittest.TestCase): self.assertNotIn("报价选项不存在", joined) self.assertNotIn("与工单不匹配", joined) + def test_lock_fail_does_not_block_airline_quote(self) -> None: + """锁舱失败后,航线再报手工价必须收下,不能回 TMS 接口异常。""" + wo = self._create_air() + handle_air_group( + self._msg(sender="air1", text=wo, chat="wr_air_lock_quote", mid="lq0"), + flow=self.flow, + ledger=self.ledger, + ) + self.ledger._cabin_lock_result = { + "ok": False, + "error": "lock_fail", + "failReason": "该航班日期不存在可锁定的配载记录", + } + phase = handle_air_group( + self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_quote", mid="lq1"), + flow=self.flow, + ledger=self.ledger, + injected_intent="air_lock", + ) + self.assertEqual(phase, "system_exception") + self.groups.sent.clear() + phase = handle_air_group( + self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"), + flow=self.flow, + ledger=self.ledger, + injected_intent="product_quote", + ) + self.assertNotEqual(phase, "system_exception_paused") + joined = "\n".join(self._texts()) + self.assertNotIn("TMS接口异常", joined) + self.assertIn(phase, {"wait_tms_choice", "quoted"}) + def test_lock_without_quote_still_shows_tms_reason(self) -> None: """TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。""" wo = self._create_air() diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py index 856a97e..47a2e08 100644 --- a/inquiry-agent/tests/test_air_route_options.py +++ b/inquiry-agent/tests/test_air_route_options.py @@ -106,6 +106,82 @@ class AirRouteOptionsTests(unittest.TestCase): self.assertEqual(sess.facts.get("起运港"), "深圳") self.assertIn("SZX-CRK", "\n".join(replies[-2:])) + def test_hangzhou_query_uses_airport_and_skips_other_cities(self) -> None: + """写了杭州就不能按目的地把南京、深圳、郑州拿来选。""" + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.air_text_flow import AirTextInquiryFlow + from agent.schema.tms_air_query import assemble_air_query + + ready = assemble_air_query({"起运港": "杭州", "目的港": "吉隆坡"}) + self.assertEqual(ready["facts"].get("originCode"), "HGH") + self.assertEqual(ready["facts"].get("destinationCode"), "KUL") + + class Ledger(MemoryLedger): + def query_tms(self, **kwargs): + out = super().query_tms(**kwargs) + quote = out["quote"] + quote["total"] = "1750.35" + quote["currency"] = "CNY" + quote["airOptions"] = [ + {"originAirportCode": "NKG", "destinationAirportCode": "KUL", "salePrice": 1750.35, "currency": "CNY"}, + {"originAirportCode": "SZX", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"}, + {"originAirportCode": "CGO", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"}, + ] + return out + + flow = AirTextInquiryFlow(Ledger()) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + facts = { + "起运港": "杭州", + "目的港": "吉隆坡", + "品名": "化妆品", + "件数": "12", + "毛重": "21", + "体积": "1", + "包装方式": "散货", + } + self.assertEqual( + flow.on_text(sender_id="u_hgh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"), + "wait_confirm", + ) + phase = flow.on_text(sender_id="u_hgh", text="确定", reply=reply) + self.assertEqual(phase, "tms_miss") + self.assertFalse(any("条线路" in x for x in replies)) + self.assertEqual(flow.session_of("u_hgh").facts.get("originCode"), "HGH") + + def test_stated_origin_quotes_directly(self) -> None: + """起运地已经是珠海时,即使 TMS 还带回别的城市,也不再让销售选线。""" + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.air_text_flow import AirTextInquiryFlow + + flow = AirTextInquiryFlow(MemoryLedger()) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + facts = { + "起运港": "珠海", + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "托盘", + "tms_multi": "1", + } + flow.on_text(sender_id="u_zuh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR") + phase = flow.on_text(sender_id="u_zuh", text="确定", reply=reply) + self.assertEqual(phase, "wait_collab") + self.assertFalse(any("条线路" in x for x in replies)) + self.assertEqual(flow.session_of("u_zuh").facts.get("起运港"), "珠海") + def test_reselect_after_adopt_voids_old_file(self) -> None: """采用后再改选:旧报价单作废,回到方案卡重新采用。""" ledger = MemoryLedger() @@ -331,7 +407,10 @@ class AirRouteOptionsTests(unittest.TestCase): self.assertIn("closeWindow", ok) self.assertIn("wecom-jssdk", ok) self.assertIn("jsapi-signature", ok) - self.assertNotIn("window.close()", ok) + mobile = ok.split("function closeMobile()")[1].split("function closeDesktop()")[0] + desktop = ok.split("function closeDesktop()")[1].split("function boot()")[0] + self.assertNotIn("window.close()", mobile) + self.assertIn("window.close()", desktop) self.assertNotIn("closeWindow", dead) diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 7503290..63eaab7 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -900,6 +900,10 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotIn("引用历史工单号", sub) self.assertNotIn("工单状态", sub) self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001"))) + self.assertIn( + "请手动将相关方拉到空运原有协同群,并在空运协同群中发送“ WO202609140001@询价小助手”,谢谢。", + sub, + ) self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False)) def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None: @@ -1555,7 +1559,8 @@ class AirTextInquiryTests(unittest.TestCase): source="test", ) self.assertEqual(replay, "wait_deal") - self.assertNotIn("咨询成交状态", "\n".join(again)) + self.assertIn("咨询成交状态", "\n".join(again)) + self.assertNotIn("请稍候", "\n".join(again)) self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal") def test_deal_click_works_while_http_still_wait_file(self) -> None: diff --git a/inquiry-agent/tests/test_attachment_upload_h5.py b/inquiry-agent/tests/test_attachment_upload_h5.py index e1a9f0f..42983ae 100644 --- a/inquiry-agent/tests/test_attachment_upload_h5.py +++ b/inquiry-agent/tests/test_attachment_upload_h5.py @@ -108,6 +108,12 @@ class AttachmentUploadH5Tests(unittest.TestCase): self.assertIn("closeWindow", ok) self.assertIn("wecom-jssdk", ok) self.assertIn("jsapi-signature", ok) + self.assertIn("function isPhone()", ok) + self.assertIn("WeixinJSBridge.invoke", ok) + mobile = ok.split("function closeMobile()")[1].split("function closeDesktop()")[0] + desktop = ok.split("function closeDesktop()")[1].split("function boot()")[0] + self.assertNotIn("window.close()", mobile) + self.assertIn("window.close()", desktop) self.assertNotIn("closeWindow", page) self.assertNotIn("closeWindow", dead) diff --git a/inquiry-agent/tests/test_continue_ticket.py b/inquiry-agent/tests/test_continue_ticket.py index 6d529f2..07efa4a 100644 --- a/inquiry-agent/tests/test_continue_ticket.py +++ b/inquiry-agent/tests/test_continue_ticket.py @@ -228,7 +228,7 @@ class ContinueTicketTests(unittest.TestCase): sender_id="u6", work_order_no=wo, reply=reply ) self.assertEqual(phase, "wait_deal") - self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies)) + self.assertTrue(any("【成交】【未成交】【协商中】" in x for x in replies)) def test_resume_spoken_deal_closes(self) -> None: """报价文件已出后,原话写工单号加已成交,要入账并回复,不能没反应。""" diff --git a/inquiry-agent/tests/test_deal_followup.py b/inquiry-agent/tests/test_deal_followup.py index 2a92024..66565df 100644 --- a/inquiry-agent/tests/test_deal_followup.py +++ b/inquiry-agent/tests/test_deal_followup.py @@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo from agent.policy.deal_followup import ( SHANGHAI, air_route_keys, + is_workday, leader_matches, next_followup_at, shift_workdays, @@ -20,33 +21,42 @@ from agent.policy.deal_followup import ( class TestWorkdays(unittest.TestCase): def test_skip_weekend(self) -> None: - # 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24 - self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23)) - self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24)) + # 普通周末仍跳过。2026-11-13 周五 → 16、17、18 三个工作日,第四个是 19 日。 + self.assertEqual(shift_workdays(date(2026, 11, 13), 3), date(2026, 11, 18)) + self.assertEqual(shift_workdays(date(2026, 11, 13), 4), date(2026, 11, 19)) + + def test_sept20_makeup_pulls_followup_forward(self) -> None: + # 2026-09-18 周五。9 月 20 日周日调休上班,第四个工作日是 23 日,不是 24 日。 + self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 23)) def test_skip_national_day(self) -> None: - # 2026-09-30 周三,国庆休 → 第四个工作日落到节后 + # 2026-09-30 周三。10 月 1–7 日放假,8 日上班,10 日周六调休。 + # 节后第 1 个工作日 8 日,第 4 个是 12 日周一。 fourth = shift_workdays(date(2026, 9, 30), 4) - self.assertGreaterEqual(fourth, date(2026, 10, 9)) - self.assertNotEqual(fourth.weekday(), 5) - self.assertNotEqual(fourth.weekday(), 6) + self.assertEqual(fourth, date(2026, 10, 12)) + + def test_oct8_works_and_sept20_is_makeup(self) -> None: + # 国办发明电〔2025〕7号:10 月 8 日正常上班,9 月 20 日周日调休上班。 + self.assertTrue(is_workday(date(2026, 10, 8))) + self.assertTrue(is_workday(date(2026, 9, 20))) + self.assertFalse(is_workday(date(2026, 10, 7))) def test_next_followup_is_9am(self) -> None: when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI)) self.assertEqual(when.hour, 9) self.assertEqual(when.minute, 0) self.assertEqual(when.tzinfo, SHANGHAI) - self.assertEqual(when.date(), date(2026, 9, 24)) + self.assertEqual(when.date(), date(2026, 9, 23)) def test_skip_weekend_keep_weekday_night(self) -> None: - start = datetime(2026, 9, 18, 17, 0, tzinfo=SHANGHAI) # 周五 - now = datetime(2026, 9, 21, 17, 0, tzinfo=SHANGHAI) # 周一 + start = datetime(2026, 11, 13, 17, 0, tzinfo=SHANGHAI) # 周五 + now = datetime(2026, 11, 16, 17, 0, tzinfo=SHANGHAI) # 周一 # 周五 17–24 = 7h,周末不算,周一 0–17 = 17h self.assertAlmostEqual(workday_elapsed_hours(start, now), 24.0, places=4) def test_rest_day_start_counts_from_next_workday(self) -> None: - start = datetime(2026, 9, 19, 10, 0, tzinfo=SHANGHAI) # 周六 - now = datetime(2026, 9, 21, 10, 0, tzinfo=SHANGHAI) # 周一 + start = datetime(2026, 11, 14, 10, 0, tzinfo=SHANGHAI) # 周六 + now = datetime(2026, 11, 16, 10, 0, tzinfo=SHANGHAI) # 周一 self.assertAlmostEqual(workday_elapsed_hours(start, now), 10.0, places=4) def test_fractional_quarter_hour(self) -> None: diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py index d601b9c..6e62350 100644 --- a/inquiry-agent/tests/test_deal_outcome.py +++ b/inquiry-agent/tests/test_deal_outcome.py @@ -193,11 +193,11 @@ class TestDealOutcome(unittest.TestCase): self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。") self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。") - def test_lost_reason_ack_fills_ticket_no_from_bot_reply(self) -> None: - """后台文案带 {{ticket_no}} 时必须填实值,禁止原样打出占位符。""" - from agent.policy import bot_reply + def test_lost_reason_ack_fills_ticket_no(self) -> None: + """后台模板带工单号时,必须换成真实单号,不能把占位符原样发出去。""" + from agent.policy.bot_reply import override_catalog - bot_reply.override_catalog( + override_catalog( { "lost.reason_ack": ( "已将未成交原因“{{lost_reason}}”记录在后台," @@ -206,12 +206,12 @@ class TestDealOutcome(unittest.TestCase): } ) try: - text = copy.lost_reason_ack("换时间", work_order_no="WO202610070018") - self.assertIn("WO202610070018", text) - self.assertNotIn("{{ticket_no}}", text) - self.assertIn("换时间", text) + self.assertEqual( + copy.lost_reason_ack("客户不需要了", work_order_no="WO202609100001"), + "已将未成交原因“客户不需要了”记录在后台,工单WO202609100001已关闭,智能体不再跟进该工单,请悉知。", + ) finally: - bot_reply.override_catalog(None) + override_catalog(None) def test_harvest_inline_lost_reason(self) -> None: self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了") diff --git a/inquiry-agent/tests/test_event_exception.py b/inquiry-agent/tests/test_event_exception.py index ae58e14..be14b0d 100644 --- a/inquiry-agent/tests/test_event_exception.py +++ b/inquiry-agent/tests/test_event_exception.py @@ -20,6 +20,7 @@ from agent.policy.event_exception import ( WAIT_PRODUCT_QUOTE, conversation_of, format_timeout_hours, + mentions_for_role, pick_app_targets, pick_mentions, timeout_code, @@ -270,6 +271,163 @@ class TestPoll(unittest.TestCase): ) self.assertEqual(n2, 0) + def test_air_group_names_airline_when_members_missing(self) -> None: + """空运群没记下成员时,超时要 @ 航线姓名,不能写成相关人员。""" + import importlib.util + from datetime import datetime, timedelta + + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.deal_followup import SHANGHAI + + path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py") + spec = importlib.util.spec_from_file_location("ee_poll_air", path) + self.assertIsNotNone(spec) + self.assertIsNotNone(spec.loader) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + book = MemoryLedger() + book.set_sea_staff( + [ + { + "wecomId": "sales1", + "name": "罗小华", + "roleCode": "sales", + "status": "active", + }, + { + "wecomId": "air1", + "name": "吴季林", + "roleCode": "air", + "status": "active", + }, + ] + ) + created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"}) + wo = created["work_order_no"] + book.start_event_wait(work_order_no=wo, wait_kind="air_quote") + ticket = book.get_ticket(work_order_no=wo) + ticket.collab_chat_id = "wr-air" + ticket.collab_member_ids = [] + ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + book._timeouts["air_route"] = 0.25 + groups: list[dict] = [] + + def send_group(*, chat_id: str, content: str, business_line: str = "") -> bool: + groups.append({"chat_id": chat_id, "content": content, "line": business_line}) + return True + + n = mod.poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + send_group=send_group, + notify_ready=False, + ) + self.assertEqual(n, 1) + self.assertEqual(len(groups), 1) + self.assertEqual( + groups[0]["content"], + f"航线响应超时\n关联询价工单 {wo}。@吴季林已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。", + ) + self.assertNotIn("相关人员", groups[0]["content"]) + + def test_sales_timeout_mentions_sales_not_airline(self) -> None: + """销售超时只 @ 本单销售。私聊也带 @,不点航线,不写相关人员。""" + import importlib.util + from datetime import datetime, timedelta + + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.deal_followup import SHANGHAI + + path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py") + spec = importlib.util.spec_from_file_location("ee_poll_sales", path) + self.assertIsNotNone(spec) + self.assertIsNotNone(spec.loader) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + book = MemoryLedger() + book.set_sea_staff( + [ + {"wecomId": "sales1", "name": "罗小华", "roleCode": "sales", "status": "active"}, + {"wecomId": "air1", "name": "吴季林", "roleCode": "air", "status": "active"}, + ] + ) + created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={}) + wo = created["work_order_no"] + book.start_event_wait(work_order_no=wo, wait_kind="adopt") + ticket = book.get_ticket(work_order_no=wo) + ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + book._timeouts["air_sales"] = 0.25 + private: list[dict] = [] + + def enqueue(**kwargs): + private.append(kwargs) + return True, "" + + def send_group(**kwargs) -> bool: + raise AssertionError("销售采用超时不应发到群") + + n = mod.poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + enqueue=enqueue, + send_group=send_group, + notify_ready=False, + ) + self.assertEqual(n, 1) + self.assertEqual(len(private), 1) + self.assertEqual( + private[0]["content"], + f"销售响应超时\n关联询价工单 {wo}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。", + ) + self.assertNotIn("吴季林", private[0]["content"]) + self.assertNotIn("相关人员", private[0]["content"]) + + created_deal = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"目的港": "胡志明"}) + deal_no = created_deal["work_order_no"] + book.start_event_wait(work_order_no=deal_no, wait_kind="deal") + deal = book.get_ticket(work_order_no=deal_no) + deal.collab_chat_id = "wr-air" + deal.event_wait_started_at = ticket.event_wait_started_at + groups: list[dict] = [] + + def send_deal_group(*, chat_id: str, content: str, business_line: str = "") -> bool: + groups.append({"chat_id": chat_id, "content": content}) + return True + + n_deal = mod.poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + enqueue=enqueue, + send_group=send_deal_group, + notify_ready=False, + ) + self.assertEqual(n_deal, 1) + self.assertEqual( + groups[0]["content"], + f"销售响应超时\n关联询价工单 {deal_no}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。", + ) + self.assertNotIn("吴季林", groups[0]["content"]) + + +class TestMentionsForRole(unittest.TestCase): + def test_keeps_active_named_role_only(self) -> None: + rows = mentions_for_role( + [ + {"wecomId": "a1", "name": "吴季林", "roleCode": "air", "status": "active"}, + {"wecomId": "s1", "name": "罗小华", "roleCode": "sales", "status": "active"}, + {"wecomId": "a2", "name": "停用航线", "roleCode": "air", "status": "disabled"}, + {"wecomId": "a3", "name": "", "roleCode": "air", "status": "active"}, + ], + role_code="air", + ) + self.assertEqual([row["name"] for row in rows], ["吴季林"]) + if __name__ == "__main__": unittest.main() 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..bde13c6 100644 --- a/inquiry-agent/tests/test_land_group_collab.py +++ b/inquiry-agent/tests/test_land_group_collab.py @@ -317,6 +317,31 @@ class LandGroupCollabTests(unittest.TestCase): ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.status, "已报价") + def test_product_fee_change_is_quote_not_adjust(self) -> None: + """产品写「报关费改为300」是手工报价。已有报价后也不能被拦成只有销售能调价。""" + from agent.routing.group_intent import classify_group_text + + self.assertEqual(classify_group_text("报关费改为300CNY", allow_network=False), INTENT_QUOTE) + self.assertEqual(classify_group_text("运费加 10%", allow_network=False), INTENT_ADJUST) + wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件") + handle_group_collab( + self._msg(sender="p_dom", text="运费 1000", mid="q_fee0"), + flow=self.flow, + ledger=self.ledger, + injected_intent=INTENT_QUOTE, + injected_quote={"total": "1000", "source_label": "产品报价"}, + ) + self.groups.sent.clear() + phase = handle_group_collab( + self._msg(sender="p_dom", text="报关费改为300CNY", mid="q_fee1"), + flow=self.flow, + ledger=self.ledger, + ) + self.assertNotEqual(phase, "adjust_not_sales") + self.assertNotIn("只有这张单的销售能调价", self._group_texts()) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertTrue(ticket.quote) + def test_product_cannot_adjust(self) -> None: self._open(option_text="国内运输拼车 国内长途/零担 急件") self.groups.sent.clear() @@ -327,7 +352,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_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 6fd1282..024d302 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -1602,7 +1602,7 @@ class SeaContinueTests(unittest.TestCase): phase = handle_continue_thread(msg, thread_id=wo, store=store) self.assertEqual(phase, "wait_deal") joined = "\n".join(item.content for item in store._outbox.values()) - self.assertFalse(any(x in joined for x in (copy.BTN_DEAL, "成交"))) + self.assertIn("【成交】【未成交】【协商中】", joined) self.assertNotIn(copy.BTN_SKIP_COLLAB, joined) diff --git a/inquiry-agent/tests/test_system_exception.py b/inquiry-agent/tests/test_system_exception.py index 54bda3a..850063d 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-agent/tests/test_tms_land_query.py b/inquiry-agent/tests/test_tms_land_query.py index 4e2d75d..a5ce6e0 100644 --- a/inquiry-agent/tests/test_tms_land_query.py +++ b/inquiry-agent/tests/test_tms_land_query.py @@ -30,7 +30,7 @@ class TmsLandQueryTest(unittest.TestCase): got = assemble_land_query(facts) self.assertTrue(got["ok"]) out = got["outbound"] - self.assertEqual(out["landTransportType"], "CHINA_HK") + self.assertEqual(out["landTransportType"], "中港") self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA") self.assertNotIn("landRouteCategory", out) self.assertEqual(out["vehicleType"], "9米6") @@ -52,6 +52,7 @@ class TmsLandQueryTest(unittest.TestCase): got = assemble_land_query(facts) self.assertTrue(got["ok"]) out = got["outbound"] + self.assertEqual(out["landTransportType"], "中港") self.assertNotIn("vehicleType", out) self.assertNotIn("quantity", out) 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 60be325..407e107 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())) { @@ -233,6 +254,26 @@ public class InquiryTmsCabinService { req.put("requestedUntil", text(body, "requestedUntil")); } } + // 航线口令里的航班号盖过报价。三项没齐就地拦住,不去调 TMS 锁舱。 + String flightFromCaller = firstNonBlank( + text(body, "flightNo", "flight_no"), + flightNoFromInstruction(text(body, "instructionText", "instruction_text"))); + if (StringUtils.isNotBlank(flightFromCaller)) { + req.put("flightNo", flightFromCaller); + } + if (StringUtils.isBlank(text(req, "airline"))) { + String fromFlight = airlineFromFlight(text(req, "flightNo")); + if (StringUtils.isNotBlank(fromFlight)) { + req.put("airline", fromFlight); + } + } + String incomplete = missingLockParamReason( + text(req, "optionNo"), text(req, "airline"), text(req, "flightNo")); + if (StringUtils.isNotBlank(incomplete)) { + log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={}", + no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo")); + return fail("lock_params_incomplete", incomplete); + } return invokeLock(ticket, req, quoteId, resolvedOption); } @@ -475,6 +516,14 @@ public class InquiryTmsCabinService { req.remove("quoteId"); } fillLockQuoteExtras(req, quote, option); + if (StringUtils.isBlank(text(req, "validUntil"))) { + // 航线手工价经常不写有效期。TMS 人工锁舱必填 validUntil, + // 用工单报价日(调用前已放进 flightDate)的当天结束,不再空着出站。 + String iso = normalizeValidUntilIso(text(req, "flightDate")); + if (StringUtils.isNotBlank(iso)) { + req.put("validUntil", iso); + } + } String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"), text(quote, "transferAirport", "transferAirportCode")); if (StringUtils.isNotBlank(transfer)) { @@ -1185,6 +1234,50 @@ public class InquiryTmsCabinService { return row; } + /** + * 锁舱三项没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。 + * 三项都缺时是:锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。 + */ + static String missingLockParamReason(String optionNo, String airline, String flightNo) { + java.util.List missing = new ArrayList<>(); + if (StringUtils.isBlank(optionNo)) { + missing.add("舱位编号"); + } + if (StringUtils.isBlank(airline)) { + missing.add("航司"); + } + if (StringUtils.isBlank(flightNo)) { + missing.add("航班号"); + } + if (missing.isEmpty()) { + return ""; + } + return "锁舱参数不完整,缺少:" + String.join("、", missing) + + "。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"; + } + + /** + * 从「AIR-OPT01 CZ 航班CZ327」抽出航班号。没有就不猜。 + */ + static String flightNoFromInstruction(String instruction) { + if (StringUtils.isBlank(instruction)) { + return ""; + } + java.util.regex.Matcher named = java.util.regex.Pattern + .compile("(?i)(?:航班号|航班)\\s*[::]?\\s*(?:是|为)?\\s*([A-Za-z]{2}\\s*\\d{2,4})") + .matcher(instruction); + if (named.find()) { + return named.group(1).toUpperCase(); + } + java.util.regex.Matcher trailed = java.util.regex.Pattern + .compile("(?i)AIR-?OPT-?\\d+\\s+(?:[A-Za-z]{2}\\s+)?([A-Za-z]{2}\\d{2,4})") + .matcher(instruction); + if (trailed.find()) { + return trailed.group(1).toUpperCase(); + } + return ""; + } + /** * 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java index e010535..69d6296 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java @@ -4,6 +4,7 @@ package org.jeecg.modules.inquiry.tms; * 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。 *

* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。 + * 中港整车、中港零担/集拼查价只传「中港」,工单仍留细类。 * 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。 * 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。 */ @@ -14,10 +15,15 @@ public final class TmsLandEnumMapper { /** * @param raw 销售中文(或历史英文码) - * @return trim 后原文;空则 "" + * @return 中港两类收成「中港」;其余 trim 后原文;空则 "" */ public static String transportType(String raw) { - return trimOrEmpty(raw); + String text = trimOrEmpty(raw); + if ("中港整车".equals(text) || "中港零担/集拼".equals(text)) { + // zhonggang: 两类细类查价都只传「中港」 + return "中港"; + } + return text; } /** diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java index 2acbf5f..ccc7d81 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java @@ -18,8 +18,8 @@ import java.util.regex.Pattern; * 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。 * 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数; * 中港整车不带车型/数量。通关口岸有值才放。 - * 中港整车、中港零担/集拼:出站类型仍用销售中文「中港整车」「中港零担/集拼」(产品要求不做英文枚举映射)。 - * 类型/线路键名仍是 landTransportType / routeCategory,值用中文原文。 + * 中港整车、中港零担/集拼:出站 landTransportType 只写「中港」,工单事实仍留细类。 + * 类型/线路键名仍是 landTransportType / routeCategory。除中港两类外,值用中文原文。 * 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。 */ public final class TmsLandSegmentMapper { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java index cd891e6..2fbb6de 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java @@ -151,7 +151,7 @@ public final class TmsV2RequestMapper { } /** - * 陆运出站:类型+线路用销售中文原文(产品要求不做英文枚举映射);线路字段名是 routeCategory。 + * 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。 * 分类和中港装载即使误放在 source 也不抄走。 */ private static void applyLandFields(Map source, Map target) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json index 2b236de..e2204c2 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json @@ -13,7 +13,13 @@ {"source_name": "北京首都", "source_code": "PEK"}, {"source_name": "北京大兴", "source_code": "PKX"}, {"source_name": "珠海", "source_code": "ZUH"}, - {"source_name": "克拉克", "source_code": "CRK"} + {"source_name": "克拉克", "source_code": "CRK"}, + {"source_name": "南京", "source_code": "NKG"}, + {"source_name": "吉隆坡", "source_code": "KUL"}, + {"source_name": "北京", "source_code": "PEK"}, + {"source_name": "杭州", "source_code": "HGH"}, + {"source_name": "萧山", "source_code": "HGH"}, + {"source_name": "杭州萧山", "source_code": "HGH"} ], "air_packaging_type": [ {"source_name": "全部", "source_code": "1"}, 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 5c4adce..a18d377 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<>(); @@ -186,6 +195,34 @@ public class InquiryTmsCabinServiceTest { assertEquals("空运费", fees.get(0).get("feeName")); } + @Test + public void manualLockFillsValidUntilFromFlightDateWhenQuoteHasNone() { + Map req = new HashMap<>(); + req.put("flightDate", "2026-10-11"); + InquiryTmsCabinService.fillManualLock(req, new HashMap<>(), new HashMap<>(), new HashMap<>(), false); + assertEquals("2026-10-11T23:59:59+08:00", req.get("validUntil")); + assertEquals("2026-10-11T23:59:59+08:00", req.get("requestedUntil")); + } + + @Test + public void missingLockParamsAsksWithoutCallingTmsWording() { + assertEquals( + "锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。", + InquiryTmsCabinService.missingLockParamReason("", "", "")); + assertEquals( + "锁舱参数不完整,缺少:航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。", + InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "")); + assertEquals("", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327")); + } + + @Test + public void flightNoFromInstructionReadsSampleFormat() { + assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction( + "AIR-OPT01 CZ 航班CZ327")); + assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction("AIR-OPT01 CZ CZ327")); + assertEquals("", InquiryTmsCabinService.flightNoFromInstruction("WO202610080023锁舱")); + } + @Test public void airlineFromInstructionReadsCodeAfterOption() { assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction( diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java index 0698c37..bfd4260 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java @@ -32,4 +32,11 @@ public class TmsExactProjectorTest { assertTrue(hit.ok()); assertEquals("SHA", hit.code); } + + @Test + public void hangzhouMapsToXiaoshan() { + TmsExactProjector.Projection hit = projector.resolveAirport("杭州"); + assertTrue(hit.ok()); + assertEquals("HGH", hit.code); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java index 8264cee..a1bc73b 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java @@ -27,6 +27,13 @@ public class TmsLandEnumMapperTest { assertEquals("", TmsLandEnumMapper.routeCategory(" ")); } + @Test + public void hkTypesCollapseToZhonggang() { + assertEquals("中港", TmsLandEnumMapper.transportType("中港整车")); + assertEquals("中港", TmsLandEnumMapper.transportType("中港零担/集拼")); + assertEquals("中港集拼", TmsLandEnumMapper.transportType("中港集拼")); + } + @Test public void legacyEnglishCodesPassThrough() { assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL")); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java index 687a919..eac1d48 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java @@ -46,7 +46,7 @@ public class TmsLandSegmentMapperTest { facts.put("车型/数量", "9米6/1"); facts.put("体积", "99"); Map seg = TmsLandSegmentMapper.build(facts); - assertEquals("中港零担/集拼", seg.get("landTransportType")); + assertEquals("中港", seg.get("landTransportType")); assertEquals("9米6", seg.get("vehicleType")); assertNull(seg.get("volumeCbm")); assertFalse(seg.containsKey("hkLoadType")); @@ -62,7 +62,7 @@ public class TmsLandSegmentMapperTest { facts.put("目的港", "香港"); facts.put("品名", "衣服"); Map seg = TmsLandSegmentMapper.build(facts); - assertEquals("中港整车", seg.get("landTransportType")); + assertEquals("中港", seg.get("landTransportType")); assertNull(seg.get("vehicleType")); assertNull(seg.get("quantity")); java.util.List missing = TmsLandSegmentMapper.missingQuery( diff --git a/inquiry-backend/src/views/inquiry/dashboard/index.vue b/inquiry-backend/src/views/inquiry/dashboard/index.vue index 413a409..7161215 100644 --- a/inquiry-backend/src/views/inquiry/dashboard/index.vue +++ b/inquiry-backend/src/views/inquiry/dashboard/index.vue @@ -666,9 +666,11 @@ onUnmounted(() => document.removeEventListener("click", onDocClick)); } .dash-route-menu { + /* 盖过图表区;宽度在 dashboard-prototype.css 里写成 360px,避免按按钮宽度收缩 */ position: absolute; right: 0; top: calc(100% + 8px); + z-index: 120; } .dash-cost-footnote { diff --git a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css index e74a1e0..5a67689 100644 --- a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css +++ b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css @@ -181,6 +181,7 @@ body { padding: 0 14px !important; gap: 12px !important; min-width: 0 !important; + flex-shrink: 0 !important; /* 允许用户下拉露出;业务 Teleport 区单独 overflow:hidden 防幽灵点击层 */ overflow: visible !important; } @@ -1127,6 +1128,7 @@ h6 { /* 分析看板:内容区不裁切,滚动交给 .main-stage */ overflow: visible !important; max-height: none !important; + align-content: start !important; } /* 看板缩窄时允许卡片/图表收缩,避免右侧被裁且无横滚 */ @@ -1589,3 +1591,37 @@ textarea.field.field-tall { background: #f8fbff !important; } +/* + * 分析看板比一屏高。整页被 html / 主区锁死时,内容区即使设了 overflow, + * 格子也会跟着内容长高,再被主区裁掉,滚轮没有可滚的对象。 + * 看板单独放开文档滚动;列表页仍锁在一屏里,表格自己滚。 + */ +html:has(.ref-surface--metrics), +body:has(.ref-surface--metrics) { + height: auto !important; + min-height: 100% !important; + overflow-x: hidden !important; + overflow-y: auto !important; +} + +#app:has(.ref-surface--metrics), +.admin-shell:has(.ref-surface--metrics) { + height: auto !important; + min-height: 100vh !important; + overflow: visible !important; +} + +.main-stage:has(.ref-surface--metrics) { + height: auto !important; + max-height: none !important; + min-height: 100vh !important; + overflow: visible !important; +} + +.admin-content:has(.ref-surface--metrics) { + height: auto !important; + max-height: none !important; + flex: none !important; + overflow: visible !important; +} + diff --git a/inquiry-backend/src/views/inquiry/styles/dashboard-prototype.css b/inquiry-backend/src/views/inquiry/styles/dashboard-prototype.css index 27faf24..beaf665 100644 --- a/inquiry-backend/src/views/inquiry/styles/dashboard-prototype.css +++ b/inquiry-backend/src/views/inquiry/styles/dashboard-prototype.css @@ -776,9 +776,13 @@ color: #155eef; } .ref-route-filter__menu { - position: fixed; + /* 必须用固定宽度。百分比会按「线路筛选」按钮的宽度算,面板只剩一字宽,中文被挤成竖排,全选也被裁掉。 */ + position: absolute; + right: 0; + top: calc(100% + 8px); z-index: 120; - width: min(360px, calc(100% - 24px)); + width: 360px; + max-width: calc(100vw - 48px); border: 1px solid #dfe5ee; border-radius: 12px; background: #fff; @@ -797,12 +801,14 @@ color: #0f172a; font-size: 11px; font-weight: 900; + white-space: nowrap; } .ref-route-filter__menu-sub { margin-top: 2px; color: #64748b; font-size: 9px; font-weight: 700; + white-space: nowrap; } .ref-route-filter__actions.tabs-pill { flex-shrink: 0; @@ -861,6 +867,7 @@ } .ref-route-chip__text { line-height: 1.2; + white-space: nowrap; } .month-range-picker { position: relative;