From 5402f94d30e8f2227e1b5416ae41e3cda2d5c29e Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Thu, 24 Sep 2026 18:28:13 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A9=BA=E8=BF=90=E9=94=81=E8=88=B1=E6=8C=89?= =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E6=9D=A5=E6=BA=90=E5=87=BA=E7=AB=99=EF=BC=8C?= =?UTF-8?q?=E8=88=AA=E7=BA=BF=E5=9B=9E=202=20=E8=A7=86=E4=B8=BA=E4=B8=8D?= =?UTF-8?q?=E7=94=A8=20TMS=E3=80=82=E8=A1=A5=E9=99=86=E8=BF=90=E5=B8=B8?= =?UTF-8?q?=E7=94=A8=E7=BA=BF=E8=B7=AF=E8=B6=85=E6=97=B6=EF=BC=8C=E6=B5=B7?= =?UTF-8?q?=E8=BF=90=E4=B8=BE=E4=BE=8B=E8=A1=8C=E4=B8=8D=E5=86=8D=E5=BD=93?= =?UTF-8?q?=E6=88=90=E5=B7=B2=E9=80=89=E5=88=86=E7=B1=BB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- inquiry-agent/agent/ledger/memory_ledger.py | 25 +- inquiry-agent/agent/policy/air_group_ops.py | 7 +- inquiry-agent/agent/policy/event_exception.py | 1 + inquiry-agent/agent/policy/inquiry_copy.py | 9 +- .../agent/routing/air_group_intent.py | 5 + inquiry-agent/agent/routing/group_intent.py | 19 +- inquiry-agent/agent/schema/land_options.py | 1 + inquiry-agent/agent/schema/sea_options.py | 28 +- inquiry-agent/tests/test_air_group_collab.py | 37 +- inquiry-agent/tests/test_event_exception.py | 12 + inquiry-agent/tests/test_land_options.py | 20 +- inquiry-agent/tests/test_sea_options.py | 11 + .../InquiryResponseTimeoutServiceImpl.java | 5 +- .../inquiry/tms/InquiryTmsCabinService.java | 366 +++++++++++++++--- .../inquiry/tms/TmsCabinRequestMapper.java | 59 ++- .../modules/inquiry/tms/TmsGatewayClient.java | 7 +- ...migration_inq_response_timeout_code_v1.sql | 3 +- ...on_inq_response_timeout_land_common_v1.sql | 33 ++ .../tms/InquiryTmsCabinServiceTest.java | 37 +- .../tms/TmsCabinRequestMapperTest.java | 31 +- .../src/views/inquiry/systemSetting/index.vue | 2 +- 21 files changed, 624 insertions(+), 94 deletions(-) create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_land_common_v1.sql diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index a22420f..27d9bad 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -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}", } diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 91ab080..b15652a 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -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), diff --git a/inquiry-agent/agent/policy/event_exception.py b/inquiry-agent/agent/policy/event_exception.py index 9852769..3bcdd6e 100644 --- a/inquiry-agent/agent/policy/event_exception.py +++ b/inquiry-agent/agent/policy/event_exception.py @@ -36,6 +36,7 @@ _SALES_CODES = { } _LAND_PRODUCT_CODES = { + "普件(常用线路/常规车型)": "land_common", "普件(东南亚、中港常用线路/常规车型)": "land_hk_common", "普件(东南亚、中亚、中欧涉及国内+国外段)": "land_hk_cross", "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)": "land_hk_special", diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 9873af1..1de492b 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -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 diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py index 294f9ac..8db5c8e 100644 --- a/inquiry-agent/agent/routing/air_group_intent.py +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -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: diff --git a/inquiry-agent/agent/routing/group_intent.py b/inquiry-agent/agent/routing/group_intent.py index 7f943b0..25cea54 100644 --- a/inquiry-agent/agent/routing/group_intent.py +++ b/inquiry-agent/agent/routing/group_intent.py @@ -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: diff --git a/inquiry-agent/agent/schema/land_options.py b/inquiry-agent/agent/schema/land_options.py index a2664fa..72e9ca6 100644 --- a/inquiry-agent/agent/schema/land_options.py +++ b/inquiry-agent/agent/schema/land_options.py @@ -28,6 +28,7 @@ ROUTES = ( "东南亚", ) LAND_CLASSES = ( + "普件(常用线路/常规车型)", "普件(东南亚、中港常用线路/常规车型)", "普件(东南亚、中亚、中欧涉及国内+国外段)", "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)", diff --git a/inquiry-agent/agent/schema/sea_options.py b/inquiry-agent/agent/schema/sea_options.py index c4ee1e9..7c9a9bf 100644 --- a/inquiry-agent/agent/schema/sea_options.py +++ b/inquiry-agent/agent/schema/sea_options.py @@ -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 diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 63ba233..11813a5 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -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, diff --git a/inquiry-agent/tests/test_event_exception.py b/inquiry-agent/tests/test_event_exception.py index bf77eff..ae58e14 100644 --- a/inquiry-agent/tests/test_event_exception.py +++ b/inquiry-agent/tests/test_event_exception.py @@ -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=""), "", diff --git a/inquiry-agent/tests/test_land_options.py b/inquiry-agent/tests/test_land_options.py index 0b0bb67..a0e3a10 100644 --- a/inquiry-agent/tests/test_land_options.py +++ b/inquiry-agent/tests/test_land_options.py @@ -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("运输类型"), "国内运输拼车") diff --git a/inquiry-agent/tests/test_sea_options.py b/inquiry-agent/tests/test_sea_options.py index c19b0a5..22acff5 100644 --- a/inquiry-agent/tests/test_sea_options.py +++ b/inquiry-agent/tests/test_sea_options.py @@ -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, {"贸易条款": "门到门", "品名": "机器设备"}) diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java index 3773256..3390610 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java @@ -41,7 +41,7 @@ public class InquiryResponseTimeoutServiceImpl private static final Map 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"); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java index 3d1e766..e0a9643 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -26,10 +26,9 @@ import java.util.Map; * 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。 *

* 锁舱按 /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 quote = latestQuote(no); - quote = refreshLockQuote(ticket, quote); - String quoteId = lockQuoteId(resolveQuoteId(quote, no), no); + List rows = quoteRows(no); + if (rows.isEmpty()) { + return fail("no_quote", "这张单还没有报价,不能锁舱"); + } + Map 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 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 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 req, Map quote, Map 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> departure = feeRows(quote, option, "departureCharges", "departure_charges"); + List> 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 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 tmsOptionForLock(List rows, String workOrderNo, String optionNo) { + if (rows == null) { + return new LinkedHashMap<>(); + } + for (InquiryQuote row : rows) { + Map payload = payloadOf(row); + if (StringUtils.isBlank(resolveQuoteId(payload, workOrderNo))) { + continue; + } + if (isManualQuote(payload)) { + continue; + } + List> options = airOptions(payload); + if (options.isEmpty()) { + continue; + } + if (StringUtils.isNotBlank(optionNo)) { + for (Map 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 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 quoteRows(String workOrderNo) { + if (StringUtils.isBlank(workOrderNo)) { + return new ArrayList<>(); + } + List rows = quoteMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, workOrderNo) + .orderByDesc(InquiryQuote::getCreateTime) + .last("LIMIT 8")); + return rows == null ? new ArrayList<>() : rows; + } + + private static Map payloadOf(InquiryQuote row) { + Map 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> feeRows(Map quote, Map option, + String... keys) { + List> out = new ArrayList<>(); + collectFeeRows(out, option, keys); + if (out.isEmpty()) { + collectFeeRows(out, quote, keys); + } + return out; + } + + @SuppressWarnings("unchecked") + private static void collectFeeRows(List> out, Map 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) item))) { + out.add((Map) item); + } + } + if (!out.isEmpty()) { + return; + } + } + } + + /** + * 报价只存了 fee_rows 时,本地费进 departureCharges,其余进 feeItems。已分开的不再搬。 + */ + private static void splitFeeRows(Map quote, List> departure, + List> fees) { + if (!departure.isEmpty() || !fees.isEmpty()) { + return; + } + for (Map 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 row) { + return text(row, "chargeName", "feeName", "name", "label"); + } + + /** + * 费用行出站改成 TMS 字段:feeName/chargeName、unitPrice、数量和金额用数字。 + * 库里的 name、unit_price 不原样送出,避免 TMS 报参数格式错误。 + */ + static List> toLockFees(List> rows, boolean departure) { + List> out = new ArrayList<>(); + if (rows == null) { + return out; + } + for (Map row : rows) { + if (row == null) { + continue; + } + Map 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 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 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 resolveOption(Map quote, String optionNo) { List> 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 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 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 tms, String flightDate) { return cabinFailReason(code, tms, flightDate, ""); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java index 10d1f33..b7c0db3 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java @@ -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; *

* 锁舱唯一键: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 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 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); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java index ed587e0..3447911 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java @@ -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); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_code_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_code_v1.sql index fc5001d..5fe07e9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_code_v1.sql +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_code_v1.sql @@ -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`=''; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_land_common_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_land_common_v1.sql new file mode 100644 index 0000000..b79779c --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_land_common_v1.sql @@ -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` +); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java index 5c155b9..198c8f6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -48,28 +48,41 @@ public class InquiryTmsCabinServiceTest { } @Test - public void resolveOptionWithoutTmsUsesAirOpt01() { + public void resolveOptionWithoutTmsStaysBlank() { Map 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 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 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 adjust = new HashMap<>(); + adjust.put("source", "SALES_ADJUST"); + Map 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 diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java index b6bf99d..6fb663e 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java @@ -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 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 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 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 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 diff --git a/inquiry-backend/src/views/inquiry/systemSetting/index.vue b/inquiry-backend/src/views/inquiry/systemSetting/index.vue index 69fcdd3..c06960f 100644 --- a/inquiry-backend/src/views/inquiry/systemSetting/index.vue +++ b/inquiry-backend/src/views/inquiry/systemSetting/index.vue @@ -4,7 +4,7 @@ * 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。 * 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。 * roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。 - * 陆运产品运输分类仅 4 项(与线路解耦);不展示线路类别。 + * 陆运产品运输分类 5 项(与线路解耦);不展示线路类别。 * 数据来自主账 inq_response_timeout;本页不做工单超时判定。 */ import { computed, onMounted, ref } from 'vue';