落地私聊空运询价与TMS报价回填,按后台模板出单。

查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 20:25:35 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 99da1faf30
78 changed files with 8838 additions and 321 deletions
+5
View File
@@ -34,5 +34,10 @@
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,175 @@
package org.jeecg.modules.inquiry.controller;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.inquiry.dto.AgentTicketCreateRequest;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
/**
* 智能体专用主账入口(服务账号)。
* <p>
* 职责:建单、重单检索、经本主账查真 TMS、按后台报价模板出单。
* 鉴权:请求头 X-Agent-Token 或 X-Access-Token 对齐 AGENT_CALLBACK_TOKEN / BIZ_API_TOKEN。
* 禁止:在本类做自然语言抽取;禁止 Tomcat 线程等 Python Graph。
*/
@Slf4j
@Api(tags = "询价-智能体服务账号")
@RestController
@RequestMapping("/inquiry/agent")
public class InquiryAgentController {
@Autowired
private IInquiryWorkOrderService workOrderService;
@Autowired
private IInquiryQuoteTemplateService quoteTemplateService;
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
private String agentToken;
@Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}")
private String bizToken;
@ApiOperation("建单(每次新开一单,相同需求不复用)")
@PostMapping("/ticket/create")
public Result<Map<String, Object>> create(@RequestBody AgentTicketCreateRequest req,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
try {
return Result.OK(workOrderService.createFromAgent(req));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
@ApiOperation("全库检索相同询价内容(不限六态,只回最新一条)")
@PostMapping("/ticket/findSimilar")
public Result<Map<String, Object>> findSimilar(@RequestBody AgentTicketCreateRequest req,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.findSimilarOpen(req));
}
@ApiOperation("智能体六态流转(成交/未成交等)")
@PostMapping("/ticket/transition")
public Result<Map<String, Object>> transition(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
org.jeecg.modules.inquiry.dto.TicketTransitionRequest req =
new org.jeecg.modules.inquiry.dto.TicketTransitionRequest();
Object no = body == null ? null : body.get("workOrderNo");
Object to = body == null ? null : body.get("toStatus");
req.setWorkOrderNo(no == null ? "" : String.valueOf(no));
req.setToStatus(to == null ? "" : String.valueOf(to));
try {
org.jeecg.modules.inquiry.entity.InquiryTicket ticket =
workOrderService.transition(req, "agent");
java.util.Map<String, Object> out = new java.util.HashMap<>();
out.put("ok", true);
out.put("status", ticket.getStatus());
out.put("work_order_no", ticket.getWorkOrderNo());
return Result.OK(out);
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
@ApiOperation("智能体续办读单(只读,校验发起人)")
@PostMapping("/ticket/get")
public Result<Map<String, Object>> get(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
String no = body == null ? "" : String.valueOf(body.getOrDefault("work_order_no", ""));
String sales = body == null ? "" : String.valueOf(body.getOrDefault("salesWecomId", ""));
if ("null".equals(no)) {
no = "";
}
if ("null".equals(sales)) {
sales = "";
}
return Result.OK(workOrderService.getForAgent(no, sales));
}
@ApiOperation("按询价需求匹配后台报价模板并填 Excel(不转 PDF)")
@PostMapping("/quote/render")
public Result<Map<String, Object>> renderQuote(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
String workOrderNo = firstStr(body, "workOrderNo", "work_order_no");
String businessLine = firstStr(body, "businessLine", "business_line");
String sales = firstStr(body, "salesWecomId", "sales_wecom_id");
@SuppressWarnings("unchecked")
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));
}
@ApiOperation("经主账查真 TMS(有价回报价 / 无价回暂无报价 / 技术失败)")
@PostMapping("/tms/query")
public Result<Map<String, Object>> tmsQuery(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.queryTms(body));
}
private boolean authed(HttpServletRequest http) {
String got = http.getHeader("X-Agent-Token");
if (StringUtils.isBlank(got)) {
got = http.getHeader("X-Access-Token");
}
String expect = StringUtils.firstNonBlank(agentToken, bizToken);
if (StringUtils.isBlank(expect)) {
// 测服未配 token 时允许本机联调,正式必须配
log.warn("inquiry.agent 未配置 token,测服放行(勿用于生产)");
return true;
}
return expect.equals(got);
}
private static String firstStr(Map<String, Object> body, String... keys) {
if (body == null) {
return "";
}
for (String k : keys) {
Object v = body.get(k);
if (v != null && !"null".equals(String.valueOf(v))) {
String s = String.valueOf(v).trim();
if (!s.isEmpty()) {
return s;
}
}
}
return "";
}
@SuppressWarnings("unchecked")
private static Map<String, Object> asMap(Object raw) {
if (raw instanceof Map) {
return (Map<String, Object>) raw;
}
return new java.util.LinkedHashMap<>();
}
}
@@ -0,0 +1,33 @@
package org.jeecg.modules.inquiry.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Map;
/**
* 智能体按后台报价模板出单请求。
* <p>
* 匹配文本来自已冻结询价事实,主账不做自然语言抽取。
* 本接口只填 Excel;转 PDF 由智能体 Worker LibreOffice 单槽完成。
*/
@Data
@ApiModel("智能体报价出单")
public class AgentQuoteRenderRequest {
@ApiModelProperty("工单号,写入报价单编号")
private String workOrderNo;
@ApiModelProperty("SEA/AIR/LAND 或 海运/空运/陆运")
private String businessLine;
@ApiModelProperty("已确认询价字段")
private Map<String, Object> facts;
@ApiModelProperty("已冻结报价(费用行/合计/有效期)")
private Map<String, Object> quote;
@ApiModelProperty("入站 sender_id,写入业务员格(可空)")
private String salesWecomId;
}
@@ -0,0 +1,35 @@
package org.jeecg.modules.inquiry.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Map;
/**
* 智能体建单请求。
* <p>
* 字段已由智能体按合同校验;主账只落库与重单检索,不做自然语言抽取。
*/
@Data
@ApiModel("智能体建单")
public class AgentTicketCreateRequest {
@ApiModelProperty("入站 sender_id")
private String salesWecomId;
@ApiModelProperty("SEA/AIR/LAND")
private String businessLine;
@ApiModelProperty("已确认查价字段(内部键)")
private Map<String, String> facts;
@ApiModelProperty("幂等键")
private String idempotencyKey;
/**
* 检索时排除的工单号。建单后查相同需求必须排除刚开的本单,否则最新一条永远是自己。
*/
@ApiModelProperty("检索时排除的工单号")
private String excludeWorkOrderNo;
}
@@ -18,7 +18,7 @@ import java.util.Date;
/**
* 询价工单实体,对应表 inq_ticket。
* <p>
* 职责:持久化六态工单主事实;列表/详情/看板聚合均以此表为真相源之一。
* 职责:持久化六态工单主事实(含 quote_date 报价日期);列表/详情/看板聚合均以此表为真相源之一。
* 副作用:写库经 Service;禁止前端本地改 status 冒充主账成功。
*/
@Data
@@ -1,8 +1,8 @@
/**
* 友通达新系统 · 询价主账手写模块(从零实现)。
* <p>
* 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语、报价模板(逻辑头+版本+映射)。
* 排除:识别任务、存档死信、Graph/自然语言抽取。
* 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语、报价模板(逻辑头+版本+映射)、智能体建单、经本模块查真 TMS。
* 排除:识别任务、报价生成、存档死信、Graph/自然语言抽取。
* 数据只写 inquiry_robot;禁止连现网 jeecg-boot。
*/
package org.jeecg.modules.inquiry;
@@ -54,4 +54,14 @@ public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTempl
* 智能体:已启用且 mode=auto 的模板指针列表。
*/
List<Map<String, Object>> listEnabledForAgent(boolean enabledOnly);
/**
* 智能体出单:询价需求匹配模板关键词,否则同业务默认模板,再否则失败。
* <p>
* 只填 Excel 并回文件字节,不转 PDF(LibreOffice 在智能体 Worker 单槽)。
* 失败 message 固定为 QuoteTemplateMatcher.FAIL_MESSAGE。
*/
Map<String, Object> renderForAgent(String workOrderNo, String businessLine,
Map<String, Object> facts, Map<String, Object> quote,
String salesWecomId);
}
@@ -3,6 +3,7 @@ package org.jeecg.modules.inquiry.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.inquiry.dto.AgentTicketCreateRequest;
import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
@@ -43,4 +44,28 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
* 系统异常重试:累加 retry_count,记日志;真正重跑任务由 Worker 后续认领(本期只落主账状态)。
*/
void retryAbnormal(TicketAbnormalRequest req, String operator);
/**
* 智能体建单:每次出询价确认都新开一单,相同需求不复用未关闭工单。
*/
Map<String, Object> createFromAgent(AgentTicketCreateRequest req);
/**
* 全库相同询价内容检索:需求指纹一模一样才算,不限六态。
* 多条只回创建时间最新的一条;excludeWorkOrderNo 排除当前刚建的单。
* 只供卡片展示「首次询价 / 引用历史工单号」,不复用当前工单号。
*/
Map<String, Object> findSimilarOpen(AgentTicketCreateRequest req);
/**
* 经主账查真 TMS(智能体入口)。
* 分类:有价 / 暂无报价 / 技术失败。不做本地字典拦查价。不再返回 fixture 假价。
*/
Map<String, Object> queryTms(Map<String, Object> body);
/**
* 智能体续办:按工单号取字段/报价/推断停点。只读。
* 必须校验 salesWecomId 与工单发起人一致。
*/
Map<String, Object> getForAgent(String workOrderNo, String salesWecomId);
}
@@ -17,6 +17,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -458,6 +459,168 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return out;
}
/**
* 智能体出单:关键词 → 默认模板 → 失败话术。只填 xlsx。
* <p>
* 副作用:写生成文件到 upload/inquiry/quote-render;成功则模板 useCount+1。
* 线程:跟随 HTTP 请求线程,禁止在此调 LibreOffice。
*/
@Override
public Map<String, Object> renderForAgent(String workOrderNo, String businessLine,
Map<String, Object> facts, Map<String, Object> quote,
String salesWecomId) {
Map<String, Object> fail = new LinkedHashMap<>();
fail.put("ok", false);
fail.put("error", "NO_TEMPLATE");
fail.put("message", QuoteTemplateMatcher.FAIL_MESSAGE);
List<Map<String, Object>> enabled = listEnabledForAgent(true);
Map<String, Object> picked = QuoteTemplateMatcher.pick(enabled, businessLine, facts, quote);
if (picked == null) {
log.warn("报价出单无模板 wo={} biz={}", workOrderNo, businessLine);
return fail;
}
String objectKey = String.valueOf(picked.getOrDefault("objectKey", ""));
String mappingJson = String.valueOf(picked.getOrDefault("mappingJson", ""));
if (StringUtils.isBlank(objectKey) || "null".equalsIgnoreCase(objectKey)) {
log.warn("报价出单模板无文件 wo={} template={}", workOrderNo, picked.get("templateId"));
return fail;
}
Path source;
try {
source = resolveObjectPath(objectKey);
} catch (Exception e) {
log.warn("报价出单找不到模板文件 wo={} err={}", workOrderNo, e.getMessage());
return fail;
}
JSONObject mapping;
try {
mapping = StringUtils.isNotBlank(mappingJson) && !"null".equalsIgnoreCase(mappingJson)
? JSON.parseObject(mappingJson)
: new JSONObject();
} catch (Exception e) {
log.warn("报价出单 mappingJson 非法 wo={} err={}", workOrderNo, e.getMessage());
return fail;
}
JSONObject fillFacts = buildFillFacts(workOrderNo, facts, quote, salesWecomId);
String safeNo = StringUtils.defaultIfBlank(workOrderNo, "WO").replaceAll("[^A-Za-z0-9_-]", "");
Path targetDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "render");
Path target = targetDir.resolve(safeNo + "-" + System.currentTimeMillis() + ".xlsx");
try {
Files.createDirectories(targetDir);
QuoteTemplateExcelSupport.fillWorkbook(source, target, mapping, fillFacts);
byte[] bytes = Files.readAllBytes(target);
bumpUseCount(String.valueOf(picked.get("templateId")));
Map<String, Object> ok = new LinkedHashMap<>();
ok.put("ok", true);
ok.put("matchType", picked.get("matchType"));
ok.put("templateId", picked.get("templateId"));
ok.put("templateName", picked.get("name"));
ok.put("versionNo", picked.get("versionNo"));
ok.put("fileName", safeNo + "_quote.xlsx");
ok.put("fileBase64", java.util.Base64.getEncoder().encodeToString(bytes));
ok.put("fileSize", bytes.length);
return ok;
} catch (Exception e) {
log.error("报价出单填表失败 wo={} template={}", workOrderNo, picked.get("templateId"), e);
fail.put("error", "FILL_FAILED");
return fail;
}
}
/**
* 把询价中文键与报价费用投影成合同 systemFields,映射点两边都能取到。
*/
public static JSONObject buildFillFacts(String workOrderNo, Map<String, Object> facts,
Map<String, Object> quote, String salesWecomId) {
JSONObject out = new JSONObject(true);
Map<String, Object> src = facts == null ? new LinkedHashMap<>() : facts;
Map<String, Object> q = quote == null ? new LinkedHashMap<>() : quote;
for (Map.Entry<String, Object> e : src.entrySet()) {
if (e.getKey() != null && e.getValue() != null) {
out.put(e.getKey(), String.valueOf(e.getValue()));
}
}
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("commodity", first(src, "品名", "commodity"));
out.put("pieces", first(src, "件数", "pieces"));
out.put("weight_kg", first(src, "毛重", "weight_kg"));
out.put("volume_cbm", first(src, "体积", "volume_cbm"));
out.put("quote_date", first(src, "报价日期", "quote_date"));
if (StringUtils.isBlank(out.getString("quote_date"))) {
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
}
out.put("valid_until", first(q, "valid_until"));
out.put("total_amount", first(q, "total", "total_amount"));
String currency = first(q, "currency");
if (StringUtils.isNotBlank(currency)) {
out.put("currency", currency);
}
out.put("sales_name", StringUtils.defaultString(salesWecomId));
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_lines"));
if (rows.isEmpty()) {
rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows"));
}
if (rows.isEmpty()) {
String freight = first(q, "freight");
if (StringUtils.isNotBlank(freight) && !"-".equals(freight)) {
JSONObject one = new JSONObject(true);
one.put("name", "空运费");
one.put("amount", freight);
if (StringUtils.isNotBlank(currency)) {
one.put("currency", currency);
}
rows.add(one);
}
}
if (!rows.isEmpty() && StringUtils.isNotBlank(currency)) {
for (int i = 0; i < rows.size(); i++) {
JSONObject one = rows.getJSONObject(i);
if (one != null && StringUtils.isBlank(one.getString("currency"))) {
one.put("currency", currency);
}
}
}
if (!rows.isEmpty()) {
out.put("fee_rows", rows);
}
return out;
}
private static String first(Map<?, ?> src, String... keys) {
if (src == null) {
return "";
}
for (String k : keys) {
Object v = src.get(k);
if (v != null && StringUtils.isNotBlank(String.valueOf(v))) {
return String.valueOf(v).trim();
}
}
return "";
}
private void bumpUseCount(String templateId) {
if (StringUtils.isBlank(templateId) || "null".equalsIgnoreCase(templateId)) {
return;
}
try {
InquiryQuoteTemplate t = getById(templateId);
if (t == null) {
return;
}
t.setUseCount((t.getUseCount() == null ? 0 : t.getUseCount()) + 1);
t.setUpdateTime(new Date());
updateById(t);
} catch (Exception e) {
log.warn("报价模板 useCount 未写入 templateId={} err={}", templateId, e.getMessage());
}
}
private InquiryQuoteTemplateVersion findEnabledOrLatest(String templateId) {
InquiryQuoteTemplateVersion enabled = versionMapper.selectOne(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
@@ -6,6 +6,7 @@ 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.TicketStatuses;
import org.jeecg.modules.inquiry.dto.AgentTicketCreateRequest;
import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
import org.jeecg.modules.inquiry.entity.InquiryAttachment;
@@ -19,12 +20,18 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -47,6 +54,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
private InquiryTicketStatusLogMapper statusLogMapper;
@Autowired
private InquiryExceptionCaseMapper exceptionCaseMapper;
@Autowired
private InquiryTmsQueryService tmsQueryService;
@Override
public IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters) {
@@ -208,6 +217,207 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "retry", operator, req.getRemark());
}
/**
* 智能体建单。
* <p>
* 每次出询价确认都分配新工单号并落「询价中」,相同起运/目的/品名不复用未关闭单。
* 副作用:插入 inq_ticket 与时间线;不在本方法查出站 TMS。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> createFromAgent(AgentTicketCreateRequest req) {
if (req == null || StringUtils.isBlank(req.getSalesWecomId()) || StringUtils.isBlank(req.getBusinessLine())) {
throw new IllegalArgumentException("salesWecomId 与 businessLine 不能为空");
}
// 每次出询价确认都新开一单,相同需求不复用未关闭工单。
Map<String, String> facts = req.getFacts() == null ? new HashMap<>() : req.getFacts();
InquiryTicket ticket = new InquiryTicket();
ticket.setWorkOrderNo(nextWorkOrderNo());
ticket.setSalesWecomId(req.getSalesWecomId());
ticket.setBusinessLine(req.getBusinessLine().trim().toUpperCase());
ticket.setBizTypeLabel(bizLabel(ticket.getBusinessLine()));
ticket.setOrigin(facts.get("起运港"));
ticket.setDestination(facts.get("目的港"));
ticket.setGoodsName(facts.get("品名"));
ticket.setPieces(parseInt(facts.get("件数")));
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
ticket.setVolumeCbm(parseDecimal(facts.get("体积")));
ticket.setQuoteDate(parseDate(facts.get("报价日期")));
ticket.setDemandTitle(ticket.getOrigin() + " → " + ticket.getDestination());
ticket.setDemandDetail("FP:" + fingerprint(req));
ticket.setStatus(TicketStatuses.INQUIRING);
ticket.setVersion(1);
ticket.setCreateBy("agent");
ticket.setCreateTime(new Date());
save(ticket);
appendLog(ticket, null, TicketStatuses.INQUIRING, "create", "agent", "智能体建单");
// 本单已落库;检索须排除自己,否则「最新相同需求」永远是本单。
AgentTicketCreateRequest look = new AgentTicketCreateRequest();
look.setBusinessLine(ticket.getBusinessLine());
look.setFacts(facts);
look.setExcludeWorkOrderNo(ticket.getWorkOrderNo());
Map<String, Object> similar = findSimilarOpen(look);
Map<String, Object> out = new HashMap<>();
out.put("ok", true);
out.put("created", true);
out.put("work_order_no", ticket.getWorkOrderNo());
out.put("status", ticket.getStatus());
out.put("found", similar.get("found"));
out.put("first_or_same", similar.get("first_or_same"));
out.put("similar_work_order_no", similar.get("work_order_no"));
return out;
}
/**
* 全库按需求指纹检索:一模一样才引用;多条只取创建时间最新的一条。
* 不限六态(成交/关闭也算历史);excludeWorkOrderNo 排除当前刚建的单。
* 副作用:只读库。禁止在此复用或改写当前工单号。
*/
@Override
public Map<String, Object> findSimilarOpen(AgentTicketCreateRequest req) {
Map<String, Object> out = new HashMap<>();
out.put("found", false);
out.put("first_or_same", "first");
out.put("work_order_no", "");
out.put("status", "");
if (req == null || StringUtils.isBlank(req.getBusinessLine())) {
return out;
}
String fp = "FP:" + fingerprint(req);
String exclude = StringUtils.trimToEmpty(req.getExcludeWorkOrderNo());
List<InquiryTicket> hits = list(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getDemandDetail, fp)
.ne(StringUtils.isNotBlank(exclude), InquiryTicket::getWorkOrderNo, exclude)
.orderByDesc(InquiryTicket::getCreateTime)
.last("LIMIT 1"));
if (hits == null || hits.isEmpty()) {
hits = listByExactFacts(req, exclude);
}
if (hits == null || hits.isEmpty()) {
return out;
}
InquiryTicket hit = hits.get(0);
out.put("found", true);
out.put("first_or_same", "same");
out.put("work_order_no", hit.getWorkOrderNo());
out.put("status", hit.getStatus());
return out;
}
/**
* 指纹列对不上时的兜底:按已落库结构化字段逐项相等(件数/重量/体积/报价日期都要比)。
* 仍不限六态,只要最新一条。
*/
private List<InquiryTicket> listByExactFacts(AgentTicketCreateRequest req, String exclude) {
Map<String, String> facts = req.getFacts() == null ? new HashMap<>() : req.getFacts();
Integer pieces = parseInt(facts.get("件数"));
BigDecimal weight = parseDecimal(firstFact(facts, "重量", "毛重"));
BigDecimal volume = parseDecimal(facts.get("体积"));
Date quoteDate = parseDate(facts.get("报价日期"));
return list(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getBusinessLine, req.getBusinessLine().trim().toUpperCase())
.eq(InquiryTicket::getOrigin, StringUtils.trimToEmpty(facts.get("起运港")))
.eq(InquiryTicket::getDestination, StringUtils.trimToEmpty(facts.get("目的港")))
.eq(InquiryTicket::getGoodsName, StringUtils.trimToEmpty(facts.get("品名")))
.eq(pieces != null, InquiryTicket::getPieces, pieces)
.isNull(pieces == null, InquiryTicket::getPieces)
.eq(weight != null, InquiryTicket::getWeightKg, weight)
.isNull(weight == null, InquiryTicket::getWeightKg)
.eq(volume != null, InquiryTicket::getVolumeCbm, volume)
.isNull(volume == null, InquiryTicket::getVolumeCbm)
.eq(quoteDate != null, InquiryTicket::getQuoteDate, quoteDate)
.isNull(quoteDate == null, InquiryTicket::getQuoteDate)
.ne(StringUtils.isNotBlank(exclude), InquiryTicket::getWorkOrderNo, exclude)
.orderByDesc(InquiryTicket::getCreateTime)
.last("LIMIT 1"));
}
@Override
public Map<String, Object> queryTms(Map<String, Object> body) {
return tmsQueryService.queryFromAgent(body);
}
private String nextWorkOrderNo() {
String day = new SimpleDateFormat("yyyyMMdd").format(new Date());
long n = count(new LambdaQueryWrapper<InquiryTicket>().likeRight(InquiryTicket::getWorkOrderNo, "WO" + day));
return String.format("WO%s%04d", day, n + 1);
}
private static String bizLabel(String line) {
if ("AIR".equals(line)) {
return "空运";
}
if ("SEA".equals(line)) {
return "海运";
}
if ("LAND".equals(line)) {
return "陆运";
}
return line;
}
private static String fingerprint(AgentTicketCreateRequest req) {
Map<String, String> facts = req.getFacts() == null ? new HashMap<>() : req.getFacts();
return String.join("|",
StringUtils.trimToEmpty(req.getBusinessLine()).toUpperCase(),
StringUtils.trimToEmpty(facts.get("起运港")),
StringUtils.trimToEmpty(facts.get("目的港")),
StringUtils.trimToEmpty(facts.get("品名")),
StringUtils.trimToEmpty(facts.get("件数")),
StringUtils.trimToEmpty(firstFact(facts, "重量", "毛重")),
StringUtils.trimToEmpty(facts.get("体积")),
StringUtils.trimToEmpty(facts.get("报价日期")));
}
/**
* 取销售侧字段:先新口径(重量),再兼容旧口径(毛重)。
*/
private static String firstFact(Map<String, String> facts, String... keys) {
if (facts == null || keys == null) {
return "";
}
for (String key : keys) {
String val = facts.get(key);
if (StringUtils.isNotBlank(val)) {
return val;
}
}
return "";
}
private static Integer parseInt(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
String digits = raw.replaceAll("[^0-9]", "");
if (digits.isEmpty()) {
return null;
}
return Integer.valueOf(digits);
}
private static BigDecimal parseDecimal(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
String cleaned = raw.replaceAll("[^0-9.]", "");
if (cleaned.isEmpty()) {
return null;
}
return new BigDecimal(cleaned);
}
private static Date parseDate(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
try {
return new SimpleDateFormat("yyyy-MM-dd").parse(raw.trim());
} catch (Exception e) {
return null;
}
}
private InquiryTicket requireTicket(TicketAbnormalRequest req) {
if (req == null || StringUtils.isBlank(req.getWorkOrderNo())) {
throw new IllegalArgumentException("工单号不能为空");
@@ -219,6 +429,77 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return ticket;
}
/**
* 智能体续办读单:只读。不属于该销售则 owned=false,不回字段。
*/
@Override
public Map<String, Object> getForAgent(String workOrderNo, String salesWecomId) {
Map<String, Object> out = new LinkedHashMap<>();
String no = StringUtils.trimToEmpty(workOrderNo);
out.put("ok", false);
out.put("found", false);
out.put("owned", false);
out.put("work_order_no", no);
if (StringUtils.isBlank(no)) {
return out;
}
InquiryTicket ticket = findByWorkOrderNo(no);
if (ticket == null) {
return out;
}
out.put("found", true);
boolean owned = StringUtils.isNotBlank(salesWecomId)
&& salesWecomId.equals(ticket.getSalesWecomId());
out.put("owned", owned);
if (!owned) {
return out;
}
Map<String, String> facts = new LinkedHashMap<>();
facts.put("起运港", StringUtils.defaultString(ticket.getOrigin()));
facts.put("目的港", StringUtils.defaultString(ticket.getDestination()));
facts.put("品名", StringUtils.defaultString(ticket.getGoodsName()));
if (ticket.getPieces() != null) {
facts.put("件数", String.valueOf(ticket.getPieces()));
}
if (ticket.getWeightKg() != null) {
facts.put("毛重", ticket.getWeightKg().stripTrailingZeros().toPlainString());
}
if (ticket.getVolumeCbm() != null) {
facts.put("体积", ticket.getVolumeCbm().stripTrailingZeros().toPlainString());
}
if (ticket.getQuoteDate() != null) {
facts.put("报价日期", new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate()));
}
Map<String, Object> quote = new LinkedHashMap<>();
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, no)
.orderByDesc(InquiryQuote::getCreateTime)
.last("LIMIT 1"));
if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) {
JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson());
if (parsed != null) {
quote.putAll(parsed);
}
}
String status = StringUtils.defaultString(ticket.getStatus());
String waitPhase;
if (TicketStatuses.DEAL.equals(status) || TicketStatuses.LOST.equals(status)
|| TicketStatuses.NEGOTIATING.equals(status) || TicketStatuses.CLOSED.equals(status)) {
waitPhase = "done";
} else if (TicketStatuses.QUOTED.equals(status) && !quote.isEmpty()) {
waitPhase = "wait_adopt";
} else {
waitPhase = "tms_miss";
}
out.put("ok", true);
out.put("status", status);
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
out.put("facts", facts);
out.put("quote", quote);
out.put("wait_phase", waitPhase);
return out;
}
private InquiryTicket findByWorkOrderNo(String workOrderNo) {
return getOne(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
@@ -178,17 +178,19 @@ public final class QuoteTemplateExcelSupport {
continue;
}
Object val = facts.get(code);
if (val == null) {
clearCell(wb, cellRef);
if ("fee_rows".equals(code)) {
continue;
}
if ("fee_rows".equals(code)) {
// 空值不覆盖模板原字,避免把单位格/币别格擦掉
if (val == null || StringUtils.isBlank(String.valueOf(val))) {
continue;
}
writeCell(wb, cellRef, String.valueOf(val));
}
}
writeFeeRows(wb, mapping, facts, yellow);
// 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算
writeFeeAmountsByName(wb, facts, yellow);
Files.createDirectories(targetXlsx.getParent());
try (OutputStream out = Files.newOutputStream(targetXlsx)) {
wb.write(out);
@@ -201,11 +203,10 @@ public final class QuoteTemplateExcelSupport {
if (feeRows == null) {
return;
}
Object raw = facts.get("fee_rows");
if (!(raw instanceof JSONArray) && !(raw instanceof List)) {
JSONArray rows = parseFeeRows(facts.get("fee_rows"));
if (rows.isEmpty()) {
return;
}
JSONArray rows = raw instanceof JSONArray ? (JSONArray) raw : JSON.parseArray(JSON.toJSONString(raw));
String anchor = feeRows.getString("anchor");
JSONObject columns = feeRows.getJSONObject("columns");
if (StringUtils.isBlank(anchor) || columns == null || rows == null || rows.isEmpty()) {
@@ -221,7 +222,13 @@ public final class QuoteTemplateExcelSupport {
JSONObject rowObj = rows.getJSONObject(i);
int r = startRow + i;
writeFeeCol(sheet, r, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName());
writeFeeCol(sheet, r, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName());
writeFeeCol(sheet, r, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName());
writeFeeCol(sheet, r, columns.getString("unit_price"), rowObj.getString("unit_price"), yellow, sheet.getSheetName());
writeFeeCol(sheet, r, columns.getString("amount"), rowObj.getString("amount"), yellow, sheet.getSheetName());
if (columns.containsKey("currency")) {
writeFeeCol(sheet, r, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName());
}
if (columns.containsKey("remark")) {
writeFeeCol(sheet, r, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName());
}
@@ -252,6 +259,311 @@ public final class QuoteTemplateExcelSupport {
writeCell(wb, cellRef, "");
}
/**
* 按费用名称把 TMS 费用行写进模板:单位 / 币别 / 单价 / 数量 / 金额 / 备注。
* 映射没有 fee_rows 时也能对上「空运费」等已有行。没有的字段不编、不覆盖模板原字。
*/
public static void writeFeeAmountsByName(Workbook wb, JSONObject facts, Set<String> yellow) {
JSONArray rows = parseFeeRows(facts == null ? null : facts.get("fee_rows"));
if (rows == null || rows.isEmpty()) {
return;
}
String headerCurrency = facts == null ? "" : StringUtils.trimToEmpty(facts.getString("currency"));
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
Map<String, Integer> cols = findFeeTableColumns(sheet);
if (cols.isEmpty()) {
cols = fallbackFeeColumns(sheet);
}
Integer amountCol = cols.get("amount");
if (amountCol == null) {
amountCol = 6;
}
for (int i = 0; i < rows.size(); i++) {
JSONObject rowObj = rows.getJSONObject(i);
if (rowObj == null) {
continue;
}
String name = StringUtils.trimToEmpty(rowObj.getString("name"));
String amount = StringUtils.trimToEmpty(rowObj.getString("amount"));
if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) {
continue;
}
Cell nameCell = findCellByFeeName(sheet, name);
if (nameCell == null) {
continue;
}
int r = nameCell.getRowIndex();
writeAmountCell(sheet, r, amountCol, amount, yellow);
String lineCurrency = StringUtils.trimToEmpty(rowObj.getString("currency"));
if (StringUtils.isBlank(lineCurrency)) {
lineCurrency = headerCurrency;
}
writeMappedText(sheet, r, cols.get("currency"), lineCurrency, yellow);
writeMappedText(sheet, r, cols.get("unit"), rowObj.getString("unit"), yellow);
writeMappedNumber(sheet, r, cols.get("quantity"), rowObj.getString("quantity"), yellow);
writeMappedNumber(sheet, r, cols.get("unit_price"), rowObj.getString("unit_price"), yellow);
writeMappedText(sheet, r, cols.get("remark"), rowObj.getString("remark"), yellow);
}
}
}
/**
* 只认「费用名称 + 金额」那一行表头,避免把货物信息里的件/KG 当成费用列。
* 币别和数量中间的空列或 CNY 列,按现网空运模板当成单价。
*/
static Map<String, Integer> findFeeTableColumns(Sheet sheet) {
Map<String, Integer> cols = new LinkedHashMap<>();
if (sheet == null) {
return cols;
}
int headerRow = -1;
int lastCol = 0;
int maxRow = Math.min(sheet.getLastRowNum(), 40);
for (int r = 0; r <= maxRow; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
boolean hasAmount = false;
boolean hasFeeName = false;
short last = row.getLastCellNum();
for (int c = 0; c < last; c++) {
String text = readCellText(row.getCell(c));
if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) {
hasAmount = true;
}
if (text.contains("费用名称") || text.contains("费用名")) {
hasFeeName = true;
}
}
if (hasAmount && hasFeeName) {
headerRow = r;
lastCol = last;
break;
}
}
if (headerRow < 0) {
return cols;
}
Row row = sheet.getRow(headerRow);
for (int c = 0; c < lastCol; c++) {
String text = StringUtils.trimToEmpty(readCellText(row.getCell(c)));
if (text.contains("费用名称") || text.contains("费用名")) {
cols.put("name", c);
} else if (text.contains("单位") || text.contains("單位")) {
cols.put("unit", c);
} else if (text.contains("币别") || text.contains("币種") || text.contains("币种")) {
cols.put("currency", c);
} else if (text.contains("单价") || text.contains("單價")) {
cols.put("unit_price", c);
} else if (text.contains("数量") || text.contains("數量")) {
cols.put("quantity", c);
} else if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) {
cols.put("amount", c);
} else if (text.contains("备注") || text.contains("備註")) {
cols.put("remark", c);
} else if (StringUtils.isBlank(text) && cols.containsKey("currency") && !cols.containsKey("quantity")
&& !cols.containsKey("unit_price")) {
cols.put("unit_price", c);
} else if (("CNY".equalsIgnoreCase(text) || "USD".equalsIgnoreCase(text) || "RMB".equalsIgnoreCase(text))
&& cols.containsKey("currency") && !cols.containsKey("unit_price")) {
cols.put("unit_price", c);
}
}
return cols;
}
private static Map<String, Integer> fallbackFeeColumns(Sheet sheet) {
Map<String, Integer> cols = new LinkedHashMap<>();
putCol(cols, "amount", findHeaderColumn(sheet, Arrays.asList("金额", "金額", "amount")));
putCol(cols, "currency", findHeaderColumn(sheet, Arrays.asList("币别", "币種", "币种")));
putCol(cols, "unit", findHeaderColumn(sheet, Arrays.asList("单位", "單位")));
putCol(cols, "quantity", findHeaderColumn(sheet, Arrays.asList("数量", "數量")));
putCol(cols, "unit_price", findHeaderColumn(sheet, Arrays.asList("单价", "單價")));
putCol(cols, "remark", findHeaderColumn(sheet, Arrays.asList("备注", "備註")));
if (!cols.containsKey("amount")) {
cols.put("amount", 6);
}
return cols;
}
private static void putCol(Map<String, Integer> cols, String key, int col) {
if (col >= 0) {
cols.put(key, col);
}
}
private static void writeMappedText(Sheet sheet, int rowIdx, Integer col, String value, Set<String> yellow) {
if (col == null || StringUtils.isBlank(value) || "-".equals(value.trim())) {
return;
}
writeTextCell(sheet, rowIdx, col, value.trim(), yellow);
}
private static void writeMappedNumber(Sheet sheet, int rowIdx, Integer col, String value, Set<String> yellow) {
if (col == null || StringUtils.isBlank(value) || "-".equals(value.trim())) {
return;
}
writeAmountCell(sheet, rowIdx, col, value, yellow);
}
/**
* fee_lines / fee_rows:List、JSONArray、JSON 字符串、二次编码字符串都认。
*/
public static JSONArray parseFeeRows(Object raw) {
if (raw == null) {
return new JSONArray();
}
if (raw instanceof JSONArray) {
return (JSONArray) raw;
}
if (raw instanceof List) {
return JSON.parseArray(JSON.toJSONString(raw));
}
String text = String.valueOf(raw).trim();
if (text.isEmpty() || "null".equalsIgnoreCase(text) || "-".equals(text)) {
return new JSONArray();
}
try {
Object parsed = JSON.parse(text);
if (parsed instanceof JSONArray) {
return (JSONArray) parsed;
}
if (parsed instanceof String) {
Object inner = JSON.parse((String) parsed);
if (inner instanceof JSONArray) {
return (JSONArray) inner;
}
}
} catch (Exception e) {
log.warn("费用行 JSON 无法解析: {}", e.getMessage());
}
return new JSONArray();
}
private static int findHeaderColumn(Sheet sheet, List<String> titles) {
int maxRow = Math.min(sheet.getLastRowNum(), 40);
for (int r = 0; r <= maxRow; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
short last = row.getLastCellNum();
for (int c = 0; c < last; c++) {
Cell cell = row.getCell(c);
String text = readCellText(cell);
if (StringUtils.isBlank(text)) {
continue;
}
for (String t : titles) {
if (text.contains(t)) {
return c;
}
}
}
}
return -1;
}
private static Cell findCellByFeeName(Sheet sheet, String name) {
String want = name.trim();
Cell fuzzy = null;
int maxRow = Math.min(sheet.getLastRowNum(), 80);
for (int r = 0; r <= maxRow; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
short last = row.getLastCellNum();
for (int c = 0; c < last; c++) {
Cell cell = row.getCell(c);
String text = readCellText(cell);
if (StringUtils.isBlank(text)) {
continue;
}
if (want.equals(text.trim())) {
return cell;
}
if (fuzzy == null && text.contains(want) && want.length() >= 2) {
fuzzy = cell;
}
}
}
return fuzzy;
}
private static String readCellText(Cell cell) {
if (cell == null) {
return "";
}
DataFormatter fmt = new DataFormatter();
return StringUtils.trimToEmpty(fmt.formatCellValue(cell));
}
private static void writeAmountCell(Sheet sheet, int rowIdx, int col, String amount, Set<String> yellow) {
int[] dest = resolveMergeOrigin(sheet, rowIdx, col);
String ref = sheet.getSheetName() + "!" + new CellReference(dest[0], dest[1]).formatAsString();
if (yellow != null && yellow.contains(ref)) {
return;
}
Row row = sheet.getRow(dest[0]);
if (row == null) {
row = sheet.createRow(dest[0]);
}
Cell cell = row.getCell(dest[1]);
if (cell == null) {
cell = row.createCell(dest[1]);
}
String norm = stripMoneyPrefix(amount);
try {
cell.setCellValue(Double.parseDouble(norm));
} catch (NumberFormatException e) {
cell.setCellValue(amount);
}
}
/**
* 去掉「CNY 14669.60」前缀,让金额列仍能写成数字给 SUM。
*/
static String stripMoneyPrefix(String amount) {
if (amount == null) {
return "";
}
String norm = amount.replace(",", "").replace(",", "").trim();
if (norm.matches("^[A-Za-z]{3}\\s+.+")) {
return norm.substring(4).trim();
}
return norm;
}
private static void writeTextCell(Sheet sheet, int rowIdx, int col, String value, Set<String> yellow) {
int[] dest = resolveMergeOrigin(sheet, rowIdx, col);
String ref = sheet.getSheetName() + "!" + new CellReference(dest[0], dest[1]).formatAsString();
if (yellow != null && yellow.contains(ref)) {
return;
}
Row row = sheet.getRow(dest[0]);
if (row == null) {
row = sheet.createRow(dest[0]);
}
Cell cell = row.getCell(dest[1]);
if (cell == null) {
cell = row.createCell(dest[1]);
}
cell.setCellValue(value == null ? "" : value);
}
private static int[] resolveMergeOrigin(Sheet sheet, int row, int col) {
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
if (range.isInRange(row, col)) {
return new int[]{range.getFirstRow(), range.getFirstColumn()};
}
}
return new int[]{row, col};
}
private static void writeCell(Workbook wb, String cellRef, String value) {
ParsedRef ref = ParsedRef.parse(cellRef);
Sheet sheet = wb.getSheet(ref.sheet);
@@ -261,15 +573,41 @@ public final class QuoteTemplateExcelSupport {
if (sheet == null) {
return;
}
Row row = sheet.getRow(ref.row);
int[] dest = resolveMergeOrigin(sheet, ref.row, ref.col);
Row row = sheet.getRow(dest[0]);
if (row == null) {
row = sheet.createRow(ref.row);
row = sheet.createRow(dest[0]);
}
Cell cell = row.getCell(ref.col);
Cell cell = row.getCell(dest[1]);
if (cell == null) {
cell = row.createCell(ref.col);
cell = row.createCell(dest[1]);
}
String text = value == null ? "" : value;
// 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺
String existing = readCellText(cell);
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
if (colon >= 0 && colon == existing.length() - 1) {
text = existing + text;
}
cell.setCellValue(text);
CellStyle style = cell.getCellStyle();
if (style != null && text.length() >= 10) {
CellStyle copy = wb.createCellStyle();
copy.cloneStyleFrom(style);
copy.setShrinkToFit(true);
copy.setWrapText(false);
copy.setDataFormat(wb.createDataFormat().getFormat("@"));
cell.setCellStyle(copy);
}
// 工单号、日期略加宽,避免列太窄只露出半截
if (dest[1] >= 0 && dest[1] < 20 && text.length() >= 8) {
org.apache.poi.ss.usermodel.Sheet sh = sheet;
int col = dest[1];
double width = sh.getColumnWidth(col) / 256.0;
if (width > 0 && width < text.length() + 2) {
sh.setColumnWidth(col, (int) Math.min(40, text.length() + 4) * 256);
}
}
cell.setCellValue(value == null ? "" : value);
}
private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) {
@@ -0,0 +1,196 @@
package org.jeecg.modules.inquiry.template;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* 报价模板匹配:询价需求文本对模板关键词,都未命中则同业务默认模板。
* <p>
* 职责:纯函数,不读库、不填表、不转 PDF。智能体出单与单测共用同一套规则。
* 禁止:在此发明价格;禁止按 userid 分叉。
*/
public final class QuoteTemplateMatcher {
/** 关键词与默认都未维护时,销售可见固定话术(产品口径,勿改字)。 */
public static final String FAIL_MESSAGE = "生成报价单失败,请联系IT运维维护后台报价模板。";
private QuoteTemplateMatcher() {
}
/**
* 业务线归一:AIR/空 → 空运,SEA/海 → 海运,LAND/陆 → 陆运。
*/
public static String normalizeBiz(String raw) {
if (StringUtils.isBlank(raw)) {
return "";
}
String t = raw.trim();
String lower = t.toLowerCase(Locale.ROOT);
if ("air".equals(lower) || "空".equals(t) || t.contains("空运") || "AIR".equals(t)) {
return "空运";
}
if ("sea".equals(lower) || "海".equals(t) || t.contains("海运") || "SEA".equals(t)) {
return "海运";
}
if ("land".equals(lower) || "陆".equals(t) || t.contains("陆运") || "LAND".equals(t)) {
return "陆运";
}
return t;
}
/**
* 拼匹配文本:已冻结询价字段 + 业务线 + 报价费用名。
*/
public static String buildHaystack(String businessLine, Map<?, ?> facts, Map<?, ?> quote) {
StringBuilder sb = new StringBuilder();
append(sb, businessLine);
append(sb, normalizeBiz(businessLine));
if (facts != null) {
for (Map.Entry<?, ?> e : facts.entrySet()) {
append(sb, e.getKey());
append(sb, e.getValue());
}
}
if (quote != null) {
append(sb, quote.get("source"));
append(sb, quote.get("source_label"));
append(sb, quote.get("freight"));
append(sb, quote.get("total"));
Object lines = quote.get("fee_lines");
if (lines instanceof Collection) {
for (Object row : (Collection<?>) lines) {
if (row instanceof Map) {
append(sb, ((Map<?, ?>) row).get("name"));
append(sb, ((Map<?, ?>) row).get("amount"));
} else {
append(sb, row);
}
}
}
}
return sb.toString();
}
/**
* 先扫同业务线关键词(列表已按默认优先排序),再退回该业务默认模板。
*
* @return 命中的模板 Map,并写入 matchType=keyword|default;都没有则 null
*/
public static Map<String, Object> pick(
List<Map<String, Object>> templates,
String businessLine,
Map<?, ?> facts,
Map<?, ?> quote
) {
String want = normalizeBiz(businessLine);
String haystack = buildHaystack(businessLine, facts, quote).toLowerCase(Locale.ROOT);
Map<String, Object> keywordHit = null;
Map<String, Object> defaultHit = null;
if (templates == null) {
return null;
}
for (Map<String, Object> t : templates) {
if (t == null) {
continue;
}
String biz = normalizeBiz(String.valueOf(t.getOrDefault("bizType", "")));
if (StringUtils.isNotBlank(want) && StringUtils.isNotBlank(biz) && !want.equals(biz)) {
continue;
}
if (defaultHit == null && isDefault(t.get("isDefault"))) {
defaultHit = t;
}
if (keywordHit == null && keywordsHit(t.get("keywords"), haystack)) {
keywordHit = t;
}
}
if (keywordHit != null) {
return withMatchType(keywordHit, "keyword");
}
if (defaultHit != null) {
return withMatchType(defaultHit, "default");
}
return null;
}
private static boolean keywordsHit(Object raw, String haystack) {
for (String kw : asKeywords(raw)) {
if (haystack.contains(kw.toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
/**
* 模板 keywordsJson / 列表字段转关键词;空串不参与匹配。
*/
public static List<String> asKeywords(Object raw) {
List<String> out = new ArrayList<>();
if (raw == null) {
return out;
}
if (raw instanceof Collection) {
for (Object it : (Collection<?>) raw) {
String s = it == null ? "" : String.valueOf(it).trim();
if (StringUtils.isNotBlank(s)) {
out.add(s);
}
}
return out;
}
String text = String.valueOf(raw).trim();
if (text.isEmpty() || "null".equalsIgnoreCase(text)) {
return out;
}
if (text.startsWith("[")) {
String inner = text.substring(1, text.endsWith("]") ? text.length() - 1 : text.length());
for (String part : inner.split(",")) {
String s = part.trim().replace("\"", "").replace("'", "");
if (StringUtils.isNotBlank(s)) {
out.add(s);
}
}
return out;
}
out.add(text);
return out;
}
private static boolean isDefault(Object raw) {
if (raw instanceof Boolean) {
return Boolean.TRUE.equals(raw);
}
if (raw instanceof Number) {
return ((Number) raw).intValue() == 1;
}
String s = raw == null ? "" : String.valueOf(raw).trim();
return "1".equals(s) || "true".equalsIgnoreCase(s);
}
private static Map<String, Object> withMatchType(Map<String, Object> src, String matchType) {
Map<String, Object> copy = new LinkedHashMap<>(src);
copy.put("matchType", matchType);
return copy;
}
private static void append(StringBuilder sb, Object val) {
if (val == null) {
return;
}
String s = String.valueOf(val).trim();
if (s.isEmpty() || "null".equalsIgnoreCase(s)) {
return;
}
if (sb.length() > 0) {
sb.append(' ');
}
sb.append(s);
}
}
@@ -0,0 +1,480 @@
package org.jeecg.modules.inquiry.tms;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
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.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
/**
* 智能体查价门面:询价必填齐了就必须打真 TMS,再按 TMS 回包分类。
* <p>
* 职责:销售侧起运/目的原文、品名、件数、重量、体积、包装、报价日期、工单号齐了就出站。
* 机场三字码能投影就带上;对不上也必须出站,禁止本地判「暂无报价」。
* 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价;
* HTTP 超时/熔断/5xx → 技术失败。
* 禁止:因 originCode 对不上就 skip;fixture 假价;LOCK/RELEASE;Python 直连 TMS。
*/
@Slf4j
@Service
public class InquiryTmsQueryService {
@Autowired
private TmsProperties properties;
@Autowired
private TmsGatewayClient gateway;
@Autowired
private TmsExactProjector projector;
@Autowired
private InquiryQuoteMapper quoteMapper;
@Autowired
@Lazy
private IInquiryWorkOrderService workOrderService;
/**
* @param body 智能体请求:work_order_no + facts(内部中文键)
* @return 给智能体的分类 envelope
*/
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();
if (!properties.queryConfigured() || !gateway.isConfigured()) {
return tech("TMS 未配置,拒绝假价");
}
Map<String, Object> segment = new LinkedHashMap<>();
segment.put("sequence", 1);
segment.put("transportMode", line);
String origin = text(facts.get("起运港"));
String destination = text(facts.get("目的港"));
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("包装类型"))));
String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期"));
if (quoteDate == null) {
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
quoteDate = fmt.format(new Date());
}
List<String> missing = missingAirQuery(workOrderNo, quoteDate, segment);
if (!missing.isEmpty()) {
log.warn("tms.query skip incomplete wo={} missing={}", workOrderNo, missing);
return incomplete(missing);
}
log.info(
"tms.query outbound wo={} origin={} originCode={} dest={} destCode={} cargo={}",
workOrderNo,
origin,
text(segment.get("originCode")),
destination,
text(segment.get("destinationCode")),
text(segment.get("cargoName")));
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("workOrderNo", workOrderNo);
internal.put("transportMode", line);
internal.put("quoteDate", quoteDate);
List<Map<String, Object>> segments = new ArrayList<>();
segments.add(segment);
internal.put("segments", segments);
String cacheKey = line + ":" + quoteDate + ":" + origin + ":" + destination
+ ":" + segment.get("weightKg") + ":" + segment.get("volumeCbm");
try {
Map<String, Object> tms = gateway.query(internal, cacheKey);
return classifyAndPersist(workOrderNo, tms);
} catch (RuntimeException ex) {
log.warn("tms.query tech wo={} err={}", workOrderNo, ex.getMessage());
return tech(ex.getMessage());
}
}
/**
* 只处理「已经打过 TMS」的回包。
* 1002 / 1003 / 无 quoteFound → 暂无报价;未出站的 incomplete 不得走进这里。
*/
private Map<String, Object> classifyAndPersist(String workOrderNo, Map<String, Object> tms) {
int code = TmsV2ResponseNormalizer.number(tms.get("code"));
Map<String, Object> out = new LinkedHashMap<>();
out.put("tmsRequestId", text(first(tms.get("traceId"), tms.get("requestId"))));
out.put("code", code);
out.put("source", "TMS");
out.put("message", text(tms.get("message")));
if (code == 1002 || code == 1003) {
// 已经打过 TMS:1002=无匹配,1003=TMS 说缺项。都按合同收成暂无报价。
// 禁止把 1003 收成「未出站 / 参数不齐」,销售会误以为没查。
out.put("ok", true);
out.put("has_price", false);
out.put("classification", "TMS_NO_QUOTE_1002");
out.put("quote", new HashMap<String, String>());
return out;
}
if (code != 1) {
return tech("TMS 业务码=" + code);
}
Map<String, String> quote = toAgentQuote(tms);
boolean found = Boolean.TRUE.equals(tms.get("quoteFound"))
|| StringUtils.isNotBlank(quote.get("total"));
if (!found) {
out.put("ok", true);
out.put("has_price", false);
out.put("classification", "TMS_NO_QUOTE_1002");
out.put("quote", new HashMap<String, String>());
return out;
}
persistHit(workOrderNo, quote);
out.put("ok", true);
out.put("has_price", true);
out.put("classification", "HAS_PRICE");
out.put("quote", quote);
return out;
}
private void persistHit(String workOrderNo, Map<String, String> quote) {
if (StringUtils.isBlank(workOrderNo)) {
return;
}
InquiryTicket ticket = workOrderService.getOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
.last("LIMIT 1"));
if (ticket == null) {
return;
}
InquiryQuote row = new InquiryQuote();
row.setTicketId(ticket.getId());
row.setWorkOrderNo(workOrderNo);
row.setQuoteVersion(1);
row.setSource("TMS");
row.setSourceLabel("TMS 标准报价");
// 后台售价栏必须带 TMS 币种,不能只落纯数字。
row.setPriceText(TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total")));
row.setHighlighted(1);
row.setPayloadJson(JSON.toJSONString(quote));
row.setCreateTime(new Date());
quoteMapper.insert(row);
// 后台列表/详情读主表售价;只写子表的话工单页看起来是空的。
applyQuoteToTicket(ticket, quote);
workOrderService.updateById(ticket);
if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) {
TicketTransitionRequest tr = new TicketTransitionRequest();
tr.setWorkOrderNo(workOrderNo);
tr.setToStatus(TicketStatuses.QUOTED);
workOrderService.transition(tr, "agent");
}
}
/**
* 把 TMS 规范化回包投影成智能体 quote。单测可直接调。
* 金额保持数字原文;币种单独落 currency / fee_lines.currency,禁止默认 CNY。
*/
@SuppressWarnings("unchecked")
static Map<String, String> toAgentQuote(Map<String, Object> tms) {
Map<String, String> quote = new LinkedHashMap<>();
Object tmsData = tms.get("tmsData");
Map<String, Object> data = tmsData instanceof Map ? (Map<String, Object>) tmsData : tms;
String headerCurrency = TmsQuoteCurrency.fromHeader(data);
TmsQuoteCurrency.Money totalMoney = TmsQuoteCurrency.split(
firstText(data.get("totalSalePrice"), data.get("salePrice"), data.get("totalCostPrice")),
headerCurrency);
if (StringUtils.isBlank(headerCurrency) && StringUtils.isNotBlank(totalMoney.currency)) {
headerCurrency = totalMoney.currency;
}
String total = totalMoney.amount;
String eta = firstText(data.get("transitTime"), data.get("transitDaysDisplay"),
data.get("transitDays"), data.get("aging"), data.get("estimatedDelivery"));
String etaDate = firstText(data.get("estimatedDelivery"), data.get("etaDate"), data.get("arriveDate"));
String validUntil = firstText(
data.get("validUntil"), data.get("quoteValidUntil"), data.get("validDate"),
data.get("expireDate"), data.get("expiryDate"), data.get("priceValidUntil"),
data.get("quoteExpireDate"), data.get("validTo"), data.get("validityDate"),
data.get("quoteValidity"), data.get("validityPeriod"));
if (StringUtils.isBlank(validUntil)) {
Object nested = data.get("quote");
if (nested instanceof Map) {
Map<?, ?> q = (Map<?, ?>) nested;
validUntil = firstText(q.get("validUntil"), q.get("quoteValidUntil"),
q.get("expireDate"), q.get("validDate"), q.get("validTo"));
}
}
TmsQuoteCurrency.Money freightMoney = TmsQuoteCurrency.split(
firstText(data.get("freight"), data.get("airFreight"), data.get("oceanFreight"), total),
headerCurrency);
TmsQuoteCurrency.Money otherMoney = TmsQuoteCurrency.split(
firstText(data.get("otherFee"), data.get("sundryFee")), headerCurrency);
TmsQuoteCurrency.Money costMoney = TmsQuoteCurrency.split(
firstText(data.get("totalCostPrice"), data.get("costPrice"), data.get("cost")),
headerCurrency);
String profit = firstText(data.get("profitRate"), data.get("grossProfitRate"), data.get("margin"));
quote.put("freight", StringUtils.isBlank(freightMoney.amount) ? "-" : freightMoney.amount);
quote.put("other_fee", StringUtils.isBlank(otherMoney.amount) ? "-" : otherMoney.amount);
quote.put("total", StringUtils.isBlank(total) ? "-" : total);
if (StringUtils.isNotBlank(headerCurrency)) {
quote.put("currency", headerCurrency);
}
if (StringUtils.isNotBlank(costMoney.amount)) {
quote.put("cost", costMoney.amount);
}
if (StringUtils.isNotBlank(profit)) {
quote.put("profit_rate", profit);
}
quote.put("eta", StringUtils.isBlank(eta) ? "-" : eta);
if (StringUtils.isNotBlank(etaDate) && !etaDate.equals(eta)) {
quote.put("eta_date", etaDate);
}
// 报价有效期只取 TMS 原文,没有就给 "-",禁止本地编日期。
quote.put("valid_until", StringUtils.isBlank(validUntil) ? "-" : validUntil);
quote.put("source", "TMS");
quote.put("source_label", "TMS 标准报价");
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
return quote;
}
/**
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
* 没有的成本/毛利率不编;售价用销售合计。
*/
private static void applyQuoteToTicket(InquiryTicket ticket, Map<String, String> quote) {
if (ticket == null || quote == null) {
return;
}
String sale = TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total"));
if (StringUtils.isNotBlank(sale) && !"-".equals(sale)) {
ticket.setSalePrice(sale);
}
String cost = text(quote.get("cost"));
if (StringUtils.isNotBlank(cost) && !"-".equals(cost)) {
ticket.setCostPrice(cost);
}
String profit = text(quote.get("profit_rate"));
if (StringUtils.isNotBlank(profit) && !"-".equals(profit)) {
ticket.setProfitRate(profit);
}
ticket.setTmsHit(1);
}
/**
* 抽出销售侧价格明细行。优先 TMS 费用清单,没有再用常见费用字段兜底。
* 每行带自己的币种;没有就继承表头,仍然不编造。
*/
@SuppressWarnings("unchecked")
private static String buildFeeLinesJson(Map<String, Object> data, String freight, String other, String headerCurrency) {
List<Map<String, String>> lines = new ArrayList<>();
Object raw = first(data.get("feeItems"), data.get("feeList"), data.get("chargeList"), data.get("costItems"));
if (raw instanceof List) {
for (Object item : (List<?>) raw) {
if (!(item instanceof Map)) {
continue;
}
Map<?, ?> row = (Map<?, ?>) item;
String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"),
row.get("itemName"), row.get("feeType"));
String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"),
row.get("feeAmount"), row.get("totalPrice"));
String lineCurrency = TmsQuoteCurrency.fromFeeRow(row);
addFeeLine(lines, name, amount,
StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency,
extrasFromFee(row));
}
}
if (lines.isEmpty()) {
addFeeLine(lines, "空运费", firstText(data.get("airFreight"), data.get("freight"), freight), headerCurrency, null);
addFeeLine(lines, "操作费", firstText(data.get("operateFee"), data.get("operationFee"), data.get("handlingFee")), headerCurrency, null);
addFeeLine(lines, "文件费", firstText(data.get("docFee"), data.get("fileFee"), data.get("documentFee")), headerCurrency, null);
addFeeLine(lines, "报关费", firstText(data.get("customsFee"), data.get("declareFee")), headerCurrency, null);
addFeeLine(lines, "提货费", firstText(data.get("pickupFee"), data.get("deliveryFee")), headerCurrency, null);
addFeeLine(lines, "目的地杂费", firstText(data.get("destFee"), data.get("destinationFee")), headerCurrency, null);
addFeeLine(lines, "其他附加费", firstText(other, data.get("otherFee"), data.get("sundryFee")), headerCurrency, null);
}
return JSON.toJSONString(lines);
}
/**
* 费用行上的单位 / 数量 / 单价 / 备注只取 TMS 原文,没有就空着。
*/
private static Map<String, String> extrasFromFee(Map<?, ?> row) {
Map<String, String> extras = new LinkedHashMap<>();
putExtra(extras, "unit", firstText(row.get("unit"), row.get("unitName"), row.get("chargeUnit")));
putExtra(extras, "quantity", firstText(row.get("quantity"), row.get("chargeQuantity"), row.get("charge_quantity")));
putExtra(extras, "unit_price", firstText(row.get("unitPrice"), row.get("unit_price")));
putExtra(extras, "remark", firstText(row.get("remark"), row.get("description"), row.get("note")));
return extras;
}
private static void putExtra(Map<String, String> extras, String key, String value) {
if (StringUtils.isNotBlank(value) && !"-".equals(value) && !"null".equalsIgnoreCase(value)) {
extras.put(key, value);
}
}
private static void addFeeLine(List<Map<String, String>> lines, String name, String amount,
String currency, Map<String, String> extras) {
TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency);
if (StringUtils.isBlank(name) || StringUtils.isBlank(money.amount) || "-".equals(money.amount)) {
return;
}
Map<String, String> row = new LinkedHashMap<>();
row.put("name", name);
row.put("amount", money.amount);
if (StringUtils.isNotBlank(money.currency)) {
row.put("currency", money.currency);
}
if (extras != null) {
for (Map.Entry<String, String> e : extras.entrySet()) {
if (StringUtils.isNotBlank(e.getValue()) && !row.containsKey(e.getKey())) {
row.put(e.getKey(), e.getValue());
}
}
}
lines.add(row);
}
/**
* 出站前只拦销售侧必填真缺。originCode / destinationCode 不是拦网:
* 对不上机场也要查,TMS 自己回无价或 1003。
*/
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}")) {
missing.add("workOrderNo");
}
if (quoteDate == null || quoteDate.isEmpty()) {
missing.add("quoteDate");
}
for (String key : new String[] {
"origin", "destination",
"cargoName", "quantity", "weightKg", "volumeCbm", "packageType"
}) {
if (segment.get(key) == null || "".equals(segment.get(key))) {
missing.add(key);
}
}
return missing;
}
private static Map<String, Object> incomplete(List<String> missing) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("ok", false);
out.put("has_price", false);
out.put("classification", "TMS_QUERY_INCOMPLETE");
out.put("source", "TMS");
out.put("message", "查价参数不齐:" + missing);
out.put("quote", new HashMap<String, String>());
return out;
}
/**
* 能填出机场代码就带上(名称命中或销售已写三字码)。
* 对不上不写假码,也不拦出站。
*/
private void fillAirportCode(Map<String, Object> segment, String key, String raw) {
TmsExactProjector.Projection hit = projector.resolveAirport(raw);
if (hit.ok()) {
segment.put(key, hit.code);
}
}
/**
* TMS 出站要 packageType。销售原文非空即收下,不对销售做包装白名单。
* 认得出代码用代码;未对上默认 2。不要默认 1(全部),现网航线 1 常无价、2/3 有价。
*/
private void fillPackageType(Map<String, Object> segment, String raw) {
if (raw == null || raw.trim().isEmpty()) {
segment.put("packageType", 2);
return;
}
if (raw.matches("\\d+")) {
segment.put("packageType", Integer.valueOf(raw));
return;
}
TmsExactProjector.Projection hit = projector.resolvePackage(raw);
if (!hit.ok()) {
segment.put("packageType", 2);
return;
}
try {
segment.put("packageType", Integer.valueOf(hit.code));
} catch (NumberFormatException ex) {
segment.put("packageType", hit.code);
}
}
private static Map<String, Object> tech(String message) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("ok", false);
out.put("has_price", false);
out.put("classification", "TMS_TECH_FAILURE");
out.put("source", "TMS");
out.put("message", message);
out.put("quote", new HashMap<String, String>());
return out;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> extractFacts(Map<String, Object> body) {
if (body == null) {
return new HashMap<>();
}
Object facts = body.get("facts");
if (facts instanceof Map) {
Map<String, Object> copy = new HashMap<>();
for (Map.Entry<?, ?> e : ((Map<?, ?>) facts).entrySet()) {
copy.put(String.valueOf(e.getKey()), e.getValue());
}
return copy;
}
return new HashMap<>(body);
}
private static Object first(Object... values) {
for (Object v : values) {
if (v != null && !"".equals(v)) {
return v;
}
}
return null;
}
private static String text(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private static String firstText(Object... values) {
for (Object v : values) {
String t = text(v);
if (!t.isEmpty() && !"null".equals(t)) {
return t;
}
}
return "";
}
}
@@ -3,43 +3,40 @@ package org.jeecg.modules.inquiry.tms;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.jeecg.common.api.vo.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* TMS 适配 HTTP 壳。
* TMS 适配探测接口。
* <p>
* 路径:/inquiry/tms/*;当前全部 501/未实现,供后台与联调探测包是否挂载。
* 路径:/inquiry/tms/*。configured 只读环境,不打 TMS;health 可探测但锁舱永不从这里触发。
* 禁止:在本控制器同步等 Python Graph。
*/
@Api(tags = "询价-TMS适配壳")
@Api(tags = "询价-TMS适配")
@RestController
@RequestMapping("/inquiry/tms")
public class TmsAdapterController {
private final TmsAdapterClient client = new TmsAdapterClient();
@Autowired
private TmsProperties properties;
@Autowired
private TmsGatewayClient client;
/**
* 健康壳:返回未实现说明,不打真实 TMS。
*
* @return Result 包装
* 配置探测:是否已配齐(不访问外网)。
*/
@ApiOperation("TMS 健康壳(未实现)")
@GetMapping("/health")
public Result<?> health() {
TmsShellResult r = client.health();
return Result.error(r.getHttpStatus(), "TMS adapter shell: " + r.getError() + ":" + r.getOperation());
}
/**
* 配置探测:是否已配齐(壳阶段恒 false)。
*
* @return configured=false
*/
@ApiOperation("TMS 配置探测壳")
@ApiOperation("TMS 配置探测")
@GetMapping("/configured")
public Result<?> configured() {
return Result.OK(java.util.Collections.singletonMap("configured", client.isConfigured()));
Map<String, Object> body = new LinkedHashMap<>();
body.put("configured", client.isConfigured());
body.put("enabled", properties.isEnabled());
body.put("lockEnabled", properties.isLockEnabled());
return Result.OK(body);
}
}
@@ -0,0 +1,153 @@
package org.jeecg.modules.inquiry.tms;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 空运字典 EXACT_ONLY 投影(种子文件,后续由 TMS 字典 sync 替换)。
* <p>
* 职责:起运/目的机场、包装类型精确匹配;包装缺省投影 packageType=1(全部)。
* 多值歧义返回 AMBIGUOUS,禁止 last-write-wins。
* 失败 source=TMS_DICTIONARY_PROJECTION,不得装成无价 1002。
*/
@Slf4j
@Component
public class TmsExactProjector {
private final Map<String, List<String>> airportByName = new LinkedHashMap<>();
private final Map<String, List<String>> airportByCode = new LinkedHashMap<>();
private final Map<String, List<String>> packByName = new LinkedHashMap<>();
@PostConstruct
public void load() {
try {
ClassPathResource res = new ClassPathResource("tms-air-exact-seed.json");
String raw = StreamUtils.copyToString(res.getInputStream(), StandardCharsets.UTF_8);
JSONObject root = JSON.parseObject(raw);
index(root.getJSONArray("air_airport"), airportByName, airportByCode);
index(root.getJSONArray("air_packaging_type"), packByName, new LinkedHashMap<>());
log.info("tms.exact seed airports={} packs={}", airportByName.size(), packByName.size());
} catch (Exception ex) {
log.error("tms.exact seed 加载失败", ex);
}
}
/**
* 精确解析一个名称或代码。
*
* @return RESOLVED / UNMAPPED / AMBIGUOUS
*/
public Projection resolveAirport(String raw) {
Projection hit = resolve(raw, airportByName, airportByCode);
if (hit.ok() || "AMBIGUOUS".equals(hit.status)) {
return hit;
}
// 销售已写出三字码(如 ZUH/CRK):这是原文代码,不是别名猜测
String text = raw == null ? "" : raw.trim();
if (text.matches("(?i)[A-Z]{3}")) {
return Projection.resolved(text, text.toUpperCase());
}
return hit;
}
/**
* 包装:空值按托盘(2),不要默认「全部」(1)。仍无则 UNMAPPED。
*/
public Projection resolvePackage(String raw) {
String text = raw == null ? "" : raw.trim();
if (text.isEmpty()) {
text = "托盘";
}
return resolve(text, packByName, packByName);
}
private static Projection resolve(String raw,
Map<String, List<String>> byName,
Map<String, List<String>> byCode) {
String text = raw == null ? "" : raw.trim();
if (text.isEmpty()) {
return Projection.unmapped("");
}
Set<String> codes = new LinkedHashSet<>();
addAll(codes, byName.get(text));
addAll(codes, byCode.get(text.toUpperCase()));
if (codes.isEmpty()) {
return Projection.unmapped(text);
}
if (codes.size() > 1) {
return Projection.ambiguous(text, new ArrayList<>(codes));
}
return Projection.resolved(text, codes.iterator().next());
}
private static void addAll(Set<String> into, List<String> values) {
if (values != null) {
into.addAll(values);
}
}
private static void index(JSONArray rows,
Map<String, List<String>> byName,
Map<String, List<String>> byCode) {
if (rows == null) {
return;
}
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.getJSONObject(i);
String name = row.getString("source_name");
String code = row.getString("source_code");
if (name == null || code == null) {
continue;
}
byName.computeIfAbsent(name.trim(), k -> new ArrayList<>()).add(code.trim());
byCode.computeIfAbsent(code.trim().toUpperCase(), k -> new ArrayList<>()).add(code.trim());
}
}
/**
* 单字段投影结果。
*/
public static final class Projection {
public final String status;
public final String source;
public final String code;
public final List<String> codes;
private Projection(String status, String source, String code, List<String> codes) {
this.status = status;
this.source = source;
this.code = code;
this.codes = codes;
}
public static Projection resolved(String source, String code) {
return new Projection("RESOLVED", source, code, null);
}
public static Projection unmapped(String source) {
return new Projection("UNMAPPED", source, null, null);
}
public static Projection ambiguous(String source, List<String> codes) {
return new Projection("AMBIGUOUS", source, null, codes);
}
public boolean ok() {
return "RESOLVED".equals(status);
}
}
}
@@ -0,0 +1,191 @@
package org.jeecg.modules.inquiry.tms;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* TMS V2 查询网关(只做 query / token;锁舱默认关闭)。
* <p>
* 职责:Bearer 出站、超时、最多 3 次、code 1/1002 短缓存、连续失败熔断。
* 线程:无全局工单大锁;熔断计数是进程级协调,不是六态。
* 禁止:LOCK/RELEASE 出站;按 userid 伪造报价。
*/
@Slf4j
@Component
public class TmsGatewayClient {
private final TmsProperties properties;
private final RestTemplate restTemplate;
private final TmsV2TokenProvider tokens;
private final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private final AtomicLong circuitOpenUntil = new AtomicLong();
/**
* Spring 注入入口。必须标 {@code @Autowired}:本类还有测试用三参构造,
* 不标的话 Spring 会去找无参构造并启动失败。
*/
@Autowired
public TmsGatewayClient(TmsProperties properties, RestTemplateBuilder builder) {
this.properties = properties;
this.restTemplate = builder
.setConnectTimeout(Duration.ofMillis(Math.max(500, properties.getConnectTimeoutMs())))
.setReadTimeout(Duration.ofMillis(Math.max(1000, properties.getReadTimeoutMs())))
.build();
this.tokens = new TmsV2TokenProvider(properties, this.restTemplate);
}
/** 单测注入,不走 Spring。 */
TmsGatewayClient(TmsProperties properties, RestTemplate restTemplate, TmsV2TokenProvider tokens) {
this.properties = properties;
this.restTemplate = restTemplate;
this.tokens = tokens;
}
/**
* 配置是否看起来可出站(不访问网络)。
*/
public boolean isConfigured() {
return properties.queryConfigured();
}
/**
* 查询报价。内部请求先经 {@link TmsV2RequestMapper}。
*
* @param internalRequest 主账内部结构
* @param cacheKey 短缓存键(已含字典版本时可拼进去)
* @return 规范化 body(含 code)
*/
public Map<String, Object> query(Map<String, Object> internalRequest, String cacheKey) {
Map<String, Object> request = TmsV2RequestMapper.quote(internalRequest);
long now = System.currentTimeMillis();
CacheEntry hit = cache.get(cacheKey);
if (hit != null && hit.expiresAt > now) {
Map<String, Object> copy = new LinkedHashMap<>(hit.body);
copy.put("cacheStatus", "HIT");
return copy;
}
Map<String, Object> body = invokeQuery(request);
int code = TmsV2ResponseNormalizer.number(body.get("code"));
if (code == 1 || code == 1002) {
cache.put(cacheKey, new CacheEntry(new LinkedHashMap<>(body),
now + Math.max(1, properties.getCacheTtlSeconds()) * 1000L));
}
return body;
}
private Map<String, Object> invokeQuery(Map<String, Object> request) {
assertCircuitAvailable();
String json = JSON.toJSONString(request);
int max = Math.max(1, Math.min(3, properties.getMaxAttempts()));
boolean refreshed401 = false;
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());
ResponseEntity<String> response = restTemplate.exchange(
TmsV2TokenProvider.joinUrl(properties.getBaseUrl(), properties.getQueryPath()),
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");
body.put("liveTmsVerified", true);
body.put("cacheStatus", "MISS");
log.info("tms.query req wo={} quoteDate={} segs={}",
request.get("workOrderNo"), request.get("quoteDate"), request.get("segments"));
log.info("tms.query ok code={} msg={} http={} costMs={} attempt={}",
body.get("code"), body.get("message"), response.getStatusCodeValue(),
System.currentTimeMillis() - started, attempt);
return body;
} catch (HttpStatusCodeException ex) {
int status = ex.getRawStatusCode();
log.warn("tms.query http={} attempt={} costMs={}",
status, attempt, System.currentTimeMillis() - started);
last = new IllegalStateException("TMS HTTP=" + status);
if (status == 401 && !refreshed401) {
tokens.invalidate();
refreshed401 = true;
continue;
}
if ((status == 429 || status >= 500) && attempt < max) {
sleepQuiet(200L * (1L << (attempt - 1)));
continue;
}
failCircuit();
throw last;
} catch (ResourceAccessException ex) {
log.warn("tms.query network attempt={} err={}", 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 void assertCircuitAvailable() {
long until = circuitOpenUntil.get();
if (until > System.currentTimeMillis()) {
throw new IllegalStateException("TMS 熔断中");
}
}
private void failCircuit() {
int n = consecutiveFailures.incrementAndGet();
if (n >= 3) {
circuitOpenUntil.set(System.currentTimeMillis()
+ Math.max(5, properties.getCircuitOpenSeconds()) * 1000L);
log.warn("tms.circuit open after {} failures", n);
}
}
private static void sleepQuiet(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
private static final class CacheEntry {
private final Map<String, Object> body;
private final long expiresAt;
private CacheEntry(Map<String, Object> body, long expiresAt) {
this.body = body;
this.expiresAt = expiresAt;
}
}
}
@@ -0,0 +1,72 @@
package org.jeecg.modules.inquiry.tms;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* TMS V2 出站配置(只读环境变量,密钥不写进本类默认值)。
* <p>
* 职责:给 Token / 查询网关读地址与超时。锁舱开关默认关,防止与现网抢舱。
* 禁止:把 ClientSecret 打进日志;禁止智能体读取本配置去直连 TMS。
*/
@Data
@Component
public class TmsProperties {
@Value("${YTD_TMS_V2_ENABLED:true}")
private boolean enabled;
@Value("${TMS_V2_BASE_URL:}")
private String baseUrl;
@Value("${TMS_V2_CLIENT_ID:}")
private String clientId;
@Value("${TMS_V2_CLIENT_SECRET:}")
private String clientSecret;
@Value("${TMS_V2_SIGNATURE_SECRET:}")
private String signatureSecret;
@Value("${TMS_V2_TOKEN_PATH:/auth/token}")
private String tokenPath;
@Value("${TMS_V2_QUERY_PATH:/quote/v2/query}")
private String queryPath;
@Value("${TMS_V2_HEALTH_PATH:/health}")
private String healthPath;
/** 与现网共用 Client 期间禁止出站锁舱。 */
@Value("${TMS_V2_LOCK_ENABLED:false}")
private boolean lockEnabled;
@Value("${TMS_V2_CONNECT_TIMEOUT_MS:3000}")
private int connectTimeoutMs;
@Value("${TMS_V2_READ_TIMEOUT_MS:8000}")
private int readTimeoutMs;
@Value("${TMS_V2_MAX_ATTEMPTS:3}")
private int maxAttempts;
@Value("${TMS_V2_CACHE_TTL_SECONDS:30}")
private int cacheTtlSeconds;
@Value("${TMS_V2_TOKEN_REFRESH_SKEW_SECONDS:60}")
private int tokenRefreshSkewSeconds;
@Value("${TMS_V2_CIRCUIT_OPEN_SECONDS:30}")
private int circuitOpenSeconds;
/**
* 是否具备出站查询的最低配置(不探测网络)。
*/
public boolean queryConfigured() {
return enabled
&& baseUrl != null && !baseUrl.trim().isEmpty()
&& clientId != null && !clientId.trim().isEmpty()
&& clientSecret != null && !clientSecret.trim().isEmpty();
}
}
@@ -0,0 +1,184 @@
package org.jeecg.modules.inquiry.tms;
import org.apache.commons.lang3.StringUtils;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 从 TMS 回包抽出币种,只认原文,不编造。
* <p>
* 职责:给查价投影一个稳定的 currency;费用行可以有自己的当地币。
* 禁止:默认 CNY/USD;把备注、金额数字当成币种。
* 线程:无状态,调用方线程内执行。
*/
public final class TmsQuoteCurrency {
private static final Pattern PREFIX = Pattern.compile("^([A-Za-z]{3})\\s+(.+)$");
private static final Pattern ISO = Pattern.compile("^[A-Z]{3}$");
private TmsQuoteCurrency() {
}
/**
* 表头币种:currency / currencyCode / 嵌套 quote / 费用行 / 航线方案,按这个顺序。
*/
@SuppressWarnings("unchecked")
public static String fromHeader(Map<String, Object> data) {
if (data == null || data.isEmpty()) {
return "";
}
String hit = firstCode(
data.get("currency"),
data.get("currencyCode"),
data.get("saleCurrency"),
data.get("chargeCurrency"),
data.get("priceCurrency"),
data.get("settlementCurrency"));
if (StringUtils.isNotBlank(hit)) {
return hit;
}
Object nested = data.get("quote");
if (nested instanceof Map) {
hit = firstCode(
((Map<?, ?>) nested).get("currency"),
((Map<?, ?>) nested).get("currencyCode"),
((Map<?, ?>) nested).get("saleCurrency"));
if (StringUtils.isNotBlank(hit)) {
return hit;
}
}
Object fees = firstNonNull(data.get("feeItems"), data.get("feeList"),
data.get("chargeList"), data.get("costItems"));
if (fees instanceof List) {
for (Object item : (List<?>) fees) {
if (item instanceof Map) {
hit = fromFeeRow((Map<?, ?>) item);
if (StringUtils.isNotBlank(hit)) {
return hit;
}
}
}
}
for (String key : new String[] {"airOptions", "seaDetails", "landSegments", "options"}) {
Object arr = data.get(key);
if (!(arr instanceof List)) {
continue;
}
for (Object opt : (List<?>) arr) {
if (opt instanceof Map) {
hit = fromHeader((Map<String, Object>) opt);
if (StringUtils.isNotBlank(hit)) {
return hit;
}
}
}
}
return "";
}
/**
* 单条费用的当地币。没有就空串,不回退成整单币种(由调用方决定要不要继承)。
*/
public static String fromFeeRow(Map<?, ?> row) {
if (row == null) {
return "";
}
return firstCode(row.get("currency"), row.get("currencyCode"),
row.get("saleCurrency"), row.get("chargeCurrency"));
}
/**
* 金额里若已带「CNY 14669.60」,拆开数字和币种,避免卡片双写、Excel SUM 失败。
*/
public static Money split(String raw, String fallbackCurrency) {
String amount = raw == null ? "" : raw.trim();
String currency = normalize(fallbackCurrency);
if (StringUtils.isBlank(amount) || "-".equals(amount)) {
return new Money(amount, currency);
}
Matcher m = PREFIX.matcher(amount);
if (m.matches()) {
String fromAmt = normalize(m.group(1));
if (StringUtils.isNotBlank(fromAmt)) {
return new Money(m.group(2).trim(), fromAmt);
}
}
return new Money(amount, currency);
}
/**
* 销售可见金额:有币种就写成「CNY 14669.60」。金额已带币种前缀则原样。
*/
public static String formatDisplay(String currency, String amount) {
Money money = split(amount, currency);
if (StringUtils.isBlank(money.amount) || "-".equals(money.amount)) {
return StringUtils.isBlank(money.amount) ? "" : money.amount;
}
if (StringUtils.isBlank(money.currency)) {
return money.amount;
}
return money.currency + " " + money.amount;
}
public static String firstCode(Object... values) {
if (values == null) {
return "";
}
for (Object value : values) {
String code = normalize(value);
if (StringUtils.isNotBlank(code)) {
return code;
}
}
return "";
}
/**
* 只收 ISO 三字码和 TMS 偶尔写的人民币/美元中文。其它原文丢掉,避免把备注当币种。
*/
public static String normalize(Object value) {
if (value == null) {
return "";
}
String text = String.valueOf(value).trim();
if (text.isEmpty() || "null".equalsIgnoreCase(text) || "-".equals(text)) {
return "";
}
String upper = text.toUpperCase();
if ("RMB".equals(upper) || "人民币".equals(text)) {
return "CNY";
}
if ("美金".equals(text) || "美元".equals(text)) {
return "USD";
}
return ISO.matcher(upper).matches() ? upper : "";
}
private static Object firstNonNull(Object... values) {
if (values == null) {
return null;
}
for (Object value : values) {
if (value != null && !"".equals(value)) {
return value;
}
}
return null;
}
/**
* 拆开后的金额与币种。amount 仍是 TMS 数字原文,currency 可能为空。
*/
public static final class Money {
public final String amount;
public final String currency;
public Money(String amount, String currency) {
this.amount = amount == null ? "" : amount;
this.currency = currency == null ? "" : currency;
}
}
}
@@ -0,0 +1,188 @@
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.Map;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 把主账内部查价结构映射成 TMS V2 /quote/v2/query 正文。
* <p>
* 对照《友通达询价智能体TMS系统对接开发接口文档V1.0.1》5.2 / 2.1 / 6.3:
* quantity 必须是 JSON 整数;weightKg / volumeCbm 必须是 JSON 小数且大于 0;
* 带「KGS」「CBM」的字符串会触发业务码 1001(参数格式错误)。
* 报价日期:文档漏了该请求字段,实际 TMS 使用 quoteDate,格式 yyyy-MM-dd(如 2026-09-07)。
* 禁止:inquiryTime=此刻(会查成无匹配);在这里做字典模糊匹配或猜测机场代码。
*/
public final class TmsV2RequestMapper {
/** 从「100KGS」「2.5CBM」「10件」里取出第一个数字。 */
private static final Pattern FIRST_NUMBER = Pattern.compile("(\\d+(?:\\.\\d+)?)");
/** 文档 2.1:后缀 Date 的字段格式 yyyy-MM-dd;也容忍斜杠或后面跟时间。 */
private static final Pattern ISO_DATE = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})");
private TmsV2RequestMapper() {
}
/**
* @param internal 含 transportMode/quoteDate/segments 的内部请求
* @return 可 JSON 序列化的 TMS V2 body(数字字段已是 Number)
*/
public static Map<String, Object> quote(Map<String, Object> internal) {
Map<String, Object> result = new LinkedHashMap<>();
copy(internal, result, "requestId");
if (result.get("requestId") == null) {
// TMS 要求 requestId 为 UUID,缺了会 1003「需补充字段」
result.put("requestId", UUID.randomUUID().toString());
}
copy(internal, result, "workOrderNo");
if (result.get("workOrderNo") == null
|| !String.valueOf(result.get("workOrderNo")).matches("WO\\d{12}")) {
// TMS 要求 WOYYYYMMDDNNNN,WO-TMP 会 1003
String day = new java.text.SimpleDateFormat("yyyyMMdd").format(new java.util.Date());
result.put("workOrderNo", "WO" + day + "0000");
}
copy(internal, result, "transportMode");
String quoteDate = normalizeQuoteDate(internal.get("quoteDate"));
if (quoteDate != null) {
result.put("quoteDate", quoteDate);
}
List<Map<String, Object>> segments = maps(internal.get("segments"));
List<Map<String, Object>> external = new ArrayList<>();
for (int i = 0; i < segments.size(); i++) {
external.add(segment(segments.get(i), i + 1));
}
result.put("segments", external);
return compact(result);
}
private static Map<String, Object> segment(Map<String, Object> source, int fallbackSequence) {
Map<String, Object> target = new LinkedHashMap<>();
Object sequence = first(source, "sequence", "segmentSequence");
target.put("sequence", sequence == null ? fallbackSequence : asInteger(sequence));
put(target, "transportMode", first(source, "transportMode"));
put(target, "origin", first(source, "origin"));
put(target, "originCode", first(source, "originCode"));
put(target, "destination", first(source, "destination"));
put(target, "destinationCode", first(source, "destinationCode"));
put(target, "cargoName", first(source, "cargoName", "goodsName"));
// 5.2:quantity=int,weightKg/volumeCbm=decimal>0,禁止带单位字符串
put(target, "quantity", asInteger(first(source, "quantity", "pieces")));
put(target, "weightKg", asPositiveDecimal(first(source, "weightKg")));
put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm")));
put(target, "packageType", first(source, "packageType", "packagingType"));
return compact(target);
}
/**
* 报价日期规范成 yyyy-MM-dd。解析不了则不传,避免 1001。
*/
public static String normalizeQuoteDate(Object raw) {
if (raw == null) {
return null;
}
String text = String.valueOf(raw).trim();
if (text.isEmpty()) {
return null;
}
Matcher matcher = ISO_DATE.matcher(text);
if (!matcher.find()) {
return null;
}
int year = Integer.parseInt(matcher.group(1));
int month = Integer.parseInt(matcher.group(2));
int day = Integer.parseInt(matcher.group(3));
if (month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
return String.format("%04d-%02d-%02d", year, month, day);
}
/**
* 件数:JSON 整数。销售原文「10件」只取数字。
*/
public static Integer asInteger(Object raw) {
if (raw instanceof Number) {
return ((Number) raw).intValue();
}
BigDecimal number = firstDecimal(raw);
if (number == null) {
return null;
}
return number.intValue();
}
/**
* 毛重/体积:JSON 小数且 &gt;0。销售原文「100KGS」「2CBM」只取数字。
*/
public static BigDecimal asPositiveDecimal(Object raw) {
if (raw instanceof Number) {
BigDecimal number = new BigDecimal(raw.toString());
return number.compareTo(BigDecimal.ZERO) > 0 ? number : null;
}
BigDecimal number = firstDecimal(raw);
if (number == null || number.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
return number;
}
private static BigDecimal firstDecimal(Object raw) {
if (raw == null) {
return null;
}
Matcher matcher = FIRST_NUMBER.matcher(String.valueOf(raw).trim());
if (!matcher.find()) {
return null;
}
try {
return new BigDecimal(matcher.group(1));
} catch (NumberFormatException ex) {
return null;
}
}
private static Object first(Map<String, Object> value, String... keys) {
for (String key : keys) {
Object current = value.get(key);
if (current != null && !"".equals(current)) {
return current;
}
}
return null;
}
private static void copy(Map<String, Object> source, Map<String, Object> target, String key) {
put(target, key, source.get(key));
}
private static void put(Map<String, Object> target, String key, Object value) {
if (value != null && !"".equals(value)) {
target.put(key, value);
}
}
private static Map<String, Object> compact(Map<String, Object> value) {
value.entrySet().removeIf(e -> e.getValue() == null || "".equals(e.getValue()));
return value;
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> maps(Object value) {
List<Map<String, Object>> result = new ArrayList<>();
if (!(value instanceof List)) {
return result;
}
for (Object item : (List<?>) value) {
if (item instanceof Map) {
result.add(new LinkedHashMap<>((Map<String, Object>) item));
}
}
return result;
}
}
@@ -0,0 +1,68 @@
package org.jeecg.modules.inquiry.tms;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* TMS 回包规范化:抽出 code / tmsData,保留原文业务字段。
* <p>
* 职责:给查询网关一个稳定 envelope,便于区分 1 / 1002 / 1003。
* 禁止:在这里把 1002 改写成无价以外的语义。
*/
public final class TmsV2ResponseNormalizer {
private TmsV2ResponseNormalizer() {
}
/**
* @param raw TMS HTTP 正文
* @return 含 code、message、traceId、tmsData 的 map
*/
public static Map<String, Object> normalize(String raw) {
Map<String, Object> envelope = parseEnvelope(raw);
Map<String, Object> data = data(envelope);
Map<String, Object> result = new LinkedHashMap<>(data);
result.put("code", number(envelope.get("code")));
result.put("message", envelope.get("message"));
result.put("traceId", envelope.get("traceId"));
result.put("tmsData", new LinkedHashMap<>(data));
return result;
}
public static Map<String, Object> parseEnvelope(String raw) {
JSONObject parsed;
try {
parsed = JSON.parseObject(raw);
} catch (RuntimeException ex) {
throw new IllegalArgumentException("invalid JSON");
}
if (parsed == null || !parsed.containsKey("code")) {
throw new IllegalArgumentException("code missing");
}
return new LinkedHashMap<>(parsed);
}
@SuppressWarnings("unchecked")
public static Map<String, Object> data(Map<String, Object> envelope) {
Object value = envelope.get("data");
if (value instanceof Map) {
return new LinkedHashMap<>((Map<String, Object>) value);
}
Map<String, Object> fallback = new LinkedHashMap<>(envelope);
fallback.remove("code");
fallback.remove("message");
fallback.remove("traceId");
return fallback;
}
public static int number(Object value) {
try {
return value == null ? 0 : Integer.parseInt(String.valueOf(value));
} catch (RuntimeException ex) {
return 0;
}
}
}
@@ -0,0 +1,121 @@
package org.jeecg.modules.inquiry.tms;
import com.alibaba.fastjson.JSON;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* TMS V2 client_credentials Token 缓存。
* <p>
* 职责:按需刷新 accessToken;state() 不输出 Token 原文。
* 线程:同一把锁只保护缓存刷新,不串行所有工单查价。
*/
public class TmsV2TokenProvider {
private final TmsProperties properties;
private final RestTemplate restTemplate;
private final Object monitor = new Object();
private volatile CachedToken cached;
public TmsV2TokenProvider(TmsProperties properties, RestTemplate restTemplate) {
this.properties = properties;
this.restTemplate = restTemplate;
}
/**
* @return Bearer 用的 accessToken
*/
public String accessToken() {
long now = System.currentTimeMillis();
CachedToken current = cached;
if (current != null && current.refreshAtMillis > now) {
return current.value;
}
synchronized (monitor) {
current = cached;
now = System.currentTimeMillis();
if (current != null && current.refreshAtMillis > now) {
return current.value;
}
cached = fetch(now);
return cached.value;
}
}
public void invalidate() {
synchronized (monitor) {
cached = null;
}
}
private CachedToken fetch(long now) {
if (!properties.queryConfigured()) {
throw new IllegalStateException("TMS V2 client credentials 未配置");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String basic = properties.getClientId() + ":" + properties.getClientSecret();
headers.set("Authorization", "Basic " + Base64.getEncoder()
.encodeToString(basic.getBytes(StandardCharsets.UTF_8)));
Map<String, Object> request = new LinkedHashMap<>();
request.put("grantType", "client_credentials");
ResponseEntity<String> response = restTemplate.exchange(
joinUrl(properties.getBaseUrl(), properties.getTokenPath()),
HttpMethod.POST,
new HttpEntity<>(JSON.toJSONString(request), headers),
String.class);
Map<String, Object> envelope = TmsV2ResponseNormalizer.parseEnvelope(response.getBody());
if (TmsV2ResponseNormalizer.number(envelope.get("code")) != 1) {
throw new IllegalStateException("TMS Token 业务码失败");
}
Map<String, Object> data = TmsV2ResponseNormalizer.data(envelope);
String token = text(data.get("accessToken"));
long expiresIn = number(data.get("expiresIn"));
if (token.isEmpty() || expiresIn <= 0) {
throw new IllegalStateException("TMS Token 响应缺少 accessToken/expiresIn");
}
long skew = Math.max(0, properties.getTokenRefreshSkewSeconds());
long refreshSeconds = Math.max(1, expiresIn - Math.min(skew, Math.max(0, expiresIn - 1)));
return new CachedToken(token, now + refreshSeconds * 1000L);
}
static String joinUrl(String base, String path) {
String b = base == null ? "" : base;
while (b.endsWith("/")) {
b = b.substring(0, b.length() - 1);
}
String p = path == null ? "" : path;
return b + (p.startsWith("/") ? p : "/" + p);
}
private static String text(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private static long number(Object value) {
try {
return value == null ? 0L : Long.parseLong(String.valueOf(value));
} catch (RuntimeException ex) {
return 0L;
}
}
private static final class CachedToken {
private final String value;
private final long refreshAtMillis;
private CachedToken(String value, long refreshAtMillis) {
this.value = value;
this.refreshAtMillis = refreshAtMillis;
}
}
}
@@ -1,9 +1,7 @@
/**
* TMS 适配空包:智能体不直连 TMS,仅主账本包出站。
* TMS 适配:仅主账本包出站(token + query)。
* <p>
* 本包当前只提供健康探测与未实现占位;token / query / 字典 sync / 锁舱
* 正式实现前一律返回未启用,禁止半可用 LOCK/RELEASE。
* <p>
* 禁止:按企微 userid 伪造 TMS 结果;禁止模糊字典匹配。
* 智能体只打 /inquiry/agent/tms/query。锁舱/释放默认关闭。
* 查价不做本地港口/包装字典对应;有价回报价,无价回暂无报价。禁止 fixture 假价。
*/
package org.jeecg.modules.inquiry.tms;
@@ -0,0 +1,26 @@
{
"comment": "空运 EXACT_ONLY 种子:source_name/source_code 精确相等才投影。多码同名视为 AMBIGUOUS。正式应以 TMS 字典 sync 覆盖本文件。",
"air_airport": [
{"source_name": "上海", "source_code": "SHA"},
{"source_name": "浦东", "source_code": "PVG"},
{"source_name": "上海浦东", "source_code": "PVG"},
{"source_name": "虹桥", "source_code": "SHA"},
{"source_name": "上海虹桥", "source_code": "SHA"},
{"source_name": "洛杉矶", "source_code": "LAX"},
{"source_name": "深圳", "source_code": "SZX"},
{"source_name": "广州", "source_code": "CAN"},
{"source_name": "香港", "source_code": "HKG"},
{"source_name": "北京首都", "source_code": "PEK"},
{"source_name": "北京大兴", "source_code": "PKX"},
{"source_name": "珠海", "source_code": "ZUH"},
{"source_name": "克拉克", "source_code": "CRK"}
],
"air_packaging_type": [
{"source_name": "全部", "source_code": "1"},
{"source_name": "托盘", "source_code": "2"},
{"source_name": "卡板", "source_code": "2"},
{"source_name": "散货", "source_code": "2"},
{"source_name": "纸箱", "source_code": "3"},
{"source_name": "木箱", "source_code": "3"}
]
}
@@ -0,0 +1,156 @@
package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 出单回填:费用行字符串要能解析;按名称写入金额列。
*/
public class QuoteTemplateFillTest {
@Test
public void parseFeeLinesJsonString() {
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows("[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]");
assertEquals(1, rows.size());
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
assertEquals("14669.60", rows.getJSONObject(0).getString("amount"));
}
@Test
public void parseFeeLinesDoubleEncoded() {
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows("[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]");
assertEquals(1, rows.size());
}
@Test
public void writeAmountOntoNamedFeeRow() throws Exception {
Path src = Files.createTempFile("quote-src", ".xlsx");
Path out = Files.createTempFile("quote-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
sheet.createRow(15).createCell(6).setCellValue("金额");
sheet.createRow(16).createCell(1).setCellValue("空运费");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("quote_no", "WO202609140048");
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows("[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]");
facts.put("fee_rows", rows);
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) {
double amt = wb.getSheetAt(0).getRow(16).getCell(6).getNumericCellValue();
assertEquals(14669.60, amt, 0.01);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
@Test
public void buildFillFactsReadsFeeLinesString() {
Map<String, Object> facts = new HashMap<>();
facts.put("起运港", "ZUH");
Map<String, Object> quote = new HashMap<>();
quote.put("total", "14669.60");
quote.put("valid_until", "2026-09-15");
quote.put("fee_lines", "[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]");
quote.put("currency", "CNY");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202609140048", facts, quote, "WuJiLin");
assertEquals("WO202609140048", out.getString("quote_no"));
assertEquals("2026-09-15", out.getString("valid_until"));
assertEquals("CNY", out.getString("currency"));
Object fee = out.get("fee_rows");
assertTrue(fee instanceof JSONArray);
assertEquals(1, ((JSONArray) fee).size());
assertEquals("CNY", ((JSONArray) fee).getJSONObject(0).getString("currency"));
}
@Test
public void writeFeeDetailColumnsOntoNamedFeeRow() throws Exception {
Path src = Files.createTempFile("quote-fee-src", ".xlsx");
Path out = Files.createTempFile("quote-fee-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
head.createCell(1).setCellValue("费用名称");
head.createCell(2).setCellValue("单位");
head.createCell(3).setCellValue("币别");
head.createCell(4).setCellValue("");
head.createCell(5).setCellValue("数量");
head.createCell(6).setCellValue("金额");
head.createCell(7).setCellValue("备注");
sheet.createRow(16).createCell(1).setCellValue("空运费");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("currency", "CNY");
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"空运费\",\"amount\":\"14669.60\",\"currency\":\"CNY\","
+ "\"unit\":\"KG\",\"quantity\":\"1333.6\",\"unit_price\":\"11.0\","
+ "\"remark\":\"计费重进位+1000KG\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) {
org.apache.poi.ss.usermodel.Row row = wb.getSheetAt(0).getRow(16);
assertEquals("KG", row.getCell(2).getStringCellValue());
assertEquals("CNY", row.getCell(3).getStringCellValue());
assertEquals(11.0, row.getCell(4).getNumericCellValue(), 0.01);
assertEquals(1333.6, row.getCell(5).getNumericCellValue(), 0.01);
assertEquals(14669.60, row.getCell(6).getNumericCellValue(), 0.01);
assertEquals("计费重进位+1000KG", row.getCell(7).getStringCellValue());
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
@Test
public void writeCurrencyOntoNamedFeeRow() throws Exception {
Path src = Files.createTempFile("quote-ccy-src", ".xlsx");
Path out = Files.createTempFile("quote-ccy-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
sheet.createRow(15).createCell(3).setCellValue("币别");
sheet.getRow(15).createCell(6).setCellValue("金额");
sheet.createRow(16).createCell(1).setCellValue("空运费");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("currency", "CNY");
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"空运费\",\"amount\":\"14669.60\",\"currency\":\"CNY\"}]");
facts.put("fee_rows", rows);
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) {
assertEquals("CNY", wb.getSheetAt(0).getRow(16).getCell(3).getStringCellValue());
assertEquals(14669.60, wb.getSheetAt(0).getRow(16).getCell(6).getNumericCellValue(), 0.01);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
}
@@ -0,0 +1,73 @@
package org.jeecg.modules.inquiry.template;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
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;
/**
* 报价模板匹配:关键词优先,否则默认,双无则空。不连库。
*/
public class QuoteTemplateMatcherTest {
@Test
public void keywordBeatsDefault() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("默认空运", "空运", true, "欧洲"));
list.add(tpl("洛杉矶专线", "空运", false, "洛杉矶"));
Map<String, Object> facts = new HashMap<>();
facts.put("目的港", "洛杉矶");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "AIR", facts, null);
assertNotNull(hit);
assertEquals("keyword", hit.get("matchType"));
assertEquals("洛杉矶专线", hit.get("name"));
}
@Test
public void fallbackDefaultWhenNoKeyword() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("默认空运", "空运", true, "欧洲"));
list.add(tpl("洛杉矶专线", "空运", false, "洛杉矶"));
Map<String, Object> facts = new HashMap<>();
facts.put("目的港", "纽约");
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "空运", facts, null);
assertNotNull(hit);
assertEquals("default", hit.get("matchType"));
assertEquals("默认空运", hit.get("name"));
}
@Test
public void noneWhenNoKeywordAndNoDefault() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("洛杉矶专线", "空运", false, "洛杉矶"));
Map<String, Object> facts = new HashMap<>();
facts.put("目的港", "纽约");
assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null));
}
@Test
public void seaTemplateIgnoredForAir() {
List<Map<String, Object>> list = new ArrayList<>();
list.add(tpl("海运默认", "海运", true, "洛杉矶"));
Map<String, Object> facts = new HashMap<>();
facts.put("目的港", "洛杉矶");
assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null));
}
private static Map<String, Object> tpl(String name, String biz, boolean isDefault, String keyword) {
Map<String, Object> m = new HashMap<>();
m.put("name", name);
m.put("bizType", biz);
m.put("isDefault", isDefault);
List<String> kws = new ArrayList<>();
kws.add(keyword);
m.put("keywords", kws);
return m;
}
}
@@ -0,0 +1,35 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 机场投影单测:不打真实 TMS。
*/
public class TmsExactProjectorTest {
private TmsExactProjector projector;
@BeforeEach
public void setUp() {
projector = new TmsExactProjector();
projector.load();
}
@Test
public void iataCodePassesThroughWhenNotInSeed() {
TmsExactProjector.Projection hit = projector.resolveAirport("SIN");
assertTrue(hit.ok());
assertEquals("SIN", hit.code);
}
@Test
public void seedNameStillMaps() {
TmsExactProjector.Projection hit = projector.resolveAirport("上海");
assertTrue(hit.ok());
assertEquals("SHA", hit.code);
}
}
@@ -0,0 +1,111 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 币种抽取单测:不打真实 TMS,禁止编造默认币种。
*/
public class TmsQuoteCurrencyTest {
@Test
public void headerCurrencyWins() {
Map<String, Object> data = new HashMap<>();
data.put("currency", "cny");
data.put("salePrice", "14669.60");
assertEquals("CNY", TmsQuoteCurrency.fromHeader(data));
}
@Test
public void currencyCodeAlias() {
Map<String, Object> data = new HashMap<>();
data.put("currencyCode", "USD");
assertEquals("USD", TmsQuoteCurrency.fromHeader(data));
}
@Test
public void feeItemCurrencyWhenHeaderEmpty() {
Map<String, Object> fee = new HashMap<>();
fee.put("feeName", "空运费");
fee.put("salePrice", "14669.60");
fee.put("currency", "CNY");
Map<String, Object> data = new HashMap<>();
data.put("feeItems", Collections.singletonList(fee));
assertEquals("CNY", TmsQuoteCurrency.fromHeader(data));
assertEquals("CNY", TmsQuoteCurrency.fromFeeRow(fee));
}
@Test
public void airOptionCurrencyWhenHeaderEmpty() {
Map<String, Object> option = new HashMap<>();
option.put("currencyCode", "CNY");
option.put("salePrice", "14669.60");
Map<String, Object> data = new HashMap<>();
data.put("airOptions", Collections.singletonList(option));
assertEquals("CNY", TmsQuoteCurrency.fromHeader(data));
}
@Test
public void missingCurrencyIsEmptyNotInvented() {
Map<String, Object> data = new HashMap<>();
data.put("salePrice", "14669.60");
assertEquals("", TmsQuoteCurrency.fromHeader(data));
assertEquals("14669.60", TmsQuoteCurrency.formatDisplay("", "14669.60"));
}
@Test
public void rmbBecomesCny() {
assertEquals("CNY", TmsQuoteCurrency.normalize("RMB"));
assertEquals("CNY", TmsQuoteCurrency.normalize("人民币"));
}
@Test
public void remarkIsNotCurrency() {
assertEquals("", TmsQuoteCurrency.normalize("含税"));
assertEquals("", TmsQuoteCurrency.normalize("CNY14669"));
}
@Test
public void splitAndFormat() {
TmsQuoteCurrency.Money money = TmsQuoteCurrency.split("CNY 14669.60", "");
assertEquals("14669.60", money.amount);
assertEquals("CNY", money.currency);
assertEquals("CNY 14669.60", TmsQuoteCurrency.formatDisplay("CNY", "14669.60"));
assertEquals("USD 1,200", TmsQuoteCurrency.formatDisplay("USD", "USD 1,200"));
}
@Test
public void toAgentQuoteKeepsCurrency() {
Map<String, Object> fee = new HashMap<>();
fee.put("feeName", "空运费");
fee.put("salePrice", "14669.60");
fee.put("currency", "CNY");
fee.put("unit", "KG");
fee.put("quantity", 1333.6);
fee.put("unitPrice", 11.0);
fee.put("remark", "计费重进位+1000KG");
Map<String, Object> data = new HashMap<>();
data.put("quoteFound", true);
data.put("totalSalePrice", "14669.60");
data.put("currency", "CNY");
data.put("feeItems", Collections.singletonList(fee));
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
assertEquals("CNY", quote.get("currency"));
assertEquals("14669.60", quote.get("total"));
assertTrue(quote.get("fee_lines").contains("\"currency\":\"CNY\""));
assertTrue(quote.get("fee_lines").contains("\"amount\":\"14669.60\""));
assertTrue(quote.get("fee_lines").contains("\"unit\":\"KG\""));
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"));
}
}
@@ -0,0 +1,61 @@
package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* 请求映射单测:不打真实 TMS。
*/
public class TmsV2RequestMapperTest {
@Test
public void quoteDateGoesToTmsNotInquiryTime() {
Map<String, Object> seg = new HashMap<>();
seg.put("originCode", "SHA");
seg.put("destinationCode", "LAX");
seg.put("packageType", "1");
Map<String, Object> internal = new HashMap<>();
internal.put("transportMode", "AIR");
internal.put("quoteDate", "2026-09-14");
internal.put("segments", Collections.singletonList(seg));
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
assertEquals("2026-09-14", body.get("quoteDate"));
assertFalse(body.containsKey("inquiryTime"));
String requestId = String.valueOf(body.get("requestId"));
assertEquals(36, requestId.length());
assertEquals(true, String.valueOf(body.get("workOrderNo")).matches("WO\\d{12}"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
assertEquals("SHA", segs.get(0).get("originCode"));
}
@Test
public void oralUnitsBecomeJsonNumbers() {
Map<String, Object> seg = new HashMap<>();
seg.put("originCode", "ZUH");
seg.put("destinationCode", "CRK");
seg.put("quantity", "10件");
seg.put("weightKg", "100KGS");
seg.put("volumeCbm", "2CBM");
seg.put("packageType", "1");
Map<String, Object> internal = new HashMap<>();
internal.put("transportMode", "AIR");
internal.put("quoteDate", "2026-09-07T18:00:00+08:00");
internal.put("segments", Collections.singletonList(seg));
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
assertEquals("2026-09-07", body.get("quoteDate"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
assertEquals(Integer.valueOf(10), segs.get(0).get("quantity"));
assertEquals(new java.math.BigDecimal("100"), segs.get(0).get("weightKg"));
assertEquals(new java.math.BigDecimal("2"), segs.get(0).get("volumeCbm"));
assertFalse(String.valueOf(segs.get(0).get("weightKg")).contains("KGS"));
}
}