""" 空运群内协同:激活当前工单、补字段、航线报价、锁舱/释放、成交。 """ from __future__ import annotations import os import sys import unittest ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if ROOT not in sys.path: sys.path.insert(0, ROOT) from agent.channel.wecom.models import InboundMessage from agent.handlers.air_group_collab import handle_air_group from agent.ledger.memory_ledger import MemoryLedger from agent.policy.air_text_flow import AirTextInquiryFlow from agent.policy import inquiry_copy as copy from agent.routing.air_group_intent import ( INTENT_LOCK, INTENT_RELEASE, classify_air_group_text, ) from agent.routing.dispatch import dispatch_inbound from agent.routing.decision import RouteDecision class FakeBotGroup: def __init__(self) -> None: self.sent: list[dict] = [] def send_group(self, *, chat_id: str, content: str, mention_userids=None): self.sent.append({"chat_id": chat_id, "content": content, "kind": "text"}) def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = ""): self.sent.append({"chat_id": chat_id, "filename": filename, "kind": "file"}) return {"ok": True} class AirGroupCollabTests(unittest.TestCase): def setUp(self) -> None: from agent.redis_coord.air_inbound import reset_air_inbound_for_tests reset_air_inbound_for_tests() self.ledger = MemoryLedger() self.groups = FakeBotGroup() self.flow = AirTextInquiryFlow(ledger=self.ledger, group_client=self.groups) self.ledger.upsert_staff( { "name": "WuJiLin", "wecomId": "sales1", "roleCode": "sales", "status": "active", } ) self.ledger.upsert_staff( { "name": "HangXian", "wecomId": "air1", "roleCode": "air", "status": "active", } ) self.ledger.set_quote_templates( [ { "templateId": "air-default", "name": "空运报价", "bizType": "空运", "isDefault": True, }, { "templateId": "sea-default", "name": "海运报价", "bizType": "海运", "isDefault": True, }, ] ) def _create_air(self, *, sender="sales1", quote=None, status="已报价", quote_date="2026-09-16") -> str: created = self.ledger.create_ticket( sender_id=sender, business_line="AIR", facts={ "起运地": "上海", "目的地": "洛杉矶", "品名": "配件", "件数": "3", "毛重": "100", "体积": "2", "包装类型": "纸箱", "报价日期": quote_date, }, ) wo = created["work_order_no"] if quote is None: quote = { "total": "USD 800", "source": "TMS", "source_label": "TMS 标准报价", "quoteId": "QT-AIR-1", "airOptions": [ {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航"} ], } if quote: self.ledger.upsert_quote(work_order_no=wo, quote=quote, to_status=status) return wo def _msg(self, *, sender: str, text: str, chat: str, mid: str) -> InboundMessage: return InboundMessage( sender_id=sender, message_id=mid, content=text, chat_id=chat, chat_type="group", raw={"source": "aibot"}, ) def _texts(self) -> list[str]: return [x.get("content") or "" for x in self.groups.sent] def test_attach_bot_client_keeps_req_id_for_quote(self) -> None: """这次 @ 的 req_id 要带给桥,群里才能引用原消息。""" from agent.channel.aibot.reply import AibotReplyClient from agent.handlers.air_group_collab import _attach_bot_client engine = AirTextInquiryFlow(ledger=self.ledger, group_client=None) msg = InboundMessage( sender_id="air1", message_id="m_quote", content="锁舱", chat_id="wr_air_1", chat_type="group", raw={"source": "aibot", "aibot_req_id": "cb_lock_1"}, ) _attach_bot_client(engine, msg) client = engine.group_client() self.assertIsInstance(client, AibotReplyClient) self.assertEqual(client._callback_req_id, "cb_lock_1") def test_intent_lock_is_not_hardcoded(self) -> None: 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_air_group_brief_matches_sea_sections(self) -> None: text = copy.air_group_brief( work_order_no="WO202609160009", facts={ "起运地": "ZUH", "目的地": "广州", "品名": "服务器", "件数": "1", "毛重": "10", "体积": "1", "包装类型": "纸箱", "报价日期": "2026-09-16", }, quote=None, collab_facts={}, has_price=False, ) self.assertIn("工单号:WO202609160009", text) self.assertIn("询价字段:\n运输方式:空运", text) self.assertIn("起运地:ZUH", text) self.assertIn("\n\n协同补充字段:\n是否含电:-", text) self.assertIn("是否含磁:-", text) general = copy.air_group_brief( work_order_no="WO202609230017", facts={ "起运地": "上海", "目的地": "洛杉矶", "品名": "普货", }, quote=None, collab_facts={}, has_price=False, ) self.assertIn("是否含电:否", general) self.assertIn("是否含磁:否", general) self.assertNotIn("是否含电:-", general) self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text) self.assertNotIn("当前工单:", text) self.assertNotIn("贸易条款", text) def test_air_group_brief_tms_total_only_once(self) -> None: """群摘要 TMS 段总销售价只一行,禁止重复。""" tms = { "source": "TMS", "source_label": "TMS标准报价", "total": "183370.0", "currency": "CNY", "eta": "-", "valid_until": "2026-09-30", "fee_lines": [ {"name": "运输费", "amount": "11.0", "currency": "CNY"}, ], } text = copy.air_group_brief( work_order_no="WO202609220018", facts={"起运港": "珠海", "目的港": "克拉克"}, quote=tms, collab_facts={}, has_price=True, tms_quote=tms, ) tms_block = text.split("TMS报价:", 1)[1] self.assertEqual(tms_block.count("总销售价:"), 1) self.assertIn("总销售价:CNY 183370.0", tms_block) self.assertNotIn("时效", tms_block) self.assertIn("报价有效期:2026-09-30", tms_block) def test_air_group_brief_uses_route_fees_not_air_freight_total(self) -> None: """ 群摘要必须跟单聊同一套线路分项。 主账快照若只有「空运费=总价」,仍要列出该线路运输费/提送货费/人工费。 """ tms = { "source": "TMS", "source_label": "TMS标准报价", "total": "183370.0", "freight": "183370.0", "currency": "CNY", "eta": "-", "valid_until": "2026-09-30", "fee_lines": [ { "name": "空运费", "amount": "183370.00", "currency": "CNY", } ], "airOptions": [ { "originAirportCode": "ZUH", "transferAirportCode": "CHG", "destinationAirportCode": "CRK", "salePrice": 183370, "currency": "CNY", "departureCharges": [ {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0}, {"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6.0}, {"chargeName": "人工费", "currency": "CNY", "unitPrice": 123.0}, ], } ], } text = copy.air_group_brief( work_order_no="WO202609220018", facts={"起运港": "珠海", "目的港": "克拉克", "originCode": "ZUH"}, quote=tms, collab_facts={}, has_price=True, tms_quote=tms, ) tms_block = text.split("TMS报价:", 1)[1] self.assertIn("运输费:CNY 11.0", tms_block) self.assertIn("提送货费:CNY 6.0", tms_block) self.assertIn("人工费:CNY 123.0", tms_block) self.assertIn("总销售价:CNY 183370", tms_block) self.assertEqual(tms_block.count("总销售价:"), 1) self.assertNotIn("空运费", tms_block) def test_air_group_brief_multi_route_does_not_borrow_other_air_freight(self) -> None: """多条线路:只出对上起运港那条的分项,禁止套整单空运费或另一条的运输费。""" tms = { "source": "TMS", "total": "200040", "freight": "183370.00", "currency": "CNY", "valid_until": "2026-09-30", "fee_lines": [ {"name": "空运费", "amount": "183370.00", "currency": "CNY"}, ], "airOptions": [ { "originAirportCode": "ZUH", "transferAirportCode": "CHG", "destinationAirportCode": "CRK", "salePrice": 183370, "currency": "CNY", "departureCharges": [ {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0}, ], }, { "originAirportCode": "SZX", "destinationAirportCode": "CRK", "salePrice": 200040, "currency": "CNY", "departureCharges": [], }, ], } text = copy.air_group_brief( work_order_no="WO202609220018", facts={"起运港": "深圳", "目的港": "克拉克", "originCode": "SZX"}, quote=tms, collab_facts={}, has_price=True, tms_quote=tms, ) tms_block = text.split("TMS报价:", 1)[1] self.assertNotIn("空运费", tms_block) self.assertNotIn("运输费", tms_block) self.assertIn("总销售价:CNY 200040", tms_block) self.assertEqual(tms_block.count("总销售价:"), 1) def test_work_order_only_is_activate_not_quote(self) -> None: from agent.routing.air_group_intent import INTENT_ACTIVATE self.assertEqual( classify_air_group_text("WO202609160002", allow_network=False), INTENT_ACTIVATE, ) self.assertEqual( classify_air_group_text("@询价小助手 WO202609160002", allow_network=False), INTENT_ACTIVATE, ) from unittest.mock import patch with patch( "agent.routing.air_group_intent._classify_with_model", side_effect=AssertionError("纯工单号不应再打模型"), ): self.assertEqual( classify_air_group_text( "@询价小助手 WO202609160002", allow_network=True, ), INTENT_ACTIVATE, ) def test_dispatch_aibot_goes_air(self) -> None: from agent.routing.dispatch import dispatch_inbound as _disp wo = self._create_air() msg = self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="d1") # 单测注入 flow,避免打到进程单例 import agent.handlers.air_group_collab as air_mod orig = air_mod.get_air_text_flow def _fake(): return self.flow air_mod.get_air_text_flow = _fake # type: ignore try: name = _disp(msg, RouteDecision(intent="ordinary_text_other")) finally: air_mod.get_air_text_flow = orig # type: ignore self.assertEqual(name, "air_group_collab") def test_activate_and_switch(self) -> None: wo1 = self._create_air() wo2 = self._create_air() phase = handle_air_group( self._msg(sender="sales1", text=wo1, chat="wr_air_1", mid="a1"), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "activated") ticket = self.ledger.get_ticket(work_order_no=wo1) self.assertEqual(ticket.event_wait_kind, "air_quote") self.assertTrue(ticket.event_wait_started_at) joined = "\n".join(self._texts()) self.assertIn(f"工单号:{wo1}", joined) self.assertIn("询价字段:", joined) self.assertIn("运输方式:空运", joined) self.assertIn("协同补充字段:", joined) self.assertIn("TMS报价:", joined) self.assertNotIn("当前工单:", joined) self.assertNotIn("询价要点:", joined) self.assertTrue(any("@WuJiLin" in t and "@HangXian" in t for t in self._texts())) self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default") kinds = [x.get("kind") for x in self.groups.sent] self.assertLess(kinds.index("text"), kinds.index("file")) self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1) phase = handle_air_group( self._msg(sender="air1", text=wo2, chat="wr_air_1", mid="a2"), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "activated") self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo2) def test_activate_prefills_magnet(self) -> None: created = self.ledger.create_ticket( sender_id="sales1", business_line="AIR", facts={ "起运地": "上海", "目的地": "洛杉矶", "品名": "配件", "件数": "3", "毛重": "100", "体积": "2", "包装类型": "纸箱", "报价日期": "2026-09-16", "是否含磁": "是", }, ) wo = created["work_order_no"] self.ledger.upsert_quote( work_order_no=wo, quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"}, to_status="已报价", ) handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="pre1"), flow=self.flow, ledger=self.ledger, ) joined = "\n".join(self._texts()) self.assertIn("是否含磁:是", joined) self.assertIn("是否含电:-", joined) def test_activate_general_cargo_defaults_battery_and_magnet(self) -> None: """品名是普货:群内激活后含电、含磁都是否,报价单也带上。""" created = self.ledger.create_ticket( sender_id="sales1", business_line="AIR", facts={ "起运地": "上海", "目的地": "洛杉矶", "品名": "普货", "件数": "3", "毛重": "100", "体积": "2", "包装类型": "纸箱", "报价日期": "2026-09-16", }, ) wo = created["work_order_no"] self.ledger.upsert_quote( work_order_no=wo, quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"}, to_status="已报价", ) handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="gc1"), flow=self.flow, ledger=self.ledger, ) ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.collab_facts.get("是否含电"), "否") self.assertEqual(ticket.collab_facts.get("是否含磁"), "否") joined = "\n".join(self._texts()) self.assertIn("是否含电:否", joined) self.assertIn("是否含磁:否", joined) fill = dict(getattr(self.ledger, "last_render", {}).get("facts") or {}) self.assertEqual(fill.get("是否含电") or fill.get("has_battery"), "否") self.assertEqual(fill.get("是否含磁") or fill.get("has_magnet"), "否") def test_activate_general_cargo_keeps_explicit_battery(self) -> None: """普货只补空着的项;私聊已经说含电,激活不能改成否。""" created = self.ledger.create_ticket( sender_id="sales1", business_line="AIR", facts={ "起运地": "上海", "目的地": "洛杉矶", "品名": "普货", "件数": "3", "毛重": "100", "体积": "2", "包装类型": "纸箱", "报价日期": "2026-09-16", "是否含电": "是", }, ) wo = created["work_order_no"] self.ledger.upsert_quote( work_order_no=wo, quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"}, to_status="已报价", ) handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="gc2"), flow=self.flow, ledger=self.ledger, ) ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.collab_facts.get("是否含电"), "是") self.assertEqual(ticket.collab_facts.get("是否含磁"), "否") joined = "\n".join(self._texts()) self.assertIn("是否含电:是", joined) self.assertIn("是否含磁:否", joined) def test_second_group_occupied(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="o1"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_2", mid="o2"), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "occupied") self.assertTrue(any("另一个群" in t for t in self._texts())) def test_need_work_order_when_empty(self) -> None: phase = handle_air_group( self._msg(sender="sales1", text="空运费100USD", chat="wr_air_1", mid="n1"), flow=self.flow, ledger=self.ledger, injected_intent="product_quote", ) self.assertEqual(phase, "need_work_order") def test_sales_can_fill_battery(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="f0"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( self._msg(sender="sales1", text="含电 不含磁", chat="wr_air_1", mid="f1"), flow=self.flow, ledger=self.ledger, injected_intent="collab_fields", ) self.assertEqual(phase, "fields_saved") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.collab_facts.get("是否含电"), "是") self.assertEqual(ticket.collab_facts.get("是否含磁"), "否") joined = "\n".join(self._texts()) self.assertIn("工单号:", joined) self.assertIn("询价字段:", joined) self.assertIn("是否含电:是", joined) self.assertIn("是否含磁:否", joined) self.assertNotIn("已记下", joined) self.assertTrue(any("@WuJiLin" in t and "@HangXian" in t for t in self._texts())) self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default") fill = dict(getattr(self.ledger, "last_render", {}).get("facts") or {}) self.assertEqual(fill.get("是否含电") or fill.get("has_battery"), "是") self.assertEqual(fill.get("是否含磁") or fill.get("has_magnet"), "否") texts = [i for i, x in enumerate(self.groups.sent) if x.get("kind") == "text" and "工单号:" in (x.get("content") or "")] files = [i for i, x in enumerate(self.groups.sent) if x.get("kind") == "file"] self.assertTrue(texts and files) self.assertLess(texts[-1], files[-1]) self.assertEqual(len(files), 2) def test_collab_after_activate_does_not_use_old_land_ticket(self) -> None: """ 测服 WO202609230023:空运进原群激活后补含电含磁, 不能写到同群更早的陆运单 WO202609230007,摘要也不能变成空字段。 """ land = self.ledger.create_ticket( sender_id="sales1", business_line="LAND", facts={ "运输类型": "国内运输整车", "始发站": "深圳", "目的站": "上海", "品名": "配件", }, )["work_order_no"] self.ledger.bind_collab_group( work_order_no=land, chat_id="wr_air_1", member_ids=["sales1"], ) air = self._create_air() handle_air_group( self._msg(sender="sales1", text=air, chat="wr_air_1", mid="mix0"), flow=self.flow, ledger=self.ledger, ) self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, air) self.assertEqual( str(self.ledger.get_ticket(work_order_no=land).collab_chat_id or ""), "", ) self.groups.sent.clear() phase = handle_air_group( self._msg(sender="sales1", text="含电 不含磁", chat="wr_air_1", mid="mix1"), flow=self.flow, ledger=self.ledger, injected_intent="collab_fields", ) self.assertEqual(phase, "fields_saved") air_ticket = self.ledger.get_ticket(work_order_no=air) land_ticket = self.ledger.get_ticket(work_order_no=land) self.assertEqual(air_ticket.collab_facts.get("是否含电"), "是") self.assertEqual(air_ticket.collab_facts.get("是否含磁"), "否") self.assertFalse(land_ticket.collab_facts.get("是否含电")) joined = "\n".join(self._texts()) self.assertIn(f"工单号:{air}", joined) self.assertNotIn(f"工单号:{land}", joined) self.assertIn("起运地:上海", joined) self.assertIn("重量(KG):100", joined) self.assertNotIn("起运地:-", joined) def test_sales_quote_rejected(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="q0"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( self._msg(sender="sales1", text="空运费 100USD", chat="wr_air_1", mid="q1"), flow=self.flow, ledger=self.ledger, injected_intent="product_quote", ) self.assertEqual(phase, "not_airline") def test_airline_quote_asks_tms_choice(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="air1", text=wo, chat="wr_air_1", mid="t0"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="t1"), flow=self.flow, ledger=self.ledger, injected_intent="product_quote", injected_quote={"total": "USD 900", "source_label": "航线报价"}, ) self.assertEqual(phase, "wait_tms_choice") self.assertTrue(any("在 TMS 基础上改" in t or "在TMS基础上改" in t for t in self._texts())) ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.quote.get("total"), "USD 800") self.assertNotEqual(ticket.quote.get("source_label"), "航线报价") phase = handle_air_group( self._msg(sender="air1", text="在TMS基础上改", chat="wr_air_1", mid="t2"), flow=self.flow, ledger=self.ledger, injected_intent="quote_keep_tms", ) self.assertEqual(phase, "quoted") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.quote.get("total"), "USD 900") self.assertEqual(ticket.quote.get("quoteId"), "QT-AIR-1") self.assertEqual(ticket.quote.get("source_label"), "航线在TMS上改") self.assertFalse(ticket.collab_facts.get("__pending_air_quote")) joined = "\n".join(self._texts()) self.assertIn("工单号:", joined) self.assertIn("询价字段:", joined) self.assertIn("人工报价:", joined) self.assertIn("报价单已发送,请确认成交状态", joined) self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default") self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent)) def test_airline_quote_without_tms_sends_brief_sheet_deal(self) -> None: wo = self._create_air(quote={}, status="询价中") handle_air_group( self._msg(sender="air1", text=wo, chat="wr_air_1", mid="nq0"), 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="nq1"), flow=self.flow, ledger=self.ledger, injected_intent="product_quote", injected_quote={"total": "USD 900", "source_label": "航线报价"}, ) self.assertEqual(phase, "quoted") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.event_wait_kind, "deal") self.assertTrue(ticket.event_wait_started_at) self.assertEqual(ticket.status, "已报价") self.assertEqual(ticket.quote.get("total"), "USD 900") joined = "\n".join(self._texts()) self.assertIn("工单号:", joined) self.assertIn("询价字段:", joined) self.assertIn("TMS 暂无匹配报价", joined) self.assertIn("人工报价:", joined) self.assertIn("报价单已发送,请确认成交状态", joined) self.assertNotIn("航线报价已记下", joined) self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default") self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent)) def test_quote_same_sentence_saves_battery(self) -> None: wo = self._create_air(quote={}, status="询价中") handle_air_group( self._msg(sender="air1", text=wo, chat="wr_air_1", mid="qs0"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( self._msg(sender="air1", text="含电 空运费900USD", chat="wr_air_1", mid="qs1"), flow=self.flow, ledger=self.ledger, injected_intent="product_quote", injected_quote={"total": "USD 900", "source_label": "航线报价"}, ) self.assertEqual(phase, "quoted") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.collab_facts.get("是否含电"), "是") self.assertIn("是否含电:是", "\n".join(self._texts())) def test_confirm_tms_saves_sheet_and_deal(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="air1", text=wo, chat="wr_air_1", mid="ct0"), flow=self.flow, ledger=self.ledger, ) self.groups.sent.clear() phase = handle_air_group( self._msg(sender="air1", text="确认报价", chat="wr_air_1", mid="ct1"), flow=self.flow, ledger=self.ledger, injected_intent="quote_confirm_tms", ) self.assertEqual(phase, "quote_confirmed") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.quote.get("source_label"), "航线确认TMS") self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts())) self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts())) def test_stranger_cannot_fill_fields(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="sf0"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( self._msg(sender="other1", text="含电 不含磁", chat="wr_air_1", mid="sf1"), flow=self.flow, ledger=self.ledger, injected_intent="collab_fields", ) self.assertEqual(phase, "fields_denied") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertFalse(ticket.collab_facts.get("是否含电")) def test_sales_deal(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="c0"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="c1"), flow=self.flow, ledger=self.ledger, injected_intent="deal_won", ) self.assertEqual(phase, "done") self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已成交") def test_lock_requires_work_order(self) -> None: phase = handle_air_group( self._msg(sender="air1", text="锁舱", chat="wr_air_1", mid="l0"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) self.assertEqual(phase, "cabin_need_work_order") self.assertTrue(any("带上工单号" in t for t in self._texts())) def test_lock_without_tms_quote(self) -> None: wo = self._create_air(quote={}, status="询价中") phase = handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l1"), 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.assertTrue(any("舱位选项:AIR-OPT-01" in t for t in self._texts())) self.assertFalse(any("还没有可锁的 TMS 报价" in t for t in self._texts())) def test_manual_quote_without_quote_id_can_lock(self) -> None: wo = self._create_air( quote={ "total": "100", "source": "manual", "source_label": "产品报价", }, status="已报价", ) phase = handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_manual", mid="lm1"), 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())) def test_lock_success_and_release_after_deal_blocked(self) -> None: wo = self._create_air() phase = handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l2"), 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())) handle_air_group( self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="l3"), flow=self.flow, ledger=self.ledger, injected_intent="deal_won", ) phase = handle_air_group( self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="l4"), flow=self.flow, ledger=self.ledger, injected_intent="air_release", ) self.assertEqual(phase, "release_deal_blocked") self.assertTrue(any("已成交" in t for t in self._texts())) def test_lost_can_release(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="r0"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) handle_air_group( self._msg(sender="sales1", text="未成交", chat="wr_air_1", mid="r1"), flow=self.flow, ledger=self.ledger, injected_intent="deal_lost", ) phase = handle_air_group( self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="r2"), flow=self.flow, ledger=self.ledger, injected_intent="air_release", ) self.assertEqual(phase, "release_ok") def test_sales_cannot_lock(self) -> None: wo = self._create_air() phase = handle_air_group( self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s1"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) self.assertEqual(phase, "not_airline") def test_dual_role_can_lock(self) -> None: self.ledger.upsert_staff( { "name": "WuJiLin", "wecomId": "sales1", "roleCode": "air", "status": "active", } ) wo = self._create_air() phase = handle_air_group( self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s2"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) self.assertEqual(phase, "lock_ok") def test_multi_option_asks(self) -> None: wo = self._create_air( quote={ "total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-2", "airOptions": [ {"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}, {"optionNo": "AIR-OPT-02", "airlineCode": "MU"}, ], } ) phase = handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="m1"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) self.assertEqual(phase, "wait_lock_option") phase = handle_air_group( self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"), flow=self.flow, ledger=self.ledger, injected_intent="air_pick_option", ) self.assertEqual(phase, "lock_ok") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-02") def _file(self, *, sender: str, filename: str, chat: str, mid: str, text: str = "") -> InboundMessage: return InboundMessage( sender_id=sender, message_id=mid, content=text, msg_type="file", chat_id=chat, chat_type="group", media={"filename": filename, "media_id": f"mid-{mid}"}, raw={"source": "aibot"}, ) def test_file_needs_work_order(self) -> None: phase = handle_air_group( self._file(sender="air1", filename="航线报价.xlsx", chat="wr_air_1", mid="af0"), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "need_work_order") def test_sales_excel_archived_not_quote(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="af1"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( self._file(sender="sales1", filename="对账单.xlsx", chat="wr_air_1", mid="af2"), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "file_archived") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.attachments[0]["file_name"], "对账单.xlsx") self.assertNotEqual(ticket.quote.get("source_label"), "航线附件报价") self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts())) def test_airline_excel_quotes_and_keeps_tms_id(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af3"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( self._file(sender="air1", filename="航线报价.pdf", chat="wr_air_1", mid="af4"), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "airline_file_quote") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.attachments[0]["file_name"], "航线报价.pdf") self.assertEqual(ticket.quote.get("source_label"), "航线附件报价") self.assertEqual(ticket.quote.get("quoteId"), "QT-AIR-1") self.assertEqual(ticket.status, "已报价") self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts())) self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts())) def test_image_rejected(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af5"), flow=self.flow, ledger=self.ledger, ) phase = handle_air_group( InboundMessage( sender_id="air1", message_id="af6", content="", msg_type="image", chat_id="wr_air_1", chat_type="group", media={"filename": "现场.jpg", "media_id": "mid-img"}, raw={"source": "aibot"}, ), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "file_type_rejected") self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments) def test_archive_file_without_at_quotes(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="air1", text=wo, chat="wr_air_1", mid="ar0"), flow=self.flow, ledger=self.ledger, ) msg = self._file(sender="air1", filename="航线报价.xlsx", chat="wr_air_1", mid="ar1") msg = InboundMessage( sender_id=msg.sender_id, message_id=msg.message_id, content=msg.content, msg_type="file", chat_id=msg.chat_id, chat_type="group", media=dict(msg.media), raw={"source": "wshoto_archive"}, ) import agent.handlers.air_group_collab as air_mod orig = air_mod.get_air_text_flow def _fake(): return self.flow air_mod.get_air_text_flow = _fake # type: ignore try: name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other")) finally: air_mod.get_air_text_flow = orig # type: ignore self.assertEqual(name, "air_group_collab") ticket = self.ledger.get_ticket(work_order_no=wo) self.assertEqual(ticket.quote.get("source_label"), "航线附件报价") self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts())) self.assertTrue(any("成交" in t or "未成交" in t or "协商" in t for t in self._texts())) def test_archive_text_ignored_until_at(self) -> None: wo = self._create_air() handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="ar2"), flow=self.flow, ledger=self.ledger, ) msg = self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="ar3") msg = InboundMessage( sender_id=msg.sender_id, message_id=msg.message_id, content=msg.content, chat_id=msg.chat_id, chat_type="group", raw={"source": "wshoto_archive"}, ) import agent.handlers.air_group_collab as air_mod orig = air_mod.get_air_text_flow def _fake(): return self.flow air_mod.get_air_text_flow = _fake # type: ignore try: name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other")) finally: air_mod.get_air_text_flow = orig # type: ignore self.assertEqual(name, "air_archive_text_ignore") self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已报价") def test_archive_at_work_order_activates(self) -> None: wo = self._create_air() msg = InboundMessage( sender_id="sales1", message_id="ar-at-1", content=f"@询价小助手 {wo}", chat_id="wr_air_new", chat_type="group", raw={"source": "wshoto_archive"}, ) import agent.handlers.air_group_collab as air_mod import agent.policy.air_text_flow as flow_mod orig = air_mod.get_air_text_flow orig2 = flow_mod.get_air_text_flow def _fake(): return self.flow air_mod.get_air_text_flow = _fake # type: ignore flow_mod.get_air_text_flow = _fake # type: ignore try: name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other")) finally: air_mod.get_air_text_flow = orig # type: ignore flow_mod.get_air_text_flow = orig2 # type: ignore self.assertEqual(name, "air_group_collab") self.assertTrue(any(wo in t for t in self._texts())) def test_file_with_work_order_activates(self) -> None: wo = self._create_air() phase = handle_air_group( self._file( sender="air1", filename="航线报价.xlsx", chat="wr_air_1", mid="af7", text=wo, ), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "airline_file_quote") self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo) def test_archive_same_sentence_different_msgid_sends_one_sheet(self) -> None: wo = self._create_air() phase = handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_dup2", mid="bot-mid-012"), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "activated") self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1) phase = handle_air_group( InboundMessage( sender_id="sales1", message_id="wshoto:archive-mid-999", content=f"@询价小助手 {wo}", chat_id="wr_air_dup2", chat_type="group", raw={"source": "wshoto_archive", "msgid": "archive-mid-999"}, ), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "duplicate_inbound") self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1) def test_aibot_and_archive_same_msgid_sends_one_sheet(self) -> None: wo = self._create_air() import agent.handlers.air_group_collab as air_mod import agent.policy.air_text_flow as flow_mod orig = air_mod.get_air_text_flow orig2 = flow_mod.get_air_text_flow def _fake(): return self.flow air_mod.get_air_text_flow = _fake # type: ignore flow_mod.get_air_text_flow = _fake # type: ignore try: aibot = self._msg(sender="sales1", text=wo, chat="wr_air_dup", mid="same-mid-011") name = dispatch_inbound(aibot, RouteDecision(intent="ordinary_text_other")) self.assertEqual(name, "air_group_collab") files_after_bot = sum(1 for x in self.groups.sent if x.get("kind") == "file") self.assertEqual(files_after_bot, 1) archive = InboundMessage( sender_id="sales1", message_id="wshoto:same-mid-011", content=f"@询价小助手 {wo}", chat_id="wr_air_dup", chat_type="group", raw={"source": "wshoto_archive", "msgid": "same-mid-011"}, ) name = dispatch_inbound(archive, RouteDecision(intent="ordinary_text_other")) self.assertEqual(name, "air_group_collab") self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1) finally: air_mod.get_air_text_flow = orig # type: ignore flow_mod.get_air_text_flow = orig2 # type: ignore def test_sea_ticket_rejected(self) -> None: created = self.ledger.create_ticket( sender_id="sales1", business_line="SEA", facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "配件", "货量": "1x40HQ", "整柜或拼柜": "整柜", "箱型箱量": "1x40HQ", "报价日期": "2026-09-16"}, ) wo = created["work_order_no"] phase = handle_air_group( self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="sea1"), flow=self.flow, ledger=self.ledger, ) self.assertEqual(phase, "not_air") def test_lock_fail_tells_operator_and_tms_reason(self) -> None: """锁舱失败群里要回操作人和 TMS 原因,不能被系统异常兜底盖掉。""" wo = self._create_air() 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_fail", mid="lf1"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) self.assertEqual(phase, "system_exception") joined = "\n".join(self._texts()) self.assertIn(f"工单{wo} 空运锁舱失败。", joined) self.assertIn("操作人:HangXian", joined) self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) self.assertIn("已通知IT运维排查,请稍等。", joined) self.assertNotIn("TMS接口异常,IT运维排查中", joined) self.assertNotIn("报价选项不存在", joined) self.assertNotIn("与工单不匹配", joined) def test_lock_without_quote_still_shows_tms_reason(self) -> None: """没有报价也可以锁舱;群里只回 TMS 原因,不要说选项不匹配。""" wo = self._create_air(quote={}, status="询价中") 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_no_quote", mid="lnq1"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) self.assertEqual(phase, "system_exception") joined = "\n".join(self._texts()) self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) self.assertNotIn("报价选项不存在", joined) self.assertNotIn("与工单不匹配", joined) def test_paused_lock_retries_and_shows_tms_reason(self) -> None: """上次锁舱失败标了系统异常后,再锁仍要打 TMS,不能只回兜底。""" wo = self._create_air() self.ledger.mark_system_exception( work_order_no=wo, system_exception="TMS", system_exception_reason="空运锁舱接口调用失败", step="tms_lock", payload={}, ) hits = {"n": 0} orig = self.ledger.lock_cabin def counted(**kwargs): hits["n"] += 1 return orig(**kwargs) self.ledger.lock_cabin = counted # type: ignore[method-assign] 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_paused", mid="lp1"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) self.assertNotEqual(phase, "system_exception_paused") self.assertGreaterEqual(hits["n"], 1) joined = "\n".join(self._texts()) self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) self.assertNotIn("TMS接口异常,IT运维排查中", joined) def test_release_fail_tells_operator_and_tms_reason(self) -> None: """释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。""" wo = self._create_air() handle_air_group( self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_rl_fail", mid="rf0"), flow=self.flow, ledger=self.ledger, injected_intent="air_lock", ) self.ledger._cabin_release_result = { "ok": False, "error": "release_fail", "failReason": "舱位不存在", } phase = handle_air_group( self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_rl_fail", mid="rf1"), flow=self.flow, ledger=self.ledger, injected_intent="air_release", ) self.assertEqual(phase, "system_exception") joined = "\n".join(self._texts()) self.assertIn(f"工单{wo} 空运释放舱位失败。", joined) self.assertIn("操作人:HangXian", joined) self.assertIn("释放舱位失败的原因:舱位不存在", joined) self.assertIn("已通知IT运维排查,请稍等。", joined) self.assertNotIn("TMS接口异常,IT运维排查中", joined) if __name__ == "__main__": unittest.main()