diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/masterdata/LineProductController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/masterdata/LineProductController.java new file mode 100644 index 00000000..cf181318 --- /dev/null +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/masterdata/LineProductController.java @@ -0,0 +1,96 @@ +package com.bms.web.controller.masterdata; + +import java.util.List; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.bms.common.annotation.Log; +import com.bms.common.core.controller.BaseController; +import com.bms.common.core.domain.AjaxResult; +import com.bms.common.core.page.TableDataInfo; +import com.bms.common.enums.BusinessType; +import com.bms.common.utils.poi.ExcelUtil; +import com.bms.masterdata.domain.LineProduct; +import com.bms.masterdata.service.ILineProductService; + +/** + * 路线产品管理(srv_line_product) + */ +@RestController +@RequestMapping("/masterdata/lineproduct") +public class LineProductController extends BaseController +{ + @Autowired + private ILineProductService lineProductService; + + @PreAuthorize("@ss.hasPermi('data:lineProduct:list')") + @GetMapping("/list") + public TableDataInfo list(LineProduct query) + { + startPage(); + List list = lineProductService.selectLineProductList(query); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('data:lineProduct:export')") + @Log(title = "路线产品", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, LineProduct query) + { + List list = lineProductService.selectLineProductList(query); + ExcelUtil util = new ExcelUtil(LineProduct.class); + util.exportExcel(response, list, "路线产品"); + } + + @PreAuthorize("@ss.hasPermi('data:lineProduct:query')") + @GetMapping("/{id}") + public AjaxResult getInfo(@PathVariable String id) + { + return success(lineProductService.selectLineProductById(id)); + } + + @PreAuthorize("@ss.hasPermi('data:lineProduct:add')") + @Log(title = "路线产品", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody LineProduct product) + { + String username = getUsername(); + product.setCreateName(username); + product.setModifier(username); + return toAjax(lineProductService.insertLineProduct(product)); + } + + @PreAuthorize("@ss.hasPermi('data:lineProduct:edit')") + @Log(title = "路线产品", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody LineProduct product) + { + product.setModifier(getUsername()); + return toAjax(lineProductService.updateLineProduct(product)); + } + + @PreAuthorize("@ss.hasPermi('data:lineProduct:edit')") + @Log(title = "路线产品", businessType = BusinessType.UPDATE) + @PutMapping("/changeStatus") + public AjaxResult changeStatus(@RequestBody LineProduct product) + { + product.setModifier(getUsername()); + return toAjax(lineProductService.updateLineProductStatus(product)); + } + + @PreAuthorize("@ss.hasPermi('data:lineProduct:remove')") + @Log(title = "路线产品", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable String[] ids) + { + return toAjax(lineProductService.deleteLineProductByIds(ids)); + } +} 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 d740f6f1..b2b579e8 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 @@ -1,6 +1,5 @@ package com.bms.web.controller.price; -import java.util.ArrayList; import java.util.List; import java.util.Locale; import org.springframework.beans.factory.annotation.Autowired; @@ -25,7 +24,6 @@ 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.PayFeeRuleGroup; import com.bms.price.domain.PayProduct; import com.bms.price.domain.PayProductTerm; import com.bms.price.domain.PaySaveCommand; @@ -72,35 +70,6 @@ public class PayProductController extends BaseController return toAjax(payProductService.updateTerm(term)); } - /** 勾选的应付产品各自带出附加费开关。productIds 用逗号分隔 */ - @PreAuthorize("@ss.hasPermi('price:pay:query')") - @GetMapping("/feeRule/list") - public AjaxResult feeRuleList(@RequestParam String productIds) - { - List ids = new ArrayList<>(); - if (productIds != null) - { - for (String part : productIds.split(",")) - { - if (part != null && !part.isBlank()) - { - ids.add(Long.valueOf(part.trim())); - } - } - } - return success(payProductService.listFeeRules(ids)); - } - - /** 按产品保存附加费开关。多个产品各写一份 */ - @PreAuthorize("@ss.hasPermi('price:pay:edit')") - @Log(title = "应付附加费开关", businessType = BusinessType.UPDATE) - @PutMapping("/feeRule") - public AjaxResult saveFeeRule(@RequestBody List products) - { - payProductService.saveFeeRules(products); - return success(); - } - @PreAuthorize("@ss.hasPermi('price:pay:edit')") @Log(title = "应付有效期", businessType = BusinessType.UPDATE) @PutMapping("/term/status") @@ -200,10 +169,19 @@ public class PayProductController extends BaseController @PreAuthorize("@ss.hasPermi('price:pay:import')") @Log(title = "应付配置", businessType = BusinessType.IMPORT) @PostMapping(value = "/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public AjaxResult preview(MultipartFile file, @RequestParam(value = "dictValue", required = false) String dictValue) throws Exception + public AjaxResult preview(MultipartFile file, @RequestParam(value = "dictValue", required = false) String dictValue, + @RequestParam(value = "progressId", required = false) String progressId) throws Exception { assertWorkbook(file); - return success(payProductService.preview(file.getBytes(), file.getOriginalFilename(), dictValue)); + return success(payProductService.preview(file.getBytes(), file.getOriginalFilename(), dictValue, progressId)); + } + + /** 导入弹框轮询解析进度。没有进度编号时返回等待中。 */ + @PreAuthorize("@ss.hasPermi('price:pay:import')") + @GetMapping("/preview/progress") + public AjaxResult previewProgress(@RequestParam(value = "progressId", required = false) String progressId) + { + return success(payProductService.parseProgress(progressId)); } /** 修改已导入的公共模板 */ diff --git a/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayCostCalculatorTest.java b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayCostCalculatorTest.java index 52284ae0..7c361881 100644 --- a/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayCostCalculatorTest.java +++ b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayCostCalculatorTest.java @@ -26,6 +26,63 @@ class PayCostCalculatorTest assertTrue(result.getSkipped().stream().anyMatch(text -> text.contains("签名费"))); } + @Test + void spainCttAddsPerKgAfterTheLastWeightBand() + { + PayProduct product = new PayProduct(); + product.setProductName("西班牙CTT"); + product.setCurrency("EUR"); + product.setWeightBands(new ArrayList<>(List.of( + band("0", "1", "2.10", "Mainland / Mad High Density"), + band("4", "5", "3.18", "Mainland / Mad High Density")))); + PaySurcharge extra = fee(31L, "每公斤续重", "0.27", "EUR", "EXTRA_KG", "5"); + extra.setZoneName("Mainland / Mad High Density"); + extra.setPriceUnit("EUR/KG"); + product.setSurcharges(List.of(extra)); + + PayOrderCostResult inside = PayCostCalculator.calculate(product, new BigDecimal("5"), + null, null, null, null, "Mainland / Mad High Density", null, 1); + assertTrue(hasFee(inside, "基础运费", "3.18")); + assertTrue(inside.getFees().stream().noneMatch(fee -> "每公斤续重".equals(fee.getFeeName()))); + + PayOrderCostResult over = PayCostCalculator.calculate(product, new BigDecimal("7"), + null, null, null, null, "Mainland / Mad High Density", null, 1); + assertTrue(hasFee(over, "基础运费", "3.18")); + assertTrue(hasFee(over, "每公斤续重", "0.54")); + assertEquals(new BigDecimal("3.72"), over.getPayTotal()); + } + + @Test + void extraKgFollowsTheSameServiceSection() + { + PayProduct product = new PayProduct(); + product.setProductName("岛屿"); + product.setCurrency("EUR"); + PayWeightBand standard = band("0", "1", "13.12", "Canary Islands / Minor Islands"); + standard.setSectionName("2. INSULAR RATES / STANDARD"); + standard.setWeightLabel("Up to 1 kg"); + PayWeightBand express = band("0", "1", "14.10", "Canary Islands / Minor Islands"); + express.setSectionName("2. INSULAR RATES / EXPRESS"); + express.setWeightLabel("Up to 1 kg"); + product.setWeightBands(new ArrayList<>(List.of(standard, express))); + PaySurcharge standardKg = fee(41L, "每公斤续重", "1.86", "EUR", "EXTRA_KG", "1"); + standardKg.setZoneName("Canary Islands / Minor Islands"); + standardKg.setSectionName("2. INSULAR RATES / STANDARD"); + standardKg.setPriceUnit("EUR/KG"); + PaySurcharge expressKg = fee(42L, "每公斤续重", "4.72", "EUR", "EXTRA_KG", "1"); + expressKg.setZoneName("Canary Islands / Minor Islands"); + expressKg.setSectionName("2. INSULAR RATES / EXPRESS"); + expressKg.setPriceUnit("EUR/KG"); + product.setSurcharges(List.of(standardKg, expressKg)); + + PayOrderCostResult over = PayCostCalculator.calculate(product, new BigDecimal("2"), + null, null, null, null, "Canary Islands / Minor Islands", null, 1); + assertTrue(hasFee(over, "基础运费", "13.12")); + assertTrue(hasFee(over, "每公斤续重", "1.86")); + assertEquals(1, over.getFees().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName())).count()); + assertEquals(new BigDecimal("14.98"), over.getPayTotal()); + } + @Test void overweightOutsideTheCardDoesNotInventFreight() { 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 99e7bfff..3927b095 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 @@ -9,6 +9,7 @@ import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; +import com.bms.price.domain.PayOrderCostResult; import com.bms.price.domain.PayPreview; import com.bms.price.domain.PayProduct; import com.bms.price.domain.PaySurcharge; @@ -482,6 +483,10 @@ class PayWorkbookParserTest void spainCttIslandsReadsFiveRateTables() throws Exception { Path file = Path.of("D:/payment/西班牙CTT带电专线(岛屿+其他地区).xlsx"); + if (!Files.exists(file)) + { + file = Path.of("D:/A-payment/西班牙CTT带电专线(岛屿+其他地区).xlsx"); + } Assumptions.assumeTrue(Files.exists(file)); PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "西班牙CTT带电专线(岛屿+其他地区)", List.of("西班牙CTT带电专线(岛屿+其他地区)")); @@ -491,45 +496,70 @@ class PayWorkbookParserTest assertEquals("续重计费", ctt.getChargeType()); assertTrue(covers(ctt.getExtraPerKg(), "1.86")); assertTrue(covers(ctt.getExtraPerKg(), "5.79")); - assertEquals(48, ctt.getWeightBands().size()); - assertEquals(4, ctt.getSurcharges().size()); + assertEquals(24, ctt.getWeightBands().size()); + assertEquals(24, ctt.getSurcharges().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName())).count()); + assertEquals(4, ctt.getSurcharges().stream().filter(fee -> "Cubic Capacity".equals(fee.getFeeName())).count()); assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "2. INSULAR RATES / STANDARD".equals(band.getSectionName()) && "Balearic Islands Economy 48H / Majorca".equals(band.getZoneName()) && "西班牙".equals(band.getCountryName()) && "ES".equals(band.getCountryCode()) && "Up to 1 kg".equals(band.getWeightLabel()) && decimal(band, "3.39") && "每件".equals(band.getChargeName()) && "EUR/件".equals(band.getPriceUnit()) && band.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0)); - assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "2. INSULAR RATES / STANDARD".equals(band.getSectionName()) - && "Canary Islands / Minor Islands".equals(band.getZoneName()) && "Additional Kg".equals(band.getWeightLabel()) - && decimal(band, "1.86") && "每公斤".equals(band.getChargeName()) && band.getWeightMin() == null)); + assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getWeightLabel() != null && band.getWeightLabel().contains("Additional"))); + assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName()) + && "2. INSULAR RATES / STANDARD".equals(fee.getSectionName()) + && "Canary Islands / Minor Islands".equals(fee.getZoneName()) && decimalFee(fee, "1.86") + && "EUR".equals(fee.getCurrency()) && "EUR/KG".equals(fee.getPriceUnit()) + && "EXTRA_KG".equals(fee.getCalcRule()) && "1".equals(fee.getCalcParam()))); assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "2. INSULAR RATES / EXPRESS".equals(band.getSectionName()) && "Canary Islands / Mayor Islands".equals(band.getZoneName()) && "Up to 1 kg".equals(band.getWeightLabel()) && decimal(band, "7.46"))); assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Price WITH CUSTOMS CLEARANCE / STANDARD".equals(band.getSectionName()) && "Canary Islands (*) / Mayor Islands".equals(band.getZoneName()) && decimal(band, "5.33"))); - assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Price WITH CUSTOMS CLEARANCE / EXPRESS".equals(band.getSectionName()) - && "Portuguese Island / Island".equals(band.getZoneName()) && "Additional Kg".equals(band.getWeightLabel()) - && decimal(band, "5.79"))); + assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName()) + && "Price WITH CUSTOMS CLEARANCE / EXPRESS".equals(fee.getSectionName()) + && "Portuguese Island / Island".equals(fee.getZoneName()) && decimalFee(fee, "5.79"))); assertTrue(ctt.getWeightBands().stream().anyMatch(band -> band.getSectionName().contains("CEUTA") && "Ceuta".equals(band.getZoneName()) && "Up to 1 kg".equals(band.getWeightLabel()) && decimal(band, "7.61"))); - assertTrue(ctt.getWeightBands().stream().anyMatch(band -> band.getSectionName().contains("CEUTA") - && "Andorra".equals(band.getZoneName()) && "Additional Kg".equals(band.getWeightLabel()) && decimal(band, "2.85"))); - assertTrue(ctt.getSurcharges().stream().allMatch(fee -> "Cubic Capacity".equals(fee.getFeeName()) && fee.getAmount() == null - && "MANUAL".equals(fee.getCalcRule()))); - assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "2. INSULAR RATES / STANDARD".equals(fee.getSectionName()) + assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName()) + && fee.getSectionName().contains("CEUTA") && "Andorra".equals(fee.getZoneName()) && decimalFee(fee, "2.85"))); + assertTrue(ctt.getSurcharges().stream().filter(fee -> "Cubic Capacity".equals(fee.getFeeName())) + .allMatch(fee -> fee.getAmount() == null && "MANUAL".equals(fee.getCalcRule()))); + assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Cubic Capacity".equals(fee.getFeeName()) + && "2. INSULAR RATES / STANDARD".equals(fee.getSectionName()) && fee.getChargeText().contains("167 kg/m3") && fee.getChargeText().contains("333 kg/m3") && fee.getFeeDesc().contains("Balearic Islands Economy 48H / Majorca") && fee.getFeeDesc().contains("Canary Islands / Interislands"))); - assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "2. INSULAR RATES / EXPRESS".equals(fee.getSectionName()) + assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Cubic Capacity".equals(fee.getFeeName()) + && "2. INSULAR RATES / EXPRESS".equals(fee.getSectionName()) && fee.getFeeDesc().contains("Portuguese Island / Island"))); - assertTrue(ctt.getSurcharges().stream().noneMatch(fee -> fee.getSectionName() != null && fee.getSectionName().contains("CEUTA"))); + assertTrue(ctt.getSurcharges().stream().noneMatch(fee -> "Cubic Capacity".equals(fee.getFeeName()) + && fee.getSectionName() != null && fee.getSectionName().contains("CEUTA"))); assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getZoneName() != null && band.getZoneName().contains("19E"))); assertTrue(ctt.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null && (fee.getFeeName().contains("计费重量") || fee.getFeeName().contains("4.95") || fee.getFeeName().contains("退货")))); + PayOrderCostResult oneKg = PayCostCalculator.calculate(ctt, new java.math.BigDecimal("1"), null, null, null, null, + "Balearic Islands Economy 48H / Majorca", null, 1); + assertEquals(new java.math.BigDecimal("3.39"), oneKg.getPayTotal()); + assertTrue(oneKg.getFees().stream().noneMatch(fee -> "每公斤续重".equals(fee.getFeeName()))); + PayOrderCostResult twoKg = PayCostCalculator.calculate(ctt, new java.math.BigDecimal("2"), null, null, null, null, + "Balearic Islands Economy 48H / Majorca", null, 1); + assertEquals(new java.math.BigDecimal("4.24"), twoKg.getPayTotal()); + assertEquals(1, twoKg.getFees().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName()) + && fee.getAmount().compareTo(new java.math.BigDecimal("0.85")) == 0).count()); + PayOrderCostResult island = PayCostCalculator.calculate(ctt, new java.math.BigDecimal("2"), null, null, null, null, + "Portuguese Island / Island", null, 1); + assertEquals(new java.math.BigDecimal("12.41"), island.getPayTotal()); + assertEquals(1, island.getFees().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName()) + && fee.getAmount().compareTo(new java.math.BigDecimal("3.34")) == 0).count()); } @Test void spainCttFillsCountryAndContinuedWeight() throws Exception { Path file = Path.of("D:/payment/西班牙CTT.xlsx"); + if (!Files.exists(file)) + { + file = Path.of("D:/A-payment/西班牙CTT.xlsx"); + } Assumptions.assumeTrue(Files.exists(file)); PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "西班牙CTT", List.of("西班牙CTT")); @@ -538,7 +568,7 @@ class PayWorkbookParserTest assertEquals("西班牙", ctt.getCountryScope()); assertEquals("续重计费", ctt.getChargeType()); assertTrue(covers(ctt.getExtraPerKg(), "0.82")); - assertEquals(72, ctt.getWeightBands().size()); + assertEquals(60, ctt.getWeightBands().size()); assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "西班牙".equals(band.getCountryName()) && "ES".equals(band.getCountryCode()) && "Mainland / Mad High Density".equals(band.getZoneName()) && "1kg".equals(band.getWeightLabel()) && decimal(band, "2.1") && "每件".equals(band.getChargeName()) @@ -547,12 +577,19 @@ class PayWorkbookParserTest assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Mainland / Portugal".equals(band.getZoneName()) && "5kg".equals(band.getWeightLabel()) && decimal(band, "3.95") && band.getWeightMin().compareTo(new java.math.BigDecimal("4")) == 0)); - assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Balearic Islands Economy 48H / Majorca".equals(band.getZoneName()) - && "Kg.ad.".equals(band.getWeightLabel()) && decimal(band, "0.82") && "每公斤".equals(band.getChargeName()) - && band.getWeightMin() == null)); + assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getWeightLabel() != null + && (band.getWeightLabel().contains("Kg.ad") || band.getWeightLabel().contains("预估")))); + assertTrue(ctt.getWeightBands().stream().noneMatch(band -> "每公斤".equals(band.getChargeName()))); + assertEquals(12, ctt.getSurcharges().stream().filter(fee -> "每公斤续重".equals(fee.getFeeName())).count()); + assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName()) + && "Balearic Islands Economy 48H / Majorca".equals(fee.getZoneName()) && decimalFee(fee, "0.82") + && "EUR".equals(fee.getCurrency()) && "EUR/KG".equals(fee.getPriceUnit()) + && "EXTRA_KG".equals(fee.getCalcRule()) && "5".equals(fee.getCalcParam()) + && fee.getFeeDesc().contains("超过5kg"))); + assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "每公斤续重".equals(fee.getFeeName()) + && "Canary Islands / Minor Islands".equals(fee.getZoneName()) && decimalFee(fee, "1.83"))); assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Canary Islands / Minor Islands".equals(band.getZoneName()) && "1kg".equals(band.getWeightLabel()) && decimal(band, "13.11"))); - assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getWeightLabel() != null && band.getWeightLabel().contains("预估"))); } @Test 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 52e79cd4..6adce62b 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 @@ -63,6 +63,15 @@ public class ExcelGrid * 按指定行数上限读取。邮编分区表会超过价卡默认的 2500 行。 */ public static ExcelGrid read(Sheet sheet, int maxRows) + { + return read(sheet, maxRows, null); + } + + /** + * 读取工作表,并在扫描、填值时回报行号。progress 为空时与 {@link #read(Sheet, int)} 相同。 + * phase 0 是统计列宽,phase 1 是读取单元格。 + */ + public static ExcelGrid read(Sheet sheet, int maxRows, ReadProgress progress) { int limit = maxRows < 1 ? MAX_ROWS : maxRows; int lastRow = sheet == null || sheet.getLastRowNum() < 0 ? -1 : Math.min(sheet.getLastRowNum(), limit - 1); @@ -85,6 +94,7 @@ public class ExcelGrid cols = c + 1; } } + reportRow(progress, 0, r, lastRow + 1); } } int rows = lastRow + 1; @@ -113,6 +123,7 @@ public class ExcelGrid { fill(row.getCell(c), text, number, linkSheet, r, c); } + reportRow(progress, 1, r, rows); } for (CellRangeAddress region : sheet.getMergedRegions()) { @@ -144,6 +155,27 @@ public class ExcelGrid return new ExcelGrid(text, number, linkSheet, colSpan, rowAnchor, rows, cols); } + /** 读取进度。大约每 20 行回报一次,避免大表回调过密。 */ + private static void reportRow(ReadProgress progress, int phase, int row, int total) + { + if (progress == null || total < 1) + { + return; + } + if (row % 20 != 0 && row != total - 1) + { + return; + } + progress.onRow(phase, row, total); + } + + /** 工作表读取进度。phase 0 统计列宽,phase 1 读取单元格。 */ + @FunctionalInterface + public interface ReadProgress + { + void onRow(int phase, int row, int total); + } + public int rows() { return rows; diff --git a/bms-back/bms-system/src/main/java/com/bms/masterdata/domain/LineProduct.java b/bms-back/bms-system/src/main/java/com/bms/masterdata/domain/LineProduct.java index 99365258..db9a5eb0 100644 --- a/bms-back/bms-system/src/main/java/com/bms/masterdata/domain/LineProduct.java +++ b/bms-back/bms-system/src/main/java/com/bms/masterdata/domain/LineProduct.java @@ -3,6 +3,7 @@ package com.bms.masterdata.domain; import java.math.BigDecimal; import java.util.Date; import com.fasterxml.jackson.annotation.JsonFormat; +import com.bms.common.annotation.Excel; import lombok.Data; /** @@ -14,72 +15,136 @@ import lombok.Data; @Data public class LineProduct { - /** 源主键 ID */ + /** 源主键 ID。列表、表单和导出都不展示。 */ private String id; /** 产品(线路)编码 */ + @Excel(name = "产品编码") private String lineCode; /** 产品(线路)名称 */ + @Excel(name = "产品名称") private String lineName; + @Excel(name = "产品类型") private String productType; + + @Excel(name = "线路类型") private String lineType; /** 是否可收货(1是 0否) */ + @Excel(name = "是否可收货", readConverterExp = "1=是,0=否") private String blCargo; + @Excel(name = "货物名称") private String cargoName; + @Excel(name = "仓储服务名称") private String productStorage; + + @Excel(name = "头程服务名称") private String productFirst; + + @Excel(name = "清关服务名称") private String productClearance; + + @Excel(name = "仓储服务编码") private String productStorageCode; + + @Excel(name = "头程服务编码") private String productFirstCode; + + @Excel(name = "清关服务编码") private String productClearanceCode; /** 是否启用(1启用 0停用) */ + @Excel(name = "是否启用", readConverterExp = "1=启用,0=停用") private String blOpen; /** 是否分段(1是 0否) */ + @Excel(name = "是否分段", readConverterExp = "1=是,0=否") private String blSection; + @Excel(name = "派送商编码") private String dispCode; + + @Excel(name = "派送商名称") private String dispName; + + @Excel(name = "费用类别") private String feeClass; + + @Excel(name = "发货人ID") private String shipperId; /** 发货规则 */ + @Excel(name = "发货规则") private String shipmentRule; /** 限重(kg) */ + @Excel(name = "限重(kg)") private BigDecimal weight; + @Excel(name = "货物类型") private String cargoType; + + @Excel(name = "产品分类") private String productCategory; /** 体积基数(抛货系数) */ + @Excel(name = "体积基数") private BigDecimal volumeBasis; + @Excel(name = "重量类型") private String weightType; + + @Excel(name = "服务类型") private String serviceType; + + @Excel(name = "发货类型") private String shipmentType; + @Excel(name = "税费金额") private BigDecimal taxAmount; + + @Excel(name = "税费币种") private String taxCurrency; + + @Excel(name = "电池类型") private String batteryType; + + @Excel(name = "备注") private String remark; + /** 源创建人,页面不展示 */ private String createName; + + /** 源创建人工号,页面不展示 */ private String createManCode; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + @Excel(name = "创建时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date createDate; + + @Excel(name = "修改人") private String modifier; + + @Excel(name = "修改人工号") private String modifierCode; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + @Excel(name = "修改时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date modifyDate; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + @Excel(name = "GMT创建时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date gmtCreate; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + @Excel(name = "GMT修改时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date gmtModified; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + @Excel(name = "同步时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date syncTime; } diff --git a/bms-back/bms-system/src/main/java/com/bms/masterdata/mapper/LineProductMapper.java b/bms-back/bms-system/src/main/java/com/bms/masterdata/mapper/LineProductMapper.java new file mode 100644 index 00000000..05508273 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/masterdata/mapper/LineProductMapper.java @@ -0,0 +1,26 @@ +package com.bms.masterdata.mapper; + +import java.util.List; +import com.bms.masterdata.domain.LineProduct; + +/** + * 路线产品 srv_line_product + */ +public interface LineProductMapper +{ + List selectLineProductList(LineProduct query); + + LineProduct selectLineProductById(String id); + + LineProduct selectLineProductByCode(String lineCode); + + int countProductChannelByProductId(String productId); + + int insertLineProduct(LineProduct product); + + int updateLineProduct(LineProduct product); + + int updateLineProductStatus(LineProduct product); + + int deleteLineProductByIds(String[] ids); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/masterdata/service/ILineProductService.java b/bms-back/bms-system/src/main/java/com/bms/masterdata/service/ILineProductService.java new file mode 100644 index 00000000..5e88cd79 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/masterdata/service/ILineProductService.java @@ -0,0 +1,22 @@ +package com.bms.masterdata.service; + +import java.util.List; +import com.bms.masterdata.domain.LineProduct; + +/** + * 路线产品 + */ +public interface ILineProductService +{ + List selectLineProductList(LineProduct query); + + LineProduct selectLineProductById(String id); + + int insertLineProduct(LineProduct product); + + int updateLineProduct(LineProduct product); + + int updateLineProductStatus(LineProduct product); + + int deleteLineProductByIds(String[] ids); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/masterdata/service/impl/LineProductServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/masterdata/service/impl/LineProductServiceImpl.java new file mode 100644 index 00000000..98c7ac16 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/masterdata/service/impl/LineProductServiceImpl.java @@ -0,0 +1,228 @@ +package com.bms.masterdata.service.impl; + +import java.math.BigDecimal; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.StringUtils; +import com.bms.common.utils.uuid.IdUtils; +import com.bms.masterdata.domain.LineProduct; +import com.bms.masterdata.mapper.LineProductMapper; +import com.bms.masterdata.service.ILineProductService; + +/** + * 路线产品 + */ +@Service +public class LineProductServiceImpl implements ILineProductService +{ + @Autowired + private LineProductMapper lineProductMapper; + + @Override + public List selectLineProductList(LineProduct query) + { + return lineProductMapper.selectLineProductList(query); + } + + @Override + public LineProduct selectLineProductById(String id) + { + return lineProductMapper.selectLineProductById(id); + } + + @Override + public int insertLineProduct(LineProduct product) + { + prepare(product, true); + product.setId(IdUtils.fastSimpleUUID()); + assertCodeUnique(product); + return lineProductMapper.insertLineProduct(product); + } + + @Override + public int updateLineProduct(LineProduct product) + { + if (product == null || StringUtils.isEmpty(product.getId())) + { + throw new ServiceException("路线产品不存在"); + } + LineProduct existing = lineProductMapper.selectLineProductById(product.getId()); + if (existing == null) + { + throw new ServiceException("路线产品不存在或已被删除"); + } + prepare(product, false); + assertCodeUnique(product); + return lineProductMapper.updateLineProduct(product); + } + + @Override + public int updateLineProductStatus(LineProduct product) + { + if (product == null || StringUtils.isEmpty(product.getId())) + { + throw new ServiceException("路线产品不存在"); + } + if (!"0".equals(product.getBlOpen()) && !"1".equals(product.getBlOpen())) + { + throw new ServiceException("启用状态只能是启用或停用"); + } + LineProduct existing = lineProductMapper.selectLineProductById(product.getId()); + if (existing == null) + { + throw new ServiceException("路线产品不存在或已被删除"); + } + return lineProductMapper.updateLineProductStatus(product); + } + + @Override + public int deleteLineProductByIds(String[] ids) + { + if (ids == null || ids.length == 0) + { + throw new ServiceException("请选择要删除的路线产品"); + } + for (String id : ids) + { + if (StringUtils.isEmpty(id)) + { + continue; + } + LineProduct existing = lineProductMapper.selectLineProductById(id); + if (existing == null) + { + continue; + } + if (lineProductMapper.countProductChannelByProductId(id) > 0) + { + throw new ServiceException("路线产品「" + existing.getLineName() + "」已被渠道关系引用,不能删除,请改为停用"); + } + } + return lineProductMapper.deleteLineProductByIds(ids); + } + + private void prepare(LineProduct product, boolean creating) + { + if (product == null) + { + throw new ServiceException("路线产品不能为空"); + } + product.setLineCode(StringUtils.trim(product.getLineCode())); + product.setLineName(StringUtils.trim(product.getLineName())); + product.setProductType(limit(product.getProductType(), 20, "产品类型")); + product.setLineType(limit(product.getLineType(), 50, "线路类型")); + product.setCargoName(limit(product.getCargoName(), 100, "货物名称")); + product.setProductStorage(trimToNull(product.getProductStorage())); + product.setProductFirst(trimToNull(product.getProductFirst())); + product.setProductClearance(trimToNull(product.getProductClearance())); + product.setProductStorageCode(limit(product.getProductStorageCode(), 1000, "仓储服务编码")); + product.setProductFirstCode(limit(product.getProductFirstCode(), 1000, "头程服务编码")); + product.setProductClearanceCode(limit(product.getProductClearanceCode(), 1000, "清关服务编码")); + product.setDispCode(limit(product.getDispCode(), 60, "派送商编码")); + product.setDispName(limit(product.getDispName(), 100, "派送商名称")); + product.setFeeClass(limit(product.getFeeClass(), 100, "费用类别")); + product.setShipperId(limit(product.getShipperId(), 64, "发货人ID")); + product.setShipmentRule(flagOrNull(product.getShipmentRule(), "发货规则")); + product.setCargoType(limit(product.getCargoType(), 64, "货物类型")); + product.setProductCategory(limit(product.getProductCategory(), 100, "产品分类")); + product.setWeightType(flagOrNull(product.getWeightType(), "重量类型")); + product.setServiceType(flagOrNull(product.getServiceType(), "服务类型")); + product.setShipmentType(limit(product.getShipmentType(), 32, "发货类型")); + product.setTaxCurrency(limit(product.getTaxCurrency(), 16, "税费币种")); + product.setBatteryType(limit(product.getBatteryType(), 64, "电池类型")); + product.setRemark(limit(product.getRemark(), 500, "备注")); + product.setBlCargo(yesNo(product.getBlCargo(), "是否可收货")); + product.setBlSection(yesNo(product.getBlSection(), "是否分段")); + if (StringUtils.isEmpty(product.getLineCode())) + { + throw new ServiceException("产品编码不能为空"); + } + if (product.getLineCode().length() > 64) + { + throw new ServiceException("产品编码不能超过64个字符"); + } + if (StringUtils.isEmpty(product.getLineName())) + { + throw new ServiceException("产品名称不能为空"); + } + if (product.getLineName().length() > 200) + { + throw new ServiceException("产品名称不能超过200个字符"); + } + if (StringUtils.isEmpty(product.getBlOpen())) + { + if (creating) + { + product.setBlOpen("1"); + } + else + { + throw new ServiceException("请选择启用状态"); + } + } + if (!"0".equals(product.getBlOpen()) && !"1".equals(product.getBlOpen())) + { + throw new ServiceException("启用状态只能是启用或停用"); + } + if (product.getWeight() != null && product.getWeight().compareTo(BigDecimal.ZERO) < 0) + { + throw new ServiceException("限重不能为负数"); + } + if (product.getVolumeBasis() != null && product.getVolumeBasis().compareTo(BigDecimal.ZERO) < 0) + { + throw new ServiceException("体积基数不能为负数"); + } + if (product.getTaxAmount() != null && product.getTaxAmount().compareTo(BigDecimal.ZERO) < 0) + { + throw new ServiceException("税费金额不能为负数"); + } + } + + private void assertCodeUnique(LineProduct product) + { + LineProduct same = lineProductMapper.selectLineProductByCode(product.getLineCode()); + if (same != null && !StringUtils.equals(same.getId(), product.getId())) + { + throw new ServiceException("产品编码「" + product.getLineCode() + "」已存在"); + } + } + + private String yesNo(String value, String label) + { + String text = trimToNull(value); + if (text != null && !"0".equals(text) && !"1".equals(text)) + { + throw new ServiceException(label + "只能是是或否"); + } + return text; + } + + /** 单字符标记:空则清空,有值时只能是一个字符。 */ + private String flagOrNull(String value, String label) + { + String text = trimToNull(value); + if (text != null && text.length() > 1) + { + throw new ServiceException(label + "只能填写一个字符"); + } + return text; + } + + private String limit(String value, int max, String label) + { + String text = trimToNull(value); + if (text != null && text.length() > max) + { + throw new ServiceException(label + "不能超过" + max + "个字符"); + } + return text; + } + + private String trimToNull(String value) + { + String text = StringUtils.trim(value); + return StringUtils.isEmpty(text) ? null : text; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayFeeRuleGroup.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayFeeRuleGroup.java deleted file mode 100644 index f5a57fcc..00000000 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayFeeRuleGroup.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.bms.price.domain; - -import java.util.ArrayList; -import java.util.List; -import lombok.Data; - -/** - * 一个应付产品上的附加费开关。多个产品各自一组,互不影响。 - */ -@Data -public class PayFeeRuleGroup -{ - private Long productId; - - private String productName; - - private List rules = new ArrayList<>(); -} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayParseProgress.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayParseProgress.java new file mode 100644 index 00000000..627f75fb --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayParseProgress.java @@ -0,0 +1,62 @@ +package com.bms.price.domain; + +/** + * 应付价卡解析进度。给导入弹框轮询,不入库。 + */ +public class PayParseProgress +{ + public static final String PENDING = "PENDING"; + + public static final String RUNNING = "RUNNING"; + + public static final String SUCCESS = "SUCCESS"; + + public static final String FAIL = "FAIL"; + + private int percent; + + private String message; + + private String status; + + public PayParseProgress() + { + } + + public PayParseProgress(int percent, String message, String status) + { + this.percent = percent; + this.message = message; + this.status = status; + } + + public int getPercent() + { + return percent; + } + + public void setPercent(int percent) + { + this.percent = percent; + } + + public String getMessage() + { + return message; + } + + public void setMessage(String message) + { + this.message = message; + } + + public String getStatus() + { + return status; + } + + public void setStatus(String status) + { + this.status = status; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductFeeRule.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductFeeRule.java deleted file mode 100644 index dd882911..00000000 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductFeeRule.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.bms.price.domain; - -import java.math.BigDecimal; -import com.bms.common.core.domain.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * 应付产品附加费开关。挂在根产品上,价格仍在价卡版本的附加费行里。 - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class PayProductFeeRule extends BaseEntity -{ - private static final long serialVersionUID = 1L; - - private Long ruleId; - - /** 根产品编号 */ - private Long productId; - - /** 附加费名称,用来到价卡版本里取价 */ - private String feeName; - - /** 1 参与计算,0 不参与 */ - private String enabled; - - /** SIZE 尺寸,VOLUME 体积,WEIGHT 重量,POSTCODE 邮编,ALWAYS 每票,RETURN 退件 */ - private String triggerType; - - private BigDecimal sideMaxCm; - - private BigDecimal sumMaxCm; - - private BigDecimal volumeMaxCm3; - - private BigDecimal minLengthCm; - - private BigDecimal minWidthCm; - - private BigDecimal minHeightCm; - - private BigDecimal weightMaxKg; - - /** ANY 任一条超出就收,ALL 写了的条件都超出才收 */ - private String joinMode; -} 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 461d52c2..7c0dca04 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 @@ -51,7 +51,7 @@ public class PaySurcharge /** 固定收取、产生时收取、旺季期间 */ private String chargeTiming; - /** ALWAYS、POSTCODE、WEIGHT、SIZE、RETURN、MANUAL */ + /** ALWAYS、POSTCODE、WEIGHT、SIZE、RETURN、EXTRA_KG、MANUAL */ private String calcRule; /** 邮编列表、重量阈值或尺寸表达式 */ diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayProductFeeRuleMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayProductFeeRuleMapper.java deleted file mode 100644 index 0f74bac2..00000000 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayProductFeeRuleMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.bms.price.mapper; - -import java.util.List; -import org.apache.ibatis.annotations.Param; -import com.bms.price.domain.PayProductFeeRule; - -/** - * 应付产品附加费开关 - */ -public interface PayProductFeeRuleMapper -{ - List selectByProductId(Long productId); - - int deleteByProductId(Long productId); - - int deleteByProductIds(@Param("productIds") List productIds); - - int insertBatch(@Param("list") List list); -} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java index 7bcb2a96..103abc87 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java @@ -68,8 +68,14 @@ public final class PayCostCalculator PayWeightBand band = matchBand(bands, weight); if (band == null) { - result.setMessage(outOfRange(product, bands, weight)); - return result; + PayWeightBand top = highestBand(bands); + if (top == null || weight.compareTo(top.getWeightMax()) <= 0 + || !hasExtraKg(product, zoneName, top.getWeightMax(), top.getSectionName())) + { + result.setMessage(outOfRange(product, bands, weight)); + return result; + } + band = top; } String currency = currencyOf(product, band); BigDecimal freight = bandAmount(band, weight); @@ -81,7 +87,8 @@ public final class PayCostCalculator PayCostLine freightLine = line("BASIC_FREIGHT", "基础运费", 0L, freight, currency, freightRemark); result.getFees().add(freightLine); result.setCurrency(currency); - addSurcharges(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, freight, currency, result); + addSurcharges(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, freight, currency, + band.getSectionName(), result); BigDecimal total = BigDecimal.ZERO; for (PayCostLine fee : result.getFees()) { @@ -111,7 +118,7 @@ public final class PayCostCalculator private static void addSurcharges(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus, BigDecimal freight, String currency, - PayOrderCostResult result) + String freightSection, PayOrderCostResult result) { if (product.getSurcharges() == null) { @@ -171,6 +178,13 @@ public final class PayCostCalculator addFlat(result.getFees(), fee, currency, "订单已退件"); } } + else if (PaySurchargeRules.EXTRA_KG.equals(rule)) + { + if (sameSection(fee.getSectionName(), freightSection)) + { + addExtraKg(result.getFees(), fee, weight, currency); + } + } else if (PaySurchargeRules.ALWAYS.equals(rule)) { PayCostLine matched = matchedFee(fee, freight, currency, blank(fee.getChargeText())); @@ -214,6 +228,97 @@ public final class PayCostCalculator return kept; } + private static void addExtraKg(List fees, PaySurcharge fee, BigDecimal weight, String currency) + { + if (fee.getAmount() == null || weight == null) + { + return; + } + BigDecimal threshold; + try + { + threshold = new BigDecimal(fee.getCalcParam() == null ? "" : fee.getCalcParam().trim()); + } + catch (NumberFormatException ex) + { + return; + } + if (weight.compareTo(threshold) <= 0) + { + return; + } + String feeCurrency = normalize(fee.getCurrency()); + if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency)) + { + return; + } + BigDecimal over = weight.subtract(threshold); + BigDecimal amount = fee.getAmount().multiply(over).setScale(2, RoundingMode.HALF_UP); + long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId(); + String remark = "超出 " + plain(over) + "kg × " + plain(fee.getAmount()) + " " + blank(fee.getPriceUnit()); + fees.add(line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark)); + } + + /** 同一地区有多张服务表时,续重只跟本次首重所在的那一张表。 */ + private static boolean sameSection(String feeSection, String freightSection) + { + if (feeSection == null || feeSection.isBlank() || freightSection == null || freightSection.isBlank()) + { + return true; + } + return feeSection.trim().equals(freightSection.trim()); + } + + /** 重量超过最高档,且该地区、该服务表有每公斤续重时,基础运费先用最高档。 */ + private static boolean hasExtraKg(PayProduct product, String zoneName, BigDecimal threshold, String section) + { + if (product.getSurcharges() == null || threshold == null) + { + return false; + } + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee == null || !PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule()) || fee.getAmount() == null) + { + continue; + } + if (zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank() + && !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName)) + { + continue; + } + if (!sameSection(fee.getSectionName(), section)) + { + continue; + } + try + { + if (fee.getCalcParam() != null && new BigDecimal(fee.getCalcParam().trim()).compareTo(threshold) == 0) + { + return true; + } + } + catch (NumberFormatException ex) + { + continue; + } + } + return false; + } + + private static PayWeightBand highestBand(List bands) + { + PayWeightBand top = null; + for (PayWeightBand band : bands) + { + if (top == null || band.getWeightMax().compareTo(top.getWeightMax()) > 0) + { + top = band; + } + } + return top; + } + private static void addFlat(List fees, PaySurcharge fee, String currency, String remark) { PayCostLine line = matchedFee(fee, null, currency, remark); diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayParseProgressListener.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayParseProgressListener.java new file mode 100644 index 00000000..5f5bb56e --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayParseProgressListener.java @@ -0,0 +1,10 @@ +package com.bms.price.pay; + +/** + * 应付价卡解析进度。只用于界面展示,不参与识别。 + */ +@FunctionalInterface +public interface PayParseProgressListener +{ + void onProgress(int percent, String message); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PaySurchargeRules.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PaySurchargeRules.java index d7928195..44a299cc 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/pay/PaySurchargeRules.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PaySurchargeRules.java @@ -26,6 +26,9 @@ public final class PaySurchargeRules public static final String MANUAL = "MANUAL"; + /** 超过重量段上限后,按超出的公斤数乘单价 */ + public static final String EXTRA_KG = "EXTRA_KG"; + private static final Pattern RANGE = Pattern.compile("([A-Z]{1,2})(\\d{1,2})\\s*-\\s*(\\d{1,2})"); private static final Pattern DISTRICT = Pattern.compile("([A-Z]{1,2})(\\d{1,2})"); 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 9ccfc0b3..b6666ea0 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 @@ -112,6 +112,15 @@ public class PayWorkbookParser * 单元格里用分号隔开的国家、分区或费用名称,拆成多条。 */ public PayPreview parseMatched(byte[] bytes, String filename, String selected, Collection dictValues) + { + return parseMatched(bytes, filename, selected, dictValues, null); + } + + /** + * 与 {@link #parseMatched(byte[], String, String, Collection)} 相同。listener 只接收进度,不改变识别结果。 + */ + public PayPreview parseMatched(byte[] bytes, String filename, String selected, Collection dictValues, + PayParseProgressListener listener) { if (bytes == null || bytes.length == 0) { @@ -128,6 +137,7 @@ public class PayWorkbookParser } String safeName = filename == null ? "价卡.xlsx" : filename; String wanted = norm(selected); + report(listener, 8, "正在打开工作簿"); try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) { if (workbook.getNumberOfSheets() == 0) @@ -141,29 +151,43 @@ public class PayWorkbookParser preview.setSupplierName(meta.supplier); preview.setEffectiveDate(PayTexts.toDate(meta.effective)); List skipped = new ArrayList<>(); - for (int i = 0; i < workbook.getNumberOfSheets(); i++) + int sheetCount = workbook.getNumberOfSheets(); + for (int i = 0; i < sheetCount; i++) { Sheet sheet = workbook.getSheetAt(i); String sheetName = sheet.getSheetName() == null ? "" : sheet.getSheetName().trim(); + int start = 12 + i * 78 / sheetCount; + int end = 12 + (i + 1) * 78 / sheetCount; String dictValue = dict.get(norm(sheetName)); + boolean holdForFallback = sheetCount == 1 && !wanted.isEmpty(); if (dictValue == null) { skipped.add(sheetName); + if (!holdForFallback) + { + report(listener, end, "已跳过「" + sheetName + "」"); + } continue; } if (!wanted.isEmpty() && !wanted.equals(norm(dictValue)) && !wanted.equals(norm(sheetName))) { skipped.add(sheetName); + if (!holdForFallback) + { + report(listener, end, "已跳过「" + sheetName + "」"); + } continue; } - preview.getProducts().add(readMatchedSheet(sheet, sheetName, dictValue, meta, preview)); + report(listener, start, "正在解析「" + sheetName + "」"); + preview.getProducts().add(readMatchedSheet(sheet, sheetName, dictValue, meta, preview, listener, start, end)); } - if (preview.getProducts().isEmpty() && !wanted.isEmpty() && workbook.getNumberOfSheets() == 1) + if (preview.getProducts().isEmpty() && !wanted.isEmpty() && sheetCount == 1) { Sheet sheet = workbook.getSheetAt(0); String sheetName = sheet.getSheetName() == null ? "" : sheet.getSheetName().trim(); String dictValue = dict.containsValue(selected.trim()) ? selected.trim() : labelOf(dict, wanted); - preview.getProducts().add(readMatchedSheet(sheet, sheetName, dictValue, meta, preview)); + report(listener, 12, "正在解析「" + sheetName + "」"); + preview.getProducts().add(readMatchedSheet(sheet, sheetName, dictValue, meta, preview, listener, 12, 90)); skipped.clear(); } if (preview.getProducts().isEmpty()) @@ -172,8 +196,10 @@ public class PayWorkbookParser ? "没有工作表能对上应付产品字典" : "文件里没有「" + selected.trim() + "」这张工作表"); } + report(listener, 92, "正在整理附加费"); attachWorkbookRates(workbook, preview.getProducts()); preview.setSkippedSheets(skipped); + report(listener, 96, "工作表解析完成"); return preview; } catch (ServiceException ex) @@ -186,7 +212,24 @@ public class PayWorkbookParser } } - private PayProduct readMatchedSheet(Sheet sheet, String sheetName, String dictValue, FileMeta meta, PayPreview preview) + private static void report(PayParseProgressListener listener, int percent, String message) + { + if (listener == null) + { + return; + } + try + { + listener.onProgress(percent, message); + } + catch (RuntimeException ignored) + { + // 进度写失败不影响价卡识别 + } + } + + private PayProduct readMatchedSheet(Sheet sheet, String sheetName, String dictValue, FileMeta meta, PayPreview preview, + PayParseProgressListener listener, int start, int end) { PayProduct product = new PayProduct(); product.setProductName(dictValue); @@ -199,11 +242,30 @@ public class PayWorkbookParser product.setWeightBands(new ArrayList<>()); product.setSurcharges(new ArrayList<>()); product.setExtraLines(new ArrayList<>()); - fillSheet(ExcelGrid.read(sheet), product); + fillSheet(readGrid(sheet, listener, start, end, sheetName), product); expandSeparated(product); + report(listener, end, "已解析「" + sheetName + "」"); return product; } + /** 读取这一张表。前半段是统计列宽,后半段是读取单元格。 */ + private ExcelGrid readGrid(Sheet sheet, PayParseProgressListener listener, int start, int end, String sheetName) + { + int span = Math.max(1, end - start); + int scanSpan = Math.max(1, span * 2 / 5); + return ExcelGrid.read(sheet, ExcelGrid.MAX_ROWS, (phase, row, total) -> { + if (total < 1) + { + return; + } + int done = Math.min(row + 1, total); + int percent = phase == 0 + ? start + scanSpan * done / total + : start + scanSpan + (span - scanSpan) * done / total; + report(listener, Math.min(end, percent), "正在解析「" + sheetName + "」"); + }); + } + /** 分号拆开的是多条数据,不是一句话。句子太长时保持原样。 */ private void expandSeparated(PayProduct product) { @@ -1848,7 +1910,8 @@ public class PayWorkbookParser /** * 岛屿价卡一张表有两级表头:上面是群岛,下面是具体岛屿。 - * Up to 1 kg 是首重,Additional Kg 是续重,Cubic Capacity 是体积重系数,不是运费。 + * Up to 1 kg 是首重,记入重量段。Additional Kg 是超出首重后的每公斤加收,记入附加费。 + * Cubic Capacity 是体积重系数,不是运费。 */ private int readInsularRates(ExcelGrid grid, int header, PayProduct product) { @@ -1869,6 +1932,7 @@ public class PayWorkbookParser zoneCols.add(col); } } + BigDecimal firstLimit = null; int row = header + 1; while (row < grid.rows() && !grid.blankRow(row)) { @@ -1887,6 +1951,10 @@ public class PayWorkbookParser row++; continue; } + if (firstKg) + { + firstLimit = firstKgLimit(label); + } for (int col : zoneCols) { String child = grid.text(header, col).replace('\n', ' ').trim(); @@ -1902,23 +1970,20 @@ public class PayWorkbookParser { continue; } + if (extraKg) + { + addExtraKg(product, section, zone, label, price, firstLimit == null ? BigDecimal.ONE : firstLimit); + continue; + } PayWeightBand band = new PayWeightBand(); band.setSectionName(section); band.setZoneName(zone); band.setWeightLabel(label); band.setPrice(price); - if (extraKg) - { - band.setChargeName("每公斤"); - band.setPriceUnit("EUR/公斤"); - } - else - { - band.setWeightMin(BigDecimal.ZERO); - band.setWeightMax(firstKgLimit(label)); - band.setChargeName("每件"); - band.setPriceUnit("EUR/件"); - } + band.setWeightMin(BigDecimal.ZERO); + band.setWeightMax(firstKgLimit(label)); + band.setChargeName("每件"); + band.setPriceUnit("EUR/件"); band.setSortNo(product.getWeightBands().size() + 1); product.getWeightBands().add(band); } @@ -1928,7 +1993,8 @@ public class PayWorkbookParser } /** - * 西班牙 CTT:上面一行是大陆或群岛,下一行是密度和岛屿。1kg 到 5kg 是该重量的票价,Kg.ad. 是超出后的续重。 + * 西班牙 CTT:上面一行是大陆或群岛,下一行是密度和岛屿。 + * 1kg 到 5kg 是该重量的票价,记入重量段。Kg.ad. 是超出最高档后的每公斤加收,记入附加费。 */ private int readKgStepTable(ExcelGrid grid, int header, PayProduct product) { @@ -1969,25 +2035,25 @@ public class PayWorkbookParser } String child = grid.text(header, col).replace('\n', ' ').trim(); String parent = groupLabel(grid, groupRow, col, service); - PayWeightBand band = new PayWeightBand(); - band.setSectionName(service); - band.setZoneName(parent.isBlank() ? child : parent + " / " + child); - band.setWeightLabel(label); - band.setPrice(price); + String zone = parent.isBlank() ? child : parent + " / " + child; if (extra) { - band.setChargeName("每公斤"); - band.setPriceUnit("EUR/公斤"); + addExtraKg(product, service, zone, label, price, previousMax); } else { + PayWeightBand band = new PayWeightBand(); + band.setSectionName(service); + band.setZoneName(zone); + band.setWeightLabel(label); + band.setPrice(price); band.setWeightMin(minKg); band.setWeightMax(maxKg); band.setChargeName("每件"); band.setPriceUnit("EUR/件"); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); } - band.setSortNo(product.getWeightBands().size() + 1); - product.getWeightBands().add(band); } if (!extra) { @@ -1998,6 +2064,26 @@ public class PayWorkbookParser return row; } + /** Kg.ad. 按地区各记一条每公斤续重,不进入重量段。 */ + private static void addExtraKg(PayProduct product, String section, String zone, String label, BigDecimal price, BigDecimal threshold) + { + BigDecimal from = threshold == null ? BigDecimal.ZERO : threshold; + PaySurcharge fee = new PaySurcharge(); + fee.setSectionName(section); + fee.setFeeName("每公斤续重"); + fee.setZoneName(zone); + fee.setChargeText(label + " " + price.stripTrailingZeros().toPlainString() + " EUR/KG"); + fee.setAmount(price); + fee.setCurrency("EUR"); + fee.setPriceUnit("EUR/KG"); + fee.setFeeDesc("超过" + from.stripTrailingZeros().toPlainString() + "kg后,按超出的重量每公斤加收"); + fee.setChargeTiming("产生时收取"); + fee.setCalcRule(PaySurchargeRules.EXTRA_KG); + fee.setCalcParam(from.stripTrailingZeros().toPlainString()); + fee.setSortNo(product.getSurcharges().size() + 1); + product.getSurcharges().add(fee); + } + private static boolean isKgStepHeader(ExcelGrid grid, int row) { int names = 0; @@ -2260,6 +2346,16 @@ public class PayWorkbookParser itemFee = true; } } + if (product.getSurcharges() != null) + { + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee != null && PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule())) + { + extraKg = true; + } + } + } if (extraKg) { return "续重计费"; @@ -2297,6 +2393,24 @@ public class PayWorkbookParser high = band.getPrice(); } } + if (product.getSurcharges() != null) + { + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee == null || fee.getAmount() == null || !PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule())) + { + continue; + } + if (low == null || fee.getAmount().compareTo(low) < 0) + { + low = fee.getAmount(); + } + if (high == null || fee.getAmount().compareTo(high) > 0) + { + high = fee.getAmount(); + } + } + } if (low == null) { return null; 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 1e19c0b2..cd32d070 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 @@ -3,7 +3,7 @@ package com.bms.price.service; import java.io.OutputStream; import java.util.List; import java.util.Date; -import com.bms.price.domain.PayFeeRuleGroup; +import com.bms.price.domain.PayParseProgress; import com.bms.price.domain.PayPreview; import com.bms.price.domain.PayPriceCompare; import com.bms.price.domain.PayProduct; @@ -21,6 +21,14 @@ public interface IPayProductService */ PayPreview preview(byte[] bytes, String filename, String dictValue); + /** + * 与 {@link #preview(byte[], String, String)} 相同。progressId 有值时写入解析进度,供页面轮询。 + */ + PayPreview preview(byte[] bytes, String filename, String dictValue, String progressId); + + /** 读取一次导入的解析进度。没有对应任务时返回等待中。 */ + PayParseProgress parseProgress(String progressId); + /** 把预览里勾选的产品写入数据库。同名字典产品再次导入时另存一版,旧价格留在有效期表里 */ String save(PaySaveCommand command); @@ -75,10 +83,4 @@ public interface IPayProductService void writeSource(Long productId, OutputStream output); int deletePayProducts(Long[] productIds); - - /** 按根产品编号带出附加费开关。没有配置过的产品返回空列表 */ - List listFeeRules(List productIds); - - /** 按产品整组替换附加费开关。每个产品一份,互不影响 */ - void saveFeeRules(List products); } 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 4649aafc..ae0382b7 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 @@ -3,13 +3,11 @@ package com.bms.price.service.impl; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.OutputStream; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.Iterator; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -28,8 +26,7 @@ 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.PayFeeRuleGroup; -import com.bms.price.domain.PayProductFeeRule; +import com.bms.price.domain.PayParseProgress; import com.bms.price.domain.PayPreview; import com.bms.price.domain.PayPriceCompare; import com.bms.price.domain.PayPriceDiff; @@ -39,9 +36,9 @@ import com.bms.price.domain.PaySaveCommand; import com.bms.price.domain.PaySurcharge; import com.bms.price.domain.PayWeightBand; import com.bms.price.mapper.ChannelProductMapMapper; -import com.bms.price.mapper.PayProductFeeRuleMapper; import com.bms.price.mapper.PayProductMapper; import com.bms.price.mapper.PayProductTermMapper; +import com.bms.price.pay.PayParseProgressListener; import com.bms.price.pay.PayWorkbookParser; import com.bms.price.service.IPayProductService; import com.bms.system.service.ISysDictTypeService; @@ -69,11 +66,6 @@ public class PayProductServiceImpl implements IPayProductService @Autowired private PayProductTermMapper payProductTermMapper; - @Autowired - private PayProductFeeRuleMapper payProductFeeRuleMapper; - - private static final Set FEE_TRIGGERS = Set.of("SIZE", "VOLUME", "WEIGHT", "POSTCODE", "ALWAYS", "RETURN"); - @Autowired private ISysDictTypeService dictTypeService; @@ -85,23 +77,110 @@ public class PayProductServiceImpl implements IPayProductService /** 预览时已经传到 OSS 的文件。保存不再只靠前端把地址传回来。 */ private final Map uploadedSources = new ConcurrentHashMap<>(); + /** 导入弹框轮询的解析进度。只留在本机内存,过期后丢掉。 */ + private final Map parseProgress = new ConcurrentHashMap<>(); + + private static final long PARSE_PROGRESS_KEEP_MS = 15L * 60 * 1000; + 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()) + return preview(bytes, filename, dictValue, null); + } + + @Override + public PayPreview preview(byte[] bytes, String filename, String dictValue, String progressId) + { + String progressKey = progressKey(progressId); + PayParseProgressListener listener = progressKey == null ? null : (percent, message) -> + writeProgress(progressKey, percent, message, PayParseProgress.RUNNING); + if (progressKey != null) { - product.setFilePath(stored); - Long existsId = isBlank(product.getDictValue()) ? null : payProductMapper.selectTemplateId(product.getDictValue()); - product.setDuplicated(existsId != null); - product.setExistsId(existsId); + writeProgress(progressKey, 2, "正在准备解析", PayParseProgress.RUNNING); } - return preview; + try + { + PayPreview preview = parser.parseMatched(bytes, filename, dictValue, loadDictValues(), listener); + writeProgress(progressKey, 98, "正在核对已有产品", PayParseProgress.RUNNING); + 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); + } + writeProgress(progressKey, 100, "解析完成", PayParseProgress.SUCCESS); + return preview; + } + catch (RuntimeException ex) + { + String message = ex.getMessage() == null || ex.getMessage().isBlank() ? "价卡解析失败" : ex.getMessage(); + writeProgress(progressKey, 100, message, PayParseProgress.FAIL); + throw ex; + } + } + + @Override + public PayParseProgress parseProgress(String progressId) + { + String key = progressKey(progressId); + if (key == null) + { + return new PayParseProgress(0, "等待解析", PayParseProgress.PENDING); + } + ParseSlot slot = parseProgress.get(key); + if (slot == null || System.currentTimeMillis() - slot.at > PARSE_PROGRESS_KEEP_MS) + { + return new PayParseProgress(0, "等待解析", PayParseProgress.PENDING); + } + return slot.progress; + } + + private void writeProgress(String key, int percent, String message, String status) + { + if (key == null) + { + return; + } + int safePercent = Math.max(0, Math.min(100, percent)); + ParseSlot current = parseProgress.get(key); + if (current != null && safePercent < current.progress.getPercent() + && !PayParseProgress.FAIL.equals(status)) + { + return; + } + if (current != null && safePercent == current.progress.getPercent() + && message.equals(current.progress.getMessage()) + && status.equals(current.progress.getStatus())) + { + return; + } + parseProgress.put(key, new ParseSlot(new PayParseProgress(safePercent, message, status), System.currentTimeMillis())); + if (parseProgress.size() > 200) + { + long now = System.currentTimeMillis(); + parseProgress.entrySet().removeIf(entry -> now - entry.getValue().at > PARSE_PROGRESS_KEEP_MS); + } + } + + /** 只接受页面生成的短编号,避免把任意字符串当成缓存键。 */ + private static String progressKey(String progressId) + { + if (progressId == null) + { + return null; + } + String key = progressId.trim(); + if (key.length() < 8 || key.length() > 64 || !key.matches("[A-Za-z0-9-]+")) + { + return null; + } + return key; } @Override @@ -680,7 +759,6 @@ public class PayProductServiceImpl implements IPayProductService return 0; } payProductTermMapper.deleteByProductIds(ids); - payProductFeeRuleMapper.deleteByProductIds(ids); payProductMapper.deleteWeightBands(ids); payProductMapper.deleteSurcharges(ids); payProductMapper.deleteExtraLines(ids); @@ -1247,176 +1325,6 @@ public class PayProductServiceImpl implements IPayProductService return rows == null ? 0 : rows.size(); } - @Override - public List listFeeRules(List productIds) - { - List groups = new ArrayList<>(); - if (productIds == null) - { - return groups; - } - LinkedHashSet ordered = new LinkedHashSet<>(); - for (Long productId : productIds) - { - if (productId != null) - { - ordered.add(productId); - } - } - for (Long productId : ordered) - { - PayProduct root = requireRoot(productId); - PayFeeRuleGroup group = new PayFeeRuleGroup(); - group.setProductId(root.getProductId()); - group.setProductName(root.getProductName()); - List rules = payProductFeeRuleMapper.selectByProductId(root.getProductId()); - group.setRules(rules == null ? new ArrayList<>() : rules); - groups.add(group); - } - return groups; - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void saveFeeRules(List products) - { - if (products == null || products.isEmpty()) - { - throw new ServiceException("请选择应付产品"); - } - String operator = SecurityUtils.getUsername(); - Date now = DateUtils.getNowDate(); - for (PayFeeRuleGroup group : products) - { - if (group == null || group.getProductId() == null) - { - throw new ServiceException("请选择应付产品"); - } - PayProduct root = requireRoot(group.getProductId()); - List rules = normalizeFeeRules(root, group.getRules(), operator, now); - payProductFeeRuleMapper.deleteByProductId(root.getProductId()); - if (!rules.isEmpty()) - { - payProductFeeRuleMapper.insertBatch(rules); - } - } - } - - private PayProduct requireRoot(Long productId) - { - PayProduct product = payProductMapper.selectPayProductById(productId); - if (product == null) - { - throw new ServiceException("应付产品不存在"); - } - if (product.getOriginId() != null) - { - product = payProductMapper.selectPayProductById(product.getOriginId()); - if (product == null) - { - throw new ServiceException("应付产品不存在"); - } - } - return product; - } - - private List normalizeFeeRules(PayProduct root, List source, String operator, Date now) - { - List rules = new ArrayList<>(); - Set names = new HashSet<>(); - String productName = root.getProductName() == null ? String.valueOf(root.getProductId()) : root.getProductName(); - if (source == null) - { - return rules; - } - for (PayProductFeeRule raw : source) - { - if (raw == null || isBlank(raw.getFeeName())) - { - if (raw != null && (feeRuleFilled(raw) || feeRuleChanged(raw))) - { - throw new ServiceException(productName + "有未选择名称的附加费"); - } - continue; - } - String feeName = raw.getFeeName().trim(); - if (feeName.length() > 100) - { - throw new ServiceException(productName + "的附加费名称过长"); - } - if (!names.add(feeName)) - { - throw new ServiceException(productName + "的附加费「" + feeName + "」重复了"); - } - String trigger = isBlank(raw.getTriggerType()) ? "ALWAYS" : raw.getTriggerType().trim().toUpperCase(Locale.ROOT); - if (!FEE_TRIGGERS.contains(trigger)) - { - throw new ServiceException(productName + "的触发类型不正确"); - } - String joinMode = isBlank(raw.getJoinMode()) ? "ANY" : raw.getJoinMode().trim().toUpperCase(Locale.ROOT); - if (!"ANY".equals(joinMode) && !"ALL".equals(joinMode)) - { - throw new ServiceException(productName + "的命中方式不正确"); - } - String enabled = "1".equals(raw.getEnabled()) ? "1" : "0"; - checkFeeNumber(raw.getSideMaxCm(), productName, "最长边"); - checkFeeNumber(raw.getSumMaxCm(), productName, "长宽高之和"); - checkFeeNumber(raw.getVolumeMaxCm3(), productName, "体积"); - checkFeeNumber(raw.getMinLengthCm(), productName, "最小长度"); - checkFeeNumber(raw.getMinWidthCm(), productName, "最小宽度"); - checkFeeNumber(raw.getMinHeightCm(), productName, "最小高度"); - checkFeeNumber(raw.getWeightMaxKg(), productName, "重量上限"); - String remark = raw.getRemark() == null ? null : raw.getRemark().trim(); - if (remark != null && remark.length() > 500) - { - throw new ServiceException(productName + "的说明过长"); - } - PayProductFeeRule rule = new PayProductFeeRule(); - rule.setProductId(root.getProductId()); - rule.setFeeName(feeName); - rule.setEnabled(enabled); - rule.setTriggerType(trigger); - rule.setSideMaxCm(raw.getSideMaxCm()); - rule.setSumMaxCm(raw.getSumMaxCm()); - rule.setVolumeMaxCm3(raw.getVolumeMaxCm3()); - rule.setMinLengthCm(raw.getMinLengthCm()); - rule.setMinWidthCm(raw.getMinWidthCm()); - rule.setMinHeightCm(raw.getMinHeightCm()); - rule.setWeightMaxKg(raw.getWeightMaxKg()); - rule.setJoinMode(joinMode); - rule.setRemark(isBlank(remark) ? null : remark); - rule.setCreateBy(operator); - rule.setCreateTime(now); - rule.setUpdateBy(operator); - rule.setUpdateTime(now); - rules.add(rule); - } - return rules; - } - - private static boolean feeRuleChanged(PayProductFeeRule rule) - { - String trigger = rule.getTriggerType() == null ? "" : rule.getTriggerType().trim(); - String joinMode = rule.getJoinMode() == null ? "" : rule.getJoinMode().trim(); - return (!trigger.isEmpty() && !"ALWAYS".equalsIgnoreCase(trigger)) - || (!joinMode.isEmpty() && !"ANY".equalsIgnoreCase(joinMode)); - } - - private static boolean feeRuleFilled(PayProductFeeRule rule) - { - return rule.getSideMaxCm() != null || rule.getSumMaxCm() != null || rule.getVolumeMaxCm3() != null - || rule.getMinLengthCm() != null || rule.getMinWidthCm() != null || rule.getMinHeightCm() != null - || rule.getWeightMaxKg() != null || !isBlank(rule.getRemark()) || "1".equals(rule.getEnabled()); - } - - private static void checkFeeNumber(BigDecimal value, String productName, String label) - { - if (value != null && value.signum() < 0) - { - throw new ServiceException(productName + "的" + label + "不能小于0"); - } - } - private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); @@ -1427,6 +1335,19 @@ public class PayProductServiceImpl implements IPayProductService return isBlank(value) ? fallback : value; } + private static final class ParseSlot + { + private final PayParseProgress progress; + + private final long at; + + private ParseSlot(PayParseProgress progress, long at) + { + this.progress = progress; + this.at = at; + } + } + private static final class UploadedSource { private final String path; diff --git a/bms-back/bms-system/src/main/resources/mapper/masterdata/LineProductMapper.xml b/bms-back/bms-system/src/main/resources/mapper/masterdata/LineProductMapper.xml new file mode 100644 index 00000000..a054d41f --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/masterdata/LineProductMapper.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select id, line_code, line_name, product_type, line_type, bl_cargo, cargo_name, + product_storage, product_first, product_clearance, + product_storage_code, product_first_code, product_clearance_code, + bl_open, bl_section, disp_code, disp_name, fee_class, shipper_id, shipment_rule, + weight, cargo_type, product_category, volume_basis, weight_type, service_type, shipment_type, + tax_amount, tax_currency, battery_type, remark, + create_name, create_man_code, create_date, modifier, modifier_code, modify_date, + gmt_create, gmt_modified, sync_time + from srv_line_product + + + + + + + + + + + + insert into srv_line_product ( + id, line_code, line_name, product_type, line_type, bl_cargo, cargo_name, + product_storage, product_first, product_clearance, + product_storage_code, product_first_code, product_clearance_code, + bl_open, bl_section, disp_code, disp_name, fee_class, shipper_id, shipment_rule, + weight, cargo_type, product_category, volume_basis, weight_type, service_type, shipment_type, + tax_amount, tax_currency, battery_type, remark, + create_name, create_date, modifier, modify_date, gmt_create, gmt_modified + ) values ( + #{id}, #{lineCode}, #{lineName}, #{productType}, #{lineType}, #{blCargo}, #{cargoName}, + #{productStorage}, #{productFirst}, #{productClearance}, + #{productStorageCode}, #{productFirstCode}, #{productClearanceCode}, + #{blOpen}, #{blSection}, #{dispCode}, #{dispName}, #{feeClass}, #{shipperId}, #{shipmentRule}, + #{weight}, #{cargoType}, #{productCategory}, #{volumeBasis}, #{weightType}, #{serviceType}, #{shipmentType}, + #{taxAmount}, #{taxCurrency}, #{batteryType}, #{remark}, + #{createName}, sysdate(), #{modifier}, sysdate(), sysdate(), sysdate() + ) + + + + update srv_line_product + set line_code = #{lineCode}, + line_name = #{lineName}, + product_type = #{productType}, + line_type = #{lineType}, + bl_cargo = #{blCargo}, + cargo_name = #{cargoName}, + product_storage = #{productStorage}, + product_first = #{productFirst}, + product_clearance = #{productClearance}, + product_storage_code = #{productStorageCode}, + product_first_code = #{productFirstCode}, + product_clearance_code = #{productClearanceCode}, + bl_open = #{blOpen}, + bl_section = #{blSection}, + disp_code = #{dispCode}, + disp_name = #{dispName}, + fee_class = #{feeClass}, + shipper_id = #{shipperId}, + shipment_rule = #{shipmentRule}, + weight = #{weight}, + cargo_type = #{cargoType}, + product_category = #{productCategory}, + volume_basis = #{volumeBasis}, + weight_type = #{weightType}, + service_type = #{serviceType}, + shipment_type = #{shipmentType}, + tax_amount = #{taxAmount}, + tax_currency = #{taxCurrency}, + battery_type = #{batteryType}, + remark = #{remark}, + modifier = #{modifier}, + modify_date = sysdate(), + gmt_modified = sysdate() + where id = #{id} + + + + update srv_line_product + set bl_open = #{blOpen}, + modifier = #{modifier}, + modify_date = sysdate(), + gmt_modified = sysdate() + where id = #{id} + + + + delete from srv_line_product where id in + + #{id} + + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PayProductFeeRuleMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PayProductFeeRuleMapper.xml deleted file mode 100644 index 41dd74e8..00000000 --- a/bms-back/bms-system/src/main/resources/mapper/price/PayProductFeeRuleMapper.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - delete from pay_product_fee_rule where product_id = #{productId} - - - - delete from pay_product_fee_rule where product_id in - #{id} - - - - insert into pay_product_fee_rule ( - product_id, fee_name, enabled, trigger_type, side_max_cm, sum_max_cm, volume_max_cm3, - min_length_cm, min_width_cm, min_height_cm, weight_max_kg, join_mode, remark, - created_by, create_time, updated_by, update_time - ) values - - (#{item.productId}, #{item.feeName}, #{item.enabled}, #{item.triggerType}, - #{item.sideMaxCm}, #{item.sumMaxCm}, #{item.volumeMaxCm3}, - #{item.minLengthCm}, #{item.minWidthCm}, #{item.minHeightCm}, #{item.weightMaxKg}, - #{item.joinMode}, #{item.remark}, #{item.createBy}, #{item.createTime}, - #{item.updateBy}, #{item.updateTime}) - - - diff --git a/bms-back/sql/data/line_product_menu.sql b/bms-back/sql/data/line_product_menu.sql new file mode 100644 index 00000000..1e8213d4 --- /dev/null +++ b/bms-back/sql/data/line_product_menu.sql @@ -0,0 +1,66 @@ +-- 数据管理 - 路线产品管理 +-- 权限:data:lineProduct:list|query|add|edit|remove|export +SET NAMES utf8mb4; + +SET @parentId := ( + SELECT menu_id FROM sys_menu + WHERE path = 'data' AND menu_type = 'M' AND parent_id = 0 + ORDER BY menu_id ASC LIMIT 1 +); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2530, N'路线产品管理', @parentId, 4, 'lineProduct', 'data/lineProduct/index', '', 'LineProduct', 1, 0, 'C', '0', '0', 'data:lineProduct:list', 'list', 'admin', sysdate(), N'路线产品维护,数据表 srv_line_product' +FROM DUAL WHERE @parentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2530 OR perms = 'data:lineProduct:list'); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT 2531, N'查询', 2530, 1, '', NULL, 1, 0, 'F', '0', '0', 'data:lineProduct:query', '#', 'admin', sysdate() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2531 OR perms = 'data:lineProduct:query'); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT 2532, N'新增', 2530, 2, '', NULL, 1, 0, 'F', '0', '0', 'data:lineProduct:add', '#', 'admin', sysdate() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2532 OR perms = 'data:lineProduct:add'); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT 2533, N'修改', 2530, 3, '', NULL, 1, 0, 'F', '0', '0', 'data:lineProduct:edit', '#', 'admin', sysdate() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2533 OR perms = 'data:lineProduct:edit'); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT 2534, N'删除', 2530, 4, '', NULL, 1, 0, 'F', '0', '0', 'data:lineProduct:remove', '#', 'admin', sysdate() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2534 OR perms = 'data:lineProduct:remove'); + +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT 2535, N'导出', 2530, 5, '', NULL, 1, 0, 'F', '0', '0', 'data:lineProduct:export', '#', 'admin', sysdate() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2535 OR perms = 'data:lineProduct:export'); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id FROM ( + SELECT 2530 AS menu_id UNION SELECT 2531 UNION SELECT 2532 UNION SELECT 2533 UNION SELECT 2534 UNION SELECT 2535 +) m +WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, dst.menu_id +FROM sys_role_menu rm +JOIN sys_menu src ON src.menu_id = rm.menu_id AND src.perms = 'data:serviceChannel:list' +JOIN sys_menu dst ON dst.perms IN ('data:lineProduct:list', 'data:lineProduct:query', 'data:lineProduct:export') +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = dst.menu_id +); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, dst.menu_id +FROM sys_role_menu rm +JOIN sys_menu src ON src.menu_id = rm.menu_id AND src.perms = 'data:serviceChannel:edit' +JOIN sys_menu dst ON dst.perms IN ('data:lineProduct:add', 'data:lineProduct:edit') +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = dst.menu_id +); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, dst.menu_id +FROM sys_role_menu rm +JOIN sys_menu src ON src.menu_id = rm.menu_id AND src.perms = 'data:serviceChannel:remove' +JOIN sys_menu dst ON dst.perms = 'data:lineProduct:remove' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = dst.menu_id +); diff --git a/bms-back/sql/price/pay_product.sql b/bms-back/sql/price/pay_product.sql index a3aacdc2..fbab0ebd 100644 --- a/bms-back/sql/price/pay_product.sql +++ b/bms-back/sql/price/pay_product.sql @@ -88,29 +88,6 @@ CREATE TABLE IF NOT EXISTS `pay_extra_line` ( KEY `idx_pay_extra_product` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付产品上的邮编或其他配置原文'; -CREATE TABLE IF NOT EXISTS `pay_product_fee_rule` ( - `rule_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '规则ID', - `product_id` bigint(20) NOT NULL COMMENT '根产品编号,渠道应付绑定的产品,不是某一版导入的版本号', - `fee_name` varchar(100) NOT NULL COMMENT '附加费名称,对应价卡附加费行,用来取价', - `enabled` char(1) NOT NULL DEFAULT '0' COMMENT '1参与计算,0不参与', - `trigger_type` varchar(20) NOT NULL DEFAULT 'ALWAYS' COMMENT '触发类型:SIZE尺寸 VOLUME体积 WEIGHT重量 POSTCODE邮编 ALWAYS每票 RETURN退件', - `side_max_cm` decimal(10,2) DEFAULT NULL COMMENT '最长边上限,厘米,超出则命中', - `sum_max_cm` decimal(10,2) DEFAULT NULL COMMENT '长+宽+高上限,厘米,超出则命中', - `volume_max_cm3` decimal(14,2) DEFAULT NULL COMMENT '体积上限,立方厘米,长×宽×高超出则命中', - `min_length_cm` decimal(10,2) DEFAULT NULL COMMENT '最小长度,厘米,小于则命中', - `min_width_cm` decimal(10,2) DEFAULT NULL COMMENT '最小宽度,厘米,小于则命中', - `min_height_cm` decimal(10,2) DEFAULT NULL COMMENT '最小高度,厘米,小于则命中', - `weight_max_kg` decimal(10,3) DEFAULT NULL COMMENT '重量上限,千克,计费重超出则命中', - `join_mode` varchar(10) NOT NULL DEFAULT 'ANY' COMMENT 'ANY任一条超出就收,ALL写了的条件都超出才收', - `remark` varchar(500) DEFAULT NULL COMMENT '说明,不参与判断', - `created_by` varchar(64) DEFAULT '' COMMENT '创建者', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - `updated_by` varchar(64) DEFAULT '' COMMENT '更新者', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`rule_id`), - UNIQUE KEY `uk_pay_fee_rule_product` (`product_id`, `fee_name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付产品附加费开关'; - -- 菜单挂在价格基础下面 SET @parentId := ( SELECT menu_id FROM sys_menu diff --git a/bms-back/sql/price/pay_product_fee_rule.sql b/bms-back/sql/price/pay_product_fee_rule.sql index 19c35360..7569eb09 100644 --- a/bms-back/sql/price/pay_product_fee_rule.sql +++ b/bms-back/sql/price/pay_product_fee_rule.sql @@ -1,25 +1,4 @@ --- 应付产品附加费开关。挂在根产品上,同一产品同一费用名称一行。可重复执行。 +-- 应付附加费开关已取消。价格和计算条件都在价卡附加费上。可重复执行。 SET NAMES utf8mb4; -CREATE TABLE IF NOT EXISTS `pay_product_fee_rule` ( - `rule_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '规则ID', - `product_id` bigint(20) NOT NULL COMMENT '根产品编号,渠道应付绑定的产品,不是某一版导入的版本号', - `fee_name` varchar(100) NOT NULL COMMENT '附加费名称,对应价卡附加费行,用来取价', - `enabled` char(1) NOT NULL DEFAULT '0' COMMENT '1参与计算,0不参与', - `trigger_type` varchar(20) NOT NULL DEFAULT 'ALWAYS' COMMENT '触发类型:SIZE尺寸 VOLUME体积 WEIGHT重量 POSTCODE邮编 ALWAYS每票 RETURN退件', - `side_max_cm` decimal(10,2) DEFAULT NULL COMMENT '最长边上限,厘米,超出则命中', - `sum_max_cm` decimal(10,2) DEFAULT NULL COMMENT '长+宽+高上限,厘米,超出则命中', - `volume_max_cm3` decimal(14,2) DEFAULT NULL COMMENT '体积上限,立方厘米,长×宽×高超出则命中', - `min_length_cm` decimal(10,2) DEFAULT NULL COMMENT '最小长度,厘米,小于则命中', - `min_width_cm` decimal(10,2) DEFAULT NULL COMMENT '最小宽度,厘米,小于则命中', - `min_height_cm` decimal(10,2) DEFAULT NULL COMMENT '最小高度,厘米,小于则命中', - `weight_max_kg` decimal(10,3) DEFAULT NULL COMMENT '重量上限,千克,计费重超出则命中', - `join_mode` varchar(10) NOT NULL DEFAULT 'ANY' COMMENT 'ANY任一条超出就收,ALL写了的条件都超出才收', - `remark` varchar(500) DEFAULT NULL COMMENT '说明,不参与判断', - `created_by` varchar(64) DEFAULT '' COMMENT '创建者', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - `updated_by` varchar(64) DEFAULT '' COMMENT '更新者', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`rule_id`), - UNIQUE KEY `uk_pay_fee_rule_product` (`product_id`, `fee_name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付产品附加费开关'; +DROP TABLE IF EXISTS `pay_product_fee_rule`; diff --git a/bms-front/src/api/masterdata/lineProduct.js b/bms-front/src/api/masterdata/lineProduct.js new file mode 100644 index 00000000..8b8eb92c --- /dev/null +++ b/bms-front/src/api/masterdata/lineProduct.js @@ -0,0 +1,47 @@ +import request from '@/utils/request' + +export function listLineProduct(query) { + return request({ + url: '/masterdata/lineproduct/list', + method: 'get', + params: query + }) +} + +export function getLineProduct(id) { + return request({ + url: '/masterdata/lineproduct/' + id, + method: 'get' + }) +} + +export function addLineProduct(data) { + return request({ + url: '/masterdata/lineproduct', + method: 'post', + data: data + }) +} + +export function updateLineProduct(data) { + return request({ + url: '/masterdata/lineproduct', + method: 'put', + data: data + }) +} + +export function changeLineProductStatus(id, blOpen) { + return request({ + url: '/masterdata/lineproduct/changeStatus', + method: 'put', + data: { id, blOpen } + }) +} + +export function delLineProduct(ids) { + return request({ + url: '/masterdata/lineproduct/' + ids, + method: 'delete' + }) +} diff --git a/bms-front/src/api/price/pay.js b/bms-front/src/api/price/pay.js index 373de1b8..145290d7 100644 --- a/bms-front/src/api/price/pay.js +++ b/bms-front/src/api/price/pay.js @@ -19,18 +19,30 @@ export function getPay(productId) { }) } -/** 按字典匹配工作表,返回预览,不写数据库 */ -export function previewPay(file, dictValue) { +/** 按字典匹配工作表,返回预览,不写数据库。progressId 用来轮询解析进度。 */ +export function previewPay(file, dictValue, progressId, onUploadProgress) { const data = new FormData() data.append('file', file) if (dictValue) data.append('dictValue', dictValue) + if (progressId) data.append('progressId', progressId) return request({ url: '/price/pay/preview', method: 'post', data, // 必须声明 multipart,否则全局 application/json 会把文件流改写成空 JSON headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }, - timeout: 10 * 60 * 1000 + timeout: 10 * 60 * 1000, + onUploadProgress + }) +} + +/** 查询一次价卡解析进度 */ +export function payParseProgress(progressId) { + return request({ + url: '/price/pay/preview/progress', + method: 'get', + params: { progressId }, + headers: { repeatSubmit: false } }) } @@ -140,24 +152,6 @@ export function generatePayBill(billId) { }) } -/** 勾选产品的附加费开关。productIds 用逗号分隔 */ -export function listFeeRule(productIds) { - return request({ - url: '/price/pay/feeRule/list', - method: 'get', - params: { productIds: productIds.join(',') } - }) -} - -/** 按产品保存附加费开关。每个产品一份 */ -export function saveFeeRule(data) { - return request({ - url: '/price/pay/feeRule', - method: 'put', - data - }) -} - /** 删除产品及其重量段、附加费、其他配置 */ export function delPay(productIds) { return request({ diff --git a/bms-front/src/views/data/lineProduct/index.vue b/bms-front/src/views/data/lineProduct/index.vue new file mode 100644 index 00000000..f5db05fe --- /dev/null +++ b/bms-front/src/views/data/lineProduct/index.vue @@ -0,0 +1,373 @@ + + + + + diff --git a/bms-front/src/views/data/serviceChannel/index.vue b/bms-front/src/views/data/serviceChannel/index.vue index 99d47ed8..30a2de31 100644 --- a/bms-front/src/views/data/serviceChannel/index.vue +++ b/bms-front/src/views/data/serviceChannel/index.vue @@ -36,13 +36,14 @@ {{ row.blMultiple === '1' ? '是' : row.blMultiple === '0' ? '否' : '-' }} - + @@ -142,7 +143,7 @@ - +
{{ payTip }}
@@ -151,8 +152,8 @@
@@ -171,12 +172,17 @@ const { proxy } = getCurrentInstance() const tableRef = ref() const formRef = ref() const open = ref(false) +const viewing = ref(false) const title = ref('') const payOptions = ref([]) const payOriginId = ref() const payProduct = ref(null) const syncingPay = ref(false) const payTip = computed(() => { + if (viewing.value) { + if (payProduct.value?.versionNo) return `已绑定第 ${payProduct.value.versionNo} 版。` + return payOriginId.value ? '已绑定应付产品。' : '未绑定应付产品。' + } if (!form.value?.id) return '先保存渠道,再选择应付产品。渠道绑定的是产品编号,更新价格不会换编号。' if (payProduct.value?.versionNo) return `当前按有效期使用第 ${payProduct.value.versionNo} 版。历史订单按下单或计费时间取当时的版本。` return '选择产品后即绑定,按应付 AP 保存。' @@ -210,7 +216,7 @@ const columns = [ { label: '末端服务商', prop: 'finalServiceName', minWidth: 140, showOverflowTooltip: true }, { label: '抛货系数', prop: 'dimDivisor', width: 90 }, { label: '同步时间', prop: 'syncTime', width: 170, parseTime: true }, - { label: '操作', prop: 'action', width: 150, fixed: 'right', slot: 'action', showInToolbar: false } + { label: '操作', prop: 'action', width: 200, fixed: 'right', slot: 'action', showInToolbar: false } ] const data = reactive({ @@ -285,6 +291,7 @@ function loadChannelPay(channelId) { } function handleAdd() { + viewing.value = false form.value = emptyForm() resetPay() loadPayOptions() @@ -295,6 +302,7 @@ function handleAdd() { function handleUpdate(row) { getServiceChannel(row.id).then(res => { + viewing.value = false form.value = Object.assign(emptyForm(), res.data || {}) resetPay() loadPayOptions() @@ -305,8 +313,21 @@ function handleUpdate(row) { }) } +function handleDetail(row) { + getServiceChannel(row.id).then(res => { + viewing.value = true + form.value = Object.assign(emptyForm(), res.data || {}) + resetPay() + loadPayOptions() + loadChannelPay(row.id) + title.value = '渠道详情' + open.value = true + nextTick(() => formRef.value && formRef.value.clearValidate()) + }) +} + function onPayOrigin(id) { - if (!form.value.id || syncingPay.value) return + if (viewing.value || !form.value.id || syncingPay.value) return if (!id) { payProduct.value = null bindChannelPay(form.value.id, null, 'AP') @@ -319,6 +340,7 @@ function onPayOrigin(id) { } function submitForm() { + if (viewing.value) return formRef.value.validate(valid => { if (!valid) return const req = form.value.id ? updateServiceChannel(form.value) : addServiceChannel(form.value) diff --git a/bms-front/src/views/price/basic/pay/components/PayFeeRuleDialog.vue b/bms-front/src/views/price/basic/pay/components/PayFeeRuleDialog.vue deleted file mode 100644 index 726a281e..00000000 --- a/bms-front/src/views/price/basic/pay/components/PayFeeRuleDialog.vue +++ /dev/null @@ -1,291 +0,0 @@ - - - - - diff --git a/bms-front/src/views/price/basic/pay/components/PayImportDialog.vue b/bms-front/src/views/price/basic/pay/components/PayImportDialog.vue index bc06fa3c..1bae61f7 100644 --- a/bms-front/src/views/price/basic/pay/components/PayImportDialog.vue +++ b/bms-front/src/views/price/basic/pay/components/PayImportDialog.vue @@ -28,6 +28,18 @@ +
+ +
{{ parseMessage }}
+
+
import { ElMessageBox } from 'element-plus' import { UploadFilled } from '@element-plus/icons-vue' -import { previewPay, savePay } from '@/api/price/pay' +import { previewPay, payParseProgress, savePay } from '@/api/price/pay' import { useDict } from '@/utils/dict' import PayConfigDetail from './PayConfigDetail.vue' @@ -116,17 +128,24 @@ const selected = ref([]) const tableRef = ref() const activeKey = ref('') const uploadKey = ref(0) +const parsePercent = ref(0) +const parseMessage = ref('') +const parseStatus = ref(undefined) const products = computed(() => preview.value?.products || []) const activeProduct = computed(() => products.value.find(item => item.rowKey === activeKey.value) || products.value[0]) const skippedText = computed(() => (preview.value?.skippedSheets || []).filter(Boolean).join('、')) +let parseSeq = 0 +let progressTimer = null + function open() { reset() visible.value = true } function reset() { + parseSeq += 1 preview.value = null selected.value = [] overwrite.value = false @@ -135,6 +154,10 @@ function reset() { activeKey.value = '' recognizing.value = false saving.value = false + parsePercent.value = 0 + parseMessage.value = '' + parseStatus.value = undefined + stopProgress() uploadKey.value += 1 } @@ -145,9 +168,26 @@ async function onFile(uploadFile) { proxy.$modal.msgError('请上传xlsx价卡') return } + const seq = ++parseSeq + const progressId = createProgressId() recognizing.value = true + parsePercent.value = 0 + parseMessage.value = '正在上传价卡…' + parseStatus.value = undefined + startProgress(seq, progressId) try { - const res = await previewPay(raw, dictValue.value) + const res = await previewPay(raw, dictValue.value, progressId, event => { + if (seq !== parseSeq || !event?.total) return + const uploaded = Math.min(8, Math.round(event.loaded * 8 / event.total)) + if (uploaded > parsePercent.value) { + parsePercent.value = uploaded + parseMessage.value = '正在上传价卡…' + } + }) + if (seq !== parseSeq) return + parsePercent.value = 100 + parseMessage.value = '解析完成' + parseStatus.value = 'success' const data = res.data || {} data.products = (data.products || []).map((item, index) => ({ ...item, @@ -160,8 +200,48 @@ async function onFile(uploadFile) { await nextTick() tableRef.value?.clearSelection() picked.forEach(row => tableRef.value?.toggleRowSelection(row, true)) + } catch (error) { + if (seq !== parseSeq) return + parseStatus.value = 'exception' + parseMessage.value = error?.message || '价卡解析失败' } finally { - recognizing.value = false + if (seq === parseSeq) { + stopProgress() + recognizing.value = false + } + } +} + +function createProgressId() { + if (window.crypto?.randomUUID) return window.crypto.randomUUID() + return `pay${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}` +} + +function startProgress(seq, progressId) { + stopProgress() + progressTimer = window.setInterval(async () => { + if (seq !== parseSeq) return + try { + const res = await payParseProgress(progressId) + if (seq !== parseSeq) return + const data = res.data || {} + if (!data.status || data.status === 'PENDING') return + const server = Math.min(99, Number(data.percent) || 0) + if (server >= parsePercent.value) { + parsePercent.value = server + if (data.message) parseMessage.value = data.message + } + if (data.status === 'FAIL') parseStatus.value = 'exception' + } catch { + // 进度查询失败时继续等预览结果 + } + }, 400) +} + +function stopProgress() { + if (progressTimer) { + window.clearInterval(progressTimer) + progressTimer = null } } @@ -262,6 +342,14 @@ defineExpose({ open }) .import-form { margin-bottom: 8px; } +.parse-progress { + margin-top: 14px; +} +.parse-progress-msg { + margin-top: 8px; + color: var(--el-text-color-regular); + font-size: 13px; +} .sheet-scroll { display: flex; gap: 8px; diff --git a/bms-front/src/views/price/basic/pay/index.vue b/bms-front/src/views/price/basic/pay/index.vue index 76040829..a14001b6 100644 --- a/bms-front/src/views/price/basic/pay/index.vue +++ b/bms-front/src/views/price/basic/pay/index.vue @@ -13,9 +13,6 @@ 导入价卡 - - 附加费配置 - 删除 @@ -63,7 +60,6 @@ -
@@ -154,12 +150,10 @@ import { listPay, getPay, delPay, updatePay, listPayTerm, updatePayTerm, changePayTermStatus, comparePay } from '@/api/price/pay' import PayImportDialog from './components/PayImportDialog.vue' import PayConfigDetail from './components/PayConfigDetail.vue' -import PayFeeRuleDialog from './components/PayFeeRuleDialog.vue' const { proxy } = getCurrentInstance() const tableRef = ref() const importRef = ref() -const feeRuleRef = ref() const detailVisible = ref(false) const detailLoading = ref(false) const editing = ref(false)