修改产品导入

This commit is contained in:
wuweihua
2026-09-29 20:50:01 +08:00
parent 3f8f3a824d
commit 38f371742a
10 changed files with 613 additions and 139 deletions
@@ -1,7 +1,9 @@
package com.bms.web.controller.price; package com.bms.web.controller.price;
import java.util.Date; import java.util.Date;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -20,12 +22,14 @@ import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult; import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo; import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType; import com.bms.common.enums.BusinessType;
import com.bms.common.utils.MessageUtils;
import com.bms.price.domain.BmsProduct; import com.bms.price.domain.BmsProduct;
import com.bms.price.domain.BmsProductCountry; import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.BmsProductImportTask;
import com.bms.price.service.IBmsProductService; import com.bms.price.service.IBmsProductService;
/** /**
* 产品列表(含 Excel 导入) * Product list (async Excel import)
* *
* @author bms * @author bms
*/ */
@@ -45,6 +49,17 @@ public class ProductController extends BaseController
return getDataTable(list); return getDataTable(list);
} }
@PreAuthorize("@ss.hasPermi('price:product:import')")
@GetMapping("/import/task/{taskId}")
public AjaxResult getImportTask(@PathVariable String taskId)
{
BmsProductImportTask task = productService.getImportTask(taskId);
if (task == null)
{
return error(MessageUtils.message("price.product.import.task.notExists"));
}
return success(task);
}
@PreAuthorize("@ss.hasPermi('price:product:query')") @PreAuthorize("@ss.hasPermi('price:product:query')")
@GetMapping("/{productCode}/countries") @GetMapping("/{productCode}/countries")
@@ -64,7 +79,7 @@ public class ProductController extends BaseController
} }
@PreAuthorize("@ss.hasPermi('price:product:add')") @PreAuthorize("@ss.hasPermi('price:product:add')")
@Log(title = "产品列表", businessType = BusinessType.INSERT) @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.INSERT)
@PostMapping @PostMapping
public AjaxResult add(@Validated @RequestBody BmsProduct product) public AjaxResult add(@Validated @RequestBody BmsProduct product)
{ {
@@ -73,7 +88,7 @@ public class ProductController extends BaseController
} }
@PreAuthorize("@ss.hasPermi('price:product:edit')") @PreAuthorize("@ss.hasPermi('price:product:edit')")
@Log(title = "产品列表", businessType = BusinessType.UPDATE) @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.UPDATE)
@PutMapping @PutMapping
public AjaxResult edit(@Validated @RequestBody BmsProduct product) public AjaxResult edit(@Validated @RequestBody BmsProduct product)
{ {
@@ -81,11 +96,8 @@ public class ProductController extends BaseController
return toAjax(productService.updateBmsProduct(product)); return toAjax(productService.updateBmsProduct(product));
} }
/**
* 修改产品国家明细行(重量段/运费/挂号费/尺寸/尾程/时效/生效失效日期)
*/
@PreAuthorize("@ss.hasPermi('price:product:edit')") @PreAuthorize("@ss.hasPermi('price:product:edit')")
@Log(title = "产品国家明细", businessType = BusinessType.UPDATE) @Log(title = "\u4ea7\u54c1\u56fd\u5bb6\u660e\u7ec6", businessType = BusinessType.UPDATE)
@PutMapping("/country") @PutMapping("/country")
public AjaxResult editCountry(@RequestBody BmsProductCountry country) public AjaxResult editCountry(@RequestBody BmsProductCountry country)
{ {
@@ -93,7 +105,7 @@ public class ProductController extends BaseController
} }
@PreAuthorize("@ss.hasPermi('price:product:remove')") @PreAuthorize("@ss.hasPermi('price:product:remove')")
@Log(title = "产品列表", businessType = BusinessType.DELETE) @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.DELETE)
@DeleteMapping("/{productIds}") @DeleteMapping("/{productIds}")
public AjaxResult remove(@PathVariable Long[] productIds) public AjaxResult remove(@PathVariable Long[] productIds)
{ {
@@ -101,26 +113,30 @@ public class ProductController extends BaseController
} }
/** /**
* 导入威狮B2C小包价表.xlsx(产品目录 + 国家服务明细) * Async import Weishi B2C workbook. Returns taskId; poll /import/task/{taskId}.
*/ */
@Log(title = "产品列表", businessType = BusinessType.IMPORT) @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.IMPORT)
@PreAuthorize("@ss.hasPermi('price:product:import')") @PreAuthorize("@ss.hasPermi('price:product:import')")
@PostMapping("/importData") @PostMapping("/importData")
public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception
{ {
String message = productService.importProductExcel(file, updateSupport, getUsername()); String taskId = productService.submitImportProductExcelAsync(file, updateSupport, getUsername());
return success(message); Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
} }
/** /**
* Import a single-country linehaul cost sheet. countryCode is the destination. * Async single-country cost-sheet import. Returns taskId; poll /import/task/{taskId}.
*/ */
@Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.IMPORT) @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.IMPORT)
@PreAuthorize("@ss.hasPermi('price:product:import')") @PreAuthorize("@ss.hasPermi('price:product:import')")
@PostMapping("/importCountryData") @PostMapping("/importCountryData")
public AjaxResult importCountryData(MultipartFile file, String countryCode) throws Exception public AjaxResult importCountryData(MultipartFile file, String countryCode) throws Exception
{ {
String message = productService.importCountryProductExcel(file, countryCode, getUsername()); String taskId = productService.submitImportCountryProductExcelAsync(file, countryCode, getUsername());
return success(message); Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
} }
} }
@@ -127,6 +127,7 @@ price.product.import.countryNotFound=国家不存在或已停用:{0}
price.product.import.countrySheetNotFound=未找到「产品目录」sheet,请上传单国专线成本价表 price.product.import.countrySheetNotFound=未找到「产品目录」sheet,请上传单国专线成本价表
price.product.import.countryNoPrice=未找到该产品的重量段价格:{0} price.product.import.countryNoPrice=未找到该产品的重量段价格:{0}
price.product.import.countrySuccess=按国家导入成功({0}):新增 {1} 个产品,跳过 {2} 个(同代码同生效日不覆盖主数据);写入重量段 {3} 行。 price.product.import.countrySuccess=按国家导入成功({0}):新增 {1} 个产品,跳过 {2} 个(同代码同生效日不覆盖主数据);写入重量段 {3} 行。
price.product.import.task.notExists=任务不存在或已过期
# 报价试算 TmsPriceQuote(对外 TMS API 错误码,保持英文契约) # 报价试算 TmsPriceQuote(对外 TMS API 错误码,保持英文契约)
price.quote.postcodeRequired=该国家/产品有分区,请填写目的邮编 price.quote.postcodeRequired=该国家/产品有分区,请填写目的邮编
price.quote.postcodeZoneNotFound=邮编未匹配到分区,请核对目的邮编 price.quote.postcodeZoneNotFound=邮编未匹配到分区,请核对目的邮编
@@ -0,0 +1,72 @@
package com.bms.price.domain;
import java.io.Serializable;
/**
* Async product Excel import progress (Redis).
*/
public class BmsProductImportTask implements Serializable
{
private static final long serialVersionUID = 1L;
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_RUNNING = "RUNNING";
public static final String STATUS_SUCCESS = "SUCCESS";
public static final String STATUS_FAIL = "FAIL";
private String taskId;
private String status;
private int percent;
private String message;
private String error;
public String getTaskId()
{
return taskId;
}
public void setTaskId(String taskId)
{
this.taskId = taskId;
}
public String getStatus()
{
return status;
}
public void setStatus(String status)
{
this.status = status;
}
public int getPercent()
{
return percent;
}
public void setPercent(int percent)
{
this.percent = percent;
}
public String getMessage()
{
return message;
}
public void setMessage(String message)
{
this.message = message;
}
public String getError()
{
return error;
}
public void setError(String error)
{
this.error = error;
}
}
@@ -39,6 +39,16 @@ public interface BmsProductCountryMapper
@Param("productCode") String productCode, @Param("productCode") String productCode,
@Param("newEffectiveDate") Date newEffectiveDate); @Param("newEffectiveDate") Date newEffectiveDate);
/**
* Close open rows for one product + country where effective_date &lt; newEffectiveDate.
* Match by country_code when present, otherwise country_name.
*/
public int expireOpenByProductCodeAndCountry(
@Param("productCode") String productCode,
@Param("countryCode") String countryCode,
@Param("countryName") String countryName,
@Param("newEffectiveDate") Date newEffectiveDate);
/** Recompute status from effective_date / expire_date vs today */ /** Recompute status from effective_date / expire_date vs today */
public int refreshStatusByDates(); public int refreshStatusByDates();
@@ -47,6 +57,16 @@ public interface BmsProductCountryMapper
@Param("productCode") String productCode, @Param("productCode") String productCode,
@Param("effectiveDate") Date effectiveDate); @Param("effectiveDate") Date effectiveDate);
/**
* Delete open rows for one product + country on a given effective_date (same-day replace).
* Needed because unique key keeps expired same-day weight bands.
*/
public int deleteOpenByProductCodeAndCountryOnDate(
@Param("productCode") String productCode,
@Param("countryCode") String countryCode,
@Param("countryName") String countryName,
@Param("effectiveDate") Date effectiveDate);
/** Open country rows (expire_date is null) for product */ /** Open country rows (expire_date is null) for product */
public List<BmsProductCountry> selectOpenByProductCode(@Param("productCode") String productCode); public List<BmsProductCountry> selectOpenByProductCode(@Param("productCode") String productCode);
@@ -5,9 +5,10 @@ import java.util.List;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.bms.price.domain.BmsProduct; import com.bms.price.domain.BmsProduct;
import com.bms.price.domain.BmsProductCountry; import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.BmsProductImportTask;
/** /**
* 产品列表 服务层 * Product list service
* *
* @author bms * @author bms
*/ */
@@ -21,9 +22,6 @@ public interface IBmsProductService
public int updateBmsProduct(BmsProduct product); public int updateBmsProduct(BmsProduct product);
/**
* 修改产品国家明细行(重量段/运费/挂号费/尺寸/尾程/时效/生效失效日期),状态按日期重算
*/
public int updateBmsProductCountry(BmsProductCountry country); public int updateBmsProductCountry(BmsProductCountry country);
public int deleteBmsProductByIds(Long[] productIds); public int deleteBmsProductByIds(Long[] productIds);
@@ -34,34 +32,26 @@ public interface IBmsProductService
public List<BmsProductCountry> selectCountryListByProductCode(String productCode, Date effectiveDate, Long productId); public List<BmsProductCountry> selectCountryListByProductCode(String productCode, Date effectiveDate, Long productId);
/**
* 导入威狮B2C小包价表:产品目录 + 各产品 sheet 国家×重量段(含运费/挂号费/尺寸/尾程/时效)
*
* @param file 上传的 xlsx/xls
* @param updateSupport unused; same code+date never overwrites, different date always inserts
* @param operName 操作人
* @return 导入结果说明(可含 HTML 换行)
*/
public String importProductExcel(MultipartFile file, boolean updateSupport, String operName) throws Exception; public String importProductExcel(MultipartFile file, boolean updateSupport, String operName) throws Exception;
/**
* Import a single-country cost sheet (catalog + product weight rows).
* The destination country is chosen by the user, not read from the sheet.
*
* @param file uploaded xlsx/xls
* @param countryCode ISO country code (region.country_code_2)
* @param operName operator
* @return HTML result message
*/
public String importCountryProductExcel(MultipartFile file, String countryCode, String operName) throws Exception; public String importCountryProductExcel(MultipartFile file, String countryCode, String operName) throws Exception;
/** Submit async all-country import; returns taskId for polling getImportTask. */
public String submitImportProductExcelAsync(MultipartFile file, boolean updateSupport, String operName) throws Exception;
/** Submit async single-country import; returns taskId for polling getImportTask. */
public String submitImportCountryProductExcelAsync(MultipartFile file, String countryCode, String operName) throws Exception;
/** Poll Redis progress for product import task. */
public BmsProductImportTask getImportTask(String taskId);
/** Sync import from bytes (used by async runner; taskId may be null). */
public String importProductExcel(byte[] bytes, String filename, boolean updateSupport, String operName, String taskId) throws Exception;
/** Sync country import from bytes (used by async runner; taskId may be null). */
public String importCountryProductExcel(byte[] bytes, String filename, String countryCode, String operName, String taskId) throws Exception;
/**
* Parse Weishi B2C quote Excel into productCode -> country/zone/weight price rows (no persist).
* Used by price-set detail price import.
*/
public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(MultipartFile file) throws Exception; public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(MultipartFile file) throws Exception;
/** Parse quote Excel from in-memory bytes (async import). */
public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(byte[] bytes, String filename) throws Exception; public java.util.Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(byte[] bytes, String filename) throws Exception;
} }
@@ -33,14 +33,17 @@ import org.springframework.web.multipart.MultipartFile;
import com.bms.common.exception.ServiceException; import com.bms.common.exception.ServiceException;
import com.bms.common.utils.MessageUtils; import com.bms.common.utils.MessageUtils;
import com.bms.common.utils.StringUtils; import com.bms.common.utils.StringUtils;
import com.bms.common.utils.uuid.IdUtils;
import com.bms.price.domain.BmsProduct; import com.bms.price.domain.BmsProduct;
import com.bms.price.domain.BmsProductCountry; import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.BmsProductImportTask;
import com.bms.price.domain.PrcZone; import com.bms.price.domain.PrcZone;
import com.bms.price.mapper.BmsProductCountryMapper; import com.bms.price.mapper.BmsProductCountryMapper;
import com.bms.price.mapper.BmsProductMapper; import com.bms.price.mapper.BmsProductMapper;
import com.bms.price.mapper.PrcZoneMapper; import com.bms.price.mapper.PrcZoneMapper;
import com.bms.price.service.IBmsProductService; import com.bms.price.service.IBmsProductService;
import com.bms.price.service.IPrcSurchargeRelService; import com.bms.price.service.IPrcSurchargeRelService;
import com.bms.price.task.BmsProductImportAsyncRunner;
import com.bms.price.util.ZoneCodeAbbrevs; import com.bms.price.util.ZoneCodeAbbrevs;
import com.bms.system.domain.BmsRegion; import com.bms.system.domain.BmsRegion;
import com.bms.system.mapper.BmsRegionMapper; import com.bms.system.mapper.BmsRegionMapper;
@@ -88,6 +91,9 @@ public class BmsProductServiceImpl implements IBmsProductService
@Autowired @Autowired
private PlatformTransactionManager transactionManager; private PlatformTransactionManager transactionManager;
@Autowired
private BmsProductImportAsyncRunner importAsyncRunner;
@Override @Override
public BmsProduct selectBmsProductById(Long productId) public BmsProduct selectBmsProductById(Long productId)
{ {
@@ -240,18 +246,39 @@ public class BmsProductServiceImpl implements IBmsProductService
@Override @Override
public String importProductExcel(MultipartFile file, boolean updateSupport, String operName) throws Exception public String importProductExcel(MultipartFile file, boolean updateSupport, String operName) throws Exception
{ {
if (file == null || file.isEmpty()) validateImportFile(file);
{ return importProductExcel(file.getBytes(), file.getOriginalFilename(), updateSupport, operName, null);
throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty"));
}
String filename = file.getOriginalFilename();
if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx")
|| filename.toLowerCase(Locale.ROOT).endsWith(".xls")))
{
throw new ServiceException(MessageUtils.message("price.product.import.fileType"));
} }
try (InputStream is = file.getInputStream(); Workbook workbook = WorkbookFactory.create(is)) @Override
public String submitImportProductExcelAsync(MultipartFile file, boolean updateSupport, String operName) throws Exception
{
validateImportFile(file);
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
String taskId = IdUtils.fastSimpleUUID();
importAsyncRunner.put(taskId, BmsProductImportTask.STATUS_PENDING, 0, "queued", null);
importAsyncRunner.runImportAll(taskId, bytes, filename, updateSupport, operName);
return taskId;
}
@Override
public BmsProductImportTask getImportTask(String taskId)
{
if (StringUtils.isEmpty(taskId) || importAsyncRunner == null)
{
return null;
}
return importAsyncRunner.get(taskId);
}
@Override
public String importProductExcel(byte[] bytes, String filename, boolean updateSupport, String operName, String taskId) throws Exception
{
validateImportBytes(bytes, filename);
reportImportProgress(taskId, 10, "parsing excel");
try (InputStream is = new java.io.ByteArrayInputStream(bytes); Workbook workbook = WorkbookFactory.create(is))
{ {
Sheet catalog = workbook.getSheet(CATALOG_SHEET); Sheet catalog = workbook.getSheet(CATALOG_SHEET);
if (catalog == null) if (catalog == null)
@@ -265,6 +292,7 @@ public class BmsProductServiceImpl implements IBmsProductService
throw new ServiceException(MessageUtils.message("price.product.import.noData")); throw new ServiceException(MessageUtils.message("price.product.import.noData"));
} }
products = dedupeCatalogProducts(products); products = dedupeCatalogProducts(products);
reportImportProgress(taskId, 20, "parsing excel");
Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn(); Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn();
if (regionByNameCn.isEmpty()) if (regionByNameCn.isEmpty())
@@ -279,9 +307,13 @@ public class BmsProductServiceImpl implements IBmsProductService
int countryNum = 0; int countryNum = 0;
Set<String> unmatchedCountries = new HashSet<>(); Set<String> unmatchedCountries = new HashSet<>();
StringBuilder failureMsg = new StringBuilder(); StringBuilder failureMsg = new StringBuilder();
int total = products.size();
int index = 0;
for (BmsProduct product : products) for (BmsProduct product : products)
{ {
index++;
reportImportProductProgress(taskId, index, total, product.getProductCode());
DefaultTransactionDefinition def = new DefaultTransactionDefinition(); DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus tx = transactionManager.getTransaction(def); TransactionStatus tx = transactionManager.getTransaction(def);
@@ -306,6 +338,7 @@ public class BmsProductServiceImpl implements IBmsProductService
} }
} }
reportImportProgress(taskId, 95, "finishing");
if (failureMsg.length() > 0) if (failureMsg.length() > 0)
{ {
// Do not throw: each product already committed/rolled back independently // Do not throw: each product already committed/rolled back independently
@@ -326,16 +359,30 @@ public class BmsProductServiceImpl implements IBmsProductService
@Override @Override
public String importCountryProductExcel(MultipartFile file, String countryCode, String operName) throws Exception public String importCountryProductExcel(MultipartFile file, String countryCode, String operName) throws Exception
{ {
if (file == null || file.isEmpty()) validateImportFile(file);
{ return importCountryProductExcel(file.getBytes(), file.getOriginalFilename(), countryCode, operName, null);
throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty"));
} }
@Override
public String submitImportCountryProductExcelAsync(MultipartFile file, String countryCode, String operName) throws Exception
{
validateImportFile(file);
if (StringUtils.isEmpty(countryCode))
{
throw new ServiceException(MessageUtils.message("price.product.import.countryRequired"));
}
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx") String taskId = IdUtils.fastSimpleUUID();
|| filename.toLowerCase(Locale.ROOT).endsWith(".xls"))) importAsyncRunner.put(taskId, BmsProductImportTask.STATUS_PENDING, 0, "queued", null);
{ importAsyncRunner.runImportCountry(taskId, bytes, filename, countryCode, operName);
throw new ServiceException(MessageUtils.message("price.product.import.fileType")); return taskId;
} }
@Override
public String importCountryProductExcel(byte[] bytes, String filename, String countryCode, String operName, String taskId) throws Exception
{
validateImportBytes(bytes, filename);
if (StringUtils.isEmpty(countryCode)) if (StringUtils.isEmpty(countryCode))
{ {
throw new ServiceException(MessageUtils.message("price.product.import.countryRequired")); throw new ServiceException(MessageUtils.message("price.product.import.countryRequired"));
@@ -346,8 +393,9 @@ public class BmsProductServiceImpl implements IBmsProductService
{ {
throw new ServiceException(MessageUtils.message("price.product.import.countryNotFound", countryCode)); throw new ServiceException(MessageUtils.message("price.product.import.countryNotFound", countryCode));
} }
reportImportProgress(taskId, 10, "parsing excel");
try (InputStream is = file.getInputStream(); Workbook workbook = WorkbookFactory.create(is)) try (InputStream is = new java.io.ByteArrayInputStream(bytes); Workbook workbook = WorkbookFactory.create(is))
{ {
Sheet catalog = workbook.getSheet(CATALOG_SHEET); Sheet catalog = workbook.getSheet(CATALOG_SHEET);
if (catalog == null) if (catalog == null)
@@ -361,6 +409,7 @@ public class BmsProductServiceImpl implements IBmsProductService
} }
products = dedupeCatalogProducts(products); products = dedupeCatalogProducts(products);
Map<String, PriceBlock> priceBlocks = parseCountryPriceBlocks(workbook); Map<String, PriceBlock> priceBlocks = parseCountryPriceBlocks(workbook);
reportImportProgress(taskId, 20, "parsing excel");
int successNum = 0; int successNum = 0;
int skipNum = 0; int skipNum = 0;
@@ -368,9 +417,13 @@ public class BmsProductServiceImpl implements IBmsProductService
StringBuilder failureMsg = new StringBuilder(); StringBuilder failureMsg = new StringBuilder();
String countryLabel = StringUtils.isNotEmpty(region.getCountryNameCn()) String countryLabel = StringUtils.isNotEmpty(region.getCountryNameCn())
? region.getCountryNameCn() : region.getCountryCode2(); ? region.getCountryNameCn() : region.getCountryCode2();
int total = products.size();
int index = 0;
for (BmsProduct product : products) for (BmsProduct product : products)
{ {
index++;
reportImportProductProgress(taskId, index, total, product.getProductCode());
DefaultTransactionDefinition def = new DefaultTransactionDefinition(); DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus tx = transactionManager.getTransaction(def); TransactionStatus tx = transactionManager.getTransaction(def);
@@ -407,6 +460,7 @@ public class BmsProductServiceImpl implements IBmsProductService
} }
} }
reportImportProgress(taskId, 95, "finishing");
if (failureMsg.length() > 0) if (failureMsg.length() > 0)
{ {
return MessageUtils.message("price.product.import.partialFail", return MessageUtils.message("price.product.import.partialFail",
@@ -417,6 +471,58 @@ public class BmsProductServiceImpl implements IBmsProductService
} }
} }
private void validateImportFile(MultipartFile file)
{
if (file == null || file.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty"));
}
validateImportFilename(file.getOriginalFilename());
}
private void validateImportBytes(byte[] bytes, String filename)
{
if (bytes == null || bytes.length == 0)
{
throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty"));
}
validateImportFilename(filename);
}
private void validateImportFilename(String filename)
{
if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx")
|| filename.toLowerCase(Locale.ROOT).endsWith(".xls")))
{
throw new ServiceException(MessageUtils.message("price.product.import.fileType"));
}
}
private void reportImportProductProgress(String taskId, int index, int total, String productCode)
{
if (total <= 0)
{
reportImportProgress(taskId, 50, "importing");
return;
}
int percent = 20 + (int) Math.floor(70.0 * index / total);
if (percent > 90)
{
percent = 90;
}
String code = StringUtils.isEmpty(productCode) ? "-" : productCode;
reportImportProgress(taskId, percent, "importing " + code + " (" + index + "/" + total + ")");
}
private void reportImportProgress(String taskId, int percent, String message)
{
if (StringUtils.isEmpty(taskId) || importAsyncRunner == null)
{
return;
}
importAsyncRunner.put(taskId, BmsProductImportTask.STATUS_RUNNING, percent, message, null);
}
@Override @Override
public Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(MultipartFile file) throws Exception public Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(MultipartFile file) throws Exception
{ {
@@ -546,10 +652,12 @@ public class BmsProductServiceImpl implements IBmsProductService
/** /**
* Save country rows by effective_date: * Save country rows by effective_date.
* - newEff &gt; last open: expire previous open rows, copy them to newEff, then match import (hit=update, miss=insert) * For each product_code + country in the Excel:
* - newEff equals last open: match update/insert on open rows * - expire all earlier open weight bands under that country
* - newEff &lt; last open: reject * - delete same-day open weight bands (unique key blocks insert if only expired)
* - insert Excel weight bands only (no copy / merge of old bands)
* Reject when newEff &lt; last open effective_date.
*/ */
private int saveCountries(BmsProduct product, List<BmsProductCountry> countries) private int saveCountries(BmsProduct product, List<BmsProductCountry> countries)
{ {
@@ -576,69 +684,33 @@ public class BmsProductServiceImpl implements IBmsProductService
formatDate(newEff), formatDate(lastOpenEff))); formatDate(newEff), formatDate(lastOpenEff)));
} }
boolean newVersionDay = lastOpenEff == null || newEff.after(lastOpenEff); Set<String> replacedCountries = new HashSet<>();
if (newVersionDay && lastOpenEff != null) String rowStatus = resolveStoredStatus(newEff, null);
{
String newStatus = resolveStoredStatus(newEff, null);
// Copy open rows first (effective_date < newEff), then expire the previous open set.
countryMapper.copyOpenCountries(productCode, productId, newEff, newStatus);
countryMapper.expireOpenByProductCode(productCode, newEff);
}
List<BmsProductCountry> baseRows = countryMapper.selectOpenByProductCode(productCode);
if (baseRows == null)
{
baseRows = new ArrayList<>();
}
// keep only rows for this effective date when same-day / after copy
Map<String, BmsProductCountry> byKey = new HashMap<>();
for (BmsProductCountry row : baseRows)
{
Date rowEff = toDateOnly(row.getEffectiveDate());
if (rowEff != null && !rowEff.equals(newEff))
{
continue;
}
byKey.put(countryMatchKey(row), row);
}
int touched = 0; int touched = 0;
for (BmsProductCountry incoming : countries) for (BmsProductCountry incoming : countries)
{ {
if (incoming == null)
{
continue;
}
String countryKey = countryIdentityKey(incoming);
if (StringUtils.isNotEmpty(countryKey) && !replacedCountries.contains(countryKey))
{
countryMapper.expireOpenByProductCodeAndCountry(
productCode, incoming.getCountryCode(), incoming.getCountryName(), newEff);
countryMapper.deleteOpenByProductCodeAndCountryOnDate(
productCode, incoming.getCountryCode(), incoming.getCountryName(), newEff);
replacedCountries.add(countryKey);
}
incoming.setProductId(productId); incoming.setProductId(productId);
incoming.setProductCode(productCode); incoming.setProductCode(productCode);
incoming.setEffectiveDate(newEff); incoming.setEffectiveDate(newEff);
incoming.setExpireDate(null); incoming.setExpireDate(null);
incoming.setStatus(resolveStoredStatus(newEff, null)); incoming.setStatus(rowStatus);
String key = countryMatchKey(incoming);
BmsProductCountry existing = byKey.get(key);
try try
{
if (existing != null)
{
existing.setProductId(productId);
existing.setCountryCode(incoming.getCountryCode());
existing.setZoneId(incoming.getZoneId());
existing.setZoneCode(incoming.getZoneCode());
existing.setZoneName(incoming.getZoneName());
existing.setWeightMin(incoming.getWeightMin());
existing.setWeightMax(incoming.getWeightMax());
existing.setFreightPrice(incoming.getFreightPrice());
existing.setRegistrationFee(incoming.getRegistrationFee());
existing.setSizeLimit(incoming.getSizeLimit());
existing.setLastMile(incoming.getLastMile());
existing.setLeadTime(incoming.getLeadTime());
existing.setEffectiveDate(newEff);
existing.setExpireDate(null);
existing.setStatus(resolveStoredStatus(newEff, null));
countryMapper.updateBmsProductCountryImport(existing);
}
else
{ {
countryMapper.insertBmsProductCountry(incoming); countryMapper.insertBmsProductCountry(incoming);
byKey.put(key, incoming);
}
touched++; touched++;
} }
catch (Exception e) catch (Exception e)
@@ -660,23 +732,22 @@ public class BmsProductServiceImpl implements IBmsProductService
return touched; return touched;
} }
/** Match key: country + zone + weight band */ /** Identity for replace scope: prefer country_code, else country_name. */
private String countryMatchKey(BmsProductCountry row) private String countryIdentityKey(BmsProductCountry row)
{ {
String country = row.getCountryName() == null ? "" : row.getCountryName().trim(); if (row == null)
String zone = row.getZoneCode() == null ? "" : row.getZoneCode().trim().toUpperCase(Locale.ROOT);
String wMin = normalizeDecimal(row.getWeightMin());
String wMax = normalizeDecimal(row.getWeightMax());
return country + "|" + zone + "|" + wMin + "|" + wMax;
}
private String normalizeDecimal(BigDecimal v)
{
if (v == null)
{ {
return ""; return "";
} }
return v.stripTrailingZeros().toPlainString(); if (StringUtils.isNotEmpty(row.getCountryCode()))
{
return "C:" + row.getCountryCode().trim().toUpperCase(Locale.ROOT);
}
if (StringUtils.isNotEmpty(row.getCountryName()))
{
return "N:" + row.getCountryName().trim();
}
return "";
} }
private Date resolveImportEffectiveDate(List<BmsProductCountry> countries) private Date resolveImportEffectiveDate(List<BmsProductCountry> countries)
@@ -0,0 +1,92 @@
package com.bms.price.task;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import com.bms.common.core.redis.RedisCache;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.BmsProductImportTask;
import com.bms.price.service.IBmsProductService;
/**
* Async runner for product Excel import (avoids HTTP timeout on large workbooks).
*/
@Component
public class BmsProductImportAsyncRunner
{
private static final Logger log = LoggerFactory.getLogger(BmsProductImportAsyncRunner.class);
public static final String CACHE_KEY_PREFIX = "price:product:import:task:";
public static final int TTL_MINUTES = 60;
@Autowired
private IBmsProductService productService;
@Autowired
private RedisCache redisCache;
/** Background all-country (Weishi catalog) import. */
@Async("threadPoolTaskExecutor")
public void runImportAll(String taskId, byte[] bytes, String filename, boolean updateSupport, String operName)
{
try
{
put(taskId, BmsProductImportTask.STATUS_RUNNING, 5, "parsing excel", null);
String message = productService.importProductExcel(bytes, filename, updateSupport, operName, taskId);
put(taskId, BmsProductImportTask.STATUS_SUCCESS, 100, message, null);
log.info("async product import done, taskId={}", taskId);
}
catch (Exception e)
{
log.error("async product import failed, taskId={}", taskId, e);
put(taskId, BmsProductImportTask.STATUS_FAIL, 100, "failed", errorMessage(e));
}
}
/** Background single-country cost-sheet import. */
@Async("threadPoolTaskExecutor")
public void runImportCountry(String taskId, byte[] bytes, String filename, String countryCode, String operName)
{
try
{
put(taskId, BmsProductImportTask.STATUS_RUNNING, 5, "parsing excel", null);
String message = productService.importCountryProductExcel(bytes, filename, countryCode, operName, taskId);
put(taskId, BmsProductImportTask.STATUS_SUCCESS, 100, message, null);
log.info("async product country import done, taskId={}, country={}", taskId, countryCode);
}
catch (Exception e)
{
log.error("async product country import failed, taskId={}, country={}", taskId, countryCode, e);
put(taskId, BmsProductImportTask.STATUS_FAIL, 100, "failed", errorMessage(e));
}
}
public void put(String taskId, String status, int percent, String message, String error)
{
BmsProductImportTask task = new BmsProductImportTask();
task.setTaskId(taskId);
task.setStatus(status);
task.setPercent(percent);
task.setMessage(message);
task.setError(error);
redisCache.setCacheObject(CACHE_KEY_PREFIX + taskId, task, TTL_MINUTES, TimeUnit.MINUTES);
}
public BmsProductImportTask get(String taskId)
{
return redisCache.getCacheObject(CACHE_KEY_PREFIX + taskId);
}
private static String errorMessage(Exception e)
{
String msg = e.getMessage();
if (StringUtils.isEmpty(msg))
{
msg = e.getClass().getSimpleName();
}
return msg;
}
}
@@ -171,6 +171,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and (effective_date is null or effective_date &lt; #{newEffectiveDate}) and (effective_date is null or effective_date &lt; #{newEffectiveDate})
</update> </update>
<update id="expireOpenByProductCodeAndCountry">
update product_country
set expire_date = #{newEffectiveDate},
update_time = sysdate()
where product_code = #{productCode}
and expire_date is null
and (effective_date is null or effective_date &lt; #{newEffectiveDate})
<choose>
<when test="countryCode != null and countryCode != ''">
and upper(ifnull(country_code,'')) = upper(#{countryCode})
</when>
<otherwise>
and country_name = #{countryName}
</otherwise>
</choose>
</update>
<update id="refreshStatusByDates"> <update id="refreshStatusByDates">
update product_country update product_country
set status = case set status = case
@@ -190,6 +207,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
) )
</delete> </delete>
<delete id="deleteOpenByProductCodeAndCountryOnDate">
delete from product_country
where product_code = #{productCode}
and expire_date is null
and (
(#{effectiveDate} is null and effective_date is null)
or effective_date = #{effectiveDate}
)
<choose>
<when test="countryCode != null and countryCode != ''">
and upper(ifnull(country_code,'')) = upper(#{countryCode})
</when>
<otherwise>
and country_name = #{countryName}
</otherwise>
</choose>
</delete>
<select id="selectMatchByProductCountryWeight" resultMap="BmsProductCountryResult"> <select id="selectMatchByProductCountryWeight" resultMap="BmsProductCountryResult">
<include refid="selectVo"/> <include refid="selectVo"/>
where c.product_code = #{productCode} where c.product_code = #{productCode}
+89
View File
@@ -63,3 +63,92 @@ export function delProduct(productIds) {
method: 'delete' method: 'delete'
}) })
} }
export function getProductImportTask(taskId) {
return request({
url: '/price/product/import/task/' + taskId,
method: 'get'
})
}
function pickImportTaskId(res) {
if (!res) return null
const d = res.data
if (d && typeof d === 'object' && d.taskId) return String(d.taskId)
if (typeof d === 'string' && d.trim()) return d.trim()
if (typeof res.msg === 'string') {
const m = res.msg.trim()
if (/^[a-f0-9]{32}$/i.test(m)) return m
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(m)) {
return m.replace(/-/g, '')
}
}
return null
}
async function pollProductImportTask(taskId, onProgress) {
const started = Date.now()
const maxMs = 30 * 60 * 1000
while (Date.now() - started < maxMs) {
const res = await getProductImportTask(taskId)
const task = res.data || {}
if (typeof onProgress === 'function') {
onProgress(task)
}
if (task.status === 'SUCCESS') {
return { task }
}
if (task.status === 'FAIL') {
const err = new Error(task.error || task.message || 'import failed')
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 800))
}
throw new Error('import timeout')
}
/**
* Async all-country product import; poll until done.
* @param {File|Blob} file
* @param {(task: object) => void} [onProgress]
*/
export async function importProductExcelAsync(file, onProgress) {
const formData = new FormData()
formData.append('file', file)
const submitRes = await request({
url: '/price/product/importData?updateSupport=0',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
const taskId = pickImportTaskId(submitRes)
if (!taskId) {
throw new Error('missing import taskId')
}
return pollProductImportTask(taskId, onProgress)
}
/**
* Async single-country product import; poll until done.
* @param {File|Blob} file
* @param {string} countryCode
* @param {(task: object) => void} [onProgress]
*/
export async function importProductCountryExcelAsync(file, countryCode, onProgress) {
const formData = new FormData()
formData.append('file', file)
const params = new URLSearchParams()
params.set('countryCode', countryCode)
const submitRes = await request({
url: '/price/product/importCountryData?' + params.toString(),
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
const taskId = pickImportTaskId(submitRes)
if (!taskId) {
throw new Error('missing import taskId')
}
return pollProductImportTask(taskId, onProgress)
}
@@ -60,25 +60,24 @@
<excel-import-dialog <excel-import-dialog
ref="importRef" ref="importRef"
title="产品 Excel 导入" title="产品 Excel 导入"
action="/price/product/importData"
width="560px" width="560px"
:show-update-support="false" :show-update-support="false"
template-url="/templates/product/product-import-all-template.xlsx" template-url="/templates/product/product-import-all-template.xlsx"
template-file-name="产品多国家价表导入模板" template-file-name="产品多国家价表导入模板"
:import-desc="allImportDesc" :import-desc="allImportDesc"
:submit-handler="runImportAllAsync"
@success="() => tableRef.refresh()" @success="() => tableRef.refresh()"
/> />
<excel-import-dialog <excel-import-dialog
ref="countryImportRef" ref="countryImportRef"
title="按国家导入" title="按国家导入"
action="/price/product/importCountryData"
width="560px" width="560px"
:show-update-support="false" :show-update-support="false"
:extra-query="countryImportQuery"
:before-submit="validateCountryImport" :before-submit="validateCountryImport"
:import-desc="countryImportDesc" :import-desc="countryImportDesc"
template-url="/templates/product/product-import-country-template.xlsx" template-url="/templates/product/product-import-country-template.xlsx"
template-file-name="产品按国家导入模板" template-file-name="产品按国家导入模板"
:submit-handler="runImportCountryAsync"
@success="() => tableRef.refresh()" @success="() => tableRef.refresh()"
> >
<template #extra> <template #extra>
@@ -96,6 +95,21 @@
</el-form> </el-form>
</template> </template>
</excel-import-dialog> </excel-import-dialog>
<el-dialog
v-model="importProgressVisible"
title="正在导入产品"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
append-to-body
>
<div class="import-progress">
<el-progress :percentage="importProgressPercent" :status="importProgressStatus" />
<div class="import-progress-msg">{{ importProgressMessage }}</div>
</div>
</el-dialog>
</div> </div>
</template> </template>
@@ -105,7 +119,7 @@ import ProductFormDialog from './components/ProductFormDialog.vue'
import ProductCountryDrawer from './components/ProductCountryDrawer.vue' import ProductCountryDrawer from './components/ProductCountryDrawer.vue'
import ProductSurchargeDrawer from './components/ProductSurchargeDrawer.vue' import ProductSurchargeDrawer from './components/ProductSurchargeDrawer.vue'
import ProductExpiredDrawer from './components/ProductExpiredDrawer.vue' import ProductExpiredDrawer from './components/ProductExpiredDrawer.vue'
import { listProduct } from '@/api/price/product' import { listProduct, importProductExcelAsync, importProductCountryExcelAsync } from '@/api/price/product'
import { listRegionOptions } from '@/api/data/region' import { listRegionOptions } from '@/api/data/region'
const { bms_product_effect_status } = useDict('bms_product_effect_status') const { bms_product_effect_status } = useDict('bms_product_effect_status')
@@ -120,15 +134,18 @@ const importRef = ref()
const countryImportRef = ref() const countryImportRef = ref()
const importCountryCode = ref('') const importCountryCode = ref('')
const regionOptions = ref([]) const regionOptions = ref([])
const countryImportQuery = computed(() => ({ countryCode: importCountryCode.value })) const importProgressVisible = ref(false)
const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const allImportDesc = [ const allImportDesc = [
'适用多国家价表(B2C 小包价表等),按目录 sheet 匹配国家导入。', '适用多国家价表(B2C 小包价表等),按目录 sheet 匹配国家导入。',
'生效日期必填。新版导入时上一版失效日=本次生效日,复制上一版后按国家/分区/重量段匹配:命中更新,未命中插入。', '生效日期必填。产品+国家有新生效日时,先失效该国家下全部旧重量段,再插入 Excel 重量段。',
'请在弹窗底部下载模板,按模板格式整理后上传。相同产品代码+生效日期不覆盖主数据。' '请在弹窗底部下载模板,按模板格式整理后上传。相同产品代码+生效日期不覆盖主数据。'
] ]
const countryImportDesc = [ const countryImportDesc = [
'选择目的国家后上传专线成本价表,目录中的产品都会记到该国家。', '选择目的国家后上传专线成本价表,目录中的产品都会记到该国家。',
'生效日期必填。新版会复制上一版全部国家明细再与导入行匹配更新/插入;其他国家行会转结到新版。', '生效日期必填。产品+国家有新生效日时,先失效该国家下全部旧重量段,再插入 Excel 重量段。',
'邮编、赔偿标准等 sheet 不导入。可在弹窗底部下载模板。' '邮编、赔偿标准等 sheet 不导入。可在弹窗底部下载模板。'
] ]
@@ -138,7 +155,6 @@ const columns = [
{ prop: 'serviceLevel', label: '时效等级', width: 90, align: 'center' }, { prop: 'serviceLevel', label: '时效等级', width: 90, align: 'center' },
{ prop: 'cargoAttr', label: '走货属性', minWidth: 120, slot: 'cargoAttr' }, { prop: 'cargoAttr', label: '走货属性', minWidth: 120, slot: 'cargoAttr' },
{ prop: 'destRegion', label: '目的区域', width: 90, align: 'center' }, { prop: 'destRegion', label: '目的区域', width: 90, align: 'center' },
// { prop: 'taxMode', label: '税模式', width: 90, align: 'center', dict: 'bms_tax_mode' },
{ prop: 'effectiveDate', label: '生效日期', width: 120, align: 'center' }, { prop: 'effectiveDate', label: '生效日期', width: 120, align: 'center' },
{ prop: 'expireDate', label: '失效日期', width: 120, align: 'center' }, { prop: 'expireDate', label: '失效日期', width: 120, align: 'center' },
{ prop: 'countryCount', label: '国家数', width: 80, align: 'center' }, { prop: 'countryCount', label: '国家数', width: 80, align: 'center' },
@@ -202,8 +218,80 @@ function validateCountryImport() {
} }
return '' return ''
} }
function applyImportProgress(t) {
importProgressPercent.value = Math.min(100, Number(t.percent) || 0)
if (t.status === 'PENDING') importProgressMessage.value = '排队中…'
else if (t.status === 'RUNNING') {
const m = t.message || ''
if (m === 'parsing excel') importProgressMessage.value = '正在解析价表…'
else if (m === 'finishing') importProgressMessage.value = '收尾中…'
else if (m === 'queued') importProgressMessage.value = '排队中…'
else if (m.indexOf('importing ') === 0) {
const rest = m.slice('importing '.length)
importProgressMessage.value = '正在导入产品 ' + rest
} else importProgressMessage.value = m || '正在导入…'
} else if (t.status === 'SUCCESS') {
importProgressMessage.value = t.message || '导入完成'
importProgressStatus.value = 'success'
} else if (t.status === 'FAIL') {
importProgressMessage.value = t.error || t.message || '导入失败'
importProgressStatus.value = 'exception'
}
}
async function runImportWithProgress(runner) {
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = '上传并解析中…'
importProgressStatus.value = undefined
try {
const { task } = await runner(applyImportProgress)
importProgressPercent.value = 100
importProgressStatus.value = 'success'
return (task && task.message) ? task.message : '导入完成'
} catch (e) {
importProgressStatus.value = 'exception'
const tip = (e && e.task && e.task.error) || (e && e.message) || '导入失败'
importProgressMessage.value = tip
await new Promise(resolve => setTimeout(resolve, 600))
throw e
} finally {
importProgressVisible.value = false
}
}
async function runImportAllAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
return runImportWithProgress((onProgress) => importProductExcelAsync(raw, onProgress))
}
async function runImportCountryAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
if (!importCountryCode.value) {
throw new Error('请先选择目的国家')
}
const code = importCountryCode.value
return runImportWithProgress((onProgress) => importProductCountryExcelAsync(raw, code, onProgress))
}
</script> </script>
<style scoped> <style scoped>
.mb16 { margin-bottom: 16px; } .mb16 { margin-bottom: 16px; }
.import-progress { padding: 8px 4px 4px; }
.import-progress-msg {
margin-top: 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
word-break: break-word;
max-height: 160px;
overflow: auto;
}
</style> </style>