落地空运群内协同与锁舱出站。

群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 17:25:42 +08:00
co-authored by Cursor
parent 12219cacb1
commit d9029dec89
62 changed files with 6772 additions and 159 deletions
@@ -37,6 +37,9 @@ public class InquiryAgentController {
@Autowired
private org.jeecg.modules.inquiry.service.IInquiryStaffRoleService staffRoleService;
@Autowired
private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService;
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
private String agentToken;
@@ -157,6 +160,18 @@ public class InquiryAgentController {
return Result.OK(out);
}
@ApiOperation("按岗位列出启用员工(空运群 @ 航线·空运)")
@PostMapping("/staff/listByRole")
public Result<Map<String, Object>> listStaffByRole(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
Map<String, Object> out = new java.util.LinkedHashMap<>();
out.put("staff", staffRoleService.listActiveByRole(firstStr(body, "roleCode", "role_code")));
return Result.OK(out);
}
@ApiOperation("按企微 userid 取员工展示名")
@PostMapping("/staff/byWecom")
public Result<Map<String, Object>> staffByWecom(@RequestBody Map<String, Object> body,
@@ -237,6 +252,36 @@ public class InquiryAgentController {
return Result.OK(workOrderService.upsertProductQuote(body));
}
@ApiOperation("空运群激活/切换当前工单(一单一群)")
@PostMapping("/ticket/activateAirGroup")
public Result<Map<String, Object>> activateAirGroup(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.activateAirGroup(body));
}
@ApiOperation("空运锁舱(主账 HMAC 出站 TMS,智能体不直连)")
@PostMapping("/tms/lockCabin")
public Result<Map<String, Object>> lockCabin(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(cabinService.lockCabin(body));
}
@ApiOperation("空运释放舱位(已成交本地拦住,不调 TMS)")
@PostMapping("/tms/releaseCabin")
public Result<Map<String, Object>> releaseCabin(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(cabinService.releaseCabin(body));
}
private boolean authed(HttpServletRequest http) {
String got = http.getHeader("X-Agent-Token");
if (StringUtils.isBlank(got)) {
@@ -0,0 +1,46 @@
package org.jeecg.modules.inquiry.domain;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* 空运群「当前工单」能不能激活。纯内存规则,不写库。
* <p>
* 可激活:业务线 AIR,且六态询价中 / 已报价 / 协商中。
* 一张单同时只跟一个群;已经绑了别的群就 occupied。
*/
public final class AirGroupActivateRule {
public static final Set<String> OPEN = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
TicketStatuses.INQUIRING, TicketStatuses.QUOTED, TicketStatuses.NEGOTIATING
)));
private AirGroupActivateRule() {
}
/**
* @return 空串表示可以激活;否则是给智能体的错误码
*/
public static String rejectReason(InquiryTicket ticket, String chatId) {
if (ticket == null) {
return "ticket_not_found";
}
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
return "not_air";
}
if (!OPEN.contains(StringUtils.trimToEmpty(ticket.getStatus()))) {
return "terminal";
}
String bound = StringUtils.trimToEmpty(ticket.getCollabChatId());
String room = StringUtils.trimToEmpty(chatId);
if (StringUtils.isNotBlank(bound) && !bound.equals(room)) {
return "occupied";
}
return "";
}
}
@@ -37,7 +37,12 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
java.util.List<java.util.Map<String, Object>> matchSeaStaff(String origin, String destination);
/**
* 按企微 userid 取展示名。
* 列出该岗位启用员工。空运群 @ 航线用 roleCode=air。
*/
java.util.List<java.util.Map<String, Object>> listActiveByRole(String roleCode);
/**
* 按企微 userid 取展示名、岗位、启用状态。空运群认「航线·空运」靠 roleCode+status。
*/
java.util.Map<String, Object> findDisplayByWecomId(String wecomId);
@@ -104,4 +104,9 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
* 产品报价写入新版本并视情况迁「已报价」。
*/
Map<String, Object> upsertProductQuote(Map<String, Object> body);
/**
* 空运群激活/切换当前工单。一单一群,未结案才能绑。不改六态。
*/
Map<String, Object> activateAirGroup(Map<String, Object> body);
}
@@ -534,9 +534,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
out.put("quote_no", StringUtils.defaultString(workOrderNo));
out.put("pol", first(src, "起运港", "pol", "origin"));
out.put("origin", first(src, "起运港", "origin", "pol"));
out.put("pod", first(src, "目的港", "pod", "destination"));
out.put("destination", first(src, "目的港", "destination", "pod"));
out.put("origin", first(src, "起运地", "起运港", "origin", "pol"));
out.put("pod", first(src, "目的地", "目的港", "pod", "destination"));
out.put("destination", first(src, "目的地", "目的港", "destination", "pod"));
out.put("commodity", first(src, "品名", "commodity"));
// 海运七项:合同键是 quantity / fcl_or_lcl / container_type_qty,工单事实是中文键
out.put("quantity", first(src, "货量", "货物数量", "quantity"));
@@ -558,6 +558,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
+ ";电:" + (StringUtils.isNotBlank(elec) ? elec : "-")
+ ";磁:" + (StringUtils.isNotBlank(mag) ? mag : "-");
}
if (StringUtils.isNotBlank(elec)) {
out.put("has_battery", elec);
out.put("是否含电", elec);
}
if (StringUtils.isNotBlank(mag)) {
out.put("has_magnet", mag);
out.put("是否含磁", mag);
}
if (StringUtils.isNotBlank(oil)) {
out.put("has_oil", oil);
out.put("是否含油", oil);
}
if (StringUtils.isNotBlank(oem)) {
out.put("oil_elec_mag", oem);
out.put("是否含油,电,磁", oem);
@@ -144,6 +144,30 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
return org.jeecg.modules.inquiry.staff.SeaStaffMatcher.match(rows, origin, destination);
}
@Override
public List<Map<String, Object>> listActiveByRole(String roleCode) {
String code = StringUtils.trimToEmpty(roleCode).toLowerCase();
List<Map<String, Object>> out = new java.util.ArrayList<>();
if (code.isEmpty()) {
return out;
}
List<InquiryStaffRole> rows = list(new QueryWrapper<InquiryStaffRole>()
.eq("role_code", code)
.eq("status", "active"));
for (InquiryStaffRole row : rows) {
if (row == null || StringUtils.isBlank(row.getWecomId())) {
continue;
}
Map<String, Object> one = new HashMap<>();
one.put("name", row.getName());
one.put("wecomId", row.getWecomId());
one.put("roleCode", row.getRoleCode());
one.put("status", row.getStatus());
out.add(one);
}
return out;
}
@Override
public Map<String, Object> findDisplayByWecomId(String wecomId) {
InquiryStaffRole row = findByWecomId(wecomId);
@@ -154,6 +178,7 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
out.put("name", row.getName());
out.put("wecomId", row.getWecomId());
out.put("roleCode", row.getRoleCode());
out.put("status", row.getStatus());
return out;
}
@@ -175,6 +200,7 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
out.put("name", row.getName());
out.put("wecomId", row.getWecomId());
out.put("roleCode", row.getRoleCode());
out.put("status", row.getStatus());
return out;
}
}
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.domain.AirGroupActivateRule;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.dto.AgentTicketCreateRequest;
import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
@@ -516,7 +517,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
boolean owned = StringUtils.isNotBlank(salesWecomId)
&& salesWecomId.equals(ticket.getSalesWecomId());
out.put("owned", owned);
if (!owned) {
// 服务账号读单(群协同/锁舱)不带销售 id,仍回快照;续办带了销售 id 才拦别人的单。
if (StringUtils.isNotBlank(salesWecomId) && !owned) {
return out;
}
Map<String, String> facts = new LinkedHashMap<>();
@@ -585,6 +587,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("ok", true);
out.put("status", status);
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
out.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
out.put("salesName", StringUtils.defaultString(ticket.getSalesName()));
out.put("facts", facts);
out.put("quote", quote);
out.put("wait_phase", waitPhase);
@@ -706,8 +710,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
continue;
}
String val = String.valueOf(e.getValue()).trim();
if (!val.isEmpty()) {
merged.put(String.valueOf(e.getKey()), val);
String factKey = String.valueOf(e.getKey());
if (val.isEmpty()) {
// 空值表示删掉该协同键,例如清掉待确认报价。
merged.remove(factKey);
} else {
merged.put(factKey, val);
}
}
}
@@ -741,6 +749,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("work_order_no", ticket.getWorkOrderNo());
out.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
out.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
out.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
out.put("salesName", StringUtils.defaultString(ticket.getSalesName()));
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds()));
out.put("memberIds", parseMemberIds(ticket.getCollabMemberIds()));
@@ -870,6 +880,57 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return out;
}
/**
* 空运群当前工单:清掉同群其它空运单绑定,一单一群。不改六态。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> activateAirGroup(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
String room = text(body, "chatId", "chat_id");
InquiryTicket ticket = findByWorkOrderNo(no);
Map<String, Object> out = new LinkedHashMap<>();
String reject = AirGroupActivateRule.rejectReason(ticket, room);
if (StringUtils.isNotBlank(reject)) {
out.put("ok", false);
out.put("error", reject);
if (ticket != null) {
out.put("status", ticket.getStatus());
out.put("other_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
}
return out;
}
List<InquiryTicket> others = list(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getCollabChatId, room)
.ne(InquiryTicket::getWorkOrderNo, no));
if (others != null) {
for (InquiryTicket other : others) {
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(other.getBusinessLine()))) {
continue;
}
other.setCollabChatId("");
other.setUpdateTime(new Date());
updateById(other);
}
}
ticket.setCollabChatId(room);
ticket.setUpdateTime(new Date());
updateById(ticket);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "activate_air_group", "agent", room);
out.put("ok", true);
out.put("work_order_no", no);
out.put("chat_id", room);
out.put("status", ticket.getStatus());
out.put("business_line", ticket.getBusinessLine());
out.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
out.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
out.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
out.put("facts", parseFactsJson(ticket.getFactsJson()));
out.put("quote", latestQuotePayload(no));
out.put("collab_chat_id", room);
return out;
}
private static Map<String, String> parseFactsJson(String raw) {
Map<String, String> facts = new LinkedHashMap<>();
if (StringUtils.isBlank(raw)) {
@@ -60,6 +60,11 @@ public final class QuoteTemplateExcelSupport {
FILL_BY_LABEL.put("HS编码", "hs_code");
FILL_BY_LABEL.put("海关编码", "hs_code");
FILL_BY_LABEL.put("是否含油,电,磁", "oil_elec_mag");
FILL_BY_LABEL.put("是否含电", "has_battery");
FILL_BY_LABEL.put("是否含磁", "has_magnet");
FILL_BY_LABEL.put("是否含油", "has_oil");
FILL_BY_LABEL.put("起运地", "origin");
FILL_BY_LABEL.put("目的地", "destination");
FILL_BY_LABEL.put("件数", "pieces");
FILL_BY_LABEL.put("重量", "weight_kg");
FILL_BY_LABEL.put("体积", "volume_cbm");
@@ -0,0 +1,425 @@
package org.jeecg.modules.inquiry.tms;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.entity.InquiryQuote;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
* <p>
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
* 当前报价没有 quoteId / optionNo 时补 QT-{工单号} + AIR-OPT-01,仍出站;不因「没 TMS 报价」本地拦住。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@Slf4j
@Service
public class InquiryTmsCabinService {
@Autowired
private TmsProperties properties;
@Autowired
private TmsGatewayClient gateway;
@Autowired
private InquiryQuoteMapper quoteMapper;
@Autowired
@Lazy
private IInquiryWorkOrderService workOrderService;
/**
* 智能体锁舱入口。
*/
public Map<String, Object> lockCabin(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
String optionNo = text(body, "optionNo", "option_no");
InquiryTicket ticket = findTicket(no);
if (ticket == null) {
return fail("ticket_not_found", "找不到工单");
}
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
return fail("not_air", "不是空运单,不能锁舱");
}
if (TicketStatuses.DEAL.equals(ticket.getStatus())
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
}
Map<String, Object> quote = latestQuote(no);
String quoteId = resolveQuoteId(quote, no);
Map<String, Object> option = resolveOption(quote, optionNo);
if (StringUtils.isBlank(quoteId) || option == null) {
return fail("no_lock_target", "锁舱缺少报价编号或舱位选项");
}
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放锁舱");
}
Map<String, Object> req = new LinkedHashMap<>();
req.put("workOrderNo", no);
req.put("quoteId", quoteId);
req.put("optionNo", text(option, "optionNo", "option_no"));
req.put("flightDate", quoteDate(ticket));
req.put("origin", firstNonBlank(text(option, "originAirportCode"), ticket.getOrigin(),
fact(ticket, "起运地", "起运港")));
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"), ticket.getDestination(),
fact(ticket, "目的地", "目的港")));
req.put("airline", firstNonBlank(text(option, "airlineCode", "airline"),
text(option, "airlineName"),
text(quote, "airlineCode", "airline", "airlineName"),
fact(ticket, "航司", "航司代码")));
req.put("weightKg", ticket.getWeightKg());
req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces());
req.put("chargeableWeightKg", firstNonBlank(
text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
text(option, "chargeableWeightKg", "chargeable_weight_kg")));
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("instructionText", text(body, "instructionText", "instruction_text"));
return invokeLock(ticket, req, quoteId, text(option, "optionNo", "option_no"));
}
/**
* 智能体释放入口。已成交不调 TMS。
*/
public Map<String, Object> releaseCabin(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
String lockId = text(body, "lockId", "lock_id");
InquiryTicket ticket = findTicket(no);
if (ticket == null) {
return fail("ticket_not_found", "找不到工单");
}
if (TicketStatuses.DEAL.equals(ticket.getStatus())) {
return fail("deal_blocked", "工单已成交");
}
if (StringUtils.isBlank(lockId)) {
return fail("release_need_lock", "本单还没有成功锁舱记录");
}
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放释放");
}
Map<String, Object> req = new LinkedHashMap<>();
req.put("lockId", lockId);
req.put("workOrderNo", no);
req.put("flightDate", quoteDate(ticket));
req.put("origin", ticket.getOrigin());
req.put("destination", ticket.getDestination());
req.put("originCode", ticket.getOrigin());
req.put("destinationCode", ticket.getDestination());
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("instructionText", text(body, "instructionText", "instruction_text"));
req.put("releaseReason", text(body, "instructionText", "instruction_text"));
return invokeRelease(ticket, req, lockId);
}
private Map<String, Object> invokeLock(InquiryTicket ticket, Map<String, Object> req,
String quoteId, String optionNo) {
try {
Map<String, Object> tms = gateway.lockCabin(req);
int code = TmsV2ResponseNormalizer.number(tms.get("code"));
if (code != 1) {
return fail("lock_fail", cabinFailReason(code, tms));
}
Map<String, Object> data = tmsData(tms);
String lockId = firstNonBlank(text(data, "lockId"), text(tms, "lockId"));
String status = firstNonBlank(text(data, "status"), "LOCKED");
if (StringUtils.isBlank(lockId) || "REJECTED".equalsIgnoreCase(status)) {
return fail("lock_fail", firstNonBlank(text(data, "failReason"), "锁舱失败"));
}
persistCabinFact(ticket, "__air_lock_id", lockId);
persistCabinFact(ticket, "__air_lock_option", optionNo);
persistCabinFact(ticket, "__air_lock_quote", quoteId);
Map<String, Object> out = okBase();
out.put("lockId", lockId);
out.put("quoteId", firstNonBlank(text(data, "quoteId"), quoteId));
out.put("optionNo", firstNonBlank(text(data, "optionNo"), optionNo));
out.put("status", status);
out.put("tmsRequestId", firstNonBlank(text(data, "tmsRequestId"), text(tms, "tmsRequestId")));
return out;
} catch (RuntimeException ex) {
log.warn("tms.lock fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
return fail("lock_fail", firstNonBlank(ex.getMessage(), "锁舱失败"));
}
}
private Map<String, Object> invokeRelease(InquiryTicket ticket, Map<String, Object> req, String lockId) {
try {
Map<String, Object> tms = gateway.releaseCabin(req);
int code = TmsV2ResponseNormalizer.number(tms.get("code"));
if (code != 1) {
return fail("release_fail", cabinFailReason(code, tms));
}
Map<String, Object> data = tmsData(tms);
Map<String, Object> out = okBase();
out.put("lockId", firstNonBlank(text(data, "lockId"), lockId));
out.put("releaseId", firstNonBlank(text(data, "releaseId"), lockId));
out.put("status", firstNonBlank(text(data, "status"), "RELEASED"));
out.put("alreadyReleased", data.get("alreadyReleased"));
out.put("tmsRequestId", firstNonBlank(text(data, "tmsRequestId"), text(tms, "tmsRequestId")));
return out;
} catch (RuntimeException ex) {
log.warn("tms.release fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
return fail("release_fail", firstNonBlank(ex.getMessage(), "释放失败"));
}
}
private void persistCabinFact(InquiryTicket ticket, String key, String value) {
if (ticket == null || StringUtils.isBlank(key) || StringUtils.isBlank(value)) {
return;
}
Map<String, Object> body = new HashMap<>();
body.put("workOrderNo", ticket.getWorkOrderNo());
Map<String, String> facts = new LinkedHashMap<>();
facts.put(key, value);
body.put("facts", facts);
workOrderService.patchCollabFacts(body);
}
private InquiryTicket findTicket(String workOrderNo) {
if (StringUtils.isBlank(workOrderNo)) {
return null;
}
return workOrderService.getOne(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
.last("LIMIT 1"), false);
}
private Map<String, Object> latestQuote(String workOrderNo) {
Map<String, Object> quote = new LinkedHashMap<>();
List<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByDesc(InquiryQuote::getCreateTime)
.last("LIMIT 8"));
if (rows == null || rows.isEmpty()) {
return quote;
}
JSONObject latest = JSON.parseObject(StringUtils.defaultString(rows.get(0).getPayloadJson()));
if (latest != null) {
quote.putAll(latest);
}
if (StringUtils.isNotBlank(text(quote, "quoteId", "quote_id")) && !airOptions(quote).isEmpty()) {
return quote;
}
for (int i = 1; i < rows.size(); i++) {
if (StringUtils.isBlank(rows.get(i).getPayloadJson())) {
continue;
}
JSONObject older = JSON.parseObject(rows.get(i).getPayloadJson());
if (older == null) {
continue;
}
if (StringUtils.isBlank(text(quote, "quoteId", "quote_id"))) {
String olderId = firstNonBlank(
older.getString("quoteId"),
older.getString("quote_id"));
if (StringUtils.isNotBlank(olderId)) {
quote.put("quoteId", olderId);
}
}
if (airOptions(quote).isEmpty()) {
Object olderOpts = older.get("airOptions");
if (olderOpts == null) {
olderOpts = older.get("air_options");
}
if (olderOpts != null) {
quote.put("airOptions", olderOpts);
}
}
}
return quote;
}
/**
* 没有 TMS 5.2 报价编号时,用工单号生成关联 ID,仍调锁舱。
*/
static String resolveQuoteId(Map<String, Object> quote, String workOrderNo) {
String id = text(quote, "quoteId", "quote_id");
if (StringUtils.isNotBlank(id)) {
return id;
}
return StringUtils.isBlank(workOrderNo) ? "" : "QT-" + workOrderNo.trim();
}
/**
* 有舱位选项按名称挑;一张都没有时补 AIR-OPT-01,让人工价也能出站。
*/
static Map<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
if (!options.isEmpty()) {
Map<String, Object> picked = pickOption(quote, optionNo);
if (picked != null) {
return picked;
}
if (options.size() == 1) {
return options.get(0);
}
return null;
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("optionNo", StringUtils.isNotBlank(optionNo) ? optionNo.trim() : "AIR-OPT-01");
String airline = text(quote, "airlineCode", "airline", "airlineName");
if (StringUtils.isNotBlank(airline)) {
row.put("airlineCode", airline);
}
return row;
}
@SuppressWarnings("unchecked")
static Map<String, Object> pickOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
if (options.isEmpty()) {
return null;
}
String want = StringUtils.trimToEmpty(optionNo);
if (want.isEmpty() && options.size() == 1) {
return options.get(0);
}
for (Map<String, Object> row : options) {
String no = firstNonBlank(text(row, "optionNo", "option_no"));
if (want.equalsIgnoreCase(no)) {
return row;
}
}
return null;
}
@SuppressWarnings("unchecked")
static List<Map<String, Object>> airOptions(Map<String, Object> quote) {
List<Map<String, Object>> out = new ArrayList<>();
if (quote == null) {
return out;
}
Object raw = quote.get("airOptions");
if (raw == null) {
raw = quote.get("air_options");
}
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) {
for (Object item : (List<?>) raw) {
if (item instanceof Map) {
out.add((Map<String, Object>) item);
}
}
}
return out;
}
private static String quoteDate(InquiryTicket ticket) {
if (ticket == null || ticket.getQuoteDate() == null) {
return "";
}
return new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate());
}
private static String fact(InquiryTicket ticket, String... keys) {
if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) {
return "";
}
JSONObject parsed = JSON.parseObject(ticket.getFactsJson());
if (parsed == null) {
return "";
}
for (String key : keys) {
Object val = parsed.get(key);
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
return String.valueOf(val).trim();
}
}
return "";
}
@SuppressWarnings("unchecked")
private static Map<String, Object> tmsData(Map<String, Object> tms) {
Object raw = tms == null ? null : tms.get("tmsData");
if (raw instanceof Map) {
return (Map<String, Object>) raw;
}
return tms == null ? new LinkedHashMap<>() : tms;
}
private static String cabinFailReason(int code, Map<String, Object> tms) {
Map<String, Object> data = tmsData(tms);
String detail = firstNonBlank(text(data, "failReason"), text(tms, "message"));
if (code == 2006) {
return firstNonBlank(detail, "舱位选项不存在或与报价不匹配");
}
if (code == 2007) {
return firstNonBlank(detail, "报价已过期,请重新询价");
}
if (code == 2008) {
return firstNonBlank(detail, "舱位不存在");
}
if (code == 2009) {
return firstNonBlank(detail, "锁舱被业务规则拒绝");
}
if (code == 1005) {
return firstNonBlank(detail, "TMS 签名失败");
}
return firstNonBlank(detail, "TMS 业务码=" + code);
}
private static Map<String, Object> okBase() {
Map<String, Object> out = new LinkedHashMap<>();
out.put("ok", true);
return out;
}
private static Map<String, Object> fail(String error, String reason) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("ok", false);
out.put("error", error);
out.put("failReason", reason);
return out;
}
private static String text(Map<String, Object> body, String... keys) {
if (body == null || keys == null) {
return "";
}
for (String key : keys) {
Object val = body.get(key);
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
return String.valueOf(val).trim();
}
}
return "";
}
private static String firstNonBlank(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
if (StringUtils.isNotBlank(value)) {
return value.trim();
}
}
return "";
}
}
@@ -32,7 +32,8 @@ import java.util.TimeZone;
* 业务线以工单为准,禁止海运请求被默认成 AIR。
* 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价;
* HTTP 超时/熔断/5xx → 技术失败。
* 禁止:因 originCode 对不上就 skip;fixture 假价;LOCK/RELEASE;Python 直连 TMS。
* 禁止:因 originCode 对不上就 skip;fixture 假价;Python 直连 TMS。
* 锁舱/释放不在本类,见 {@link InquiryTmsCabinService}。
*/
@Slf4j
@Service
@@ -282,6 +283,20 @@ public class InquiryTmsQueryService {
quote.put("source", "TMS");
quote.put("source_label", "TMS 标准报价");
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
// 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。
String quoteId = firstText(data.get("quoteId"), data.get("quote_id"));
if (StringUtils.isNotBlank(quoteId)) {
quote.put("quoteId", quoteId);
}
Object airOptions = data.get("airOptions");
if (airOptions == null) {
airOptions = data.get("air_options");
}
if (airOptions != null) {
quote.put("airOptions", airOptions instanceof String
? String.valueOf(airOptions)
: JSON.toJSONString(airOptions));
}
return quote;
}
@@ -48,7 +48,7 @@ public class TmsAdapterClient {
}
/**
* 锁舱壳:默认关闭;未实现。
* 旧壳保留。正式锁舱走 {@link TmsGatewayClient#lockCabin},受 TMS_V2_LOCK_ENABLED 约束。
*
* @return not_implemented
*/
@@ -0,0 +1,113 @@
package org.jeecg.modules.inquiry.tms;
import org.apache.commons.lang3.StringUtils;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* 把主账工单 + 当前报价拼成 TMS /air/cabin/lock、/air/cabin/release 正文。
* <p>
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
* quoteId / optionNo 由主账补齐:有 TMS 报价用原值,没有则 QT-工单号 + AIR-OPT-01。
*/
public final class TmsCabinRequestMapper {
private TmsCabinRequestMapper() {
}
/**
* 锁舱正文。字段顺序固定,签名按这份 JSON 原文计算。
*/
public static Map<String, Object> lock(Map<String, Object> internal) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("requestId", firstText(internal.get("requestId"), UUID.randomUUID().toString()));
put(body, "workOrderNo", internal.get("workOrderNo"));
put(body, "quoteId", internal.get("quoteId"));
put(body, "optionNo", internal.get("optionNo"));
Object until = internal.get("requestedUntil");
if (until != null && StringUtils.isNotBlank(String.valueOf(until))) {
body.put("requestedUntil", String.valueOf(until).trim());
}
put(body, "origin", first(internal, "origin", "originCode"));
put(body, "destination", first(internal, "destination", "destinationCode"));
put(body, "flightDate", TmsV2RequestMapper.normalizeQuoteDate(internal.get("flightDate")));
put(body, "airline", first(internal, "airline", "airlineCode"));
putNumber(body, "weightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("weightKg")));
putNumber(body, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(internal.get("volumeCbm")));
putNumber(body, "pieceQty", TmsV2RequestMapper.asInteger(internal.get("pieceQty")));
putNumber(body, "chargeableWeightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("chargeableWeightKg")));
put(body, "operatorUserId", internal.get("operatorUserId"));
put(body, "operatorName", internal.get("operatorName"));
body.put("operatorRole", "AIRLINE");
put(body, "instructionText", internal.get("instructionText"));
return body;
}
/**
* 释放正文:按 lockId 精确释放,workOrderNo 必须与 lock 时一致。
*/
public static Map<String, Object> release(Map<String, Object> internal) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("requestId", firstText(internal.get("requestId"), UUID.randomUUID().toString()));
put(body, "lockId", internal.get("lockId"));
put(body, "workOrderNo", internal.get("workOrderNo"));
put(body, "originCode", first(internal, "originCode", "origin"));
put(body, "destinationCode", first(internal, "destinationCode", "destination"));
put(body, "flightDate", TmsV2RequestMapper.normalizeQuoteDate(internal.get("flightDate")));
put(body, "operatorUserId", internal.get("operatorUserId"));
put(body, "operatorName", internal.get("operatorName"));
body.put("operatorRole", "AIRLINE");
put(body, "releaseReason", first(internal, "releaseReason", "instructionText"));
put(body, "instructionText", internal.get("instructionText"));
return body;
}
private static void put(Map<String, Object> target, String key, Object value) {
if (value == null) {
return;
}
String text = String.valueOf(value).trim();
if (!text.isEmpty() && !"null".equals(text)) {
target.put(key, text);
}
}
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());
} else {
target.put(key, value);
}
}
}
private static Object first(Map<String, Object> src, String... keys) {
if (src == null || keys == null) {
return null;
}
for (String key : keys) {
Object val = src.get(key);
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
return val;
}
}
return null;
}
private static String firstText(Object... values) {
if (values == null) {
return "";
}
for (Object value : values) {
if (value != null && StringUtils.isNotBlank(String.valueOf(value))) {
return String.valueOf(value).trim();
}
}
return "";
}
}
@@ -22,11 +22,12 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* TMS V2 查询网关(只做 query / token;锁舱默认关闭)。
* TMS V2 网关:查询 Bearer;锁舱/释放 Bearer + HMAC(文档 Header 均要)。
* <p>
* 职责:Bearer 出站、超时、最多 3 次、code 1/1002 短缓存、连续失败熔断。
* 职责:出站、超时、最多 3 次、query 对 code 1/1002 短缓存、连续失败熔断。
* 锁舱默认关(TMS_V2_LOCK_ENABLED=false);打开后才 HMAC 出站,单测禁止打真实 TMS。
* 线程:无全局工单大锁;熔断计数是进程级协调,不是六态。
* 禁止:LOCK/RELEASE 出站;按 userid 伪造报价。
* 禁止:按 userid 伪造报价;密钥写入日志。
*/
@Slf4j
@Component
@@ -92,6 +93,89 @@ public class TmsGatewayClient {
return body;
}
/**
* 空运锁舱。开关关闭或密钥不全时直接失败,不出站。
*
* @param request 已按文档排好的 lock 正文
* @return 规范化 envelope(含 code / tmsData)
*/
public Map<String, Object> lockCabin(Map<String, Object> request) {
return invokeCabin(properties.getLockPath(), TmsCabinRequestMapper.lock(request));
}
/**
* 空运释放舱位。同样受 TMS_V2_LOCK_ENABLED 约束。
*/
public Map<String, Object> releaseCabin(Map<String, Object> request) {
return invokeCabin(properties.getReleasePath(), TmsCabinRequestMapper.release(request));
}
/**
* HMAC 出站:X-Timestamp / X-Nonce / X-Signature 与正文原字节一致。
* 锁舱/释放不缓存。连续失败走同一熔断。
*/
private Map<String, Object> invokeCabin(String path, Map<String, Object> request) {
if (!properties.lockConfigured()) {
throw new IllegalStateException("TMS 锁舱未开放或签名未配置");
}
assertCircuitAvailable();
String json = JSON.toJSONString(request);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = java.util.UUID.randomUUID().toString();
String signature = TmsHmacSigner.sign(properties.getSignatureSecret(), timestamp, nonce, json);
int max = Math.max(1, Math.min(3, properties.getMaxAttempts()));
RuntimeException last = new IllegalStateException("TMS 锁舱未尝试");
for (int attempt = 1; attempt <= max; attempt++) {
long started = System.currentTimeMillis();
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(tokens.accessToken());
headers.set("X-Timestamp", timestamp);
headers.set("X-Nonce", nonce);
headers.set("X-Signature", signature);
ResponseEntity<String> response = restTemplate.exchange(
TmsV2TokenProvider.joinUrl(properties.getBaseUrl(), path),
HttpMethod.POST,
new HttpEntity<>(json, headers),
String.class);
Map<String, Object> body = TmsV2ResponseNormalizer.normalize(response.getBody());
consecutiveFailures.set(0);
body.put("formal", true);
body.put("source", "TMS");
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={}",
path, request.get("workOrderNo"), request.get("lockId"), request.get("quoteId"),
body.get("code"), response.getStatusCodeValue(),
System.currentTimeMillis() - started, attempt);
return body;
} catch (HttpStatusCodeException ex) {
int status = ex.getRawStatusCode();
log.warn("tms.cabin http={} path={} attempt={} costMs={}",
status, path, attempt, System.currentTimeMillis() - started);
last = new IllegalStateException("TMS HTTP=" + status);
if ((status == 429 || status >= 500) && attempt < max) {
sleepQuiet(200L * (1L << (attempt - 1)));
continue;
}
failCircuit();
throw last;
} catch (ResourceAccessException ex) {
log.warn("tms.cabin network path={} attempt={} err={}", path, attempt, ex.getMessage());
last = new IllegalStateException("TMS 网络或超时");
if (attempt < max) {
sleepQuiet(200L * (1L << (attempt - 1)));
continue;
}
failCircuit();
throw last;
} catch (IllegalArgumentException ex) {
throw new IllegalStateException("TMS 回包无法解析");
}
}
failCircuit();
throw last;
}
private Map<String, Object> invokeQuery(Map<String, Object> request) {
assertCircuitAvailable();
String json = JSON.toJSONString(request);
@@ -0,0 +1,43 @@
package org.jeecg.modules.inquiry.tms;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* TMS 锁舱/释放 HMAC-SHA256。
* <p>
* 对照接口文档 3.2:canonical = timestamp + "\\n" + nonce + "\\n" + 原始 JSON 正文;
* 密钥和正文都按 UTF-8;X-Signature = Base64(HMAC-SHA256)。
* timestamp / nonce 必须和请求头原文完全一致,禁止重排 JSON 后再签。
* 禁止:把密钥打进日志;禁止智能体侧复制本类去直连 TMS。
*/
public final class TmsHmacSigner {
private TmsHmacSigner() {
}
/**
* @param secret TMS_V2_SIGNATURE_SECRET,不能写进代码默认值
* @param timestamp 请求头 X-Timestamp 原文
* @param nonce 请求头 X-Nonce 原文
* @param rawBody 即将发出的 HTTP 原始 JSON 文本
* @return Base64 签名,写入 X-Signature
*/
public static String sign(String secret, String timestamp, String nonce, String rawBody) {
if (secret == null || secret.isEmpty()) {
throw new IllegalArgumentException("TMS 签名密钥未配置");
}
String canonical = String.valueOf(timestamp) + "\n"
+ String.valueOf(nonce) + "\n"
+ (rawBody == null ? "" : rawBody);
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return Base64.getEncoder().encodeToString(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (Exception ex) {
throw new IllegalStateException("TMS HMAC 计算失败", ex);
}
}
}
@@ -38,10 +38,16 @@ public class TmsProperties {
@Value("${TMS_V2_HEALTH_PATH:/health}")
private String healthPath;
/** 与现网共用 Client 期间禁止出站锁舱。 */
/** 与现网共用 Client 期间禁止出站锁舱。测服联调再开。 */
@Value("${TMS_V2_LOCK_ENABLED:false}")
private boolean lockEnabled;
@Value("${TMS_V2_LOCK_PATH:/air/cabin/lock}")
private String lockPath;
@Value("${TMS_V2_RELEASE_PATH:/air/cabin/release}")
private String releasePath;
@Value("${TMS_V2_CONNECT_TIMEOUT_MS:3000}")
private int connectTimeoutMs;
@@ -69,4 +75,14 @@ public class TmsProperties {
&& clientId != null && !clientId.trim().isEmpty()
&& clientSecret != null && !clientSecret.trim().isEmpty();
}
/**
* 锁舱/释放出站最低配置:开关打开 + 地址 + HMAC 密钥。
* 现网验收期保持 lockEnabled=false,避免和新系统抢同一票舱。
*/
public boolean lockConfigured() {
return lockEnabled
&& baseUrl != null && !baseUrl.trim().isEmpty()
&& signatureSecret != null && !signatureSecret.trim().isEmpty();
}
}
@@ -0,0 +1,50 @@
package org.jeecg.modules.inquiry.domain;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 空运群激活规则:不写库、不打 TMS。
*/
public class AirGroupActivateRuleTest {
@Test
public void openAirCanActivate() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.QUOTED, "");
assertEquals("", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
}
@Test
public void seaRejected() {
InquiryTicket ticket = ticket("SEA", TicketStatuses.QUOTED, "");
assertEquals("not_air", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
}
@Test
public void dealRejected() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.DEAL, "wr_air_1");
assertEquals("terminal", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
}
@Test
public void otherGroupOccupied() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.QUOTED, "wr_air_1");
assertEquals("occupied", AirGroupActivateRule.rejectReason(ticket, "wr_air_2"));
}
@Test
public void sameGroupAgainOk() {
InquiryTicket ticket = ticket("AIR", TicketStatuses.INQUIRING, "wr_air_1");
assertEquals("", AirGroupActivateRule.rejectReason(ticket, "wr_air_1"));
}
private static InquiryTicket ticket(String line, String status, String chat) {
InquiryTicket row = new InquiryTicket();
row.setBusinessLine(line);
row.setStatus(status);
row.setCollabChatId(chat);
return row;
}
}
@@ -0,0 +1,65 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* 舱位选项挑选:不打真实 TMS。
*/
public class InquiryTmsCabinServiceTest {
@Test
public void pickNamedOption() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> first = new HashMap<>();
first.put("optionNo", "AIR-OPT-01");
Map<String, Object> second = new HashMap<>();
second.put("optionNo", "AIR-OPT-02");
quote.put("airOptions", Arrays.asList(first, second));
assertEquals("AIR-OPT-02", InquiryTmsCabinService.pickOption(quote, "AIR-OPT-02").get("optionNo"));
}
@Test
public void singleOptionWhenBlank() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> only = new HashMap<>();
only.put("optionNo", "AIR-OPT-01");
quote.put("airOptions", Arrays.asList(only));
assertNotNull(InquiryTmsCabinService.pickOption(quote, ""));
}
@Test
public void missingOptionIsNull() {
Map<String, Object> quote = new HashMap<>();
quote.put("airOptions", "[]");
assertNull(InquiryTmsCabinService.pickOption(quote, "AIR-OPT-09"));
}
@Test
public void resolveQuoteIdWithoutTmsUsesWorkOrder() {
assertEquals("QT-WO202609160012",
InquiryTmsCabinService.resolveQuoteId(new HashMap<>(), "WO202609160012"));
}
@Test
public void resolveOptionWithoutTmsSynthesizesDefault() {
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "");
assertEquals("AIR-OPT-01", option.get("optionNo"));
}
@Test
public void resolveOptionKeepsExistingWhenPresent() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> only = new HashMap<>();
only.put("optionNo", "AIR-OPT-03");
quote.put("airOptions", Arrays.asList(only));
assertEquals("AIR-OPT-03", InquiryTmsCabinService.resolveOption(quote, "").get("optionNo"));
}
}
@@ -0,0 +1,54 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 锁舱/释放正文:日期走报价日期,角色固定 AIRLINE。不打真实 TMS。
*/
public class TmsCabinRequestMapperTest {
@Test
public void lockUsesQuoteDateAndAirlineRole() {
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("workOrderNo", "WO202609160001");
internal.put("quoteId", "QT-AIR-1");
internal.put("optionNo", "AIR-OPT-01");
internal.put("flightDate", "2026-09-16");
internal.put("origin", "上海");
internal.put("destination", "LAX");
internal.put("airline", "CZ");
internal.put("weightKg", "100");
internal.put("volumeCbm", "2");
internal.put("pieceQty", "3");
internal.put("operatorUserId", "air1");
internal.put("operatorName", "航线");
internal.put("instructionText", "WO202609160001锁舱");
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
assertEquals("WO202609160001", body.get("workOrderNo"));
assertEquals("QT-AIR-1", body.get("quoteId"));
assertEquals("AIR-OPT-01", body.get("optionNo"));
assertEquals("2026-09-16", body.get("flightDate"));
assertEquals("AIRLINE", body.get("operatorRole"));
assertEquals("CZ", body.get("airline"));
assertTrue(body.containsKey("requestId"));
}
@Test
public void releaseKeepsLockId() {
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("lockId", "CA-1");
internal.put("workOrderNo", "WO202609160001");
internal.put("flightDate", "2026/09/16");
internal.put("operatorUserId", "air1");
Map<String, Object> body = TmsCabinRequestMapper.release(internal);
assertEquals("CA-1", body.get("lockId"));
assertEquals("2026-09-16", body.get("flightDate"));
assertEquals("AIRLINE", body.get("operatorRole"));
}
}
@@ -0,0 +1,33 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* HMAC 签名单测:不打真实 TMS。改正文或时间戳必须换签名。
*/
public class TmsHmacSignerTest {
@Test
public void sameInputSameSignature() {
String first = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", "{\"a\":1}");
String second = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", "{\"a\":1}");
assertEquals(first, second);
}
@Test
public void bodyChangeChangesSignature() {
String first = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", "{\"a\":1}");
String second = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", "{\"a\":2}");
assertNotEquals(first, second);
}
@Test
public void blankSecretRejected() {
assertThrows(IllegalArgumentException.class,
() -> TmsHmacSigner.sign("", "1", "n", "{}"));
}
}
@@ -92,8 +92,10 @@ public class TmsQuoteCurrencyTest {
fee.put("remark", "计费重进位+1000KG");
Map<String, Object> data = new HashMap<>();
data.put("quoteFound", true);
data.put("quoteId", "QT-AIR-KEEP");
data.put("totalSalePrice", "14669.60");
data.put("currency", "CNY");
data.put("airOptions", Collections.singletonList(Collections.singletonMap("optionNo", "AIR-OPT-01")));
data.put("feeItems", Collections.singletonList(fee));
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
@@ -107,5 +109,7 @@ public class TmsQuoteCurrencyTest {
assertTrue(quote.get("fee_lines").contains("\"quantity\":\"1333.6\""));
assertTrue(quote.get("fee_lines").contains("\"unit_price\":\"11.0\""));
assertTrue(quote.get("fee_lines").contains("计费重进位+1000KG"));
assertEquals("QT-AIR-KEEP", quote.get("quoteId"));
assertTrue(quote.get("airOptions").contains("AIR-OPT-01"));
}
}