空运锁舱按报价来源出站,航线回 2 视为不用 TMS。补陆运常用线路超时,海运举例行不再当成已选分类。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+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");
|
||||
|
||||
+322
-44
@@ -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, "");
|
||||
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user