空运锁舱按报价来源出站,航线回 2 视为不用 TMS。补陆运常用线路超时,海运举例行不再当成已选分类。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
"普件(常用线路/常规车型)",
|
||||
"普件(东南亚、中港常用线路/常规车型)",
|
||||
"普件(东南亚、中亚、中欧涉及国内+国外段)",
|
||||
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=""),
|
||||
"",
|
||||
|
||||
@@ -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("运输类型"), "国内运输拼车")
|
||||
|
||||
@@ -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, {"贸易条款": "门到门", "品名": "机器设备"})
|
||||
|
||||
+3
-2
@@ -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");
|
||||
|
||||
+320
-42
@@ -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);
|
||||
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 = lockQuoteId(resolveQuoteId(quote, no), no);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱出站用的舱位选项。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, "");
|
||||
|
||||
+56
-3
@@ -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);
|
||||
}
|
||||
|
||||
+4
-3
@@ -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);
|
||||
|
||||
+2
-1
@@ -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`='';
|
||||
|
||||
+33
@@ -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`
|
||||
);
|
||||
+25
-12
@@ -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
|
||||
|
||||
+22
-9
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user