空运锁舱按报价来源出站,航线回 2 视为不用 TMS。补陆运常用线路超时,海运举例行不再当成已选分类。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-24 18:28:13 +08:00
co-authored by Cursor
parent 37278aa0db
commit 5402f94d30
21 changed files with 624 additions and 94 deletions
+24 -1
View File
@@ -21,6 +21,20 @@ logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
def _memory_quote_can_lock(quote: dict[str, Any]) -> bool:
"""单测锁舱:有 TMS 编号,或有人工总价/费用,才算有报价。不编造编号。"""
src = dict(quote or {})
label = str(src.get("source_label") or src.get("sourceLabel") or "")
if "确认" in label and "TMS" in label.upper():
return bool(str(src.get("quoteId") or "").strip()) and not str(src.get("quoteId") or "").upper().startswith("QT-")
if str(src.get("total") or "").strip() not in {"", "-"}:
return True
if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"):
return True
quote_id = str(src.get("quoteId") or "").strip()
return bool(quote_id) and not quote_id.upper().startswith("QT-WO")
@dataclass
class MemoryTicket:
"""内存工单。"""
@@ -818,11 +832,20 @@ class MemoryLedger:
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
quote = dict(ticket.quote or {}) if ticket else {}
if not _memory_quote_can_lock(quote):
return {
"ok": False,
"error": "no_quote",
"failReason": "这张单还没有报价,不能锁舱",
}
quote_id = str(quote.get("quoteId") or "").strip()
if quote_id.upper().startswith("QT-WO"):
quote_id = ""
return {
"ok": True,
"status": "LOCKED",
"lockId": f"CA-{no}-LOCK",
"quoteId": str(quote.get("quoteId") or f"QT-{no}"),
"quoteId": quote_id,
"optionNo": option_no,
"tmsRequestId": f"TMS-LOCK-{no}",
}
+4 -3
View File
@@ -680,10 +680,8 @@ def apply_air_lock(
)
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
return "wait_lock_option"
if not picked and options:
if not picked and len(options) == 1:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
if not picked:
picked = "AIR-OPT-01"
locker = getattr(flow.ledger, "lock_cabin", None)
if not callable(locker):
send_group_text(
@@ -712,6 +710,9 @@ def apply_air_lock(
is_ok=lambda x: bool((x or {}).get("ok")),
)
if not out.get("ok"):
if str(out.get("error") or "") == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
why = copy.air_cabin_fail_reason(
tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket),
@@ -36,6 +36,7 @@ _SALES_CODES = {
}
_LAND_PRODUCT_CODES = {
"普件(常用线路/常规车型)": "land_common",
"普件(东南亚、中港常用线路/常规车型)": "land_hk_common",
"普件(东南亚、中亚、中欧涉及国内+国外段)": "land_hk_cross",
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)": "land_hk_special",
+7 -2
View File
@@ -2811,8 +2811,13 @@ def air_fields_denied() -> str:
def air_no_tms_quote_to_lock(work_order_no: str) -> str:
return air_lock_need_quote(work_order_no)
def air_lock_need_quote(work_order_no: str) -> str:
"""没有任何报价时的锁舱拒绝。不提 TMS,避免把人工报价说成没报价。"""
no = (work_order_no or "").strip()
return f"工单{no}还没有可锁的 TMS 报价,不能锁舱。"
return f"工单{no}还没有报价,不能锁舱。"
def air_ask_lock_option(options: list) -> str:
@@ -2857,7 +2862,7 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
"""
锁舱/释放失败原因:原样回 TMS 的 failReason。
没有报价也可以锁舱,禁止改写成「报价选项不存在」或「航班已起飞」。
禁止改写成「报价选项不存在」或「航班已起飞」。
flight_date / today 只为兼容旧调用,不再用来改写原文。
"""
_ = flight_date
@@ -22,6 +22,7 @@ from agent.routing.group_intent import (
INTENT_NEGOTIATE,
INTENT_OTHER,
INTENT_QUOTE,
_exact_tms_choice,
_hint_confirm_tms,
_hint_deal_outcome,
_hint_tms_choice,
@@ -94,6 +95,10 @@ def classify_air_group_text(
locked = _hint_lock_or_release(raw)
if locked:
return locked
# 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。
exact = _exact_tms_choice(raw)
if exact:
return exact
# wo_plus_deal_not_activate:工单号+成交跟进同一句先关单,不能只当激活。
closed = _hint_deal_outcome(raw)
if copy.extract_work_order_no(raw) and closed:
+18 -1
View File
@@ -134,6 +134,10 @@ def classify_group_text(
raw = (text or "").strip()
if not raw:
return INTENT_OTHER
# 问句让回 1 或 2。整句就是选项时先认,不能把「2」抽成价格。
exact = _exact_tms_choice(raw)
if exact:
return exact
try:
from agent.config import get_settings
@@ -184,7 +188,20 @@ def classify_group_text(
def _norm_intent_text(text: str) -> str:
return (text or "").replace(" ", "").replace(" ", "").lower()
"""去掉空格和 @询价小助手,方便整句就是「2」时对上选项。"""
blob = (text or "").replace(" ", "").replace(" ", "").lower()
blob = blob.replace("@询价小助手", "").replace("询价小助手", "")
return blob.strip()
def _exact_tms_choice(text: str) -> str:
"""整句只是 1 / 2 / 第一个 / 第二个。这是在选题,不是报价金额。"""
blob = _norm_intent_text(text)
if blob in _KEEP_CHOICE:
return INTENT_KEEP_TMS
if blob in _DROP_CHOICE:
return INTENT_DROP_TMS
return ""
def _hint_confirm_tms(text: str) -> str:
@@ -28,6 +28,7 @@ ROUTES = (
"东南亚",
)
LAND_CLASSES = (
"普件(常用线路/常规车型)",
"普件(东南亚、中港常用线路/常规车型)",
"普件(东南亚、中亚、中欧涉及国内+国外段)",
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
+24 -4
View File
@@ -8,8 +8,13 @@
from __future__ import annotations
import re
SEA_CLASSES = ("Port to Port", "其他")
# 补问清单下一行「(如:Port to Port、其他)」只是举例,不能当成销售已选。
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
_ALIASES = (
("port to port", "Port to Port"),
@@ -29,9 +34,16 @@ def canonicalize_sea_class(raw: str) -> str:
text = (raw or "").strip()
if not text:
return ""
lowered = text.lower()
# 企微常把空格发成不换行空格,Port to Port 对不上普通空格就会整段丢掉。
folded = re.sub(r"\s+", " ", text).strip()
lowered = folded.lower()
squashed = re.sub(r"\s+", "", lowered)
if "port to port" in lowered or "porttoport" in squashed:
return "Port to Port"
for needle, canon in _ALIASES:
if needle in lowered or needle in text:
if needle == "port to port":
continue
if needle in lowered or needle in text or needle in squashed:
return canon
stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip()
if stripped in SEA_CLASSES:
@@ -39,15 +51,23 @@ def canonicalize_sea_class(raw: str) -> str:
return ""
def _without_clarify_examples(text: str) -> str:
"""丢掉补问举例行。销售只在冒号后作答,「(如:…)」整行不参与判断。"""
return "\n".join(
line for line in (text or "").splitlines() if not _CLARIFY_EXAMPLE_LINE.match(line)
)
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
"""
当前句能认则覆盖;认不出则保留已有标准值。
参数:本轮原文、已有 facts。返回合并后的 facts 副本。
非法旧值会丢掉,避免「门到门」一直占着必填位。
举例行「(如:Port to Port、其他)」先丢掉,避免示例里的 Port to Port 盖掉冒号后的「其它」。
「其它」与「其他」都收成「其他」。非法旧值会丢掉,避免「门到门」一直占着必填位。
"""
merged = dict(current or {})
found = canonicalize_sea_class(text or "")
found = canonicalize_sea_class(_without_clarify_examples(text or ""))
if found:
merged["运输分类"] = found
return merged
+32 -5
View File
@@ -695,6 +695,33 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
def test_reply_2_means_drop_tms_not_price(self) -> None:
"""问 1/2 之后,航线回 @询价小助手 2 是选不用 TMS,不能把价格改成 2。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="d0"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._msg(sender="air1", text="空运费 900USD", chat="wr_air_1", mid="d1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
injected_quote={"total": "USD 900", "currency": "USD", "source_label": "航线报价"},
)
self.assertEqual(phase, "wait_tms_choice")
phase = handle_air_group(
self._msg(sender="air1", text="@询价小助手 2", chat="wr_air_1", mid="d2"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "quoted")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.quote.get("total"), "USD 900")
self.assertEqual(ticket.quote.get("source_label"), "航线报价")
self.assertNotEqual(ticket.quote.get("quoteId"), "QT-AIR-1")
def test_airline_quote_without_tms_sends_brief_sheet_deal(self) -> None:
wo = self._create_air(quote={}, status="询价中")
handle_air_group(
@@ -816,10 +843,10 @@ 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()))
self.assertTrue(any("舱位选项:AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("还没有可锁的 TMS 报价" in t for t in self._texts()))
self.assertEqual(phase, "no_quote")
self.assertTrue(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()))
def test_manual_quote_without_quote_id_can_lock(self) -> None:
wo = self._create_air(
@@ -1233,7 +1260,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("与工单不匹配", joined)
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""没有报价也可以锁舱;群里只回 TMS 原因,不要说选项不匹配。"""
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
wo = self._create_air(quote={}, status="询价中")
self.ledger._cabin_lock_result = {
"ok": False,
@@ -62,6 +62,18 @@ class TestTimeoutCode(unittest.TestCase):
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="急件"),
"land_hk_express",
)
self.assertEqual(
timeout_code(
wait_kind=WAIT_PRODUCT_QUOTE,
business_line="LAND",
transport_class="普件(常用线路/常规车型)",
),
"land_common",
)
self.assertEqual(
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="常用线路"),
"",
)
self.assertEqual(
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class=""),
"",
+17 -3
View File
@@ -115,8 +115,9 @@ class LandOptionsTest(unittest.TestCase):
self.assertIn("6.中港零担/集拼", text)
self.assertIn("线路类别(必填):\n1.1国内长途/零担", text)
self.assertIn("6.1中港/中亚/中欧", text)
self.assertIn("运输分类(必填):\n1.普件(东南亚、中港常用线路/常规车型)", text)
self.assertIn("4.急件", text)
self.assertIn("运输分类(必填):\n1.普件(常用线路/常规车型)", text)
self.assertIn("2.普件(东南亚、中港常用线路/常规车型)", text)
self.assertIn("5.急件", text)
self.assertNotIn("请一次选择:运输类型", text)
self.assertNotIn("可选择回复", text)
self.assertNotIn("填写样例见图", text)
@@ -185,13 +186,26 @@ class LandOptionsTest(unittest.TestCase):
pasted = (
"运输类型(必填):2\n"
"线路类别(必填):2.1\n"
"运输分类(必填):4"
"运输分类(必填):5"
)
got = harvest_land_options(pasted)
self.assertEqual(got.get("运输类型"), "国内运输整车")
self.assertEqual(got.get("线路类别"), "国内长途/零担")
self.assertEqual(got.get("运输分类"), "急件")
def test_common_route_class_only_full_text_or_number(self):
"""新项只认编号 1 或全文;短话和逗号写法不收。"""
full = "普件(常用线路/常规车型)"
self.assertEqual(harvest_land_options(full).get("运输分类"), full)
numbered = harvest_land_options(
"1",
current={"运输类型": "国内运输拼车", "线路类别": "国内长途/零担"},
)
self.assertEqual(numbered.get("运输分类"), full)
self.assertFalse(harvest_land_options("常用线路").get("运输分类"))
self.assertFalse(harvest_land_options("常规车型").get("运输分类"))
self.assertFalse(harvest_land_options("普件,常用线路/常规车型").get("运输分类"))
def test_route_code_infers_type_when_empty(self):
got = harvest_land_options("1.1")
self.assertEqual(got.get("运输类型"), "国内运输拼车")
+11
View File
@@ -26,6 +26,9 @@ class SeaOptionsTest(unittest.TestCase):
self.assertEqual(canonicalize_sea_class("port to port"), "Port to Port")
self.assertEqual(canonicalize_sea_class("P2P"), "Port to Port")
self.assertEqual(canonicalize_sea_class("港到港"), "Port to Port")
glued = "15件,Port\u00a0to\u00a0Port\n贸易条款:DOOR-DOOR"
self.assertEqual(canonicalize_sea_class(glued), "Port to Port")
self.assertEqual(harvest_sea_class(glued, {}).get("运输分类"), "Port to Port")
def test_other_alias(self):
self.assertEqual(canonicalize_sea_class("其他"), "其他")
@@ -42,6 +45,14 @@ class SeaOptionsTest(unittest.TestCase):
got = harvest_sea_class("运输分类:其他", {"运输分类": "Port to Port"})
self.assertEqual(got["运输分类"], "其他")
def test_clarify_example_does_not_override_filled_other(self):
"""举例行里的 Port to Port 不能盖掉冒号后的其它/其他。"""
pasted = "1.运输分类(必填):其它\n(如:Port to Port、其他)"
got = harvest_sea_class(pasted, {"运输分类": "Port to Port"})
self.assertEqual(got["运输分类"], "其他")
other = harvest_sea_class("运输分类(必填):其他\n(如:Port to Port、其他)", {})
self.assertEqual(other["运输分类"], "其他")
def test_door_to_door_not_kept_as_trade_terms(self):
text = "海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件"
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "品名": "机器设备"})