落地海运私聊文字询价全流程,海运多项费用改走文本明细,避免企微卡片引用条截断。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 14:26:09 +08:00
co-authored by Cursor
parent 58b3c2f3e6
commit 0a3c74e3a1
41 changed files with 3702 additions and 48 deletions
@@ -34,6 +34,9 @@ public class InquiryAgentController {
@Autowired
private IInquiryQuoteTemplateService quoteTemplateService;
@Autowired
private org.jeecg.modules.inquiry.service.IInquiryStaffRoleService staffRoleService;
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
private String agentToken;
@@ -122,7 +125,12 @@ public class InquiryAgentController {
Map<String, Object> facts = asMap(body == null ? null : body.get("facts"));
@SuppressWarnings("unchecked")
Map<String, Object> quote = asMap(body == null ? null : body.get("quote"));
return Result.OK(quoteTemplateService.renderForAgent(workOrderNo, businessLine, facts, quote, sales));
Map<String, Object> rendered = quoteTemplateService.renderForAgent(
workOrderNo, businessLine, facts, quote, sales);
if (rendered != null && Boolean.TRUE.equals(rendered.get("ok"))) {
workOrderService.markQuoteFileIssued(workOrderNo);
}
return Result.OK(rendered);
}
@ApiOperation("经主账查真 TMS(有价回报价 / 无价回暂无报价 / 技术失败)")
@@ -135,6 +143,70 @@ public class InquiryAgentController {
return Result.OK(workOrderService.queryTms(body));
}
@ApiOperation("海运拉群:按起运/目的匹配启用的产品·海运")
@PostMapping("/staff/matchSea")
public Result<Map<String, Object>> matchSea(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
String origin = firstStr(body, "origin", "起运港");
String dest = firstStr(body, "destination", "目的港");
Map<String, Object> out = new java.util.LinkedHashMap<>();
out.put("staff", staffRoleService.matchSeaStaff(origin, dest));
return Result.OK(out);
}
@ApiOperation("按企微 userid 取员工展示名")
@PostMapping("/staff/byWecom")
public Result<Map<String, Object>> staffByWecom(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(staffRoleService.findDisplayByWecomId(firstStr(body, "wecomId", "wecom_id")));
}
@ApiOperation("按员工姓名取企微 userid(会话存档询价机器人)")
@PostMapping("/staff/byName")
public Result<Map<String, Object>> staffByName(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(staffRoleService.findDisplayByName(firstStr(body, "name")));
}
@ApiOperation("记下海运协同群(不改六态)")
@PostMapping("/ticket/bindCollabGroup")
public Result<Map<String, Object>> bindCollabGroup(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.bindCollabGroup(body));
}
@ApiOperation("合并协同字段(不改六态、不写报价版本)")
@PostMapping("/ticket/patchCollabFacts")
public Result<Map<String, Object>> patchCollabFacts(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.patchCollabFacts(body));
}
@ApiOperation("按协同群 chat_id 找回工单(只读)")
@PostMapping("/ticket/byCollabChat")
public Result<Map<String, Object>> byCollabChat(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.findByCollabChat(firstStr(body, "chatId", "chat_id")));
}
private boolean authed(HttpServletRequest http) {
String got = http.getHeader("X-Agent-Token");
if (StringUtils.isBlank(got)) {
@@ -118,6 +118,27 @@ public class InquiryTicket implements Serializable {
@ApiModelProperty("业务版本")
private Integer version;
@ApiModelProperty("海运货量原文")
private String cargoQty;
@ApiModelProperty("整柜或拼柜")
private String fclLcl;
@ApiModelProperty("箱型箱量原文")
private String containerSpec;
@ApiModelProperty("询价字段快照 JSON")
private String factsJson;
@ApiModelProperty("协同字段快照 JSON")
private String collabFactsJson;
@ApiModelProperty("协同群 chat_id")
private String collabChatId;
@ApiModelProperty("协同群成员 userid JSON")
private String collabMemberIds;
private String createBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@@ -30,4 +30,19 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
* @return 同步计数
*/
StaffWecomSyncResult syncFromWeCom(String operator);
/**
* 海运拉群:启用的产品·海运,按起运/目的匹配线路。
*/
java.util.List<java.util.Map<String, Object>> matchSeaStaff(String origin, String destination);
/**
* 按企微 userid 取展示名。
*/
java.util.Map<String, Object> findDisplayByWecomId(String wecomId);
/**
* 按员工姓名取启用账号(会话存档「询价机器人」用)。
*/
java.util.Map<String, Object> findDisplayByName(String name);
}
@@ -68,4 +68,25 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
* 必须校验 salesWecomId 与工单发起人一致。
*/
Map<String, Object> getForAgent(String workOrderNo, String salesWecomId);
/**
* 报价文件已发给销售:写入报价载荷 quoteFileIssued,续办据此回到成交跟进。
* 不改六态(仍是已报价,直到点成交/未成交/协商中)。
*/
void markQuoteFileIssued(String workOrderNo);
/**
* 记下海运协同群。不改六态。
*/
Map<String, Object> bindCollabGroup(Map<String, Object> body);
/**
* 合并协同字段。不改六态、不写报价版本。
*/
Map<String, Object> patchCollabFacts(Map<String, Object> body);
/**
* 用协同群 chat_id 找回工单。只读。
*/
Map<String, Object> findByCollabChat(String chatId);
}
@@ -661,15 +661,24 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return null;
}
/**
* 读库 keywords_json。合法 JSON 数组优先;非法(如 {@code [海运,整柜,FCL]})
* 或数组里套了一整段方括号,都交给 {@link QuoteTemplateMatcher#asKeywords} 摊开。
* 禁止把整段原文当成一个关键词,否则海运有模板也对不上。
*/
private static List<String> parseKeywords(String json) {
if (StringUtils.isBlank(json)) {
return Collections.emptyList();
}
try {
return JSON.parseArray(json, String.class);
} catch (Exception e) {
return Collections.singletonList(json);
List<String> parsed = JSON.parseArray(json, String.class);
if (parsed != null && !parsed.isEmpty()) {
return QuoteTemplateMatcher.asKeywords(parsed);
}
} catch (Exception ignored) {
// 非法 JSON:按方括号/逗号拆
}
return QuoteTemplateMatcher.asKeywords(json);
}
private static List<String> extractUnmapped(String mappingJson) {
@@ -132,4 +132,49 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
return result;
}
}
/**
* 海运拉群:库里启用的产品·海运,再按起运/目的过滤线路。
*/
@Override
public List<Map<String, Object>> matchSeaStaff(String origin, String destination) {
List<InquiryStaffRole> rows = list(new QueryWrapper<InquiryStaffRole>()
.eq("role_code", "sea")
.eq("status", "active"));
return org.jeecg.modules.inquiry.staff.SeaStaffMatcher.match(rows, origin, destination);
}
@Override
public Map<String, Object> findDisplayByWecomId(String wecomId) {
InquiryStaffRole row = findByWecomId(wecomId);
Map<String, Object> out = new HashMap<>();
if (row == null) {
return out;
}
out.put("name", row.getName());
out.put("wecomId", row.getWecomId());
out.put("roleCode", row.getRoleCode());
return out;
}
@Override
public Map<String, Object> findDisplayByName(String name) {
Map<String, Object> out = new HashMap<>();
if (StringUtils.isBlank(name)) {
return out;
}
InquiryStaffRole row = getOne(new QueryWrapper<InquiryStaffRole>()
.eq("name", name.trim())
.eq("status", "active")
.isNotNull("wecom_id")
.ne("wecom_id", "")
.last("LIMIT 1"), false);
if (row == null) {
return out;
}
out.put("name", row.getName());
out.put("wecomId", row.getWecomId());
out.put("roleCode", row.getRoleCode());
return out;
}
}
@@ -301,6 +301,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
ticket.setVolumeCbm(parseDecimal(facts.get("体积")));
ticket.setQuoteDate(parseDate(facts.get("报价日期")));
ticket.setCargoQty(StringUtils.trimToNull(firstFact(facts, "货量", "货物数量")));
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
ticket.setContainerSpec(StringUtils.trimToNull(facts.get("箱型箱量")));
ticket.setFactsJson(JSON.toJSONString(facts));
ticket.setDemandTitle(ticket.getOrigin() + " → " + ticket.getDestination());
ticket.setDemandDetail("FP:" + fingerprint(req));
ticket.setStatus(TicketStatuses.INQUIRING);
@@ -422,8 +426,11 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
StringUtils.trimToEmpty(facts.get("目的港")),
StringUtils.trimToEmpty(facts.get("品名")),
StringUtils.trimToEmpty(facts.get("件数")),
StringUtils.trimToEmpty(firstFact(facts, "货量", "货物数量")),
StringUtils.trimToEmpty(firstFact(facts, "重量", "毛重")),
StringUtils.trimToEmpty(facts.get("体积")),
StringUtils.trimToEmpty(facts.get("箱型箱量")),
StringUtils.trimToEmpty(facts.get("整柜或拼柜")),
StringUtils.trimToEmpty(facts.get("报价日期")));
}
@@ -528,6 +535,25 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (ticket.getQuoteDate() != null) {
facts.put("报价日期", new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate()));
}
if (StringUtils.isNotBlank(ticket.getCargoQty())) {
facts.put("货量", ticket.getCargoQty());
}
if (StringUtils.isNotBlank(ticket.getFclLcl())) {
facts.put("整柜或拼柜", ticket.getFclLcl());
}
if (StringUtils.isNotBlank(ticket.getContainerSpec())) {
facts.put("箱型箱量", ticket.getContainerSpec());
}
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
if (stored != null) {
for (String key : stored.keySet()) {
if (StringUtils.isBlank(facts.get(key)) && stored.get(key) != null) {
facts.put(key, String.valueOf(stored.get(key)));
}
}
}
}
Map<String, Object> quote = new LinkedHashMap<>();
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, no)
@@ -545,7 +571,14 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|| TicketStatuses.NEGOTIATING.equals(status) || TicketStatuses.CLOSED.equals(status)) {
waitPhase = "done";
} else if (TicketStatuses.QUOTED.equals(status) && !quote.isEmpty()) {
waitPhase = "wait_adopt";
// 文件已发出:成交跟进。六态仍是已报价,不能退回协同/出方案卡
if (quoteFileAlreadyIssued(no, quote)) {
waitPhase = "wait_deal";
} else if ("SEA".equalsIgnoreCase(ticket.getBusinessLine())) {
waitPhase = "wait_collab";
} else {
waitPhase = "wait_adopt";
}
} else {
waitPhase = "tms_miss";
}
@@ -555,9 +588,235 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("facts", facts);
out.put("quote", quote);
out.put("wait_phase", waitPhase);
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
Map<String, String> collabFacts = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson());
if (parsed != null) {
for (String key : parsed.keySet()) {
if (parsed.get(key) != null) {
collabFacts.put(key, String.valueOf(parsed.get(key)));
}
}
}
}
out.put("collab_facts", collabFacts);
out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds()));
return out;
}
/**
* 报价文件已发给销售。只打载荷标记,不改六态。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void markQuoteFileIssued(String workOrderNo) {
String no = StringUtils.trimToEmpty(workOrderNo);
if (StringUtils.isBlank(no)) {
return;
}
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, no)
.orderByDesc(InquiryQuote::getCreateTime)
.last("LIMIT 1"));
if (quotes == null || quotes.isEmpty()) {
return;
}
InquiryQuote row = quotes.get(0);
JSONObject payload = StringUtils.isBlank(row.getPayloadJson())
? new JSONObject(true)
: JSON.parseObject(row.getPayloadJson());
if (payload == null) {
payload = new JSONObject(true);
}
payload.put("quoteFileIssued", true);
row.setPayloadJson(payload.toJSONString());
row.setUpdateTime(new Date());
quoteMapper.updateById(row);
}
/**
* 续办停点:文件已发出或已有附件,就不再回到出方案/协同。
*/
private boolean quoteFileAlreadyIssued(String workOrderNo, Map<String, Object> quote) {
Object flag = quote == null ? null : quote.get("quoteFileIssued");
if (Boolean.TRUE.equals(flag) || "true".equalsIgnoreCase(String.valueOf(flag))) {
return true;
}
Long n = attachmentMapper.selectCount(new LambdaQueryWrapper<InquiryAttachment>()
.eq(InquiryAttachment::getWorkOrderNo, workOrderNo));
return n != null && n > 0;
}
/**
* 记下海运协同群 chat_id 与成员。不改六态、不写报价版本。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> bindCollabGroup(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
String chatId = text(body, "chatId", "chat_id");
InquiryTicket ticket = findByWorkOrderNo(no);
Map<String, Object> out = new HashMap<>();
if (ticket == null || StringUtils.isBlank(chatId)) {
out.put("ok", false);
out.put("error", "ticket_or_chat_missing");
return out;
}
ticket.setCollabChatId(chatId);
ticket.setCollabMemberIds(memberIdsJson(body));
ticket.setUpdateTime(new Date());
updateById(ticket);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "bind_collab_group", "agent", chatId);
out.put("ok", true);
out.put("chat_id", chatId);
out.put("work_order_no", no);
return out;
}
/**
* 合并协同六字段。只写非空键;不改六态。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> patchCollabFacts(Map<String, Object> body) {
String no = text(body, "workOrderNo", "work_order_no");
InquiryTicket ticket = findByWorkOrderNo(no);
Map<String, Object> out = new HashMap<>();
if (ticket == null) {
out.put("ok", false);
out.put("error", "ticket_not_found");
return out;
}
Map<String, String> merged = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
JSONObject old = JSON.parseObject(ticket.getCollabFactsJson());
if (old != null) {
for (String key : old.keySet()) {
if (old.get(key) != null) {
merged.put(key, String.valueOf(old.get(key)));
}
}
}
}
Object raw = body == null ? null : body.get("facts");
if (raw instanceof Map) {
for (Map.Entry<?, ?> e : ((Map<?, ?>) raw).entrySet()) {
if (e.getKey() == null || e.getValue() == null) {
continue;
}
String val = String.valueOf(e.getValue()).trim();
if (!val.isEmpty()) {
merged.put(String.valueOf(e.getKey()), val);
}
}
}
ticket.setCollabFactsJson(JSON.toJSONString(merged));
ticket.setUpdateTime(new Date());
updateById(ticket);
out.put("ok", true);
out.put("facts", merged);
return out;
}
/**
* 按协同群找回工单。群里补字段用。只读。
*/
@Override
public Map<String, Object> findByCollabChat(String chatId) {
Map<String, Object> out = new LinkedHashMap<>();
String id = StringUtils.trimToEmpty(chatId);
out.put("found", false);
if (StringUtils.isBlank(id)) {
return out;
}
InquiryTicket ticket = getOne(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getCollabChatId, id)
.last("LIMIT 1"), false);
if (ticket == null) {
return out;
}
out.put("found", true);
out.put("ok", true);
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("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds()));
out.put("memberIds", parseMemberIds(ticket.getCollabMemberIds()));
Map<String, String> collabFacts = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson());
if (parsed != null) {
for (String key : parsed.keySet()) {
if (parsed.get(key) != null) {
collabFacts.put(key, String.valueOf(parsed.get(key)));
}
}
}
}
out.put("collab_facts", collabFacts);
return out;
}
private static String memberIdsJson(Map<String, Object> body) {
if (body == null) {
return "[]";
}
Object raw = body.get("memberIds");
if (raw == null) {
raw = body.get("member_ids");
}
if (raw instanceof java.util.Collection) {
return JSON.toJSONString(raw);
}
if (raw instanceof String && StringUtils.isNotBlank((String) raw)) {
return ((String) raw).trim();
}
return "[]";
}
private static List<String> parseMemberIds(String raw) {
List<String> ids = new ArrayList<>();
if (StringUtils.isBlank(raw)) {
return ids;
}
try {
com.alibaba.fastjson.JSONArray arr = JSON.parseArray(raw);
if (arr != null) {
for (int i = 0; i < arr.size(); i++) {
String uid = StringUtils.trimToEmpty(arr.getString(i));
if (!uid.isEmpty()) {
ids.add(uid);
}
}
return ids;
}
} catch (Exception ignore) {
// 兼容逗号分隔旧值
}
for (String part : raw.split("[,;]")) {
String uid = StringUtils.trimToEmpty(part);
if (!uid.isEmpty()) {
ids.add(uid);
}
}
return ids;
}
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 InquiryTicket findByWorkOrderNo(String workOrderNo) {
return getOne(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
@@ -0,0 +1,63 @@
package org.jeecg.modules.inquiry.staff;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 海运拉群人员匹配:只看员工管理里岗位=产品·海运且启用的人。
* <p>
* 「海运·全部线路 / 海运全线」必拉;写了具体港口的,对得上本单起运港或目的港才拉。
* 禁止:按销售 userid 伪造名单;不查企微通讯录。
*/
public final class SeaStaffMatcher {
private SeaStaffMatcher() {
}
/**
* @param rows 员工岗位行
* @param origin 本单起运港
* @param destination 本单目的港
* @return 给智能体的 {name, wecomId, routes}
*/
public static List<Map<String, Object>> match(List<InquiryStaffRole> rows, String origin, String destination) {
List<Map<String, Object>> hits = new ArrayList<>();
if (rows == null) {
return hits;
}
String from = StringUtils.trimToEmpty(origin);
String to = StringUtils.trimToEmpty(destination);
for (InquiryStaffRole row : rows) {
if (row == null) {
continue;
}
if (!"sea".equalsIgnoreCase(StringUtils.trimToEmpty(row.getRoleCode()))) {
continue;
}
if (!"active".equalsIgnoreCase(StringUtils.defaultIfBlank(row.getStatus(), "active"))) {
continue;
}
if (StringUtils.isBlank(row.getWecomId())) {
continue;
}
String routes = StringUtils.defaultString(row.getRoutes());
if (routes.contains("海运·全部线路") || routes.contains("海运全线")
|| (StringUtils.isNotBlank(from) && routes.contains(from))
|| (StringUtils.isNotBlank(to) && routes.contains(to))) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("name", StringUtils.defaultString(row.getName()));
item.put("wecomId", row.getWecomId().trim());
item.put("roleCode", "sea");
item.put("routes", routes);
item.put("status", "active");
hits.add(item);
}
}
return hits;
}
}
@@ -130,6 +130,10 @@ public final class QuoteTemplateMatcher {
/**
* 模板 keywordsJson / 列表字段转关键词;空串不参与匹配。
* <p>
* 后台/seed 偶发把未加引号的 {@code [海运,整柜,FCL]} 整段塞进列表一项,
* 或 JSON 解析失败后把原文当成单个词。这两种都要摊开,否则「海运」对不上、
* 又没设默认模板时,销售会看到固定失败话术。
*/
public static List<String> asKeywords(Object raw) {
List<String> out = new ArrayList<>();
@@ -139,7 +143,9 @@ public final class QuoteTemplateMatcher {
if (raw instanceof Collection) {
for (Object it : (Collection<?>) raw) {
String s = it == null ? "" : String.valueOf(it).trim();
if (StringUtils.isNotBlank(s)) {
if (s.startsWith("[")) {
out.addAll(asKeywords(s));
} else if (StringUtils.isNotBlank(s)) {
out.add(s);
}
}
@@ -1,6 +1,8 @@
package org.jeecg.modules.inquiry.tms;
import com.alibaba.fastjson.JSON;
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;
@@ -25,8 +27,9 @@ import java.util.TimeZone;
/**
* 智能体查价门面:询价必填齐了就必须打真 TMS,再按 TMS 回包分类。
* <p>
* 职责:销售侧起运/目的原文、品名、件数、重量、体积、包装、报价日期、工单号齐了就出站。
* 职责:询价必填齐了就出站。空运看件数/重量/体积/包装;海运看货量/整柜或拼柜/箱型箱量,不拦空运量尺。
* 机场三字码能投影就带上;对不上也必须出站,禁止本地判「暂无报价」。
* 业务线以工单为准,禁止海运请求被默认成 AIR。
* 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价;
* HTTP 超时/熔断/5xx → 技术失败。
* 禁止:因 originCode 对不上就 skip;fixture 假价;LOCK/RELEASE;Python 直连 TMS。
@@ -53,8 +56,18 @@ public class InquiryTmsQueryService {
*/
public Map<String, Object> queryFromAgent(Map<String, Object> body) {
Map<String, Object> facts = extractFacts(body);
String workOrderNo = text(first(body.get("work_order_no"), facts.get("work_order_no")));
String line = text(first(facts.get("业务线"), facts.get("businessLine"), "AIR")).toUpperCase();
String workOrderNo = text(first(
body == null ? null : body.get("work_order_no"),
body == null ? null : body.get("workOrderNo"),
facts.get("work_order_no")));
InquiryTicket ticket = findTicket(workOrderNo);
if (ticket != null) {
mergeTicketFacts(facts, ticket);
}
String line = resolveTransportLine(
body,
facts,
ticket == null ? "" : ticket.getBusinessLine());
if (!properties.queryConfigured() || !gateway.isConfigured()) {
return tech("TMS 未配置,拒绝假价");
@@ -68,12 +81,23 @@ public class InquiryTmsQueryService {
segment.put("origin", origin);
segment.put("destination", destination);
segment.put("cargoName", text(facts.get("品名")));
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型"))));
if ("SEA".equals(line)) {
// 海运不拦空运件数/重量/体积/包装;港口只带名称,没有港口字典也不跳过。
segment.put("cargoQty", first(facts.get("货量"), facts.get("货物数量")));
segment.put("loadType", facts.get("整柜或拼柜"));
segment.put("containerSpec", facts.get("箱型箱量"));
Object items = facts.get("containerItems");
if (items != null) {
segment.put("containerItems", items);
}
} else {
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型"))));
}
String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期"));
if (quoteDate == null) {
@@ -82,7 +106,9 @@ public class InquiryTmsQueryService {
quoteDate = fmt.format(new Date());
}
List<String> missing = missingAirQuery(workOrderNo, quoteDate, segment);
List<String> missing = "SEA".equals(line)
? missingSeaQuery(workOrderNo, quoteDate, segment, facts)
: missingAirQuery(workOrderNo, quoteDate, segment);
if (!missing.isEmpty()) {
log.warn("tms.query skip incomplete wo={} missing={}", workOrderNo, missing);
return incomplete(missing);
@@ -362,6 +388,40 @@ public class InquiryTmsQueryService {
* 出站前只拦销售侧必填真缺。originCode / destinationCode 不是拦网:
* 对不上机场也要查,TMS 自己回无价或 1003。
*/
/**
* 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量。
* 不要求机场三字码、件数、重量、体积、包装。
*/
private static List<String> missingSeaQuery(String workOrderNo, String quoteDate,
Map<String, Object> segment, Map<String, Object> facts) {
List<String> missing = new ArrayList<>();
if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) {
missing.add("workOrderNo");
}
if (quoteDate == null || quoteDate.isEmpty()) {
missing.add("quoteDate");
}
for (String key : new String[] {"origin", "destination", "cargoName"}) {
if (segment.get(key) == null || "".equals(segment.get(key))) {
missing.add(key);
}
}
if (blank(facts.get("货量")) && blank(facts.get("货物数量"))) {
missing.add("货量");
}
if (blank(facts.get("整柜或拼柜"))) {
missing.add("整柜或拼柜");
}
if (blank(facts.get("箱型箱量"))) {
missing.add("箱型箱量");
}
return missing;
}
private static boolean blank(Object raw) {
return raw == null || String.valueOf(raw).trim().isEmpty();
}
private static List<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
List<String> missing = new ArrayList<>();
if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) {
@@ -439,6 +499,69 @@ public class InquiryTmsQueryService {
return out;
}
/**
* 业务线:请求体 / 字段快照 / 工单,禁止无业务线时把海运当成空运拦网。
*/
static String resolveTransportLine(Map<String, Object> body, Map<String, Object> facts, String ticketLine) {
String line = text(first(
body == null ? null : body.get("businessLine"),
body == null ? null : body.get("business_line"),
facts == null ? null : facts.get("业务线"),
facts == null ? null : facts.get("businessLine"),
facts == null ? null : facts.get("business_line"),
ticketLine
)).toUpperCase();
if ("SEA".equals(line) || "AIR".equals(line) || "LAND".equals(line)) {
return line;
}
return "AIR";
}
private InquiryTicket findTicket(String workOrderNo) {
if (StringUtils.isBlank(workOrderNo) || workOrderService == null) {
return null;
}
return workOrderService.getOne(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
.last("LIMIT 1"), false);
}
/**
* 工单已落库的海运事实补进查价 facts,避免智能体漏传货量/箱型。
*/
static void mergeTicketFacts(Map<String, Object> facts, InquiryTicket ticket) {
if (facts == null || ticket == null) {
return;
}
putIfBlank(facts, "起运港", ticket.getOrigin());
putIfBlank(facts, "目的港", ticket.getDestination());
putIfBlank(facts, "品名", ticket.getGoodsName());
putIfBlank(facts, "货量", ticket.getCargoQty());
putIfBlank(facts, "整柜或拼柜", ticket.getFclLcl());
putIfBlank(facts, "箱型箱量", ticket.getContainerSpec());
if (ticket.getQuoteDate() != null && blank(facts.get("报价日期"))) {
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
facts.put("报价日期", fmt.format(ticket.getQuoteDate()));
}
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
if (stored != null) {
for (String key : stored.keySet()) {
if (blank(facts.get(key)) && stored.get(key) != null) {
facts.put(key, stored.get(key));
}
}
}
}
}
private static void putIfBlank(Map<String, Object> facts, String key, String val) {
if (blank(facts.get(key)) && StringUtils.isNotBlank(val)) {
facts.put(key, val);
}
}
@SuppressWarnings("unchecked")
private static Map<String, Object> extractFacts(Map<String, Object> body) {
if (body == null) {
@@ -0,0 +1,146 @@
package org.jeecg.modules.inquiry.tms;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 海运出站前把销售口语整理成 TMS V2 字段。
* <p>
* 对照接口文档 6.3:FCL 必须带 containerType + containerQty(如 40HQ / 1),
* loadType 只能是 FCL/LCL,cargoQty 必须是数字。销售卡片仍看原文「整柜 / 1x40HQ / 15件」。
* 禁止:把口语原样扔给 TMS;在这里猜港口。
*/
public final class SeaTmsFieldNormalizer {
/** 1x40HQ / 2*40高柜 */
private static final Pattern QTY_X_TYPE = Pattern.compile(
"(?i)(\\d+)\\s*[xX×*]\\s*(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜|柜)?");
/** 40HQ*2 */
private static final Pattern TYPE_X_QTY = Pattern.compile(
"(?i)(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜)?\\s*[xX×*]\\s*(\\d+)");
/** 2个40尺高柜 */
private static final Pattern CN_GE = Pattern.compile(
"(?i)(\\d+)\\s*个\\s*(20|40|45)\\s*['\"′尺]?(HQ|GP|高柜|平柜|柜)?");
/** 单独写 40HQ / 40高柜,箱量按 1 */
private static final Pattern BARE_TYPE = Pattern.compile(
"(?i)(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜)");
private SeaTmsFieldNormalizer() {
}
/**
* 整柜/拼柜 → FCL/LCL。已经是 FCL/LCL 的原样(大写)。
*/
public static String loadType(Object raw) {
if (raw == null) {
return null;
}
String text = String.valueOf(raw).trim();
if (text.isEmpty()) {
return null;
}
String upper = text.toUpperCase(Locale.ROOT);
if ("FCL".equals(upper) || text.contains("整柜") || text.contains("整箱")) {
return "FCL";
}
if ("LCL".equals(upper) || text.contains("拼柜") || text.contains("拼箱")) {
return "LCL";
}
return upper;
}
/**
* 货物数量:15件 / 20吨 → 15 / 20。带单位会 1001。
*/
public static BigDecimal cargoQty(Object raw) {
return TmsV2RequestMapper.asPositiveDecimal(raw);
}
/**
* 把「1x40HQ」「2个40尺高柜」「1*20GP+1*40HQ」拆成 TMS 箱型列表。
*
* @return 每项含 containerType、containerQty;拆不出则空列表,不编造。
*/
public static List<Map<String, Object>> parseContainers(Object raw) {
List<Map<String, Object>> items = new ArrayList<>();
if (raw == null) {
return items;
}
String text = String.valueOf(raw).trim();
if (text.isEmpty()) {
return items;
}
collect(items, QTY_X_TYPE.matcher(text), 1, 2, 3);
if (!items.isEmpty()) {
return items;
}
Matcher typeFirst = TYPE_X_QTY.matcher(text);
while (typeFirst.find()) {
addItem(items, typeFirst.group(1), typeFirst.group(2), typeFirst.group(3));
}
if (!items.isEmpty()) {
return items;
}
collect(items, CN_GE.matcher(text), 1, 2, 3);
if (!items.isEmpty()) {
return items;
}
Matcher bare = BARE_TYPE.matcher(text);
if (bare.find()) {
addItem(items, bare.group(1), bare.group(2), "1");
}
return items;
}
private static void collect(List<Map<String, Object>> items, Matcher matcher,
int qtyGroup, int sizeGroup, int suffixGroup) {
while (matcher.find()) {
addItem(items, matcher.group(sizeGroup), matcher.group(suffixGroup), matcher.group(qtyGroup));
}
}
private static void addItem(List<Map<String, Object>> items, String size, String suffix, String qty) {
String type = toTmsType(size, suffix);
Integer count = TmsV2RequestMapper.asInteger(qty);
if (type == null || count == null || count < 1) {
return;
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("containerType", type);
row.put("containerQty", count);
items.add(row);
}
/**
* 口语箱型落到 TMS 码:40HQ / 40GP / 20GP / 45HQ。
*/
static String toTmsType(String size, String suffix) {
if (size == null) {
return null;
}
String foot = size.trim();
String tag = suffix == null ? "" : suffix.trim().toUpperCase(Locale.ROOT);
if (tag.contains("高") || "HQ".equals(tag) || "HC".equals(tag)) {
return foot + "HQ";
}
if (tag.contains("NOR")) {
return foot + "NOR";
}
if (tag.contains("RH")) {
return foot + "RH";
}
if (tag.contains("平") || "GP".equals(tag) || "柜".equals(suffix) || tag.isEmpty()) {
return foot + "GP";
}
return foot + tag;
}
}
@@ -15,6 +15,7 @@ import java.util.regex.Pattern;
* 对照《友通达询价智能体TMS系统对接开发接口文档V1.0.1》5.2 / 2.1 / 6.3:
* quantity 必须是 JSON 整数;weightKg / volumeCbm 必须是 JSON 小数且大于 0;
* 带「KGS」「CBM」的字符串会触发业务码 1001(参数格式错误)。
* 海运同样:整柜/1x40HQ/15件 必须先整理成 FCL + 40HQ + 1 + 数字货量,否则 TMS 回 1001。
* 报价日期:文档漏了该请求字段,实际 TMS 使用 quoteDate,格式 yyyy-MM-dd(如 2026-09-07)。
* 禁止:inquiryTime=此刻(会查成无匹配);在这里做字典模糊匹配或猜测机场代码。
*/
@@ -76,9 +77,43 @@ public final class TmsV2RequestMapper {
put(target, "weightKg", asPositiveDecimal(first(source, "weightKg")));
put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm")));
put(target, "packageType", first(source, "packageType", "packagingType"));
// 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL,
// cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。
applySeaFields(source, target);
return compact(target);
}
/**
* 把海运口语字段写成 TMS 查询字段。空运段没有这些键则不写。
*/
private static void applySeaFields(Map<String, Object> source, Map<String, Object> target) {
Object cargoQty = first(source, "cargoQty", "货量");
Object loadRaw = first(source, "loadType", "整柜或拼柜");
Object spec = first(source, "containerSpec", "箱型箱量", "containerTypeQty");
if (cargoQty == null && loadRaw == null && spec == null
&& first(source, "containerType") == null
&& first(source, "containerItems") == null) {
return;
}
put(target, "cargoQty", SeaTmsFieldNormalizer.cargoQty(cargoQty));
put(target, "loadType", SeaTmsFieldNormalizer.loadType(loadRaw));
Object readyItems = first(source, "containerItems");
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers(spec);
if (readyItems instanceof List && !((List<?>) readyItems).isEmpty()) {
put(target, "containerItems", readyItems);
} else if (items.size() > 1) {
put(target, "containerItems", items);
}
Object type = first(source, "containerType");
Object qty = first(source, "containerQty");
if (type == null && !items.isEmpty()) {
type = items.get(0).get("containerType");
qty = items.get(0).get("containerQty");
}
put(target, "containerType", type);
put(target, "containerQty", asInteger(qty));
}
/**
* 报价日期规范成 yyyy-MM-dd。解析不了则不传,避免 1001。
*/
@@ -0,0 +1,64 @@
package org.jeecg.modules.inquiry.staff;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 海运拉群人员匹配:不连库、不打企微。
* <p>
* 「海运·全部线路」必拉;写了具体港口的,对得上本单起运/目的才拉;停用不拉。
*/
public class SeaStaffMatcherTest {
@Test
public void allRoutesMustPull() {
InquiryStaffRole row = staff("sea", "active", "p1", "张产品", "海运·全部线路");
List<Map<String, Object>> hits = SeaStaffMatcher.match(
Arrays.asList(row), "上海", "洛杉矶");
assertEquals(1, hits.size());
assertEquals("p1", hits.get(0).get("wecomId"));
}
@Test
public void specificPortPullsWhenMatch() {
InquiryStaffRole row = staff("sea", "active", "p2", "李产品", "上海,宁波");
List<Map<String, Object>> hits = SeaStaffMatcher.match(
Arrays.asList(row), "上海", "汉堡");
assertEquals(1, hits.size());
}
@Test
public void specificPortSkipsWhenMismatch() {
InquiryStaffRole row = staff("sea", "active", "p3", "王产品", "深圳");
List<Map<String, Object>> hits = SeaStaffMatcher.match(
Arrays.asList(row), "上海", "洛杉矶");
assertTrue(hits.isEmpty());
}
@Test
public void disabledOrWrongRoleSkipped() {
InquiryStaffRole disabled = staff("sea", "disabled", "p4", "停用", "海运·全部线路");
InquiryStaffRole air = staff("air", "active", "p5", "空运岗", "海运·全部线路");
List<Map<String, Object>> hits = SeaStaffMatcher.match(
Arrays.asList(disabled, air), "上海", "洛杉矶");
assertTrue(hits.isEmpty());
}
private static InquiryStaffRole staff(String role, String status, String wecomId,
String name, String routes) {
InquiryStaffRole row = new InquiryStaffRole();
row.setRoleCode(role);
row.setStatus(status);
row.setWecomId(wecomId);
row.setName(name);
row.setRoutes(routes);
return row;
}
}
@@ -51,6 +51,37 @@ public class QuoteTemplateMatcherTest {
assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null));
}
@Test
public void brokenBracketKeywordStillHitsSea() {
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> sea = new HashMap<>();
sea.put("name", "海运报价单模板");
sea.put("bizType", "海运");
sea.put("isDefault", false);
List<String> broken = new ArrayList<>();
broken.add("[海运,整柜,FCL]");
sea.put("keywords", broken);
list.add(sea);
Map<String, Object> facts = new HashMap<>();
facts.put("整柜或拼柜", "整柜");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "SEA", facts, null);
assertNotNull(hit);
assertEquals("keyword", hit.get("matchType"));
assertEquals("海运报价单模板", hit.get("name"));
}
@Test
public void seaFallsBackToDefaultWhenKeywordMisses() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("海运默认", "海运", true, "洛杉矶"));
Map<String, Object> facts = new HashMap<>();
facts.put("目的港", "纽约港");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "SEA", facts, null);
assertNotNull(hit);
assertEquals("default", hit.get("matchType"));
assertEquals("海运默认", hit.get("name"));
}
@Test
public void seaTemplateIgnoredForAir() {
List<Map<String, Object>> list = new ArrayList<>();
@@ -0,0 +1,50 @@
package org.jeecg.modules.inquiry.tms;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 海运查价身份:不打真实 TMS。
* <p>
* 工单已是 SEA 时,即使请求没带业务线,也不能默认成 AIR。
*/
public class InquiryTmsQueryServiceTest {
@Test
public void ticketSeaWinsWhenBodyOmitsLine() {
Map<String, Object> body = new HashMap<>();
body.put("work_order_no", "WO202609150001");
String line = InquiryTmsQueryService.resolveTransportLine(body, new HashMap<>(), "SEA");
assertEquals("SEA", line);
}
@Test
public void bodyBusinessLineWins() {
Map<String, Object> body = new HashMap<>();
body.put("businessLine", "SEA");
String line = InquiryTmsQueryService.resolveTransportLine(body, new HashMap<>(), "AIR");
assertEquals("SEA", line);
}
@Test
public void mergeTicketFillsSeaFacts() {
InquiryTicket ticket = new InquiryTicket();
ticket.setOrigin("上海");
ticket.setDestination("洛杉矶");
ticket.setGoodsName("普货");
ticket.setCargoQty("20吨");
ticket.setFclLcl("整柜");
ticket.setContainerSpec("1x40HQ");
Map<String, Object> facts = new HashMap<>();
InquiryTmsQueryService.mergeTicketFacts(facts, ticket);
assertEquals("上海", facts.get("起运港"));
assertEquals("20吨", facts.get("货量"));
assertEquals("整柜", facts.get("整柜或拼柜"));
assertEquals("1x40HQ", facts.get("箱型箱量"));
}
}
@@ -0,0 +1,58 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 海运口语 → TMS 字段。不连 TMS。
*/
public class SeaTmsFieldNormalizerTest {
@Test
public void loadTypeFclLcl() {
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("整柜"));
assertEquals("LCL", SeaTmsFieldNormalizer.loadType("拼柜"));
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("FCL"));
}
@Test
public void cargoQtyStripsUnit() {
assertEquals(new BigDecimal("15"), SeaTmsFieldNormalizer.cargoQty("15件"));
assertEquals(new BigDecimal("20"), SeaTmsFieldNormalizer.cargoQty("20吨"));
}
@Test
public void parseOneByFortyHq() {
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers("1x40HQ");
assertEquals(1, items.size());
assertEquals("40HQ", items.get(0).get("containerType"));
assertEquals(1, items.get(0).get("containerQty"));
}
@Test
public void parseChineseHighCube() {
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers("2个40尺高柜");
assertEquals(1, items.size());
assertEquals("40HQ", items.get(0).get("containerType"));
assertEquals(2, items.get(0).get("containerQty"));
}
@Test
public void parseTwoTypes() {
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers("1x20GP+1x40HQ");
assertEquals(2, items.size());
assertEquals("20GP", items.get(0).get("containerType"));
assertEquals("40HQ", items.get(1).get("containerType"));
}
@Test
public void unknownTextNotInvented() {
assertTrue(SeaTmsFieldNormalizer.parseContainers("随便写").isEmpty());
}
}
@@ -58,4 +58,51 @@ public class TmsV2RequestMapperTest {
assertEquals(new java.math.BigDecimal("2"), segs.get(0).get("volumeCbm"));
assertFalse(String.valueOf(segs.get(0).get("weightKg")).contains("KGS"));
}
@Test
public void seaFieldsPassThroughWithoutAirMeasures() {
Map<String, Object> seg = new HashMap<>();
seg.put("origin", "上海");
seg.put("destination", "洛杉矶");
seg.put("cargoName", "普货");
seg.put("cargoQty", "20吨");
seg.put("loadType", "整柜");
List<Map<String, Object>> items = Collections.singletonList(
Collections.singletonMap("type", "40HQ"));
seg.put("containerItems", items);
Map<String, Object> internal = new HashMap<>();
internal.put("transportMode", "SEA");
internal.put("quoteDate", "2026-09-15");
internal.put("segments", Collections.singletonList(seg));
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
assertEquals(new java.math.BigDecimal("20"), segs.get(0).get("cargoQty"));
assertEquals("FCL", segs.get(0).get("loadType"));
assertEquals(items, segs.get(0).get("containerItems"));
assertFalse(segs.get(0).containsKey("quantity"));
assertFalse(segs.get(0).containsKey("weightKg"));
}
@Test
public void oralSeaSpecBecomesTmsContainerFields() {
Map<String, Object> seg = new HashMap<>();
seg.put("origin", "上海港");
seg.put("destination", "巴生西港");
seg.put("cargoName", "机器设备");
seg.put("cargoQty", "15件");
seg.put("loadType", "整柜");
seg.put("containerSpec", "1x40HQ");
Map<String, Object> internal = new HashMap<>();
internal.put("transportMode", "SEA");
internal.put("quoteDate", "2026-09-15");
internal.put("segments", Collections.singletonList(seg));
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
assertEquals(new java.math.BigDecimal("15"), segs.get(0).get("cargoQty"));
assertEquals("FCL", segs.get(0).get("loadType"));
assertEquals("40HQ", segs.get(0).get("containerType"));
assertEquals(Integer.valueOf(1), segs.get(0).get("containerQty"));
}
}