空运锁舱按报价来源出站,航线回 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
@@ -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