空运成交后可在本群继续锁舱;调价同步舱位费用桶;出单按空运段骨架落费并保留陆运车型尺寸;导出文件名乱码修复。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user