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