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

1274 lines
48 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)。
覆盖:海运补问、询价确认卡、有价/无价卡、跳过协同、拉群、群里静默补协同字段。
空运话术不得出现在海运路径。
"""
from __future__ import annotations
import os
import sys
import unittest
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
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.wecom.models import InboundMessage
from agent.handlers.group_collab import handle_group_collab
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.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
from agent.schema.field_validate import validate_required_fields
from agent.schema.tms_sea_query import assemble_sea_query
COMPLETE_SEA = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
}
class FakeGroupClient:
"""单测假企微建群:不打外网。"""
def __init__(self) -> None:
self.created: list[dict] = []
self.sent: list[dict] = []
self.fail = False
self.next_chat_id = "wr_sea_1"
def create_group(self, *, name: str, userids: list[str]) -> dict:
if self.fail:
return {"ok": False, "error": "wecom_create_fail", "chat_id": ""}
self.created.append({"name": name, "userids": list(userids)})
return {"ok": True, "chat_id": self.next_chat_id, "error": ""}
def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict:
self.created.append({"name": "", "userids": list(userids), "chat_id": chat_id, "add": True})
return {"ok": True}
def send_group(self, *, chat_id: str, content: str, mention_userids: list | None = None) -> dict:
self.sent.append(
{
"chat_id": chat_id,
"content": content,
"kind": "text",
"mention_userids": list(mention_userids or []),
}
)
return {"ok": True}
def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = "") -> dict:
self.sent.append(
{"chat_id": chat_id, "filename": filename, "kind": "file", "file_b64": file_b64}
)
return {"ok": True}
def send_group_card(self, *, chat_id: str, template_card: dict) -> dict:
self.sent.append({"chat_id": chat_id, "kind": "card", "template_card": template_card})
return {"ok": True}
def dismiss_group(self, *, chat_id: str, userids: list) -> dict:
self.sent.append({"chat_id": chat_id, "kind": "dismiss", "userids": list(userids)})
return {"ok": True}
class SeaTextInquiryTests(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
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 test_sea_pieces_fill_cargo_qty(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"件数": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertTrue(check["ok"])
self.assertEqual(check["facts"]["货量"], "15件")
self.assertNotIn("货量", check["missing"])
def test_sea_missing_container_only_asks_container(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "茶叶",
"货量": "20吨",
"整柜或拼柜": "整柜",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertFalse(check["ok"])
self.assertEqual(check["missing"], ["箱型箱量"])
self.assertIn("箱型箱量", check["missing_display"])
self.assertNotIn("件数", check["missing"])
self.assertNotIn("毛重", check["missing"])
text = copy.ask_clarify(
facts=check["facts"],
missing_keys=check["missing"],
transport_mode="海运",
)
self.assertIn("运输方式:海运", text)
self.assertIn("起运港:上海", text)
self.assertIn("货物数量:20吨", text)
self.assertIn("箱型箱量:(如", text)
self.assertNotIn("件数", text)
self.assertNotIn("重量", text)
self.assertNotIn("报价日期:(如", text)
self.assertIn("报价日期:", text)
def test_complete_sea_inquiry_card_uses_seven_fields(self) -> None:
phase = self.flow.on_text(
sender_id="s1",
text="海运完整",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("运输方式:海运", joined)
self.assertIn("起运港:上海", joined)
self.assertIn("目的港:洛杉矶", joined)
self.assertIn("货物数量:20吨", joined)
self.assertIn("整柜或拼柜:整柜", joined)
self.assertIn("箱型箱量:1x40HQ", joined)
self.assertIn("正在查询TMS报价", joined)
self.assertNotIn("件数:", joined.split("正在查询TMS报价")[0])
self.assertNotIn("本轮先开通空运", joined)
sess = self.flow.session_of("s1")
self.assertEqual(sess.business_line, "SEA")
self.assertEqual(sess.status, "已报价")
def test_handler_routes_sea_not_air_deferred(self) -> None:
reset_sea_text_flow_for_test()
from agent.channel.queue import MemoryMessageStore
store = MemoryMessageStore()
msg = InboundMessage(
sender_id="s2",
message_id="m-sea-1",
content="海运询价",
)
phase = handle_text_inquiry(
msg,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
flow=self.flow,
store=store,
)
self.assertEqual(phase, "wait_collab")
self.assertNotEqual(phase, "sea_deferred")
class SeaTmsCardTests(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
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 test_assemble_sea_query_does_not_need_air_measures(self) -> None:
ready = assemble_sea_query(COMPLETE_SEA)
self.assertTrue(ready["ok"])
self.assertEqual(ready["missing"], [])
self.assertNotIn("件数", ready.get("missing") or [])
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
"""海运多项费用:完整明细必须在文本里,卡片引用条不得半截。"""
long_quote = {
"total": "2287.65",
"currency": "CNY",
"eta": "1天",
"valid_until": "2026-09-30",
"source_label": "TMS 标准报价",
"fee_lines": [
{"name": "海运费", "amount": "100.00", "currency": "USD"},
{"name": "燃油附加费", "amount": "15.00", "currency": "USD"},
{"name": "操作费", "amount": "680.00", "currency": "CNY"},
{"name": "文件费", "amount": "450.00", "currency": "CNY"},
{"name": "封条费(SEAI)", "amount": "50.00", "currency": "CNY"},
{"name": "设备费", "amount": "50.00", "currency": "CNY"},
{"name": "报关费", "amount": "250.00", "currency": "CNY"},
],
}
text = copy.sea_quote_detail_text(
work_order_no="WO202609150030", quote=long_quote
)
self.assertEqual(
text,
"\n".join(
[
"TMS标准报价",
"WO202609150030价格明细",
"海运费:USD 100.00",
"燃油附加费:USD 15.00",
"操作费:CNY 680.00",
"文件费:CNY 450.00",
"封条费(SEAI):CNY 50.00",
"设备费:CNY 50.00",
"报关费:CNY 250.00",
"总销售价合计:CNY 2287.65",
]
),
)
self.assertNotIn("时效", text)
self.assertNotIn("报价有效期", text)
card_text = copy.quote_area_safe_text(long_quote)
self.assertIn("总销售价合计:CNY 2287.65", card_text)
self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text)
self.assertLessEqual(len(card_text), 80)
payload = copy.sea_tms_hit_wecom_payload(
work_order_no="WO202609150030", quote=long_quote
)
self.assertEqual(
payload["template_card"]["quote_area"]["quote_text"], card_text
)
sub = payload["template_card"]["sub_title_text"]
self.assertIn("时效:1天", sub)
self.assertIn("报价有效期:2026-09-30", sub)
def test_sea_hit_card_has_price_and_two_buttons(self) -> None:
phase = self.flow.on_text(
sender_id="s1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("价格明细", joined)
self.assertIn("是否拉产品人员进群协同", joined)
self.assertIn(copy.BTN_PULL_COLLAB, joined)
self.assertIn(copy.BTN_SKIP_COLLAB, joined)
self.assertNotIn("请手动拉相关人员", joined)
self.assertNotIn("若需要咨询内部航线人员", joined)
cards = [x for x in self.extras if x.get("msgtype") == "template_card"]
self.assertTrue(cards)
buttons = [b["text"] for b in cards[-1]["template_card"]["button_list"]]
self.assertEqual(buttons, [copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB])
def test_sea_miss_one_card_with_pull_button(self) -> None:
facts = dict(COMPLETE_SEA)
facts["目的港"] = "无价港"
phase = self.flow.on_text(
sender_id="s1",
text="海运无价",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "tms_miss")
sess = self.flow.session_of("s1")
self.assertEqual(sess.status, "询价中")
joined = "\n".join(self.replies)
self.assertIn("暂无匹配报价", joined)
self.assertIn("询价中", joined)
self.assertIn("未查到合适报价", joined)
self.assertIn("是否需要拉内部产品人员进群", joined)
self.assertIn(copy.BTN_PULL_COLLAB, joined)
self.assertNotIn("请手动拉相关人员和智能体到原有群", joined)
cards = [x for x in self.extras if x.get("msgtype") == "template_card"]
self.assertTrue(cards)
buttons = [b["text"] for b in cards[-1]["template_card"].get("button_list") or []]
self.assertEqual(buttons, [copy.BTN_PULL_COLLAB])
def test_sea_tech_fail_is_not_miss(self) -> None:
facts = dict(COMPLETE_SEA)
facts["tms_force"] = "tech"
phase = self.flow.on_text(
sender_id="s1",
text="海运技术失败",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "tms_tech")
self.assertIn("TMS 查询失败", "\n".join(self.replies))
self.assertNotIn(copy.BTN_PULL_COLLAB, "\n".join(self.replies))
class SeaSkipAndPullTests(unittest.TestCase):
def setUp(self) -> None:
os.environ["WECOM_ARCHIVE_SEAT_USER_IDS"] = ""
from agent.config import get_settings
get_settings.cache_clear()
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
self.extras: list[dict] = []
def _reply(self, text: str, extra=None) -> str:
self.replies.append(text)
self.extras.append(dict(extra) if extra else {})
return ""
def _quote_first(self, sender: str = "s1") -> None:
self.flow.on_text(
sender_id=sender,
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
def test_skip_collab_reuses_air_scheme(self) -> None:
self._quote_first()
phase = self.flow.on_button(
sender_id="s1",
action=copy.BTN_SKIP_COLLAB,
reply=self._reply,
card_meta={"response_code": "rc-sea-1"},
)
self.assertEqual(phase, "wait_adopt")
self.assertIn("已跳过内部协同", "\n".join(self.replies))
scheme = "\n".join(x for x in self.replies if "报价方案" in x)
self.assertIn("运输方式:海运", scheme)
self.assertIn("货物数量:", scheme)
self.assertIn("整柜或拼柜:", scheme)
self.assertIn("箱型箱量:", scheme)
self.assertNotIn("件数:", scheme)
self.assertNotIn("重量:", scheme)
self.assertNotIn("体积:", scheme)
self.assertNotIn("包装类型:", scheme)
cards = [
x.get("template_card") or {}
for x in self.extras
if (x.get("template_card") or {}).get("main_title", {}).get("title", "").endswith("报价方案")
]
self.assertTrue(cards)
rows = {r["keyname"]: r["value"] for r in cards[0].get("horizontal_content_list") or []}
self.assertIn("海运", rows.get("航线", ""))
self.assertIn("箱型", rows)
self.assertNotIn("包装", rows)
again = self.flow.on_button(
sender_id="s1", action=copy.BTN_SKIP_COLLAB, reply=self._reply
)
self.assertEqual(again, "card_discard")
def test_pull_group_success_greys_buttons(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "张产品",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
self._quote_first()
wo = self.flow.session_of("s1").work_order_no
phase = self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
card_meta={"response_code": "rc-pull-1"},
)
self.assertEqual(phase, "collab_group")
joined = "\n".join(self.replies)
self.assertIn("协同群已创建", joined)
self.assertIn(wo, joined)
self.assertIn("销售", joined)
self.assertIn("产品张产品", joined)
self.assertEqual(self.groups.created[0]["name"], wo)
self.assertIn("prod1", self.groups.created[0]["userids"])
self.assertIn("s1", self.groups.created[0]["userids"])
self.assertNotIn("AI001", self.groups.created[0]["userids"])
self.assertTrue(self.groups.sent)
group_text = self.groups.sent[0]["content"]
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
self.assertIn(wo, group_text)
self.assertIn("起运港", group_text)
self.assertIn("贸易条款", group_text)
self.assertIn("商品海关编码", group_text)
self.assertNotIn("HS编码", group_text)
self.assertIn("TMS", group_text)
sess = self.flow.session_of("s1")
self.assertNotIn(copy.BTN_PULL_COLLAB, sess.allowed)
self.assertNotIn(copy.BTN_SKIP_COLLAB, sess.allowed)
again = self.flow.on_button(
sender_id="s1", action=copy.BTN_SKIP_COLLAB, reply=self._reply
)
self.assertEqual(again, "card_discard")
def test_pull_sends_sheet_when_client_not_injected(self) -> None:
"""测服正式路径不注入 group_client,拉群仍要发出填好的模板。"""
from unittest.mock import patch
live = FakeGroupClient()
self.ledger.set_sea_staff(
[
{
"name": "张产品",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
with patch("agent.policy.sea_text_flow._default_group_client", return_value=live):
flow = SeaTextInquiryFlow(self.ledger, group_client=None)
flow.on_text(
sender_id="s9",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
phase = flow.on_button(
sender_id="s9",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(phase, "collab_group")
self.assertTrue(any(x.get("kind") == "text" for x in live.sent))
self.assertTrue(any(x.get("kind") == "file" for x in live.sent))
def test_pull_group_includes_archive_bot(self) -> None:
self.ledger.upsert_staff(
{
"name": "询价机器人",
"wecomId": "AI001",
"roleCode": "sales",
"status": "active",
}
)
self.ledger.set_sea_staff(
[
{
"name": "张产品",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运全线",
"status": "active",
}
]
)
self._quote_first()
self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
userids = self.groups.created[0]["userids"]
self.assertIn("AI001", userids)
self.assertIn("s1", userids)
self.assertIn("prod1", userids)
joined = "\n".join(self.replies)
self.assertIn("产品张产品", joined)
self.assertNotIn("产品询价机器人", joined)
self.assertNotIn("销售询价机器人", joined)
def test_pull_no_staff_keeps_button(self) -> None:
self.ledger.set_sea_staff([])
self._quote_first()
phase = self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(phase, "wait_collab")
self.assertIn("没有可拉的产品人员", "\n".join(self.replies))
self.assertEqual(self.groups.created, [])
self.assertIn(copy.BTN_PULL_COLLAB, self.flow.session_of("s1").allowed)
def test_pull_already_has_group_no_second(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "李产品",
"wecomId": "prod2",
"roleCode": "sea",
"routes": "上海",
"status": "active",
}
]
)
self._quote_first()
self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
n = len(self.groups.created)
self.flow.session_of("s1").allowed = (copy.BTN_PULL_COLLAB,)
phase = self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(phase, "collab_group")
self.assertEqual(len(self.groups.created), n)
self.assertIn("群已在", "\n".join(self.replies))
def test_miss_group_message_writes_no_quote(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "王产品",
"wecomId": "prod3",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
facts = dict(COMPLETE_SEA)
facts["目的港"] = "无价港"
self.flow.on_text(
sender_id="s1",
text="海运无价",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertTrue(self.groups.sent)
group_text = self.groups.sent[0]["content"]
self.assertIn("TMS 暂无匹配报价", group_text)
self.assertNotIn("未查到合适报价", group_text)
self.assertIn("\n\n协同补充字段:\n", group_text)
self.assertIn("\n\nTMS报价:\n", group_text)
class SeaGroupCollabFieldTests(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
def _reply(self, text: str, extra=None) -> None:
self.replies.append(text)
def test_group_text_writes_incoterm_silently(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "赵产品",
"wecomId": "prod4",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
self.flow.on_text(
sender_id="sales1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
wo = self.flow.session_of("sales1").work_order_no
ticket = self.ledger.get_ticket(work_order_no=wo)
chat_id = ticket.collab_chat_id
n = len(self.replies)
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="g1",
content="贸易条款:FOB",
chat_id=chat_id,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "collab_field_saved")
self.assertEqual(len(self.replies), n)
self.assertEqual(ticket.collab_facts.get("贸易条款"), "FOB")
def test_group_price_chat_is_ignored(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "赵产品",
"wecomId": "prod4",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
self.flow.on_text(
sender_id="sales1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
wo = self.flow.session_of("sales1").work_order_no
ticket = self.ledger.get_ticket(work_order_no=wo)
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="g2",
content="报价 1200 美元可以做",
chat_id=ticket.collab_chat_id,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "product_quote_sent")
self.assertEqual(str(ticket.quote.get("total") or ""), "1200")
deal_texts = [x["content"] for x in self.groups.sent if x.get("kind") == "text" and "成交" in (x.get("content") or "")]
self.assertTrue(deal_texts)
class SeaContinueTests(unittest.TestCase):
def setUp(self) -> None:
from agent.redis_coord.runtime import shutdown_redis_runtime
shutdown_redis_runtime()
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.flow = SeaTextInquiryFlow(self.ledger)
self.replies: list[str] = []
self.extras: list[dict] = []
def _reply(self, text: str, extra=None) -> str:
self.replies.append(text)
self.extras.append(dict(extra) if extra else {})
return ""
def test_continue_after_skip_replays_sea_scheme_not_air(self) -> None:
"""已跳过协同后再续办:必须海运方案卡,不能套空运件数包装。"""
from unittest.mock import patch
from agent.channel.queue import MemoryMessageStore
from agent.handlers.continue_thread import handle_continue_thread
from agent.policy.air_text_flow import AirTextInquiryFlow
self.flow.on_text(
sender_id="c1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="c1",
action=copy.BTN_SKIP_COLLAB,
reply=self._reply,
card_meta={"response_code": "rc-cont-1"},
)
wo = self.flow.session_of("c1").work_order_no
store = MemoryMessageStore()
msg = InboundMessage(
sender_id="c1",
message_id="m-cont-sea",
content=f"继续工单{wo}",
)
air_trap = AirTextInquiryFlow(MemoryLedger())
with patch(
"agent.handlers.continue_thread.get_sea_text_flow", return_value=self.flow
), patch(
"agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap
):
phase = handle_continue_thread(msg, thread_id=wo, store=store)
self.assertEqual(phase, "wait_adopt")
cards = [
(item.payload or {}).get("template_card") or {}
for item in store._outbox.values()
]
titles = [
(c.get("main_title") or {}).get("title") or "" for c in cards if c
]
self.assertTrue(any(t == f"{wo}报价方案" for t in titles))
rows = {}
for card in cards:
if (card.get("main_title") or {}).get("title") == f"{wo}报价方案":
rows = {
r["keyname"]: r["value"]
for r in card.get("horizontal_content_list") or []
}
self.assertIn("海运", rows.get("航线", ""))
self.assertIn("箱型", rows)
self.assertNotIn("包装", rows)
def test_continue_from_ledger_uses_sea_collab_card(self) -> None:
"""进程无书签:主账海运已报价应回收海运协同卡,不是空运卡。"""
from unittest.mock import patch
from agent.channel.queue import MemoryMessageStore
from agent.handlers.continue_thread import handle_continue_thread
from agent.policy.air_text_flow import AirTextInquiryFlow
created = self.ledger.create_ticket(
sender_id="c2", business_line="SEA", facts=COMPLETE_SEA
)
wo = created["work_order_no"]
self.ledger.upsert_quote(
work_order_no=wo,
quote={
"total": "USD 1,200",
"eta": "18天",
"valid_until": "2026-09-20",
"source_label": "TMS 标准报价",
},
to_status="已报价",
)
empty = SeaTextInquiryFlow(self.ledger)
store = MemoryMessageStore()
msg = InboundMessage(
sender_id="c2",
message_id="m-cont-ledger",
content=f"继续工单{wo}",
)
air_trap = AirTextInquiryFlow(MemoryLedger())
with patch(
"agent.handlers.continue_thread.get_sea_text_flow", return_value=empty
), patch(
"agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap
):
phase = handle_continue_thread(msg, thread_id=wo, store=store)
self.assertEqual(phase, "wait_collab")
sess = empty.session_by_ticket("c2", wo)
self.assertIsNotNone(sess)
self.assertEqual(sess.business_line, "SEA")
joined = "\n".join(item.content for item in store._outbox.values())
self.assertIn(copy.BTN_PULL_COLLAB, joined)
self.assertIn("是否拉产品人员进群协同", joined)
self.assertNotIn("包装方式", joined)
def test_continue_after_quote_file_returns_deal(self) -> None:
"""报价单已发出后再续办:必须成交跟进,不能退回生成报价单。"""
from unittest.mock import patch
from agent.channel.queue import MemoryMessageStore
from agent.handlers.continue_thread import handle_continue_thread
from agent.policy.air_text_flow import AirTextInquiryFlow
self.flow.on_text(
sender_id="c3",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="c3",
action=copy.BTN_SKIP_COLLAB,
reply=self._reply,
card_meta={"response_code": "rc-deal-1"},
)
wo = self.flow.session_of("c3").work_order_no
self.flow.on_button(
sender_id="c3",
action=copy.BTN_ADOPT_EXCEL,
reply=self._reply,
card_meta={"event_key": f"adopt_excel:{wo}", "response_code": "rc-xl"},
)
self.assertEqual(self.flow.session_of("c3").phase, "wait_deal")
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-"))
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, [])
empty = SeaTextInquiryFlow(self.ledger)
store = MemoryMessageStore()
msg = InboundMessage(
sender_id="c3",
message_id="m-cont-deal",
content=f"继续工单{wo}",
)
air_trap = AirTextInquiryFlow(MemoryLedger())
with patch(
"agent.handlers.continue_thread.get_sea_text_flow", return_value=empty
), patch(
"agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap
):
phase = handle_continue_thread(msg, thread_id=wo, store=store)
self.assertEqual(phase, "wait_deal")
joined = "\n".join(item.content for item in store._outbox.values())
self.assertFalse(any(x in joined for x in (copy.BTN_DEAL, "成交")))
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
class SeaGroupCopyTests(unittest.TestCase):
def test_group_brief_uses_customs_code_and_section_breaks(self) -> None:
text = copy.sea_group_brief(
work_order_no="WO202609150023",
facts={
"起运港": "纽约港",
"目的港": "巴生西港",
"品名": "机器设备",
"货量": "15",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"报价日期": "2026-09-15",
},
quote=None,
collab_facts={},
sales_name="吴季林",
product_names=["罗小华"],
has_price=False,
)
self.assertIn("@吴季林 @罗小华", text)
self.assertIn("询价字段:\n运输方式:海运", text)
self.assertIn("\n\n协同补充字段:\n", text)
self.assertIn("商品海关编码:-", text)
self.assertNotIn("HS编码", text)
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
self.assertNotIn("未查到合适报价", text)
def test_scheme_card_uses_sea_fields(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"报价日期": "2026-09-15",
}
quote = {"total": "USD 1200", "eta": "18天", "valid_until": "2026-09-20"}
text = copy.scheme_card(
work_order_no="WO202609150021",
status="已报价",
facts=facts,
quote=quote,
transport_mode="海运",
)
self.assertIn("运输方式:海运", text)
self.assertIn("货物数量:15件", text)
self.assertIn("箱型箱量:1x40HQ", text)
self.assertNotIn("件数:", text)
self.assertNotIn("包装类型:", text)
rows = {
x["keyname"]: x["value"]
for x in copy.scheme_horizontal_rows(
work_order_no="WO202609150021",
facts=facts,
quote=quote,
transport_mode="海运",
)
}
self.assertIn("海运 上海→洛杉矶", rows["航线"])
self.assertIn("15件", rows["货物"])
self.assertIn("1x40HQ", rows["箱型"])
self.assertNotIn("包装", rows)
def test_invited_names_carry_role(self) -> None:
text = copy.sea_group_created(
work_order_no="WO202609150019",
sales_name="吴季林",
product_names=["罗小华"],
)
self.assertEqual(
text,
"协同群已创建,群名与工单号一致:WO202609150019。"
"已邀请:销售吴季林、产品罗小华。请在群聊中继续确认报价。",
)
def test_group_only_excel_pdf_copy(self) -> None:
self.assertEqual(
copy.group_only_excel_pdf(),
"群里只收 Excel 和 PDF,其它文件不会存入工单",
)
class SeaGroupOpsTests(unittest.TestCase):
"""群协同:附件类型、产品报价、解散后再拉、终态提醒。"""
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.ledger.set_sea_staff(
[
{
"name": "赵产品",
"wecomId": "prod4",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
self.ledger.upsert_staff({"name": "吴吉林", "wecomId": "sales1"})
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
def _reply(self, text: str, extra=None) -> None:
self.replies.append(text)
def _open_group(self) -> tuple[str, str]:
self.flow.on_text(
sender_id="sales1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
sess = self.flow.session_of("sales1")
return sess.work_order_no, sess.collab_chat_id
def test_group_rejects_non_excel_pdf(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="img1",
content="",
msg_type="image",
chat_id=chat,
chat_type="group",
media={"filename": "现场.jpg", "media_id": "mid-1"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "file_type_rejected")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertIn("群里只收 Excel 和 PDF,其它文件不会存入工单", texts)
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertFalse(ticket.attachments)
def test_sales_excel_is_archived_not_quote(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="xls1",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "对账单.xlsx", "media_id": "mid-2"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "file_archived")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.attachments[0]["file_name"], "对账单.xlsx")
self.assertNotEqual(ticket.quote.get("source_label"), "产品附件报价")
def test_product_excel_quotes_and_deal(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="xls2",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "产品报价.pdf", "media_id": "mid-3"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "product_file_quote")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.attachments[0]["file_name"], "产品报价.pdf")
self.assertEqual(ticket.status, "已报价")
self.assertTrue(any(x.get("kind") == "card" for x in self.groups.sent))
def test_terminal_file_speaks(self) -> None:
wo, chat = self._open_group()
self.ledger.transition(work_order_no=wo, to_status="已成交")
sess = self.flow.session_of("sales1")
sess.status = "已成交"
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="xls3",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "晚到.xlsx", "media_id": "mid-4"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "ticket_locked_file")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertTrue(any("不能再上传附件" in t for t in texts))
self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments)
def test_dissolve_then_cannot_recreate(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="dis1",
content="这个群解散吧",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="dissolve",
)
self.assertEqual(phase, "group_dissolved")
self.assertTrue(any(x.get("kind") == "dismiss" for x in self.groups.sent))
self.assertTrue(self.ledger.get_ticket(work_order_no=wo).collab_ended)
sess = self.flow.session_of("sales1")
sess.allowed = (copy.BTN_PULL_COLLAB,)
again = self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(again, "collab_ended")
self.assertIn("不能再拉群", "\n".join(self.replies))
def test_group_oral_keeps_inquiry_facts_when_snapshot_skinny(self) -> None:
wo, chat = self._open_group()
real_find = self.ledger.find_by_collab_chat
def skinny(*, chat_id: str):
ticket = real_find(chat_id=chat_id)
if ticket is None:
return None
return {
"found": True,
"work_order_no": ticket.work_order_no,
"sales_wecom_id": ticket.sales_wecom_id,
"collab_chat_id": ticket.collab_chat_id,
"collab_member_ids": list(ticket.collab_member_ids),
"collab_facts": dict(ticket.collab_facts),
}
self.ledger.find_by_collab_chat = skinny # type: ignore[method-assign]
self.flow._by_ticket.clear()
self.flow._by_sender.clear()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="oral-keep-facts-1",
content="不含电、不含油、随时可提",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="collab_fields",
)
self.assertEqual(phase, "collab_field_saved")
texts = "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text")
self.assertIn("上海", texts)
self.assertIn("洛杉矶", texts)
self.assertIn("随时可提", texts)
self.assertNotRegex(texts, r"起运港:\s*-")
self.assertIn("@吴吉林 @赵产品", texts)
self.assertNotRegex(texts, r"^@销售(\s|$)")
briefs = [
x
for x in self.groups.sent
if x.get("kind") == "text" and "询价字段" in (x.get("content") or "")
]
self.assertTrue(briefs)
self.assertIn("sales1", briefs[-1].get("mention_userids") or [])
self.assertIn("prod4", briefs[-1].get("mention_userids") or [])
self.assertNotIn("AI001", briefs[-1].get("mention_userids") or [])
def test_group_oral_oil_power_ready_is_saved(self) -> None:
wo, chat = self._open_group()
n = len([x for x in self.groups.sent if x.get("kind") == "text"])
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="oral-collab-1",
content="不含电、不含油、随时可提",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="collab_fields",
)
self.assertEqual(phase, "collab_field_saved")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("是否含电"), "否")
self.assertEqual(ticket.collab_facts.get("是否含油"), "否")
self.assertEqual(ticket.collab_facts.get("货好时间"), "随时可提")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertGreater(len(texts), n)
def test_collab_facts_are_projected_for_quote_sheet(self) -> None:
from agent.policy.quote_overlay import overlay_labeled_facts
from agent.policy.sea_group_ops import _facts_for_fill
from openpyxl import Workbook, load_workbook
filled = _facts_for_fill(
{"起运港": "DONGGUAN", "目的港": "海防"},
{"货好时间": "随时可提", "是否含油": "否", "是否含电": "否", "HS编码": "8479"},
)
self.assertEqual(filled["cargo_ready_date"], "随时可提")
self.assertEqual(filled["hs_code"], "8479")
self.assertIn("油:否", filled["oil_elec_mag"])
self.assertIn("电:否", filled["oil_elec_mag"])
import tempfile
from pathlib import Path
wb = Workbook()
ws = wb.active
ws["A1"] = "*货好时间/Cargo Ready Date:"
ws["A2"] = "*商品海关编码/HS Code:"
ws["A3"] = "*是否含油,电,磁/OIL,ELEC,MAG:"
path = str(Path(tempfile.mkdtemp(prefix="collab-sheet-")) / "src.xlsx")
wb.save(path)
out = overlay_labeled_facts(filepath=path, facts=filled)
painted = load_workbook(out["filepath"])
sheet = painted.active
self.assertEqual(sheet["B1"].value, "随时可提")
self.assertEqual(sheet["B2"].value, "8479")
self.assertIn("油:否", str(sheet["B3"].value or ""))
def test_stale_deal_card_speaks(self) -> None:
from agent.policy.sea_group_ops import explain_deal_click
self._open_group()
sess = self.flow.session_of("sales1")
sess.deal_version = 2
sess.collab_chat_id = sess.collab_chat_id or "wr_sea_1"
reason = explain_deal_click(
flow=self.flow,
sender_id="sales1",
action=copy.BTN_DEAL,
card_meta={"event_key": f"deal:{sess.work_order_no}:v1"},
sess=sess,
)
self.assertEqual(reason, "stale_deal")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertTrue(any("已过期" in t for t in texts))
def test_parse_group_file_fills_media(self) -> None:
from agent.channel.wecom.parse import parse_plain_xml
xml = (
"<xml><FromUserName>prod4</FromUserName><CreateTime>1</CreateTime>"
"<MsgType>file</MsgType><MediaId>mid-x</MediaId>"
"<FileName>报价.xlsx</FileName><ChatId>wr_sea_1</ChatId></xml>"
)
msg = parse_plain_xml(xml)
self.assertEqual(msg.chat_type, "group")
self.assertEqual(msg.chat_id, "wr_sea_1")
self.assertEqual(msg.media.get("filename"), "报价.xlsx")
self.assertEqual(msg.media.get("media_id"), "mid-x")
if __name__ == "__main__":
unittest.main()