From 049ee51c3cfaa6f8f2be8b9229caef2d46808393 Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Tue, 22 Sep 2026 23:18:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=99=86=E6=B5=B7=E8=BF=90?= =?UTF-8?q?=20TMS=20=E5=AD=97=E6=AE=B5=E4=B8=8E=E6=8A=A5=E4=BB=B7=E5=8D=95?= =?UTF-8?q?=E5=A1=AB=E8=A1=A8=EF=BC=9A=E8=B4=B9=E7=94=A8=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E3=80=81=E6=B8=85=E7=A9=BA=E6=AD=A2=E8=A1=8C?= =?UTF-8?q?=E5=AE=89=E5=85=A8=EF=BC=8C=E5=86=99=E5=85=A5=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=B7=B2=E6=8A=A5=E4=BB=B7=E5=88=99=E7=BB=AD=E5=8A=9E=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- inquiry-agent/agent/ledger/http_ledger.py | 37 +++ .../impl/InquiryQuoteTemplateServiceImpl.java | 14 +- .../template/QuoteTemplateExcelSupport.java | 216 ++++++++++++++++-- .../inquiry/tms/InquiryTmsQueryService.java | 3 + .../inquiry/tms/SeaTmsFieldNormalizer.java | 22 +- .../inquiry/tms/TmsLandEnumMapper.java | 59 ++--- .../inquiry/tms/TmsLandSegmentMapper.java | 22 +- .../inquiry/tms/TmsV2RequestMapper.java | 8 +- .../template/QuoteTemplateFillTest.java | 84 ++++++- .../tms/SeaTmsFieldNormalizerTest.java | 6 +- .../inquiry/tms/TmsLandEnumMapperTest.java | 22 +- .../inquiry/tms/TmsLandSegmentMapperTest.java | 18 +- .../inquiry/tms/TmsV2RequestMapperTest.java | 8 +- 13 files changed, 402 insertions(+), 117 deletions(-) diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index dd55b24..cedc20e 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -147,6 +147,9 @@ class HttpLedger: """ 覆盖本单最新 TMS 快照。选定陆运线路后必须走这里,才能把该条成本写回工单。 空报价不打主账,避免把查价结果抹掉。 + + 若接口暂不可用(热更丢映射常见 404)但查价时主账已落库为已报价, + 则按成功续办,避免销售侧误报「写入报价失败」。 """ no = (work_order_no or "").strip() src = dict(quote or {}) @@ -158,11 +161,45 @@ class HttpLedger: idem=f"tms-quote:{no}:{src.get('total') or ''}:{src.get('route_line') or ''}", ) if out.get("ok") is False: + recovered = self._recover_upsert_if_already_quoted(work_order_no=no, failed=out) + if recovered is not None: + return recovered return out out.setdefault("ok", True) out.setdefault("status", to_status or "已报价") return out + def _recover_upsert_if_already_quoted( + self, + *, + work_order_no: str, + failed: dict[str, Any], + ) -> dict[str, Any] | None: + """ + replaceTmsQuote 失败时,读主账工单:已报价且已有 TMS 价则视为写入成功。 + 查价路径会先 persistHit,接口 404 时工单其实已经有价。 + """ + try: + snap = self.get_for_agent(work_order_no=work_order_no, sender_id="") + except Exception: # noqa: BLE001 — 兜底读失败仍按原失败返回 + return None + status = str(snap.get("status") or "") + tms_hit = snap.get("tms_hit") in (1, "1", True, "true") + sale = str(snap.get("sale_price") or snap.get("salePrice") or "").strip() + if status == "已报价" and (tms_hit or sale): + logger.warning( + "upsert_quote 接口失败但工单已报价,按成功续办 wo=%s err=%s", + work_order_no, + failed.get("error") or failed, + ) + return { + "ok": True, + "status": "已报价", + "recovered": True, + "quote_version": snap.get("quote_version") or 1, + } + return None + def render_quote( self, *, diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index 8a91b8e..d41205c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -813,8 +813,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * stopAtText 未命中时 {@link #findStopRow} 会回 Integer.MAX_VALUE。 + * 若原样循环清空,会捅到 Excel 最大行(1048576)并抛 IllegalArgumentException,整张出单失败。 + * 因此未命中时只清到表内已有末行,绝不 createRow 扩到上限。 */ private static void clearFeeRowsAfter(Sheet sheet, int fromRow, int stopRow, JSONObject columns, Set yellow, String sheetName) { - if (sheet == null || columns == null || fromRow >= stopRow) { + if (sheet == null || columns == null || fromRow < 0) { + return; + } + int lastExisting = sheet.getLastRowNum(); + int end = stopRow; + if (end == Integer.MAX_VALUE || end > lastExisting + 1) { + end = lastExisting + 1; + } + // XSSF 合法行号 0..1048575;end 是开区间上界 + end = Math.min(end, 1048575 + 1); + if (fromRow >= end) { return; } // 备注列常黄底「发生时收取」:不清 remark,只清费用数据列;黄格由 writeFeeCol 跳过 String[] keys = {"project", "name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"}; - for (int r = fromRow; r < stopRow; r++) { + for (int r = fromRow; r < end; r++) { for (String key : keys) { String colLetter = columns.getString(key); if (StringUtils.isBlank(colLetter)) { @@ -1317,8 +1334,11 @@ public final class QuoteTemplateExcelSupport { } private static int findStopRow(Sheet sheet, JSONArray stopAtText, int fromRow) { - if (sheet == null || stopAtText == null || stopAtText.isEmpty()) { - return Integer.MAX_VALUE; + if (sheet == null) { + return 0; + } + if (stopAtText == null || stopAtText.isEmpty()) { + return sheet.getLastRowNum() + 1; } DataFormatter fmt = new DataFormatter(); List needles = new ArrayList<>(); @@ -1329,7 +1349,7 @@ public final class QuoteTemplateExcelSupport { } } if (needles.isEmpty()) { - return Integer.MAX_VALUE; + return sheet.getLastRowNum() + 1; } int last = sheet.getLastRowNum(); for (int r = Math.max(0, fromRow); r <= last; r++) { @@ -1352,7 +1372,8 @@ public final class QuoteTemplateExcelSupport { } } } - return Integer.MAX_VALUE; + // 未命中:停在表末,禁止回 Integer.MAX_VALUE(清空费用占位时会捅爆 Excel 行上限) + return last + 1; } /** @@ -1440,7 +1461,9 @@ public final class QuoteTemplateExcelSupport { 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("quantity"), + feeQuantityOrDefault(rowObj.getString("quantity"), rowObj.getString("name")), + 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")) { @@ -1456,6 +1479,10 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(colLetter)) { return; } + // 清空不存在的行:不必 createRow,避免 stop 未命中时把表撑到 Excel 上限 + if (rowIdx < 0 || rowIdx > 1048575) { + return; + } int col = CellReference.convertColStringToIndex(colLetter.trim()); String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString(); if (yellow != null && yellow.contains(ref)) { @@ -1463,6 +1490,9 @@ public final class QuoteTemplateExcelSupport { } Row row = sheet.getRow(rowIdx); if (row == null) { + if (StringUtils.isBlank(value)) { + return; + } row = sheet.createRow(rowIdx); } Cell cell = row.getCell(col); @@ -1561,6 +1591,14 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isNotBlank(unit)) { line.put("unit", unit); } + // 固定费无数量时默认 1;运输费留给 enrich 写计费重 + String qty = firstNonBlank(ch.getString("quantity"), ch.getString("chargeQuantity"), + ch.getString("charge_quantity")); + if (StringUtils.isNotBlank(qty)) { + line.put("quantity", qty); + } else if (!isAirWeightFeeName(name)) { + line.put("quantity", "1"); + } String ccy = firstNonBlank(ch.getString("currency"), optCcy); if (StringUtils.isNotBlank(ccy)) { line.put("currency", ccy); @@ -1629,6 +1667,26 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 费用行数量:固定费(提送货/人工等)无 quantity 时默认 1; + * 空运运输费缺数量时不要填 1(应由计费重 enrich,误填 1 会把金额算错观感)。 + */ + static String feeQuantityOrDefault(String quantity, String feeName) { + String q = StringUtils.trimToEmpty(quantity); + if (!q.isEmpty() && !"-".equals(q)) { + return q; + } + if (isAirWeightFeeName(feeName)) { + return ""; + } + return "1"; + } + + /** 兼容旧调用:无费目名时按固定费处理(默认 1)。 */ + static String feeQuantityOrDefault(String quantity) { + return feeQuantityOrDefault(quantity, ""); + } + public static boolean isAirWeightFeeName(String name) { String n = StringUtils.trimToEmpty(name); if (n.isEmpty()) { @@ -1681,6 +1739,123 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 预估费用 / 预估总价行只保留一个总价。 + *

+ * 为何:模板金额列是 SUM,刷公式后常把「发生时收取」的提送货/人工费加进去; + * 若再按标签把 TMS total 写到标签右侧空格,销售会看到同行两个数(如 183370 与 183499)。 + * 有 total_amount 时以它为准,写入原 SUM 格(或本行最右金额格),并清空同行其它多余数字。 + */ + static void overwriteEstimateTotal(Workbook wb, JSONObject facts) { + if (wb == null || facts == null) { + return; + } + String total = firstFact(facts, "total_amount", "total", "报价合计", "预估费用", "预估总价"); + if (StringUtils.isBlank(total) || "-".equals(total.trim())) { + return; + } + String norm = stripMoneyPrefix(total.trim()); + Double num; + try { + num = Double.parseDouble(norm.replace(",", "")); + } catch (NumberFormatException e) { + num = null; + } + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + for (int r = 0; r <= sheet.getLastRowNum(); r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + int labelCol = -1; + for (Cell cell : row) { + if (cell == null) { + continue; + } + String t = StringUtils.trimToEmpty(readCellText(cell)); + if (t.contains("预估费用") || t.contains("预估总价") || t.contains("费用总计") + || t.contains("报价合计") || t.contains("总销售价")) { + labelCol = cell.getColumnIndex(); + break; + } + } + if (labelCol < 0) { + continue; + } + Cell target = null; + List extras = new ArrayList<>(); + short last = row.getLastCellNum(); + for (int c = labelCol + 1; c < last; c++) { + int[] dest = resolveMergeOrigin(sheet, r, c); + Cell cell = row.getCell(dest[1]); + if (cell == null) { + continue; + } + String exist = readCellText(cell); + if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cell)) { + continue; + } + if (cell.getCellType() == CellType.FORMULA) { + if (target == null) { + target = cell; + } else { + extras.add(cell); + } + continue; + } + if (cell.getCellType() == CellType.NUMERIC + || looksLikeMoneyText(exist)) { + extras.add(cell); + } + } + if (target == null && !extras.isEmpty()) { + // 无公式时取最右金额格作为唯一总价格 + target = extras.remove(extras.size() - 1); + } + if (target == null) { + // 标签右侧尚无格:落在金额列常见位置(与费目表头对齐时由调用方保证) + target = row.getCell(labelCol + 1); + if (target == null) { + target = row.createCell(labelCol + 1); + } + } + if (num != null) { + target.setCellValue(num.doubleValue()); + } else { + target.setCellValue(norm); + } + for (Cell extra : extras) { + if (extra == null || extra == target) { + continue; + } + // POI 旧版无 setBlank;清空即可,避免同行残留第二份总价 + extra.setCellValue(""); + } + } + } + } + + /** 是否像金额文本(含小数或纯数字),用于清掉预估费用行上误写的第二份数。 */ + private static boolean looksLikeMoneyText(String raw) { + if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) { + return false; + } + String t = stripMoneyPrefix(raw.trim()).replace(",", ""); + if (!t.matches("-?\\d+(\\.\\d+)?")) { + return false; + } + try { + Double.parseDouble(t); + return true; + } catch (NumberFormatException e) { + return false; + } + } + /** * 回填后重算公式,把 SUM 等缓存从 0 刷成真实合计。 * 失败不抛:出单仍要发出,只记日志。 @@ -1922,7 +2097,8 @@ public final class QuoteTemplateExcelSupport { } writeMappedText(sheet, r, cols.get("currency"), lineCurrency, yellow); writeMappedText(sheet, r, cols.get("unit"), rowObj.getString("unit"), yellow); - writeMappedNumber(sheet, r, qtyCol, rowObj.getString("quantity"), yellow); + writeMappedNumber(sheet, r, qtyCol, + feeQuantityOrDefault(rowObj.getString("quantity"), rowObj.getString("name")), yellow); writeMappedNumber(sheet, r, cols.get("unit_price"), rowObj.getString("unit_price"), yellow); writeMappedText(sheet, r, cols.get("remark"), formatFeeRemark(rowObj.getString("remark")), yellow); Integer boxCol = pickContainerRateColumn(cols, boxType); @@ -2552,6 +2728,10 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(code)) { continue; } + // 合计由 overwriteEstimateTotal 独占写入,避免标签旁与 SUM 格各写一份 + if ("total_amount".equals(code)) { + continue; + } if ("currency".equals(code) && feeHeader) { continue; } @@ -2635,13 +2815,21 @@ public final class QuoteTemplateExcelSupport { if (dict.containsKey(primary)) { return dict.get(primary); } + // 模板常写成「重 量:」「体 积:」,去空白后再对 + String compact = primary.replace(":", "").replaceAll("\\s+", ""); + if (dict.containsKey(compact)) { + return dict.get(compact); + } String noColon = primary.replace(":", "").trim(); for (Map.Entry e : dict.entrySet()) { - String dk = e.getKey().replace(":", ":").replace(":", "").trim(); - if (dk.length() < 3 || noColon.length() < 3) { + String dk = e.getKey().replace(":", ":").replace(":", "").replaceAll("\\s+", "").trim(); + if (dk.length() < 2 || compact.length() < 2) { continue; } - if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) { + if (dk.equalsIgnoreCase(compact) || compact.equals(dk)) { + return e.getValue(); + } + if (dk.length() >= 3 && (compact.startsWith(dk) || dk.startsWith(compact))) { return e.getValue(); } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index e547d3b..49bf59d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -792,6 +792,9 @@ public class InquiryTmsQueryService { Map extras = new LinkedHashMap<>(); putExtra(extras, "unit", firstText(ch.get("unit"), ch.get("unitName"))); putExtra(extras, "unit_price", unitPrice); + // 固定费无数量时默认 1;运输费 enrich 会改成计费重 + String qty = firstText(ch.get("quantity"), ch.get("chargeQuantity"), ch.get("charge_quantity")); + putExtra(extras, "quantity", StringUtils.isBlank(qty) ? "1" : qty); putExtra(extras, "project", isAirWeightFeeName(name) ? "空运段" : "起运港"); // 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重 addFeeLine(out, name, unitPrice, lineCcy, extras); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizer.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizer.java index 367bf68..3adbff8 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizer.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizer.java @@ -10,11 +10,12 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * 海运出站前把销售口语整理成 TMS V2 字段。 + * 海运出站字段整理。 *

- * 对照接口文档 6.3:FCL 必须带 containerType + containerQty(如 40HQ / 1), - * loadType 只能是 FCL/LCL,cargoQty 必须是数字。销售卡片仍看原文「整柜 / 1x40HQ / 15件」。 - * 禁止:把口语原样扔给 TMS;在这里猜港口。 + * 产品口径(与陆运一致):整柜/拼柜等业务枚举不再翻成 FCL/LCL,销售原文直传。 + * 箱型箱量、货量仍只做「去单位 / 拆箱型」的格式整理(20GP*1→20GP+1、15件→15), + * 避免带「件」「吨」触发 TMS 1001;这不是中英字典映射。 + * 禁止:在这里猜港口。 */ public final class SeaTmsFieldNormalizer { @@ -38,7 +39,7 @@ public final class SeaTmsFieldNormalizer { } /** - * 整柜/拼柜 → FCL/LCL。已经是 FCL/LCL 的原样(大写)。 + * 整柜/拼柜原文直传(trim)。已是 FCL/LCL 的旧快照也原样放过,不做双向翻译。 */ public static String loadType(Object raw) { if (raw == null) { @@ -48,14 +49,7 @@ public final class SeaTmsFieldNormalizer { if (text.isEmpty()) { return null; } - String upper = text.toUpperCase(Locale.ROOT); - if ("FCL".equals(upper) || text.contains("整柜") || text.contains("整箱")) { - return "FCL"; - } - if ("LCL".equals(upper) || text.contains("拼柜") || text.contains("拼箱")) { - return "LCL"; - } - return upper; + return text; } /** @@ -121,7 +115,7 @@ public final class SeaTmsFieldNormalizer { } /** - * 口语箱型落到 TMS 码:40HQ / 40GP / 20GP / 45HQ。 + * 箱型尺码落到标准码:40HQ / 40GP / 20GP / 45HQ(从销售原文拆出,非中英字典表)。 */ static String toTmsType(String size, String suffix) { if (size == null) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java index b8997fd..e010535 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapper.java @@ -1,68 +1,37 @@ package org.jeecg.modules.inquiry.tms; -import java.util.LinkedHashMap; -import java.util.Map; - /** - * 陆运 TMS 6.1.1 枚举:销售中文 → 出站码。 + * 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。 *

- * 职责:只在打 TMS 前翻译运输类型、线路类别。工单/卡片/拉人仍用中文。 - * 禁止:把运输分类、中港装载方式译进出站;发明表外码。 + * 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。 + * 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。 + * 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。 */ public final class TmsLandEnumMapper { - private static final Map TYPE = new LinkedHashMap(); - private static final Map ROUTE = new LinkedHashMap(); - - static { - TYPE.put("国内运输拼车", "DOMESTIC_LTL"); - TYPE.put("国内运输整车", "DOMESTIC_FTL"); - TYPE.put("跨境集拼", "CROSS_BORDER_LTL"); - TYPE.put("跨境整车", "CROSS_BORDER_FTL"); - TYPE.put("中港整车", "CHINA_HK"); - TYPE.put("中港零担/集拼", "CHINA_HK"); - TYPE.put("中港", "CHINA_HK"); - TYPE.put("DOMESTIC_LTL", "DOMESTIC_LTL"); - TYPE.put("DOMESTIC_FTL", "DOMESTIC_FTL"); - TYPE.put("CROSS_BORDER_LTL", "CROSS_BORDER_LTL"); - TYPE.put("CROSS_BORDER_FTL", "CROSS_BORDER_FTL"); - TYPE.put("CHINA_HK", "CHINA_HK"); - ROUTE.put("国内长途/零担", "DOMESTIC_LONG_LTL"); - ROUTE.put("国内城配/拖车/打包", "DOMESTIC_CITY_DIST"); - ROUTE.put("中港/中亚/中欧", "CN_HK_CA_EU"); - ROUTE.put("东南亚", "SOUTHEAST_ASIA"); - ROUTE.put("DOMESTIC_LONG_LTL", "DOMESTIC_LONG_LTL"); - ROUTE.put("DOMESTIC_CITY_DIST", "DOMESTIC_CITY_DIST"); - ROUTE.put("CN_HK_CA_EU", "CN_HK_CA_EU"); - ROUTE.put("SOUTHEAST_ASIA", "SOUTHEAST_ASIA"); - } - private TmsLandEnumMapper() { } /** - * @param raw 销售中文或已是 TMS 码 - * @return 6.1.1 landTransportType;对不上返回空串,禁止用中文顶上 + * @param raw 销售中文(或历史英文码) + * @return trim 后原文;空则 "" */ public static String transportType(String raw) { - if (raw == null) { - return ""; - } - String key = raw.trim(); - String code = TYPE.get(key); - return code == null ? "" : code; + return trimOrEmpty(raw); } /** - * @param raw 销售中文或已是 TMS 码 - * @return 6.1.1 routeCategory;对不上返回空串 + * @param raw 销售中文(或历史英文码) + * @return trim 后原文;空则 "" */ public static String routeCategory(String raw) { + return trimOrEmpty(raw); + } + + private static String trimOrEmpty(String raw) { if (raw == null) { return ""; } - String key = raw.trim(); - String code = ROUTE.get(key); - return code == null ? "" : code; + return raw.trim(); } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java index 9f79b7c..bd42efb 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java @@ -18,9 +18,9 @@ import java.util.regex.Pattern; * 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。 * 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数; * 中港整车不带车型/数量。通关口岸有值才放。 - * 中港整车、中港零担/集拼出站类型都是 CHINA_HK。 - * 类型/线路出站必须是 TMS 6.1.1 英文枚举,线路键名 routeCategory。 - * 禁止:按空运要包装件数;把分类当查价条件;直连 TMS。 + * 中港整车、中港零担/集拼:出站类型仍用销售中文「中港整车」「中港零担/集拼」(产品要求不做英文枚举映射)。 + * 类型/线路键名仍是 landTransportType / routeCategory,值用中文原文。 + * 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。 */ public final class TmsLandSegmentMapper { @@ -91,15 +91,19 @@ public final class TmsLandSegmentMapper { missing.add(key); } } - String typeEnum = text(seg.get("landTransportType")); + // 出站已改为中文原文;拦网同时认中文集合与历史英文码,避免旧快照误拦。 + String typeOnSeg = text(seg.get("landTransportType")); String cnType = text(facts == null ? null : facts.get("运输类型")); boolean lcl = LCL.contains(cnType) - || "DOMESTIC_LTL".equals(typeEnum) - || "CROSS_BORDER_LTL".equals(typeEnum); - boolean hkFull = "中港整车".equals(cnType); + || LCL.contains(typeOnSeg) + || "DOMESTIC_LTL".equals(typeOnSeg) + || "CROSS_BORDER_LTL".equals(typeOnSeg); + boolean hkFull = "中港整车".equals(cnType) || "中港整车".equals(typeOnSeg); boolean ftl = FTL.contains(cnType) - || "DOMESTIC_FTL".equals(typeEnum) - || "CROSS_BORDER_FTL".equals(typeEnum) + || FTL.contains(typeOnSeg) + || "DOMESTIC_FTL".equals(typeOnSeg) + || "CROSS_BORDER_FTL".equals(typeOnSeg) + || "CHINA_HK".equals(typeOnSeg) || "中港零担/集拼".equals(cnType); if (lcl) { if (seg.get("volumeCbm") == null) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java index 48f691c..6cd55e0 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java @@ -92,8 +92,8 @@ public final class TmsV2RequestMapper { put(target, "packageType", first(source, "packageType", "packagingType")); // 空运选填:有值才带。贸易条款不进出站,只给后台模板关键词匹配。 applyAirOptionalFields(source, target); - // 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL, - // cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。 + // 海运出站:货量去单位、箱型拆码;整柜/拼柜改为原文直传(不再 FCL/LCL)。 + // FCL 要 containerType+containerQty;销售侧「整柜 / 1x40HQ / 15件」卡片仍看原文。 applySeaFields(source, target); applyLandFields(source, target); return compact(target); @@ -144,8 +144,8 @@ public final class TmsV2RequestMapper { } /** - * 陆运出站:类型+线路用 6.1.1 枚举;线路字段名是 routeCategory。 - * 分类和中港装载即使误放在 source 也不抄走。中文不得原样出站。 + * 陆运出站:类型+线路用销售中文原文(产品要求不做英文枚举映射);线路字段名是 routeCategory。 + * 分类和中港装载即使误放在 source 也不抄走。 */ private static void applyLandFields(Map source, Map target) { String type = textOf(first(source, "landTransportType")); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java index e7fd585..6cfc9ff 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java @@ -221,8 +221,8 @@ public class QuoteTemplateFillTest { } /** - * 空运模板「预估总价」是 SUM。回填后必须把公式缓存刷成合计, - * 否则企微/WPS 预览仍显示模板里的 0。 + * 空运模板「预估总价」原是 SUM。有 total_amount(TMS 总销售价)时改写成该数, + * 避免刷公式后把固定费加进去、或标签旁再写一份形成两个总价。 */ @Test public void refreshSumCacheAfterWritingFeeAmount() throws Exception { @@ -260,9 +260,7 @@ public class QuoteTemplateFillTest { QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); try (Workbook formulas = new XSSFWorkbook(Files.newInputStream(out))) { org.apache.poi.ss.usermodel.Cell sum = formulas.getSheetAt(0).getRow(22).getCell(6); - assertEquals(CellType.FORMULA, sum.getCellType()); - assertEquals("SUM(G17:G22)", sum.getCellFormula()); - // 公式格的数字缓存 = 企微预览看到的值;没刷缓存会是 0 + assertEquals(CellType.NUMERIC, sum.getCellType()); assertEquals(14669.60, sum.getNumericCellValue(), 0.01); assertEquals("单价", formulas.getSheetAt(0).getRow(15).getCell(4).getStringCellValue()); } finally { @@ -271,6 +269,82 @@ public class QuoteTemplateFillTest { } } + /** + * 空运段 + 起运港固定费时:SUM 会大于 TMS 总销售价; + * 预估费用行只能出现 total_amount 一个数,不能标签旁与金额列各一份。 + */ + @Test + public void estimateTotalKeepsSingleTmsAmount() throws Exception { + Path src = Files.createTempFile("quote-est-src", ".xlsx"); + Path out = Files.createTempFile("quote-est-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("报价"); + org.apache.poi.ss.usermodel.Row head = sheet.createRow(10); + head.createCell(0).setCellValue("项目"); + head.createCell(1).setCellValue("费用名称"); + head.createCell(6).setCellValue("金额"); + sheet.createRow(11).createCell(1).setCellValue("运输费"); + sheet.createRow(12).createCell(1).setCellValue("提送货费"); + sheet.createRow(13).createCell(1).setCellValue("人工费"); + org.apache.poi.ss.usermodel.Row total = sheet.createRow(15); + total.createCell(0).setCellValue("预估费用"); + total.createCell(6).setCellFormula("SUM(G12:G14)"); + 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("total_amount", "183370.00"); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[" + + "{\"name\":\"运输费\",\"amount\":\"183370.00\",\"currency\":\"CNY\",\"unit\":\"KG\",\"quantity\":\"16670\",\"unit_price\":\"11\"}," + + "{\"name\":\"提送货费\",\"amount\":\"6.00\",\"currency\":\"CNY\",\"unit\":\"板数\",\"unit_price\":\"6\"}," + + "{\"name\":\"人工费\",\"amount\":\"123.00\",\"currency\":\"CNY\",\"unit\":\"柜\",\"unit_price\":\"123\"}" + + "]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) { + org.apache.poi.ss.usermodel.Row row = got.getSheetAt(0).getRow(15); + int numericCount = 0; + Double only = null; + for (int c = 1; c < row.getLastCellNum(); c++) { + org.apache.poi.ss.usermodel.Cell cell = row.getCell(c); + if (cell == null) { + continue; + } + if (cell.getCellType() == CellType.NUMERIC) { + numericCount++; + only = cell.getNumericCellValue(); + } else if (cell.getCellType() == CellType.FORMULA) { + numericCount++; + only = cell.getNumericCellValue(); + } else if (cell.getCellType() == CellType.STRING + && cell.getStringCellValue() != null + && cell.getStringCellValue().trim().matches("-?\\d+(\\.\\d+)?")) { + numericCount++; + only = Double.parseDouble(cell.getStringCellValue().trim()); + } + } + assertEquals(1, numericCount); + assertEquals(183370.00, only, 0.01); + assertEquals(CellType.NUMERIC, row.getCell(6).getCellType()); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + @Test + public void feeQuantityDefaultsToOneWhenBlank() { + assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "提送货费")); + assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault("", "人工费")); + assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault("-", "操作费")); + assertEquals("", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "运输费")); + assertEquals("", QuoteTemplateExcelSupport.feeQuantityOrDefault("", "空运费")); + assertEquals("16670", QuoteTemplateExcelSupport.feeQuantityOrDefault("16670", "运输费")); + } + /** * 出单后必须 A4 水平居中,抬头行高够放下中英文公司名。 * 否则转 PDF 会顶部裁字、整页偏左。 diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizerTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizerTest.java index ddcd8e8..6accd02 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizerTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizerTest.java @@ -15,9 +15,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class SeaTmsFieldNormalizerTest { @Test - public void loadTypeFclLcl() { - assertEquals("FCL", SeaTmsFieldNormalizer.loadType("整柜")); - assertEquals("LCL", SeaTmsFieldNormalizer.loadType("拼柜")); + public void loadTypePassesChinese() { + assertEquals("整柜", SeaTmsFieldNormalizer.loadType("整柜")); + assertEquals("拼柜", SeaTmsFieldNormalizer.loadType("拼柜")); assertEquals("FCL", SeaTmsFieldNormalizer.loadType("FCL")); } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java index 9944f23..8264cee 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandEnumMapperTest.java @@ -5,24 +5,30 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; /** - * 陆运 TMS 枚举:销售中文不得原样出站。不打真实 TMS。 + * 陆运类型/线路:产品要求原文直传,不再翻英文枚举。 */ public class TmsLandEnumMapperTest { @Test - public void pincheCityDistMatchesTms61() { - assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("国内运输拼车")); - assertEquals("DOMESTIC_CITY_DIST", TmsLandEnumMapper.routeCategory("国内城配/拖车/打包")); + public void transportTypePassesChinese() { + assertEquals("国内运输拼车", TmsLandEnumMapper.transportType("国内运输拼车")); + assertEquals("跨境整车", TmsLandEnumMapper.transportType(" 跨境整车 ")); } @Test - public void hkSplitTypesBothMapChinaHk() { - assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港整车")); - assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港零担/集拼")); + public void routeCategoryPassesChinese() { + assertEquals("国内城配/拖车/打包", TmsLandEnumMapper.routeCategory("国内城配/拖车/打包")); + assertEquals("东南亚", TmsLandEnumMapper.routeCategory("东南亚")); } @Test - public void alreadyEnumStays() { + public void blankBecomesEmpty() { + assertEquals("", TmsLandEnumMapper.transportType(null)); + assertEquals("", TmsLandEnumMapper.routeCategory(" ")); + } + + @Test + public void legacyEnglishCodesPassThrough() { assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL")); assertEquals("SOUTHEAST_ASIA", TmsLandEnumMapper.routeCategory("SOUTHEAST_ASIA")); } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java index 5f318ee..687a919 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapperTest.java @@ -27,8 +27,8 @@ public class TmsLandSegmentMapperTest { facts.put("毛重", "50"); facts.put("报价日期", "2026-09-17"); Map seg = TmsLandSegmentMapper.build(facts); - assertEquals("CROSS_BORDER_LTL", seg.get("landTransportType")); - assertEquals("SOUTHEAST_ASIA", seg.get("routeCategory")); + assertEquals("跨境集拼", seg.get("landTransportType")); + assertEquals("东南亚", seg.get("routeCategory")); assertFalse(seg.containsKey("landRouteCategory")); assertNull(seg.get("landClass")); assertFalse(seg.containsKey("hkLoadType")); @@ -46,7 +46,7 @@ public class TmsLandSegmentMapperTest { facts.put("车型/数量", "9米6/1"); facts.put("体积", "99"); Map seg = TmsLandSegmentMapper.build(facts); - assertEquals("CHINA_HK", seg.get("landTransportType")); + assertEquals("中港零担/集拼", seg.get("landTransportType")); assertEquals("9米6", seg.get("vehicleType")); assertNull(seg.get("volumeCbm")); assertFalse(seg.containsKey("hkLoadType")); @@ -62,7 +62,7 @@ public class TmsLandSegmentMapperTest { facts.put("目的港", "香港"); facts.put("品名", "衣服"); Map seg = TmsLandSegmentMapper.build(facts); - assertEquals("CHINA_HK", seg.get("landTransportType")); + assertEquals("中港整车", seg.get("landTransportType")); assertNull(seg.get("vehicleType")); assertNull(seg.get("quantity")); java.util.List missing = TmsLandSegmentMapper.missingQuery( @@ -85,7 +85,7 @@ public class TmsLandSegmentMapperTest { } @Test - public void wo011PincheCityDistUsesTmsEnums() { + public void wo011PincheCityDistPassesChinese() { Map facts = new HashMap(); facts.put("运输类型", "国内运输拼车"); facts.put("线路类别", "国内城配/拖车/打包"); @@ -95,8 +95,8 @@ public class TmsLandSegmentMapperTest { facts.put("体积", "10CBM"); facts.put("毛重", "10KG"); Map seg = TmsLandSegmentMapper.build(facts); - assertEquals("DOMESTIC_LTL", seg.get("landTransportType")); - assertEquals("DOMESTIC_CITY_DIST", seg.get("routeCategory")); + assertEquals("国内运输拼车", seg.get("landTransportType")); + assertEquals("国内城配/拖车/打包", seg.get("routeCategory")); assertFalse(seg.containsKey("landRouteCategory")); java.util.List missing = TmsLandSegmentMapper.missingQuery( "WO202609170011", "2026-09-17", seg, facts); @@ -116,8 +116,8 @@ public class TmsLandSegmentMapperTest { Map seg = TmsLandSegmentMapper.build(facts); java.util.List missing = TmsLandSegmentMapper.missingQuery( "WO202609170005", "2026-09-17", seg); - assertEquals("DOMESTIC_LTL", seg.get("landTransportType")); - assertEquals("DOMESTIC_LONG_LTL", seg.get("routeCategory")); + assertEquals("国内运输拼车", seg.get("landTransportType")); + assertEquals("国内长途/零担", seg.get("routeCategory")); assertNull(seg.get("quantity")); assertEquals(java.util.Collections.emptyList(), missing); } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java index 2bec94d..f0ce881 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java @@ -79,7 +79,7 @@ public class TmsV2RequestMapperTest { @SuppressWarnings("unchecked") List> segs = (List>) body.get("segments"); assertEquals(new java.math.BigDecimal("20"), segs.get(0).get("cargoQty")); - assertEquals("FCL", segs.get(0).get("loadType")); + assertEquals("整柜", segs.get(0).get("loadType")); assertEquals(items, segs.get(0).get("containerItems")); assertFalse(segs.get(0).containsKey("quantity")); assertFalse(segs.get(0).containsKey("weightKg")); @@ -102,7 +102,7 @@ public class TmsV2RequestMapperTest { @SuppressWarnings("unchecked") List> segs = (List>) body.get("segments"); assertEquals(new java.math.BigDecimal("15"), segs.get(0).get("cargoQty")); - assertEquals("FCL", segs.get(0).get("loadType")); + assertEquals("整柜", segs.get(0).get("loadType")); assertEquals("40HQ", segs.get(0).get("containerType")); assertEquals(Integer.valueOf(1), segs.get(0).get("containerQty")); } @@ -126,8 +126,8 @@ public class TmsV2RequestMapperTest { Map body = TmsV2RequestMapper.quote(internal); @SuppressWarnings("unchecked") List> segs = (List>) body.get("segments"); - assertEquals("CROSS_BORDER_LTL", segs.get(0).get("landTransportType")); - assertEquals("SOUTHEAST_ASIA", segs.get(0).get("routeCategory")); + assertEquals("跨境集拼", segs.get(0).get("landTransportType")); + assertEquals("东南亚", segs.get(0).get("routeCategory")); assertFalse(segs.get(0).containsKey("landRouteCategory")); assertFalse(segs.get(0).containsKey("landClass")); }