""" 海运文字询价主路径单测(不连企微 / 真实 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 = ( "prod41" "filemid-x" "报价.xlsxwr_sea_1" ) 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()