""" 空运文字询价主路径单测(不连企微 / 真实 TMS / 8180)。 覆盖:无运输方式、缺项补问、建单卡片、有价/无价、跳过协同、采用 Excel/PDF、成交。 """ from __future__ import annotations import json 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) os.environ.setdefault("YTD_ENV", "test") os.environ.setdefault("REDIS_BACKEND", "memory") os.environ.setdefault("CHECKPOINT_BACKEND", "memory") os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory") os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false") os.environ.setdefault("LLM_ALLOW_NETWORK", "false") os.environ.setdefault("LEDGER_BACKEND", "memory") from agent.channel.queue import MemoryMessageStore from agent.channel.wecom.models import InboundMessage from agent.handlers.text_inquiry import handle_text_inquiry from agent.ledger.memory_ledger import MemoryLedger from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import AirTextInquiryFlow from agent.schema.field_validate import validate_required_fields def _drive_until_quoted(flow, *, sender_id: str, reply, **kwargs) -> str: """必填齐时先出核对卡,再自动回确定。""" phase = flow.on_text(sender_id=sender_id, reply=reply, **kwargs) if phase == "wait_confirm": phase = flow.on_text(sender_id=sender_id, text="确定", reply=reply) return phase COMPLETE_AIR = { "起运港": "上海", "目的港": "洛杉矶", "品名": "普货", "件数": "3", "毛重": "180", "体积": "2.5", "包装方式": "纸箱", } class AirTextInquiryTests(unittest.TestCase): def setUp(self) -> None: from agent.redis_coord.runtime import shutdown_redis_runtime shutdown_redis_runtime() self.flow = AirTextInquiryFlow(MemoryLedger()) self.replies: list[str] = [] self.extras: list[dict] = [] def _reply(self, text: str, extra=None) -> None: self.replies.append(text) self.extras.append(dict(extra) if extra else {}) def _on_text(self, **kwargs): """单测默认:核对卡出现后自动回确定,对齐销售点确定后的旧断言。""" reply = kwargs.get("reply") or self._reply kwargs["reply"] = reply phase = self.flow.on_text(**kwargs) if phase == "wait_confirm": phase = self.flow.on_text( sender_id=kwargs.get("sender_id"), text="确定", reply=reply, ) return phase def test_clarify_shows_known_and_examples(self) -> None: text = copy.ask_clarify( facts={ "起运港": "珠海", "目的港": "克拉克", "originCode": "ZUH", "destinationCode": "CRK", "品名": "茶叶", "件数": "10", }, missing_keys=["毛重", "体积", "包装方式"], transport_mode="空运", ) self.assertIn("请确认并补充以下信息:", text) self.assertIn("运输方式:空运", text) self.assertIn("起运地:珠海", text) self.assertLess(text.index("起运地:珠海"), text.index("目的地:克拉克")) self.assertIn("目的地:克拉克", text) self.assertIn("品名:茶叶", text) self.assertIn("件数:10件", text) self.assertIn(copy.CLARIFY_PASTE_HINT, text) self.assertNotIn("待补充:", text) self.assertIn("1.重量(KG)(必填):\n(如:100)", text) self.assertIn("2.体积(CBM)(必填):\n(如:2)", text) self.assertIn("3.包装类型(必填):\n(如:托盘、散货)", text) self.assertNotIn("重量:(如:", text) self.assertIn("货源地(非必填):\n(如:东莞)", text) def test_inquiry_card_matches_sales_format(self) -> None: text = copy.inquiry_card( work_order_no="WO202609110080", facts={ "起运港": "珠海", "目的港": "克拉克", "originCode": "ZUH", "destinationCode": "CRK", "品名": "茶叶", "件数": "10", "毛重": "1", "体积": "8", "包装方式": "托盘", "报价日期": "2026-09-12", }, ) self.assertEqual( text, "\n".join( [ "工单号:WO202609110080", "运输方式:空运", "起运地:珠海", "目的地:克拉克", "品名:茶叶", "件数:10件", "重量(KG):1", "体积(CBM):8", "包装类型:托盘", "报价日期:2026-09-12", "工单已创建,正在查询TMS报价。", ] ), ) def test_quote_date_defaults_today_not_in_missing(self) -> None: result = validate_required_fields(facts=COMPLETE_AIR, business_line="AIR") self.assertTrue(result["ok"]) self.assertTrue(result["facts"]["报价日期"]) self.assertNotIn("报价日期", result["missing"]) def test_fly_is_air_and_followup_keeps_mode(self) -> None: phase = self._on_text( sender_id="u_fly", text="从南京飞吉隆坡,9月14日茶叶,托盘,4.2cbm,1.5KG", reply=self._reply, ) self.assertEqual(phase, "clarify") self.assertIn("运输方式:空运", self.replies[-1]) self.assertNotIn("请补充运输方式", self.replies[-1]) self.replies.clear() phase = self._on_text( sender_id="u_fly", text="10件", reply=self._reply, ) self.assertNotEqual(phase, "need_mode") joined = "\n".join(self.replies) self.assertNotIn("请补充运输方式", joined) self.assertEqual(self.flow.session_of("u_fly").business_line, "AIR") def test_worker_clarify_http_pallet_keeps_air(self) -> None: """识图在 Worker 写下补问,HTTP 补「托盘」必须仍是空运,不能再问运输方式。""" from agent.redis_coord.flow_bookmark import DictBookmarkStore store = DictBookmarkStore() worker = AirTextInquiryFlow(MemoryLedger(), bookmark_store=store) http = AirTextInquiryFlow(MemoryLedger(), bookmark_store=store) replies: list[str] = [] def reply(text: str, extra=None) -> None: replies.append(text) phase = worker.on_text( sender_id="u_img", text="空运 深圳到雅加达 发光二级管组件 11P 2400KG 20cbm", reply=reply, ) self.assertEqual(phase, "clarify") replies.clear() phase = http.on_text(sender_id="u_img", text="托盘", reply=reply) self.assertNotEqual(phase, "need_mode") self.assertNotIn("请补充运输方式", "\n".join(replies)) sess = http.session_of("u_img") self.assertIsNotNone(sess) self.assertEqual(sess.business_line, "AIR") self.assertEqual(sess.facts.get("包装方式"), "托盘") def test_missing_mode_asks_transport(self) -> None: phase = self._on_text( sender_id="u1", text="帮我看看价格", reply=self._reply, injected_facts={}, injected_mode="", ) self.assertEqual(phase, "need_mode") self.assertIn(copy.ASK_TRANSPORT, self.replies[-1]) self.assertIn("1.陆运", self.replies[-1]) self.assertIn("2.海运", self.replies[-1]) self.assertIn("3.空运", self.replies[-1]) def test_missing_mode_accepts_number_or_word(self) -> None: """未定运输方式时,回 3 或「空运」都走空运。""" self.flow.on_text( sender_id="u_mode_num", text="帮我看看价格", reply=self._reply, injected_facts={}, injected_mode="", ) phase = self.flow.on_text(sender_id="u_mode_num", text="3", reply=self._reply) self.assertNotEqual(phase, "need_mode") self.assertEqual(self.flow.session_of("u_mode_num").business_line, "AIR") self.flow.on_text( sender_id="u_mode_word", text="帮我看看价格", reply=self._reply, injected_facts={}, injected_mode="", ) phase = self.flow.on_text(sender_id="u_mode_word", text="空运", reply=self._reply) self.assertNotEqual(phase, "need_mode") self.assertEqual(self.flow.session_of("u_mode_word").business_line, "AIR") def test_pack_bulk_cargo_accepted_as_user_said(self) -> None: facts = dict(COMPLETE_AIR) facts["包装方式"] = "散货" phase = self._on_text( sender_id="u_bulk", text="空运", reply=self._reply, injected_facts=facts, injected_mode="AIR", ) self.assertNotEqual(phase, "clarify") joined = "\n".join(self.replies) self.assertIn("包装类型:散货", joined) self.assertIn("工单已创建,正在查询TMS报价", joined) self.assertEqual(self.flow.session_of("u_bulk").facts["包装方式"], "散货") def test_oral_kgs_cbm_not_asked_again(self) -> None: phase = self._on_text( sender_id="u_kgs", text="空运 茶叶 10件 100KGS 2CBM", reply=self._reply, injected_facts={ "起运港": "珠海", "目的港": "克拉克", "品名": "茶叶", "包装方式": "托盘", }, injected_mode="AIR", ) self.assertNotEqual(phase, "clarify") sess = self.flow.session_of("u_kgs") self.assertEqual(sess.facts["毛重"], "100KGS") self.assertEqual(sess.facts["体积"], "2CBM") self.assertEqual(sess.facts["originCode"], "ZUH") self.assertEqual(sess.facts["destinationCode"], "CRK") self.assertEqual(sess.facts["packageType"], "托盘") def test_missing_fields_prompt(self) -> None: phase = self._on_text( sender_id="u1", text="空运询价", reply=self._reply, injected_facts={"起运港": "上海"}, injected_mode="AIR", ) self.assertEqual(phase, "clarify") last = self.replies[-1] self.assertIn("请确认并补充以下信息", last) self.assertIn("运输方式:空运", last) self.assertIn("起运地:上海", last) self.assertIn(copy.CLARIFY_PASTE_HINT, last) self.assertIn("目的地", last) self.assertIn("件数", last) self.assertIn("如:", last) self.assertIn("报价日期:", last) self.assertNotIn("报价日期:(如", last) def test_clarify_paste_reply_fills_missing_required(self) -> None: """销售复制清单只填冒号后的值,空着的包装类型继续补问。""" phase = self.flow.on_text( sender_id="paste1", text="空运询价", reply=self._reply, injected_facts={ "起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶", "报价日期": "2026-09-17", }, injected_mode="AIR", ) self.assertEqual(phase, "clarify") phase = self.flow.on_text( sender_id="paste1", text=( "1.件数(必填):10\n" "(如:10)\n" "2.重量(KG)(必填):10\n" "(如:100)\n" "3.体积(CBM)(必填):10\n" "(如:2)\n" "4.包装类型(必填):\n" "(如:托盘、散货)" ), reply=self._reply, ) self.assertEqual(phase, "clarify") last = self.replies[-1] self.assertIn("件数:10件", last) self.assertIn("包装类型(必填)", last) self.assertNotIn("包装类型:托盘、散货", last) def test_complete_air_hit_then_skip_adopt_excel_deal(self) -> None: phase = self._on_text( sender_id="u1", text="空运完整", reply=self._reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) self.assertEqual(phase, "wait_collab") joined = "\n".join(self.replies) self.assertIn("工单号:", joined) self.assertIn("运输方式:空运", joined) self.assertIn("起运地:上海", joined) self.assertIn("目的地:洛杉矶", joined) self.assertIn("重量(KG):", joined) self.assertIn("正在查询TMS报价", joined) self.assertNotIn("【询价卡片】", joined) self.assertNotIn("[test]", joined) self.assertIn("已报价", self.flow.session_of("u1").status) self.assertIn(copy.BTN_SKIP_COLLAB, joined) self.assertIn("价格明细", joined) self.assertIn("费用合计", joined) self.assertNotIn("时效:", joined) self.assertIn("报价有效期:", joined) self.assertNotIn("相同询价内容检索", joined) self.assertNotIn("引用历史工单号", joined) self.assertNotIn("工单状态:已报价", joined) self.assertNotIn("【价格明细】", joined) extras: list[dict] = [] def _reply_keep(text: str, extra=None) -> None: self.replies.append(text) extras.append(dict(extra or {})) phase = self.flow.on_button( sender_id="u1", action=copy.BTN_SKIP_COLLAB, reply=_reply_keep, card_meta={"response_code": "rc-test-1"}, ) self.assertEqual(phase, "wait_adopt") self.assertIn("已跳过内部协同", "\n".join(self.replies)) self.assertTrue( any(x.get("msgtype") == "update_template_card" for x in extras) ) wo = self.flow.session_of("u1").work_order_no scheme_cards = [ x for x in extras if (x.get("template_card") or {}).get("main_title", {}).get("title") == f"{wo}报价方案" ] self.assertEqual(len(scheme_cards), 1) card = scheme_cards[0]["template_card"] self.assertEqual( card["quote_area"]["quote_text"], "费用合计:USD 1,280\n共2项费用,详见上一条明细", ) self.assertEqual(len(card["button_list"]), 3) self.assertEqual( [x["text"] for x in card["button_list"]], [copy.BTN_ADOPT_EXCEL_CARD, copy.BTN_ADOPT_PDF_CARD, copy.BTN_REJECT], ) rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} self.assertIn("2.5", rows["货物"]) self.assertNotIn("时效", rows) self.assertNotIn("sub_title_text", card) n = len(self.replies) again = self.flow.on_button( sender_id="u1", action=copy.BTN_SKIP_COLLAB, reply=self._reply ) self.assertEqual(again, "card_discard") self.assertEqual(len(self.replies), n) self.assertIn(copy.BTN_ADOPT_EXCEL_CARD, "\n".join(self.replies)) phase = self.flow.on_button( sender_id="u1", action=copy.BTN_ADOPT_EXCEL, reply=self._reply ) self.assertEqual(phase, "wait_deal") self.assertIn("Excel", "\n".join(self.replies)) self.assertIn("咨询成交状态", "\n".join(self.replies)) file_extras = [x for x in self.extras if x.get("msgtype") == "file"] self.assertTrue(file_extras) follow = file_extras[-1].get("followup_card") or {} self.assertTrue(str(follow.get("task_id") or "").startswith("deal-")) deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []} self.assertIn("费用合计", deal_rows) self.assertNotIn("时效", deal_rows) self.assertNotIn("历史报价", deal_rows) self.assertNotIn("工单状态", deal_rows) self.assertNotIn("来源", deal_rows) separate_deal = [ x for x in self.extras if x.get("msgtype") == "template_card" and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-") ] self.assertEqual(separate_deal, []) phase = self.flow.on_button( sender_id="u1", action=copy.BTN_DEAL, reply=self._reply, card_meta={"response_code": "rc-deal-1"}, ) self.assertEqual(phase, "done") deal_updates = [ x for x in self.extras if x.get("msgtype") == "update_template_card" and x.get("response_code") == "rc-deal-1" ] self.assertTrue(deal_updates) self.assertEqual(deal_updates[-1].get("button", {}).get("replace_name"), "已成交") ticket = self.flow.ledger.get_ticket( work_order_no=self.flow.session_of("u1").work_order_no ) self.assertEqual(ticket.status, "已成交") def test_city_name_counts_as_origin_dest(self) -> None: """起运地/目的地说了地名就算齐,不对销售再要三字码。""" facts = { "起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶", "件数": "10", "毛重": "1.5", "体积": "4.2", "包装方式": "散货", "报价日期": "2026-09-16", } phase = self._on_text( sender_id="u_nkg", text="南京飞吉隆坡,9月16日茶叶,散货,4.2cbm,1.5KG,10件", reply=self._reply, injected_facts=facts, injected_mode="AIR", ) self.assertNotEqual(phase, "clarify") joined = "\n".join(self.replies) self.assertIn("起运地:南京", joined) self.assertIn("目的地:吉隆坡", joined) self.assertIn("重量(KG):", joined) self.assertIn("体积(CBM):", joined) self.assertIn("包装类型:散货", joined) self.assertIn("报价日期:2026-09-16", joined) self.assertNotIn("重量(KG)", joined) self.assertNotIn("体积(CBM)", joined) self.assertNotIn("机场名或三字码", joined) def test_unknown_airport_still_queries_tms(self) -> None: """机场对不上也必须先打 query_tms,不能本地直接说暂无报价。""" called: list[dict] = [] class _Spy: def create_ticket(self, **kwargs): return { "ok": True, "work_order_no": "WO202609140088", "first_or_same": "first", } def query_tms(self, **kwargs): called.append(dict(kwargs)) return { "ok": True, "has_price": False, "classification": "TMS_NO_QUOTE_1002", } facts = dict(COMPLETE_AIR) facts["起运港"] = "阿拉伯" facts["目的港"] = "广州" flow = AirTextInquiryFlow(_Spy()) phase = _drive_until_quoted( flow, sender_id="u_arab", text="空运", reply=self._reply, injected_facts=facts, injected_mode="AIR", ) self.assertEqual(len(called), 1, "对不上机场也必须去 TMS 查价") self.assertEqual(called[0]["work_order_no"], "WO202609140088") self.assertEqual(called[0]["facts"].get("起运港"), "阿拉伯") self.assertFalse(called[0]["facts"].get("originCode")) self.assertEqual(phase, "tms_miss") self.assertTrue(any("暂无匹配报价" in x for x in self.replies)) self.assertIn("工单编号WO202609140088", self.replies[-1]) self.assertNotIn("查价参数尚未齐全", "\n".join(self.replies)) miss = [x for x in self.extras if x.get("msgtype") == "template_card"] self.assertTrue(miss) self.assertEqual( miss[-1]["template_card"]["main_title"]["title"], "询价工单 WO202609140088", ) def test_unknown_airport_no_card(self) -> None: facts = dict(COMPLETE_AIR) facts["目的港"] = "歧义港" phase = self._on_text( sender_id="u2c", text="空运", reply=self._reply, injected_facts=facts, injected_mode="AIR", ) # 目的地说了就算齐;对不上字典不补问,查价后按 TMS 分类 self.assertEqual(phase, "tms_miss") self.assertTrue(any("暂无匹配报价" in x for x in self.replies)) self.assertIn("工单编号", self.replies[-1]) def test_tms_miss_copy(self) -> None: facts = dict(COMPLETE_AIR) facts["tms_force"] = "1002" phase = self._on_text( sender_id="u2", text="空运", reply=self._reply, injected_facts=facts, injected_mode="AIR", ) self.assertEqual(phase, "tms_miss") wo = self.flow.session_of("u2").work_order_no self.assertEqual(self.replies[-1], copy.tms_miss_followup(work_order_no=wo)) self.assertTrue(any("暂无匹配报价" in x for x in self.replies)) miss = [x for x in self.extras if x.get("msgtype") == "template_card"] self.assertTrue(miss) card = miss[-1]["template_card"] self.assertEqual(card["card_type"], "text_notice") self.assertEqual(card["source"]["desc"], "TMS查价结果") self.assertEqual(card["main_title"]["title"], f"询价工单 {wo}") self.assertEqual(card["main_title"]["desc"], copy.tms_miss_hint()) self.assertEqual(card["sub_title_text"], copy.tms_miss_body()) rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} self.assertEqual(rows["工单状态"], "询价中") self.assertEqual(rows["查价结果"], "暂无匹配报价") self.assertNotIn("来源", rows) self.assertNotIn("button_list", card) self.assertEqual(self.flow.session_of("u2").allowed, ()) def test_inquiry_must_send_before_tms_query(self) -> None: """询价确认出站完成之前不能打 TMS,避免暂无报价卡抢先。""" import threading import time from unittest.mock import patch from agent.channel.queue import TaskState store = MemoryMessageStore() query_saw: list[str] = [] class _Led: def create_ticket(self, **kwargs): return { "ok": True, "work_order_no": "WO202609150111", "first_or_same": "first", } def query_tms(self, **kwargs): inq = [ i for i in store._outbox.values() if "正在查询TMS报价" in (i.content or "") ] query_saw.append(inq[0].state.value if inq else "missing") return { "ok": True, "has_price": False, "classification": "TMS_NO_QUOTE_1002", } def reply(text: str, extra=None) -> str: _ok, oid = store.enqueue_outbound( touser="u_ord", content=text, payload=dict(extra or {}), ) return oid stop = threading.Event() def pump() -> None: while not stop.is_set(): item = store.claim_outbound() if item: store.complete_outbound(item.id) else: time.sleep(0.01) worker = threading.Thread(target=pump, daemon=True) worker.start() try: with patch("agent.channel.queue.get_message_store", return_value=store): flow = AirTextInquiryFlow(_Led()) phase = _drive_until_quoted( flow, sender_id="u_ord", text="空运", reply=reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) finally: stop.set() self.assertEqual(phase, "tms_miss") self.assertEqual(query_saw, [TaskState.DONE.value]) def test_incomplete_skip_is_not_no_quote(self) -> None: """主账未出站不能对销售说暂无报价。""" class _Skip: def create_ticket(self, **kwargs): return { "ok": True, "work_order_no": "WO202609140099", "first_or_same": "first", } def query_tms(self, **kwargs): return { "ok": False, "has_price": False, "classification": "TMS_QUERY_INCOMPLETE", } flow = AirTextInquiryFlow(_Skip()) phase = _drive_until_quoted( flow, sender_id="u_skip", text="空运", reply=self._reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) self.assertEqual(phase, "tms_tech") joined = "\n".join(self.replies) self.assertIn("TMS 查询失败", joined) self.assertNotIn("暂无报价", joined) self.assertNotIn("查价参数尚未齐全", joined) def test_same_content_second_ticket(self) -> None: self._on_text( sender_id="u3", text="第一次", reply=self._reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) no1 = self.flow.session_of("u3").work_order_no self.replies.clear() self._on_text( sender_id="u4", text="第二次相同", reply=self._reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) no2 = self.flow.session_of("u4").work_order_no self.assertIn("工单已创建,正在查询TMS报价", "\n".join(self.replies)) self.assertNotIn("相同询价内容检索", "\n".join(self.replies)) self.assertNotIn("引用历史工单号", "\n".join(self.replies)) self.assertEqual(self.flow.session_of("u4").history_work_order_no, no1) self.assertTrue(no1) self.assertTrue(no2) self.assertNotEqual(no1, no2) def test_reject_then_adjust_prompt(self) -> None: self._on_text( sender_id="u5", text="空运", reply=self._reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) self.flow.on_button(sender_id="u5", action=copy.BTN_SKIP_COLLAB, reply=self._reply) phase = self.flow.on_button(sender_id="u5", action=copy.BTN_REJECT, reply=self._reply) self.assertEqual(phase, "wait_adjust") self.assertIn("提货费加 10%", self.replies[-1]) def test_private_adjust_percent_refreshes_card(self) -> None: self._on_text( sender_id="u-adj", text="空运", reply=self._reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) self.replies.clear() phase = self._on_text( sender_id="u-adj", text="空运费加 10%", reply=self._reply, ) self.assertEqual(phase, "wait_collab") joined = "\n".join(self.replies) self.assertIn("已识别调价指令", joined) self.assertIn("空运费", joined) sess = self.flow.session_of("u-adj") self.assertEqual(sess.status, "已报价") rows = sess.quote.get("fee_rows") or sess.quote.get("fee_lines") or [] air = next(x for x in rows if "空运费" in str(x.get("name") or "")) self.assertIn("1320", str(air.get("amount") or "")) self.replies.clear() self.flow.on_button(sender_id="u-adj", action=copy.BTN_SKIP_COLLAB, reply=self._reply) scheme = "\n".join(self.replies) self.assertIn("1320", scheme) def test_adopt_pdf(self) -> None: self._on_text( sender_id="u6", text="空运", reply=self._reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) self.flow.on_button(sender_id="u6", action=copy.BTN_SKIP_COLLAB, reply=self._reply) phase = self.flow.on_button( sender_id="u6", action=copy.BTN_ADOPT_PDF, reply=self._reply ) self.assertEqual(phase, "wait_deal") self.assertIn("PDF", "\n".join(self.replies)) def test_handler_writes_outbox(self) -> None: store = MemoryMessageStore() msg = InboundMessage( sender_id="u7", message_id="m7", content="空运", msg_type="text", ) phase = handle_text_inquiry( msg, injected_facts=COMPLETE_AIR, injected_mode="AIR", flow=self.flow, store=store, ) if phase == "wait_confirm": phase = handle_text_inquiry( InboundMessage( sender_id="u7", message_id="m7b", content="确定", msg_type="text", ), flow=self.flow, store=store, ) self.assertEqual(phase, "wait_collab") pending = [i for i in store._outbox.values()] self.assertGreaterEqual(len(pending), 2) self.assertTrue(any("工单已创建,正在查询TMS报价" in i.content for i in pending)) self.assertFalse(any("[test]" in i.content for i in pending)) self.assertFalse(any("【询价卡片】" in i.content for i in pending)) def test_wrong_button_discarded(self) -> None: self._on_text( sender_id="u8", text="空运", reply=self._reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) n = len(self.replies) phase = self.flow.on_button( sender_id="u8", action=copy.BTN_ADOPT_EXCEL, reply=self._reply ) self.assertEqual(phase, "card_discard") self.assertEqual(len(self.replies), n) def test_create_fail_keeps_weight_and_replies(self) -> None: class _FailCreate: def create_ticket(self, **kwargs): return {"ok": False, "work_order_no": "", "first_or_same": "first"} flow = AirTextInquiryFlow(_FailCreate()) replies: list[str] = [] phase = _drive_until_quoted( flow, sender_id="u9", text="空运", reply=replies.append, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) self.assertEqual(phase, "ledger_fail") self.assertTrue(any("建单失败" in x for x in replies)) self.assertEqual(flow.session_of("u9").facts["毛重"], "180") def test_tms_miss_card_matches_sales_layout(self) -> None: payload = copy.tms_miss_wecom_payload(work_order_no="WO202606160008") card = payload["template_card"] self.assertEqual(card["card_type"], "text_notice") self.assertEqual(card["source"]["desc"], "TMS查价结果") self.assertEqual(card["main_title"]["title"], "询价工单 WO202606160008") self.assertEqual(card["main_title"]["desc"], "TMS 标准价库暂无匹配报价") self.assertEqual(card["sub_title_text"], "需求已解析并创建工单。") rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} self.assertEqual(rows, { "工单状态": "询价中", "查价结果": "暂无匹配报价", }) self.assertNotIn("来源", rows) self.assertNotIn("button_list", card) self.assertNotIn("quote_area", card) self.assertNotIn("card_action", card) with_url = copy.tms_miss_wecom_payload( work_order_no="WO202606160008", action_url="https://example.test", )["template_card"] self.assertEqual(with_url["card_action"]["url"], "https://example.test") self.assertEqual( copy.tms_miss(work_order_no="WO202606160008"), "询价工单WO202606160008已建立,TMS标准价库暂无匹配报价。", ) self.assertEqual( copy.tms_miss_followup(work_order_no="WO202606160008"), "请手动拉相关人员和智能体到原有群,并在群聊发送工单编号WO202606160008", ) def test_quote_card_payload_has_clickable_button(self) -> None: payload = copy.tms_hit_wecom_payload( work_order_no="WO202609140001", quote={ "total": "USD 1,280", "eta": "3-5 天", "valid_until": "2026-09-20", "source_label": "TMS 标准报价", "fee_lines": [ {"name": "空运费", "amount": "USD 1,200"}, {"name": "其他附加费", "amount": "USD 80"}, ], }, ) card = payload["template_card"] self.assertEqual(payload["msgtype"], "template_card") self.assertEqual(card["card_type"], "button_interaction") self.assertEqual(card["main_title"]["title"], "WO202609140001价格明细") self.assertEqual(card["button_list"][0]["text"], copy.BTN_SKIP_COLLAB) self.assertEqual(card["button_list"][0]["key"], "skip_collab:WO202609140001") self.assertTrue(str(card["task_id"]).startswith("quote-WO202609140001-")) self.assertEqual( copy.work_order_from_card_meta({"task_id": card["task_id"]}), "WO202609140001", ) self.assertNotIn("horizontal_content_list", card) quote_text = card["quote_area"]["quote_text"] self.assertNotIn("title", card["quote_area"]) self.assertEqual( quote_text, "费用合计:USD 1,280\n共2项费用,详见上一条明细", ) self.assertNotIn("时效:", quote_text) numeric = copy.tms_hit_wecom_payload( work_order_no="WO202609140048", quote={ "total": "14669.60", "currency": "CNY", "eta": "-", "valid_until": "2026-09-15", "fee_lines": [{"name": "空运费", "amount": "14669.60", "currency": "CNY"}], }, )["template_card"]["quote_area"]["quote_text"] self.assertEqual( numeric, "费用合计:CNY 14669.60\n共1项费用,详见上一条明细", ) self.assertEqual(copy.money_text("14669.60", ""), "14669.60") self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text) sub = card["sub_title_text"] self.assertNotIn("时效", sub) self.assertIn("报价有效期:2026-09-20", sub) self.assertNotIn("相同询价内容检索", sub) 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: """空运多项费用跟海运一样:完整明细走文本,卡片引用条不得半截。""" long_quote = { "total": "142998.53", "currency": "CNY", "eta": "3天", "valid_until": "2026-09-30", "source_label": "TMS标准报价", "fee_lines": [ {"name": "操作费", "amount": "1220.00", "currency": "CNY"}, {"name": "箱单费", "amount": "50.00", "currency": "CNY"}, {"name": "文件费", "amount": "450.00", "currency": "CNY"}, {"name": "电放费", "amount": "100.00", "currency": "CNY"}, {"name": "更改费", "amount": "450.00", "currency": "CNY"}, {"name": "空运费", "amount": "1250.00", "currency": "USD"}, {"name": "目的港操作费", "amount": "240.00", "currency": "USD"}, ], } text = copy.sea_quote_detail_text( work_order_no="WO202609220005", quote=long_quote, business_line="AIR", ) self.assertIn("TMS标准报价", text) self.assertIn("WO202609220005价格明细", text) self.assertIn("操作费:CNY 1220.00", text) self.assertIn("空运费:USD 1250.00", text) self.assertIn("费用合计:CNY 142998.53", text) self.assertNotIn("时效", text) self.assertIn("报价有效期:2026-09-30", text) card_text = copy.quote_area_safe_text(long_quote, business_line="AIR") self.assertIn("费用合计:CNY 142998.53", card_text) self.assertIn("详见上一条明细", card_text) self.assertNotIn("操作费:CNY 1220.00", card_text) payload = copy.tms_hit_wecom_payload( work_order_no="WO202609220005", quote=long_quote ) self.assertEqual( payload["template_card"]["quote_area"]["quote_text"], card_text ) def test_air_quote_area_always_summary_and_transfer_on_detail_and_scheme(self) -> None: """ 空运引用条即使只有 3 项也不能塞费用,企微仍会裁成半截。 明细和方案卡航线必须带中转三字码。 """ quote = { "total": "183370", "currency": "CNY", "source_label": "TMS标准报价", "selected_air_option": { "originAirportCode": "ZUH", "transferAirportCode": "CAN", "destinationAirportCode": "CRK", }, "fee_lines": [ {"name": "运输费", "amount": "11.0", "currency": "CNY"}, {"name": "提送货费", "amount": "6.0", "currency": "CNY"}, {"name": "人工费", "amount": "123.0", "currency": "CNY"}, ], } card_text = copy.quote_area_safe_text(quote, business_line="AIR") self.assertEqual( card_text, "费用合计:CNY 183370\n共3项费用,详见上一条明细", ) payload = copy.tms_hit_wecom_payload( work_order_no="WO202609220006", quote=quote ) self.assertEqual(payload["template_card"]["quote_area"]["quote_text"], card_text) self.assertNotIn("运输费", card_text) detail = copy.sea_quote_detail_text( work_order_no="WO202609220006", quote=quote, business_line="AIR", ) self.assertIn("ZUH-CAN-CRK", detail) self.assertIn("运输费:CNY 11.0", detail) self.assertIn("费用合计:CNY 183370", detail) rows = { x["keyname"]: x["value"] for x in copy.scheme_horizontal_rows( work_order_no="WO202609220006", facts={"起运港": "珠海", "目的港": "克拉克", "品名": "茶叶"}, quote=quote, transport_mode="空运", ) } self.assertEqual(rows["航线"], "空运ZUH→CAN→CRK") scheme = copy.scheme_wecom_payload( work_order_no="WO202609220006", facts={"起运港": "珠海", "目的港": "克拉克", "品名": "茶叶"}, quote=quote, transport_mode="空运", ) self.assertEqual( scheme["template_card"]["quote_area"]["quote_text"], "费用合计:CNY 183370\n共3项费用,详见上一条明细", ) def test_air_hit_sends_detail_text_before_card(self) -> None: """有价必须先发出价格明细文字,再发卡,避免卡上半截费用。""" steps: list[str] = [] def reply(text: str, extra=None) -> str: kind = "card" if extra else "text" steps.append(kind) return f"out-{len(steps)}" def wait(out_id: str, *, work_order_no: str, what: str) -> None: steps.append(f"wait:{out_id}") flow = AirTextInquiryFlow(MemoryLedger()) flow._wait_outbound_sent = wait # type: ignore[method-assign] flow._detail_before_card_gap_sec = 0.0 flow._emit_air_hit( reply, work_order_no="WO202609220005", quote={ "total": "142998.53", "currency": "CNY", "source_label": "TMS标准报价", "fee_lines": [{"name": "操作费", "amount": "1220", "currency": "CNY"}], }, ) self.assertEqual(steps[:3], ["text", "wait:out-1", "card"]) def test_scheme_card_payload_has_quote_and_buttons(self) -> None: quote = { "total": "USD 1,280", "eta": "3-5 天", "valid_until": "2026-09-20", "source_label": "TMS 标准报价", "fee_lines": [ {"name": "空运费", "amount": "USD 1,200"}, {"name": "其他附加费", "amount": "USD 80"}, ], } text = copy.scheme_card( work_order_no="WO202609140001", status="已报价", facts=COMPLETE_AIR, quote=quote, ) self.assertIn("【WO202609140001报价方案】", text) self.assertIn("工单号:WO202609140001", text) self.assertIn("起运地:上海", text) self.assertIn("体积(CBM):2.5", text) self.assertIn("空运费:USD 1,200", text) self.assertNotIn("时效", text) self.assertIn("报价有效期:2026-09-20", text) self.assertNotIn("报价日期:", text) self.assertNotIn("工单状态", text) payload = copy.scheme_wecom_payload( work_order_no="WO202609140001", facts=COMPLETE_AIR, quote=quote ) card = payload["template_card"] self.assertEqual(card["main_title"]["title"], "WO202609140001报价方案") self.assertTrue(str(card["task_id"]).startswith("scheme-WO202609140001-")) again = copy.scheme_wecom_payload( work_order_no="WO202609140001", facts=COMPLETE_AIR, quote=quote ) self.assertNotEqual(card["task_id"], again["template_card"]["task_id"]) self.assertEqual( copy.work_order_from_card_meta({"task_id": card["task_id"]}), "WO202609140001", ) quote_text = card["quote_area"]["quote_text"] self.assertNotIn("title", card["quote_area"]) self.assertEqual( quote_text, "费用合计:USD 1,280\n共2项费用,详见上一条明细", ) self.assertNotIn("sub_title_text", card) rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} self.assertEqual(rows["工单号"], "WO202609140001") self.assertEqual(rows["航线"], "空运SHA→LAX") self.assertIn("普货", rows["货物"]) self.assertIn("2.5", rows["货物"]) self.assertNotIn("时效", rows) self.assertEqual(rows["报价有效期"], "2026-09-20") self.assertNotIn("报价日期", rows) empty_eta = copy.scheme_horizontal_rows( work_order_no="WO1", facts=COMPLETE_AIR, quote={"valid_until": "2026-09-20"} ) self.assertNotIn("时效", {x["keyname"] for x in empty_eta}) dashed = copy.scheme_horizontal_rows( work_order_no="WO1", facts=COMPLETE_AIR, quote={"eta": "-3-5 天", "valid_until": "2026-09-20"}, ) dashed_rows = {x["keyname"]: x["value"] for x in dashed} self.assertNotIn("时效", dashed_rows) self.assertEqual(len(card["button_list"]), 3) self.assertEqual( [x["text"] for x in card["button_list"]], ["生成Excel", "生成PDF", "不采用"], ) self.assertEqual( copy.work_order_from_card_meta( { "task_id": "scheme-WO202609140001", "event_key": "adopt_excel:WO202609140001", } ), "WO202609140001", ) self.assertEqual(copy.canonical_button("adopt_excel:WO202609140001"), copy.BTN_ADOPT_EXCEL) self.assertEqual(copy.canonical_button(copy.BTN_ADOPT_EXCEL_CARD), copy.BTN_ADOPT_EXCEL) def test_deal_card_matches_followup_layout(self) -> None: quote = { "total": "722.5", "currency": "CNY", "eta": "2天", "eta_date": "2026-08-23", "valid_until": "2026-08-24", "source_label": "TMS 标准报价", } text = copy.deal_card(work_order_no="WO202608210017", quote=quote, business_line="AIR") self.assertIn("询价工单 WO202608210017", text) self.assertIn("报价单已发送,请跟进成交状态", text) self.assertIn("咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中。", text) self.assertIn("费用合计:CNY 722.5", text) self.assertNotIn("时效", text) self.assertNotIn("- / -", text) self.assertIn("报价有效期:2026-08-24", text) self.assertNotIn("历史报价", text) self.assertNotIn("工单状态", text) self.assertNotIn("来源:", text) self.assertNotIn("总成本价合计", text) payload = copy.deal_wecom_payload(work_order_no="WO202608210017", quote=quote, business_line="AIR") card = payload["template_card"] self.assertEqual(payload["msgtype"], "template_card") self.assertEqual(card["card_type"], "button_interaction") self.assertEqual(card["source"]["desc"], "TMS标准报价") self.assertEqual(card["main_title"]["title"], "询价工单 WO202608210017") self.assertEqual(card["main_title"]["desc"], "报价单已发送,请跟进成交状态") self.assertEqual(card["sub_title_text"], copy.DEAL_HINT) rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} self.assertEqual( rows, { "费用合计": "CNY 722.5", "报价有效期": "2026-08-24", }, ) self.assertNotIn("历史报价", rows) self.assertNotIn("工单状态", rows) self.assertNotIn("来源", rows) self.assertEqual( [x["text"] for x in card["button_list"]], [copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE], ) self.assertEqual(card["button_list"][0]["key"], "deal:WO202608210017") self.assertTrue(str(card["task_id"]).startswith("deal-WO202608210017-")) self.assertEqual( copy.work_order_from_card_meta({"event_key": "lost:WO202608210017"}), "WO202608210017", ) self.assertEqual(copy.canonical_button("deal:WO202608210017"), copy.BTN_DEAL) self.assertEqual(copy.canonical_button("negotiate:WO202608210017"), copy.BTN_NEGOTIATE) empty_rows = { x["keyname"]: x["value"] for x in copy.deal_horizontal_rows( quote={"total": "-", "valid_until": "-"}, business_line="AIR", ) } self.assertNotIn("时效", empty_rows) self.assertEqual(empty_rows["报价有效期"], "-") def test_same_demand_cites_latest_even_if_closed(self) -> None: ledger = MemoryLedger() flow = AirTextInquiryFlow(ledger) replies_a: list[str] = [] def _keep_a(text: str, extra=None) -> None: replies_a.append(text) _ = extra _drive_until_quoted( flow, sender_id="hist-a", text="空运", reply=_keep_a, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) wo1 = flow.session_of("hist-a").work_order_no self.assertTrue(wo1) ledger.transition(work_order_no=wo1, to_status="已关闭") replies_b: list[str] = [] def _keep_b(text: str, extra=None) -> None: replies_b.append(text) _ = extra _drive_until_quoted( flow, sender_id="hist-b", text="空运", reply=_keep_b, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) wo2 = flow.session_of("hist-b").work_order_no self.assertTrue(wo2) self.assertNotEqual(wo2, wo1) self.assertEqual(flow.session_of("hist-b").history_work_order_no, wo1) self.assertNotIn("相同询价内容检索", "\n".join(replies_b)) self.assertNotIn("引用历史工单号", "\n".join(replies_b)) replies_c: list[str] = [] def _keep_c(text: str, extra=None) -> None: replies_c.append(text) _ = extra _drive_until_quoted( flow, sender_id="hist-c", text="空运", reply=_keep_c, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) wo3 = flow.session_of("hist-c").work_order_no self.assertNotEqual(wo3, wo2) self.assertEqual(flow.session_of("hist-c").history_work_order_no, wo2) self.assertNotEqual(flow.session_of("hist-c").history_work_order_no, wo1) self.assertNotIn("相同询价内容检索", "\n".join(replies_c)) self.assertNotIn("引用历史工单号", "\n".join(replies_c)) def test_card_event_key_matches_skip_button(self) -> None: from agent.channel.wecom.parse import parse_plain_xml xml = ( "wwWuJiLin" "1event" "template_card_event" "skip_collab:WO202609140099" "quote-WO202609140099" "rc-wo-001" ) msg = parse_plain_xml(xml, default_agent_id="1000010") self.assertEqual(msg.sender_id, "WuJiLin") self.assertEqual(msg.content, "skip_collab:WO202609140099") self.assertEqual(msg.raw.get("response_code"), "rc-wo-001") self.assertEqual(msg.raw.get("task_id"), "quote-WO202609140099") self.assertEqual(copy.canonical_button(msg.content), copy.BTN_SKIP_COLLAB) self.assertEqual( copy.work_order_from_card_meta(msg.raw, text=msg.content), "WO202609140099", ) self._on_text( sender_id="WuJiLin", text="空运", reply=self._reply, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) wo = self.flow.session_of("WuJiLin").work_order_no xml_live = xml.replace("WO202609140099", wo) live = parse_plain_xml(xml_live, default_agent_id="1000010") phase = self._on_text( sender_id="WuJiLin", text=live.content, reply=self._reply, inbound_raw=live.raw, ) self.assertEqual(phase, "wait_adopt") def test_old_price_card_uses_that_ticket_not_latest(self) -> None: """同一人连问两单,点第一张价格卡必须出第一单的方案,不能落到最新单。""" facts_a = dict(COMPLETE_AIR) facts_a["品名"] = "茶叶" facts_b = dict(COMPLETE_AIR) facts_b["品名"] = "服装" self._on_text( sender_id="card-owner", text="空运", reply=self._reply, injected_facts=facts_a, injected_mode="AIR", ) wo1 = self.flow.session_of("card-owner").work_order_no self.assertTrue(wo1) self._on_text( sender_id="card-owner", text="空运", reply=self._reply, injected_facts=facts_b, injected_mode="AIR", ) wo2 = self.flow.session_of("card-owner").work_order_no self.assertTrue(wo2) self.assertNotEqual(wo1, wo2) self.replies.clear() phase = self._on_text( sender_id="card-owner", text=f"skip_collab:{wo1}", reply=self._reply, inbound_raw={ "event": "template_card_event", "event_key": f"skip_collab:{wo1}", "task_id": f"quote-{wo1}", "response_code": "rc-old-card", }, ) self.assertEqual(phase, "wait_adopt") scheme = "\n".join(x for x in self.replies if "报价方案" in x) self.assertIn(wo1, scheme) self.assertIn("茶叶", scheme) self.assertNotIn(wo2, scheme) self.assertNotIn("服装", scheme) self.assertEqual(self.flow.session_of("card-owner").work_order_no, wo1) def test_second_ticket_skip_still_greys_card(self) -> None: """两张价格卡都要点「不需要协同」并置灰,去重键不能只按按钮文案。""" from agent.handlers.card_action import handle_card_action store = MemoryMessageStore() ledger = MemoryLedger() flow = AirTextInquiryFlow(ledger) def _open(uid: str) -> str: _drive_until_quoted( flow, sender_id=uid, text="空运", reply=lambda *_a, **_k: None, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) return flow.session_of(uid).work_order_no wo1 = _open("ua") wo2 = _open("ua") handle_card_action( sender_id="ua", thread_id="", wait_version=0, action=copy.BTN_SKIP_COLLAB, payload={"response_code": "rc-aaa", "task_id": f"quote-{wo1}-x"}, flow=flow, store=store, ) handle_card_action( sender_id="ua", thread_id="", wait_version=0, action=copy.BTN_SKIP_COLLAB, payload={"response_code": "rc-bbb", "task_id": f"quote-{wo2}-y"}, flow=flow, store=store, ) updates = [ item for item in store._outbox.values() if (item.payload or {}).get("msgtype") == "update_template_card" ] self.assertEqual(len(updates), 2) def test_adopt_excel_without_template_stays_wait_adopt(self) -> None: ledger = MemoryLedger() flow = AirTextInquiryFlow(ledger) replies: list[str] = [] _drive_until_quoted( flow, sender_id="u-tpl-0", text="空运", reply=lambda t, extra=None: replies.append(t), injected_facts=COMPLETE_AIR, injected_mode="AIR", ) flow.on_button( sender_id="u-tpl-0", action=copy.BTN_SKIP_COLLAB, reply=lambda t, extra=None: replies.append(t), ) ledger.set_quote_templates([]) phase = flow.on_button( sender_id="u-tpl-0", action=copy.BTN_ADOPT_EXCEL, reply=lambda t, extra=None: replies.append(t), ) self.assertEqual(phase, "wait_adopt") self.assertIn(copy.QUOTE_TEMPLATE_MISSING, "\n".join(replies)) self.assertNotIn("咨询成交状态", "\n".join(replies)) self.assertEqual(flow.session_of("u-tpl-0").phase, "wait_adopt") def test_adopt_excel_keyword_then_default(self) -> None: ledger = MemoryLedger() ledger.set_quote_templates( [ { "templateId": "d1", "name": "默认空运", "bizType": "空运", "keywords": ["欧洲"], "isDefault": True, }, { "templateId": "k1", "name": "洛杉矶专线", "bizType": "空运", "keywords": ["洛杉矶"], "isDefault": False, }, ] ) flow = AirTextInquiryFlow(ledger) replies: list[str] = [] _drive_until_quoted( flow, sender_id="u-tpl-k", text="空运", reply=lambda t, extra=None: replies.append(t), injected_facts=COMPLETE_AIR, injected_mode="AIR", ) flow.on_button( sender_id="u-tpl-k", action=copy.BTN_SKIP_COLLAB, reply=lambda t, extra=None: replies.append(t), ) phase = flow.on_button( sender_id="u-tpl-k", action=copy.BTN_ADOPT_EXCEL, reply=lambda t, extra=None: replies.append(t), ) self.assertEqual(phase, "wait_deal") self.assertEqual(ledger.last_render.get("matchType"), "keyword") self.assertEqual(ledger.last_render.get("templateName"), "洛杉矶专线") self.assertIn("咨询成交状态", "\n".join(replies)) ny = dict(COMPLETE_AIR) ny["目的港"] = "纽约" flow2 = AirTextInquiryFlow(ledger) replies2: list[str] = [] _drive_until_quoted( flow2, sender_id="u-tpl-d", text="空运", reply=lambda t, extra=None: replies2.append(t), injected_facts=ny, injected_mode="AIR", ) flow2.on_button( sender_id="u-tpl-d", action=copy.BTN_SKIP_COLLAB, reply=lambda t, extra=None: replies2.append(t), ) phase2 = flow2.on_button( sender_id="u-tpl-d", action=copy.BTN_ADOPT_EXCEL, reply=lambda t, extra=None: replies2.append(t), ) self.assertEqual(phase2, "wait_deal") self.assertEqual(ledger.last_render.get("matchType"), "default") self.assertEqual(ledger.last_render.get("templateName"), "默认空运") def test_pdf_queue_does_not_emit_deal_before_file(self) -> None: """PDF 还在 LibreOffice 队列时,不能先出成交跟进卡。""" ledger = MemoryLedger() ledger.set_quote_templates( [{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}] ) orig = ledger.render_quote def _render(**kwargs): out = orig(**kwargs) out["needsPdf"] = True return out ledger.render_quote = _render # type: ignore[method-assign] flow = AirTextInquiryFlow(ledger) replies: list[str] = [] _drive_until_quoted( flow, sender_id="u-pdf-q", text="空运", reply=lambda t, extra=None: replies.append(t), injected_facts=COMPLETE_AIR, injected_mode="AIR", ) flow.on_button( sender_id="u-pdf-q", action=copy.BTN_SKIP_COLLAB, reply=lambda t, extra=None: replies.append(t), ) flow._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign] phase = flow.on_button( sender_id="u-pdf-q", action=copy.BTN_ADOPT_PDF, reply=lambda t, extra=None: replies.append(t), ) self.assertEqual(phase, "wait_file") self.assertNotIn("咨询成交状态", "\n".join(replies)) self.assertEqual(flow.session_of("u-pdf-q").phase, "wait_file") again: list[str] = [] replay = flow._replay_wait(flow.session_of("u-pdf-q"), lambda t, extra=None: again.append(t), source="test") self.assertEqual(replay, "wait_file") self.assertIn("请稍候", "\n".join(again)) self.assertNotIn("咨询成交状态", "\n".join(again)) def test_worker_file_ready_unblocks_http_wait_file(self) -> None: """Worker 出完文件后 HTTP 进程必须能切到成交跟进,不能一直请稍候。""" ledger = MemoryLedger() ledger.set_quote_templates( [{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}] ) orig = ledger.render_quote def _render(**kwargs): out = orig(**kwargs) out["needsPdf"] = True return out ledger.render_quote = _render # type: ignore[method-assign] http = AirTextInquiryFlow(ledger) replies: list[str] = [] _drive_until_quoted( http, sender_id="u-pdf-x", text="空运", reply=lambda t, extra=None: replies.append(t), injected_facts=COMPLETE_AIR, injected_mode="AIR", ) http.on_button( sender_id="u-pdf-x", action=copy.BTN_SKIP_COLLAB, reply=lambda t, extra=None: replies.append(t), ) http._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign] http.on_button( sender_id="u-pdf-x", action=copy.BTN_ADOPT_PDF, reply=lambda t, extra=None: replies.append(t), ) self.assertEqual(http.session_of("u-pdf-x").phase, "wait_file") wo = http.session_of("u-pdf-x").work_order_no worker = AirTextInquiryFlow(ledger) worker.mark_wait_deal("u-pdf-x", wo) again: list[str] = [] replay = http._replay_wait( http.session_of("u-pdf-x"), lambda t, extra=None: again.append(t), source="test", ) self.assertEqual(replay, "wait_deal") 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: """成交卡已发出时,点成交不能因为书签还停在 wait_file 就没反应。""" ledger = MemoryLedger() ledger.set_quote_templates( [{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}] ) orig = ledger.render_quote def _render(**kwargs): out = orig(**kwargs) out["needsPdf"] = True return out ledger.render_quote = _render # type: ignore[method-assign] flow = AirTextInquiryFlow(ledger) _drive_until_quoted( flow, sender_id="u-pdf-d", text="空运", reply=lambda t, extra=None: None, injected_facts=COMPLETE_AIR, injected_mode="AIR", ) flow.on_button( sender_id="u-pdf-d", action=copy.BTN_SKIP_COLLAB, reply=lambda t, extra=None: None, ) flow._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign] flow.on_button( sender_id="u-pdf-d", action=copy.BTN_ADOPT_PDF, reply=lambda t, extra=None: None, ) self.assertEqual(flow.session_of("u-pdf-d").phase, "wait_file") extras: list[dict] = [] phase = flow.on_button( sender_id="u-pdf-d", action=copy.BTN_DEAL, reply=lambda t, extra=None: extras.append(dict(extra or {})), card_meta={"response_code": "rc-stuck-deal"}, ) self.assertEqual(phase, "done") self.assertEqual(flow.session_of("u-pdf-d").phase, "done") self.assertTrue(any(x.get("msgtype") == "update_template_card" for x in extras)) class FileThenDealOrderTests(unittest.TestCase): def test_send_outbound_sends_file_before_followup_card(self) -> None: """同一条出站必须先传文件再发成交卡。""" from agent.channel.outbox.sender import WeComAppClient, WeComSendResult order: list[str] = [] class _Client(WeComAppClient): def send_file(self, **kwargs): # type: ignore[override] order.append("file") return WeComSendResult(ok=True) def _post_message(self, body, preview=""): # type: ignore[override] order.append(str(preview or body.get("msgtype"))) return WeComSendResult(ok=True) client = _Client(corp_id="c", secret="s", agent_id=1) client._followup_gap_sec = 0 client.send_outbound( touser="u1", content="file+deal", payload={ "msgtype": "file", "filename": "WO1.xlsx", "file_b64": "QQ==", "followup_card": {"task_id": "deal-WO1"}, }, ) self.assertEqual(order, ["file", "[followup_card]"]) def test_wait_outbound_stays_until_claimed_item_finishes(self) -> None: """已被认领的出站必须等到发完,不能中途当成功。""" store = MemoryMessageStore() ok, oid = store.enqueue_outbound(touser="u1", content="报价文件") self.assertTrue(ok) claimed = store.claim_outbound() self.assertIsNotNone(claimed) done = store.wait_outbound_done(oid, timeout_sec=0.45) self.assertFalse(done)