陆运补问把货物品名放进选项清单,并按销售原文回显已填项。

空着的运输分类不再被菜单第一项认成已填。空运报价改为货物信息加价格方案,锁舱成功回执补起运地、目的地、航司和航班日期。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-14 17:39:54 +08:00
co-authored by Cursor
parent cc8529f569
commit 9ffba70741
21 changed files with 1129 additions and 130 deletions
+89 -2
View File
@@ -341,6 +341,59 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("报价有效期:2026-09-30", tms_block)
self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text)
self.assertIn("亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertNotIn("询价字段:", text)
self.assertIn("备注:-", text)
def test_air_group_brief_with_tms_uses_cargo_and_price_shape(self) -> None:
"""有 TMS 报价时,群摘要换成货物信息和三种费用写法。普货含电含磁仍是「否」。"""
tms = {
"source": "TMS",
"total": "87150.0",
"currency": "CNY",
"valid_until": "2026-10-31",
"remark": "其他杂费如有实报实销",
"airlineCode": "TH",
"chargeableWeightKg": "8300",
"cargoType": "1:212",
"fee_lines": [
{"name": "空运费", "currency": "CNY", "unit": "KG", "unitPrice": 11, "amount": "91300"},
{"name": "文件费", "currency": "CNY", "unit": "票", "amount": "300"},
],
}
text = copy.air_group_brief(
work_order_no="WO202610090040",
facts={
"起运地": "NKG",
"目的地": "KUL",
"品名": "普货",
"件数": "800箱",
"毛重": "8300KG",
"体积": "39CBM",
"包装类型": "散货",
"报价日期": "2026-10-09",
},
quote=tms,
collab_facts={},
has_price=True,
tms_quote=tms,
sales_name="吴季林",
airline_names=["高芸"],
)
self.assertTrue(text.startswith("@吴季林 @高芸"))
self.assertNotIn("询价字段:", text)
self.assertIn("航司:TH", text)
self.assertIn("计费重:8300KG", text)
self.assertIn("货型:1:212", text)
self.assertIn("是否含电:否", text)
self.assertIn("是否含磁:否", text)
tms_block = text.split("TMS报价:", 1)[1]
self.assertIn("空运费:单价CNY11/KG", tms_block)
self.assertIn("文件费:CNY300/票", tms_block)
self.assertIn("备注:其他杂费如有实报实销", tms_block)
self.assertIn("费用合计:CNY 87150.0", tms_block)
self.assertIn("报价有效期:2026-10-31", tms_block)
self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text)
self.assertLess(tms_block.find("报价有效期:"), tms_block.find("请航线人员"))
self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text)
self.assertNotIn("请产品人员回复", text)
@@ -1111,7 +1164,7 @@ class AirGroupCollabTests(unittest.TestCase):
injected_intent="air_pick_option",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
self.assertTrue(any("TMS空运锁舱成功" in t for t in self._texts()))
def test_tms_quote_with_selected_cabin_locks_without_asking_params(self) -> None:
"""TMS 已有 selected_air_option:直接锁,不追问舱位/航司/航班(WO014)。"""
@@ -1162,6 +1215,40 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertEqual(calls[0].get("flight_no"), "JG2719")
self.assertFalse(any("锁舱参数不完整" in t for t in self._texts()))
def test_lock_ok_reply_includes_route(self) -> None:
"""锁舱成功回执带出本次舱位的起运地、目的地、航司、航班日期。"""
wo = self._create_air(
quote={
"total": "1800",
"source": "TMS",
"source_label": "航线确认TMS",
"quoteId": "AIR-2099695579169554433-2099695579182137346",
"selected_air_option": {
"optionNo": "AIR-OPT01",
"airlineCode": "TH",
"flightNo": "TH123",
"originAirportCode": "NKG",
"destinationAirportCode": "KUL",
"flightDate": "2026-10-15",
},
},
status="已成交",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_route", mid="route1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
text = self._texts()[-1]
self.assertIn("TMS空运锁舱成功。", text)
self.assertIn("舱位选项:AIR-OPT01", text)
self.assertIn("起运地:NKG", text)
self.assertIn("目的地:KUL", text)
self.assertIn("航司:TH", text)
self.assertIn("航班日期:2026-10-15", text)
def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None:
"""无舱位明细时缺三项才请补,不锁舱、不报 TMS 失败、不通知 IT。"""
wo = self._create_air(
@@ -1318,7 +1405,7 @@ class AirGroupCollabTests(unittest.TestCase):
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
self.assertTrue(any("TMS空运锁舱成功" in t for t in self._texts()))
self.assertFalse(any("请确认成交状态" in t for t in self._texts()))
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_1", mid="l5"),
+75 -3
View File
@@ -382,17 +382,89 @@ class AirRouteOptionsTests(unittest.TestCase):
text,
"\n".join(
[
"TMS标准报价",
"WO202609220013价格明细",
"NKG-KUL",
"工单号:WO202609220013",
"运输方式:空运",
"起运地:-",
"目的地:-",
"航司:-",
"品名:-",
"件数:-",
"重量(KG):-",
"体积(CBM):-",
"包装类型:-",
"计费重:-",
"货型:-",
"报价日期:-",
"",
"价格方案如下:",
"增点费:CNY 100.0",
"拆板费:USD 100.0",
"备注:-",
"费用合计:CNY 5001.0",
"报价有效期:2026-09-30",
]
),
)
self.assertNotIn("时效", text)
self.assertNotIn("TMS标准报价", text)
self.assertNotIn("NKG-KUL", text)
def test_air_detail_text_uses_ticket_facts_and_tms_price_shape(self) -> None:
"""货物信息用工单,价格方案按单价/按票/只有金额三种写法。"""
text = copy.sea_quote_detail_text(
work_order_no="WO202610090040",
facts={
"起运地": "NKG",
"目的地": "KUL",
"品名": "普货",
"件数": "800箱",
"毛重": "8300KG",
"体积": "39CBM",
"包装类型": "散货",
"报价日期": "2026-10-09",
},
quote={
"currency": "CNY",
"total": "87150.0",
"valid_until": "2026-10-31",
"remark": "其他杂费如有实报实销,不含续页;续页50/票",
"airlineCode": "TH",
"chargeableWeightKg": "8300",
"cargoType": "1:212",
"fee_lines": [
{
"name": "空运费",
"currency": "CNY",
"unit": "KG",
"unitPrice": 11,
"amount": "91300",
},
{"name": "文件费", "currency": "CNY", "unit": "票", "amount": "300"},
{"name": "报关费", "currency": "CNY", "unit": "/票", "unitPrice": "250"},
{"name": "增点费", "currency": "CNY", "amount": "100.0"},
],
},
business_line="AIR",
)
self.assertIn("工单号:WO202610090040", text)
self.assertIn("起运地:NKG", text)
self.assertIn("目的地:KUL", text)
self.assertIn("航司:TH", text)
self.assertIn("件数:800箱", text)
self.assertIn("重量(KG):8300KG", text)
self.assertIn("体积(CBM):39CBM", text)
self.assertIn("计费重:8300KG", text)
self.assertIn("货型:1:212", text)
self.assertIn("报价日期:2026-10-09", text)
self.assertIn("价格方案如下:", text)
self.assertIn("空运费:单价CNY11/KG", text)
self.assertIn("文件费:CNY300/票", text)
self.assertIn("报关费:CNY250/票", text)
self.assertIn("增点费:CNY 100.0", text)
self.assertIn("备注:其他杂费如有实报实销,不含续页;续页50/票", text)
self.assertIn("费用合计:CNY 87150.0", text)
self.assertIn("报价有效期:2026-10-31", text)
self.assertNotIn("询价字段", text)
def test_h5_page_lists_route_and_fees(self) -> None:
from agent.channel.h5.air_route_page import render_air_route_page
+6 -3
View File
@@ -934,10 +934,12 @@ class AirTextInquiryTests(unittest.TestCase):
quote=long_quote,
business_line="AIR",
)
self.assertIn("TMS标准报价", text)
self.assertIn("WO202609220005价格明细", text)
self.assertIn("价格方案如下:", text)
self.assertNotIn("TMS标准报价", text)
self.assertNotIn("价格明细", text)
self.assertIn("操作费:CNY 1220.00", text)
self.assertIn("空运费:USD 1250.00", text)
self.assertIn("备注:-", text)
self.assertIn("费用合计:CNY 142998.53", text)
self.assertNotIn("时效", text)
self.assertIn("报价有效期:2026-09-30", text)
@@ -987,7 +989,8 @@ class AirTextInquiryTests(unittest.TestCase):
quote=quote,
business_line="AIR",
)
self.assertIn("ZUH-CAN-CRK", detail)
self.assertNotIn("ZUH-CAN-CRK", detail)
self.assertIn("价格方案如下:", detail)
self.assertIn("运输费:CNY 11.0", detail)
self.assertIn("费用合计:CNY 183370", detail)
rows = {
+3
View File
@@ -124,6 +124,9 @@ class LandCopyTest(unittest.TestCase):
self.assertIn(copy.CLARIFY_PASTE_HINT, text)
self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text)
self.assertIn("2.通关口岸(非必填):\n(如:友谊关)", text)
self.assertIn("货物品名(必填):衣服", text.split(copy.CLARIFY_PASTE_HINT, 1)[0])
pending = text.split(copy.CLARIFY_PASTE_HINT, 1)[1]
self.assertNotIn("货物品名", pending)
self.assertNotIn("体积(必填):(如:", text)
self.assertNotIn(copy.LAND_CONFIRM_TAIL, text)
+63 -6
View File
@@ -16,6 +16,7 @@ if ROOT not in sys.path:
from agent.schema.land_options import (
harvest_land_options,
land_cargo_name,
looks_like_confirm,
option_prompt,
option_prompt_needs_sample,
@@ -118,6 +119,12 @@ class LandOptionsTest(unittest.TestCase):
self.assertIn("运输分类(必填):\n1.普件(常用线路/常规车型)", text)
self.assertIn("2.普件(东南亚、中港常用线路/常规车型)", text)
self.assertIn("5.急件", text)
self.assertIn("货物品名(必填):\n(如:衣服)", text)
self.assertTrue(text.startswith("Step1: 请复制以下内容,粘贴到输入框;"))
self.assertLess(
text.index("5.急件"),
text.index("货物品名(必填)"),
)
self.assertNotIn("请一次选择:运输类型", text)
self.assertNotIn("可选择回复", text)
self.assertNotIn("填写样例见图", text)
@@ -134,16 +141,26 @@ class LandOptionsTest(unittest.TestCase):
self.assertNotIn("请补充陆运类型:国内运输拼车", text)
def test_sample_table_when_route_missing(self):
text = option_prompt(current={"运输类型": "国内运输拼车"})
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text)
self.assertIn("---请复制以下内容---\n线路类别(必填):", text)
self.assertIn("线路类别(必填):\n1.1国内长途/零担", text)
self.assertIn("运输分类(必填):\n1.普件", text)
text = option_prompt(
current={
"运输类型": "国内运输拼车",
"_land_show_运输类型": "1",
"品名": "衣服",
}
)
head, _, tail = text.partition("---请复制以下内容---")
self.assertIn("运输类型(必填):1\n1.国内运输拼车", head)
self.assertIn("6.中港零担/集拼", head)
self.assertIn("货物品名(必填):衣服", head)
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", head)
self.assertIn("线路类别(必填):\n1.1国内长途/零担", tail)
self.assertIn("运输分类(必填):\n1.普件", tail)
self.assertNotIn("运输类型(必填)", tail)
self.assertNotIn("货物品名", tail)
self.assertFalse(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"}))
self.assertFalse(option_prompt_needs_sample(current={}))
self.assertNotIn("国内运输拼车 + 国内长途/零担", text)
self.assertNotIn("请一次选择:运输类型", text)
self.assertNotIn("运输类型(必填)", text)
def test_sample_pairs_go_to_image_not_text(self):
text = option_prompt(current={})
@@ -218,6 +235,46 @@ class LandOptionsTest(unittest.TestCase):
self.assertFalse(got.get("线路类别"))
self.assertFalse(got.get("运输分类"))
def test_paste_number_is_echoed_and_kept(self):
"""冒号后写 5,下一张清单回显 5,不改成标准名称。"""
got = harvest_land_options("运输类型(必填):5")
self.assertEqual(got.get("运输类型"), "中港整车")
self.assertEqual(got.get("_land_show_运输类型"), "5")
nxt = harvest_land_options("线路类别(必填):5.1", current=got)
self.assertEqual(nxt.get("_land_show_运输类型"), "5")
self.assertEqual(nxt.get("_land_show_线路类别"), "5.1")
self.assertEqual(nxt.get("线路类别"), "中港/中亚/中欧")
text = option_prompt(current={**nxt, "品名": ""})
head, _, tail = text.partition("---请复制以下内容---")
self.assertIn("运输类型(必填):5\n1.国内运输拼车", head)
self.assertIn("线路类别(必填):5.1\n1.1国内长途/零担", head)
self.assertIn("货物品名(必填):\n(如:衣服)", tail)
self.assertNotIn("运输类型(必填)", tail)
self.assertNotIn("线路类别(必填)", tail)
def test_prose_type_echoes_words(self):
got = harvest_land_options("中港整车,衣服,广州到香港")
self.assertEqual(got.get("运输类型"), "中港整车")
self.assertEqual(got.get("_land_show_运输类型"), "中港整车")
def test_placeholder_cargo_name_is_still_empty(self):
self.assertEqual(land_cargo_name({"品名": "衣服"}), "衣服")
self.assertEqual(land_cargo_name({"货物品名": "零件"}), "零件")
self.assertEqual(land_cargo_name({"品名": "(如:衣服)"}), "")
text = option_prompt(
current={
"运输类型": "中港整车",
"线路类别": "中港/中亚/中欧",
"运输分类": "急件",
"品名": "(如:衣服)",
}
)
head, _, tail = text.partition("---请复制以下内容---")
self.assertIn("运输类型(必填):中港整车", head)
self.assertNotIn("货物品名(必填):", head)
self.assertIn("货物品名(必填):\n(如:衣服)", tail)
self.assertNotIn("运输类型(必填)", tail)
def test_confirm_intent(self):
self.assertTrue(looks_like_confirm("确认"))
self.assertTrue(looks_like_confirm("确定"))
@@ -28,6 +28,7 @@ from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession
from agent.policy.land_text_flow import LandTextInquiryFlow
from agent.schema.land_options import option_prompt
class CountingLedger(MemoryLedger):
@@ -75,6 +76,9 @@ class LandTextInquiryFrontTests(unittest.TestCase):
self.assertIn("中港整车", text)
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text)
self.assertIn("---请复制以下内容---", text)
head, _, tail = text.partition("---请复制以下内容---")
self.assertIn("货物品名(必填):衣服", head)
self.assertNotIn("货物品名", tail)
self.assertNotIn("请补充陆运类型:", text)
self.assertFalse(any(x.get("msgtype") == "image" for x in self.extras))
@@ -232,6 +236,80 @@ class LandTextInquiryFrontTests(unittest.TestCase):
self.assertEqual(phase, "wait_confirm")
self.assertEqual(self.flow.session_of("u1").facts.get("通关口岸"), "皇岗")
def test_missing_cargo_name_stays_on_option_sheet(self) -> None:
"""三项齐了但没品名,仍停在第一张复制清单,不进始发站补问。"""
phase = self.flow.on_text(
sender_id="u1",
text="国内运输拼车 国内长途/零担 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "深圳",
"体积": "100CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
text = self._joined()
head, _, tail = text.partition("---请复制以下内容---")
self.assertIn("运输类型(必填):国内运输拼车", head)
self.assertIn("货物品名(必填):\n(如:衣服)", tail)
self.assertNotIn("始发站", text)
self.assertNotIn(copy.LAND_CONFIRM_TAIL, text)
def test_typed_number_is_shown_on_next_sheet(self) -> None:
self.flow.on_text(
sender_id="u1",
text="陆运",
reply=self._reply,
injected_facts={"起运港": "广州", "目的港": "深圳"},
injected_mode="LAND",
)
self.replies.clear()
phase = self.flow.on_text(
sender_id="u1",
text="运输类型(必填):5",
reply=self._reply,
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
text = self._joined()
head, _, tail = text.partition("---请复制以下内容---")
self.assertIn("运输类型(必填):5\n1.国内运输拼车", head)
self.assertNotIn("运输类型(必填)", tail)
self.assertIn("货物品名(必填):", tail)
self.assertEqual(self.flow.session_of("u1").facts.get("运输类型"), "中港整车")
def test_blank_class_on_paste_is_not_taken_from_menu(self) -> None:
"""复制清单里运输分类冒号是空的,不能把菜单第一项或模型抄来的分类当成已填。"""
sheet = option_prompt(current={})
sheet = sheet.replace("运输类型(必填):\n", "运输类型(必填):1\n", 1)
sheet = sheet.replace("货物品名(必填):\n", "货物品名(必填):茶叶\n", 1)
phase = self.flow.on_text(
sender_id="u1",
text=sheet,
reply=self._reply,
injected_facts={
"运输类型": "国内运输拼车",
"运输分类": "普件(常用线路/常规车型)",
"品名": "茶叶",
},
injected_mode="LAND",
)
self.assertEqual(phase, "need_land_options")
facts = self.flow.session_of("u1").facts
self.assertEqual(facts.get("运输类型"), "国内运输拼车")
self.assertEqual(facts.get("品名"), "茶叶")
self.assertFalse(facts.get("运输分类"))
self.assertFalse(facts.get("线路类别"))
head, _, tail = self._joined().partition("---请复制以下内容---")
self.assertIn("运输类型(必填):1", head)
self.assertIn("货物品名(必填):茶叶", head)
self.assertNotIn("运输分类(必填):普件", head)
self.assertIn("运输分类(必填):", tail)
self.assertIn("线路类别(必填):", tail)
def test_missing_volume_no_confirm(self) -> None:
phase = self.flow.on_text(
sender_id="u1",
+2 -1
View File
@@ -545,7 +545,8 @@ class FlowTests(unittest.TestCase):
self.assertEqual(phase, "clarify")
card = self.last_prompt()
self.assertNotIn("起运港:(如:上海)", card)
self.assertIn("始发站", card)
self.assertIn("货物品名(必填)", card)
self.assertNotIn("始发站", card)
sess = self.flow.session_of("sales")
segs = self.flow._segments(sess)
sea = next(seg for seg in segs if seg.mode == "SEA")