空运成交后可在本群继续锁舱;调价同步舱位费用桶;出单按空运段骨架落费并保留陆运车型尺寸;导出文件名乱码修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-14 15:34:24 +08:00
co-authored by Cursor
parent 92bdf56a4d
commit cc8529f569
29 changed files with 1941 additions and 276 deletions
+76 -51
View File
@@ -1055,6 +1055,8 @@ class AirGroupCollabTests(unittest.TestCase):
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已成交")
self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts()))
self.assertTrue(any("已记录为「已成交」" in t for t in self._texts()))
def test_lock_requires_work_order(self) -> None:
phase = handle_air_group(
@@ -1074,8 +1076,9 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "no_quote")
self.assertTrue(any("还没有报价,不能锁舱" in t for t in self._texts()))
self.assertEqual(phase, "lock_need_deal")
self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts()))
self.assertFalse(any("还没有报价,不能锁舱" in t for t in self._texts()))
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("执行成功" in t for t in self._texts()))
@@ -1087,7 +1090,7 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "manual",
"source_label": "产品报价",
},
status="已报价",
status="已成交",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_manual", mid="lm1"),
@@ -1136,7 +1139,7 @@ class AirGroupCollabTests(unittest.TestCase):
},
],
},
status="已报价",
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1167,7 +1170,8 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-23",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1217,7 +1221,8 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-24",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1257,7 +1262,8 @@ class AirGroupCollabTests(unittest.TestCase):
"total": "CNY 1800",
"source": "manual",
"source_label": "产品报价",
}
},
status="已成交",
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
@@ -1285,7 +1291,8 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertEqual(calls[0].get("flight_no"), "MU5123")
self.assertFalse(any("锁舱参数不完整" in t for t in self._texts()))
def test_lock_success_and_release_after_deal_blocked(self) -> None:
def test_deal_then_lock_then_release(self) -> None:
"""成交前不能锁。成交后点名航线,锁成功可取消,不再问一次是否成交。"""
wo = self._create_air()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l2"),
@@ -1293,36 +1300,64 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
handle_air_group(
self.assertEqual(phase, "lock_need_deal")
self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts()))
phase = handle_air_group(
self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="l3"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_won",
)
self.assertEqual(phase, "done")
self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts()))
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="l4"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_release",
)
self.assertEqual(phase, "release_deal_blocked")
self.assertTrue(any("已成交" in t for t in self._texts()))
def test_lost_can_release(self) -> None:
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="r0"),
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l4"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
handle_air_group(
self._msg(sender="sales1", text="未成交", chat="wr_air_1", mid="r1"),
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" 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"),
flow=self.flow,
ledger=self.ledger,
injected_intent="deal_lost",
)
self.assertEqual(phase, "release_ok")
self.assertTrue(any("取消舱位成功" in t for t in self._texts()))
def test_negotiating_and_lost_cannot_lock(self) -> None:
wo_talk = self._create_air(status="协商中")
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo_talk}锁舱", chat="wr_air_talk", mid="ng1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_negotiating")
self.assertTrue(any("还在协商中,成交后才能锁舱" in t for t in self._texts()))
self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts()))
wo_lost = self._create_air(status="未成交")
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo_lost}锁舱", chat="wr_air_lost", mid="ls1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_lost")
self.assertTrue(any("已是「未成交」,不能锁舱" in t for t in self._texts()))
self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts()))
def test_lost_can_release(self) -> None:
"""未成交不能新锁,但改规则前已经锁上的仍可取消。"""
wo = self._create_air(status="未成交")
self.ledger.patch_collab_facts(
work_order_no=wo,
facts={"__air_lock_id": "CA-OLD-LOCK"},
sender_id="air1",
)
before = len(self._texts())
phase = handle_air_group(
@@ -1338,7 +1373,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_release_wording_asks_before_cancel(self) -> None:
"""说出释放舱位只提问。销售回「是」不算,航线确认后才取消。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask", mid="ask0"),
flow=self.flow,
@@ -1370,7 +1405,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
def test_release_wording_can_be_declined(self) -> None:
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask_no", mid="no0"),
flow=self.flow,
@@ -1393,7 +1428,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts()))
def test_cancel_cabin_words_release_immediately(self) -> None:
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_now", mid="now0"),
flow=self.flow,
@@ -1430,7 +1465,7 @@ class AirGroupCollabTests(unittest.TestCase):
"status": "active",
}
)
wo = self._create_air()
wo = self._create_air(status="已成交")
phase = handle_air_group(
self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s2"),
flow=self.flow,
@@ -1439,7 +1474,8 @@ class AirGroupCollabTests(unittest.TestCase):
)
self.assertEqual(phase, "lock_ok")
def test_multi_option_asks(self) -> None:
def test_multi_tms_option_locks_first_until_named(self) -> None:
"""TMS 有多条舱位且没点名时,直接锁第一条,不再追问三项。"""
wo = self._create_air(
quote={
"total": "USD 800",
@@ -1449,7 +1485,8 @@ class AirGroupCollabTests(unittest.TestCase):
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"},
{"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"},
],
}
},
status="已成交",
)
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="m1"),
@@ -1457,16 +1494,9 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "wait_lock_params")
phase = handle_air_group(
self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_pick_option",
)
self.assertEqual(phase, "lock_ok")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-02")
self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-01")
def _file(self, *, sender: str, filename: str, chat: str, mid: str, text: str = "") -> InboundMessage:
return InboundMessage(
@@ -1787,7 +1817,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_lock_fail_tells_operator_and_tms_reason(self) -> None:
"""锁舱失败群里要回操作人和 TMS 原因,不能被系统异常兜底盖掉。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
@@ -1810,25 +1840,20 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("与工单不匹配", joined)
def test_lock_fail_does_not_block_airline_quote(self) -> None:
"""锁舱失败后,航线再报手工价必须收下,不能回 TMS 接口异常。"""
"""成交前锁舱不会打 TMS,也不标系统异常,航线仍可继续报价。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_lock_quote", mid="lq0"),
flow=self.flow,
ledger=self.ledger,
)
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
"failReason": "该航班日期不存在可锁定的配载记录",
}
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_quote", mid="lq1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "system_exception")
self.assertEqual(phase, "lock_need_deal")
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"),
@@ -1843,7 +1868,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
@@ -1863,7 +1888,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_paused_lock_retries_and_shows_tms_reason(self) -> None:
"""上次锁舱失败标了系统异常后,再锁仍要打 TMS,不能只回兜底。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
self.ledger.mark_system_exception(
work_order_no=wo,
system_exception="TMS",
@@ -1937,7 +1962,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_release_fail_tells_operator_and_tms_reason(self) -> None:
"""释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。"""
wo = self._create_air()
wo = self._create_air(status="已成交")
handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_rl_fail", mid="rf0"),
flow=self.flow,
@@ -65,6 +65,32 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertTrue(needs_route_pick(quote))
self.assertEqual(len(parse_air_options(quote)), 2)
def test_route_codes_tolerates_string_selected_option(self) -> None:
"""主账若把 selected_air_option 存成 JSON 串或编号串,不能再 dict(字符串) 炸掉成交卡。"""
quote_json = {
"selected_air_option": (
'{"originAirportCode":"NKG","destinationAirportCode":"KUL"}'
),
}
self.assertEqual(
route_codes(None, {"起运港": "南京", "目的港": "吉隆坡"}, quote_json),
"NKG-KUL",
)
quote_code = {
"selected_air_option": "AIR-OPT01",
"airOptions": [
{
"optionNo": "AIR-OPT01",
"originAirportCode": "SZX",
"destinationAirportCode": "CGK",
}
],
}
self.assertEqual(
route_codes(None, {"起运港": "深圳", "目的港": "雅加达"}, quote_code),
"SZX-CGK",
)
def test_select_writes_origin(self) -> None:
option = {"originAirportCode": "SZX", "destinationAirportCode": "KUL"}
facts = apply_selected_option({"目的港": "吉隆坡"}, option)
@@ -1392,8 +1392,41 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["空运费"].get("amount") or ""), "9900.00")
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
self.assertEqual(str(rows["空运费"].get("quantity") or ""), "100")
self.assertEqual(str(rows["空运费"].get("unit") or "").upper(), "KG")
self.assertEqual(str(rows["空运费"].get("project") or ""), "空运段")
self.assertEqual(str(quote.get("total") or ""), "9900.0")
def test_air_origin_fee_underscore_unit_board(self) -> None:
"""WO008:起运港、增值费_板,单价… — 单位是板,不能落成票;项目是起运港。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"起运港、增值费_板,单价CNY10,数量100,金额CNY1000@询价小助手",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("增值费", rows)
self.assertEqual(str(rows["增值费"].get("unit") or ""), "板")
self.assertEqual(str(rows["增值费"].get("unit_price") or ""), "10")
self.assertEqual(str(rows["增值费"].get("quantity") or ""), "100")
self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000")
self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港")
self.assertEqual(str(quote.get("total") or ""), "1000.0")
def test_air_origin_fee_mention_prefix_keeps_project_and_unit(self) -> None:
"""WO009:句首 @询价小助手 不能挡住起运港分段;逗号单位「板」不能丢。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"@询价小助手 起运港、增值费,板,单价CNY10,数量100,金额CNY1000",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("增值费", rows)
self.assertEqual(str(rows["增值费"].get("unit") or ""), "板")
self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港")
self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000")
def test_fee_line_text_is_a_product_quote(self) -> None:
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
from agent.llm.extract_text import extract_product_quote
+1 -1
View File
@@ -495,7 +495,7 @@ class TestConfirmSystemException(unittest.TestCase):
"quoteId": "QT-AIR-1",
"airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}],
},
to_status="已报价",
to_status="已成交",
)
self.ledger.mark_system_exception(
work_order_no=air_wo,