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 7625136..223f882 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 @@ -235,6 +235,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl> listEnabledForAgent(boolean enabledOnly) { QueryWrapper qw = new QueryWrapper<>(); @@ -750,13 +759,16 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl e : QuoteTemplateExcelSupport.LAND_TMS_KEY_TO_CODE.entrySet()) { String val = first(q, e.getKey(), e.getValue()); if (StringUtils.isNotBlank(val) && !"-".equals(val) && StringUtils.isBlank(out.getString(e.getValue()))) { @@ -1080,8 +1104,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl> lineMaps = new ArrayList<>(); for (int i = 0; i < rows.size(); i++) { JSONObject one = rows.getJSONObject(i); @@ -1097,8 +1121,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl m : lineMaps) { // Fastjson 只要 Map,不能直接塞 Map。 @@ -1573,7 +1604,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl - * stopAtText 未命中时 {@link #findStopRow} 会回 Integer.MAX_VALUE。 - * 若原样循环清空,会捅到 Excel 最大行(1048576)并抛 IllegalArgumentException,整张出单失败。 - * 因此未命中时只清到表内已有末行,绝不 createRow 扩到上限。 + * 只动空运运输费行,不起运港操作费/文件费等(那些不是按毛重计价)。 + * 副作用:写 Excel 单位格。线程:调用方线程。 + */ + static int fillAirWeightUnitOnTemplateRows(Sheet sheet, int startRow, int stopRow, JSONObject columns, + JSONObject facts, Set yellow) { + if (sheet == null || columns == null || startRow < 0) { + return 0; + } + String unitCol = columns.getString("unit"); + String nameCol = columns.getString("name"); + if (StringUtils.isBlank(unitCol) || StringUtils.isBlank(nameCol)) { + return 0; + } + String weightUnit = resolveWeightUnitFromFacts(facts); + int lastExisting = sheet.getLastRowNum(); + int end = stopRow; + if (end == Integer.MAX_VALUE || end > lastExisting + 1) { + end = lastExisting + 1; + } + end = Math.min(end, 1048575 + 1); + if (startRow >= end) { + return 0; + } + int nameIdx = CellReference.convertColStringToIndex(nameCol.trim()); + int unitIdx = CellReference.convertColStringToIndex(unitCol.trim()); + int filled = 0; + for (int r = startRow; r < end; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + String name = readCellText(row.getCell(nameIdx)); + if (!isAirWeightFeeName(name)) { + continue; + } + String existing = readCellText(row.getCell(unitIdx)); + if (StringUtils.isNotBlank(existing) && !"-".equals(existing.trim())) { + continue; + } + writeFeeCol(sheet, r, unitCol, weightUnit, yellow, sheet.getSheetName()); + filled++; + } + return filled; + } + + /** + * 从填表事实取重量单位:显式字段优先,其次毛重/重量原文里的 KG/公斤,默认 KG。 + */ + static String resolveWeightUnitFromFacts(JSONObject facts) { + if (facts == null) { + return "KG"; + } + String raw = firstNonBlank(facts.getString("weight_unit"), facts.getString("重量单位"), + facts.getString("weightUnit"), facts.getString("毛重单位")); + if (StringUtils.isBlank(raw)) { + String w = firstNonBlank(facts.getString("毛重"), facts.getString("重量"), + facts.getString("重量(KG)"), facts.getString("weight_kg")); + if (StringUtils.isNotBlank(w) && w.matches("(?i).*\\b(KG|KGS|公斤)\\b.*")) { + raw = "KG"; + } + } + return normalizeWeightUnit(raw); + } + + /** + * 清空 [fromRow, stopRow) 费用列。 + *

+ * 项目列「空运段/起运港/目的港/其他」是模板骨架,禁止擦掉。 + * {@code clearSampleRemarks=true} 时连备注样例(含黄底「发生时收取」)一并清掉, + * 实现空运到门与到港一致的「有费用的才显示行」。 + *

+ * stopAtText 未命中时 {@link #findStopRow} 会回 Integer.MAX_VALUE; + * 未命中时只清到表内已有末行,绝不 createRow 扩到上限。 */ private static void clearFeeRowsAfter(Sheet sheet, int fromRow, int stopRow, JSONObject columns, Set yellow, String sheetName) { + clearFeeRowsAfter(sheet, fromRow, stopRow, columns, yellow, sheetName, false); + } + + private static void clearFeeRowsAfter(Sheet sheet, int fromRow, int stopRow, JSONObject columns, + Set yellow, String sheetName, boolean clearSampleRemarks) { if (sheet == null || columns == null || fromRow < 0) { return; } @@ -2323,19 +2454,104 @@ public final class QuoteTemplateExcelSupport { if (fromRow >= end) { return; } - // 备注列常黄底「发生时收取」:不清 remark,只清费用数据列;黄格由 writeFeeCol 跳过 - String[] keys = {"project", "name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"}; + // 有费才出行时:映射若缺备注列,按表头补上,否则「发生时收取」清不掉 + if (clearSampleRemarks && StringUtils.isBlank(columns.getString("remark"))) { + Map header = findFeeTableColumnsAbove(sheet, Math.max(fromRow, 0)); + if (header == null || header.isEmpty()) { + header = findFeeTableColumns(sheet); + } + if (header != null && header.get("remark") != null) { + columns.put("remark", CellReference.convertNumToColString(header.get("remark"))); + } + } + int projectCol = -1; + if (StringUtils.isNotBlank(columns.getString("project"))) { + projectCol = CellReference.convertColStringToIndex(columns.getString("project").trim()); + } + // 项目列保留骨架;费用名/金额等可清;有费才出行时备注样例也清。 + String[] keys = clearSampleRemarks + ? new String[]{"name", "unit", "quantity", "unit_price", "amount", "currency", + "container_rate", "remark"} + : new String[]{"name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"}; for (int r = fromRow; r < end; r++) { for (String key : keys) { String colLetter = columns.getString(key); if (StringUtils.isBlank(colLetter)) { continue; } - writeFeeCol(sheet, r, colLetter, "", yellow, sheetName); + int col = CellReference.convertColStringToIndex(colLetter.trim()); + // 绝对不清项目列(含误配成 name=A 的情况) + if (projectCol >= 0 && col == projectCol) { + continue; + } + int[] origin = resolveMergeOrigin(sheet, r, col); + // 合并区顶落在已写费用行:禁止经 origin 误清 + if (origin[0] < fromRow) { + continue; + } + // 合并原点若落在项目列(到门 A18:A21 起运港),只清本格,绝不擦骨架 + if (projectCol >= 0 && origin[1] == projectCol) { + origin = new int[]{r, col}; + } + if (clearSampleRemarks) { + // 黄底样例备注/费目名也要清,否则到门模板仍像「有一行费用」 + forceBlankFeeCell(sheet, origin[0], origin[1], projectCol); + } else { + writeFeeCol(sheet, r, colLetter, "", yellow, sheetName); + } } } } + /** + * 强制写入项目骨架文案(空运段/起运港/目的港/其他),绕过黄底与 writeFeeCol 保护。 + * 用于「增点费」落到模板首行时把「空运段」改成「起运港」。 + */ + private static void forceWriteProjectSkeleton(Sheet sheet, int rowIdx, int colIdx, String value) { + if (sheet == null || rowIdx < 0 || colIdx < 0 || StringUtils.isBlank(value)) { + return; + } + Row row = sheet.getRow(rowIdx); + if (row == null) { + row = sheet.createRow(rowIdx); + } + Cell cell = row.getCell(colIdx); + if (cell == null) { + cell = row.createCell(colIdx); + } + cell.setCellValue(value); + } + + /** + * 强制清空费用格(绕过黄底保护)。仅用于清模板样例占位,不用于写业务值。 + * + * @param projectCol 项目列索引;该列及「空运段/起运港/目的港/其他」文案禁止清空 + */ + private static void forceBlankFeeCell(Sheet sheet, int rowIdx, int colIdx, int projectCol) { + if (sheet == null || rowIdx < 0 || rowIdx > 1048575 || colIdx < 0) { + return; + } + if (projectCol >= 0 && colIdx == projectCol) { + return; + } + Row row = sheet.getRow(rowIdx); + if (row == null) { + return; + } + Cell cell = row.getCell(colIdx); + if (cell == null) { + return; + } + String exist = readCellText(cell); + // 项目骨架与表头/章节标题不碰 + if (isSectionTitle(sheet, cell) || isFeeTableHeader(exist) + || "空运段".equals(exist) || "起运港".equals(exist) || "目的港".equals(exist) + || "其他".equals(exist) || "其它".equals(exist)) { + return; + } + cell.setBlank(); + } + /** * 把费用区表头「箱型(动态填充箱型)」替换为真实箱型码(20GP / 40HQ…)。 */ @@ -4837,7 +5053,9 @@ public final class QuoteTemplateExcelSupport { boolean needPrice = StringUtils.isBlank(out.getString("unit_price")); boolean needQty = StringUtils.isBlank(out.getString("quantity")); boolean needProject = StringUtils.isBlank(out.getString("project")); - if (!needUnit && !needPrice && !needQty && !needProject) { + boolean needRemark = StringUtils.isBlank(out.getString("remark")); + if (!needUnit && !needPrice && !needQty && !needProject && !needRemark + && StringUtils.isNotBlank(out.getString("name"))) { return out; } Map header = findFeeTableColumnsAbove(sheet, Math.max(dataStartRow, 0)); @@ -4859,6 +5077,9 @@ public final class QuoteTemplateExcelSupport { if (needProject && header.get("project") != null) { out.put("project", CellReference.convertNumToColString(header.get("project"))); } + if (needRemark && header.get("remark") != null) { + out.put("remark", CellReference.convertNumToColString(header.get("remark"))); + } if (StringUtils.isBlank(out.getString("name")) && header.get("name") != null) { out.put("name", CellReference.convertNumToColString(header.get("name"))); } @@ -4867,7 +5088,11 @@ public final class QuoteTemplateExcelSupport { /** * 空运 airOptions[].departureCharges → 费用行(出单用)。 + *

* 已落库 payload 常把 airOptions 存成 JSON 字符串。 + * TMS 常把「空运费」只放在舱位 salePrice/unitPrice,departureCharges 只有增点费/拆板费; + * 若 firstNonNull 只取 departureCharges,出单会缺空运费,且模板样例「空运段|增点费」会残留。 + * 这里合并 departureCharges+feeItems,缺运费时用舱位价注入「空运费」,并保证项目列归类。 */ public static JSONArray parseAirDepartureChargeRows(Object airOptions, Object airOptionsAlt, String chargeableWeightKg, String headerCurrency) { @@ -4883,9 +5108,11 @@ public final class QuoteTemplateExcelSupport { if (opt == null) { continue; } - JSONArray charges = parseFeeRows(firstNonNull(opt.get("departureCharges"), opt.get("departure_charges"), - opt.get("charges"), opt.get("feeItems"))); + // 合并起运附加费与 feeItems,禁止 firstNonNull 只吃一边丢掉空运费 + JSONArray charges = mergeAirOptionChargeArrays(opt); String optCcy = firstNonBlank(opt.getString("currency"), headerCurrency); + String optCw = firstNonBlank(opt.getString("chargeableWeightKg"), opt.getString("chargeable_weight_kg"), + chargeableWeightKg); for (int c = 0; c < charges.size(); c++) { JSONObject ch = charges.getJSONObject(c); if (ch == null) { @@ -4921,17 +5148,113 @@ public final class QuoteTemplateExcelSupport { } lines.add(line); } + // departureCharges 只有附加费时,用舱位 salePrice/unitPrice 补「空运费」行 + injectAirFreightLineIfMissing(lines, opt, optCw, optCcy); if (!lines.isEmpty()) { break; } } enrichAirWeightFeeRows(lines, chargeableWeightKg); + // 空运段(运费)在前,起运港附加费在后,避免盖住模板首行样例「空运段」 + lines.sort((a, b) -> { + boolean aAir = a != null && isAirWeightFeeName(a.get("name")); + boolean bAir = b != null && isAirWeightFeeName(b.get("name")); + if (aAir == bAir) { + return 0; + } + return aAir ? -1 : 1; + }); for (Map line : lines) { out.add(JSON.parseObject(JSON.toJSONString(line))); } return out; } + /** + * 合并舱位上的 departureCharges 与 feeItems(去重:同名只留先出现的)。 + * 副作用:无。线程:调用方线程。 + */ + static JSONArray mergeAirOptionChargeArrays(JSONObject opt) { + JSONArray out = new JSONArray(); + if (opt == null) { + return out; + } + Set seen = new HashSet<>(); + for (Object key : new Object[]{"departureCharges", "departure_charges", "charges", "feeItems", "fee_items"}) { + JSONArray part = parseFeeRows(opt.get(String.valueOf(key))); + for (int i = 0; i < part.size(); i++) { + JSONObject ch = part.getJSONObject(i); + if (ch == null) { + continue; + } + String name = firstNonBlank(ch.getString("chargeName"), ch.getString("feeName"), ch.getString("name")); + if (StringUtils.isBlank(name)) { + continue; + } + String dedupe = name.trim().toLowerCase(Locale.ROOT); + if (!seen.add(dedupe)) { + continue; + } + out.add(ch); + } + } + return out; + } + + /** + * 明细里没有运输/空运费时,用舱位 unitPrice×计费重或 salePrice 注入一行「空运费」。 + * WO202610090014:departureCharges 只有增点/拆板,salePrice=4750.95、计费重=500.1。 + */ + static void injectAirFreightLineIfMissing(List> lines, JSONObject opt, + String chargeableWeightKg, String currency) { + if (lines == null || opt == null) { + return; + } + for (Map line : lines) { + if (line != null && isAirWeightFeeName(line.get("name"))) { + return; + } + } + String unitPrice = firstNonBlank(opt.getString("unitPrice"), opt.getString("unit_price"), + opt.getString("pricePerKg"), opt.getString("freightRate")); + String salePrice = firstNonBlank(opt.getString("salePrice"), opt.getString("sale_price"), + opt.getString("totalSalePrice"), opt.getString("airFreight"), opt.getString("freight")); + String cw = StringUtils.trimToEmpty(chargeableWeightKg); + if (cw.isEmpty() || "-".equals(cw)) { + cw = ""; + } + String amount = ""; + String price = unitPrice; + if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(cw)) { + amount = multiplyDecimal(unitPrice, cw); + } else if (StringUtils.isNotBlank(salePrice) && !"-".equals(salePrice)) { + amount = salePrice; + if (StringUtils.isBlank(price) && StringUtils.isNotBlank(cw)) { + price = divideDecimal(salePrice, cw); + } + } + if (StringUtils.isBlank(amount) || "-".equals(amount)) { + return; + } + Map air = new LinkedHashMap<>(); + air.put("name", "空运费"); + air.put("project", "空运段"); + air.put("amount", amount); + if (StringUtils.isNotBlank(price)) { + air.put("unit_price", price); + } else { + air.put("unit_price", amount); + } + if (StringUtils.isNotBlank(cw)) { + air.put("quantity", cw); + air.put("unit", "KG"); + } + if (StringUtils.isNotBlank(currency)) { + air.put("currency", currency); + } + lines.add(0, air); + } + private static Object firstNonNull(Object... vals) { if (vals == null) { return null; @@ -4944,43 +5267,146 @@ public final class QuoteTemplateExcelSupport { return null; } + /** - * 空运运输费:单价 × 计费重 = 行金额。 - * 出单时调用;有单价+数量则重算金额(数值比较,避免 11.00≠11.0 漏算)。 + * 空运运输费:单价 × 计费重 = 行金额;缺单位时用询价重量单位(默认 KG)。 + *

+ * 手工报价常只有金额(WO020):无计费重时补 数量=1、单价=金额,避免单价/数量空白。 */ public static void enrichAirWeightFeeRows(List> lines, String chargeableWeightKg) { + enrichAirWeightFeeRows(lines, chargeableWeightKg, null); + } + + /** + * @param weightUnit 询价重量单位(如 KG/KGS/公斤);空则按 KG + */ + public static void enrichAirWeightFeeRows(List> lines, String chargeableWeightKg, + String weightUnit) { if (lines == null || lines.isEmpty()) { return; } String chargeable = StringUtils.trimToEmpty(chargeableWeightKg); - if (chargeable.isEmpty() || "-".equals(chargeable)) { - return; + if ("-".equals(chargeable)) { + chargeable = ""; } + String unitDefault = normalizeWeightUnit(weightUnit); for (Map line : lines) { if (line == null || !isAirWeightFeeName(line.get("name"))) { continue; } + // 无 TMS / 手工报价常缺单位:先补单位,再按计费重或金额补数量/单价 + if (StringUtils.isBlank(line.get("unit"))) { + line.put("unit", unitDefault); + } String unitPrice = firstNonBlank(line.get("unit_price"), line.get("unitPrice")); String qty = StringUtils.trimToEmpty(line.get("quantity")); String amount = StringUtils.trimToEmpty(line.get("amount")); - String unit = StringUtils.trimToEmpty(line.get("unit")); + if ("-".equals(qty)) { + qty = ""; + } + if ("-".equals(amount)) { + amount = ""; + } + if (chargeable.isEmpty()) { + // 无计费重:手工只有总额 → 数量 1、单价=金额(WO020 空运费) + if (qty.isEmpty() && !amount.isEmpty()) { + line.put("quantity", "1"); + qty = "1"; + } + if (unitPrice.isEmpty() && !amount.isEmpty()) { + if ("1".equals(qty) || qty.isEmpty()) { + line.put("unit_price", amount); + } else { + String divided = divideDecimal(amount, qty); + if (!divided.isEmpty()) { + line.put("unit_price", divided); + } else { + line.put("unit_price", amount); + } + } + } else if (!unitPrice.isEmpty() && qty.isEmpty()) { + line.put("quantity", "1"); + } + continue; + } if (qty.isEmpty()) { line.put("quantity", chargeable); qty = chargeable; } - // TMS 常给「板数」;按计费重计价统一 KG - line.put("unit", "KG"); + // 有计费重时统一按重量计价单位 + line.put("unit", unitDefault); if (unitPrice.isEmpty() && !amount.isEmpty()) { - line.put("unit_price", amount); - unitPrice = amount; + String divided = divideDecimal(amount, qty); + line.put("unit_price", StringUtils.isNotBlank(divided) ? divided : amount); + unitPrice = line.get("unit_price"); } if (!unitPrice.isEmpty() && !qty.isEmpty()) { String product = multiplyDecimal(unitPrice, qty); if (!product.isEmpty() && (amount.isEmpty() || !sameDecimal(amount, product))) { - line.put("amount", product); + // 已有明确总额时不要用单价×数量覆盖(手工总额优先) + if (amount.isEmpty()) { + line.put("amount", product); + } } } } + // 起运港固定费(增点费等):补单位/数量/单价 + enrichFixedAirAddonFeeRows(lines); + } + + /** + * 空运起运附加费(增点/操作/文件等):手工常只有金额。 + * 补单位=票、数量=1、单价=金额(WO020 增点费)。 + */ + public static void enrichFixedAirAddonFeeRows(List> lines) { + if (lines == null || lines.isEmpty()) { + return; + } + for (Map line : lines) { + if (line == null || isAirWeightFeeName(line.get("name"))) { + continue; + } + String name = StringUtils.trimToEmpty(line.get("name")); + if (name.isEmpty()) { + continue; + } + // 增点费等禁止留在「空运段」(手工/产品报价可能误带 project=空运段) + line.put("project", "起运港"); + if (StringUtils.isBlank(line.get("unit"))) { + line.put("unit", "票"); + } + String qty = StringUtils.trimToEmpty(line.get("quantity")); + String amount = StringUtils.trimToEmpty(line.get("amount")); + String unitPrice = firstNonBlank(line.get("unit_price"), line.get("unitPrice")); + if (qty.isEmpty() || "-".equals(qty)) { + line.put("quantity", "1"); + qty = "1"; + } + if ((unitPrice.isEmpty() || "-".equals(unitPrice)) && StringUtils.isNotBlank(amount) && !"-".equals(amount)) { + if ("1".equals(qty)) { + line.put("unit_price", amount); + } else { + String divided = divideDecimal(amount, qty); + line.put("unit_price", StringUtils.isNotBlank(divided) ? divided : amount); + } + } + } + } + + /** + * 询价重量单位归一:KG / KGS / 公斤 → KG;空默认 KG。 + */ + public static String normalizeWeightUnit(String raw) { + String u = StringUtils.trimToEmpty(raw); + if (u.isEmpty() || "-".equals(u)) { + return "KG"; + } + String compact = u.replaceAll("\\s+", "").toUpperCase(Locale.ROOT); + if (compact.contains("公斤") || "KG".equals(compact) || "KGS".equals(compact) + || "KILOGRAM".equals(compact) || "KILOGRAMS".equals(compact)) { + return "KG"; + } + return u; } /** @@ -5003,12 +5429,15 @@ public final class QuoteTemplateExcelSupport { return feeQuantityOrDefault(quantity, ""); } + public static boolean isAirWeightFeeName(String name) { String n = StringUtils.trimToEmpty(name); if (n.isEmpty()) { return false; } - if (n.contains("提") || n.contains("送") || n.contains("人工") || n.contains("报关") + // 起运附加费禁止归进「空运段」(WO014/图2:增点费曾误留在空运段样例行) + if (n.contains("增点") || n.contains("拆板") || n.contains("打板") || n.contains("提") + || n.contains("送") || n.contains("人工") || n.contains("报关") || n.contains("操作") || n.contains("文件") || n.contains("杂费")) { return false; } @@ -5035,6 +5464,20 @@ public final class QuoteTemplateExcelSupport { } } + /** 除法:空运费总额 ÷ 计费重 → 单价;除数为 0 或非法时返回空串。 */ + static String divideDecimal(String left, String right) { + try { + java.math.BigDecimal a = new java.math.BigDecimal(left.trim().replace(",", "")); + java.math.BigDecimal b = new java.math.BigDecimal(right.trim().replace(",", "")); + if (b.compareTo(java.math.BigDecimal.ZERO) == 0) { + return ""; + } + return a.divide(b, 4, java.math.RoundingMode.HALF_UP).stripTrailingZeros().toPlainString(); + } catch (Exception e) { + return ""; + } + } + /** * 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStore.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStore.java index 45b965f..42c47f1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStore.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStore.java @@ -22,7 +22,8 @@ import java.util.Locale; *

* Jeecg MinIO/OSS 上传返回完整 http(s) URL,Windows 上 {@code Paths.get("http://...")} * 会在冒号处抛 Illegal char <:>。本类先认远程 URL,拉到本地缓存再给 POI / 下载。 - * 副作用:写 {@code upload/inquiry/quote-templates/_remote-cache/}。 + * 副作用:写 {@code upload/inquiry/quote-templates/_remote-cache/},以及 + * {@code upload/inquiry/quote-templates/by-sha/{sha256}.xlsx} 本地镜像(上传时写入,远程损坏时可回退)。 * 线程:跟随调用方 HTTP 线程;不持有全局锁。 */ @Slf4j @@ -70,18 +71,75 @@ public class QuoteTemplateObjectStore { * @return 已存在的本地文件,或从对象存储落地后的缓存文件 */ public Path resolve(String objectKey) { + return resolve(objectKey, null); + } + + /** + * 解析模板文件,并在提供 expectedSha256 时校验内容指纹。 + *

+ * 为何:MinIO 对象偶发被掏空/覆盖后,旧逻辑只要缓存「非空」就直接用,导致空运报价费目名全空。 + * 有 sha 时:缓存/远程不匹配则删缓存重拉;仍不匹配则回退 {@code by-sha/{sha}.xlsx}。 + * + * @param objectKey 主账 version.object_key(本地相对路径或 http(s) URL) + * @param expectedSha256 版本表记录的 sha256(可空;空则只做路径解析,与历史行为一致) + * @return 可读本地文件 + */ + public Path resolve(String objectKey, String expectedSha256) { if (StringUtils.isBlank(objectKey)) { throw new IllegalArgumentException("objectKey 空"); } String key = objectKey.trim(); + String want = normalizeSha(expectedSha256); + + if (want != null) { + Path bySha = tryByShaMirror(want); + if (bySha != null) { + return bySha; + } + } + + Path resolved; if (isRemoteUrl(key)) { - return fetchRemoteToCache(key); + resolved = fetchRemoteToCache(key, want); + } else { + resolved = tryLocalFile(key); + if (resolved == null) { + throw new IllegalStateException("找不到模板文件: " + key); + } + if (want != null && !shaMatches(resolved, want)) { + Path fallback = tryByShaMirror(want); + if (fallback != null) { + log.warn("本地模板 sha 不匹配,改用 by-sha 镜像 key={}", key); + return fallback; + } + throw new IllegalStateException("模板文件 sha256 不匹配: " + key); + } } - Path local = tryLocalFile(key); - if (local != null) { - return local; + + return resolved; + } + + /** + * 上传成功后写一份按内容指纹命名的本地镜像,供远程损坏时回退。 + *

+ * 副作用:写 {@code upload/inquiry/quote-templates/by-sha/{sha256}.xlsx}。 + * 失败只记日志,不阻断上传主流程。 + */ + public void writeByShaMirror(byte[] fileBytes, String sha256Hex) { + String sha = normalizeSha(sha256Hex); + if (fileBytes == null || fileBytes.length == 0 || sha == null) { + return; + } + try { + Path dir = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")), + BIZ_PATH, "by-sha"); + Files.createDirectories(dir); + Path target = dir.resolve(sha + ".xlsx"); + Files.write(target, fileBytes); + log.info("模板 by-sha 镜像已写入 sha={} bytes={}", sha.substring(0, 12), fileBytes.length); + } catch (Exception e) { + log.warn("模板 by-sha 镜像写入失败 sha={} err={}", sha, e.getMessage()); } - throw new IllegalStateException("找不到模板文件: " + key); } /** @@ -138,9 +196,11 @@ public class QuoteTemplateObjectStore { } /** - * 从 MinIO/OSS 拉到本地缓存。同一 URL 只落一次(文件名带时间戳,一般不会覆盖)。 + * 从 MinIO/OSS 拉到本地缓存。同一 URL 缓存文件名 = sha256(url)。 + *

+ * 有 expectedSha 时:已有缓存若指纹不对则删除重拉;重拉后仍不对则回退 by-sha 镜像。 */ - private Path fetchRemoteToCache(String url) { + private Path fetchRemoteToCache(String url, String expectedSha) { Path cacheDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")), BIZ_PATH, "_remote-cache"); try { @@ -152,7 +212,11 @@ public class QuoteTemplateObjectStore { Path cacheFile = cacheDir.resolve(DigestUtils.sha256Hex(url) + ext); try { if (Files.isRegularFile(cacheFile) && Files.size(cacheFile) > 0) { - return cacheFile; + if (expectedSha == null || shaMatches(cacheFile, expectedSha)) { + return cacheFile; + } + log.warn("模板远程缓存 sha 不匹配,删除后重拉 file={}", cacheFile.getFileName()); + Files.deleteIfExists(cacheFile); } } catch (Exception ignored) { // 读缓存失败则重新拉取 @@ -161,11 +225,66 @@ public class QuoteTemplateObjectStore { Files.copy(in, cacheFile, StandardCopyOption.REPLACE_EXISTING); } catch (Exception e) { log.error("对象存储落地失败 url={}", url, e); + if (expectedSha != null) { + Path fallback = tryByShaMirror(expectedSha); + if (fallback != null) { + log.warn("对象存储读取失败,改用 by-sha 镜像 url={}", url); + return fallback; + } + } throw new IllegalStateException("对象存储读取失败: " + e.getMessage(), e); } + if (expectedSha != null && !shaMatches(cacheFile, expectedSha)) { + log.error("对象存储模板 sha256 与版本表不一致 urlHash={}", + DigestUtils.sha256Hex(url).substring(0, 12)); + try { + Files.deleteIfExists(cacheFile); + } catch (Exception ignored) { + // ignore + } + Path fallback = tryByShaMirror(expectedSha); + if (fallback != null) { + log.warn("远程模板损坏,改用 by-sha 镜像"); + return fallback; + } + throw new IllegalStateException("对象存储模板内容与版本 sha256 不一致"); + } return cacheFile; } + private Path tryByShaMirror(String sha) { + if (StringUtils.isBlank(sha)) { + return null; + } + Path p = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")), + BIZ_PATH, "by-sha", sha + ".xlsx"); + try { + if (Files.isRegularFile(p) && Files.size(p) > 0 && shaMatches(p, sha)) { + return p; + } + } catch (Exception ignored) { + return null; + } + return null; + } + + private static String normalizeSha(String sha) { + if (StringUtils.isBlank(sha)) { + return null; + } + String s = sha.trim().toLowerCase(Locale.ROOT); + return s.matches("[0-9a-f]{64}") ? s : null; + } + + private static boolean shaMatches(Path file, String expectedSha) { + try { + String actual = DigestUtils.sha256Hex(Files.readAllBytes(file)).toLowerCase(Locale.ROOT); + return expectedSha.equals(actual); + } catch (Exception e) { + return false; + } + } + private InputStream openRemoteStream(String url) { String[] parts = splitStorageUrl(url, MinioUtil.getMinioUrl()); if (parts != 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 32efbf9..9fa55aa 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 @@ -11,6 +11,7 @@ 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.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -1050,6 +1051,10 @@ public class InquiryTmsCabinService { item.put("currency", currency); } String unit = text(row, "unit", "单位"); + // 手工报价常只有金额无单位;空运运输费锁舱必须带 unit(WO007:单位未传 TMS) + if (QuoteTemplateExcelSupport.isAirWeightFeeName(name)) { + unit = QuoteTemplateExcelSupport.normalizeWeightUnit(unit); + } if (StringUtils.isNotBlank(unit)) { item.put("unit", unit); } 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 bf4997f..f9de0ec 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 @@ -20,9 +20,12 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.TimeZone; /** @@ -803,17 +806,13 @@ public class InquiryTmsQueryService { continue; } Map opt = (Map) optObj; - Object charges = first(opt.get("departureCharges"), opt.get("departure_charges"), - opt.get("charges"), opt.get("feeItems")); - if (!(charges instanceof List)) { - continue; - } + // 合并 departureCharges + feeItems,避免只取附加费丢掉空运费 + List> charges = mergeAirOptionChargeMaps(opt); String optCcy = firstText(opt.get("currency"), headerCurrency); - for (Object one : (List) charges) { - if (!(one instanceof Map)) { + for (Map ch : charges) { + if (ch == null) { continue; } - Map ch = (Map) one; String name = firstText(ch.get("chargeName"), ch.get("feeName"), ch.get("name")); String unitPrice = firstText(ch.get("unitPrice"), ch.get("unit_price"), ch.get("salePrice"), ch.get("amount")); if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) { @@ -830,6 +829,8 @@ public class InquiryTmsQueryService { // 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重 addFeeLine(out, name, unitPrice, lineCcy, extras); } + // departureCharges 只有附加费时,用舱位 salePrice 补空运费(与出单 parse 对齐) + injectAirFreightFeeLineIfMissing(out, opt, headerCurrency); // 通常只用首个舱位选项的费用明细 if (!out.isEmpty()) { break; @@ -838,6 +839,93 @@ public class InquiryTmsQueryService { return out; } + /** 合并舱位 departureCharges / feeItems,同名去重。 */ + @SuppressWarnings("unchecked") + static List> mergeAirOptionChargeMaps(Map opt) { + List> out = new ArrayList<>(); + if (opt == null) { + return out; + } + Set seen = new HashSet<>(); + for (Object key : new Object[]{"departureCharges", "departure_charges", "charges", "feeItems", "fee_items"}) { + Object raw = opt.get(key); + if (!(raw instanceof List)) { + continue; + } + for (Object one : (List) raw) { + if (!(one instanceof Map)) { + continue; + } + Map ch = (Map) one; + String name = firstText(ch.get("chargeName"), ch.get("feeName"), ch.get("name")); + if (StringUtils.isBlank(name)) { + continue; + } + String dedupe = name.trim().toLowerCase(Locale.ROOT); + if (!seen.add(dedupe)) { + continue; + } + out.add(ch); + } + } + return out; + } + + /** + * 查价投影 fee_lines:明细无运输/空运费时,用舱位 salePrice/unitPrice 注入。 + * 与 QuoteTemplateExcelSupport.injectAirFreightLineIfMissing 口径一致。 + */ + static void injectAirFreightFeeLineIfMissing(List> lines, Map opt, String headerCurrency) { + if (lines == null || opt == null) { + return; + } + for (Map line : lines) { + if (line != null && isAirWeightFeeName(line.get("name"))) { + return; + } + } + String unitPrice = firstText(opt.get("unitPrice"), opt.get("unit_price"), opt.get("pricePerKg"), opt.get("freightRate")); + String salePrice = firstText(opt.get("salePrice"), opt.get("sale_price"), opt.get("totalSalePrice"), + opt.get("airFreight"), opt.get("freight")); + String cw = firstText(opt.get("chargeableWeightKg"), opt.get("chargeable_weight_kg")); + String amount = ""; + String price = unitPrice; + if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(cw)) { + amount = multiplyDecimal(unitPrice, cw); + } else if (StringUtils.isNotBlank(salePrice) && !"-".equals(salePrice)) { + amount = salePrice; + if (StringUtils.isBlank(price) && StringUtils.isNotBlank(cw)) { + try { + java.math.BigDecimal a = new java.math.BigDecimal(salePrice.trim().replace(",", "")); + java.math.BigDecimal b = new java.math.BigDecimal(cw.trim().replace(",", "")); + if (b.compareTo(java.math.BigDecimal.ZERO) != 0) { + price = a.divide(b, 4, java.math.RoundingMode.HALF_UP).stripTrailingZeros().toPlainString(); + } + } catch (Exception ignored) { + price = ""; + } + } + } + if (StringUtils.isBlank(amount) || "-".equals(amount)) { + return; + } + String ccy = firstText(opt.get("currency"), headerCurrency); + Map extras = new LinkedHashMap<>(); + putExtra(extras, "project", "空运段"); + putExtra(extras, "unit_price", StringUtils.isNotBlank(price) ? price : amount); + if (StringUtils.isNotBlank(cw)) { + putExtra(extras, "quantity", cw); + putExtra(extras, "unit", "KG"); + } + // 先 append 再挪到最前:空运段在附加费之前 + int before = lines.size(); + addFeeLine(lines, "空运费", amount, ccy, extras); + if (lines.size() > before) { + Map injected = lines.remove(lines.size() - 1); + lines.add(0, injected); + } + } + /** * 空运运输费:单价 × 计费重 = 行金额。 *

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 47391ed..02e036e 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 @@ -2,6 +2,7 @@ package org.jeecg.modules.inquiry.template; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; +import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.FillPatternType; import org.apache.poi.ss.usermodel.PrintSetup; import org.apache.poi.ss.usermodel.Row; @@ -38,6 +39,31 @@ public class QuoteTemplateFillTest { assertEquals("14669.60", rows.getJSONObject(0).getString("amount")); } + @Test + public void manualAirFeesOnlyAmountFillsUnitPriceQtyAndUnit() { + // WO020:手工只有金额 → 空运费补 KG/1/单价;增点费补 票/1/单价 + java.util.List> lines = new java.util.ArrayList<>(); + Map air = new HashMap<>(); + air.put("name", "空运费"); + air.put("amount", "150"); + air.put("currency", "CNY"); + lines.add(air); + Map add = new HashMap<>(); + add.put("name", "增点费"); + add.put("amount", "200"); + add.put("currency", "CNY"); + add.put("project", "空运段"); // 误归空运段也要纠正 + lines.add(add); + QuoteTemplateExcelSupport.enrichAirWeightFeeRows(lines, "", "KG"); + assertEquals("KG", lines.get(0).get("unit")); + assertEquals("1", lines.get(0).get("quantity")); + assertEquals("150", lines.get(0).get("unit_price")); + assertEquals("票", lines.get(1).get("unit")); + assertEquals("1", lines.get(1).get("quantity")); + assertEquals("200", lines.get(1).get("unit_price")); + assertEquals("起运港", lines.get(1).get("project")); + } + @Test public void parseFeeLinesDoubleEncoded() { JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows("[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]"); @@ -242,8 +268,8 @@ public class QuoteTemplateFillTest { } /** - * 回归:无 TMS 费用行时,费用表「项目=起运港」不得把 pol 写进右侧「费用名称」。 - * 对应 WO202609230026 空运协同出单:空运段旁费用名称被写成「广州」。 + * 回归:无 TMS 费用行时,不得把 pol 写进费用名称; + * 并保留模板项目骨架「空运段/起运港」(图2),禁止掏空项目列。 */ @Test public void airNoTmsMustNotWritePolIntoFeeNameColumn() throws Exception { @@ -267,13 +293,17 @@ public class QuoteTemplateFillTest { head.createCell(7).setCellValue("备注"); org.apache.poi.ss.usermodel.Row airSeg = sheet.createRow(16); airSeg.createCell(0).setCellValue("空运段"); - airSeg.createCell(1).setCellValue(""); + airSeg.createCell(1).setCellValue("空运费"); org.apache.poi.ss.usermodel.Row polSeg = sheet.createRow(17); polSeg.createCell(0).setCellValue("起运港"); - polSeg.createCell(1).setCellValue(""); - org.apache.poi.ss.usermodel.Row otherSeg = sheet.createRow(18); + polSeg.createCell(1).setCellValue("操作费"); + org.apache.poi.ss.usermodel.Row destSeg = sheet.createRow(18); + destSeg.createCell(0).setCellValue("目的港"); + destSeg.createCell(1).setCellValue("目的港派送费"); + org.apache.poi.ss.usermodel.Row otherSeg = sheet.createRow(19); otherSeg.createCell(0).setCellValue("其他"); - otherSeg.createCell(1).setCellValue(""); + otherSeg.createCell(1).setCellValue("*其他杂费"); + otherSeg.createCell(7).setCellValue("发生时收取"); try (java.io.OutputStream os = Files.newOutputStream(src)) { wb.write(os); } @@ -300,13 +330,22 @@ public class QuoteTemplateFillTest { b16 = fmt.formatCellValue(sheet.getRow(16).getCell(1)); b17 = fmt.formatCellValue(sheet.getRow(17).getCell(1)); b18 = fmt.formatCellValue(sheet.getRow(18).getCell(1)); + String b19 = fmt.formatCellValue(sheet.getRow(19).getCell(1)); + String h19 = fmt.formatCellValue(sheet.getRow(19).getCell(7)); String cargoPol = fmt.formatCellValue(sheet.getRow(5).getCell(1)); assertTrue(cargoPol.contains("广州"), "货物区起运港应写入广州,实际=" + cargoPol); - assertFalse(b16.contains("广州"), "空运段旁费用名称不应是起运港,实际=" + b16); - assertFalse(b17.contains("广州"), "起运港项目旁费用名称不应是起运港,实际=" + b17); - assertFalse(b18.contains("广州"), "其他旁费用名称不应是起运港,实际=" + b18); - assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0))); + assertFalse(b16.contains("广州"), "费用名称不应是起运港,实际=" + b16); + assertFalse(b17.contains("广州"), "费用名称不应是起运港,实际=" + b17); + assertFalse(b18.contains("广州"), "费用名称不应是起运港,实际=" + b18); + // 无 fee_rows:项目骨架保留;样例费目名/备注清掉(有费用的才显示行) assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0))); + assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0))); + assertEquals("目的港", fmt.formatCellValue(sheet.getRow(18).getCell(0))); + assertEquals("", b16.trim(), "无费用时空运费样例名应清空,实际=" + b16); + assertEquals("", b17.trim(), "无费用时操作费样例名应清空,实际=" + b17); + assertEquals("", b18.trim(), "无费用时目的港派送费样例名应清空,实际=" + b18); + assertEquals("", b19.trim(), "无费用时其他杂费样例名应清空,实际=" + b19); + assertEquals("", h19.trim(), "无费用时样例备注应清空,实际=" + h19); } finally { Files.deleteIfExists(src); Files.deleteIfExists(out); @@ -857,6 +896,104 @@ public class QuoteTemplateFillTest { assertEquals("16670", QuoteTemplateExcelSupport.feeQuantityOrDefault("16670", "运输费")); } + /** + * WO014:departureCharges 只有增点/拆板时,必须注入空运费,且项目列归类正确。 + */ + @Test + public void parseAirDepartureChargesInjectsFreightAndProjects() { + String airOptions = "[" + + "{\"optionNo\":\"AIR-OPT01\",\"salePrice\":4750.95,\"currency\":\"CNY\"," + + "\"chargeableWeightKg\":500.1," + + "\"departureCharges\":[" + + "{\"chargeName\":\"增点费\",\"unitPrice\":100.0,\"unit\":\"点\",\"currency\":\"CNY\"}," + + "{\"chargeName\":\"拆板费\",\"unitPrice\":100.0,\"unit\":\"板数\",\"currency\":\"USD\"}" + + "]}]"; + JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows( + airOptions, null, "500.1", "CNY"); + assertEquals(3, rows.size()); + assertEquals("空运费", rows.getJSONObject(0).getString("name")); + assertEquals("空运段", rows.getJSONObject(0).getString("project")); + assertEquals("4750.95", rows.getJSONObject(0).getString("amount")); + assertEquals("增点费", rows.getJSONObject(1).getString("name")); + assertEquals("起运港", rows.getJSONObject(1).getString("project")); + assertEquals("拆板费", rows.getJSONObject(2).getString("name")); + assertEquals("起运港", rows.getJSONObject(2).getString("project")); + } + + /** + * 映射未标 project 列时,表头「项目」仍要写入,增点费不能留在模板样例「空运段」。 + */ + @Test + public void airFeeProjectColumnFilledFromHeaderWhenMappingOmitsIt() throws Exception { + Path src = Files.createTempFile("quote-air-proj-src", ".xlsx"); + Path out = Files.createTempFile("quote-air-proj-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet2"); + org.apache.poi.ss.usermodel.Row head = sheet.createRow(15); + head.createCell(0).setCellValue("项目"); + head.createCell(1).setCellValue("费用名称"); + head.createCell(2).setCellValue("单位"); + head.createCell(3).setCellValue("币别"); + head.createCell(4).setCellValue("单价"); + head.createCell(5).setCellValue("数量"); + head.createCell(6).setCellValue("金额"); + org.apache.poi.ss.usermodel.Row r1 = sheet.createRow(16); + r1.createCell(0).setCellValue("空运段"); + r1.createCell(1).setCellValue("样例费"); + r1.createCell(6).setCellValue(1.0); + org.apache.poi.ss.usermodel.Row r2 = sheet.createRow(17); + r2.createCell(0).setCellValue("起运港"); + r2.createCell(1).setCellValue("样例2"); + r2.createCell(6).setCellValue(1.0); + org.apache.poi.ss.usermodel.Row total = sheet.createRow(22); + total.createCell(0).setCellValue("预估费用"); + total.createCell(6).setCellFormula("SUM(G17:G22)"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(); + mapping.put("fields", new JSONObject()); + JSONObject region = new JSONObject(true); + region.put("id", "air_fee_detail"); + region.put("anchor", "Sheet2!A17"); + region.put("stopAtText", java.util.Collections.singletonList("预估费用")); + JSONObject cols = new JSONObject(true); + cols.put("name", "B"); + cols.put("unit", "C"); + cols.put("currency", "D"); + cols.put("unit_price", "E"); + cols.put("quantity", "F"); + cols.put("amount", "G"); + // 故意不写 project,验证 mergeFeeColumnsFromHeader 补 A 列 + region.put("columns", cols); + mapping.put("feeRegions", new JSONArray(java.util.Collections.singletonList(region))); + JSONObject facts = new JSONObject(true); + facts.put("total_amount", "5001"); + facts.put("currency", "CNY"); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseAirDepartureChargeRows( + "[{\"salePrice\":4750.95,\"currency\":\"CNY\",\"chargeableWeightKg\":500.1," + + "\"departureCharges\":[" + + "{\"chargeName\":\"增点费\",\"unitPrice\":100.0,\"unit\":\"点\",\"currency\":\"CNY\"}," + + "{\"chargeName\":\"拆板费\",\"unitPrice\":100.0,\"unit\":\"板数\",\"currency\":\"USD\"}" + + "]}]", + null, "500.1", "CNY")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = got.getSheet("Sheet2"); + DataFormatter fmt = new DataFormatter(); + assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0))); + assertEquals("空运费", fmt.formatCellValue(sheet.getRow(16).getCell(1))); + assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0))); + assertEquals("增点费", fmt.formatCellValue(sheet.getRow(17).getCell(1))); + assertEquals("起运港", fmt.formatCellValue(sheet.getRow(18).getCell(0))); + assertEquals("拆板费", fmt.formatCellValue(sheet.getRow(18).getCell(1))); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * 出单后必须 A4 水平居中,抬头行高够放下中英文公司名。 * 否则转 PDF 会顶部裁字、整页偏左。 diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStoreTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStoreTest.java index 7b4c095..8226cc0 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStoreTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateObjectStoreTest.java @@ -42,4 +42,11 @@ public class QuoteTemplateObjectStoreTest { public void splitRejectsBlank() { assertNull(QuoteTemplateObjectStore.splitStorageUrl(" ", "http://127.0.0.1:9000/")); } + + @Test + public void remoteUrlStillDetectedAfterShaOverload() { + // 回归:增加 resolve(objectKey, sha) 后,URL 识别不能回退成 Paths.get + assertTrue(QuoteTemplateObjectStore.isRemoteUrl( + "http://127.0.0.1:9000/inquiry-robot/inquiry/quote-templates/空运到门报价单模板_1790069791709.xlsx")); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java index a18d377..de459cb 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -193,6 +193,30 @@ public class InquiryTmsCabinServiceTest { java.util.List> fees = (java.util.List>) req.get("feeItems"); assertEquals("空运费", fees.get(0).get("feeName")); + // 空运费缺单位时锁舱补 KG(WO007) + assertEquals("KG", fees.get(0).get("unit")); + } + + @Test + public void manualFeeLinesWithoutUnitGetsKgOnLock() { + Map quote = new HashMap<>(); + quote.put("source", "PRODUCT"); + Map line = new HashMap<>(); + line.put("name", "空运费"); + line.put("unit_price", "10.00"); + line.put("quantity", "5063"); + line.put("amount", "50630"); + line.put("currency", "CNY"); + quote.put("fee_lines", java.util.Arrays.asList(line)); + Map req = new HashMap<>(); + InquiryTmsCabinService.fillLockQuoteExtras(req, quote, new HashMap<>()); + @SuppressWarnings("unchecked") + java.util.List> fees = + (java.util.List>) req.get("feeItems"); + assertNotNull(fees); + assertEquals(1, fees.size()); + assertEquals("空运费", fees.get(0).get("feeName")); + assertEquals("KG", fees.get(0).get("unit")); } @Test