1651 lines
65 KiB
Python
1651 lines
65 KiB
Python
"""
|
||
空运文字询价主路径单测(不连企微 / 真实 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)
|