增加产品导入功能
This commit is contained in:
+98
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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;
|
||||||
|
}
|
||||||
+639
@@ -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);
|
public BmsRegion selectRegionByCode2(String countryCode2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据中文名精确查询区域
|
||||||
|
*
|
||||||
|
* @param countryNameCn 中文名
|
||||||
|
* @return 区域信息
|
||||||
|
*/
|
||||||
|
public BmsRegion selectRegionByNameCn(String countryNameCn);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新增区域
|
* 新增区域
|
||||||
*
|
*
|
||||||
|
|||||||
+19
-22
@@ -1,16 +1,15 @@
|
|||||||
package com.ruoyi.system.service.impl;
|
package com.ruoyi.system.service.impl;
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.ruoyi.common.utils.StringUtils;
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
import com.ruoyi.system.domain.BmsRegion;
|
import com.ruoyi.system.domain.BmsRegion;
|
||||||
@@ -21,7 +20,7 @@ import com.ruoyi.system.service.IBmsRegionSyncService;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 区域数据同步 服务层实现
|
* 区域数据同步 服务层实现
|
||||||
* 从本地 countries.json 文件加载国家数据
|
* 通过 jsdelivr + mledoze/countries 同步国家数据(含中文翻译)
|
||||||
*
|
*
|
||||||
* @author ruoyi
|
* @author ruoyi
|
||||||
*/
|
*/
|
||||||
@@ -30,7 +29,12 @@ public class BmsRegionSyncServiceImpl implements IBmsRegionSyncService
|
|||||||
{
|
{
|
||||||
private static final Logger log = LoggerFactory.getLogger(BmsRegionSyncServiceImpl.class);
|
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
|
@Autowired
|
||||||
private BmsRegionMapper regionMapper;
|
private BmsRegionMapper regionMapper;
|
||||||
@@ -77,18 +81,13 @@ public class BmsRegionSyncServiceImpl implements IBmsRegionSyncService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
log.info("开始同步国家数据,读取本地文件: {}", DATA_FILE);
|
log.info("开始同步国家数据,远程数据源: {}", dataSourceUrl);
|
||||||
|
|
||||||
// 从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);
|
|
||||||
|
|
||||||
|
// 从远程接口拉取国家数据
|
||||||
|
String jsonData = restTemplate.getForObject(dataSourceUrl, String.class);
|
||||||
if (StringUtils.isEmpty(jsonData))
|
if (StringUtils.isEmpty(jsonData))
|
||||||
{
|
{
|
||||||
throw new RuntimeException("国家数据文件为空");
|
throw new RuntimeException("远程国家数据为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析JSON
|
// 解析JSON
|
||||||
@@ -180,20 +179,18 @@ public class BmsRegionSyncServiceImpl implements IBmsRegionSyncService
|
|||||||
region.setCountryNameEn((String) name.get("common"));
|
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");
|
Map<String, Object> translations = (Map<String, Object>) country.get("translations");
|
||||||
if (translations != null)
|
if (translations != null)
|
||||||
{
|
{
|
||||||
Map<String, Object> cmn = (Map<String, Object>) translations.get("cmn");
|
Map<String, Object> zho = (Map<String, Object>) translations.get("zho");
|
||||||
if (cmn != null)
|
if (zho != null)
|
||||||
{
|
{
|
||||||
region.setCountryNameCn((String) cmn.get("common"));
|
region.setCountryNameCn((String) zho.get("common"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (StringUtils.isEmpty(region.getCountryNameCn()))
|
// 注:取不到中文名时不再用英文名兜底,避免污染业务匹配。
|
||||||
{
|
// 若确需兜底,可改为 region.setCountryNameCn(StringUtils.EMPTY);
|
||||||
region.setCountryNameCn(region.getCountryNameEn());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ISO代码
|
// ISO代码
|
||||||
region.setCountryCode2((String) country.get("cca2"));
|
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
|
where country_code_2 = #{countryCode2} limit 1
|
||||||
</select>
|
</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">
|
<select id="selectRegionList" parameterType="BmsRegion" resultMap="BmsRegionResult">
|
||||||
<include refid="selectRegionVo"/>
|
<include refid="selectRegionVo"/>
|
||||||
<where>
|
<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>
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user