()
.eq(InquiryAttachment::getWorkOrderNo, workOrderNo));
- return n != null && n > 0;
+ return InquiryQuoteFileSupport.isIssued(payload, n != null && n > 0);
}
/**
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteFileSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteFileSupport.java
new file mode 100644
index 0000000..caa2d49
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryQuoteFileSupport.java
@@ -0,0 +1,66 @@
+package org.jeecg.modules.inquiry.support;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * 报价单发出 / 作废标记。
+ *
+ * 职责:只改报价载荷里的 quoteFileIssued / quoteFileVoided,不改六态。
+ * 副作用:无。调用方写回 inq_quote.payload_json。
+ * 为何:采用后改选必须作废旧单,续办不能再走进成交跟进。
+ */
+public final class InquiryQuoteFileSupport {
+
+ public static final String ISSUED = "quoteFileIssued";
+ public static final String VOIDED = "quoteFileVoided";
+
+ private InquiryQuoteFileSupport() {
+ }
+
+ /**
+ * 作废:清发出标记,写下作废。有附件也不能再当有效报价单。
+ */
+ public static String applyVoid(String payloadJson) {
+ JSONObject payload = parse(payloadJson);
+ payload.put(ISSUED, false);
+ payload.put(VOIDED, true);
+ return payload.toJSONString();
+ }
+
+ /**
+ * 重新采用:打发出标记,去掉作废。
+ */
+ public static String markIssued(String payloadJson) {
+ JSONObject payload = parse(payloadJson);
+ payload.put(ISSUED, true);
+ payload.remove(VOIDED);
+ return payload.toJSONString();
+ }
+
+ /**
+ * 续办是否按「文件已发出」进成交跟进。
+ * 作废优先:即使库里还有附件也不算有效报价单。
+ */
+ public static boolean isIssued(String payloadJson, boolean hasAttachments) {
+ JSONObject payload = parse(payloadJson);
+ if (Boolean.TRUE.equals(payload.getBoolean(VOIDED))
+ || "true".equalsIgnoreCase(String.valueOf(payload.get(VOIDED)))) {
+ return false;
+ }
+ Object flag = payload.get(ISSUED);
+ if (Boolean.TRUE.equals(flag) || "true".equalsIgnoreCase(String.valueOf(flag))) {
+ return true;
+ }
+ return hasAttachments;
+ }
+
+ private static JSONObject parse(String payloadJson) {
+ if (StringUtils.isBlank(payloadJson)) {
+ return new JSONObject(true);
+ }
+ JSONObject parsed = JSON.parseObject(payloadJson);
+ return parsed == null ? new JSONObject(true) : parsed;
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java
new file mode 100644
index 0000000..b2ee8e1
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketFactsSupport.java
@@ -0,0 +1,157 @@
+package org.jeecg.modules.inquiry.support;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import org.apache.commons.lang3.StringUtils;
+import org.jeecg.modules.inquiry.entity.InquiryTicket;
+
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 工单询价事实写回:选定线路后覆盖起运港等列。
+ *
+ * 职责:合并 factsJson 与列字段。不改六态、不写报价版本。
+ * 副作用:无。调用方负责 updateById。
+ * 为何:H5 选线路后工单起运港必须变成所选,改选覆盖。
+ */
+public final class InquiryTicketFactsSupport {
+
+ private InquiryTicketFactsSupport() {
+ }
+
+ /**
+ * 用销售侧内部中文键覆盖工单。空值不删已有键(改选只覆盖有值项)。
+ *
+ * @param ticket 当前工单
+ * @param facts 完整或增量 facts
+ */
+ public static void apply(InquiryTicket ticket, Map facts) {
+ if (ticket == null || facts == null || facts.isEmpty()) {
+ return;
+ }
+ Map merged = parseFacts(ticket.getFactsJson());
+ for (Map.Entry e : facts.entrySet()) {
+ if (e.getKey() == null || e.getValue() == null) {
+ continue;
+ }
+ String val = e.getValue().trim();
+ if (val.isEmpty()) {
+ continue;
+ }
+ merged.put(e.getKey(), val);
+ }
+ ticket.setFactsJson(JSON.toJSONString(merged));
+ if (StringUtils.isNotBlank(merged.get("起运港"))) {
+ ticket.setOrigin(merged.get("起运港"));
+ }
+ if (StringUtils.isNotBlank(merged.get("目的港"))) {
+ ticket.setDestination(merged.get("目的港"));
+ }
+ if (StringUtils.isNotBlank(merged.get("品名"))) {
+ ticket.setGoodsName(merged.get("品名"));
+ }
+ Integer pieces = parseInt(merged.get("件数"));
+ if (pieces != null) {
+ ticket.setPieces(pieces);
+ }
+ BigDecimal weight = parseDecimal(first(merged, "重量", "毛重"));
+ if (weight != null) {
+ ticket.setWeightKg(weight);
+ }
+ BigDecimal volume = parseDecimal(merged.get("体积"));
+ if (volume != null) {
+ ticket.setVolumeCbm(volume);
+ }
+ Date quoteDate = parseDate(merged.get("报价日期"));
+ if (quoteDate != null) {
+ ticket.setQuoteDate(quoteDate);
+ }
+ String origin = StringUtils.defaultString(ticket.getOrigin());
+ String dest = StringUtils.defaultString(ticket.getDestination());
+ if (StringUtils.isNotBlank(origin) || StringUtils.isNotBlank(dest)) {
+ ticket.setDemandTitle(origin + " → " + dest);
+ }
+ }
+
+ /**
+ * 解开 factsJson。坏 JSON 当空。
+ */
+ public static Map parseFacts(String factsJson) {
+ Map out = new LinkedHashMap<>();
+ if (StringUtils.isBlank(factsJson)) {
+ return out;
+ }
+ try {
+ JSONObject parsed = JSON.parseObject(factsJson);
+ if (parsed == null) {
+ return out;
+ }
+ for (String key : parsed.keySet()) {
+ if (parsed.get(key) != null) {
+ out.put(key, String.valueOf(parsed.get(key)));
+ }
+ }
+ } catch (Exception ignored) {
+ return out;
+ }
+ return out;
+ }
+
+ private static String first(Map facts, String... keys) {
+ if (facts == null || keys == null) {
+ return null;
+ }
+ for (String key : keys) {
+ String val = facts.get(key);
+ if (StringUtils.isNotBlank(val)) {
+ return val;
+ }
+ }
+ return null;
+ }
+
+ private static Integer parseInt(String raw) {
+ if (StringUtils.isBlank(raw)) {
+ return null;
+ }
+ String digits = raw.replaceAll("[^0-9-]", "");
+ if (digits.isEmpty() || "-".equals(digits)) {
+ return null;
+ }
+ try {
+ return Integer.valueOf(digits);
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ private static BigDecimal parseDecimal(String raw) {
+ if (StringUtils.isBlank(raw)) {
+ return null;
+ }
+ String norm = raw.replaceAll("[^0-9.\\-]", "");
+ if (norm.isEmpty() || ".".equals(norm) || "-".equals(norm)) {
+ return null;
+ }
+ try {
+ return new BigDecimal(norm);
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ private static Date parseDate(String raw) {
+ if (StringUtils.isBlank(raw)) {
+ return null;
+ }
+ try {
+ return new SimpleDateFormat("yyyy-MM-dd").parse(raw.trim());
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java
index ea56717..3d1e766 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java
@@ -26,8 +26,10 @@ import java.util.Map;
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
*
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
- * 锁舱必须带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
- * 仍没有编号才补 QT-{工单号} + AIR-OPT-01 出站;不因「没 TMS 报价」本地拦住。
+ * 锁舱优先带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
+ * 没有 5.2 报价也可以锁舱:TMS 锁舱这两项必填,缺了会 1001「参数格式错误」。
+ * 此时补 QT-{工单号} + AIR-OPT-01,禁止拿空字段出站。
+ * 有真实 TMS 编号时不准用自编号盖掉,否则会回 2006。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@@ -67,18 +69,20 @@ public class InquiryTmsCabinService {
}
Map quote = latestQuote(no);
quote = refreshLockQuote(ticket, quote);
- String quoteId = resolveQuoteId(quote, no);
+ String quoteId = lockQuoteId(resolveQuoteId(quote, no), no);
Map option = resolveOption(quote, optionNo);
- if (StringUtils.isBlank(quoteId) || option == null) {
- return fail("no_lock_target", "锁舱缺少报价编号或舱位选项");
+ if (option == null) {
+ option = new LinkedHashMap<>();
}
+ String resolvedOption = lockOptionNo(text(option, "optionNo", "option_no"));
+ option.put("optionNo", resolvedOption);
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放锁舱");
}
Map req = new LinkedHashMap<>();
req.put("workOrderNo", no);
req.put("quoteId", quoteId);
- req.put("optionNo", text(option, "optionNo", "option_no"));
+ req.put("optionNo", resolvedOption);
req.put("flightDate", quoteDate(ticket));
req.put("origin", firstNonBlank(text(option, "originAirportCode"), ticket.getOrigin(),
fact(ticket, "起运地", "起运港")));
@@ -97,7 +101,7 @@ public class InquiryTmsCabinService {
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("instructionText", text(body, "instructionText", "instruction_text"));
- return invokeLock(ticket, req, quoteId, text(option, "optionNo", "option_no"));
+ return invokeLock(ticket, req, quoteId, resolvedOption);
}
/**
@@ -140,7 +144,10 @@ public class InquiryTmsCabinService {
Map tms = gateway.lockCabin(req);
int code = TmsV2ResponseNormalizer.number(tms.get("code"));
if (code != 1) {
- return fail("lock_fail", cabinFailReason(code, tms));
+ String why = cabinFailReason(code, tms, quoteDate(ticket));
+ log.info("tms.lock fail wo={} quoteId={} optionNo={} code={} reason={}",
+ ticket.getWorkOrderNo(), quoteId, optionNo, code, why);
+ return fail("lock_fail", why);
}
Map data = tmsData(tms);
String lockId = firstNonBlank(text(data, "lockId"), text(tms, "lockId"));
@@ -169,7 +176,9 @@ public class InquiryTmsCabinService {
Map tms = gateway.releaseCabin(req);
int code = TmsV2ResponseNormalizer.number(tms.get("code"));
if (code != 1) {
- return fail("release_fail", cabinFailReason(code, tms));
+ String why = cabinFailReason(code, tms, quoteDate(ticket));
+ log.info("tms.release fail wo={} code={} reason={}", ticket.getWorkOrderNo(), code, why);
+ return fail("release_fail", why);
}
Map data = tmsData(tms);
Map out = okBase();
@@ -301,18 +310,49 @@ public class InquiryTmsCabinService {
}
/**
- * 没有 TMS 5.2 报价编号时,用工单号生成关联 ID,仍调锁舱。
+ * 库里的真实 TMS 报价编号。自编 QT-工单号不算:先再查一次 5.2,
+ * 仍没有才在 {@link #lockQuoteId} 补占位,给无报价锁舱用。
*/
static String resolveQuoteId(Map quote, String workOrderNo) {
String id = text(quote, "quoteId", "quote_id");
- if (StringUtils.isNotBlank(id)) {
- return id;
+ if (StringUtils.isBlank(id)) {
+ return "";
}
- return StringUtils.isBlank(workOrderNo) ? "" : "QT-" + workOrderNo.trim();
+ String wo = StringUtils.trimToEmpty(workOrderNo);
+ if (id.equalsIgnoreCase("QT-" + wo) || id.toUpperCase().startsWith("QT-WO")) {
+ return "";
+ }
+ return id;
}
/**
- * 有舱位选项按名称挑;一张都没有时补 AIR-OPT-01,让人工价也能出站。
+ * 锁舱出站用的报价编号。TMS /air/cabin/lock 的 quoteId 必填,缺了回 1001。
+ * 有 5.2 真编号就用真编号;没有才补 QT-{工单号},允许无报价锁舱。
+ */
+ static String lockQuoteId(String resolved, String workOrderNo) {
+ if (StringUtils.isNotBlank(resolved)) {
+ return resolved.trim();
+ }
+ String wo = StringUtils.trimToEmpty(workOrderNo);
+ if (wo.isEmpty()) {
+ return "";
+ }
+ return "QT-" + wo;
+ }
+
+ /**
+ * 锁舱出站用的舱位选项。TMS optionNo 必填,缺了回 1001。
+ * 有选项用选项;没有补 AIR-OPT-01。
+ */
+ static String lockOptionNo(String resolved) {
+ if (StringUtils.isNotBlank(resolved)) {
+ return resolved.trim();
+ }
+ return "AIR-OPT-01";
+ }
+
+ /**
+ * 有舱位选项按名称挑;没有就补 AIR-OPT-01,无报价锁舱也要带上必填项。
*/
static Map resolveOption(Map quote, String optionNo) {
List