""" 陆运私聊:选项补齐 → 货物补问 → 无工单号核对卡。确定建单见后续用例。 不连企微 / 真实 TMS / 8180。 """ from __future__ import annotations import os import sys import unittest from datetime import datetime from zoneinfo import ZoneInfo 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.ledger.memory_ledger import MemoryLedger from agent.policy import inquiry_copy as copy from agent.policy.land_text_flow import LandTextInquiryFlow class CountingLedger(MemoryLedger): def __init__(self) -> None: super().__init__() self.create_calls = 0 self.last_created_no = "" def create_ticket(self, **kwargs): self.create_calls += 1 out = super().create_ticket(**kwargs) self.last_created_no = str(out.get("work_order_no") or "") return out class LandTextInquiryFrontTests(unittest.TestCase): def setUp(self) -> None: self.ledger = CountingLedger() self.flow = LandTextInquiryFlow(self.ledger) self.replies: list[str] = [] def _reply(self, text: str, extra=None) -> str: self.replies.append(text) return "" def _joined(self) -> str: return "\n".join(self.replies) def test_all_options_missing_lists_sheet(self) -> None: phase = self.flow.on_text( sender_id="u1", text="陆运广州到深圳衣服", reply=self._reply, injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"}, injected_mode="LAND", ) self.assertEqual(phase, "need_land_options") self.assertEqual(self.ledger.create_calls, 0) text = self._joined() self.assertIn("国内运输拼车", text) self.assertIn("线路类别", text) self.assertIn("运输分类", text) self.assertNotIn("请补充陆运类型:", text) def test_known_type_does_not_reask_type(self) -> None: phase = self.flow.on_text( sender_id="u1", text="广州到深圳国内拼车衣服100方", reply=self._reply, injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"}, injected_mode="LAND", ) self.assertEqual(phase, "need_land_options") self.assertNotIn("请选择运输类型", self._joined()) self.assertIn("国内长途/零担", self._joined()) def test_wrong_route_keeps_type(self) -> None: phase = self.flow.on_text( sender_id="u1", text="国内运输拼车,线路东南亚", reply=self._reply, injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"}, injected_mode="LAND", ) self.assertEqual(phase, "need_land_options") self.assertIn("国内长途/零担", self._joined()) sess = self.flow.session_of("u1") self.assertEqual(sess.facts.get("运输类型"), "国内运输拼车") def test_hk_lcl_asks_vehicle(self) -> None: phase = self.flow.on_text( sender_id="u1", text="中港 东南亚 急件 零担", reply=self._reply, injected_facts={ "起运港": "广州", "目的港": "香港", "品名": "衣服", "体积": "10CBM", "毛重": "50kg", }, injected_mode="LAND", ) self.assertEqual(phase, "clarify") self.assertIn("车型/数量", self._joined()) self.assertNotIn("\n数量:", self._joined()) self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined()) self.assertEqual(self.ledger.create_calls, 0) def test_hk_full_truck_skips_vehicle(self) -> None: phase = self.flow.on_text( sender_id="u1", text="中港 东南亚 急件 整车", reply=self._reply, injected_facts={ "起运港": "广州", "目的港": "香港", "品名": "衣服", }, injected_mode="LAND", ) self.assertEqual(phase, "wait_confirm") self.assertNotIn("车型/数量", self._joined()) self.assertEqual(self.ledger.create_calls, 0) def test_domestic_complete_card_no_ticket(self) -> None: phase = self.flow.on_text( sender_id="u1", text="国内运输拼车 国内长途/零担 急件", reply=self._reply, injected_facts={ "起运港": "广州", "目的港": "深圳", "品名": "衣服", "体积": "100CBM", "毛重": "50kg", }, injected_mode="LAND", ) self.assertEqual(phase, "wait_confirm") self.assertEqual(self.ledger.create_calls, 0) today = datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat() joined = self._joined() self.assertIn(today, joined) self.assertNotIn("通关口岸", joined) self.assertNotIn("WO", joined) self.assertIn(copy.LAND_CONFIRM_TAIL, joined) def test_cross_complete_shows_empty_customs(self) -> None: phase = self.flow.on_text( sender_id="u1", text="跨境集拼 东南亚 急件", reply=self._reply, injected_facts={ "起运港": "广州", "目的港": "河内", "品名": "衣服", "体积": "100CBM", "毛重": "50kg", }, injected_mode="LAND", ) self.assertEqual(phase, "wait_confirm") self.assertIn("通关口岸(非必填):", self._joined()) def test_missing_volume_no_confirm(self) -> None: phase = self.flow.on_text( sender_id="u1", text="国内运输拼车 国内长途/零担 急件", reply=self._reply, injected_facts={ "起运港": "广州", "目的港": "深圳", "品名": "衣服", "毛重": "50kg", }, injected_mode="LAND", ) self.assertEqual(phase, "clarify") self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined()) class LandTextConfirmTests(unittest.TestCase): def setUp(self) -> None: self.ledger = CountingLedger() self.flow = LandTextInquiryFlow(self.ledger) self.replies: list[str] = [] def _reply(self, text: str, extra=None) -> str: self.replies.append(text) return "" def _to_confirm(self) -> str: return self.flow.on_text( sender_id="u1", text="国内运输拼车 国内长途/零担 急件", reply=self._reply, injected_facts={ "起运港": "广州", "目的港": "深圳", "品名": "衣服", "体积": "100CBM", "毛重": "50kg", }, injected_mode="LAND", ) def test_confirm_creates_ticket_and_queries(self) -> None: self.assertEqual(self._to_confirm(), "wait_confirm") self.replies.clear() phase = self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) self.assertEqual(phase, "wait_collab") self.assertTrue(self.ledger.last_created_no.startswith("WO")) self.assertIn(self.ledger.last_created_no, "\n".join(self.replies)) ticket = self.ledger.get_ticket(work_order_no=self.ledger.last_created_no) self.assertEqual(ticket.facts.get("运输类型"), "国内运输拼车") self.assertEqual(ticket.facts.get("线路类别"), "国内长途/零担") joined = "\n".join(self.replies) self.assertIn("工单号:" + self.ledger.last_created_no, joined) self.assertIn("运输方式:陆运", joined) self.assertIn("运输类型:国内运输拼车", joined) self.assertIn("始发站:广州", joined) self.assertIn("工单已创建,正在查询TMS报价。", joined) self.assertNotIn("工单已创建:" + self.ledger.last_created_no, joined) def test_tms_fail_then_confirm_retries_same_ticket(self) -> None: class Flaky(CountingLedger): def __init__(self) -> None: super().__init__() self.query_calls = 0 def query_tms(self, **kwargs): self.query_calls += 1 if self.query_calls == 1: return { "ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}, } return super().query_tms(**kwargs) ledger = Flaky() flow = LandTextInquiryFlow(ledger) replies: list[str] = [] def reply(text: str, extra=None) -> str: replies.append(text) return "" flow.on_text( sender_id="u1", text="国内运输拼车 国内长途/零担 急件", reply=reply, injected_facts={ "起运港": "广州", "目的港": "深圳", "品名": "衣服", "体积": "100CBM", "毛重": "50kg", }, injected_mode="LAND", ) phase = flow.on_text(sender_id="u1", text="确定", reply=reply) self.assertEqual(phase, "tms_tech") self.assertEqual(ledger.create_calls, 1) wo = ledger.last_created_no replies.clear() flow._by_sender.clear() flow._by_ticket.clear() phase = flow.on_text(sender_id="u1", text="确定", reply=reply) self.assertEqual(phase, "wait_collab") self.assertEqual(ledger.create_calls, 1) self.assertEqual(ledger.query_calls, 2) self.assertEqual(flow.session_of("u1").work_order_no, wo) self.assertIn(wo, "\n".join(replies)) self.assertNotIn("你好,我是询价机器人", "\n".join(replies)) def test_confirm_synonyms(self) -> None: for word in ("确认", "查价", "可以查了"): ledger = CountingLedger() flow = LandTextInquiryFlow(ledger) replies: list[str] = [] def reply(text: str, extra=None) -> str: replies.append(text) return "" flow.on_text( sender_id="u1", text="国内运输拼车 国内长途/零担 急件", reply=reply, injected_facts={ "起运港": "广州", "目的港": "深圳", "品名": "衣服", "体积": "100CBM", "毛重": "50kg", }, injected_mode="LAND", ) phase = flow.on_text(sender_id="u1", text=word, reply=reply) self.assertEqual(phase, "wait_collab", msg=word) def test_resend_changes_type_requeries_fields(self) -> None: self.assertEqual(self._to_confirm(), "wait_confirm") self.replies.clear() phase = self.flow.on_text( sender_id="u1", text="改成国内整车", reply=self._reply, ) self.assertEqual(phase, "clarify") self.assertTrue(any("车型" in x for x in self.replies)) self.assertEqual(self.ledger.create_calls, 0) def test_new_land_after_quoted_does_not_recurse(self) -> None: self.assertEqual(self._to_confirm(), "wait_confirm") phase = self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) self.assertEqual(phase, "wait_collab") self.replies.clear() phase = self.flow.on_text( sender_id="u1", text="陆运,国内运输拼车,泉州市到南京市,通信机柜及线缆配件,10CBM,10KG", reply=self._reply, injected_facts={ "起运港": "泉州", "目的港": "南京", "品名": "通信机柜及线缆配件", "体积": "10CBM", "毛重": "10KG", "运输类型": "国内运输拼车", }, injected_mode="LAND", ) self.assertEqual(phase, "need_land_options") self.assertTrue(self.replies) self.assertIn("线路类别", "\n".join(self.replies)) def test_new_land_after_tms_miss_does_not_recurse(self) -> None: self.flow.on_text( sender_id="u1", text="国内运输拼车 国内长途/零担 急件", reply=self._reply, injected_facts={ "起运港": "广州", "目的港": "无价", "品名": "衣服", "体积": "100CBM", "毛重": "50kg", }, injected_mode="LAND", ) phase = self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) self.assertEqual(phase, "tms_miss") self.replies.clear() phase = self.flow.on_text( sender_id="u1", text="陆运,国内运输拼车,泉州市到南京市,通信机柜及线缆配件,10CBM,10KG", reply=self._reply, injected_facts={ "起运港": "泉州", "目的港": "南京", "品名": "通信机柜及线缆配件", "体积": "10CBM", "毛重": "10KG", "运输类型": "国内运输拼车", }, injected_mode="LAND", ) self.assertEqual(phase, "need_land_options") self.assertIn("线路类别", "\n".join(self.replies)) def test_followup_city_route_does_not_reask_type(self) -> None: """ 已选国内运输拼车后,用户只补线路+分类。 模型若把运输类型写成「陆运」,不得整张清单重问。 """ self.flow.on_text( sender_id="u1", text="陆运,国内运输拼车,泉州市到南京市,通信机柜及线缆配件,10CBM,10KG", reply=self._reply, injected_facts={ "起运港": "泉州", "目的港": "南京", "品名": "通信机柜及线缆配件", "体积": "10CBM", "毛重": "10KG", "运输类型": "国内运输拼车", }, injected_mode="LAND", ) self.assertEqual(self.flow.session_of("u1").phase, "need_land_options") self.replies.clear() phase = self.flow.on_text( sender_id="u1", text="国内城配/拖车/打包;普件,常用线路/常规车型", reply=self._reply, injected_facts={"品名": "通信机柜及线缆配件", "运输类型": "陆运"}, injected_mode="LAND", ) self.assertEqual(phase, "wait_confirm") text = "\n".join(self.replies) self.assertNotIn("请一次选择:运输类型", text) self.assertIn("国内运输拼车", text) self.assertIn("国内城配/拖车/打包", text) self.assertIn("普件,常用线路/常规车型", text) class FakeGroupClient: """单测假企微建群:不打外网。""" def __init__(self) -> None: self.created: list[dict] = [] self.sent: list[dict] = [] def create_group(self, *, name: str, userids: list[str]) -> dict: self.created.append({"name": name, "userids": list(userids)}) return {"ok": True, "chat_id": "wr_land_1", "error": ""} def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict: 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"}) 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"}) return {"ok": True} class LandTextPullTests(unittest.TestCase): def setUp(self) -> None: self.ledger = CountingLedger() self.ledger.set_quote_templates( [ { "templateId": "mem-default-land", "name": "陆运默认", "bizType": "陆运", "keywords": [], "isDefault": True, } ] ) self.groups = FakeGroupClient() self.flow = LandTextInquiryFlow(self.ledger, group_client=self.groups) self.replies: list[str] = [] def _reply(self, text: str, extra=None) -> str: self.replies.append(text) return "" def _to_quoted(self) -> None: self.flow.on_text( sender_id="u1", text="国内运输拼车 国内长途/零担 急件", reply=self._reply, injected_facts={ "起运港": "广州", "目的港": "深圳", "品名": "衣服", "体积": "100CBM", "毛重": "50kg", }, injected_mode="LAND", ) self.flow.on_text(sender_id="u1", text="确定", reply=self._reply) self.replies.clear() def test_pull_only_matching_route(self) -> None: self.ledger.set_land_staff( [ {"roleCode": "land", "status": "active", "wecomId": "p_sea", "name": "甲", "routes": "东南亚"}, {"roleCode": "land", "status": "active", "wecomId": "p_dom", "name": "乙", "routes": "国内长途/零担"}, ] ) self._to_quoted() phase = self.flow.on_button( sender_id="u1", action=copy.BTN_PULL_COLLAB, reply=self._reply, ) self.assertEqual(phase, "collab_group") userids = self.groups.created[0]["userids"] self.assertIn("p_dom", userids) self.assertNotIn("p_sea", userids) brief = "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text") self.assertIn("运输类型", brief) self.assertIn("国内运输拼车", brief) self.assertIn("国内长途/零担", brief) self.assertIn("急件", brief) def test_pull_no_staff(self) -> None: self.ledger.set_land_staff([]) self._to_quoted() phase = self.flow.on_button( sender_id="u1", action=copy.BTN_PULL_COLLAB, reply=self._reply, ) self.assertIn("陆运产品岗", "\n".join(self.replies)) self.assertEqual(self.groups.created, []) self.assertEqual(phase, "wait_collab") def test_land_role_is_product_in_group(self) -> None: from agent.policy.sea_group_ops import is_product self.ledger.set_land_staff( [ { "roleCode": "land", "status": "active", "wecomId": "u1", "name": "销产", "routes": "国内长途/零担", } ] ) self._to_quoted() ticket = self.ledger.get_ticket(work_order_no=self.ledger.last_created_no) self.assertTrue(is_product(ticket, "u1", flow=self.flow)) if __name__ == "__main__": unittest.main()