Files
inquiry_robot/inquiry-agent/tests/test_air_text_inquiry.py
T

1651 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
空运文字询价主路径单测(不连企微 / 真实 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 = (
"<xml><ToUserName>ww</ToUserName><FromUserName>WuJiLin</FromUserName>"
"<CreateTime>1</CreateTime><MsgType>event</MsgType>"
"<Event>template_card_event</Event>"
"<EventKey>skip_collab:WO202609140099</EventKey>"
"<TaskId>quote-WO202609140099</TaskId>"
"<ResponseCode>rc-wo-001</ResponseCode></xml>"
)
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)