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 0a01f43..3887ec2 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 @@ -21,7 +21,6 @@ import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport; import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher; import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore; import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer; -import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -866,6 +865,12 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl enrichCtx = new LinkedHashMap<>(); - enrichCtx.put("chargeableWeightKg", chargeable); - InquiryTmsQueryService.enrichAirWeightFeeLines(lineMaps, enrichCtx); + // 出单路径自带重算(单位/单价/数量/金额) + QuoteTemplateExcelSupport.enrichAirWeightFeeRows(lineMaps, chargeable); JSONArray enriched = new JSONArray(); for (Map m : lineMaps) { // Fastjson 只要 Map,不能直接塞 Map。 diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java index 91a9d5e..47ec18a 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -771,11 +771,12 @@ public final class QuoteTemplateExcelSupport { log.warn("费用区行数仍越过 stop,截断 region={} wrote={}/{}", region.getString("id"), r, rows.size()); break; } - // 单行黄底:保留模板占位文案,不覆盖 - if (rowLooksYellowFixed(sheet, rowIdx)) { - continue; - } + // 不整行跳过:备注「发生时收取」常标黄,整行跳过会把费用名留成模板样例(如 ZUH)。 + // 黄格仍由 writeFeeCol 逐格跳过。 JSONObject rowObj = rows.getJSONObject(r); + writeFeeCol(sheet, rowIdx, columns.getString("project"), + firstNonBlank(rowObj.getString("project"), rowObj.getString("segment")), + yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName()); @@ -812,6 +813,29 @@ public final class QuoteTemplateExcelSupport { } } } + // 明细行数少于模板占位时,清掉后面非黄底样例行(避免 ZUH/文件费残留) + clearFeeRowsAfter(sheet, startRow + rows.size(), stopRow, columns, yellow, sheet.getSheetName()); + } + } + + /** + * 清空 [fromRow, stopRow) 费用列;黄底固定行保留(如「发生时收取」占位)。 + */ + private static void clearFeeRowsAfter(Sheet sheet, int fromRow, int stopRow, JSONObject columns, + Set yellow, String sheetName) { + if (sheet == null || columns == null || fromRow >= stopRow) { + return; + } + // 备注列常黄底「发生时收取」:不清 remark,只清费用数据列;黄格由 writeFeeCol 跳过 + String[] keys = {"project", "name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"}; + for (int r = fromRow; r < stopRow; r++) { + for (String key : keys) { + String colLetter = columns.getString(key); + if (StringUtils.isBlank(colLetter)) { + continue; + } + writeFeeCol(sheet, r, colLetter, "", yellow, sheetName); + } } } @@ -1466,7 +1490,8 @@ public final class QuoteTemplateExcelSupport { boolean needUnit = StringUtils.isBlank(out.getString("unit")); boolean needPrice = StringUtils.isBlank(out.getString("unit_price")); boolean needQty = StringUtils.isBlank(out.getString("quantity")); - if (!needUnit && !needPrice && !needQty) { + boolean needProject = StringUtils.isBlank(out.getString("project")); + if (!needUnit && !needPrice && !needQty && !needProject) { return out; } Map header = findFeeTableColumnsAbove(sheet, Math.max(dataStartRow, 0)); @@ -1485,9 +1510,157 @@ public final class QuoteTemplateExcelSupport { if (needQty && header.get("quantity") != null) { out.put("quantity", CellReference.convertNumToColString(header.get("quantity"))); } + if (needProject && header.get("project") != null) { + out.put("project", CellReference.convertNumToColString(header.get("project"))); + } + if (StringUtils.isBlank(out.getString("name")) && header.get("name") != null) { + out.put("name", CellReference.convertNumToColString(header.get("name"))); + } return out; } + /** + * 空运 airOptions[].departureCharges → 费用行(出单用)。 + * 已落库 payload 常把 airOptions 存成 JSON 字符串。 + */ + public static JSONArray parseAirDepartureChargeRows(Object airOptions, Object airOptionsAlt, + String chargeableWeightKg, String headerCurrency) { + JSONArray out = new JSONArray(); + Object raw = airOptions != null ? airOptions : airOptionsAlt; + JSONArray opts = parseFeeRows(raw); + if (opts.isEmpty()) { + return out; + } + List> lines = new ArrayList<>(); + for (int i = 0; i < opts.size(); i++) { + JSONObject opt = opts.getJSONObject(i); + if (opt == null) { + continue; + } + JSONArray charges = parseFeeRows(firstNonNull(opt.get("departureCharges"), opt.get("departure_charges"), + opt.get("charges"), opt.get("feeItems"))); + String optCcy = firstNonBlank(opt.getString("currency"), headerCurrency); + for (int c = 0; c < charges.size(); c++) { + JSONObject ch = charges.getJSONObject(c); + if (ch == null) { + continue; + } + String name = firstNonBlank(ch.getString("chargeName"), ch.getString("feeName"), ch.getString("name")); + String unitPrice = firstNonBlank(ch.getString("unitPrice"), ch.getString("unit_price"), + ch.getString("salePrice"), ch.getString("amount")); + if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) { + continue; + } + Map line = new LinkedHashMap<>(); + line.put("name", name); + line.put("amount", unitPrice); + line.put("unit_price", unitPrice); + // 空运模板「项目」列:运费归空运段,其它归起运港 + line.put("project", isAirWeightFeeName(name) ? "空运段" : "起运港"); + String unit = firstNonBlank(ch.getString("unit"), ch.getString("unitName")); + if (StringUtils.isNotBlank(unit)) { + line.put("unit", unit); + } + String ccy = firstNonBlank(ch.getString("currency"), optCcy); + if (StringUtils.isNotBlank(ccy)) { + line.put("currency", ccy); + } + lines.add(line); + } + if (!lines.isEmpty()) { + break; + } + } + enrichAirWeightFeeRows(lines, chargeableWeightKg); + for (Map line : lines) { + out.add(JSON.parseObject(JSON.toJSONString(line))); + } + return out; + } + + private static Object firstNonNull(Object... vals) { + if (vals == null) { + return null; + } + for (Object v : vals) { + if (v != null) { + return v; + } + } + return null; + } + + /** + * 空运运输费:单价 × 计费重 = 行金额。 + * 出单时调用;有单价+数量则重算金额(数值比较,避免 11.00≠11.0 漏算)。 + */ + public static void enrichAirWeightFeeRows(List> lines, String chargeableWeightKg) { + if (lines == null || lines.isEmpty()) { + return; + } + String chargeable = StringUtils.trimToEmpty(chargeableWeightKg); + if (chargeable.isEmpty() || "-".equals(chargeable)) { + return; + } + for (Map line : lines) { + if (line == null || !isAirWeightFeeName(line.get("name"))) { + continue; + } + 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 (qty.isEmpty()) { + line.put("quantity", chargeable); + qty = chargeable; + } + // TMS 常给「板数」;按计费重计价统一 KG + line.put("unit", "KG"); + if (unitPrice.isEmpty() && !amount.isEmpty()) { + line.put("unit_price", amount); + unitPrice = amount; + } + if (!unitPrice.isEmpty() && !qty.isEmpty()) { + String product = multiplyDecimal(unitPrice, qty); + if (!product.isEmpty() && (amount.isEmpty() || !sameDecimal(amount, product))) { + line.put("amount", product); + } + } + } + } + + 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("报关") + || n.contains("操作") || n.contains("文件") || n.contains("杂费")) { + return false; + } + return n.contains("空运") || n.contains("运输费") || "运费".equals(n); + } + + static boolean sameDecimal(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(",", "")); + return a.compareTo(b) == 0; + } catch (Exception e) { + return StringUtils.equals(left, right); + } + } + + static String multiplyDecimal(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(",", "")); + return a.multiply(b).stripTrailingZeros().toPlainString(); + } catch (Exception e) { + return ""; + } + } + /** * 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。 */ @@ -1821,7 +1994,15 @@ public final class QuoteTemplateExcelSupport { } for (int c = 0; c < last; c++) { String text = StringUtils.trimToEmpty(readCellText(row.getCell(c))); - if (isFeeNameHeader(text)) { + String primary = text; + int slash = text.indexOf('/'); + if (slash > 0) { + primary = text.substring(0, slash).trim(); + } + // 空运表头「项目 | 费用名称」两列:项目单独记,勿盖掉费用名称 + if ("项目".equals(primary) || "项目".equals(text) || text.startsWith("项目/")) { + cols.put("project", c); + } else if (isFeeNameHeader(text)) { cols.put("name", c); } else if (isContainerTypeHeader(text)) { cols.put(normalizeContainerType(text), c); @@ -2351,24 +2532,29 @@ public final class QuoteTemplateExcelSupport { if (row == null) { continue; } - boolean feeHeader = !parseFeeHeaderRow(sheet, r).isEmpty(); - short lastCol = row.getLastCellNum(); - for (int c = 0; c < lastCol; c++) { - Cell cell = row.getCell(c); - if (cell == null) { - continue; - } - String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); - if (text.isEmpty() || isSectionTitle(sheet, cell) || isFeeTableHeader(text)) { - continue; - } - String code = matchLabel(text.replace(":", ":").trim(), FILL_BY_LABEL); - if (StringUtils.isBlank(code)) { - continue; - } - if ("currency".equals(code) && feeHeader) { + boolean feeHeader = !parseFeeHeaderRow(sheet, r).isEmpty(); + // 费用明细数据行:「起运港/目的港」是项目列值,不是询价标签; + // 否则会把 pol=ZUH 写进右侧「费用名称」,盖掉提送货费/人工费。 + if (!feeHeader && isFeeDetailDataRow(sheet, r)) { continue; } + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + Cell cell = row.getCell(c); + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (text.isEmpty() || isSectionTitle(sheet, cell) || isFeeTableHeader(text)) { + continue; + } + String code = matchLabel(text.replace(":", ":").trim(), FILL_BY_LABEL); + if (StringUtils.isBlank(code)) { + continue; + } + if ("currency".equals(code) && feeHeader) { + continue; + } String val = firstFact(facts, code, text.replace(":", "").replace(":", "").trim()); if (StringUtils.isBlank(val) || "-".equals(val)) { continue; @@ -2404,6 +2590,38 @@ public final class QuoteTemplateExcelSupport { return text != null && (text.contains(":") || text.contains(":")); } + /** + * 费用明细表数据行(表头「费用名称/金额」之下、汇总行之上)。 + * 这些行的「起运港」是项目分段,不是询价单「起运港」标签。 + */ + static boolean isFeeDetailDataRow(Sheet sheet, int rowIdx) { + if (sheet == null || rowIdx < 0) { + return false; + } + if (!parseFeeHeaderRow(sheet, rowIdx).isEmpty()) { + return false; + } + Row row = sheet.getRow(rowIdx); + if (row != null) { + 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("总销售价")) { + return false; + } + } + } + for (int r = rowIdx - 1; r >= Math.max(0, rowIdx - 40); r--) { + if (!parseFeeHeaderRow(sheet, r).isEmpty()) { + return true; + } + } + return false; + } + private static String matchLabel(String normalized, Map dict) { if (dict == null || dict.isEmpty() || StringUtils.isBlank(normalized)) { return null; 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 5895ebe..459e104 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 @@ -527,6 +527,12 @@ public class InquiryTmsQueryService { } } } + // 空运:明细在 airOptions[].departureCharges(chargeName/unitPrice),不是顶层 feeItems + List> airCharges = extractAirDepartureCharges(data, headerCurrency); + if (!airCharges.isEmpty() && (lines.isEmpty() || lines.size() < airCharges.size())) { + lines.clear(); + lines.addAll(airCharges); + } 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); @@ -541,6 +547,64 @@ public class InquiryTmsQueryService { return JSON.toJSONString(lines); } + /** + * 空运舱位选项上的 departureCharges → 扁平费用行。 + * TMS 字段:chargeName / unitPrice / unit / currency;行金额出单时再按计费重补。 + */ + @SuppressWarnings("unchecked") + static List> extractAirDepartureCharges(Map data, String headerCurrency) { + List> out = new ArrayList<>(); + if (data == null) { + return out; + } + Object raw = first(data.get("airOptions"), data.get("air_options")); + if (raw instanceof String) { + try { + raw = JSON.parse(String.valueOf(raw)); + } catch (Exception e) { + return out; + } + } + if (!(raw instanceof List)) { + return out; + } + for (Object optObj : (List) raw) { + if (!(optObj instanceof Map)) { + 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; + } + String optCcy = firstText(opt.get("currency"), headerCurrency); + for (Object one : (List) charges) { + if (!(one instanceof Map)) { + 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)) { + continue; + } + String lineCcy = firstText(ch.get("currency"), optCcy, headerCurrency); + Map extras = new LinkedHashMap<>(); + putExtra(extras, "unit", firstText(ch.get("unit"), ch.get("unitName"))); + putExtra(extras, "unit_price", unitPrice); + putExtra(extras, "project", isAirWeightFeeName(name) ? "空运段" : "起运港"); + // 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重 + addFeeLine(out, name, unitPrice, lineCcy, extras); + } + // 通常只用首个舱位选项的费用明细 + if (!out.isEmpty()) { + break; + } + } + return out; + } + /** * 空运运输费:单价 × 计费重 = 行金额。 *

@@ -568,25 +632,35 @@ public class InquiryTmsQueryService { line.put("quantity", chargeable); qty = chargeable; } - if (StringUtils.isBlank(unit)) { - line.put("unit", "KG"); - } + // TMS 常给单位「板数」;按计费重计价时统一写成 KG + line.put("unit", "KG"); // 有数量、无单价:当前 amount 当作单价(TMS 把费率塞进 salePrice/amount) if (StringUtils.isBlank(unitPrice) && StringUtils.isNotBlank(amount)) { line.put("unit_price", amount); unitPrice = amount; } - // 单价与金额相同(或金额空):按 单价×数量 重算行金额 - if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(qty) - && (StringUtils.isBlank(amount) || amount.equals(unitPrice))) { + // 有单价+数量:行金额必须是 单价×数量(勿用字符串 equals:11.00 ≠ 11.0 会漏算) + if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(qty)) { String product = multiplyDecimal(unitPrice, qty); - if (StringUtils.isNotBlank(product)) { + if (StringUtils.isNotBlank(product) + && (StringUtils.isBlank(amount) || !sameDecimal(amount, product))) { line.put("amount", product); } } } } + /** 数值相等(忽略尾零与小数位写法差异)。 */ + static boolean sameDecimal(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(",", "")); + return a.compareTo(b) == 0; + } catch (Exception e) { + return StringUtils.equals(left, right); + } + } + /** 空运按计费重的费目:运输费 / 空运费;不含提送货、人工、报关等固定费。 */ public static boolean isAirWeightFeeName(String name) { String n = StringUtils.trimToEmpty(name); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java index 6efc7f2..699d4e5 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -260,4 +261,74 @@ public class TmsQuoteCurrencyTest { assertTrue(quote.get("fee_lines").contains("\"amount\":\"6.0\"")); assertTrue(quote.get("fee_lines").contains("\"amount\":\"123.0\"")); } + + @Test + public void toAgentQuoteUsesAirDepartureCharges() { + Map c1 = new HashMap<>(); + c1.put("chargeName", "运输费"); + c1.put("unitPrice", 11.0); + c1.put("unit", "板数"); + c1.put("currency", "CNY"); + Map c2 = new HashMap<>(); + c2.put("chargeName", "提送货费"); + c2.put("unitPrice", 6.0); + c2.put("unit", "板数"); + c2.put("currency", "CNY"); + Map c3 = new HashMap<>(); + c3.put("chargeName", "人工费"); + c3.put("unitPrice", 123.0); + c3.put("unit", "柜"); + c3.put("currency", "CNY"); + Map option = new HashMap<>(); + option.put("optionNo", "AIR-OPT01"); + option.put("salePrice", 183370.0); + option.put("currency", "CNY"); + option.put("chargeableWeightKg", 16670.0); + option.put("departureCharges", java.util.Arrays.asList(c1, c2, c3)); + Map data = new HashMap<>(); + data.put("quoteFound", true); + data.put("totalSalePrice", "183370.0"); + data.put("currency", "CNY"); + data.put("chargeableWeightKg", "16670.0000"); + data.put("feeItems", Collections.emptyList()); + data.put("airOptions", Collections.singletonList(option)); + Map tms = new HashMap<>(); + tms.put("code", 1); + tms.put("tmsData", data); + Map quote = InquiryTmsQueryService.toAgentQuote(tms); + String fees = quote.get("fee_lines"); + assertTrue(fees.contains("运输费")); + assertTrue(fees.contains("提送货费")); + assertTrue(fees.contains("人工费")); + assertTrue(fees.contains("\"amount\":\"183370\"")); + assertTrue(fees.contains("\"amount\":\"6.0\"") || fees.contains("\"amount\":\"6\"")); + assertTrue(fees.contains("\"amount\":\"123.0\"") || fees.contains("\"amount\":\"123\"")); + assertTrue(fees.contains("\"unit\":\"KG\"")); + assertTrue(fees.contains("\"quantity\":\"16670.0000\"")); + } + + @Test + public void enrichAirWeightRecalculatesWhenAmountLooksLikeRateWithDecimals() { + // 单价已是 11.00、数量已是计费重,但金额仍是 11.00(字符串不等于 11.0 也要重算) + Map freight = new LinkedHashMap<>(); + freight.put("name", "运输费"); + freight.put("unit", "KG"); + freight.put("unit_price", "11.00"); + freight.put("quantity", "16670.00"); + freight.put("amount", "11.00"); + freight.put("currency", "CNY"); + Map labor = new LinkedHashMap<>(); + labor.put("name", "人工费"); + labor.put("amount", "123.00"); + labor.put("currency", "CNY"); + List> lines = new ArrayList<>(); + lines.add(freight); + lines.add(labor); + Map data = new HashMap<>(); + data.put("chargeableWeightKg", "16670.0000"); + InquiryTmsQueryService.enrichAirWeightFeeLines(lines, data); + assertEquals("183370", lines.get(0).get("amount")); + assertEquals("11.00", lines.get(0).get("unit_price")); + assertEquals("123.00", lines.get(1).get("amount")); + } }