落地陆运私聊文字询价:选项核对后再建单查价,并按线路拉产品进群。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 16:23:30 +08:00
co-authored by Cursor
parent 83b907c4b8
commit be95d823a2
52 changed files with 4534 additions and 76 deletions
+206
View File
@@ -0,0 +1,206 @@
"""
陆运核对卡与无产品话术。
"""
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)
from agent.policy import inquiry_copy as copy
class LandCopyTest(unittest.TestCase):
def test_domestic_card_no_customs_no_wo(self):
text = copy.land_confirm_card(
{
"运输类型": "国内运输拼车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
"报价日期": "2026-09-17",
}
)
self.assertIn("始发站(必填):广州", text)
self.assertNotIn("通关口岸", text)
self.assertNotIn("WO", text)
self.assertIn('请回复“确定”进行TMS查价,内容有误时,请重新发内容。', text)
self.assertIn("运输类型", text)
self.assertIn("国内运输拼车", text)
def test_cross_empty_customs(self):
text = copy.land_confirm_card(
{
"运输类型": "跨境集拼",
"线路类别": "东南亚",
"运输分类": "急件",
"起运港": "广州",
"目的港": "河内",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
"报价日期": "2026-09-17",
}
)
self.assertIn("通关口岸(非必填):", text)
def test_hk_shows_load(self):
text = copy.land_confirm_card(
{
"运输类型": "中港",
"线路类别": "东南亚",
"运输分类": "急件",
"中港装载方式": "零担/集拼",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"车型/数量": "9米6/1",
"报价日期": "2026-09-17",
}
)
self.assertIn("零担/集拼", text)
self.assertIn("车型/数量(必填):9米6/1", text)
self.assertNotIn("\n数量(必填)", text)
def test_hk_full_truck_hides_vehicle(self):
text = copy.land_confirm_card(
{
"运输类型": "中港",
"线路类别": "东南亚",
"运输分类": "急件",
"中港装载方式": "整车",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"报价日期": "2026-09-17",
}
)
self.assertIn("整车", text)
self.assertNotIn("车型/数量", text)
def test_domestic_ftl_one_field(self):
text = copy.land_confirm_card(
{
"运输类型": "国内运输整车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"车型/数量": "9米6/1",
"报价日期": "2026-09-17",
}
)
self.assertIn("车型/数量(必填):9米6/1", text)
self.assertNotIn("\n数量(必填)", text)
def test_no_staff_copy(self):
self.assertIn("陆运产品岗", copy.land_group_no_staff())
def test_inquiry_card_lists_land_fields_and_wo(self):
text = copy.inquiry_card(
work_order_no="WO202609170006",
facts={
"运输类型": "国内运输拼车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
"报价日期": "2026-09-17",
},
transport_mode="陆运",
)
self.assertIn("工单号:WO202609170006", text)
self.assertIn("运输方式:陆运", text)
self.assertIn("运输类型:国内运输拼车", text)
self.assertIn("始发站:广州", text)
self.assertIn("货物品名:衣服", text)
self.assertIn("工单已创建,正在查询TMS报价。", text)
self.assertNotIn("件数", text)
def test_scheme_card_uses_land_fields_not_sea(self):
facts = {
"运输类型": "国内运输拼车",
"线路类别": "国内城配/拖车/打包",
"运输分类": "普件,常用线路/常规车型",
"起运港": "泉州市",
"目的港": "南京市",
"品名": "通信机柜及线缆配件",
"体积": "10CBM",
"毛重": "10KG",
"报价日期": "2026-09-17",
}
quote = {"total": "CNY 1200", "eta": "3天", "valid_until": "2026-09-20"}
text = copy.scheme_card(
work_order_no="WO202609170011",
status="已报价",
facts=facts,
quote=quote,
transport_mode="陆运",
)
self.assertIn("运输方式:陆运", text)
self.assertIn("运输类型:国内运输拼车", text)
self.assertIn("始发站:泉州市", text)
self.assertIn("货物品名:通信机柜及线缆配件", text)
self.assertIn("体积:10CBM", text)
self.assertNotIn("整柜或拼柜", text)
self.assertNotIn("箱型箱量", text)
self.assertNotIn("件数:", text)
self.assertNotIn("包装类型:", text)
rows = {
x["keyname"]: x["value"]
for x in copy.scheme_horizontal_rows(
work_order_no="WO202609170011",
facts=facts,
quote=quote,
transport_mode="陆运",
)
}
self.assertIn("陆运 泉州市→南京市", rows["线路"])
self.assertIn("国内运输拼车", rows["类型"])
self.assertIn("国内城配/拖车/打包", rows["类型"])
self.assertIn("通信机柜及线缆配件", rows["货物"])
self.assertIn("10CBM", rows["货物"])
self.assertNotIn("航线", rows)
self.assertNotIn("箱型", rows)
self.assertNotIn("包装", rows)
self.assertNotIn("空运", "".join(rows.values()))
self.assertNotIn("海运", "".join(rows.values()))
def test_scheme_card_ftl_shows_vehicle_combo(self):
facts = {
"运输类型": "国内运输整车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"车型/数量": "9米6/1",
}
rows = {
x["keyname"]: x["value"]
for x in copy.scheme_horizontal_rows(
work_order_no="WO202609170012",
facts=facts,
quote={},
transport_mode="陆运",
)
}
self.assertIn("9米6/1", rows["货物"])
self.assertNotIn("CBM", rows["货物"])
if __name__ == "__main__":
unittest.main()
+259
View File
@@ -0,0 +1,259 @@
"""
入站选路:陆运书签/本句陆运交给 LandTextInquiryFlow;海运立即建单不变。
"""
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.queue import MemoryMessageStore, get_message_store
from agent.channel.wecom.models import InboundMessage
from agent.handlers.text_inquiry import handle_text_inquiry
from agent.policy.air_text_flow import reset_air_text_flow_for_test
from agent.policy.land_text_flow import get_land_text_flow, reset_land_text_flow_for_test
from agent.policy.sea_text_flow import reset_sea_text_flow_for_test
from agent.routing.decision import RouteDecision
from agent.routing.dispatch import dispatch_inbound
from tests.test_sea_text_inquiry import COMPLETE_SEA
class LandDispatchTests(unittest.TestCase):
def setUp(self) -> None:
reset_sea_text_flow_for_test()
reset_land_text_flow_for_test()
reset_air_text_flow_for_test()
self.store = MemoryMessageStore()
def _outbox_text(self) -> str:
return "\n".join(i.content for i in self.store._outbox.values())
def test_handler_routes_land_to_option_sheet(self) -> None:
msg = InboundMessage(sender_id="ld1", message_id="m-land-1", content="陆运询价")
phase = handle_text_inquiry(
msg,
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
injected_mode="LAND",
store=self.store,
)
self.assertEqual(phase, "need_land_options")
body = self._outbox_text()
self.assertIn("运输类型", body)
self.assertIn("线路类别", body)
self.assertIn("运输分类", body)
self.assertNotIn("请补充陆运类型:", body)
def test_air_owner_forwards_land_to_option_sheet(self) -> None:
from agent.policy.air_text_flow import get_air_text_flow
replies: list[str] = []
phase = get_air_text_flow().on_text(
sender_id="ld-air",
text="陆运询价",
reply=lambda t, extra=None: replies.append(t) or "",
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
text = "\n".join(replies)
self.assertIn("线路类别", text)
self.assertIn("运输分类", text)
self.assertNotIn("请补充陆运类型:", text)
def test_land_session_survives_confirm(self) -> None:
msg1 = InboundMessage(sender_id="ld2", message_id="m-l2a", content="陆运")
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
phase = handle_text_inquiry(
msg1,
injected_facts=facts,
injected_mode="LAND",
store=self.store,
)
# 补选项走同一 sender
msg2 = InboundMessage(
sender_id="ld2",
message_id="m-l2b",
content="国内运输拼车 国内长途/零担 急件",
)
phase = handle_text_inquiry(
msg2,
injected_facts=facts,
injected_mode="LAND",
store=self.store,
)
self.assertEqual(phase, "wait_confirm")
msg3 = InboundMessage(sender_id="ld2", message_id="m-l2c", content="确定")
phase = handle_text_inquiry(msg3, store=self.store)
self.assertEqual(phase, "wait_collab")
def test_confirm_misrouted_as_other_still_creates(self) -> None:
store = get_message_store()
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
handle_text_inquiry(
InboundMessage(sender_id="ld3", message_id="m-l3a", content="陆运"),
injected_facts=facts,
injected_mode="LAND",
store=store,
)
handle_text_inquiry(
InboundMessage(
sender_id="ld3",
message_id="m-l3b",
content="国内运输拼车 国内长途/零担 急件",
),
injected_facts=facts,
injected_mode="LAND",
store=store,
)
self.assertEqual(get_land_text_flow().session_of("ld3").phase, "wait_confirm")
name = dispatch_inbound(
InboundMessage(sender_id="ld3", message_id="m-l3c", content="确定"),
RouteDecision(intent="ordinary_text_other"),
)
self.assertEqual(name, "ordinary_text_inquiry")
sess = get_land_text_flow().session_of("ld3")
self.assertEqual(sess.phase, "wait_collab")
self.assertTrue((sess.work_order_no or "").startswith("WO"))
bodies = "\n".join(i.content for i in store._outbox.values())
self.assertIn(sess.work_order_no, bodies)
last = list(store._outbox.values())[-1].content
self.assertNotIn("你好,我是询价机器人", last)
def test_confirm_after_memory_lost_still_creates(self) -> None:
store = get_message_store()
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
handle_text_inquiry(
InboundMessage(sender_id="ld4", message_id="m-l4a", content="陆运"),
injected_facts=facts,
injected_mode="LAND",
store=store,
)
handle_text_inquiry(
InboundMessage(
sender_id="ld4",
message_id="m-l4b",
content="国内运输拼车 国内长途/零担 急件",
),
injected_facts=facts,
injected_mode="LAND",
store=store,
)
self.assertEqual(get_land_text_flow().session_of("ld4").phase, "wait_confirm")
reset_land_text_flow_for_test()
self.assertIsNone(get_land_text_flow().session_of("ld4"))
name = dispatch_inbound(
InboundMessage(sender_id="ld4", message_id="m-l4c", content="确定"),
RouteDecision(intent="ordinary_text_other"),
)
self.assertEqual(name, "ordinary_text_inquiry")
sess = get_land_text_flow().session_of("ld4")
self.assertEqual(sess.phase, "wait_collab")
self.assertTrue((sess.work_order_no or "").startswith("WO"))
last = list(store._outbox.values())[-1].content
self.assertNotIn("你好,我是询价机器人", last)
def test_tms_retry_misrouted_as_other(self) -> None:
from tests.test_land_text_inquiry import CountingLedger
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)
land = reset_land_text_flow_for_test()
land.ledger = Flaky()
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
handle_text_inquiry(
InboundMessage(sender_id="ld5", message_id="m-l5a", content="陆运"),
injected_facts=facts,
injected_mode="LAND",
store=self.store,
)
handle_text_inquiry(
InboundMessage(
sender_id="ld5",
message_id="m-l5b",
content="国内运输拼车 国内长途/零担 急件",
),
injected_facts=facts,
injected_mode="LAND",
store=self.store,
)
handle_text_inquiry(
InboundMessage(sender_id="ld5", message_id="m-l5c", content="确定"),
store=self.store,
)
self.assertEqual(get_land_text_flow().session_of("ld5").phase, "tms_tech")
wo = get_land_text_flow().session_of("ld5").work_order_no
name = dispatch_inbound(
InboundMessage(sender_id="ld5", message_id="m-l5d", content="确定"),
RouteDecision(intent="ordinary_text_other"),
)
self.assertEqual(name, "ordinary_text_inquiry")
sess = get_land_text_flow().session_of("ld5")
self.assertEqual(sess.phase, "wait_collab")
self.assertEqual(sess.work_order_no, wo)
last = list(self.store._outbox.values())[-1].content
self.assertNotIn("你好,我是询价机器人", last)
def test_sea_still_creates_immediately(self) -> None:
msg = InboundMessage(sender_id="s-sea", message_id="m-sea-keep", content="海运询价")
phase = handle_text_inquiry(
msg,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
store=self.store,
)
self.assertEqual(phase, "wait_collab")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,109 @@
"""
陆运五套询价必填校验。不连 TMS。
"""
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)
from agent.schema.field_validate import display_name, validate_required_fields
class LandFieldValidateTest(unittest.TestCase):
def test_ltl_missing_volume(self):
got = validate_required_fields(
facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服", "毛重": "50kg"},
business_line="LAND",
land_subtype="国内运输拼车",
)
self.assertFalse(got["ok"])
self.assertIn("体积", got["missing"])
def test_hk_lcl_still_needs_vehicle(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"体积": "10CBM",
"毛重": "50kg",
"中港装载方式": "零担/集拼",
},
business_line="LAND",
land_subtype="中港",
)
self.assertIn("车型/数量", got["missing"])
self.assertNotIn("数量", got["missing"])
self.assertNotIn("车型", got["missing"])
def test_hk_full_truck_skips_vehicle(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"中港装载方式": "整车",
},
business_line="LAND",
land_subtype="中港",
)
self.assertTrue(got["ok"])
self.assertNotIn("车型/数量", got["missing"])
def test_domestic_ftl_one_field(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"车型/数量": "9米6/1",
},
business_line="LAND",
land_subtype="国内运输整车",
)
self.assertTrue(got["ok"])
self.assertEqual(got["facts"]["车型/数量"], "9米6/1")
def test_cross_ftl_folds_split_keys(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "河内",
"品名": "衣服",
"车型": "9米6",
"数量": "1",
},
business_line="LAND",
land_subtype="跨境整车",
)
self.assertTrue(got["ok"])
self.assertEqual(got["facts"]["车型/数量"], "9米6/1")
def test_quote_date_defaults(self):
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
},
business_line="LAND",
land_subtype="国内运输拼车",
)
self.assertTrue(got["ok"])
self.assertRegex(got["facts"]["报价日期"], r"^\d{4}-\d{2}-\d{2}$")
def test_land_display_names(self):
self.assertEqual(display_name("起运港", "LAND"), "始发站")
self.assertEqual(display_name("品名", "LAND"), "货物品名")
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
"""
陆运选项联动单测:类型/线路/分类/中港装载,选错只纠正该项。
不连企微、主账、TMS。
"""
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)
from agent.schema.land_options import (
harvest_land_options,
looks_like_confirm,
option_prompt,
validate_land_options,
)
class LandOptionsTest(unittest.TestCase):
def test_domestic_ltl_rejects_sea_route(self):
got = validate_land_options(
{
"运输类型": "国内运输拼车",
"线路类别": "东南亚",
"运输分类": "急件",
}
)
self.assertFalse(got["ok"])
msg = got["errors"][0]["message"]
self.assertIn("国内长途/零担", msg)
self.assertIn("国内城配/拖车/打包", msg)
def test_followup_route_class_keeps_type(self):
cur = harvest_land_options(
"国内城配/拖车/打包;普件,常用线路/常规车型",
current={"运输类型": "国内运输拼车"},
)
self.assertEqual(cur["运输类型"], "国内运输拼车")
self.assertEqual(cur["线路类别"], "国内城配/拖车/打包")
self.assertEqual(cur["运输分类"], "普件,常用线路/常规车型")
def test_model_land_does_not_wipe_type(self):
dropped = harvest_land_options(
"国内城配/拖车/打包;普件,常用线路/常规车型",
current={"运输类型": "陆运"},
)
self.assertFalse(dropped.get("运输类型"))
cur = harvest_land_options(
"国内城配/拖车/打包;普件,常用线路/常规车型",
current={"运输类型": "国内运输拼车"},
)
junk = harvest_land_options("陆运", current=cur)
self.assertEqual(junk["运输类型"], "国内运输拼车")
self.assertEqual(junk["线路类别"], "国内城配/拖车/打包")
cur = harvest_land_options("广州到深圳国内拼车衣服100方")
self.assertEqual(cur["运输类型"], "国内运输拼车")
self.assertFalse(cur.get("线路类别"))
def test_full_prompt_when_empty(self):
text = option_prompt(current={})
self.assertIn("运输类型", text)
self.assertIn("线路类别", text)
self.assertIn("运输分类", text)
self.assertIn("国内运输拼车", text)
self.assertIn("整车", text)
self.assertNotIn("请补充陆运类型:", text)
def test_copy_alias_lists_all_three(self):
from agent.policy import inquiry_copy as copy
text = copy.ASK_LAND_SUBTYPE
self.assertIn("运输类型", text)
self.assertIn("线路类别", text)
self.assertIn("运输分类", text)
self.assertNotIn("请补充陆运类型:国内运输拼车", text)
def test_narrow_prompt_when_type_known(self):
text = option_prompt(current={"运输类型": "国内运输拼车"})
self.assertIn("国内长途/零担", text)
self.assertNotIn("请选择运输类型", text)
def test_hk_needs_load(self):
got = validate_land_options(
{
"运输类型": "中港",
"线路类别": "东南亚",
"运输分类": "急件",
}
)
self.assertIn("中港装载方式", got["missing"])
def test_confirm_intent(self):
self.assertTrue(looks_like_confirm("确认"))
self.assertTrue(looks_like_confirm("可以查了"))
self.assertTrue(looks_like_confirm("查价"))
self.assertFalse(looks_like_confirm("广州到深圳衣服"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,536 @@
"""
陆运私聊:选项补齐 → 货物补问 → 无工单号核对卡。确定建单见后续用例。
不连企微 / 真实 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()
@@ -0,0 +1,44 @@
"""
内存账本:陆运按线路类别整词匹配产品岗。
"""
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("LEDGER_BACKEND", "memory")
from agent.ledger.memory_ledger import MemoryLedger
class MemoryLandStaffTest(unittest.TestCase):
def test_match_route_token(self):
book = MemoryLedger()
book.set_land_staff(
[
{"roleCode": "land", "status": "active", "wecomId": "a", "name": "甲", "routes": "东南亚"},
{"roleCode": "land", "status": "active", "wecomId": "b", "name": "乙", "routes": "国内长途/零担"},
{"roleCode": "land", "status": "active", "wecomId": "c", "name": "丙", "routes": "中港/中亚/中欧,东南亚"},
]
)
hits = book.match_land_staff(route_category="东南亚")
names = [x.get("name") for x in hits]
self.assertEqual(names, ["甲", "丙"])
def test_sea_staff_not_pulled(self):
book = MemoryLedger()
book.set_land_staff(
[{"roleCode": "sea", "status": "active", "wecomId": "s", "name": "海", "routes": "东南亚"}]
)
self.assertEqual(book.match_land_staff(route_category="东南亚"), [])
if __name__ == "__main__":
unittest.main()
@@ -81,6 +81,109 @@ class QuoteTemplateMatchTests(unittest.TestCase):
)
self.assertIsNone(hit)
def test_land_pinche_not_stolen_by_generic_land_keyword(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "hk",
"name": "中港",
"bizType": "陆运",
"keywords": ["中港", "陆运"],
"isDefault": False,
},
{
"templateId": "ltl",
"name": "国内拼车",
"bizType": "陆运",
"keywords": ["拼车", "零担"],
"isDefault": False,
},
{
"templateId": "d1",
"name": "陆运默认",
"bizType": "陆运",
"keywords": ["普件"],
"isDefault": True,
},
],
business_line="LAND",
facts={
"运输类型": "国内运输拼车",
"线路类别": "国内城配/拖车/打包",
"运输分类": "普件,常用线路/常规车型",
"起运港": "泉州市",
"目的港": "南京市",
},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.match_type, "keyword")
self.assertEqual(hit.name, "国内拼车")
def test_land_cross_ftl_beats_domestic_ftl(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "dom",
"name": "国内整车",
"bizType": "陆运",
"keywords": ["整车", "国内"],
"isDefault": False,
},
{
"templateId": "xb",
"name": "跨境整车",
"bizType": "陆运",
"keywords": ["跨境", "整车"],
"isDefault": False,
},
],
business_line="LAND",
facts={"运输类型": "跨境整车", "线路类别": "东南亚"},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.name, "跨境整车")
def test_land_falls_back_to_default(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "hk",
"name": "中港",
"bizType": "陆运",
"keywords": ["中港"],
"isDefault": False,
},
{
"templateId": "d1",
"name": "陆运默认",
"bizType": "陆运",
"keywords": ["急件"],
"isDefault": True,
},
],
business_line="LAND",
facts={"运输类型": "国内运输拼车", "运输分类": "普件,常用线路/常规车型"},
)
self.assertIsNotNone(hit)
self.assertEqual(hit.match_type, "default")
self.assertEqual(hit.name, "陆运默认")
def test_land_none_without_default(self) -> None:
hit = pick_quote_template(
templates=[
{
"templateId": "hk",
"name": "中港",
"bizType": "陆运",
"keywords": ["中港"],
"isDefault": False,
},
],
business_line="LAND",
facts={"运输类型": "国内运输拼车"},
)
self.assertIsNone(hit)
if __name__ == "__main__":
unittest.main()
+108
View File
@@ -0,0 +1,108 @@
"""
陆运 TMS 出站拼参:分类和中港装载方式不得带出。
"""
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)
from agent.schema.tms_land_query import assemble_land_query
class TmsLandQueryTest(unittest.TestCase):
def test_outbound_omits_class_and_hk_load(self):
facts = {
"运输类型": "中港",
"线路类别": "东南亚",
"运输分类": "急件",
"中港装载方式": "零担/集拼",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"车型/数量": "9米6/1",
"通关口岸": "皇岗",
}
got = assemble_land_query(facts)
self.assertTrue(got["ok"])
out = got["outbound"]
self.assertEqual(out["landTransportType"], "CHINA_HK")
self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA")
self.assertNotIn("landRouteCategory", out)
self.assertEqual(out["vehicleType"], "9米6")
self.assertEqual(str(out["quantity"]), "1")
self.assertEqual(out["customsPort"], "皇岗")
self.assertNotIn("运输分类", out)
self.assertNotIn("landClass", out)
self.assertNotIn("hkLoadType", out)
self.assertNotIn("中港装载方式", out)
def test_hk_full_truck_omits_vehicle(self):
facts = {
"运输类型": "中港",
"线路类别": "东南亚",
"中港装载方式": "整车",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
}
got = assemble_land_query(facts)
self.assertTrue(got["ok"])
out = got["outbound"]
self.assertNotIn("vehicleType", out)
self.assertNotIn("quantity", out)
def test_domestic_ftl_splits_combo(self):
facts = {
"运输类型": "国内运输整车",
"线路类别": "国内长途/零担",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"车型/数量": "9米6*2",
}
out = assemble_land_query(facts)["outbound"]
self.assertEqual(out["vehicleType"], "9米6")
self.assertEqual(out["quantity"], "2")
def test_domestic_has_no_customs(self):
facts = {
"运输类型": "国内运输拼车",
"线路类别": "国内长途/零担",
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
}
out = assemble_land_query(facts)["outbound"]
self.assertNotIn("customsPort", out)
self.assertIn("volumeCbm", out)
self.assertEqual(out["landTransportType"], "DOMESTIC_LTL")
self.assertEqual(out["routeCategory"], "DOMESTIC_LONG_LTL")
def test_wo011_pinche_city_dist_uses_tms_enums(self):
facts = {
"运输类型": "国内运输拼车",
"线路类别": "国内城配/拖车/打包",
"起运港": "泉州市",
"目的港": "南京市",
"品名": "通信机柜及线缆配件",
"体积": "10CBM",
"毛重": "10KG",
}
got = assemble_land_query(facts)
self.assertTrue(got["ok"])
out = got["outbound"]
self.assertEqual(out["landTransportType"], "DOMESTIC_LTL")
self.assertEqual(out["routeCategory"], "DOMESTIC_CITY_DIST")
self.assertNotIn("landRouteCategory", out)
if __name__ == "__main__":
unittest.main()