增加产品导入功能

This commit is contained in:
wuweihua
2026-09-04 18:08:08 +08:00
parent 5e15ab9c44
commit 7204497a65
16 changed files with 1644 additions and 22 deletions
@@ -0,0 +1,98 @@
package com.ruoyi.web.controller.price;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
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 org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.price.domain.BmsProduct;
import com.ruoyi.price.domain.BmsProductCountry;
import com.ruoyi.price.service.IBmsProductService;
/**
* 产品列表(含 Excel 导入)
*
* @author ruoyi
*/
@RestController
@RequestMapping("/price/product")
public class ProductController extends BaseController
{
@Autowired
private IBmsProductService productService;
@PreAuthorize("@ss.hasPermi('price:product:list')")
@GetMapping("/list")
public TableDataInfo list(BmsProduct product)
{
startPage();
List<BmsProduct> list = productService.selectBmsProductList(product);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('price:product:query')")
@GetMapping("/{productCode}/countries")
public AjaxResult countries(@PathVariable String productCode)
{
List<BmsProductCountry> list = productService.selectCountryListByProductCode(productCode);
return success(list);
}
@PreAuthorize("@ss.hasPermi('price:product:query')")
@GetMapping("/{productId}")
public AjaxResult getInfo(@PathVariable Long productId)
{
return success(productService.selectBmsProductById(productId));
}
@PreAuthorize("@ss.hasPermi('price:product:add')")
@Log(title = "产品列表", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody BmsProduct product)
{
product.setCreateBy(getUsername());
return toAjax(productService.insertBmsProduct(product));
}
@PreAuthorize("@ss.hasPermi('price:product:edit')")
@Log(title = "产品列表", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody BmsProduct product)
{
product.setUpdateBy(getUsername());
return toAjax(productService.updateBmsProduct(product));
}
@PreAuthorize("@ss.hasPermi('price:product:remove')")
@Log(title = "产品列表", businessType = BusinessType.DELETE)
@DeleteMapping("/{productIds}")
public AjaxResult remove(@PathVariable Long[] productIds)
{
return toAjax(productService.deleteBmsProductByIds(productIds));
}
/**
* 导入威狮B2C小包价表.xlsx(产品目录 + 国家服务明细)
*/
@Log(title = "产品列表", businessType = BusinessType.IMPORT)
@PreAuthorize("@ss.hasPermi('price:product:import')")
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception
{
String message = productService.importProductExcel(file, updateSupport, getUsername());
return success(message);
}
}
@@ -0,0 +1,82 @@
package com.ruoyi.price.domain;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* BMS产品主表 bms_product
*
* @author ruoyi
*/
public class BmsProduct extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long productId;
private String productCode;
private String productName;
private String serviceLevel;
private String cargoAttr;
private String destRegion;
private String updateNote;
/** TAX_INCLUDED / TAX_EXCLUDED */
private String taxMode;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date effectiveDate;
private String status;
private String sourceSheet;
private Long tmsId;
/** 国家明细数(列表展示) */
private Integer countryCount;
private List<BmsProductCountry> countries;
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public String getProductCode() { return productCode; }
public void setProductCode(String productCode) { this.productCode = productCode; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public String getServiceLevel() { return serviceLevel; }
public void setServiceLevel(String serviceLevel) { this.serviceLevel = serviceLevel; }
public String getCargoAttr() { return cargoAttr; }
public void setCargoAttr(String cargoAttr) { this.cargoAttr = cargoAttr; }
public String getDestRegion() { return destRegion; }
public void setDestRegion(String destRegion) { this.destRegion = destRegion; }
public String getUpdateNote() { return updateNote; }
public void setUpdateNote(String updateNote) { this.updateNote = updateNote; }
public String getTaxMode() { return taxMode; }
public void setTaxMode(String taxMode) { this.taxMode = taxMode; }
public Date getEffectiveDate() { return effectiveDate; }
public void setEffectiveDate(Date effectiveDate) { this.effectiveDate = effectiveDate; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getSourceSheet() { return sourceSheet; }
public void setSourceSheet(String sourceSheet) { this.sourceSheet = sourceSheet; }
public Long getTmsId() { return tmsId; }
public void setTmsId(Long tmsId) { this.tmsId = tmsId; }
public Integer getCountryCount() { return countryCount; }
public void setCountryCount(Integer countryCount) { this.countryCount = countryCount; }
public List<BmsProductCountry> getCountries() { return countries; }
public void setCountries(List<BmsProductCountry> countries) { this.countries = countries; }
@Override
public String toString()
{
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("productId", getProductId())
.append("productCode", getProductCode())
.append("productName", getProductName())
.append("serviceLevel", getServiceLevel())
.append("cargoAttr", getCargoAttr())
.append("destRegion", getDestRegion())
.append("taxMode", getTaxMode())
.append("effectiveDate", getEffectiveDate())
.append("status", getStatus())
.append("sourceSheet", getSourceSheet())
.toString();
}
}
@@ -0,0 +1,74 @@
package com.ruoyi.price.domain;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
/**
* 产品国家服务明细 bms_product_country(含重量段,便于计费匹配)
*
* @author ruoyi
*/
public class BmsProductCountry implements Serializable
{
private static final long serialVersionUID = 1L;
private Long detailId;
private String productCode;
private String countryName;
private String countryCode;
/** 重量段最小值 KG */
private BigDecimal weightMin;
/** 重量段最大值 KG */
private BigDecimal weightMax;
private String sizeLimit;
private String lastMile;
private String leadTime;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date effectiveDate;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updatedAt;
public Long getDetailId() { return detailId; }
public void setDetailId(Long detailId) { this.detailId = detailId; }
public String getProductCode() { return productCode; }
public void setProductCode(String productCode) { this.productCode = productCode; }
public String getCountryName() { return countryName; }
public void setCountryName(String countryName) { this.countryName = countryName; }
public String getCountryCode() { return countryCode; }
public void setCountryCode(String countryCode) { this.countryCode = countryCode; }
public BigDecimal getWeightMin() { return weightMin; }
public void setWeightMin(BigDecimal weightMin) { this.weightMin = weightMin; }
public BigDecimal getWeightMax() { return weightMax; }
public void setWeightMax(BigDecimal weightMax) { this.weightMax = weightMax; }
public String getSizeLimit() { return sizeLimit; }
public void setSizeLimit(String sizeLimit) { this.sizeLimit = sizeLimit; }
public String getLastMile() { return lastMile; }
public void setLastMile(String lastMile) { this.lastMile = lastMile; }
public String getLeadTime() { return leadTime; }
public void setLeadTime(String leadTime) { this.leadTime = leadTime; }
public Date getEffectiveDate() { return effectiveDate; }
public void setEffectiveDate(Date effectiveDate) { this.effectiveDate = effectiveDate; }
public Date getCreatedAt() { return createdAt; }
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
public Date getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
@Override
public String toString()
{
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("detailId", getDetailId())
.append("productCode", getProductCode())
.append("countryName", getCountryName())
.append("countryCode", getCountryCode())
.append("weightMin", getWeightMin())
.append("weightMax", getWeightMax())
.toString();
}
}
@@ -0,0 +1,20 @@
package com.ruoyi.price.mapper;
import java.util.List;
import com.ruoyi.price.domain.BmsProductCountry;
/**
* 产品国家明细 数据层
*
* @author ruoyi
*/
public interface BmsProductCountryMapper
{
public List<BmsProductCountry> selectBmsProductCountryList(BmsProductCountry country);
public int insertBmsProductCountry(BmsProductCountry country);
public int deleteByProductCode(String productCode);
public int deleteByProductCodes(String[] productCodes);
}
@@ -0,0 +1,24 @@
package com.ruoyi.price.mapper;
import java.util.List;
import com.ruoyi.price.domain.BmsProduct;
/**
* 产品主表 数据层
*
* @author ruoyi
*/
public interface BmsProductMapper
{
public BmsProduct selectBmsProductById(Long productId);
public BmsProduct selectBmsProductByCode(String productCode);
public List<BmsProduct> selectBmsProductList(BmsProduct product);
public int insertBmsProduct(BmsProduct product);
public int updateBmsProduct(BmsProduct product);
public int deleteBmsProductByIds(Long[] productIds);
}
@@ -0,0 +1,36 @@
package com.ruoyi.price.service;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.price.domain.BmsProduct;
import com.ruoyi.price.domain.BmsProductCountry;
/**
* 产品列表 服务层
*
* @author ruoyi
*/
public interface IBmsProductService
{
public BmsProduct selectBmsProductById(Long productId);
public List<BmsProduct> selectBmsProductList(BmsProduct product);
public int insertBmsProduct(BmsProduct product);
public int updateBmsProduct(BmsProduct product);
public int deleteBmsProductByIds(Long[] productIds);
public List<BmsProductCountry> selectCountryListByProductCode(String productCode);
/**
* 导入威狮B2C小包价表:产品目录 + 各产品 sheet 国家服务属性(不含运费/挂号费)
*
* @param file 上传的 xlsx/xls
* @param updateSupport 是否更新已存在产品
* @param operName 操作人
* @return 导入结果说明(可含 HTML 换行)
*/
public String importProductExcel(MultipartFile file, boolean updateSupport, String operName) throws Exception;
}
@@ -0,0 +1,639 @@
package com.ruoyi.price.service.impl;
import java.io.InputStream;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.price.domain.BmsProduct;
import com.ruoyi.price.domain.BmsProductCountry;
import com.ruoyi.price.mapper.BmsProductCountryMapper;
import com.ruoyi.price.mapper.BmsProductMapper;
import com.ruoyi.price.service.IBmsProductService;
import com.ruoyi.system.domain.BmsRegion;
import com.ruoyi.system.mapper.BmsRegionMapper;
/**
* Product service with Weishi Excel import (catalog + country weight bands)
*
* @author ruoyi
*/
@Service
public class BmsProductServiceImpl implements IBmsProductService
{
private static final String CATALOG_SHEET = "\u4ea7\u54c1\u76ee\u5f55";
private static final Set<String> SKIP_SHEETS = new HashSet<>(Arrays.asList(
CATALOG_SHEET, "\u9000\u4ef6\u91cd\u6d3e", "\u7533\u62a5\u8bf4\u660e", "\u8d54\u507f\u6807\u51c6"));
private static final Pattern EFFECTIVE_DATE_PATTERN = Pattern.compile(
"\u751f\u6548\u65e5\u671f[\uff1a:]\\s*(\\d{4})[/.-](\\d{1,2})[/.-](\\d{1,2})");
/** Parse 0.01\u2264W\u22640.3 / 0.3<W\u22641 */
private static final Pattern WEIGHT_RANGE_PATTERN = Pattern.compile(
"(\\d+(?:\\.\\d+)?)\\s*[<\\u2264]=?\\s*W\\s*[<\\u2264]=?\\s*(\\d+(?:\\.\\d+)?)",
Pattern.CASE_INSENSITIVE);
@Autowired
private BmsProductMapper productMapper;
@Autowired
private BmsProductCountryMapper countryMapper;
@Autowired
private BmsRegionMapper regionMapper;
@Override
public BmsProduct selectBmsProductById(Long productId)
{
return productMapper.selectBmsProductById(productId);
}
@Override
public List<BmsProduct> selectBmsProductList(BmsProduct product)
{
return productMapper.selectBmsProductList(product);
}
@Override
public int insertBmsProduct(BmsProduct product)
{
if (StringUtils.isEmpty(product.getStatus()))
{
product.setStatus("0");
}
if (StringUtils.isEmpty(product.getTaxMode()))
{
product.setTaxMode(resolveTaxMode(product.getProductName()));
}
return productMapper.insertBmsProduct(product);
}
@Override
public int updateBmsProduct(BmsProduct product)
{
return productMapper.updateBmsProduct(product);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteBmsProductByIds(Long[] productIds)
{
if (productIds == null || productIds.length == 0)
{
return 0;
}
List<String> codes = new ArrayList<>();
for (Long id : productIds)
{
BmsProduct p = productMapper.selectBmsProductById(id);
if (p != null && StringUtils.isNotEmpty(p.getProductCode()))
{
codes.add(p.getProductCode());
}
}
if (!codes.isEmpty())
{
countryMapper.deleteByProductCodes(codes.toArray(new String[0]));
}
return productMapper.deleteBmsProductByIds(productIds);
}
@Override
public List<BmsProductCountry> selectCountryListByProductCode(String productCode)
{
BmsProductCountry query = new BmsProductCountry();
query.setProductCode(productCode);
return countryMapper.selectBmsProductCountryList(query);
}
@Override
@Transactional(rollbackFor = Exception.class)
public String importProductExcel(MultipartFile file, boolean updateSupport, String operName) throws Exception
{
if (file == null || file.isEmpty())
{
throw new ServiceException("\u4e0a\u4f20\u6587\u4ef6\u4e0d\u80fd\u4e3a\u7a7a");
}
String filename = file.getOriginalFilename();
if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx")
|| filename.toLowerCase(Locale.ROOT).endsWith(".xls")))
{
throw new ServiceException("\u4ec5\u652f\u6301 xls / xlsx \u6587\u4ef6");
}
try (InputStream is = file.getInputStream(); Workbook workbook = WorkbookFactory.create(is))
{
Sheet catalog = workbook.getSheet(CATALOG_SHEET);
if (catalog == null)
{
throw new ServiceException("\u672a\u627e\u5230\u300c\u4ea7\u54c1\u76ee\u5f55\u300dsheet\uff0c\u8bf7\u4e0a\u4f20\u5a01\u72eeB2C\u5c0f\u5305\u4ef7\u8868\u683c\u5f0f\u6587\u4ef6");
}
List<BmsProduct> products = parseCatalog(catalog);
if (products.isEmpty())
{
throw new ServiceException("\u4ea7\u54c1\u76ee\u5f55\u65e0\u6709\u6548\u6570\u636e\u884c");
}
Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn();
if (regionByNameCn.isEmpty())
{
throw new ServiceException("bms_region \u65e0\u53ef\u7528\u56fd\u5bb6\u6570\u636e\uff0c\u8bf7\u5148\u540c\u6b65/\u7ef4\u62a4\u56fd\u5bb6\u8868\u540e\u518d\u5bfc\u5165");
}
int successNum = 0;
int updateNum = 0;
int skipNum = 0;
int countryNum = 0;
Set<String> unmatchedCountries = new HashSet<>();
StringBuilder failureMsg = new StringBuilder();
for (BmsProduct product : products)
{
try
{
product.setSourceSheet(product.getProductName());
product.setCreateBy(operName);
product.setUpdateBy(operName);
List<BmsProductCountry> countries = parseProductSheet(workbook, product, regionByNameCn, unmatchedCountries);
BmsProduct existing = productMapper.selectBmsProductByCode(product.getProductCode());
if (existing == null)
{
product.setStatus("0");
productMapper.insertBmsProduct(product);
saveCountries(product.getProductCode(), countries);
successNum++;
countryNum += countries.size();
}
else if (updateSupport)
{
product.setProductId(existing.getProductId());
productMapper.updateBmsProduct(product);
countryMapper.deleteByProductCode(product.getProductCode());
saveCountries(product.getProductCode(), countries);
updateNum++;
countryNum += countries.size();
}
else
{
skipNum++;
}
}
catch (Exception e)
{
failureMsg.append("<br/>").append(product.getProductCode()).append("\uff1a").append(e.getMessage());
}
}
if (failureMsg.length() > 0)
{
throw new ServiceException("\u5bfc\u5165\u5b8c\u6210\u4f46\u5b58\u5728\u5931\u8d25\u9879\uff1a\u65b0\u589e " + successNum + "\uff0c\u66f4\u65b0 " + updateNum
+ "\uff0c\u8df3\u8fc7 " + skipNum + "\uff1b\u5931\u8d25\u660e\u7ec6\uff1a" + failureMsg);
}
String msg = "\u5bfc\u5165\u6210\u529f\uff1a\u65b0\u589e " + successNum + " \u4e2a\u4ea7\u54c1\uff0c\u66f4\u65b0 " + updateNum + " \u4e2a\uff0c\u8df3\u8fc7 "
+ skipNum + " \u4e2a\uff1b\u5199\u5165\u56fd\u5bb6/\u91cd\u91cf\u6bb5\u660e\u7ec6 " + countryNum + " \u884c\u3002"
+ "<br/>\u8303\u56f4\uff1a\u4ea7\u54c1\u76ee\u5f55 + \u91cd\u91cf\u6bb5min/max + \u5c3a\u5bf8/\u5c3e\u7a0b/\u65f6\u6548\uff1b\u56fd\u5bb6\u7801\u6765\u81ea bms_region.country_name_cn / country_code_2\u3002";
if (!unmatchedCountries.isEmpty())
{
msg += "<br/>\u672a\u5339\u914d\u56fd\u5bb6\u8868\uff08country_code \u4e3a\u7a7a\uff09\uff1a" + String.join("\u3001", unmatchedCountries);
}
return msg;
}
}
private void saveCountries(String productCode, List<BmsProductCountry> countries)
{
for (BmsProductCountry c : countries)
{
c.setProductCode(productCode);
countryMapper.insertBmsProductCountry(c);
}
}
private List<BmsProduct> parseCatalog(Sheet sheet)
{
List<BmsProduct> list = new ArrayList<>();
// Weishi workbook often starts data at column B (A empty); locate by header.
int[] header = findHeaderCell(sheet, "\u4ea7\u54c1\u5bf9\u63a5\u4ee3\u7801");
if (header == null)
{
return list;
}
int headerRow = header[0];
int baseCol = header[1];
int lastRow = sheet.getLastRowNum();
for (int i = headerRow + 1; i <= lastRow; i++)
{
Row row = sheet.getRow(i);
if (row == null)
{
continue;
}
String code = cellString(row.getCell(baseCol));
if (StringUtils.isEmpty(code) || "\u4ea7\u54c1\u5bf9\u63a5\u4ee3\u7801".equals(code))
{
continue;
}
if (!code.matches("(?i)^[A-Z0-9_-]+$"))
{
continue;
}
BmsProduct p = new BmsProduct();
p.setProductCode(code.trim());
p.setProductName(cellString(row.getCell(baseCol + 1)));
p.setServiceLevel(cellString(row.getCell(baseCol + 2)));
p.setCargoAttr(cellString(row.getCell(baseCol + 3)));
p.setDestRegion(cellString(row.getCell(baseCol + 4)));
p.setUpdateNote(cellString(row.getCell(baseCol + 5)));
p.setEffectiveDate(cellDate(row.getCell(baseCol + 6)));
p.setTaxMode(resolveTaxMode(p.getProductName()));
if (StringUtils.isEmpty(p.getProductName()))
{
continue;
}
list.add(p);
}
return list;
}
private List<BmsProductCountry> parseProductSheet(Workbook workbook, BmsProduct product,
Map<String, BmsRegion> regionByNameCn, Set<String> unmatchedCountries)
{
String sheetName = product.getProductName();
Sheet sheet = workbook.getSheet(sheetName);
if (sheet == null)
{
for (int i = 0; i < workbook.getNumberOfSheets(); i++)
{
String name = workbook.getSheetName(i);
if (SKIP_SHEETS.contains(name))
{
continue;
}
if (name != null && name.trim().equals(sheetName.trim()))
{
sheet = workbook.getSheetAt(i);
break;
}
}
}
if (sheet == null)
{
return new ArrayList<>();
}
Date sheetEffective = parseSheetEffectiveDate(sheet);
List<BmsProductCountry> list = new ArrayList<>();
// Product sheets also often start at column B.
int[] header = findHeaderCell(sheet, "\u56fd\u5bb6");
int baseCol = header != null ? header[1] : 0;
int startRow = header != null ? header[0] + 1 : 0;
String currentCountry = null;
String currentSize = null;
String currentLastMile = null;
String currentLeadTime = null;
int lastRow = sheet.getLastRowNum();
for (int i = startRow; i <= lastRow; i++)
{
Row row = sheet.getRow(i);
if (row == null)
{
continue;
}
String countryCell = cellString(row.getCell(baseCol));
String weightLabel = cellString(row.getCell(baseCol + 1));
if ("\u56fd\u5bb6".equals(countryCell) || weightLabel.contains("\u91cd\u91cf\u6bb5"))
{
continue;
}
if (StringUtils.isNotEmpty(countryCell))
{
currentCountry = countryCell.trim();
currentSize = emptyToNull(cellString(row.getCell(baseCol + 4)));
currentLastMile = emptyToNull(cellString(row.getCell(baseCol + 5)));
currentLeadTime = emptyToNull(cellString(row.getCell(baseCol + 6)));
}
if (StringUtils.isEmpty(currentCountry))
{
continue;
}
BigDecimal[] range = parseWeightRange(weightLabel);
if (range == null)
{
continue;
}
BmsRegion region = resolveRegion(currentCountry, regionByNameCn);
BmsProductCountry c = new BmsProductCountry();
if (region != null)
{
c.setCountryName(region.getCountryNameCn());
c.setCountryCode(region.getCountryCode2());
}
else
{
c.setCountryName(currentCountry);
c.setCountryCode(null);
unmatchedCountries.add(currentCountry);
}
c.setWeightMin(range[0]);
c.setWeightMax(range[1]);
c.setSizeLimit(currentSize);
c.setLastMile(currentLastMile);
c.setLeadTime(currentLeadTime);
c.setEffectiveDate(sheetEffective != null ? sheetEffective : product.getEffectiveDate());
list.add(c);
}
return list;
}
/**
* ???? bms_region??country_name_cn -> ????? country_code_2??
*/
private Map<String, BmsRegion> loadRegionByNameCn()
{
BmsRegion query = new BmsRegion();
query.setStatus("0");
List<BmsRegion> regions = regionMapper.selectRegionList(query);
Map<String, BmsRegion> map = new HashMap<>();
if (regions == null)
{
return map;
}
for (BmsRegion region : regions)
{
if (region == null || StringUtils.isEmpty(region.getCountryNameCn()))
{
continue;
}
map.put(region.getCountryNameCn().trim(), region);
}
return map;
}
/**
* ????? country_name_cn ?????????????????????????? ??? / ?????????
*/
private BmsRegion resolveRegion(String excelName, Map<String, BmsRegion> regionByNameCn)
{
if (StringUtils.isEmpty(excelName) || regionByNameCn == null || regionByNameCn.isEmpty())
{
return null;
}
String name = excelName.trim();
BmsRegion exact = regionByNameCn.get(name);
if (exact != null)
{
return exact;
}
List<BmsRegion> candidates = new ArrayList<>();
for (BmsRegion region : regionByNameCn.values())
{
String cn = region.getCountryNameCn();
if (StringUtils.isEmpty(cn))
{
continue;
}
if (cn.contains(name) || name.contains(cn))
{
candidates.add(region);
}
}
if (candidates.size() == 1)
{
return candidates.get(0);
}
return null;
}
private BigDecimal[] parseWeightRange(String label)
{
if (StringUtils.isEmpty(label))
{
return null;
}
Matcher m = WEIGHT_RANGE_PATTERN.matcher(label.replace(" ", ""));
if (!m.find())
{
return null;
}
try
{
BigDecimal min = new BigDecimal(m.group(1));
BigDecimal max = new BigDecimal(m.group(2));
if (min.compareTo(max) > 0)
{
BigDecimal tmp = min;
min = max;
max = tmp;
}
return new BigDecimal[] { min, max };
}
catch (Exception e)
{
return null;
}
}
private Date parseSheetEffectiveDate(Sheet sheet)
{
Row row0 = sheet.getRow(0);
if (row0 == null)
{
return null;
}
for (int c = 0; c < 12; c++)
{
String text = cellString(row0.getCell(c));
if (StringUtils.isEmpty(text))
{
continue;
}
Matcher m = EFFECTIVE_DATE_PATTERN.matcher(text);
if (m.find())
{
try
{
String d = m.group(1) + "-" + pad2(m.group(2)) + "-" + pad2(m.group(3));
return new SimpleDateFormat("yyyy-MM-dd").parse(d);
}
catch (Exception ignored)
{
return null;
}
}
}
return null;
}
private static String pad2(String n)
{
return n.length() == 1 ? "0" + n : n;
}
private static String resolveTaxMode(String productName)
{
if (productName != null && productName.contains("\u5305\u7a0e"))
{
return "TAX_INCLUDED";
}
if (productName != null && productName.contains("\u4e0d\u5305\u7a0e"))
{
return "TAX_EXCLUDED";
}
return "TAX_INCLUDED";
}
private static String emptyToNull(String s)
{
return StringUtils.isEmpty(s) ? null : s;
}
/**
* Scan top-left area for a header cell; returns {rowIndex, colIndex} or null.
* Prefers exact match, then contains.
*/
private static int[] findHeaderCell(Sheet sheet, String headerText)
{
if (sheet == null || StringUtils.isEmpty(headerText))
{
return null;
}
int lastRow = Math.min(sheet.getLastRowNum(), 30);
int[] containsHit = null;
for (int r = 0; r <= lastRow; r++)
{
Row row = sheet.getRow(r);
if (row == null)
{
continue;
}
short lastCell = row.getLastCellNum();
if (lastCell < 0)
{
continue;
}
// Also scan a few leading empty columns beyond lastCellNum edge cases
int maxCol = Math.max(lastCell, 12);
maxCol = Math.min(maxCol, 20);
for (int c = 0; c < maxCol; c++)
{
String v = cellString(row.getCell(c));
if (StringUtils.isEmpty(v))
{
continue;
}
if (headerText.equals(v))
{
return new int[] { r, c };
}
if (containsHit == null && v.contains(headerText))
{
containsHit = new int[] { r, c };
}
}
}
return containsHit;
}
private static String cellString(Cell cell)
{
if (cell == null)
{
return "";
}
CellType type = cell.getCellType();
if (type == CellType.FORMULA)
{
type = cell.getCachedFormulaResultType();
}
switch (type)
{
case STRING:
return cell.getStringCellValue() == null ? "" : cell.getStringCellValue().trim();
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell))
{
Date d = cell.getDateCellValue();
return d == null ? "" : new SimpleDateFormat("yyyy-MM-dd").format(d);
}
double num = cell.getNumericCellValue();
if (Math.floor(num) == num)
{
return String.valueOf((long) num);
}
return String.valueOf(num);
case BOOLEAN:
return String.valueOf(cell.getBooleanCellValue());
case BLANK:
return "";
default:
return "";
}
}
private static Date cellDate(Cell cell)
{
if (cell == null)
{
return null;
}
CellType type = cell.getCellType();
if (type == CellType.FORMULA)
{
type = cell.getCachedFormulaResultType();
}
if (type == CellType.NUMERIC)
{
if (DateUtil.isCellDateFormatted(cell))
{
return cell.getDateCellValue();
}
return DateUtil.getJavaDate(cell.getNumericCellValue());
}
if (type == CellType.STRING)
{
String s = cell.getStringCellValue();
if (StringUtils.isEmpty(s))
{
return null;
}
String[] patterns = { "yyyy-MM-dd", "yyyy/M/d", "yyyy/MM/dd", "yyyy-M-d",
"M/d/yy", "M/d/yyyy", "M/d/yy H:mm", "M/d/yyyy H:mm", "yyyy/M/d H:mm" };
for (String p : patterns)
{
try
{
return new SimpleDateFormat(p).parse(s.trim());
}
catch (Exception ignored)
{
}
}
}
return null;
}
}
@@ -34,6 +34,14 @@ public interface BmsRegionMapper
*/
public BmsRegion selectRegionByCode2(String countryCode2);
/**
* 根据中文名精确查询区域
*
* @param countryNameCn 中文名
* @return 区域信息
*/
public BmsRegion selectRegionByNameCn(String countryNameCn);
/**
* 新增区域
*
@@ -1,16 +1,15 @@
package com.ruoyi.system.service.impl;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.domain.BmsRegion;
@@ -21,7 +20,7 @@ import com.ruoyi.system.service.IBmsRegionSyncService;
/**
* 区域数据同步 服务层实现
* 从本地 countries.json 文件加载国家数据
* 通过 jsdelivr + mledoze/countries 同步国家数据(含中文翻译)
*
* @author ruoyi
*/
@@ -30,7 +29,12 @@ public class BmsRegionSyncServiceImpl implements IBmsRegionSyncService
{
private static final Logger log = LoggerFactory.getLogger(BmsRegionSyncServiceImpl.class);
private static final String DATA_FILE = "countries.json";
/** 国家数据源(mledoze/countries,jsdelivr CDN,国内可直连,无需鉴权) */
@Value("${bms.region.sync.url:https://cdn.jsdelivr.net/gh/mledoze/countries@master/dist/countries.json}")
private String dataSourceUrl;
@Autowired
private RestTemplate restTemplate;
@Autowired
private BmsRegionMapper regionMapper;
@@ -77,18 +81,13 @@ public class BmsRegionSyncServiceImpl implements IBmsRegionSyncService
try
{
log.info("开始同步国家数据,读取本地文件: {}", DATA_FILE);
// 从classpath读取国家数据文件
ClassPathResource resource = new ClassPathResource(DATA_FILE);
InputStream inputStream = resource.getInputStream();
byte[] bytes = inputStream.readAllBytes();
inputStream.close();
String jsonData = new String(bytes, StandardCharsets.UTF_8);
log.info("开始同步国家数据,远程数据源: {}", dataSourceUrl);
// 从远程接口拉取国家数据
String jsonData = restTemplate.getForObject(dataSourceUrl, String.class);
if (StringUtils.isEmpty(jsonData))
{
throw new RuntimeException("国家数据文件为空");
throw new RuntimeException("远程国家数据为空");
}
// 解析JSON
@@ -180,20 +179,18 @@ public class BmsRegionSyncServiceImpl implements IBmsRegionSyncService
region.setCountryNameEn((String) name.get("common"));
}
// 中文名 translations.cmn.common,如无则用英文名
// 中文名 translations.zho.common(mledoze/countries 标准字段名为 zho)
Map<String, Object> translations = (Map<String, Object>) country.get("translations");
if (translations != null)
{
Map<String, Object> cmn = (Map<String, Object>) translations.get("cmn");
if (cmn != null)
Map<String, Object> zho = (Map<String, Object>) translations.get("zho");
if (zho != null)
{
region.setCountryNameCn((String) cmn.get("common"));
region.setCountryNameCn((String) zho.get("common"));
}
}
if (StringUtils.isEmpty(region.getCountryNameCn()))
{
region.setCountryNameCn(region.getCountryNameEn());
}
// 注:取不到中文名时不再用英文名兜底,避免污染业务匹配。
// 若确需兜底,可改为 region.setCountryNameCn(StringUtils.EMPTY);
// ISO代码
region.setCountryCode2((String) country.get("cca2"));
@@ -47,6 +47,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where country_code_2 = #{countryCode2} limit 1
</select>
<select id="selectRegionByNameCn" parameterType="String" resultMap="BmsRegionResult">
<include refid="selectRegionVo"/>
where del_flag = '0' and country_name_cn = #{countryNameCn} limit 1
</select>
<select id="selectRegionList" parameterType="BmsRegion" resultMap="BmsRegionResult">
<include refid="selectRegionVo"/>
<where>
@@ -0,0 +1,67 @@
<?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.ruoyi.price.mapper.BmsProductCountryMapper">
<resultMap type="BmsProductCountry" id="BmsProductCountryResult">
<id property="detailId" column="detail_id" />
<result property="productCode" column="product_code" />
<result property="countryName" column="country_name" />
<result property="countryCode" column="country_code" />
<result property="weightMin" column="weight_min" />
<result property="weightMax" column="weight_max" />
<result property="sizeLimit" column="size_limit" />
<result property="lastMile" column="last_mile" />
<result property="leadTime" column="lead_time" />
<result property="effectiveDate" column="effective_date" />
<result property="createdAt" column="created_at" />
<result property="updatedAt" column="updated_at" />
</resultMap>
<select id="selectBmsProductCountryList" parameterType="BmsProductCountry" resultMap="BmsProductCountryResult">
select detail_id, product_code, country_name, country_code, weight_min, weight_max,
size_limit, last_mile, lead_time, effective_date, created_at, updated_at
from bms_product_country
<where>
<if test="productCode != null and productCode != ''">
AND product_code = #{productCode}
</if>
<if test="countryName != null and countryName != ''">
AND country_name like concat('%', #{countryName}, '%')
</if>
<if test="countryCode != null and countryCode != ''">
AND country_code = #{countryCode}
</if>
<if test="weightMin != null">
AND weight_min = #{weightMin}
</if>
<if test="weightMax != null">
AND weight_max = #{weightMax}
</if>
</where>
order by country_name asc, weight_min asc, detail_id asc
</select>
<insert id="insertBmsProductCountry" parameterType="BmsProductCountry" useGeneratedKeys="true" keyProperty="detailId">
insert into bms_product_country (
product_code, country_name, country_code, weight_min, weight_max,
size_limit, last_mile, lead_time, effective_date, created_at
) values (
#{productCode}, #{countryName}, #{countryCode}, #{weightMin}, #{weightMax},
#{sizeLimit}, #{lastMile}, #{leadTime}, #{effectiveDate}, sysdate()
)
</insert>
<delete id="deleteByProductCode" parameterType="String">
delete from bms_product_country where product_code = #{productCode}
</delete>
<delete id="deleteByProductCodes">
delete from bms_product_country where product_code in
<foreach item="code" collection="array" open="(" separator="," close=")">
#{code}
</foreach>
</delete>
</mapper>
@@ -0,0 +1,134 @@
<?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.ruoyi.price.mapper.BmsProductMapper">
<resultMap type="BmsProduct" id="BmsProductResult">
<id property="productId" column="product_id" />
<result property="productCode" column="product_code" />
<result property="productName" column="product_name" />
<result property="serviceLevel" column="service_level" />
<result property="cargoAttr" column="cargo_attr" />
<result property="destRegion" column="dest_region" />
<result property="updateNote" column="update_note" />
<result property="taxMode" column="tax_mode" />
<result property="effectiveDate" column="effective_date" />
<result property="status" column="status" />
<result property="sourceSheet" column="source_sheet" />
<result property="remark" column="remark" />
<result property="tmsId" column="tms_id" />
<result property="createBy" column="created_by" />
<result property="createTime" column="created_at" />
<result property="updateBy" column="updated_by" />
<result property="updateTime" column="updated_at" />
<result property="countryCount" column="country_count" />
</resultMap>
<sql id="selectBmsProductVo">
select p.product_id, p.product_code, p.product_name, p.service_level, p.cargo_attr,
p.dest_region, p.update_note, p.tax_mode, p.effective_date, p.status,
p.source_sheet, p.remark, p.tms_id, p.created_by, p.created_at, p.updated_by, p.updated_at,
(select count(distinct c.country_name) from bms_product_country c where c.product_code = p.product_code) as country_count
from bms_product p
</sql>
<select id="selectBmsProductById" parameterType="Long" resultMap="BmsProductResult">
<include refid="selectBmsProductVo"/>
where p.product_id = #{productId}
</select>
<select id="selectBmsProductByCode" parameterType="String" resultMap="BmsProductResult">
<include refid="selectBmsProductVo"/>
where p.product_code = #{productCode}
</select>
<select id="selectBmsProductList" parameterType="BmsProduct" resultMap="BmsProductResult">
<include refid="selectBmsProductVo"/>
<where>
<if test="productCode != null and productCode != ''">
AND p.product_code like concat('%', #{productCode}, '%')
</if>
<if test="productName != null and productName != ''">
AND p.product_name like concat('%', #{productName}, '%')
</if>
<if test="taxMode != null and taxMode != ''">
AND p.tax_mode = #{taxMode}
</if>
<if test="status != null and status != ''">
AND p.status = #{status}
</if>
<if test="cargoAttr != null and cargoAttr != ''">
AND p.cargo_attr like concat('%', #{cargoAttr}, '%')
</if>
<if test="params != null and params.keyword != null and params.keyword != ''">
AND (p.product_code like concat('%', #{params.keyword}, '%')
OR p.product_name like concat('%', #{params.keyword}, '%'))
</if>
</where>
order by p.product_id desc
</select>
<insert id="insertBmsProduct" parameterType="BmsProduct" useGeneratedKeys="true" keyProperty="productId">
insert into bms_product (
<if test="productCode != null and productCode != ''">product_code,</if>
<if test="productName != null and productName != ''">product_name,</if>
<if test="serviceLevel != null">service_level,</if>
<if test="cargoAttr != null">cargo_attr,</if>
<if test="destRegion != null">dest_region,</if>
<if test="updateNote != null">update_note,</if>
<if test="taxMode != null and taxMode != ''">tax_mode,</if>
<if test="effectiveDate != null">effective_date,</if>
<if test="status != null and status != ''">status,</if>
<if test="sourceSheet != null">source_sheet,</if>
<if test="remark != null">remark,</if>
<if test="tmsId != null">tms_id,</if>
<if test="createBy != null and createBy != ''">created_by,</if>
created_at
) values (
<if test="productCode != null and productCode != ''">#{productCode},</if>
<if test="productName != null and productName != ''">#{productName},</if>
<if test="serviceLevel != null">#{serviceLevel},</if>
<if test="cargoAttr != null">#{cargoAttr},</if>
<if test="destRegion != null">#{destRegion},</if>
<if test="updateNote != null">#{updateNote},</if>
<if test="taxMode != null and taxMode != ''">#{taxMode},</if>
<if test="effectiveDate != null">#{effectiveDate},</if>
<if test="status != null and status != ''">#{status},</if>
<if test="sourceSheet != null">#{sourceSheet},</if>
<if test="remark != null">#{remark},</if>
<if test="tmsId != null">#{tmsId},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
sysdate()
)
</insert>
<update id="updateBmsProduct" parameterType="BmsProduct">
update bms_product
<set>
<if test="productCode != null and productCode != ''">product_code = #{productCode},</if>
<if test="productName != null and productName != ''">product_name = #{productName},</if>
<if test="serviceLevel != null">service_level = #{serviceLevel},</if>
<if test="cargoAttr != null">cargo_attr = #{cargoAttr},</if>
<if test="destRegion != null">dest_region = #{destRegion},</if>
<if test="updateNote != null">update_note = #{updateNote},</if>
<if test="taxMode != null and taxMode != ''">tax_mode = #{taxMode},</if>
<if test="effectiveDate != null">effective_date = #{effectiveDate},</if>
<if test="status != null and status != ''">status = #{status},</if>
<if test="sourceSheet != null">source_sheet = #{sourceSheet},</if>
<if test="remark != null">remark = #{remark},</if>
<if test="tmsId != null">tms_id = #{tmsId},</if>
<if test="updateBy != null and updateBy != ''">updated_by = #{updateBy},</if>
updated_at = sysdate()
</set>
where product_id = #{productId}
</update>
<delete id="deleteBmsProductByIds" parameterType="Long">
delete from bms_product where product_id in
<foreach item="productId" collection="array" open="(" separator="," close=")">
#{productId}
</foreach>
</delete>
</mapper>
+74
View File
@@ -0,0 +1,74 @@
-- 产品主数据(来源:威狮B2C小包价表.xlsx)
-- 导入范围:产品目录 + 各产品 sheet 国家服务明细(尺寸/尾程/时效)
-- 不导入:运费/挂号费矩阵、退件重派、申报说明、赔偿标准
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS bms_product (
product_id bigint NOT NULL AUTO_INCREMENT COMMENT '产品ID',
product_code varchar(20) NOT NULL COMMENT '产品对接代码,如 WSP037',
product_name varchar(100) NOT NULL COMMENT '产品名称',
service_level varchar(20) DEFAULT NULL COMMENT '时效等级:标准等',
cargo_attr varchar(100) DEFAULT NULL COMMENT '走货属性:普货/F普货/带电/敏感等',
dest_region varchar(50) DEFAULT NULL COMMENT '目的区域:欧盟等',
update_note varchar(500) DEFAULT NULL COMMENT '更新内容说明',
tax_mode varchar(20) NOT NULL DEFAULT 'TAX_INCLUDED' COMMENT 'TAX_INCLUDED包税 TAX_EXCLUDED不包税',
effective_date date DEFAULT NULL COMMENT '目录生效日期',
status char(1) NOT NULL DEFAULT '0' COMMENT '0正常 1停用',
source_sheet varchar(100) DEFAULT NULL COMMENT '来源Excel sheet名',
remark varchar(500) DEFAULT NULL,
tms_id int DEFAULT NULL COMMENT '多租户ID',
created_by varchar(50) NOT NULL DEFAULT '',
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by varchar(50) NOT NULL DEFAULT '',
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (product_id),
UNIQUE KEY uk_product_code (product_code),
KEY idx_tax_status (tax_mode, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BMS产品主表-来自产品目录';
CREATE TABLE IF NOT EXISTS bms_product_country (
detail_id bigint NOT NULL AUTO_INCREMENT COMMENT '明细ID',
product_code varchar(20) NOT NULL COMMENT '产品代码',
country_name varchar(50) NOT NULL COMMENT '国家中文名',
country_code char(2) DEFAULT NULL COMMENT 'ISO2,可空待补齐',
weight_min decimal(10,3) DEFAULT NULL COMMENT '重量段最小值KG(含)',
weight_max decimal(10,3) DEFAULT NULL COMMENT '重量段最大值KG(含)',
size_limit varchar(1000) DEFAULT NULL COMMENT '尺寸限制',
last_mile varchar(500) DEFAULT NULL COMMENT '尾程派送说明',
lead_time varchar(100) DEFAULT NULL COMMENT '参考时效',
effective_date date DEFAULT NULL COMMENT '产品价表生效日期',
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (detail_id),
UNIQUE KEY uk_product_country_weight (product_code, country_name, weight_min, weight_max),
KEY idx_country_code (country_code),
KEY idx_weight_match (product_code, country_code, weight_min, weight_max),
CONSTRAINT fk_product_country_code FOREIGN KEY (product_code) REFERENCES bms_product (product_code) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品国家服务明细-重量段+尺寸/尾程/时效(不含运费)';
-- 已建表升级(幂等:列已存在时跳过报错可忽略)
-- ALTER TABLE bms_product_country ADD COLUMN weight_min decimal(10,3) DEFAULT NULL COMMENT '重量段最小值KG(含)' AFTER country_code;
-- ALTER TABLE bms_product_country ADD COLUMN weight_max decimal(10,3) DEFAULT NULL COMMENT '重量段最大值KG(含)' AFTER weight_min;
-- ALTER TABLE bms_product_country DROP INDEX uk_product_country;
-- ALTER TABLE bms_product_country ADD UNIQUE KEY uk_product_country_weight (product_code, country_name, weight_min, weight_max);
-- ALTER TABLE bms_product_country ADD KEY idx_weight_match (product_code, country_code, weight_min, weight_max);
-- 菜单:挂在价格基础 2100 下(order_num=0 置顶)
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 2130, N'产品列表', 2100, 0, 'product', 'price/basic/product/index', '', 'PriceProduct', 1, 0, 'C', '0', '0', 'price:product:list', 'shopping', 'admin', sysdate(), N'产品目录与国家服务明细'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id=2130);
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 2131, N'产品查询', 2130, 1, '', NULL, 1, 0, 'F', '0', '0', 'price:product:query', '#', 'admin', sysdate() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id=2131);
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 2132, N'产品新增', 2130, 2, '', NULL, 1, 0, 'F', '0', '0', 'price:product:add', '#', 'admin', sysdate() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id=2132);
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 2133, N'产品修改', 2130, 3, '', NULL, 1, 0, 'F', '0', '0', 'price:product:edit', '#', 'admin', sysdate() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id=2133);
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 2134, N'产品删除', 2130, 4, '', NULL, 1, 0, 'F', '0', '0', 'price:product:remove', '#', 'admin', sysdate() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id=2134);
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 2135, N'产品导入', 2130, 5, '', NULL, 1, 0, 'F', '0', '0', 'price:product:import', '#', 'admin', sysdate() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id=2135);
INSERT INTO sys_role_menu(role_id, menu_id)
SELECT 1, m.menu_id FROM (SELECT 2130 AS menu_id UNION SELECT 2131 UNION SELECT 2132 UNION SELECT 2133 UNION SELECT 2134 UNION SELECT 2135) m
WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id=1 AND rm.menu_id=m.menu_id);
@@ -0,0 +1,13 @@
-- 已存在 bms_product_country 时升级:增加重量段最小/最大值,便于后续按重量匹配
-- 执行后请重新在「产品列表」导入 Excel(旧数据一行一国 → 新数据按重量段多行)
SET NAMES utf8mb4;
ALTER TABLE bms_product_country
ADD COLUMN weight_min decimal(10,3) DEFAULT NULL COMMENT '重量段最小值KG(含)' AFTER country_code,
ADD COLUMN weight_max decimal(10,3) DEFAULT NULL COMMENT '重量段最大值KG(含)' AFTER weight_min;
-- 旧唯一键(国家级)改为重量段级
ALTER TABLE bms_product_country DROP INDEX uk_product_country;
ALTER TABLE bms_product_country
ADD UNIQUE KEY uk_product_country_weight (product_code, country_name, weight_min, weight_max),
ADD KEY idx_weight_match (product_code, country_code, weight_min, weight_max);
+52
View File
@@ -0,0 +1,52 @@
import request from '@/utils/request'
/** 查询产品列表 */
export function listProduct(query) {
return request({
url: '/price/product/list',
method: 'get',
params: query
})
}
/** 查询产品详情 */
export function getProduct(productId) {
return request({
url: '/price/product/' + productId,
method: 'get'
})
}
/** 查询产品国家明细 */
export function listProductCountries(productCode) {
return request({
url: '/price/product/' + productCode + '/countries',
method: 'get'
})
}
/** 新增产品 */
export function addProduct(data) {
return request({
url: '/price/product',
method: 'post',
data: data
})
}
/** 修改产品 */
export function updateProduct(data) {
return request({
url: '/price/product',
method: 'put',
data: data
})
}
/** 删除产品 */
export function delProduct(productIds) {
return request({
url: '/price/product/' + productIds,
method: 'delete'
})
}
@@ -0,0 +1,299 @@
<template>
<div class="app-container">
<el-alert
type="info"
:closable="false"
show-icon
class="mb16"
title="Excel 导入:上传威狮B2C小包价表.xlsx。仅入库产品目录 + 各产品 sheet 的国家尺寸/尾程/时效;不导入运费、挂号费、退件重派、申报说明、赔偿标准。"
/>
<basic-table
ref="tableRef"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
row-key="productId"
>
<template #toolbar="{ selection, ids }">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['price:product:add']">新建产品</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" plain icon="Edit" :disabled="selection.length !== 1" @click="handleUpdate(selection[0])" v-hasPermi="['price:product: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:product:remove']">删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="info" plain icon="Upload" @click="handleImport" v-hasPermi="['price:product:import']">导入 Excel</el-button>
</el-col>
</template>
<template #col-taxMode="{ row }">
<el-tag :type="row.taxMode === 'TAX_INCLUDED' ? 'success' : 'warning'" size="small">
{{ row.taxMode === 'TAX_INCLUDED' ? '包税' : '不包税' }}
</el-tag>
</template>
<template #col-cargoAttr="{ row }">
<el-tag type="primary" size="small" effect="plain">{{ row.cargoAttr || '-' }}</el-tag>
</template>
<template #col-status="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'info'" size="small">
{{ row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="openCountries(row)" v-hasPermi="['price:product:query']">国家明细</el-button>
<el-button link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['price:product:edit']">编辑</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(row.productId)" v-hasPermi="['price:product:remove']">删除</el-button>
</template>
</basic-table>
<el-dialog :title="title" v-model="open" width="640px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-row>
<el-col :span="12">
<el-form-item label="产品代码" prop="productCode">
<el-input v-model="form.productCode" placeholder="如 WSP037" maxlength="20" :disabled="!!form.productId" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品名称" prop="productName">
<el-input v-model="form.productName" placeholder="产品名称" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="时效等级" prop="serviceLevel">
<el-input v-model="form.serviceLevel" placeholder="如 标准" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="走货属性" prop="cargoAttr">
<el-input v-model="form.cargoAttr" placeholder="普货/带电等" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="目的区域" prop="destRegion">
<el-input v-model="form.destRegion" placeholder="如 欧盟" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="税模式" prop="taxMode">
<el-select v-model="form.taxMode" placeholder="请选择" style="width: 100%">
<el-option label="包税" value="TAX_INCLUDED" />
<el-option label="不包税" value="TAX_EXCLUDED" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="生效日期" prop="effectiveDate">
<el-date-picker v-model="form.effectiveDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio value="0">正常</el-radio>
<el-radio value="1">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="更新内容" prop="updateNote">
<el-input v-model="form.updateNote" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="open = false">取 消</el-button>
</template>
</el-dialog>
<el-drawer v-model="countryOpen" size="720px" :title="countryTitle" append-to-body>
<el-table v-loading="countryLoading" :data="countryList" border empty-text="暂无国家明细">
<el-table-column label="国家" prop="countryName" width="90" show-overflow-tooltip />
<el-table-column label="ISO2" prop="countryCode" width="60" align="center" />
<el-table-column label="重量段(KG)" width="120" align="center">
<template #default="{ row }">
<span v-if="row.weightMin != null || row.weightMax != null">{{ row.weightMin }} ~ {{ row.weightMax }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="尺寸限制" prop="sizeLimit" min-width="140">
<template #default="{ row }">
<span class="pre-wrap">{{ row.sizeLimit }}</span>
</template>
</el-table-column>
<el-table-column label="尾程" prop="lastMile" min-width="110">
<template #default="{ row }">
<span class="pre-wrap">{{ row.lastMile }}</span>
</template>
</el-table-column>
<el-table-column label="时效" prop="leadTime" width="100" show-overflow-tooltip />
</el-table>
</el-drawer>
<excel-import-dialog
ref="importRef"
title="产品 Excel 导入"
action="/price/product/importData"
update-support-label="是否更新已经存在的产品(含国家明细覆盖)"
@success="() => tableRef.refresh()"
/>
</div>
</template>
<script setup name="PriceProduct">
import ExcelImportDialog from '@/components/ExcelImportDialog'
import { listProduct, getProduct, addProduct, updateProduct, delProduct, listProductCountries } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const tableRef = ref()
const importRef = ref()
const formRef = ref()
const open = ref(false)
const title = ref('')
const countryOpen = ref(false)
const countryTitle = ref('')
const countryLoading = ref(false)
const countryList = ref([])
const columns = [
{ prop: 'productCode', label: '产品代码', minWidth: 110 },
{ prop: 'productName', label: '产品名称', minWidth: 140, showOverflowTooltip: true },
{ prop: 'serviceLevel', label: '时效等级', width: 90, align: 'center' },
{ prop: 'cargoAttr', label: '走货属性', minWidth: 120, slot: 'cargoAttr' },
{ prop: 'destRegion', label: '目的区域', width: 90, align: 'center' },
{ prop: 'taxMode', label: '税模式', width: 90, align: 'center', slot: 'taxMode' },
{ prop: 'effectiveDate', label: '生效日期', width: 120, align: 'center' },
{ prop: 'countryCount', label: '国家数', width: 80, align: 'center' },
{ prop: 'status', label: '状态', width: 80, align: 'center', slot: 'status' },
{ prop: 'updateNote', label: '更新内容', minWidth: 160, showOverflowTooltip: true },
{ prop: 'action', label: '操作', width: 240, fixed: 'right', slot: 'action' }
]
const searchColumns = [
{ prop: 'productCode', label: '产品代码' },
{ prop: 'productName', label: '产品名称' },
{
prop: 'taxMode',
label: '税模式',
type: 'select',
options: [
{ label: '包税', value: 'TAX_INCLUDED' },
{ label: '不包税', value: 'TAX_EXCLUDED' }
]
},
{
prop: 'status',
label: '状态',
type: 'select',
options: [
{ label: '正常', value: '0' },
{ label: '停用', value: '1' }
]
}
]
const data = reactive({
form: {},
rules: {
productCode: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }],
productName: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
taxMode: [{ required: true, message: '请选择税模式', trigger: 'change' }]
}
})
const { form, rules } = toRefs(data)
function queryList(params) {
return listProduct(params)
}
function reset() {
form.value = {
productId: undefined,
productCode: undefined,
productName: undefined,
serviceLevel: undefined,
cargoAttr: undefined,
destRegion: undefined,
updateNote: undefined,
taxMode: 'TAX_INCLUDED',
effectiveDate: undefined,
status: '0',
remark: undefined
}
proxy.resetForm('formRef')
}
function handleAdd() {
reset()
open.value = true
title.value = '新建产品'
}
function handleUpdate(row) {
reset()
getProduct(row.productId).then(res => {
form.value = res.data
open.value = true
title.value = '修改产品'
})
}
function submitForm() {
proxy.$refs['formRef'].validate(valid => {
if (!valid) return
const req = form.value.productId ? updateProduct(form.value) : addProduct(form.value)
req.then(() => {
proxy.$modal.msgSuccess('操作成功')
open.value = false
tableRef.value.refresh()
})
})
}
function handleDelete(ids) {
const idText = Array.isArray(ids) ? ids.join(',') : ids
proxy.$modal.confirm('是否确认删除所选产品?国家明细将一并删除。').then(() => {
return delProduct(idText)
}).then(() => {
proxy.$modal.msgSuccess('删除成功')
tableRef.value.refresh()
}).catch(() => {})
}
function handleImport() {
importRef.value.open()
}
function openCountries(row) {
countryTitle.value = `${row.productCode} · ${row.productName} · 国家服务明细`
countryOpen.value = true
countryLoading.value = true
countryList.value = []
listProductCountries(row.productCode).then(res => {
countryList.value = res.data || []
}).finally(() => {
countryLoading.value = false
})
}
</script>
<style scoped>
.mb16 { margin-bottom: 16px; }
.pre-wrap { white-space: pre-wrap; font-size: 12px; line-height: 1.4; }
</style>