导入批量修改价格明细

This commit is contained in:
wuweihua
2026-09-28 18:01:26 +08:00
parent c278ed76f7
commit b247bc2fd2
19 changed files with 777 additions and 54 deletions
@@ -37,6 +37,10 @@ public class PrcPriceSetDetail implements Serializable
private Long productCountryId;
private BigDecimal fvValue;
private BigDecimal fvRegistrationFee;
/** product base freight from bms_product_country (display only) */
private BigDecimal baseFreightPrice;
/** product base registration from bms_product_country (display only) */
private BigDecimal baseRegistrationFee;
private BigDecimal discountValue;
/** UP / DOWN / HALF_UP */
private String roundMode;
@@ -1,5 +1,6 @@
package com.bms.price.mapper;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
@@ -75,4 +76,10 @@ public interface PrcPriceSetDetailMapper
@Param("priceSetId") Long priceSetId,
@Param("psvId") Long psvId,
@Param("productCode") String productCode);
/** Update package freight / registration only */
public int updateDetailPrices(
@Param("psdId") Long psdId,
@Param("fvValue") BigDecimal fvValue,
@Param("fvRegistrationFee") BigDecimal fvRegistrationFee);
}
@@ -58,4 +58,13 @@ public interface IBmsProductService
/** Distinct base-price version options (by effective_date desc) for package create */
public List<BmsProductPriceVersion> selectPriceVersionOptions();
/**
* 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;
}
@@ -1,6 +1,7 @@
package com.bms.price.service;
import java.util.Date;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
import com.bms.price.domain.PrcCustomerPriceSet;
@@ -96,4 +97,19 @@ public interface IPrcPriceSetService
/** poll async save progress */
public PrcPriceSetSaveTask getSaveTask(String taskId);
/**
* Submit async import of Weishi B2C quote Excel into FIXED package detail prices.
* @return taskId for polling getSaveTask
*/
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName) throws Exception;
/**
* Sync worker: parse bytes and update FIXED detail prices matched by
* product + country + zone(if any) + weight band.
*/
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName) throws Exception;
/** Sync worker with optional taskId for progress reporting. */
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName, String taskId) throws Exception;
}
@@ -8,6 +8,7 @@ import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -421,6 +422,73 @@ public class BmsProductServiceImpl implements IBmsProductService
}
}
@Override
public Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(MultipartFile file) throws Exception
{
if (file == null || file.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty"));
}
return parseQuoteExcelByProduct(file.getBytes(), file.getOriginalFilename());
}
@Override
public Map<String, List<BmsProductCountry>> parseQuoteExcelByProduct(byte[] bytes, String filename) throws Exception
{
if (bytes == null || bytes.length == 0)
{
throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty"));
}
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 = new java.io.ByteArrayInputStream(bytes); Workbook workbook = WorkbookFactory.create(is))
{
Sheet catalog = workbook.getSheet(CATALOG_SHEET);
if (catalog == null)
{
throw new ServiceException(MessageUtils.message("price.product.import.sheetNotFound"));
}
List<BmsProduct> products = dedupeCatalogProducts(parseCatalog(catalog));
if (products.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.product.import.noData"));
}
Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn();
List<BmsRegion> euRegions = loadEuRegions(regionByNameCn);
Map<String, PrcZone> zoneByCountryAndCode = loadZoneByCountryAndCode();
Set<String> unmatchedCountries = new HashSet<>();
Map<String, List<BmsProductCountry>> out = new LinkedHashMap<>();
for (BmsProduct product : products)
{
if (product == null || StringUtils.isEmpty(product.getProductCode()))
{
continue;
}
Sheet productSheet = findProductSheet(workbook, product);
if (productSheet == null)
{
continue;
}
List<BmsProductCountry> rows = parseProductSheet(productSheet, product,
regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries);
if (rows == null || rows.isEmpty())
{
continue;
}
String code = product.getProductCode().trim().toUpperCase(Locale.ROOT);
out.put(code, rows);
}
if (out.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
}
return out;
}
}
@Override
public List<BmsProductPriceVersion> selectPriceVersionOptions()
{
@@ -9,12 +9,14 @@ import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.Set;
import java.util.zip.CRC32;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.DateUtils;
import com.bms.common.utils.MessageUtils;
@@ -38,6 +40,7 @@ import com.bms.price.mapper.PrcPriceSetVersionMapper;
import com.bms.price.mapper.PrcPriceSetVersionSurchargeMapper;
import com.bms.price.mapper.PrcSurchargeMapper;
import com.bms.price.mapper.PrcSurchargeRelMapper;
import com.bms.price.service.IBmsProductService;
import com.bms.price.service.IPrcPriceSetService;
import com.bms.price.task.PrcPriceSetSaveAsyncRunner;
@@ -87,6 +90,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
@Autowired
private BmsProductCountryMapper productCountryMapper;
@Lazy
@Autowired
private IBmsProductService productService;
@Lazy
@Autowired
private PrcPriceSetSaveAsyncRunner saveAsyncRunner;
@@ -797,6 +804,270 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return rows;
}
@Override
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName) throws Exception
{
PrcPriceSet db = requireSet(priceSetId);
assertChildEditable(db);
if (!TYPE_FIXED.equals(db.getSetType()))
{
throw new ServiceException(MessageUtils.message("price.set.import.fixedOnly"));
}
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"));
}
byte[] bytes = file.getBytes();
if (psvId == null)
{
psvId = resolveCurrentPsvId(db);
}
String taskId = IdUtils.fastSimpleUUID();
saveAsyncRunner.put(taskId, PrcPriceSetSaveTask.STATUS_PENDING, 0, "queued", priceSetId, null);
saveAsyncRunner.runImportPrices(taskId, priceSetId, psvId, bytes, filename, operName);
return taskId;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName) throws Exception
{
return importDetailPrices(priceSetId, psvId, bytes, filename, operName, null);
}
@Override
@Transactional(rollbackFor = Exception.class)
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName, String taskId) throws Exception
{
PrcPriceSet db = requireSet(priceSetId);
assertChildEditable(db);
if (!TYPE_FIXED.equals(db.getSetType()))
{
throw new ServiceException(MessageUtils.message("price.set.import.fixedOnly"));
}
if (psvId == null)
{
psvId = resolveCurrentPsvId(db);
}
reportImportProgress(taskId, priceSetId, 20, "parsing excel");
Map<String, List<BmsProductCountry>> byProduct = productService.parseQuoteExcelByProduct(bytes, filename);
reportImportProgress(taskId, priceSetId, 50, "matching details");
PrcPriceSetDetail q = new PrcPriceSetDetail();
q.setPriceSetId(priceSetId);
if (psvId != null)
{
q.setPsvId(psvId);
}
List<PrcPriceSetDetail> details = detailMapper.selectPrcPriceSetDetailList(q);
if (details == null || details.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.import.noDetails"));
}
Map<String, List<PrcPriceSetDetail>> detailIndex = new HashMap<String, List<PrcPriceSetDetail>>();
for (PrcPriceSetDetail d : details)
{
if (d == null || StringUtils.isEmpty(d.getProductCode()))
{
continue;
}
String pc = d.getProductCode().trim().toUpperCase(Locale.ROOT);
List<PrcPriceSetDetail> list = detailIndex.get(pc);
if (list == null)
{
list = new ArrayList<PrcPriceSetDetail>();
detailIndex.put(pc, list);
}
list.add(d);
}
int updated = 0;
int unmatched = 0;
int skippedProduct = 0;
Set<Long> touched = new HashSet<Long>();
for (Map.Entry<String, List<BmsProductCountry>> e : byProduct.entrySet())
{
String productCode = e.getKey();
List<PrcPriceSetDetail> candidates = detailIndex.get(productCode);
if (candidates == null || candidates.isEmpty())
{
skippedProduct++;
continue;
}
for (BmsProductCountry row : e.getValue())
{
if (row == null)
{
continue;
}
List<PrcPriceSetDetail> hits = matchImportDetails(candidates, row);
if (hits.isEmpty())
{
unmatched++;
continue;
}
for (PrcPriceSetDetail hit : hits)
{
if (hit.getPsdId() == null || touched.contains(hit.getPsdId()))
{
continue;
}
detailMapper.updateDetailPrices(hit.getPsdId(), row.getFreightPrice(), row.getRegistrationFee());
hit.setFvValue(row.getFreightPrice());
hit.setFvRegistrationFee(row.getRegistrationFee());
touched.add(hit.getPsdId());
updated++;
}
}
}
reportImportProgress(taskId, priceSetId, 95, "finishing");
return MessageUtils.message("price.set.import.success",
Integer.valueOf(updated), Integer.valueOf(unmatched), Integer.valueOf(skippedProduct));
}
private void reportImportProgress(String taskId, Long priceSetId, int percent, String message)
{
if (StringUtils.isEmpty(taskId) || saveAsyncRunner == null)
{
return;
}
saveAsyncRunner.put(taskId, PrcPriceSetSaveTask.STATUS_RUNNING, percent, message, priceSetId, null);
}
/** Resolve editable version id: draft > pending > effect > first version. */
private Long resolveCurrentPsvId(PrcPriceSet set)
{
if (set == null)
{
return null;
}
if (set.getVersion() != null && set.getVersion().getPsvId() != null)
{
return set.getVersion().getPsvId();
}
List<PrcPriceSetVersion> versions = set.getVersions();
if (versions == null || versions.isEmpty())
{
PrcPriceSetVersion q = new PrcPriceSetVersion();
q.setPriceSetId(set.getPriceSetId());
versions = versionMapper.selectPrcPriceSetVersionList(q);
}
if (versions == null || versions.isEmpty())
{
return null;
}
PrcPriceSetVersion draft = null;
PrcPriceSetVersion pending = null;
PrcPriceSetVersion effect = null;
for (PrcPriceSetVersion v : versions)
{
if (v == null)
{
continue;
}
if (VER_DRAFT.equals(v.getStatus()) && draft == null)
{
draft = v;
}
else if (VER_PENDING.equals(v.getStatus()) && pending == null)
{
pending = v;
}
else if (VER_EFFECT.equals(v.getStatus()) && effect == null)
{
effect = v;
}
}
PrcPriceSetVersion pick = draft != null ? draft : (pending != null ? pending : (effect != null ? effect : versions.get(0)));
return pick == null ? null : pick.getPsvId();
}
private List<PrcPriceSetDetail> matchImportDetails(List<PrcPriceSetDetail> candidates, BmsProductCountry row)
{
List<PrcPriceSetDetail> hits = new ArrayList<PrcPriceSetDetail>();
if (candidates == null || row == null)
{
return hits;
}
String importZone = normZone(row.getZoneCode());
for (PrcPriceSetDetail d : candidates)
{
if (!sameCountry(d, row))
{
continue;
}
if (!sameWeight(d.getWeightMin(), d.getWeightMax(), row.getWeightMin(), row.getWeightMax()))
{
continue;
}
String detailZone = normZone(d.getZoneCode());
if (StringUtils.isNotEmpty(importZone))
{
if (!importZone.equals(detailZone))
{
continue;
}
}
else if (StringUtils.isNotEmpty(detailZone))
{
// Excel has no zone: only match non-zone details
continue;
}
hits.add(d);
}
return hits;
}
private boolean sameCountry(PrcPriceSetDetail d, BmsProductCountry row)
{
if (d == null || row == null)
{
return false;
}
if (StringUtils.isNotEmpty(d.getCountryCode()) && StringUtils.isNotEmpty(row.getCountryCode()))
{
return d.getCountryCode().trim().equalsIgnoreCase(row.getCountryCode().trim());
}
if (StringUtils.isNotEmpty(d.getCountryName()) && StringUtils.isNotEmpty(row.getCountryName()))
{
return d.getCountryName().trim().equals(row.getCountryName().trim());
}
return false;
}
private boolean sameWeight(BigDecimal aMin, BigDecimal aMax, BigDecimal bMin, BigDecimal bMax)
{
return decimalEq(aMin, bMin) && decimalEq(aMax, bMax);
}
private boolean decimalEq(BigDecimal a, BigDecimal b)
{
if (a == null && b == null)
{
return true;
}
if (a == null || b == null)
{
return false;
}
return a.compareTo(b) == 0;
}
private String normZone(String zone)
{
if (StringUtils.isEmpty(zone))
{
return "";
}
return zone.trim().toUpperCase(Locale.ROOT);
}
@Override
public int updateDetail(PrcPriceSetDetail detail)
{
@@ -76,6 +76,30 @@ public class PrcPriceSetSaveAsyncRunner
}
}
/** Background import of quote Excel into package detail prices. */
@Async("threadPoolTaskExecutor")
public void runImportPrices(String taskId, Long priceSetId, Long psvId, byte[] bytes, String filename, String operName)
{
try
{
put(taskId, PrcPriceSetSaveTask.STATUS_RUNNING, 10, "parsing excel", priceSetId, null);
String message = priceSetService.importDetailPrices(priceSetId, psvId, bytes, filename, operName, taskId);
put(taskId, PrcPriceSetSaveTask.STATUS_SUCCESS, 100, message, priceSetId, null);
log.info("async import prices done, taskId={}, priceSetId={}", taskId, priceSetId);
}
catch (Exception e)
{
log.error("async import prices failed, taskId={}, priceSetId={}", taskId, priceSetId, e);
String msg = e.getMessage();
if (StringUtils.isEmpty(msg))
{
msg = e.getClass().getSimpleName();
}
put(taskId, PrcPriceSetSaveTask.STATUS_FAIL, 100, "failed", priceSetId, msg);
}
}
public void put(String taskId, String status, int percent, String message, Long priceSetId, String error)
{
PrcPriceSetSaveTask task = new PrcPriceSetSaveTask();
@@ -23,6 +23,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="productCountryId" column="product_country_id" />
<result property="fvValue" column="fv_value" />
<result property="fvRegistrationFee" column="fv_registration_fee" />
<result property="baseFreightPrice" column="base_freight_price" />
<result property="baseRegistrationFee" column="base_registration_fee" />
<result property="discountValue" column="discount_value" />
<result property="roundMode" column="round_mode" />
<result property="prId" column="pr_id" />
@@ -46,54 +48,62 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select>
<select id="selectPrcPriceSetDetailList" parameterType="PrcPriceSetDetail" resultMap="PrcPriceSetDetailResult">
<include refid="selectPrcPriceSetDetailVo"/>
select d.psd_id, d.price_set_id, d.psv_id, d.product_code, d.product_name, d.country_code, d.country_name,
d.zone_id, d.zone_code, d.zone_name,
d.zd_id, d.wsd_id, d.weight_label, d.weight_min, d.weight_max, d.product_country_id,
d.fv_value, d.fv_registration_fee,
c.freight_price as base_freight_price,
c.registration_fee as base_registration_fee,
d.discount_value, d.round_mode, d.pr_id, d.effective_date, d.expire_date, d.create_time, d.update_time
from prc_price_set_detail d
left join bms_product_country c on c.detail_id = d.product_country_id
<where>
<if test="priceSetId != null">
AND price_set_id = #{priceSetId}
AND d.price_set_id = #{priceSetId}
</if>
<if test="psvId != null">
AND psv_id = #{psvId}
AND d.psv_id = #{psvId}
</if>
<if test="productCode != null and productCode != ''">
AND product_code = #{productCode}
AND d.product_code = #{productCode}
</if>
<if test="countryCode != null and countryCode != ''">
AND country_code = #{countryCode}
AND d.country_code = #{countryCode}
</if>
<if test="zoneCode != null and zoneCode != ''">
AND zone_code = #{zoneCode}
AND d.zone_code = #{zoneCode}
</if>
<if test="productKeyword != null and productKeyword != ''">
AND (
product_code like concat('%', #{productKeyword}, '%')
or product_name like concat('%', #{productKeyword}, '%')
d.product_code like concat('%', #{productKeyword}, '%')
or d.product_name like concat('%', #{productKeyword}, '%')
)
</if>
<if test="countryKeyword != null and countryKeyword != ''">
AND (
country_code like concat('%', #{countryKeyword}, '%')
or country_name like concat('%', #{countryKeyword}, '%')
d.country_code like concat('%', #{countryKeyword}, '%')
or d.country_name like concat('%', #{countryKeyword}, '%')
)
</if>
<if test="zoneKeyword != null and zoneKeyword != ''">
AND (
zone_code like concat('%', #{zoneKeyword}, '%')
or zone_name like concat('%', #{zoneKeyword}, '%')
d.zone_code like concat('%', #{zoneKeyword}, '%')
or d.zone_name like concat('%', #{zoneKeyword}, '%')
)
</if>
<if test="keyword != null and keyword != ''">
AND (
product_code like concat('%', #{keyword}, '%')
or product_name like concat('%', #{keyword}, '%')
or country_code like concat('%', #{keyword}, '%')
or country_name like concat('%', #{keyword}, '%')
or zone_code like concat('%', #{keyword}, '%')
or zone_name like concat('%', #{keyword}, '%')
or weight_label like concat('%', #{keyword}, '%')
d.product_code like concat('%', #{keyword}, '%')
or d.product_name like concat('%', #{keyword}, '%')
or d.country_code like concat('%', #{keyword}, '%')
or d.country_name like concat('%', #{keyword}, '%')
or d.zone_code like concat('%', #{keyword}, '%')
or d.zone_name like concat('%', #{keyword}, '%')
or d.weight_label like concat('%', #{keyword}, '%')
)
</if>
</where>
order by psd_id asc
order by d.psd_id asc
</select>
<select id="countProductByPriceSetId" parameterType="Long" resultType="int">
@@ -271,6 +281,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
)
</insert>
<update id="updateDetailPrices">
update prc_price_set_detail
set fv_value = #{fvValue},
fv_registration_fee = #{fvRegistrationFee},
update_time = sysdate()
where psd_id = #{psdId}
</update>
<update id="updatePrcPriceSetDetail" parameterType="PrcPriceSetDetail">
update prc_price_set_detail
<set>