落地海运私聊文字询价全流程,海运多项费用改走文本明细,避免企微卡片引用条截断。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 14:26:09 +08:00
co-authored by Cursor
parent 58b3c2f3e6
commit 0a3c74e3a1
41 changed files with 3702 additions and 48 deletions
@@ -45,6 +45,9 @@ COMPLETE_AIR = {
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] = []
@@ -41,6 +41,11 @@ COMPLETE_AIR = {
class ContinueTicketTests(unittest.TestCase):
def setUp(self) -> None:
from agent.redis_coord.runtime import shutdown_redis_runtime
shutdown_redis_runtime()
def test_model_contract_accepts_continue_intent(self) -> None:
digest = "a" * 64
parsed = parse_ordered_actions_arguments(
@@ -182,6 +187,36 @@ class ContinueTicketTests(unittest.TestCase):
self.assertEqual(phase, "wait_adopt")
self.assertTrue(any("报价方案" in x for x in replies))
def test_resume_after_quote_file_is_deal(self) -> None:
ledger = MemoryLedger()
created = ledger.create_ticket(
sender_id="u6", business_line="AIR", facts=COMPLETE_AIR
)
wo = created["work_order_no"]
ledger.upsert_quote(
work_order_no=wo,
quote={"total": "USD 1", "eta": "-", "valid_until": "-"},
to_status="已报价",
)
ledger.render_quote(
work_order_no=wo,
business_line="AIR",
facts=COMPLETE_AIR,
quote={"total": "USD 1"},
)
flow = AirTextInquiryFlow(ledger)
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
phase = flow.resume_historical(
sender_id="u6", work_order_no=wo, reply=reply
)
self.assertEqual(phase, "wait_deal")
self.assertTrue(any("成交" in x or copy.BTN_DEAL in x for x in replies))
def test_resume_missing_wo_asks(self) -> None:
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
@@ -53,6 +53,24 @@ class QuoteTemplateMatchTests(unittest.TestCase):
self.assertIsNone(hit)
self.assertIn("IT运维", FAIL_MESSAGE)
def test_broken_bracket_keyword_still_hits_sea(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "s1",
"name": "海运报价单模板",
"bizType": "海运",
"keywords": ["[海运,整柜,FCL]"],
"isDefault": False,
},
],
business_line="SEA",
facts={"整柜或拼柜": "整柜", "目的港": "巴生西港"},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.match_type, "keyword")
self.assertEqual(hit.name, "海运报价单模板")
def test_sea_ignored_for_air(self) -> None:
hit = pick_quote_template(
templates=[
@@ -0,0 +1,836 @@
"""
海运文字询价主路径单测(不连企微 / 真实 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) -> dict:
self.sent.append({"chat_id": chat_id, "content": content})
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="WO202609150029", quote=long_quote
)
self.assertIn("海运费:USD 100.00", text)
self.assertIn("报关费:CNY 250.00", text)
self.assertIn("总销售价合计:CNY 2287.65", 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="WO202609150029", quote=long_quote
)
self.assertEqual(
payload["template_card"]["quote_area"]["quote_text"], card_text
)
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.assertIn(wo, group_text)
self.assertIn("起运港", group_text)
self.assertIn("贸易条款", 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_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)
self.assertIn("暂无匹配报价", self.groups.sent[0]["content"])
class SeaGroupCollabFieldTests(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] = []
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, "group_ignore")
self.assertFalse(ticket.quote_version > 1 and "1200" in str(ticket.quote))
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")
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.assertTrue(any(x in joined for x in (copy.BTN_DEAL, "成交")))
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
class SeaGroupCopyTests(unittest.TestCase):
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。"
"已邀请:销售吴季林、产品罗小华。请在群聊中继续确认报价。",
)
if __name__ == "__main__":
unittest.main()