修改产品导入

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
@@ -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("newEffectiveDate") Date newEffectiveDate);
/**
* Close open rows for one product + country where effective_date < 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 */
public int refreshStatusByDates();
@@ -47,6 +57,16 @@ public interface BmsProductCountryMapper
@Param("productCode") String productCode,
@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 */
public List<BmsProductCountry> selectOpenByProductCode(@Param("productCode") String productCode);
@@ -5,9 +5,10 @@ import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import com.bms.price.domain.BmsProduct;
import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.BmsProductImportTask;
/**
* 产品列表 服务层
* Product list service
*
* @author bms
*/
@@ -21,9 +22,6 @@ public interface IBmsProductService
public int updateBmsProduct(BmsProduct product);
/**
* 修改产品国家明细行(重量段/运费/挂号费/尺寸/尾程/时效/生效失效日期),状态按日期重算
*/
public int updateBmsProductCountry(BmsProductCountry country);
public int deleteBmsProductByIds(Long[] productIds);
@@ -34,34 +32,26 @@ public interface IBmsProductService
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;
/**
* 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;
/** 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;
/** Parse quote Excel from in-memory bytes (async import). */
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.utils.MessageUtils;
import com.bms.common.utils.StringUtils;
import com.bms.common.utils.uuid.IdUtils;
import com.bms.price.domain.BmsProduct;
import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.BmsProductImportTask;
import com.bms.price.domain.PrcZone;
import com.bms.price.mapper.BmsProductCountryMapper;
import com.bms.price.mapper.BmsProductMapper;
import com.bms.price.mapper.PrcZoneMapper;
import com.bms.price.service.IBmsProductService;
import com.bms.price.service.IPrcSurchargeRelService;
import com.bms.price.task.BmsProductImportAsyncRunner;
import com.bms.price.util.ZoneCodeAbbrevs;
import com.bms.system.domain.BmsRegion;
import com.bms.system.mapper.BmsRegionMapper;
@@ -88,6 +91,9 @@ public class BmsProductServiceImpl implements IBmsProductService
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private BmsProductImportAsyncRunner importAsyncRunner;
@Override
public BmsProduct selectBmsProductById(Long productId)
{
@@ -240,18 +246,39 @@ public class BmsProductServiceImpl implements IBmsProductService
@Override
public String importProductExcel(MultipartFile file, boolean updateSupport, String operName) throws Exception
{
if (file == null || file.isEmpty())
{
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"));
}
validateImportFile(file);
return importProductExcel(file.getBytes(), file.getOriginalFilename(), updateSupport, operName, null);
}
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);
if (catalog == null)
@@ -265,6 +292,7 @@ public class BmsProductServiceImpl implements IBmsProductService
throw new ServiceException(MessageUtils.message("price.product.import.noData"));
}
products = dedupeCatalogProducts(products);
reportImportProgress(taskId, 20, "parsing excel");
Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn();
if (regionByNameCn.isEmpty())
@@ -279,9 +307,13 @@ public class BmsProductServiceImpl implements IBmsProductService
int countryNum = 0;
Set<String> unmatchedCountries = new HashSet<>();
StringBuilder failureMsg = new StringBuilder();
int total = products.size();
int index = 0;
for (BmsProduct product : products)
{
index++;
reportImportProductProgress(taskId, index, total, product.getProductCode());
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus tx = transactionManager.getTransaction(def);
@@ -306,6 +338,7 @@ public class BmsProductServiceImpl implements IBmsProductService
}
}
reportImportProgress(taskId, 95, "finishing");
if (failureMsg.length() > 0)
{
// Do not throw: each product already committed/rolled back independently
@@ -326,16 +359,30 @@ public class BmsProductServiceImpl implements IBmsProductService
@Override
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);
}
@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.fileEmpty"));
throw new ServiceException(MessageUtils.message("price.product.import.countryRequired"));
}
byte[] bytes = file.getBytes();
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"));
}
String taskId = IdUtils.fastSimpleUUID();
importAsyncRunner.put(taskId, BmsProductImportTask.STATUS_PENDING, 0, "queued", null);
importAsyncRunner.runImportCountry(taskId, bytes, filename, countryCode, operName);
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))
{
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));
}
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);
if (catalog == null)
@@ -361,6 +409,7 @@ public class BmsProductServiceImpl implements IBmsProductService
}
products = dedupeCatalogProducts(products);
Map<String, PriceBlock> priceBlocks = parseCountryPriceBlocks(workbook);
reportImportProgress(taskId, 20, "parsing excel");
int successNum = 0;
int skipNum = 0;
@@ -368,9 +417,13 @@ public class BmsProductServiceImpl implements IBmsProductService
StringBuilder failureMsg = new StringBuilder();
String countryLabel = StringUtils.isNotEmpty(region.getCountryNameCn())
? region.getCountryNameCn() : region.getCountryCode2();
int total = products.size();
int index = 0;
for (BmsProduct product : products)
{
index++;
reportImportProductProgress(taskId, index, total, product.getProductCode());
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus tx = transactionManager.getTransaction(def);
@@ -407,6 +460,7 @@ public class BmsProductServiceImpl implements IBmsProductService
}
}
reportImportProgress(taskId, 95, "finishing");
if (failureMsg.length() > 0)
{
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
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:
* - newEff &gt; last open: expire previous open rows, copy them to newEff, then match import (hit=update, miss=insert)
* - newEff equals last open: match update/insert on open rows
* - newEff &lt; last open: reject
* Save country rows by effective_date.
* For each product_code + country in the Excel:
* - expire all earlier open weight bands under that country
* - 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)
{
@@ -576,69 +684,33 @@ public class BmsProductServiceImpl implements IBmsProductService
formatDate(newEff), formatDate(lastOpenEff)));
}
boolean newVersionDay = lastOpenEff == null || newEff.after(lastOpenEff);
if (newVersionDay && lastOpenEff != 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);
}
Set<String> replacedCountries = new HashSet<>();
String rowStatus = resolveStoredStatus(newEff, null);
int touched = 0;
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.setProductCode(productCode);
incoming.setEffectiveDate(newEff);
incoming.setExpireDate(null);
incoming.setStatus(resolveStoredStatus(newEff, null));
String key = countryMatchKey(incoming);
BmsProductCountry existing = byKey.get(key);
incoming.setStatus(rowStatus);
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);
byKey.put(key, incoming);
}
countryMapper.insertBmsProductCountry(incoming);
touched++;
}
catch (Exception e)
@@ -660,23 +732,22 @@ public class BmsProductServiceImpl implements IBmsProductService
return touched;
}
/** Match key: country + zone + weight band */
private String countryMatchKey(BmsProductCountry row)
/** Identity for replace scope: prefer country_code, else country_name. */
private String countryIdentityKey(BmsProductCountry row)
{
String country = row.getCountryName() == null ? "" : row.getCountryName().trim();
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)
if (row == null)
{
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)
@@ -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;
}
}