diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayProductController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayProductController.java index 4a1c98e9..cd262391 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayProductController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayProductController.java @@ -21,6 +21,7 @@ import com.bms.common.core.domain.AjaxResult; import com.bms.common.core.page.TableDataInfo; import com.bms.common.enums.BusinessType; import com.bms.common.exception.ServiceException; +import com.bms.common.utils.file.FileUtils; import com.bms.common.utils.poi.ExcelUtil; import com.bms.price.domain.ChannelProductMap; import com.bms.price.domain.PayProduct; @@ -86,6 +87,18 @@ public class PayProductController extends BaseController return success(payOrderCostService.generate(billId)); } + /** 下载某一版导入时上传的源文件 */ + @PreAuthorize("@ss.hasPermi('price:pay:query')") + @PostMapping("/{productId}/source") + public void downloadSource(@PathVariable Long productId, HttpServletResponse response) throws Exception + { + String filename = payProductService.sourceFileName(productId); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + FileUtils.setAttachmentResponseHeader(response, filename); + payProductService.writeSource(productId, response.getOutputStream()); + } + @PreAuthorize("@ss.hasPermi('price:pay:query')") @GetMapping("/{productId}") public AjaxResult getInfo(@PathVariable Long productId) diff --git a/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayWorkbookParserTest.java b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayWorkbookParserTest.java index d995a3d1..e5ba6004 100644 --- a/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayWorkbookParserTest.java +++ b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayWorkbookParserTest.java @@ -52,6 +52,7 @@ class PayWorkbookParserTest .filter(fee -> "高风险地区附加费".equals(fee.getFeeName()) && "AF".equals(fee.getCalcParam())).findFirst().orElseThrow(); assertEquals("Afghanistan", afghan.getZoneName()); assertEquals("RMB", afghan.getCurrency()); + assertEquals("RMB/KG", afghan.getPriceUnit()); assertTrue(afghan.getChargeText().contains("10.4") && afghan.getChargeText().contains("RMB/kg")); assertTrue(decimalFee(afghan, "10.4")); assertEquals("MANUAL", afghan.getCalcRule()); @@ -532,6 +533,40 @@ class PayWorkbookParserTest assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getWeightLabel() != null && band.getWeightLabel().contains("预估"))); } + @Test + void posteItalianeKeepsTwoDeliveryServicesAndSizeRules() throws Exception + { + Path file = Path.of("D:/payment/PosteItaliane.xlsx"); + Assumptions.assumeTrue(Files.exists(file)); + PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), null, List.of("PosteItaliane")); + PayProduct poste = preview.getProducts().get(0); + assertEquals("OK", poste.getParseStatus()); + assertEquals(18, poste.getWeightBands().size(), poste.getWeightBands().stream() + .map(band -> band.getChargeName() + "/" + band.getZoneName() + "/" + band.getWeightLabel() + "/" + band.getPrice()) + .toList().toString()); + assertEquals(9, poste.getWeightBands().stream().filter(band -> "派送费 Crono Express".equals(band.getChargeName())).count()); + assertEquals(9, poste.getWeightBands().stream().filter(band -> "派送费 Crono".equals(band.getChargeName())).count()); + assertTrue(poste.getWeightBands().stream().anyMatch(band -> "派送费 Crono Express".equals(band.getChargeName()) + && "City".equals(band.getZoneName()) && band.getWeightLabel().contains("0") && decimal(band, "3") + && "EUR/ITEM".equals(band.getPriceUnit()) && "意大利(IT)".equals(band.getCountryName()) && "IT".equals(band.getCountryCode()))); + assertTrue(poste.getWeightBands().stream().anyMatch(band -> "派送费 Crono Express".equals(band.getChargeName()) + && "Province".equals(band.getZoneName()) && band.getWeightLabel().contains("5") && decimal(band, "6.63"))); + assertTrue(poste.getWeightBands().stream().anyMatch(band -> "派送费 Crono".equals(band.getChargeName()) + && "Rural".equals(band.getZoneName()) && band.getWeightLabel().contains("2") && decimal(band, "4.38") + && "意大利(IT)".equals(band.getCountryName()))); + assertTrue(poste.getWeightBands().stream().anyMatch(band -> "派送费 Crono".equals(band.getChargeName()) + && "Province".equals(band.getZoneName()) && band.getWeightLabel().contains("5") && decimal(band, "5.83"))); + assertTrue(poste.getExtraLines().stream().anyMatch(line -> "配置".equals(line.getLineType()) + && line.getLineText().contains("尺寸限制") && line.getLineText().contains("长+宽+高≤130cm") + && line.getLineText().contains("最长边≤60cm"))); + assertTrue(poste.getExtraLines().stream().anyMatch(line -> line.getLineText().contains("计费说明") && line.getLineText().contains("计费重"))); + assertTrue(poste.getSurcharges().stream().anyMatch(fee -> "销毁费".equals(fee.getFeeName()) && decimalFee(fee, "0.2") + && "EUR/ITEM".equals(fee.getPriceUnit()) && "产生时收取".equals(fee.getChargeTiming()))); + assertTrue(poste.getSurcharges().stream().anyMatch(fee -> "退件重派费".equals(fee.getFeeName()) + && fee.getChargeText().contains("同正向运费") && "产生时收取".equals(fee.getChargeTiming()))); + assertTrue(poste.getSurcharges().stream().filter(fee -> "产生时收取".equals(fee.getChargeTiming())).count() >= 6); + } + private PayPreview parse(String prefix) throws Exception { Path dir = Path.of("D:/应付供应商"); @@ -557,7 +592,8 @@ class PayWorkbookParserTest && "速递运费".equals(band.getChargeName()) && decimal(band, "0.23") && "EUR/KG".equals(band.getPriceUnit()))); assertTrue(dhl.getServiceNote() != null && dhl.getServiceNote().contains("报价已含燃油")); assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "其他费用".equals(fee.getSectionName()) - && "道路通行费".equals(fee.getFeeName()) && decimalFee(fee, "0.19"))); + && "道路通行费".equals(fee.getFeeName()) && decimalFee(fee, "0.19") + && "EUR".equals(fee.getCurrency()) && "EUR/ITEM".equals(fee.getPriceUnit()))); assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "旺季附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.19") && fee.getEffectiveStart() != null)); assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "高峰旺季附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.5"))); diff --git a/bms-back/bms-common/src/main/java/com/bms/common/utils/poi/ExcelGrid.java b/bms-back/bms-common/src/main/java/com/bms/common/utils/poi/ExcelGrid.java index ffb1ace3..52e79cd4 100644 --- a/bms-back/bms-common/src/main/java/com/bms/common/utils/poi/ExcelGrid.java +++ b/bms-back/bms-common/src/main/java/com/bms/common/utils/poi/ExcelGrid.java @@ -33,16 +33,20 @@ public class ExcelGrid /** 合并区域左上角横向占几列,其余单元格为 0 */ private final int[][] colSpan; + /** 纵向合并时,被盖住的格子指向左上角行;没有纵向合并是 -1 */ + private final int[][] rowAnchor; + private final int rows; private final int cols; - private ExcelGrid(String[][] text, Double[][] number, String[][] linkSheet, int[][] colSpan, int rows, int cols) + private ExcelGrid(String[][] text, Double[][] number, String[][] linkSheet, int[][] colSpan, int[][] rowAnchor, int rows, int cols) { this.text = text; this.number = number; this.linkSheet = linkSheet; this.colSpan = colSpan; + this.rowAnchor = rowAnchor; this.rows = rows; this.cols = cols; } @@ -88,6 +92,14 @@ public class ExcelGrid Double[][] number = new Double[Math.max(rows, 0)][Math.max(cols, 0)]; String[][] linkSheet = new String[Math.max(rows, 0)][Math.max(cols, 0)]; int[][] colSpan = new int[Math.max(rows, 0)][Math.max(cols, 0)]; + int[][] rowAnchor = new int[Math.max(rows, 0)][Math.max(cols, 0)]; + for (int r = 0; r < rowAnchor.length; r++) + { + for (int c = 0; c < rowAnchor[r].length; c++) + { + rowAnchor[r][c] = -1; + } + } if (sheet != null && rows > 0 && cols > 0) { for (int r = 0; r < rows; r++) @@ -115,9 +127,21 @@ public class ExcelGrid { colSpan[r0][c0] = width; } + int bottom = Math.min(region.getLastRow(), rows - 1); + int right = Math.min(region.getLastColumn(), cols - 1); + if (bottom > r0) + { + for (int r = r0 + 1; r <= bottom; r++) + { + for (int c = c0; c <= right; c++) + { + rowAnchor[r][c] = r0; + } + } + } } } - return new ExcelGrid(text, number, linkSheet, colSpan, rows, cols); + return new ExcelGrid(text, number, linkSheet, colSpan, rowAnchor, rows, cols); } public int rows() @@ -139,6 +163,18 @@ public class ExcelGrid return text[row][col] == null ? "" : text[row][col]; } + /** 本格有字就用本格。纵向合并盖住的空格,用合并区左上角的原文。 */ + public String mergedText(int row, int col) + { + String own = text(row, col); + if (!own.isEmpty() || row < 0 || col < 0 || row >= rows || col >= cols) + { + return own; + } + int anchor = rowAnchor[row][col]; + return anchor < 0 ? "" : text(anchor, col); + } + public Double number(int row, int col) { if (row < 0 || col < 0 || row >= rows || col >= cols) diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPreview.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPreview.java index 62192e6e..8a6e7ec6 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPreview.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPreview.java @@ -14,6 +14,9 @@ public class PayPreview { private String sourceFile; + /** 本次价卡在 OSS 上的地址,保存时写到每个产品版本 */ + private String filePath; + private String fileMd5; private String supplierName; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProduct.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProduct.java index 39d95093..1b754306 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProduct.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProduct.java @@ -93,6 +93,9 @@ public class PayProduct extends BaseEntity @Excel(name = "来源文件", width = 36) private String sourceFile; + /** 源文件在 OSS 上的地址,形如 oss://bucket/key。历史记录按它下载 */ + private String filePath; + private String fileMd5; @Excel(name = "价卡归属", width = 12) diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductTerm.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductTerm.java index 3039f14d..9ce58f39 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductTerm.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductTerm.java @@ -37,6 +37,9 @@ public class PayProductTerm extends BaseEntity private String sourceFile; + /** 这一版源文件的 OSS 地址,空表示导入时还没有存文件 */ + private String filePath; + private Integer weightCount; private Integer surchargeCount; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java index ac14f5dc..461d52c2 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java @@ -30,8 +30,12 @@ public class PaySurcharge /** 从收费标准里解析出的金额;百分比时这里是百分数 */ private BigDecimal amount; + /** 币种代码,计费时和订单币种比对 */ private String currency; + /** 货币和计费单位,与重量段单位同一写法,如 EUR/ITEM、RMB/KG、% */ + private String priceUnit; + /** 费用说明 */ private String feeDesc; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayDetailExporter.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayDetailExporter.java index f40c4af9..d6e92f32 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayDetailExporter.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayDetailExporter.java @@ -35,7 +35,7 @@ public final class PayDetailExporter header(weightSheet, "产品名称", "报价工作表", "区块", "国家", "国家代码", "分区", "重量段", "下限KG", "上限KG", "体积", "费用项", "单价", "单位"); Sheet feeSheet = workbook.createSheet("附加费"); - header(feeSheet, "产品名称", "报价工作表", "区块", "费用名称", "分区", "收费标准", "金额", "币种", "收取时点", + header(feeSheet, "产品名称", "报价工作表", "区块", "费用名称", "分区", "收费标准", "金额", "币种", "单位", "收取时点", "生效开始", "生效结束", "时效原文", "费用说明"); Sheet extraSheet = workbook.createSheet("其他配置"); header(extraSheet, "产品名称", "报价工作表", "类型", "配置内容"); @@ -121,11 +121,12 @@ public final class PayDetailExporter cell(row, 5, fee.getChargeText()); cell(row, 6, plain(fee.getAmount())); cell(row, 7, fee.getCurrency()); - cell(row, 8, fee.getChargeTiming()); - cell(row, 9, day(fee.getEffectiveStart())); - cell(row, 10, day(fee.getEffectiveEnd())); - cell(row, 11, fee.getPeriodText()); - cell(row, 12, fee.getFeeDesc()); + cell(row, 8, fee.getPriceUnit()); + cell(row, 9, fee.getChargeTiming()); + cell(row, 10, day(fee.getEffectiveStart())); + cell(row, 11, day(fee.getEffectiveEnd())); + cell(row, 12, fee.getPeriodText()); + cell(row, 13, fee.getFeeDesc()); } return rowIndex; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayWorkbookParser.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayWorkbookParser.java index a5ebaf77..def9ba2f 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayWorkbookParser.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayWorkbookParser.java @@ -384,6 +384,7 @@ public class PayWorkbookParser copy.setChargeText(fee.getChargeText()); copy.setAmount(fee.getAmount()); copy.setCurrency(fee.getCurrency()); + copy.setPriceUnit(fee.getPriceUnit()); copy.setFeeDesc(fee.getFeeDesc()); copy.setPeriodText(fee.getPeriodText()); copy.setEffectiveStart(fee.getEffectiveStart()); @@ -857,9 +858,10 @@ public class PayWorkbookParser fee.setSectionName(cut(new StringBuilder(sheetName), 200)); fee.setFeeName("高风险地区附加费"); fee.setZoneName(cut(new StringBuilder(country.isEmpty() ? code : country), 200)); - fee.setChargeText(cut(new StringBuilder(amount.toPlainString() + " " + currency + (unit.contains("/kg") ? "/kg" : "")), 500)); fee.setAmount(amount); fee.setCurrency(currency); + fee.setPriceUnit(currency + (unit.toLowerCase(Locale.ROOT).contains("/kg") ? "/KG" : "")); + fee.setChargeText(cut(new StringBuilder(amount.toPlainString() + " " + currency + (unit.contains("/kg") ? "/kg" : "")), 500)); fee.setFeeDesc(desc); fee.setCalcRule(PaySurchargeRules.MANUAL); fee.setCalcParam(code); @@ -1090,6 +1092,12 @@ public class PayWorkbookParser pending = ""; continue; } + if (isNoteTitle(grid, row)) + { + row = readNoteBlock(grid, row, product); + pending = ""; + continue; + } if (isLooseSurcharge(grid, row)) { addLooseSurcharge(grid, row, pending, product); @@ -1320,10 +1328,18 @@ public class PayWorkbookParser for (int col = 0; col < grid.cols(); col++) { String text = grid.text(data, col); + if ((kind[col] == 2 || kind[col] == 3) && text.isBlank()) + { + text = grid.mergedText(data, col); + } if (text.isBlank()) { continue; } + if (kind[col] == 0 && parent[col].contains("尺寸")) + { + keepLimit(product, parent[col], text); + } if (kind[col] == 1 || (weight.isEmpty() && PayTexts.looksLikeWeight(text))) { if (weight.isEmpty() || kind[col] == 1) @@ -1364,7 +1380,8 @@ public class PayWorkbookParser { continue; } - if (col > 0 && parent[col].equals(parent[col - 1])) + if (col > 0 && parent[col].equals(parent[col - 1]) + && zoneOf(sub[col], parent[col]).equals(zoneOf(sub[col - 1], parent[col - 1]))) { BigDecimal previous = priceOf(grid, data, col - 1); if (previous != null && previous.compareTo(price) == 0) @@ -1384,7 +1401,7 @@ public class PayWorkbookParser band.setWeightMax(bounds[1]); if (feeHead) { - band.setChargeName(shortCharge(parent[col])); + band.setChargeName(chargeTitle(parent[col])); band.setZoneName(zoneOf(sub[col], parent[col])); } else @@ -1793,6 +1810,7 @@ public class PayWorkbookParser { fee.setAmount(null); fee.setCurrency(""); + fee.setPriceUnit(""); return; } Matcher matcher = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*欧元").matcher(sentence); @@ -1805,12 +1823,15 @@ public class PayWorkbookParser } if (hits == 1) { - fee.setAmount(new BigDecimal(amount)); + BigDecimal price = new BigDecimal(amount); + fee.setAmount(price); fee.setCurrency("EUR"); + fee.setPriceUnit(surchargeUnit(new PayTexts.Money(price, "EUR", ""), sentence)); return; } fee.setAmount(null); fee.setCurrency(""); + fee.setPriceUnit(""); } private static void markManual(PayProduct product) @@ -2039,6 +2060,7 @@ public class PayWorkbookParser fee.setChargeText(String.join("、", factors.keySet())); fee.setAmount(null); fee.setCurrency(""); + fee.setPriceUnit(""); markManual(product); } @@ -2369,6 +2391,10 @@ public class PayWorkbookParser fee.setChargeText(priceText.trim()); fee.setAmount(BigDecimal.ZERO); fee.setCurrency(fee.getCurrency() == null || fee.getCurrency().isBlank() ? "EUR" : fee.getCurrency()); + if (fee.getPriceUnit() == null || fee.getPriceUnit().isBlank()) + { + fee.setPriceUnit(fee.getCurrency()); + } } row++; } @@ -2838,6 +2864,10 @@ public class PayWorkbookParser { remarkCol = col; } + else if (text.contains("收取") || text.contains("时点")) + { + remarkCol = remarkCol < 0 ? col : remarkCol; + } else if (nameCol < 0 && isFeeNameHead(text)) { nameCol = col; @@ -3125,6 +3155,7 @@ public class PayWorkbookParser fee.setChargeText(cut(new StringBuilder(blankTo(standard, money.amount == null ? "" : money.amount.toPlainString())), 500)); fee.setAmount(money.amount); fee.setCurrency(money.currency); + fee.setPriceUnit(cut(new StringBuilder(surchargeUnit(money, blankTo(standard, combined))), 40)); fee.setFeeDesc(cut(new StringBuilder(joinDesc(desc, remark)), 2000)); fee.setPeriodText(period.text == null ? "" : cut(new StringBuilder(period.text), 500)); fee.setEffectiveStart(PayTexts.toDate(period.start)); @@ -3134,6 +3165,7 @@ public class PayWorkbookParser { fee.setAmount(null); fee.setCurrency(""); + fee.setPriceUnit(""); } fee.setSortNo(product.getSurcharges().size() + 1); PaySurchargeRules.apply(fee); @@ -3588,6 +3620,76 @@ public class PayWorkbookParser return next < grid.rows() && rowHasWeight(grid, next) && countNumbers(grid, next) >= 2; } + /** 一、计费说明这种标题。后面的句子是配置,不是费用行。 */ + private static boolean isNoteTitle(ExcelGrid grid, int row) + { + String line = joinRow(grid, row).trim(); + if (line.isEmpty() || line.length() > 24 || countText(grid, row) > 2 || line.contains("。")) + { + return false; + } + return line.matches("^[一二三四五六七八九十]+、.+"); + } + + private int readNoteBlock(ExcelGrid grid, int row, PayProduct product) + { + String title = joinRow(grid, row).trim(); + StringBuilder body = new StringBuilder(); + row++; + while (row < grid.rows() && !grid.blankRow(row) && !isHardHeader(grid, row) && !isNoteTitle(grid, row) + && !isSurchargeHeader(grid, row)) + { + String line = joinRow(grid, row).replace('\n', ' ').trim(); + if (!line.isEmpty()) + { + if (body.length() > 0) + { + body.append(' '); + } + body.append(line); + } + row++; + } + if (body.length() > 0) + { + addConfig(product, title + " | " + body); + } + return row; + } + + /** 尺寸限制是整段产品的规则,不写进每一档单价。 */ + private void keepLimit(PayProduct product, String head, String text) + { + String body = text.replace('\n', ';').replace(";;", ";").trim(); + if (body.isEmpty()) + { + return; + } + String title = head.contains("尺寸") ? "尺寸限制" : shortCharge(head); + addConfig(product, title + " | " + body); + } + + private void addConfig(PayProduct product, String text) + { + String line = cut(new StringBuilder(text), 1000); + if (line.isEmpty()) + { + return; + } + for (PayExtraLine existing : product.getExtraLines()) + { + if (line.equals(existing.getLineText())) + { + return; + } + } + PayExtraLine extra = new PayExtraLine(); + extra.setLineType("配置"); + extra.setLineText(line); + extra.setSortNo(product.getExtraLines().size() + 1); + product.getExtraLines().add(extra); + } + /** 单独的「附加费」标题。后面跟着金额说明或条件费率,而不是收费标准表。 */ private static boolean isFeeSectionTitle(ExcelGrid grid, int row) { @@ -4122,6 +4224,39 @@ public class PayWorkbookParser return currency.isEmpty() ? "每票" : currency + "/票"; } + /** 附加费的货币单位跟重量段一样,价格数字单独放 amount,这里只放 EUR/ITEM、RMB/KG、%。 */ + private static String surchargeUnit(PayTexts.Money money, String standard) + { + if (money == null || money.amount == null) + { + return ""; + } + if ("%".equals(money.unit)) + { + return "%"; + } + String fromText = unitOf(standard == null ? "" : standard); + String currency = money.currency == null ? "" : money.currency; + String unit = money.unit == null ? "" : money.unit; + if (fromText.contains("/")) + { + return fromText; + } + if (unit.isEmpty() && ("ITEM".equals(fromText) || "KG".equals(fromText))) + { + unit = fromText; + } + if (!currency.isEmpty() && !unit.isEmpty()) + { + return currency + "/" + unit; + } + if (!fromText.isEmpty() && currency.isEmpty()) + { + return fromText; + } + return currency.isEmpty() ? unit : currency; + } + private static String unitOf(String header) { String value = header.toUpperCase(Locale.ROOT); @@ -4135,6 +4270,19 @@ public class PayWorkbookParser return per.isEmpty() ? currency : currency + "/" + per; } + /** 去掉表头里的币种括号,留下服务名。两段派送费才能分成 Crono Express 和 Crono。 */ + private static String chargeTitle(String head) + { + if (head == null || head.isBlank()) + { + return ""; + } + String value = head.replace('\n', ' ').replace('\r', ' '); + value = value.replaceAll("(?i)[((]\\s*(?:EUR|GBP|USD|RMB|CNY|欧元|英镑|美元|人民币)\\s*/\\s*[^))]{1,12}[))]", " "); + value = value.replaceAll("\\s+", " ").trim(); + return shortCharge(value); + } + private static String shortCharge(String text) { if (text == null) @@ -4169,7 +4317,7 @@ public class PayWorkbookParser { if (isPriceHead(head)) { - return shortCharge(head); + return chargeTitle(head); } } return "价格"; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IPayProductService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayProductService.java index b6c879e6..fae6c118 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/IPayProductService.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayProductService.java @@ -1,5 +1,6 @@ package com.bms.price.service; +import java.io.OutputStream; import java.util.List; import java.util.Date; import com.bms.price.domain.PayPreview; @@ -66,5 +67,11 @@ public interface IPayProductService /** 产品及其重量段、附加费、邮编配置 */ PayProduct selectPayProductDetail(Long productId); + /** 确认这一版有源文件,返回原始文件名 */ + String sourceFileName(Long productId); + + /** 把这一版的源文件写到输出流 */ + void writeSource(Long productId, OutputStream output); + int deletePayProducts(Long[] productIds); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java index 7c3e700c..deb79eae 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java @@ -1,20 +1,29 @@ package com.bms.price.service.impl; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.bms.common.core.domain.entity.SysDictData; import com.bms.common.exception.ServiceException; +import com.bms.common.utils.DateUtils; import com.bms.common.utils.SecurityUtils; +import com.bms.common.utils.file.OssStorage; +import com.bms.common.utils.uuid.IdUtils; import com.bms.price.domain.ChannelProductMap; import com.bms.price.domain.PayExtraLine; import com.bms.price.domain.PayPreview; @@ -58,14 +67,26 @@ public class PayProductServiceImpl implements IPayProductService @Autowired private ISysDictTypeService dictTypeService; + @Autowired + private OssStorage ossStorage; + private final PayWorkbookParser parser = new PayWorkbookParser(); + /** 预览时已经传到 OSS 的文件。保存不再只靠前端把地址传回来。 */ + private final Map uploadedSources = new ConcurrentHashMap<>(); + + private static final long SOURCE_REMEMBER_MS = 6L * 60 * 60 * 1000; + @Override public PayPreview preview(byte[] bytes, String filename, String dictValue) { PayPreview preview = parser.parseMatched(bytes, filename, dictValue, loadDictValues()); + String stored = storeSource(bytes, filename); + rememberSource(preview.getFileMd5(), stored); + preview.setFilePath(stored); for (PayProduct product : preview.getProducts()) { + product.setFilePath(stored); Long existsId = isBlank(product.getDictValue()) ? null : payProductMapper.selectTemplateId(product.getDictValue()); product.setDuplicated(existsId != null); product.setExistsId(existsId); @@ -102,6 +123,7 @@ public class PayProductServiceImpl implements IPayProductService continue; } product.setProductType(requireProductType(product.getProductType())); + product.setFilePath(resolveSource(product)); prepareCounts(product); fitColumns(product); product.setProductId(null); @@ -152,6 +174,7 @@ public class PayProductServiceImpl implements IPayProductService product.setProductType(isBlank(product.getProductType()) ? existing.getProductType() : payTypeOf(product.getProductType())); product.setFileMd5(blankTo(product.getFileMd5(), existing.getFileMd5())); product.setSourceFile(blankTo(product.getSourceFile(), existing.getSourceFile())); + product.setFilePath(blankTo(sourcePath(product.getFilePath()), existing.getFilePath())); product.setSheetName(blankTo(product.getSheetName(), existing.getSheetName())); product.setUpdateBy(SecurityUtils.getUsername()); prepareCounts(product); @@ -334,6 +357,7 @@ public class PayProductServiceImpl implements IPayProductService copy.setSheetName(blankTo(edited.getSheetName(), origin.getSheetName())); copy.setRelatedSheet(blankTo(edited.getRelatedSheet(), origin.getRelatedSheet())); copy.setSourceFile(blankTo(edited.getSourceFile(), origin.getSourceFile())); + copy.setFilePath(blankTo(sourcePath(edited.getFilePath()), origin.getFilePath())); copy.setFileMd5(blankTo(edited.getFileMd5(), origin.getFileMd5())); copy.setSupplierName(blankTo(edited.getSupplierName(), origin.getSupplierName())); copy.setEffectiveDate(edited.getEffectiveDate() != null ? edited.getEffectiveDate() : origin.getEffectiveDate()); @@ -453,6 +477,7 @@ public class PayProductServiceImpl implements IPayProductService term.setProductId(product.getProductId()); term.setVersionNo(product.getVersionNo() == null ? 1 : product.getVersionNo()); term.setEffectiveStart(product.getEffectiveDate()); + term.setFilePath(sourcePath(product.getFilePath())); term.setStatus("1"); term.setCreateBy(product.getCreateBy()); payProductTermMapper.insertTerm(term); @@ -477,6 +502,130 @@ public class PayProductServiceImpl implements IPayProductService return products; } + @Override + public String sourceFileName(Long productId) + { + return sourceOf(productId).getSourceFile(); + } + + @Override + public void writeSource(Long productId, OutputStream output) + { + try + { + ossStorage.download(sourceOf(productId).getFilePath(), output); + } + catch (IOException ex) + { + throw new ServiceException("源文件下载失败"); + } + } + + private PayProduct sourceOf(Long productId) + { + PayProduct product = productId == null ? null : payProductMapper.selectPayProductById(productId); + if (product == null) + { + throw new ServiceException("产品不存在或已删除"); + } + String path = sourcePath(product.getFilePath()); + if (path == null) + { + throw new ServiceException("这一版没有可下载的源文件"); + } + product.setFilePath(path); + if (isBlank(product.getSourceFile())) + { + product.setSourceFile("价卡.xlsx"); + } + return product; + } + + /** 识别完成后把原文件放到 OSS,同一份文件只传一次。 */ + private String storeSource(byte[] bytes, String filename) + { + String original = isBlank(filename) ? "import.xlsx" : filename.trim(); + String ext = "xlsx"; + int dot = original.lastIndexOf('.'); + if (dot >= 0 && dot < original.length() - 1) + { + ext = original.substring(dot + 1).toLowerCase(Locale.ROOT); + } + if (!"xlsx".equals(ext) && !"xls".equals(ext)) + { + ext = "xlsx"; + } + String contentType = "xls".equals(ext) + ? "application/vnd.ms-excel" + : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + String objectKey = "price/pay/" + DateUtils.datePath() + "/" + IdUtils.fastSimpleUUID() + "." + ext; + try + { + return ossStorage.upload(new ByteArrayInputStream(bytes), bytes.length, objectKey, contentType); + } + catch (IOException ex) + { + throw new ServiceException("价卡文件上传失败"); + } + } + + /** 保存时用预览传回的地址;前端没带上时,用刚才上传记住的那一份。 */ + private String resolveSource(PayProduct product) + { + String path = sourcePath(product.getFilePath()); + if (path == null) + { + path = rememberedSource(product.getFileMd5()); + } + if (path == null) + { + throw new ServiceException("源文件还没有上传成功,请重新选择价卡后再保存"); + } + return path; + } + + private void rememberSource(String fileMd5, String path) + { + if (isBlank(fileMd5) || sourcePath(path) == null) + { + return; + } + long now = System.currentTimeMillis(); + Iterator> it = uploadedSources.entrySet().iterator(); + while (it.hasNext()) + { + if (now - it.next().getValue().at > SOURCE_REMEMBER_MS) + { + it.remove(); + } + } + uploadedSources.put(fileMd5, new UploadedSource(path, now)); + } + + private String rememberedSource(String fileMd5) + { + if (isBlank(fileMd5)) + { + return null; + } + UploadedSource stored = uploadedSources.get(fileMd5); + if (stored == null || System.currentTimeMillis() - stored.at > SOURCE_REMEMBER_MS) + { + return null; + } + return stored.path; + } + + /** 只接受本次应付价卡目录下的 OSS 地址,避免把别的文件地址写进来。 */ + private static String sourcePath(String path) + { + if (path == null || !path.startsWith("oss://") || !path.contains("/price/pay/")) + { + return null; + } + return path.trim(); + } + @Override public PayProduct selectPayProductDetail(Long productId) { @@ -647,6 +796,7 @@ public class PayProductServiceImpl implements IPayProductService product.setSheetName(cut(product.getSheetName(), 200)); product.setRelatedSheet(cut(product.getRelatedSheet(), 500)); product.setSourceFile(cut(product.getSourceFile(), 500)); + product.setFilePath(cut(product.getFilePath(), 500)); product.setSupplierName(cut(product.getSupplierName(), 100)); product.setCurrency(cut(product.getCurrency(), 20)); product.setServiceNote(cut(product.getServiceNote(), 4000)); @@ -675,6 +825,7 @@ public class PayProductServiceImpl implements IPayProductService fee.setZoneName(cut(fee.getZoneName(), 200)); fee.setChargeText(cut(fee.getChargeText(), 500)); fee.setCurrency(cut(fee.getCurrency(), 20)); + fee.setPriceUnit(cut(fee.getPriceUnit(), 40)); fee.setFeeDesc(cut(fee.getFeeDesc(), 2000)); fee.setPeriodText(cut(fee.getPeriodText(), 500)); fee.setChargeTiming(cut(fee.getChargeTiming(), 40)); @@ -1074,4 +1225,17 @@ public class PayProductServiceImpl implements IPayProductService { return isBlank(value) ? fallback : value; } + + private static final class UploadedSource + { + private final String path; + + private final long at; + + private UploadedSource(String path, long at) + { + this.path = path; + this.at = at; + } + } } diff --git a/bms-back/bms-system/src/main/resources/mapper/masterdata/ServiceChannelMapper.xml b/bms-back/bms-system/src/main/resources/mapper/masterdata/ServiceChannelMapper.xml index 8c13255a..b8ed9f89 100644 --- a/bms-back/bms-system/src/main/resources/mapper/masterdata/ServiceChannelMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/masterdata/ServiceChannelMapper.xml @@ -122,6 +122,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update srv_service_channel set channel_code = #{channelCode}, + channel_name = #{channelName}, service_code = #{serviceCode}, service_name = #{serviceName}, diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PayProductMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PayProductMapper.xml index b72a5913..d67a7ee5 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PayProductMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PayProductMapper.xml @@ -23,6 +23,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -112,6 +113,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" ifnull(cur.extra_per_kg, pay_product.extra_per_kg) as extra_per_kg, pay_product.service_type, pay_product.classification, pay_product.catalog_remark, pay_product.update_content, pay_product.weight_limit, pay_product.sheet_name, pay_product.related_sheet, ifnull(cur.source_file, pay_product.source_file) as source_file, + case when cur.product_id is null then pay_product.file_path else cur.file_path end as file_path, pay_product.file_md5, pay_product.supplier_name, pay_product.effective_date, ifnull(cur.currency, pay_product.currency) as currency, pay_product.service_note, @@ -137,7 +139,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select surcharge_id as surchargeId, product_id as productId, version_no as versionNo, section_name as sectionName, fee_name as feeName, - zone_name as zoneName, charge_text as chargeText, amount, currency, fee_desc as feeDesc, + zone_name as zoneName, charge_text as chargeText, amount, currency, price_unit as priceUnit, fee_desc as feeDesc, period_text as periodText, effective_start as effectiveStart, effective_end as effectiveEnd, charge_timing as chargeTiming, calc_rule as calcRule, calc_param as calcParam, sort_no as sortNo from pay_surcharge diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PayProductTermMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PayProductTermMapper.xml index f761b557..3ca041ac 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PayProductTermMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PayProductTermMapper.xml @@ -8,7 +8,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select t.term_id as termId, t.dict_value as dictValue, t.product_id as productId, t.version_no as versionNo, t.effective_start as effectiveStart, t.effective_end as effectiveEnd, t.status, t.created_by as createBy, t.create_time as createTime, t.remark, - p.product_name as productName, p.source_file as sourceFile, p.weight_count as weightCount, + p.product_name as productName, p.source_file as sourceFile, + ifnull(t.file_path, p.file_path) as filePath, p.weight_count as weightCount, p.surcharge_count as surchargeCount, p.currency from pay_product_term t join pay_product p on p.product_id = t.product_id @@ -34,9 +35,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" insert into pay_product_term ( - dict_value, product_id, version_no, effective_start, effective_end, status, created_by, create_time, remark + dict_value, product_id, version_no, effective_start, effective_end, file_path, status, created_by, create_time, remark ) values ( - #{dictValue}, #{productId}, ifnull(#{versionNo}, 1), #{effectiveStart}, #{effectiveEnd}, + #{dictValue}, #{productId}, ifnull(#{versionNo}, 1), #{effectiveStart}, #{effectiveEnd}, #{filePath}, ifnull(#{status}, '0'), #{createBy}, sysdate(), #{remark} ) diff --git a/bms-back/sql/price/pay_product.sql b/bms-back/sql/price/pay_product.sql index 7c270536..fbab0ebd 100644 --- a/bms-back/sql/price/pay_product.sql +++ b/bms-back/sql/price/pay_product.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS `pay_product` ( `sheet_name` varchar(200) NOT NULL COMMENT '报价工作表', `related_sheet` varchar(500) DEFAULT NULL COMMENT '目录里一并指向的分区或附加表,不单独入库', `source_file` varchar(500) NOT NULL COMMENT '来源文件名', + `file_path` varchar(500) DEFAULT NULL COMMENT '源文件OSS地址,oss://bucket/key', `file_md5` char(32) NOT NULL COMMENT '来源文件MD5', `supplier_name` varchar(100) DEFAULT NULL COMMENT '价卡归属,如威狮、易德、GWE', `effective_date` date DEFAULT NULL COMMENT '价卡生效日', @@ -65,6 +66,7 @@ CREATE TABLE IF NOT EXISTS `pay_surcharge` ( `charge_text` varchar(500) DEFAULT NULL COMMENT '收费标准原文', `amount` decimal(12,4) DEFAULT NULL COMMENT '能解析出的金额或百分比', `currency` varchar(20) DEFAULT NULL COMMENT '币种', + `price_unit` varchar(40) DEFAULT NULL COMMENT '货币和计费单位,如EUR/ITEM、RMB/KG、%', `fee_desc` varchar(2000) DEFAULT NULL COMMENT '费用说明', `period_text` varchar(500) DEFAULT NULL COMMENT '时效原文', `effective_start` date DEFAULT NULL COMMENT '生效开始,空表示价卡期内一直有效', diff --git a/bms-back/sql/price/pay_product_source.sql b/bms-back/sql/price/pay_product_source.sql new file mode 100644 index 00000000..2facfff5 --- /dev/null +++ b/bms-back/sql/price/pay_product_source.sql @@ -0,0 +1,16 @@ +-- 应付价卡每一版保存源文件在 OSS 上的地址,历史记录按这个地址下载。可重复执行。 +SET NAMES utf8mb4; + +SET @ddl = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_product' AND COLUMN_NAME = 'file_path' + ), + 'SELECT 1', + 'ALTER TABLE pay_product ADD COLUMN file_path varchar(500) DEFAULT NULL COMMENT ''源文件OSS地址,oss://bucket/key'' AFTER source_file' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/bms-back/sql/price/pay_product_term.sql b/bms-back/sql/price/pay_product_term.sql index 22f3158e..145b832d 100644 --- a/bms-back/sql/price/pay_product_term.sql +++ b/bms-back/sql/price/pay_product_term.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS `pay_product_term` ( `version_no` int(11) NOT NULL DEFAULT 1 COMMENT '版本号', `effective_start` date DEFAULT NULL COMMENT '生效开始', `effective_end` date DEFAULT NULL COMMENT '生效结束,空表示不限', + `file_path` varchar(500) DEFAULT NULL COMMENT '这一版源文件OSS地址,oss://bucket/key', `status` char(1) NOT NULL DEFAULT '0' COMMENT '0生效 1失效', `created_by` varchar(64) DEFAULT '' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', diff --git a/bms-back/sql/price/pay_product_term_source.sql b/bms-back/sql/price/pay_product_term_source.sql new file mode 100644 index 00000000..e839a8a7 --- /dev/null +++ b/bms-back/sql/price/pay_product_term_source.sql @@ -0,0 +1,16 @@ +-- 历史版本记下源文件地址。可重复执行。 +SET NAMES utf8mb4; + +SET @ddl = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_product_term' AND COLUMN_NAME = 'file_path' + ), + 'SELECT 1', + 'ALTER TABLE pay_product_term ADD COLUMN file_path varchar(500) DEFAULT NULL COMMENT ''这一版源文件OSS地址,oss://bucket/key'' AFTER effective_end' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/bms-back/sql/price/pay_surcharge_price_unit.sql b/bms-back/sql/price/pay_surcharge_price_unit.sql new file mode 100644 index 00000000..cbcd2c85 --- /dev/null +++ b/bms-back/sql/price/pay_surcharge_price_unit.sql @@ -0,0 +1,16 @@ +-- 附加费的价格和货币分开。金额仍在 amount,货币和计费单位在 price_unit,如 EUR/ITEM。可重复执行。 +SET NAMES utf8mb4; + +SET @ddl = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_surcharge' AND COLUMN_NAME = 'price_unit' + ), + 'SELECT 1', + 'ALTER TABLE pay_surcharge ADD COLUMN price_unit varchar(40) DEFAULT NULL COMMENT ''货币和计费单位,如EUR/ITEM、RMB/KG、%'' AFTER currency' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue b/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue index 45525231..38ba9361 100644 --- a/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue +++ b/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue @@ -78,7 +78,7 @@
- + 添加附加费
@@ -88,8 +88,23 @@ - - + + + + + + + + @@ -214,7 +229,7 @@ function filteredBands(pane) { } function filteredFees(pane) { - return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'feeDesc', 'calcRule', 'calcParam']) + return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam']) } function addBand(pane) { @@ -225,7 +240,7 @@ function addBand(pane) { function addFee(pane) { if (!pane.item.surcharges) pane.item.surcharges = [] - pane.item.surcharges.push({ feeName: '', zoneName: '', chargeText: '', feeDesc: '', calcRule: 'MANUAL', calcParam: '' }) + pane.item.surcharges.push({ feeName: '', zoneName: '', chargeText: '', amount: '', priceUnit: '', currency: '', feeDesc: '', calcRule: 'MANUAL', calcParam: '' }) pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1 } @@ -290,6 +305,19 @@ function money(value) { return Number.isNaN(num) ? value : String(num) } +/** 价格和货币已经拆开时,收费标准只留说明,不再重复「0.19 EUR/ITEM」。 */ +function feeNote(row) { + const text = (row.chargeText || '').trim() + if (!text || row.amount === null || row.amount === undefined || row.amount === '') return text + const amount = Number(row.amount) + if (!Number.isFinite(amount)) return text + const match = text.match(/^\s*(\d+(?:\.\d+)?)\s*(?:EUR|GBP|USD|RMB|CNY|欧元|英镑|人民币|美元)?\s*(?:\/\s*[A-Za-z\u4e00-\u9fa5]+)?\s*([\s\S]*)$/i) + if (!match || Number(match[1]) !== amount) return text + const rest = (match[2] || '').trim() + if (!rest || rest === '%' || rest === (row.priceUnit || '') || /^[。..、,,]+$/.test(rest)) return '' + return rest +} + function periodLabel(row) { if (row.effectiveStart || row.effectiveEnd) { return `${row.effectiveStart || '…'} 至 ${row.effectiveEnd || '…'}` diff --git a/bms-front/src/views/price/basic/pay/index.vue b/bms-front/src/views/price/basic/pay/index.vue index b0ffbacd..a14001b6 100644 --- a/bms-front/src/views/price/basic/pay/index.vue +++ b/bms-front/src/views/price/basic/pay/index.vue @@ -24,6 +24,11 @@ + + @@ -56,7 +61,7 @@ - +
比对所选版本 勾选两个版本后比对。历史订单按生效时间取价,不看当前开关。 @@ -64,7 +69,12 @@ - + + + @@ -88,8 +98,9 @@ /> - + @@ -126,6 +137,7 @@