新增路线产品

This commit is contained in:
liuminghui
2026-11-06 13:39:26 +08:00
parent b4da248815
commit 6f342d424f
32 changed files with 1850 additions and 785 deletions
@@ -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<LineProduct> 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<LineProduct> list = lineProductService.selectLineProductList(query);
ExcelUtil<LineProduct> util = new ExcelUtil<LineProduct>(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));
}
}
@@ -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<Long> 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<PayFeeRuleGroup> 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));
}
/** 修改已导入的公共模板 */
@@ -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()
{
@@ -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
@@ -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;
@@ -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;
}
@@ -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<LineProduct> 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);
}
@@ -0,0 +1,22 @@
package com.bms.masterdata.service;
import java.util.List;
import com.bms.masterdata.domain.LineProduct;
/**
* 路线产品
*/
public interface ILineProductService
{
List<LineProduct> selectLineProductList(LineProduct query);
LineProduct selectLineProductById(String id);
int insertLineProduct(LineProduct product);
int updateLineProduct(LineProduct product);
int updateLineProductStatus(LineProduct product);
int deleteLineProductByIds(String[] ids);
}
@@ -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<LineProduct> 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;
}
}
@@ -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<PayProductFeeRule> rules = new ArrayList<>();
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
/** 邮编列表、重量阈值或尺寸表达式 */
@@ -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<PayProductFeeRule> selectByProductId(Long productId);
int deleteByProductId(Long productId);
int deleteByProductIds(@Param("productIds") List<Long> productIds);
int insertBatch(@Param("list") List<PayProductFeeRule> list);
}
@@ -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<PayCostLine> 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<PayWeightBand> 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<PayCostLine> fees, PaySurcharge fee, String currency, String remark)
{
PayCostLine line = matchedFee(fee, null, currency, remark);
@@ -0,0 +1,10 @@
package com.bms.price.pay;
/**
* 应付价卡解析进度。只用于界面展示,不参与识别。
*/
@FunctionalInterface
public interface PayParseProgressListener
{
void onProgress(int percent, String message);
}
@@ -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})");
@@ -112,6 +112,15 @@ public class PayWorkbookParser
* 单元格里用分号隔开的国家、分区或费用名称,拆成多条。
*/
public PayPreview parseMatched(byte[] bytes, String filename, String selected, Collection<String> 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<String> 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<String> 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;
@@ -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<PayFeeRuleGroup> listFeeRules(List<Long> productIds);
/** 按产品整组替换附加费开关。每个产品一份,互不影响 */
void saveFeeRules(List<PayFeeRuleGroup> products);
}
@@ -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<String> 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<String, UploadedSource> uploadedSources = new ConcurrentHashMap<>();
/** 导入弹框轮询的解析进度。只留在本机内存,过期后丢掉。 */
private final Map<String, ParseSlot> 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<PayFeeRuleGroup> listFeeRules(List<Long> productIds)
{
List<PayFeeRuleGroup> groups = new ArrayList<>();
if (productIds == null)
{
return groups;
}
LinkedHashSet<Long> 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<PayProductFeeRule> 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<PayFeeRuleGroup> 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<PayProductFeeRule> 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<PayProductFeeRule> normalizeFeeRules(PayProduct root, List<PayProductFeeRule> source, String operator, Date now)
{
List<PayProductFeeRule> rules = new ArrayList<>();
Set<String> 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;
@@ -0,0 +1,184 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bms.masterdata.mapper.LineProductMapper">
<resultMap id="LineProductResult" type="com.bms.masterdata.domain.LineProduct">
<result property="id" column="id"/>
<result property="lineCode" column="line_code"/>
<result property="lineName" column="line_name"/>
<result property="productType" column="product_type"/>
<result property="lineType" column="line_type"/>
<result property="blCargo" column="bl_cargo"/>
<result property="cargoName" column="cargo_name"/>
<result property="productStorage" column="product_storage"/>
<result property="productFirst" column="product_first"/>
<result property="productClearance" column="product_clearance"/>
<result property="productStorageCode" column="product_storage_code"/>
<result property="productFirstCode" column="product_first_code"/>
<result property="productClearanceCode" column="product_clearance_code"/>
<result property="blOpen" column="bl_open"/>
<result property="blSection" column="bl_section"/>
<result property="dispCode" column="disp_code"/>
<result property="dispName" column="disp_name"/>
<result property="feeClass" column="fee_class"/>
<result property="shipperId" column="shipper_id"/>
<result property="shipmentRule" column="shipment_rule"/>
<result property="weight" column="weight"/>
<result property="cargoType" column="cargo_type"/>
<result property="productCategory" column="product_category"/>
<result property="volumeBasis" column="volume_basis"/>
<result property="weightType" column="weight_type"/>
<result property="serviceType" column="service_type"/>
<result property="shipmentType" column="shipment_type"/>
<result property="taxAmount" column="tax_amount"/>
<result property="taxCurrency" column="tax_currency"/>
<result property="batteryType" column="battery_type"/>
<result property="remark" column="remark"/>
<result property="createName" column="create_name"/>
<result property="createManCode" column="create_man_code"/>
<result property="createDate" column="create_date"/>
<result property="modifier" column="modifier"/>
<result property="modifierCode" column="modifier_code"/>
<result property="modifyDate" column="modify_date"/>
<result property="gmtCreate" column="gmt_create"/>
<result property="gmtModified" column="gmt_modified"/>
<result property="syncTime" column="sync_time"/>
</resultMap>
<sql id="selectLineProductVo">
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
</sql>
<select id="selectLineProductList" parameterType="com.bms.masterdata.domain.LineProduct" resultMap="LineProductResult">
<include refid="selectLineProductVo"/>
<where>
<if test="lineCode != null and lineCode != ''">
AND line_code like concat('%', #{lineCode}, '%')
</if>
<if test="lineName != null and lineName != ''">
AND line_name like concat('%', #{lineName}, '%')
</if>
<if test="productType != null and productType != ''">
AND product_type like concat('%', #{productType}, '%')
</if>
<if test="lineType != null and lineType != ''">
AND line_type like concat('%', #{lineType}, '%')
</if>
<if test="blOpen != null and blOpen != ''">
AND bl_open = #{blOpen}
</if>
<if test="blCargo != null and blCargo != ''">
AND bl_cargo = #{blCargo}
</if>
<if test="dispName != null and dispName != ''">
AND disp_name like concat('%', #{dispName}, '%')
</if>
<if test="cargoType != null and cargoType != ''">
AND cargo_type like concat('%', #{cargoType}, '%')
</if>
<if test="productCategory != null and productCategory != ''">
AND product_category like concat('%', #{productCategory}, '%')
</if>
</where>
order by line_code
</select>
<select id="selectLineProductById" parameterType="String" resultMap="LineProductResult">
<include refid="selectLineProductVo"/>
where id = #{id}
</select>
<select id="selectLineProductByCode" parameterType="String" resultMap="LineProductResult">
<include refid="selectLineProductVo"/>
where line_code = #{lineCode}
limit 1
</select>
<select id="countProductChannelByProductId" parameterType="String" resultType="int">
select count(1) from srv_product_channel where product_id = #{productId}
</select>
<insert id="insertLineProduct" parameterType="com.bms.masterdata.domain.LineProduct">
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()
)
</insert>
<update id="updateLineProduct" parameterType="com.bms.masterdata.domain.LineProduct">
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>
<update id="updateLineProductStatus" parameterType="com.bms.masterdata.domain.LineProduct">
update srv_line_product
set bl_open = #{blOpen},
modifier = #{modifier},
modify_date = sysdate(),
gmt_modified = sysdate()
where id = #{id}
</update>
<delete id="deleteLineProductByIds" parameterType="String">
delete from srv_line_product where id in
<foreach item="id" collection="array" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>
@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bms.price.mapper.PayProductFeeRuleMapper">
<select id="selectByProductId" resultType="com.bms.price.domain.PayProductFeeRule">
select rule_id as ruleId, product_id as productId, fee_name as feeName, enabled,
trigger_type as triggerType, side_max_cm as sideMaxCm, sum_max_cm as sumMaxCm,
volume_max_cm3 as volumeMaxCm3, min_length_cm as minLengthCm, min_width_cm as minWidthCm,
min_height_cm as minHeightCm, weight_max_kg as weightMaxKg, join_mode as joinMode,
remark, created_by as createBy, create_time as createTime, updated_by as updateBy,
update_time as updateTime
from pay_product_fee_rule
where product_id = #{productId}
order by rule_id
</select>
<delete id="deleteByProductId">
delete from pay_product_fee_rule where product_id = #{productId}
</delete>
<delete id="deleteByProductIds">
delete from pay_product_fee_rule where product_id in
<foreach collection="productIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</delete>
<insert id="insertBatch">
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
<foreach collection="list" item="item" separator=",">
(#{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})
</foreach>
</insert>
</mapper>
+66
View File
@@ -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
);
-23
View File
@@ -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
+2 -23
View File
@@ -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`;
@@ -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'
})
}
+15 -21
View File
@@ -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({
@@ -0,0 +1,373 @@
<template>
<div class="app-container">
<basic-table
ref="tableRef"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
row-key="id"
>
<template #toolbar>
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['data:lineProduct:add']">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['data:lineProduct:export']">导出</el-button>
</el-col>
</template>
<template #col-blOpen="{ row }">{{ row.blOpen === '1' ? '启用' : row.blOpen === '0' ? '停用' : '-' }}</template>
<template #col-blCargo="{ row }">{{ yesNoText(row.blCargo) }}</template>
<template #col-blSection="{ row }">{{ yesNoText(row.blSection) }}</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="handleDetail(row)" v-hasPermi="['data:lineProduct:query']">详情</el-button>
</template>
</basic-table>
<el-dialog :title="title" v-model="open" width="980px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" :disabled="viewing" label-width="120px" class="line-form">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="产品编码" prop="lineCode">
<el-input v-model="form.lineCode" maxlength="64" placeholder="请输入产品编码" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品名称" prop="lineName">
<el-input v-model="form.lineName" maxlength="200" placeholder="请输入产品名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品类型" prop="productType">
<el-input v-model="form.productType" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="线路类型" prop="lineType">
<el-input v-model="form.lineType" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品分类" prop="productCategory">
<el-input v-model="form.productCategory" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="货物类型" prop="cargoType">
<el-input v-model="form.cargoType" maxlength="64" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="货物名称" prop="cargoName">
<el-input v-model="form.cargoName" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="费用类别" prop="feeClass">
<el-input v-model="form.feeClass" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="启用状态" prop="blOpen">
<el-radio-group v-model="form.blOpen">
<el-radio value="1">启用</el-radio>
<el-radio value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="是否可收货" prop="blCargo">
<el-radio-group v-model="form.blCargo">
<el-radio value="1">是</el-radio>
<el-radio value="0">否</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="是否分段" prop="blSection">
<el-radio-group v-model="form.blSection">
<el-radio value="1">是</el-radio>
<el-radio value="0">否</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="派送商编码" prop="dispCode">
<el-input v-model="form.dispCode" maxlength="60" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="派送商名称" prop="dispName">
<el-input v-model="form.dispName" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发货人ID" prop="shipperId">
<el-input v-model="form.shipperId" maxlength="64" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发货规则" prop="shipmentRule">
<el-input v-model="form.shipmentRule" maxlength="1" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发货类型" prop="shipmentType">
<el-input v-model="form.shipmentType" maxlength="32" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服务类型" prop="serviceType">
<el-input v-model="form.serviceType" maxlength="1" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="重量类型" prop="weightType">
<el-input v-model="form.weightType" maxlength="1" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="限重(kg)" prop="weight">
<el-input-number v-model="form.weight" :min="0" :precision="2" :value-on-clear="null" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="体积基数" prop="volumeBasis">
<el-input-number v-model="form.volumeBasis" :min="0" :precision="2" :value-on-clear="null" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="税费金额" prop="taxAmount">
<el-input-number v-model="form.taxAmount" :min="0" :precision="3" :value-on-clear="null" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="税费币种" prop="taxCurrency">
<el-input v-model="form.taxCurrency" maxlength="16" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="电池类型" prop="batteryType">
<el-input v-model="form.batteryType" maxlength="64" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="仓储服务名称" prop="productStorage">
<el-input v-model="form.productStorage" type="textarea" :rows="2" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="仓储服务编码" prop="productStorageCode">
<el-input v-model="form.productStorageCode" type="textarea" :rows="2" maxlength="1000" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="头程服务名称" prop="productFirst">
<el-input v-model="form.productFirst" type="textarea" :rows="2" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="头程服务编码" prop="productFirstCode">
<el-input v-model="form.productFirstCode" type="textarea" :rows="2" maxlength="1000" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="清关服务名称" prop="productClearance">
<el-input v-model="form.productClearance" type="textarea" :rows="2" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="清关服务编码" prop="productClearanceCode">
<el-input v-model="form.productClearanceCode" type="textarea" :rows="2" maxlength="1000" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button v-if="!viewing" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="open = false">{{ viewing ? '关 闭' : '取 消' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="LineProduct">
import { nextTick } from 'vue'
import {
listLineProduct, getLineProduct, addLineProduct
} from '@/api/masterdata/lineProduct'
const { proxy } = getCurrentInstance()
const tableRef = ref()
const formRef = ref()
const open = ref(false)
const viewing = ref(false)
const title = ref('')
const openOptions = [
{ label: '启用', value: '1' },
{ label: '停用', value: '0' }
]
const yesNoOptions = [
{ label: '是', value: '1' },
{ label: '否', value: '0' }
]
const searchColumns = [
{ label: '产品编码', prop: 'lineCode', component: 'input', placeholder: '产品编码', width: '160px' },
{ label: '产品名称', prop: 'lineName', component: 'input', placeholder: '模糊查询', width: '180px' },
{ label: '产品类型', prop: 'productType', component: 'input', placeholder: '模糊查询', width: '150px' },
{ label: '线路类型', prop: 'lineType', component: 'input', placeholder: '模糊查询', width: '150px' },
{ label: '派送商', prop: 'dispName', component: 'input', placeholder: '模糊查询', width: '160px' },
{ label: '货物类型', prop: 'cargoType', component: 'input', placeholder: '模糊查询', width: '150px' },
{ label: '产品分类', prop: 'productCategory', component: 'input', placeholder: '模糊查询', width: '150px' },
{ label: '启用', prop: 'blOpen', component: 'select', options: openOptions, placeholder: '启用状态', width: '130px', props: { clearable: true } },
{ label: '可收货', prop: 'blCargo', component: 'select', options: yesNoOptions, placeholder: '是否可收货', width: '130px', props: { clearable: true } }
]
const columns = [
{ label: '产品编码', prop: 'lineCode', minWidth: 140, showOverflowTooltip: true },
{ label: '产品名称', prop: 'lineName', minWidth: 180, showOverflowTooltip: true },
{ label: '产品类型', prop: 'productType', minWidth: 110, showOverflowTooltip: true },
{ label: '线路类型', prop: 'lineType', minWidth: 120, showOverflowTooltip: true },
{ label: '可收货', prop: 'blCargo', width: 80, slot: 'blCargo' },
{ label: '货物名称', prop: 'cargoName', minWidth: 140, showOverflowTooltip: true },
{ label: '仓储服务名称', prop: 'productStorage', minWidth: 160, showOverflowTooltip: true },
{ label: '头程服务名称', prop: 'productFirst', minWidth: 160, showOverflowTooltip: true },
{ label: '清关服务名称', prop: 'productClearance', minWidth: 160, showOverflowTooltip: true },
{ label: '仓储服务编码', prop: 'productStorageCode', minWidth: 160, showOverflowTooltip: true },
{ label: '头程服务编码', prop: 'productFirstCode', minWidth: 160, showOverflowTooltip: true },
{ label: '清关服务编码', prop: 'productClearanceCode', minWidth: 160, showOverflowTooltip: true },
{ label: '启用', prop: 'blOpen', width: 80, slot: 'blOpen' },
{ label: '分段', prop: 'blSection', width: 70, slot: 'blSection' },
{ label: '派送商编码', prop: 'dispCode', minWidth: 120, showOverflowTooltip: true },
{ label: '派送商名称', prop: 'dispName', minWidth: 140, showOverflowTooltip: true },
{ label: '费用类别', prop: 'feeClass', minWidth: 120, showOverflowTooltip: true },
{ label: '发货人ID', prop: 'shipperId', minWidth: 120, showOverflowTooltip: true },
{ label: '发货规则', prop: 'shipmentRule', width: 90 },
{ label: '限重(kg)', prop: 'weight', width: 100 },
{ label: '货物类型', prop: 'cargoType', minWidth: 120, showOverflowTooltip: true },
{ label: '产品分类', prop: 'productCategory', minWidth: 120, showOverflowTooltip: true },
{ label: '体积基数', prop: 'volumeBasis', width: 100 },
{ label: '重量类型', prop: 'weightType', width: 90 },
{ label: '服务类型', prop: 'serviceType', width: 90 },
{ label: '发货类型', prop: 'shipmentType', minWidth: 110, showOverflowTooltip: true },
{ label: '税费金额', prop: 'taxAmount', width: 100 },
{ label: '税费币种', prop: 'taxCurrency', width: 90 },
{ label: '电池类型', prop: 'batteryType', minWidth: 110, showOverflowTooltip: true },
{ label: '备注', prop: 'remark', minWidth: 160, showOverflowTooltip: true },
{ label: '创建时间', prop: 'createDate', width: 170, parseTime: true },
{ label: '修改人', prop: 'modifier', minWidth: 100, showOverflowTooltip: true },
{ label: '修改人工号', prop: 'modifierCode', minWidth: 110, showOverflowTooltip: true },
{ label: '修改时间', prop: 'modifyDate', width: 170, parseTime: true },
{ label: 'GMT创建时间', prop: 'gmtCreate', width: 170, parseTime: true },
{ label: 'GMT修改时间', prop: 'gmtModified', width: 170, parseTime: true },
{ label: '同步时间', prop: 'syncTime', width: 170, parseTime: true },
{ label: '操作', prop: 'action', width: 90, fixed: 'right', slot: 'action', showInToolbar: false }
]
const data = reactive({
form: {},
rules: {
lineCode: [{ required: true, message: '产品编码不能为空', trigger: 'blur' }],
lineName: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
blOpen: [{ required: true, message: '请选择启用状态', trigger: 'change' }]
}
})
const { form, rules } = toRefs(data)
function queryList(params) {
return listLineProduct(params)
}
function yesNoText(value) {
if (value === '1') return '是'
if (value === '0') return '否'
return '-'
}
function emptyForm() {
return {
id: undefined,
lineCode: '',
lineName: '',
productType: '',
lineType: '',
blCargo: '1',
cargoName: '',
productStorage: '',
productFirst: '',
productClearance: '',
productStorageCode: '',
productFirstCode: '',
productClearanceCode: '',
blOpen: '1',
blSection: '0',
dispCode: '',
dispName: '',
feeClass: '',
shipperId: '',
shipmentRule: '',
weight: undefined,
cargoType: '',
productCategory: '',
volumeBasis: undefined,
weightType: '',
serviceType: '',
shipmentType: '',
taxAmount: undefined,
taxCurrency: '',
batteryType: '',
remark: ''
}
}
function handleAdd() {
viewing.value = false
form.value = emptyForm()
title.value = '新增路线产品'
open.value = true
nextTick(() => formRef.value && formRef.value.clearValidate())
}
function handleDetail(row) {
getLineProduct(row.id).then(res => {
viewing.value = true
form.value = Object.assign(emptyForm(), res.data || {})
title.value = '路线产品详情'
open.value = true
nextTick(() => formRef.value && formRef.value.clearValidate())
})
}
function submitForm() {
if (viewing.value) return
formRef.value.validate(valid => {
if (!valid) return
addLineProduct(form.value).then(() => {
proxy.$modal.msgSuccess('保存成功')
open.value = false
tableRef.value?.refresh()
})
})
}
function handleExport() {
proxy.download('masterdata/lineproduct/export', {
...tableRef.value.getQueryParams()
}, `line_product_${new Date().getTime()}.xlsx`)
}
</script>
<style scoped>
.line-form {
max-height: 68vh;
overflow: auto;
padding-right: 8px;
}
</style>
@@ -36,13 +36,14 @@
<span>{{ row.blMultiple === '1' ? '是' : row.blMultiple === '0' ? '否' : '-' }}</span>
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="handleDetail(row)" v-hasPermi="['data:serviceChannel:query']">详情</el-button>
<el-button link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['data:serviceChannel:edit']">修改</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete([row.id])" v-hasPermi="['data:serviceChannel:remove']">删除</el-button>
</template>
</basic-table>
<el-dialog :title="title" v-model="open" width="760px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form ref="formRef" :model="form" :rules="viewing ? {} : rules" :disabled="viewing" label-width="120px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="渠道编码" prop="channelCode">
@@ -142,7 +143,7 @@
</el-col>
<el-col :span="24">
<el-form-item label="应付产品">
<el-select v-model="payOriginId" filterable clearable placeholder="选择已导入的应付产品" style="width: 280px" :disabled="!form.id" @change="onPayOrigin">
<el-select v-model="payOriginId" filterable clearable placeholder="选择已导入的应付产品" style="width: 280px" :disabled="viewing || !form.id" @change="onPayOrigin">
<el-option v-for="item in payOptions" :key="item.productId" :label="payOptionLabel(item)" :value="item.productId" />
</el-select>
<div class="pay-tip">{{ payTip }}</div>
@@ -151,8 +152,8 @@
</el-row>
</el-form>
<template #footer>
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="open = false">取 消</el-button>
<el-button v-if="!viewing" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="open = false">{{ viewing ? '关 闭' : '取 消' }}</el-button>
</template>
</el-dialog>
@@ -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)
@@ -1,291 +0,0 @@
<template>
<el-dialog
v-model="visible"
title="附加费配置"
width="1280px"
top="6vh"
append-to-body
destroy-on-close
@closed="reset"
>
<div v-loading="loading">
<p class="fee-tip">还没配置的产品共用一页,保存后按产品各写一份。已经配置过的产品单独一页,互不影响。</p>
<el-tabs v-if="tabs.length > 1" v-model="active">
<el-tab-pane v-for="tab in tabs" :key="tab.key" :label="tab.label" :name="tab.key" />
</el-tabs>
<div v-if="current" class="fee-bar">
<span>{{ currentHint }}</span>
<el-button type="primary" plain icon="Plus" @click="addRow">添加一行</el-button>
</div>
<el-table v-if="current" :data="current.rules" border size="small" max-height="460" empty-text="还没有附加费">
<el-table-column label="附加费名称" min-width="180">
<template #default="{ row }">
<el-select v-model="row.feeName" filterable clearable placeholder="选择附加费">
<el-option v-for="item in feeOptions(row)" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="启用" width="72" align="center">
<template #default="{ row }">
<el-switch v-model="row.enabled" active-value="1" inactive-value="0" />
</template>
</el-table-column>
<el-table-column label="触发类型" width="120">
<template #default="{ row }">
<el-select v-model="row.triggerType">
<el-option v-for="item in triggerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="最长边cm" width="108">
<template #default="{ row }"><el-input-number v-model="row.sideMaxCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="长+宽+高cm" width="116">
<template #default="{ row }"><el-input-number v-model="row.sumMaxCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="体积cm³" width="116">
<template #default="{ row }"><el-input-number v-model="row.volumeMaxCm3" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="最小长" width="96">
<template #default="{ row }"><el-input-number v-model="row.minLengthCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="最小宽" width="96">
<template #default="{ row }"><el-input-number v-model="row.minWidthCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="最小高" width="96">
<template #default="{ row }"><el-input-number v-model="row.minHeightCm" :min="0" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="重量上限kg" width="112">
<template #default="{ row }"><el-input-number v-model="row.weightMaxKg" :min="0" :precision="3" :controls="false" :value-on-clear="null" /></template>
</el-table-column>
<el-table-column label="命中方式" width="128">
<template #default="{ row }">
<el-select v-model="row.joinMode">
<el-option label="任一超出" value="ANY" />
<el-option label="全部超出" value="ALL" />
</el-select>
</template>
</el-table-column>
<el-table-column label="说明" min-width="150">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="500" placeholder="不参与判断" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" @click="removeRow($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { listFeeRule, saveFeeRule } from '@/api/price/pay'
import { useDict } from '@/utils/dict'
const { proxy } = getCurrentInstance()
const { bms_surcharge_type } = useDict('bms_surcharge_type')
const visible = ref(false)
const loading = ref(false)
const saving = ref(false)
const tabs = ref([])
const active = ref('')
const triggerOptions = [
{ label: '尺寸', value: 'SIZE' },
{ label: '体积', value: 'VOLUME' },
{ label: '重量', value: 'WEIGHT' },
{ label: '邮编', value: 'POSTCODE' },
{ label: '每票', value: 'ALWAYS' },
{ label: '退件', value: 'RETURN' }
]
const current = computed(() => tabs.value.find(tab => tab.key === active.value) || null)
const currentHint = computed(() => {
if (!current.value) return ''
if (current.value.productIds.length > 1) return `同时写入:${current.value.productName}`
return tabs.value.length === 1 ? current.value.productName : ''
})
function blankRule() {
return {
feeName: '',
enabled: '0',
triggerType: 'ALWAYS',
sideMaxCm: null,
sumMaxCm: null,
volumeMaxCm3: null,
minLengthCm: null,
minWidthCm: null,
minHeightCm: null,
weightMaxKg: null,
joinMode: 'ANY',
remark: ''
}
}
function copyRule(row) {
const next = blankRule()
next.feeName = row.feeName || ''
next.enabled = row.enabled === '1' ? '1' : '0'
next.triggerType = row.triggerType || 'ALWAYS'
next.sideMaxCm = toNumber(row.sideMaxCm)
next.sumMaxCm = toNumber(row.sumMaxCm)
next.volumeMaxCm3 = toNumber(row.volumeMaxCm3)
next.minLengthCm = toNumber(row.minLengthCm)
next.minWidthCm = toNumber(row.minWidthCm)
next.minHeightCm = toNumber(row.minHeightCm)
next.weightMaxKg = toNumber(row.weightMaxKg)
next.joinMode = row.joinMode || 'ANY'
next.remark = row.remark || ''
return next
}
function toNumber(value) {
if (value === null || value === undefined || value === '') return null
const number = Number(value)
return Number.isFinite(number) ? number : null
}
function feeOptions(row) {
const list = bms_surcharge_type.value || []
if (row.feeName && !list.some(item => item.value === row.feeName)) {
return [{ label: row.feeName, value: row.feeName }, ...list]
}
return list
}
function addRow() {
current.value?.rules.push(blankRule())
}
function removeRow(index) {
if (!current.value) return
current.value.rules.splice(index, 1)
if (!current.value.rules.length) current.value.rules.push(blankRule())
}
function rowTouched(row) {
return !!(row.feeName || row.remark || row.enabled === '1'
|| (row.triggerType && row.triggerType !== 'ALWAYS')
|| (row.joinMode && row.joinMode !== 'ANY')
|| [row.sideMaxCm, row.sumMaxCm, row.volumeMaxCm3, row.minLengthCm, row.minWidthCm, row.minHeightCm, row.weightMaxKg]
.some(value => value !== null && value !== undefined && value !== ''))
}
function keptRules(tab) {
const kept = []
const names = new Set()
for (const row of tab.rules) {
if (!rowTouched(row)) continue
const feeName = (row.feeName || '').trim()
if (!feeName) {
proxy.$modal.msgError(`${tab.productName || '产品'}有未选择名称的附加费`)
return null
}
if (names.has(feeName)) {
proxy.$modal.msgError(`${tab.productName || '产品'}的附加费「${feeName}」重复了`)
return null
}
names.add(feeName)
kept.push({ ...row, feeName, remark: (row.remark || '').trim() })
}
return kept
}
function save() {
const products = []
for (const tab of tabs.value) {
const rules = keptRules(tab)
if (!rules) return
for (const productId of tab.productIds) {
products.push({ productId, rules: rules.map(row => ({ ...row })) })
}
}
if (!products.length) return
saving.value = true
saveFeeRule(products).then(() => {
proxy.$modal.msgSuccess('附加费配置已保存')
visible.value = false
}).finally(() => {
saving.value = false
})
}
function reset() {
tabs.value = []
active.value = ''
loading.value = false
saving.value = false
}
function open(rows) {
const picked = (rows || []).filter(row => row && row.productId != null)
if (!picked.length) return
reset()
visible.value = true
loading.value = true
listFeeRule(picked.map(row => row.productId)).then(res => {
const groups = res.data || []
const unbound = groups.filter(group => !group.rules?.length)
const bound = groups.filter(group => group.rules?.length)
const next = []
if (unbound.length) {
const names = unbound.map(group => group.productName || `产品${group.productId}`)
next.push({
key: 'unbound',
label: '未配置',
productIds: unbound.map(group => group.productId),
productName: names.join('、'),
rules: [blankRule()]
})
}
const nameCount = bound.reduce((map, group) => {
const name = group.productName || ''
map.set(name, (map.get(name) || 0) + 1)
return map
}, new Map())
bound.forEach(group => {
const name = group.productName || `产品${group.productId}`
next.push({
key: `bound-${group.productId}`,
label: nameCount.get(group.productName || '') > 1 ? `${name}(${group.productId})` : name,
productIds: [group.productId],
productName: name,
rules: group.rules.map(copyRule)
})
})
tabs.value = next
active.value = next.length ? next[0].key : ''
}).catch(() => {
visible.value = false
}).finally(() => {
loading.value = false
})
}
defineExpose({ open })
</script>
<style scoped>
.fee-tip {
margin: 0 0 12px;
color: var(--el-text-color-secondary);
}
.fee-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
:deep(.el-input-number) {
width: 100%;
}
</style>
@@ -28,6 +28,18 @@
</template>
</el-upload>
<div v-if="recognizing" class="parse-progress">
<el-progress
:percentage="parsePercent"
:stroke-width="16"
:status="parseStatus"
striped
striped-flow
:duration="12"
/>
<div class="parse-progress-msg">{{ parseMessage }}</div>
</div>
<div v-if="preview" v-loading="recognizing" class="preview-box">
<el-alert
:title="`${preview.sourceFile || ''} 归属 ${preview.supplierName || '-'} 生效 ${preview.effectiveDate || '-'} 共 ${products.length} 个产品`"
@@ -97,7 +109,7 @@
<script setup>
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;
@@ -13,9 +13,6 @@
<el-col :span="1.5">
<el-button type="primary" plain icon="Upload" @click="importRef.open()" v-hasPermi="['price:pay:import']">导入价卡</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="primary" plain icon="Setting" :disabled="!selection.length" @click="feeRuleRef.open(selection)" v-hasPermi="['price:pay:edit']">附加费配置</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="Delete" :disabled="!selection.length" @click="handleDelete(ids)" v-hasPermi="['price:pay:remove']">删除</el-button>
</el-col>
@@ -63,7 +60,6 @@
</basic-table>
<pay-import-dialog ref="importRef" @saved="tableRef.refresh()" />
<pay-fee-rule-dialog ref="feeRuleRef" />
<el-drawer v-model="historyVisible" :title="historyTitle" size="980px" destroy-on-close>
<div class="history-bar">
@@ -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)