空运锁舱按报价来源出站,航线回 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, {"贸易条款": "门到门", "品名": "机器设备"})
@@ -41,7 +41,7 @@ public class InquiryResponseTimeoutServiceImpl
private static final Map<String, String> ID_TO_TIMEOUT_CODE = new LinkedHashMap<>();
static {
// 陆运产品仅保留 4 项运输分类(与线路解耦);旧逗号写法一次性纠偏
// 陆运产品 5 项运输分类(与线路解耦);旧逗号写法一次性纠偏
LAND_PRODUCT_TYPE_RENAMES.put(
"普件,中港、东南亚常用线路/常规车型",
"普件(东南亚、中港常用线路/常规车型)");
@@ -58,7 +58,8 @@ public class InquiryResponseTimeoutServiceImpl
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000004", "land_sales");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000005", "sea_product_ptp");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000006", "sea_product_other");
// 陆运产品运输分类 4 项(编码沿用 land_hk_*,勿再按线路拆 ltl/city/sea)
// 陆运产品运输分类 5 项(编码勿再按线路拆 ltl/city/sea)
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000020", "land_common");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000007", "land_hk_common");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
@@ -26,10 +26,9 @@ import java.util.Map;
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
* <p>
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
* 锁舱优先带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
* 没有 5.2 报价也可以锁舱:TMS 锁舱这两项必填,缺了会 1001「参数格式错误」。
* 此时补 QT-{工单号} + AIR-OPT-01,禁止拿空字段出站。
* 有真实 TMS 编号时不准用自编号盖掉,否则会回 2006。
* 没有报价不能锁舱,禁止编造 QT-{工单号}、AIR-OPT-01。
* 只看最新一版:库里 source=TMS(或航线确认 TMS)出站写成 source=SYSTEM;人工报价出站 source=MANUAL。
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班和费用不拦。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@@ -67,15 +66,34 @@ public class InquiryTmsCabinService {
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
}
Map<String, Object> quote = latestQuote(no);
quote = refreshLockQuote(ticket, quote);
String quoteId = lockQuoteId(resolveQuoteId(quote, no), no);
List<InquiryQuote> rows = quoteRows(no);
if (rows.isEmpty()) {
return fail("no_quote", "这张单还没有报价,不能锁舱");
}
Map<String, Object> quote = payloadOf(rows.get(0));
boolean manual = isManualQuote(quote);
if (!manual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
quote = refreshLockQuote(ticket, quote);
}
String quoteId = manual ? realTmsQuoteId(rows, no) : resolveQuoteId(quote, no);
if (!manual && StringUtils.isBlank(quoteId)) {
quoteId = realTmsQuoteId(rows, no);
}
if (!manual && StringUtils.isBlank(quoteId)) {
return fail("no_quote", "这张单还没有报价,不能锁舱");
}
Map<String, Object> option = resolveOption(quote, optionNo);
if (option == null) {
option = new LinkedHashMap<>();
}
String resolvedOption = lockOptionNo(text(option, "optionNo", "option_no"));
option.put("optionNo", resolvedOption);
if (manual && StringUtils.isBlank(text(option, "optionNo", "option_no"))) {
// 手工价往往只有金额。舱位、航班仍用本单那条 TMS 报价,不另编编号。
Map<String, Object> tmsOption = tmsOptionForLock(rows, no, optionNo);
if (!tmsOption.isEmpty()) {
option = tmsOption;
}
}
String resolvedOption = text(option, "optionNo", "option_no");
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放锁舱");
}
@@ -83,15 +101,20 @@ public class InquiryTmsCabinService {
req.put("workOrderNo", no);
req.put("quoteId", quoteId);
req.put("optionNo", resolvedOption);
req.put("flightDate", quoteDate(ticket));
req.put("origin", firstNonBlank(text(option, "originAirportCode"), ticket.getOrigin(),
req.put("flightDate", firstNonBlank(text(option, "flightDate"), quoteDate(ticket)));
req.put("origin", firstNonBlank(text(option, "originAirportCode"),
fact(ticket, "originCode"),
text(option, "origin"), ticket.getOrigin(),
fact(ticket, "起运地", "起运港")));
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"), ticket.getDestination(),
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"),
fact(ticket, "destinationCode"),
text(option, "destination"), ticket.getDestination(),
fact(ticket, "目的地", "目的港")));
req.put("airline", firstNonBlank(text(option, "airlineCode", "airline"),
text(option, "airlineName"),
text(quote, "airlineCode", "airline", "airlineName"),
fact(ticket, "航司", "航司代码")));
fact(ticket, "航司", "航司代码"),
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")))));
req.put("weightKg", ticket.getWeightKg());
req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces());
@@ -101,6 +124,15 @@ public class InquiryTmsCabinService {
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("instructionText", text(body, "instructionText", "instruction_text"));
if (StringUtils.isNotBlank(text(body, "requestedUntil"))) {
req.put("requestedUntil", text(body, "requestedUntil"));
}
if (manual) {
fillManualLock(req, quote, option);
} else {
// 库里仍是 source=TMS。TMS 锁舱要求纯系统报价带 source=SYSTEM,只写在出站正文。
req.put("source", "SYSTEM");
}
return invokeLock(ticket, req, quoteId, resolvedOption);
}
@@ -310,8 +342,267 @@ public class InquiryTmsCabinService {
}
/**
* 库里的真实 TMS 报价编号。自编 QT-工单号不算:先再查一次 5.2,
* 仍没有才在 {@link #lockQuoteId} 补占位,给无报价锁舱用。
* 人工报价出站:source 只写在 TMS 正文,不回写报价表。
* 费用数组用报价里已有的;没有就不放。不用总价编一条空运费。
*/
static void fillManualLock(Map<String, Object> req, Map<String, Object> quote, Map<String, Object> option) {
req.put("source", "MANUAL");
String until = firstNonBlank(text(quote, "validUntil", "valid_until"), text(option, "validUntil"));
if (StringUtils.isNotBlank(until) && !"-".equals(until)) {
req.put("validUntil", until);
}
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
text(quote, "transferAirport", "transferAirportCode"));
if (StringUtils.isNotBlank(transfer)) {
req.put("transferAirport", transfer);
}
String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"));
if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo);
}
String currency = firstNonBlank(text(quote, "currency", "currencyCode"), text(option, "currency"));
if (StringUtils.isNotBlank(currency)) {
req.put("currency", currency);
}
String total = firstNonBlank(text(quote, "totalSalePrice", "salePrice", "total"), text(option, "salePrice"));
if (StringUtils.isNotBlank(total) && !"-".equals(total)) {
req.put("totalSalePrice", total);
}
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
splitFeeRows(quote, departure, fees);
if (!departure.isEmpty()) {
req.put("departureCharges", toLockFees(departure, true));
}
if (!fees.isEmpty()) {
req.put("feeItems", toLockFees(fees, false));
}
}
/**
* 最新一版是不是人工报价。航线只确认沿用 TMS 不算。
* 认 payload 里的 source / source_label,因为落库时确认 TMS 也会被写成 PRODUCT。
*/
static boolean isManualQuote(Map<String, Object> quote) {
String label = text(quote, "source_label", "sourceLabel");
if (label.contains("确认") && label.toUpperCase().contains("TMS")) {
return false;
}
String src = text(quote, "source").toUpperCase();
if ("SALES_ADJUST".equals(src) || label.contains("销售调价")) {
return true;
}
if ("PRODUCT".equals(src) || "MANUAL".equals(src)) {
return true;
}
return label.contains("航线报价") || label.contains("航线附件")
|| label.contains("在TMS上改") || label.contains("手工");
}
/**
* 本单更早报价里的真实 TMS 编号。自编 QT-工单号不算。
*/
/**
* 手工价没有舱位时,用本单 TMS 报价上的那一条。多条且没指定就不猜。
*/
static Map<String, Object> tmsOptionForLock(List<InquiryQuote> rows, String workOrderNo, String optionNo) {
if (rows == null) {
return new LinkedHashMap<>();
}
for (InquiryQuote row : rows) {
Map<String, Object> payload = payloadOf(row);
if (StringUtils.isBlank(resolveQuoteId(payload, workOrderNo))) {
continue;
}
if (isManualQuote(payload)) {
continue;
}
List<Map<String, Object>> options = airOptions(payload);
if (options.isEmpty()) {
continue;
}
if (StringUtils.isNotBlank(optionNo)) {
for (Map<String, Object> item : options) {
if (optionNo.equals(text(item, "optionNo", "option_no"))) {
return item;
}
}
continue;
}
if (options.size() == 1) {
return options.get(0);
}
return new LinkedHashMap<>();
}
return new LinkedHashMap<>();
}
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
if (rows == null) {
return "";
}
for (InquiryQuote row : rows) {
String id = resolveQuoteId(payloadOf(row), workOrderNo);
if (StringUtils.isNotBlank(id)) {
return id;
}
}
return "";
}
private List<InquiryQuote> quoteRows(String workOrderNo) {
if (StringUtils.isBlank(workOrderNo)) {
return new ArrayList<>();
}
List<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByDesc(InquiryQuote::getCreateTime)
.last("LIMIT 8"));
return rows == null ? new ArrayList<>() : rows;
}
private static Map<String, Object> payloadOf(InquiryQuote row) {
Map<String, Object> quote = new LinkedHashMap<>();
if (row == null) {
return quote;
}
if (StringUtils.isNotBlank(row.getPayloadJson())) {
JSONObject parsed = JSON.parseObject(row.getPayloadJson());
if (parsed != null) {
quote.putAll(parsed);
}
}
if (StringUtils.isNotBlank(row.getSource()) && StringUtils.isBlank(text(quote, "source"))) {
quote.put("source", row.getSource());
}
if (StringUtils.isNotBlank(row.getSourceLabel()) && StringUtils.isBlank(text(quote, "source_label", "sourceLabel"))) {
quote.put("source_label", row.getSourceLabel());
}
return quote;
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> feeRows(Map<String, Object> quote, Map<String, Object> option,
String... keys) {
List<Map<String, Object>> out = new ArrayList<>();
collectFeeRows(out, option, keys);
if (out.isEmpty()) {
collectFeeRows(out, quote, keys);
}
return out;
}
@SuppressWarnings("unchecked")
private static void collectFeeRows(List<Map<String, Object>> out, Map<String, Object> src, String... keys) {
if (src == null || keys == null) {
return;
}
for (String key : keys) {
Object raw = src.get(key);
if (raw instanceof String && String.valueOf(raw).trim().startsWith("[")) {
try {
raw = JSON.parseArray(String.valueOf(raw));
} catch (Exception ignore) {
raw = null;
}
}
if (raw instanceof JSONArray) {
raw = ((JSONArray) raw).toJavaList(Object.class);
}
if (!(raw instanceof List)) {
continue;
}
for (Object item : (List<?>) raw) {
if (item instanceof Map && StringUtils.isNotBlank(feeName((Map<String, Object>) item))) {
out.add((Map<String, Object>) item);
}
}
if (!out.isEmpty()) {
return;
}
}
}
/**
* 报价只存了 fee_rows 时,本地费进 departureCharges,其余进 feeItems。已分开的不再搬。
*/
private static void splitFeeRows(Map<String, Object> quote, List<Map<String, Object>> departure,
List<Map<String, Object>> fees) {
if (!departure.isEmpty() || !fees.isEmpty()) {
return;
}
for (Map<String, Object> row : feeRows(quote, null, "fee_rows", "fee_lines")) {
String name = feeName(row);
if ("运输费".equals(name) || "提送货费".equals(name) || "人工费".equals(name)) {
departure.add(row);
} else {
fees.add(row);
}
}
}
private static String feeName(Map<String, Object> row) {
return text(row, "chargeName", "feeName", "name", "label");
}
/**
* 费用行出站改成 TMS 字段:feeName/chargeName、unitPrice、数量和金额用数字。
* 库里的 name、unit_price 不原样送出,避免 TMS 报参数格式错误。
*/
static List<Map<String, Object>> toLockFees(List<Map<String, Object>> rows, boolean departure) {
List<Map<String, Object>> out = new ArrayList<>();
if (rows == null) {
return out;
}
for (Map<String, Object> row : rows) {
if (row == null) {
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
String name = feeName(row);
if (StringUtils.isNotBlank(name)) {
item.put(departure ? "chargeName" : "feeName", name);
}
String code = text(row, "feeCode", "fee_code");
if (!departure && StringUtils.isNotBlank(code)) {
item.put("feeCode", code);
}
putFeeNumber(item, "unitPrice", text(row, "unitPrice", "unit_price"));
putFeeNumber(item, "quantity", text(row, "quantity"));
putFeeNumber(item, "amount", text(row, "amount"));
String currency = text(row, "currency");
if (StringUtils.isNotBlank(currency)) {
item.put("currency", currency);
}
String unit = text(row, "unit", "单位");
if (StringUtils.isNotBlank(unit)) {
item.put("unit", unit);
}
if (!item.isEmpty()) {
out.add(item);
}
}
return out;
}
private static void putFeeNumber(Map<String, Object> item, String key, String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw)) {
return;
}
String text = raw.trim().replace(",", "");
int space = text.lastIndexOf(' ');
if (space >= 0) {
text = text.substring(space + 1).trim();
}
try {
item.put(key, new java.math.BigDecimal(text));
} catch (NumberFormatException ignore) {
// 不是数字就不送,避免把「99.00元」这类原文塞进 TMS 数字栏。
}
}
/**
* 库里的真实 TMS 报价编号。自编 QT-工单号不算,缺了也不补占位。
*/
static String resolveQuoteId(Map<String, Object> quote, String workOrderNo) {
String id = text(quote, "quoteId", "quote_id");
@@ -326,33 +617,21 @@ public class InquiryTmsCabinService {
}
/**
* 锁舱出站用的报价编号。TMS /air/cabin/lock 的 quoteId 必填,缺了回 1001。
* 有 5.2 真编号就用真编号;没有才补 QT-{工单号},允许无报价锁舱。
* 报价没给航司时,用航班号前两位字母。TH739 → TH。
* 不是字母开头就不猜,避免把纯数字航班编成航司。
*/
static String lockQuoteId(String resolved, String workOrderNo) {
if (StringUtils.isNotBlank(resolved)) {
return resolved.trim();
static String airlineFromFlight(String flightNo) {
String raw = StringUtils.trimToEmpty(flightNo).toUpperCase();
if (raw.length() >= 2
&& Character.isLetter(raw.charAt(0))
&& Character.isLetter(raw.charAt(1))) {
return raw.substring(0, 2);
}
String wo = StringUtils.trimToEmpty(workOrderNo);
if (wo.isEmpty()) {
return "";
}
return "QT-" + wo;
return "";
}
/**
* 锁舱出站用的舱位选项。TMS optionNo 必填,缺了回 1001。
* 有选项用选项;没有补 AIR-OPT-01。
*/
static String lockOptionNo(String resolved) {
if (StringUtils.isNotBlank(resolved)) {
return resolved.trim();
}
return "AIR-OPT-01";
}
/**
* 有舱位选项按名称挑;没有就补 AIR-OPT-01,无报价锁舱也要带上必填项。
* 有舱位按名称挑。没有舱位时,航线已指定的编号原样带上,不编 AIR-OPT-01。
*/
static Map<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
@@ -361,17 +640,16 @@ public class InquiryTmsCabinService {
if (picked != null) {
return picked;
}
if (options.size() == 1) {
if (options.size() == 1 && StringUtils.isBlank(optionNo)) {
return options.get(0);
}
return new LinkedHashMap<>();
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("optionNo", lockOptionNo(optionNo));
String airline = text(quote, "airlineCode", "airline", "airlineName");
if (StringUtils.isNotBlank(airline)) {
row.put("airlineCode", airline);
if (StringUtils.isBlank(optionNo)) {
return new LinkedHashMap<>();
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("optionNo", optionNo.trim());
return row;
}
@@ -459,7 +737,7 @@ public class InquiryTmsCabinService {
/**
* 锁舱/释放失败话术:原样用 TMS 的 failReason。
* 禁止按航班日改写成「起飞」,禁止把 2006 映射成「报价选项不存在」——没有报价也可以锁舱。
* 禁止按航班日改写成「起飞」,禁止把 2006 改写成另一句业务话。
*/
static String cabinFailReason(int code, Map<String, Object> tms, String flightDate) {
return cabinFailReason(code, tms, flightDate, "");
@@ -4,6 +4,7 @@ import org.apache.commons.lang3.StringUtils;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -12,8 +13,9 @@ import java.util.UUID;
* <p>
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
* quoteId / optionNo 必填:有 TMS 报价用真编号;没有时由 CabinService 补 QT-{工单号} / AIR-OPT-01。
* mapper 仍跳过空字符串,避免把 null 写成 JSON。
* 只有 TMS 报价:沿用原字段,不带 source、不带费用数组。
* 人工报价:调用方放入 source=MANUAL 和已有的费用;空字符串、空数组不写出。
* 不在这里编造 quoteId / optionNo。
*/
public final class TmsCabinRequestMapper {
@@ -45,6 +47,14 @@ public final class TmsCabinRequestMapper {
put(body, "operatorName", internal.get("operatorName"));
body.put("operatorRole", "AIRLINE");
put(body, "instructionText", internal.get("instructionText"));
put(body, "source", internal.get("source"));
put(body, "validUntil", internal.get("validUntil"));
put(body, "transferAirport", internal.get("transferAirport"));
put(body, "flightNo", internal.get("flightNo"));
put(body, "currency", internal.get("currency"));
putNumber(body, "totalSalePrice", asDecimal(internal.get("totalSalePrice")));
putList(body, "departureCharges", internal.get("departureCharges"));
putList(body, "feeItems", internal.get("feeItems"));
return body;
}
@@ -77,10 +87,53 @@ public final class TmsCabinRequestMapper {
}
}
/**
* 费用数组有行才写入。空数组不传,避免 TMS 把「没有明细」当成一条空费用。
*/
private static void putList(Map<String, Object> target, String key, Object value) {
if (value instanceof List && !((List<?>) value).isEmpty()) {
target.put(key, value);
}
}
private static BigDecimal asDecimal(Object value) {
if (value instanceof BigDecimal) {
return (BigDecimal) value;
}
if (value instanceof Number) {
return new BigDecimal(String.valueOf(value));
}
if (value == null || StringUtils.isBlank(String.valueOf(value))) {
return null;
}
String text = String.valueOf(value).trim().replace(",", "");
int space = text.lastIndexOf(' ');
if (space >= 0) {
text = text.substring(space + 1).trim();
}
try {
return new BigDecimal(text);
} catch (NumberFormatException ex) {
return null;
}
}
/**
* 100.000 去掉尾零会变成 1E+2,TMS 锁舱当参数格式错误。
* 重量和体积类型是 decimal,整数也写成 100.0,避免被收成没有小数点的整数。
*/
static BigDecimal plainDecimal(BigDecimal value) {
BigDecimal stripped = value.stripTrailingZeros();
if (stripped.scale() < 1) {
return stripped.setScale(1);
}
return stripped;
}
private static void putNumber(Map<String, Object> target, String key, Number value) {
if (value != null) {
if (value instanceof BigDecimal) {
target.put(key, ((BigDecimal) value).stripTrailingZeros());
target.put(key, plainDecimal((BigDecimal) value));
} else {
target.put(key, value);
}
@@ -122,7 +122,8 @@ public class TmsGatewayClient {
throw new IllegalStateException("TMS 锁舱未开放或签名未配置");
}
assertCircuitAvailable();
String json = JSON.toJSONString(request);
String json = JSON.toJSONString(request, com.alibaba.fastjson.serializer.SerializerFeature.WriteBigDecimalAsPlain);
log.info("tms.cabin req path={} json={}", path, json);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = java.util.UUID.randomUUID().toString();
String signature = TmsHmacSigner.sign(properties.getSignatureSecret(), timestamp, nonce, json);
@@ -167,10 +168,10 @@ public class TmsGatewayClient {
consecutiveFailures.set(0);
body.put("formal", true);
body.put("source", "TMS");
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={} bodyLen={}",
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={} bodyLen={} resp={}",
path, request.get("workOrderNo"), request.get("lockId"), request.get("quoteId"),
body.get("code"), status,
System.currentTimeMillis() - started, attempt, response.bodyLen);
System.currentTimeMillis() - started, attempt, response.bodyLen, response.body);
return body;
} catch (SocketTimeoutException ex) {
log.warn("tms.cabin timeout path={} attempt={}", path, attempt);
@@ -19,7 +19,8 @@ UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_common' WHERE `id`='rt
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_cross' WHERE `id`='rt100000000000000000000000000008' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_special' WHERE `id`='rt100000000000000000000000000009' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_express' WHERE `id`='rt100000000000000000000000000010' AND (`timeout_code` IS NULL OR `timeout_code`='');
-- 陆运产品仅 4 项;ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
-- 陆运产品 5 项;常用线路见 migration_inq_response_timeout_land_common_v1.sql
-- ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
-- 3) 唯一索引(列已全部非空后再建;若仍有 NULL 会失败)
UPDATE `inq_response_timeout` SET `timeout_code`=CONCAT('rt_', `id`) WHERE `timeout_code` IS NULL OR `timeout_code`='';
@@ -0,0 +1,33 @@
-- 陆运产品运输分类增加「普件(常用线路/常规车型)」,超时小时 0=不监测。
-- 排在原 land_hk_common 之前;不改销售行,不改原 4 项文案和小时数。
-- 仅改 inquiry_robot;幂等可重复执行。
SET NAMES utf8mb4;
INSERT INTO `inq_response_timeout` (
`id`, `timeout_code`, `transport_mode`, `role_code`, `route_category`,
`transport_type`, `timeout_hours`, `sort_no`, `create_by`, `create_time`, `update_by`, `update_time`
)
SELECT
'rt100000000000000000000000000020',
'land_common',
'LAND',
'product',
'',
CONVERT(UNHEX('e699aee4bbb628e5b8b8e794a8e7babfe8b7af2fe5b8b8e8a784e8bda6e59e8b29') USING utf8mb4),
0,
IFNULL((
SELECT `sort_no` FROM (
SELECT `sort_no` FROM `inq_response_timeout` WHERE `timeout_code`='land_hk_common' LIMIT 1
) AS `hk`
), 7) - 1,
'system',
NOW(),
'system',
NOW()
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM (
SELECT `id` FROM `inq_response_timeout` WHERE `timeout_code`='land_common'
) AS `existed`
);
@@ -48,28 +48,41 @@ public class InquiryTmsCabinServiceTest {
}
@Test
public void resolveOptionWithoutTmsUsesAirOpt01() {
public void resolveOptionWithoutTmsStaysBlank() {
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "");
assertEquals("AIR-OPT-01", option.get("optionNo"));
assertNull(option.get("optionNo"));
}
@Test
public void lockQuoteIdWithoutTmsUsesWorkOrderPlaceholder() {
assertEquals("QT-WO202609210003",
InquiryTmsCabinService.lockQuoteId("", "WO202609210003"));
public void manualQuoteKeepsCallerOptionText() {
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "AIR-OPT01");
assertEquals("AIR-OPT01", option.get("optionNo"));
}
@Test
public void lockQuoteIdKeepsRealAirId() {
assertEquals("AIR-2094721692181016578-2094721692654972929",
InquiryTmsCabinService.lockQuoteId(
"AIR-2094721692181016578-2094721692654972929",
"WO202609160015"));
public void airlineFromFlightUsesFirstTwoLetters() {
assertEquals("TH", InquiryTmsCabinService.airlineFromFlight("TH739"));
assertEquals("CZ", InquiryTmsCabinService.airlineFromFlight("cz327"));
assertEquals("", InquiryTmsCabinService.airlineFromFlight("739"));
assertEquals("", InquiryTmsCabinService.airlineFromFlight(""));
}
@Test
public void lockOptionNoWithoutTmsUsesAirOpt01() {
assertEquals("AIR-OPT-01", InquiryTmsCabinService.lockOptionNo(""));
public void confirmTmsIsNotManual() {
Map<String, Object> quote = new HashMap<>();
quote.put("source", "PRODUCT");
quote.put("source_label", "航线确认TMS");
org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isManualQuote(quote));
}
@Test
public void salesAdjustAndProductAreManual() {
Map<String, Object> adjust = new HashMap<>();
adjust.put("source", "SALES_ADJUST");
Map<String, Object> edited = new HashMap<>();
edited.put("source_label", "航线在TMS上改");
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isManualQuote(adjust));
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isManualQuote(edited));
}
@Test
@@ -2,6 +2,7 @@ package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -53,15 +54,27 @@ public class TmsCabinRequestMapperTest {
}
@Test
public void lockKeepsNoQuotePlaceholders() {
Map<String, Object> filled = new LinkedHashMap<>();
filled.put("workOrderNo", "WO202609210003");
filled.put("quoteId", "QT-WO202609210003");
filled.put("optionNo", "AIR-OPT-01");
filled.put("flightDate", "2026-09-20");
Map<String, Object> sent = TmsCabinRequestMapper.lock(filled);
assertEquals("QT-WO202609210003", sent.get("quoteId"));
assertEquals("AIR-OPT-01", sent.get("optionNo"));
public void weightWithTrailingZerosStaysPlain() {
assertEquals("100.0", TmsCabinRequestMapper.plainDecimal(new BigDecimal("100.000")).toString());
assertEquals("2.0", TmsCabinRequestMapper.plainDecimal(new BigDecimal("2.0000")).toString());
}
@Test
public void manualLockOmitsEmptyFeesAndKeepsSource() {
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("workOrderNo", "WO202609220001");
internal.put("source", "MANUAL");
internal.put("quoteId", "AIR-1");
internal.put("totalSalePrice", "14809.6");
internal.put("currency", "CNY");
internal.put("departureCharges", java.util.Collections.emptyList());
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
assertEquals("MANUAL", body.get("source"));
assertEquals("AIR-1", body.get("quoteId"));
assertEquals(new java.math.BigDecimal("14809.6"), body.get("totalSalePrice"));
assertEquals(null, body.get("departureCharges"));
assertEquals(null, body.get("feeItems"));
assertEquals(null, body.get("flightNo"));
}
@Test
@@ -4,7 +4,7 @@
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
* 陆运产品运输分类仅 4 项(与线路解耦);不展示线路类别。
* 陆运产品运输分类 5 项(与线路解耦);不展示线路类别。
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
*/
import { computed, onMounted, ref } from 'vue';