""" 事件异常纯规则:超时编码、点名对象、卡片接收人(不打外网)。 """ 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.policy import inquiry_copy as copy from agent.policy.event_exception import ( WAIT_ADOPT, WAIT_AIR_QUOTE, WAIT_DEAL, WAIT_PRODUCT_QUOTE, conversation_of, format_timeout_hours, mentions_for_role, pick_app_targets, pick_mentions, timeout_code, ) class TestTimeoutCode(unittest.TestCase): def test_sales_unified(self) -> None: self.assertEqual( timeout_code(wait_kind=WAIT_ADOPT, business_line="SEA", transport_class="急件"), "sea_sales", ) self.assertEqual( timeout_code(wait_kind=WAIT_DEAL, business_line="AIR", transport_class=""), "air_sales", ) self.assertEqual( timeout_code(wait_kind="lost_reason", business_line="LAND", transport_class="急件"), "land_sales", ) def test_air_route(self) -> None: self.assertEqual( timeout_code(wait_kind=WAIT_AIR_QUOTE, business_line="AIR", transport_class=""), "air_route", ) def test_sea_and_land_product_class(self) -> None: self.assertEqual( timeout_code( wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class="Port to Port" ), "sea_product_ptp", ) self.assertEqual( timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class="其他"), "sea_product_other", ) self.assertEqual( timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="急件"), "land_hk_express", ) self.assertEqual( timeout_code( wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="普件(常用线路/常规车型)", ), "land_common", ) self.assertEqual( timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="常用线路"), "", ) self.assertEqual( timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class=""), "", ) def test_hours_format(self) -> None: self.assertEqual(format_timeout_hours(0.25), "0.25") self.assertEqual(format_timeout_hours(48), "48") class TestMentions(unittest.TestCase): def test_product_all_unanswered(self) -> None: sales = { "wecomId": "s1", "name": "销售甲", "roleCode": "sales", "notifyEventException": 1, } members = [ {"wecomId": "p1", "name": "张三", "roleCode": "sea", "notifyEventException": 1}, {"wecomId": "p2", "name": "李四", "roleCode": "sea", "notifyEventException": 0}, {"wecomId": "s1", "name": "销售甲", "roleCode": "sales", "notifyEventException": 1}, ] hit = pick_mentions( wait_kind=WAIT_PRODUCT_QUOTE, sales=sales, members=members, business_line="SEA", ) self.assertEqual([p["name"] for p in hit], ["张三", "李四"]) apps = pick_app_targets(hit) self.assertEqual([p["wecomId"] for p in apps], ["p1"]) def test_adopt_is_private(self) -> None: self.assertEqual(conversation_of(wait_kind=WAIT_ADOPT, collab_chat_id="wrxxx"), "private") def test_deal_uses_group_when_present(self) -> None: self.assertEqual(conversation_of(wait_kind=WAIT_DEAL, collab_chat_id="wrxxx"), "group") self.assertEqual(conversation_of(wait_kind=WAIT_DEAL, collab_chat_id=""), "private") class TestMemoryEventWait(unittest.TestCase): def test_same_kind_keeps_start(self) -> None: from agent.ledger.memory_ledger import MemoryLedger book = MemoryLedger() created = book.create_ticket(sender_id="s1", business_line="SEA", facts={"运输分类": "其他"}) wo = created["work_order_no"] book.start_event_wait(work_order_no=wo, wait_kind="product_quote") first = book.get_ticket(work_order_no=wo).event_wait_started_at book.start_event_wait(work_order_no=wo, wait_kind="product_quote") self.assertEqual(book.get_ticket(work_order_no=wo).event_wait_started_at, first) def test_record_appends_and_alerts(self) -> None: from agent.ledger.memory_ledger import MemoryLedger book = MemoryLedger() created = book.create_ticket(sender_id="s1", business_line="AIR", facts={}) wo = created["work_order_no"] book.start_event_wait(work_order_no=wo, wait_kind="air_quote") book.record_event_exception(work_order_no=wo, event_exception="航线响应超时") book.record_event_exception(work_order_no=wo, event_exception="销售响应超时") t = book.get_ticket(work_order_no=wo) self.assertEqual(t.event_exception, "航线响应超时;销售响应超时") self.assertEqual(t.event_wait_alerted, 1) self.assertEqual(book.list_event_waits(), []) class TestEventExceptionCopy(unittest.TestCase): def test_group_air(self) -> None: text = copy.event_exception_session_text( exception_type="航线响应超时", work_order_no="WO202606180003", names=["张三"], hours=0.25, mention=True, ) self.assertEqual( text, "航线响应超时\n关联询价工单 WO202606180003。@张三已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。", ) def test_group_two_names(self) -> None: text = copy.event_exception_session_text( exception_type="产品响应超时", work_order_no="WO1", names=["张三", "李四"], hours=2, mention=True, ) self.assertIn("@张三 @李四已超过 2 工作小时", text) def test_private_sales_no_at(self) -> None: text = copy.event_exception_session_text( exception_type="销售响应超时", work_order_no="WO1", names=["王五"], hours=48, mention=False, ) self.assertIn("王五已超过 48 工作小时", text) self.assertNotIn("@王五", text) def test_test_prefix(self) -> None: """测服会话和应用卡片都不加 [test]。""" text = copy.event_exception_session_text( exception_type="销售响应超时", work_order_no="WO1", names=["王五"], hours=1, mention=False, test_prefix=True, ) self.assertFalse(text.startswith("[test]")) self.assertNotIn("[test]", text) payload = copy.event_exception_alert_payload( card_title="销售响应异常", work_order_no="WO1", hours=1, test_prefix=True, ) title = (payload.get("template_card") or {}).get("main_title", {}).get("title") or "" self.assertEqual(title, "销售响应异常") self.assertNotIn("[test]", title) def test_app_card(self) -> None: payload = copy.event_exception_alert_payload( card_title="销售响应异常", work_order_no="WO202609090199", hours=48, ) self.assertEqual(payload["wecom_app"], "notify") card = payload["template_card"] self.assertEqual(card["source"]["desc"], "询价机器人事件告警") self.assertEqual(card["main_title"]["title"], "销售响应异常") self.assertEqual(card["main_title"]["desc"], "关联询价工单 WO202609090199") self.assertIn("您已超过 48 工作小时无响应", card["sub_title_text"]) class TestPoll(unittest.TestCase): def test_fires_once_on_timeout(self) -> None: import importlib.util from datetime import datetime, timedelta from agent.ledger.memory_ledger import MemoryLedger from agent.policy.deal_followup import SHANGHAI # 钉在周五,避免周日跑单测时工作日折算为 0 now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI) path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py") spec = importlib.util.spec_from_file_location("ee_poll", path) self.assertIsNotNone(spec) self.assertIsNotNone(spec.loader) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) poll_event_exceptions = mod.poll_event_exceptions book = MemoryLedger() book.set_sea_staff( [ { "wecomId": "p1", "name": "张三", "roleCode": "sea", "notifyEventException": 1, "status": "active", } ] ) created = book.create_ticket(sender_id="s1", business_line="SEA", facts={"运输分类": "其他"}) wo = created["work_order_no"] book.start_event_wait(work_order_no=wo, wait_kind="product_quote") t = book.get_ticket(work_order_no=wo) t.collab_chat_id = "wr1" t.collab_member_ids = ["p1"] t.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S") book._timeouts["sea_product_other"] = 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 = poll_event_exceptions( ledger=book, now=now, send_group=send_group, notify_ready=False ) self.assertEqual(n, 1) self.assertEqual(book.get_ticket(work_order_no=wo).event_exception, "产品响应超时") self.assertTrue(groups) self.assertIn("@张三", groups[0]["content"]) n2 = poll_event_exceptions( ledger=book, now=now, send_group=send_group, notify_ready=False ) 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"] now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI) 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 = (now - 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=now, 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"}, ] ) now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI) 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 = (now - 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=now, 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=now, 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], ["吴季林"]) class TestDealOutcomeStopsTimeout(unittest.TestCase): """对应现网截图:协商中顺延 / 未成交已写原因后不应再发销售响应超时。""" def _poll_mod(self): import importlib.util path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py") spec = importlib.util.spec_from_file_location("ee_poll_deal_stop", path) self.assertIsNotNone(spec) self.assertIsNotNone(spec.loader) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def _book_with_deal_wait(self): from datetime import datetime, timedelta from agent.ledger.memory_ledger import MemoryLedger from agent.policy.deal_followup import SHANGHAI book = MemoryLedger() book.set_sea_staff( [ { "wecomId": "sales1", "name": "罗小华", "roleCode": "sales", "status": "active", } ] ) created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={}) wo = created["work_order_no"] book.transition(work_order_no=wo, to_status="已报价") book.start_event_wait(work_order_no=wo, wait_kind="deal") ticket = book.get_ticket(work_order_no=wo) ticket.collab_chat_id = "wr-deal" ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=72)).strftime( "%Y-%m-%d %H:%M:%S" ) book._timeouts["air_sales"] = 48 return book, wo def test_negotiate_clears_wait_no_timeout(self) -> None: from datetime import datetime from agent.policy.deal_followup import SHANGHAI from agent.policy.deal_outcome import finish_deal_outcome book, wo = self._book_with_deal_wait() result = finish_deal_outcome( ledger=book, work_order_no=wo, outcome="协商中", facts={}, quote={"total": "100"}, business_line="AIR", sales_name="罗小华", notify_ready=False, ) self.assertTrue(result.get("ok")) ticket = book.get_ticket(work_order_no=wo) self.assertEqual(ticket.status, "协商中") self.assertEqual(ticket.event_wait_kind, "") groups: list[dict] = [] n = self._poll_mod().poll_event_exceptions( ledger=book, now=datetime.now(SHANGHAI), send_group=lambda **kwargs: groups.append(kwargs) or True, notify_ready=False, ) self.assertEqual(n, 0) self.assertEqual(groups, []) def test_lost_with_reason_clears_wait_no_timeout(self) -> None: from datetime import datetime from agent.policy.deal_followup import SHANGHAI from agent.policy.deal_outcome import finish_deal_outcome book, wo = self._book_with_deal_wait() result = finish_deal_outcome( ledger=book, work_order_no=wo, outcome="未成交", facts={}, quote={"total": "100"}, business_line="AIR", sales_name="罗小华", lost_reason="客户不要了", notify_ready=False, ) self.assertTrue(result.get("ok")) self.assertEqual(result.get("phase"), "done") ticket = book.get_ticket(work_order_no=wo) self.assertEqual(ticket.status, "未成交") self.assertEqual(ticket.lost_reason, "客户不要了") self.assertEqual(ticket.event_wait_kind, "") groups: list[dict] = [] n = self._poll_mod().poll_event_exceptions( ledger=book, now=datetime.now(SHANGHAI), send_group=lambda **kwargs: groups.append(kwargs) or True, notify_ready=False, ) self.assertEqual(n, 0) self.assertEqual(groups, []) def test_stale_deal_wait_during_negotiate_is_skipped(self) -> None: """截图回归:协商中已顺延,即使库里仍留着陈旧 deal 等待,也不再发超时。""" from datetime import datetime from agent.policy.deal_followup import SHANGHAI from agent.policy.deal_outcome import finish_deal_outcome book, wo = self._book_with_deal_wait() ticket = book.get_ticket(work_order_no=wo) started = ticket.event_wait_started_at result = finish_deal_outcome( ledger=book, work_order_no=wo, outcome="协商中", facts={}, quote={"total": "100"}, business_line="AIR", sales_name="罗小华", notify_ready=False, ) self.assertTrue(result.get("ok")) ticket = book.get_ticket(work_order_no=wo) self.assertEqual(ticket.status, "协商中") self.assertTrue((ticket.next_followup_at or "").strip()) # 人为留下陈旧等待(模拟 clear/迁态未清干净),巡检必须跳过 ticket.event_wait_kind = "deal" ticket.event_wait_started_at = started ticket.event_wait_alerted = 0 groups: list[dict] = [] n = self._poll_mod().poll_event_exceptions( ledger=book, now=datetime.now(SHANGHAI), send_group=lambda **kwargs: groups.append(kwargs) or True, notify_ready=False, ) self.assertEqual(n, 0) self.assertEqual(groups, []) def test_stale_deal_wait_after_lost_reason_is_skipped(self) -> None: """截图回归:未成交原因已记,陈旧 deal 等待不再触发销售超时。""" from datetime import datetime from agent.policy.deal_followup import SHANGHAI from agent.policy.deal_outcome import finish_deal_outcome book, wo = self._book_with_deal_wait() ticket = book.get_ticket(work_order_no=wo) started = ticket.event_wait_started_at result = finish_deal_outcome( ledger=book, work_order_no=wo, outcome="未成交", facts={}, quote={"total": "100"}, business_line="AIR", sales_name="罗小华", lost_reason="客户不要了", notify_ready=False, ) self.assertTrue(result.get("ok")) ticket = book.get_ticket(work_order_no=wo) self.assertEqual(ticket.lost_reason, "客户不要了") ticket.event_wait_kind = "deal" ticket.event_wait_started_at = started ticket.event_wait_alerted = 0 groups: list[dict] = [] n = self._poll_mod().poll_event_exceptions( ledger=book, now=datetime.now(SHANGHAI), send_group=lambda **kwargs: groups.append(kwargs) or True, notify_ready=False, ) self.assertEqual(n, 0) self.assertEqual(groups, []) if __name__ == "__main__": unittest.main()