落地私聊空运询价与TMS报价回填,按后台模板出单。
查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+175
@@ -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<>();
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ import java.util.Date;
|
||||
/**
|
||||
* 询价工单实体,对应表 inq_ticket。
|
||||
* <p>
|
||||
* 职责:持久化六态工单主事实;列表/详情/看板聚合均以此表为真相源之一。
|
||||
* 职责:持久化六态工单主事实(含 quote_date 报价日期);列表/详情/看板聚合均以此表为真相源之一。
|
||||
* 副作用:写库经 Service;禁止前端本地改 status 冒充主账成功。
|
||||
*/
|
||||
@Data
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 友通达新系统 · 询价主账手写模块(从零实现)。
|
||||
* <p>
|
||||
* 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语、报价模板(逻辑头+版本+映射)。
|
||||
* 排除:识别任务、存档死信、Graph/自然语言抽取。
|
||||
* 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语、报价模板(逻辑头+版本+映射)、智能体建单、经本模块查真 TMS。
|
||||
* 排除:识别任务、报价生成、存档死信、Graph/自然语言抽取。
|
||||
* 数据只写 inquiry_robot;禁止连现网 jeecg-boot。
|
||||
*/
|
||||
package org.jeecg.modules.inquiry;
|
||||
|
||||
+10
@@ -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);
|
||||
}
|
||||
|
||||
+25
@@ -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);
|
||||
}
|
||||
|
||||
+163
@@ -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)
|
||||
|
||||
+281
@@ -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)
|
||||
|
||||
+349
-11
@@ -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) {
|
||||
|
||||
+196
@@ -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);
|
||||
}
|
||||
}
|
||||
+480
@@ -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 "";
|
||||
}
|
||||
}
|
||||
+18
-21
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+153
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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();
|
||||
}
|
||||
}
|
||||
+184
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -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 小数且 >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;
|
||||
}
|
||||
}
|
||||
+68
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -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"}
|
||||
]
|
||||
}
|
||||
+156
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+111
@@ -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"));
|
||||
}
|
||||
}
|
||||
+61
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user