Enhance price set import functionality with preview options and update handling. Added new endpoints for previewing product library pulls and Excel imports, along with support for update-only imports. Updated internationalization messages for import success and error scenarios.

This commit is contained in:
wuweihua
2026-09-30 16:15:44 +08:00
parent 90cde8da4a
commit 6e301ecee7
19 changed files with 1260 additions and 260 deletions
@@ -24,6 +24,7 @@ import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.MessageUtils;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetDetail;
@@ -233,14 +234,47 @@ public class PriceSetController extends BaseController
@PostMapping("/detail/importPrices")
public AjaxResult importDetailPrices(MultipartFile file,
@RequestParam("priceSetId") Long priceSetId,
@RequestParam(value = "psvId", required = false) Long psvId) throws Exception
@RequestParam(value = "psvId", required = false) Long psvId,
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly) throws Exception
{
String taskId = priceSetService.submitImportDetailPricesAsync(priceSetId, psvId, file, getUsername());
String taskId = priceSetService.submitImportDetailPricesAsync(priceSetId, psvId, file, getUsername(), updateOnly);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/**
* Preview pull product library (no DB write). For create wizard before finish.
*/
@PreAuthorize("@ss.hasPermi('price:set:edit') or @ss.hasPermi('price:set:forceEdit') or @ss.hasPermi('price:set:add')")
@PostMapping("/detail/previewPullFromLibrary")
public AjaxResult previewPullFromLibrary()
{
return success(priceSetService.previewPullFromLibrary());
}
/**
* Preview Excel import (no DB write). Optional detailsJson for updateOnly merge.
*/
@PreAuthorize("@ss.hasPermi('price:set:edit') or @ss.hasPermi('price:set:forceEdit') or @ss.hasPermi('price:set:add')")
@PostMapping("/detail/previewImportPrices")
public AjaxResult previewImportPrices(MultipartFile file,
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly,
@RequestParam(value = "detailsJson", required = false) String detailsJson) throws Exception
{
if (file == null || file.isEmpty())
{
return error("file required");
}
List<PrcPriceSetDetail> existing = null;
if (StringUtils.isNotEmpty(detailsJson))
{
existing = com.alibaba.fastjson2.JSON.parseArray(detailsJson, PrcPriceSetDetail.class);
}
return success(priceSetService.previewImportDetailPrices(
file.getBytes(), file.getOriginalFilename(), updateOnly, existing));
}
/**
* Async pull product library into version details. Returns taskId; poll /save/task/{taskId}.
*/
@@ -79,7 +79,10 @@ price.set.import.noDetails=\u5957\u9910\u6682\u65e0\u4ef7\u683c\u660e\u7ec6\uff0
price.set.import.noPriceRows=\u4ef7\u8868\u672a\u89e3\u6790\u5230\u6709\u6548\u4ef7\u683c\u884c
price.set.import.success=\u5bfc\u5165\u5b8c\u6210\uff1a\u66f4\u65b0 {0} \u6761\uff0c\u672a\u5339\u914d {1} \u6761\uff0c\u5957\u9910\u65e0\u5bf9\u5e94\u4ea7\u54c1 {2} \u4e2a
price.set.import.noDetailWritten=\u672a\u5199\u5165\u4efb\u4f55\u5957\u9910\u660e\u7ec6\uff08\u4ef7\u8868\u65e0\u53ef\u7528\u91cd\u91cf\u6bb5\u6216\u5747\u4e3a\u7a7a\u8fd0\u8d39/\u6302\u53f7\u8d39\u4e14\u65e0\u53ef\u66f4\u65b0\u884c\uff09
price.set.import.updateSuccess=\u5bfc\u5165\u4fee\u6539\u5b8c\u6210\uff1a\u66f4\u65b0 {0} \u6761\uff0c\u672a\u5339\u914d {1} \u6761\uff0c\u8df3\u8fc7\u7a7a\u8fd0\u8d39/\u6302\u53f7\u8d39 {2} \u6761
price.set.import.upsertSuccess=\u5bfc\u5165\u5b8c\u6210\uff1a\u65b0\u589e {0} \u6761\uff0c\u66f4\u65b0 {1} \u6761\uff0c\u8df3\u8fc7\u7a7a\u8fd0\u8d39/\u6302\u53f7\u8d39 {2} \u6761
price.set.import.weightBandMismatch=\u5bfc\u5165\u5931\u8d25\uff1a\u4ee5\u4e0b\u4ea7\u54c1+\u56fd\u5bb6\u7684\u91cd\u91cf\u6bb5\u4e0e\u5957\u9910\u4e0d\u4e00\u81f4\uff1a{0}
price.set.import.weightBandMismatch.item=\u4ea7\u54c1 {0} / \u56fd\u5bb6 {1}\uff08\u5957\u9910\u91cd\u91cf\u6bb5\uff1a{2}\uff1bExcel\uff1a{3}\uff09
price.set.pull.success=\u62c9\u53d6\u5b8c\u6210\uff1a\u5df2\u5199\u5165 {0} \u6761\u5957\u9910\u660e\u7ec6
price.set.customer.notExists=客户绑定不存在
price.set.customer.unbindAudit=审核中的套餐不允许解绑客户
@@ -26,6 +26,11 @@ public class PrcPriceSetVersion extends BaseEntity
private Date expireTime;
/** 0 draft / 3 pending / 1 active / 2 expired */
private String status;
/**
* Create-price mode for this version: PULL = from product library, IMPORT = Excel.
* Column: price_build_mode
*/
private String priceBuildMode;
private List<PrcPriceSetDetail> details;
private List<PrcPriceSetProduct> products;
@@ -22,6 +22,9 @@ public interface PrcPriceSetDetailMapper
public int insertPrcPriceSetDetail(PrcPriceSetDetail detail);
/** Batch insert details; fills generated psdId on each item. */
public int insertPrcPriceSetDetailBatch(@Param("list") List<PrcPriceSetDetail> list);
public int updatePrcPriceSetDetail(PrcPriceSetDetail detail);
public int deletePrcPriceSetDetailByIds(Long[] psdIds);
@@ -49,6 +52,11 @@ public interface PrcPriceSetDetailMapper
@Param("asOf") Date asOf,
@Param("openOnly") boolean openOnly);
/** Preview: FIXED details from product country library (no insert). */
public List<PrcPriceSetDetail> selectFixedDetailsFromLibrary(
@Param("asOf") Date asOf,
@Param("openOnly") boolean openOnly);
/** DISCOUNT: one detail row per effective product */
public int insertDiscountDetailsFromLibrary(
@Param("priceSetId") Long priceSetId,
@@ -26,6 +26,9 @@ public interface PrcPriceSetProductMapper
/** Copy active library products into this version (ignore duplicates). */
public int insertFromLibrary(@Param("priceSetId") Long priceSetId, @Param("psvId") Long psvId);
/** Preview: active library products without writing set-product rows. */
public List<PrcPriceSetProduct> selectFromLibrary();
/** Copy set products from one version to another. */
public int copyFromVersion(@Param("srcPsvId") Long srcPsvId, @Param("newPsvId") Long newPsvId);
@@ -109,15 +109,24 @@ public interface IPrcPriceSetService
*/
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName) throws Exception;
/**
* @param updateOnly true = only update matched existing details (no insert / no new product)
*/
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName,
boolean updateOnly) throws Exception;
/**
* Sync worker: parse bytes and upsert FIXED detail prices matched by
* product + country + zone(if any) + weight band (insert when missing).
* product + country + zone(if any) + weight band (insert when missing unless updateOnly).
*/
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;
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
String taskId, boolean updateOnly) throws Exception;
/**
* Submit async pull of product library into version details.
* @return taskId for polling getSaveTask
@@ -126,4 +135,16 @@ public interface IPrcPriceSetService
/** Sync worker: replace (or skip) version details from product / product_country. */
public String pullFromLibrary(Long priceSetId, Long psvId, boolean force, String operName, String taskId);
/**
* Preview pull: return products + FIXED details from library without writing DB.
*/
public Map<String, Object> previewPullFromLibrary();
/**
* Preview import: parse Excel into products + details without writing DB.
* When updateOnly, merge prices into existingDetails (in-memory match).
*/
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
List<PrcPriceSetDetail> existingDetails) throws Exception;
}
@@ -11,6 +11,7 @@ import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.zip.CRC32;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
@@ -69,6 +70,14 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
/** Pending: effective_time set but not yet reached */
private static final String VER_PENDING = "3";
private static final String VER_EXPIRED = "2";
/** Bulk detail insert chunk size for create/finish async save. */
private static final int DETAIL_INSERT_BATCH = 200;
/** Request-scoped cache: productCode -> open product_country rows (bulk upsert). */
private final ThreadLocal<Map<String, List<BmsProductCountry>>> countryCacheTl =
new ThreadLocal<Map<String, List<BmsProductCountry>>>();
/** Request-scoped cache: productCode -> psp_id (bulk upsert). */
private final ThreadLocal<Map<String, Long>> setProductCacheTl =
new ThreadLocal<Map<String, Long>>();
@Autowired
private PrcPriceSetMapper setMapper;
@@ -406,6 +415,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
nv.setStatus(VER_DRAFT);
nv.setEffectiveTime(null);
nv.setExpireTime(null);
nv.setPriceBuildMode(src.getPriceBuildMode());
nv.setCreateBy(createBy);
versionMapper.insertPrcPriceSetVersion(nv);
@@ -836,6 +846,13 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
@Override
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName) throws Exception
{
return submitImportDetailPricesAsync(priceSetId, psvId, file, operName, false);
}
@Override
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName,
boolean updateOnly) throws Exception
{
PrcPriceSet db = requireSet(priceSetId);
assertChildEditable(db);
@@ -860,7 +877,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
String taskId = IdUtils.fastSimpleUUID();
saveAsyncRunner.put(taskId, PrcPriceSetSaveTask.STATUS_PENDING, 0, "queued", priceSetId, null);
saveAsyncRunner.runImportPrices(taskId, priceSetId, psvId, bytes, filename, operName);
saveAsyncRunner.runImportPrices(taskId, priceSetId, psvId, bytes, filename, operName, updateOnly);
return taskId;
}
@@ -915,16 +932,241 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return MessageUtils.message("price.set.pull.success", Integer.valueOf(count));
}
@Override
public Map<String, Object> previewPullFromLibrary()
{
List<PrcPriceSetProduct> products = setProductMapper.selectFromLibrary();
if (products == null || products.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.library.empty"));
}
List<PrcPriceSetDetail> details = detailMapper.selectFixedDetailsFromLibrary(null, true);
if (details == null || details.isEmpty())
{
details = detailMapper.selectFixedDetailsFromLibrary(new Date(), false);
}
if (details == null || details.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.library.empty"));
}
Map<String, Object> result = new HashMap<String, Object>();
result.put("products", products);
result.put("details", details);
result.put("message", MessageUtils.message("price.set.pull.success", Integer.valueOf(details.size())));
return result;
}
@Override
public Map<String, Object> previewImportDetailPrices(byte[] bytes, String filename, boolean updateOnly,
List<PrcPriceSetDetail> existingDetails) throws Exception
{
Map<String, List<BmsProductCountry>> byProduct = productService.parseQuoteExcelByProduct(bytes, filename);
if (byProduct == null || byProduct.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
}
List<PrcPriceSetDetail> details = new ArrayList<PrcPriceSetDetail>();
if (existingDetails != null)
{
for (PrcPriceSetDetail d : existingDetails)
{
if (d != null)
{
details.add(d);
}
}
}
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);
}
if (updateOnly)
{
assertUpdateOnlyWeightBandsMatch(byProduct, detailIndex);
}
Map<String, PrcPriceSetProduct> productMap = new LinkedHashMap<String, PrcPriceSetProduct>();
int updated = 0;
int inserted = 0;
int skippedFee = 0;
int skippedUnmatched = 0;
Set<Integer> touchedLocal = new HashSet<Integer>();
for (Map.Entry<String, List<BmsProductCountry>> e : byProduct.entrySet())
{
String productCode = e.getKey();
List<PrcPriceSetDetail> candidates = detailIndex.get(productCode);
if (candidates == null)
{
candidates = new ArrayList<PrcPriceSetDetail>();
detailIndex.put(productCode, candidates);
}
if (updateOnly && candidates.isEmpty())
{
skippedUnmatched += e.getValue() == null ? 0 : e.getValue().size();
continue;
}
String productName = resolveImportProductName(null, productCode, candidates);
if (!updateOnly)
{
ensurePreviewProduct(productMap, productCode, productName);
}
for (BmsProductCountry row : e.getValue())
{
if (row == null)
{
continue;
}
BigDecimal freight = row.getFreightPrice();
BigDecimal registration = row.getRegistrationFee();
List<PrcPriceSetDetail> hits = matchImportDetails(candidates, row);
if (!hits.isEmpty())
{
if (freight == null && registration == null)
{
skippedFee++;
continue;
}
for (PrcPriceSetDetail hit : hits)
{
int identity = System.identityHashCode(hit);
if (touchedLocal.contains(Integer.valueOf(identity)))
{
continue;
}
if (freight != null)
{
hit.setFvValue(freight);
}
if (registration != null)
{
hit.setFvRegistrationFee(registration);
}
touchedLocal.add(Integer.valueOf(identity));
updated++;
}
continue;
}
if (updateOnly)
{
skippedUnmatched++;
continue;
}
if (freight == null && registration == null)
{
skippedFee++;
continue;
}
PrcPriceSetDetail created = buildDetailFromImportRow(
null, null, productCode, productName, row, freight, registration);
candidates.add(created);
details.add(created);
inserted++;
}
}
if (updateOnly)
{
if (updated == 0)
{
throw new ServiceException(MessageUtils.message("price.set.import.noDetailWritten"));
}
// Keep product list from existing details codes
for (PrcPriceSetDetail d : details)
{
if (d != null && StringUtils.isNotEmpty(d.getProductCode()))
{
ensurePreviewProduct(productMap, d.getProductCode(), d.getProductName());
}
}
}
else if (inserted == 0 && updated == 0)
{
throw new ServiceException(MessageUtils.message("price.set.import.noDetailWritten"));
}
Map<String, Object> result = new HashMap<String, Object>();
result.put("products", new ArrayList<PrcPriceSetProduct>(productMap.values()));
result.put("details", details);
if (updateOnly)
{
result.put("message", MessageUtils.message("price.set.import.updateSuccess",
Integer.valueOf(updated), Integer.valueOf(skippedUnmatched), Integer.valueOf(skippedFee)));
}
else
{
result.put("message", MessageUtils.message("price.set.import.upsertSuccess",
Integer.valueOf(inserted), Integer.valueOf(updated), Integer.valueOf(skippedFee)));
}
return result;
}
private void ensurePreviewProduct(Map<String, PrcPriceSetProduct> productMap, String productCode, String productName)
{
if (productMap == null || StringUtils.isEmpty(productCode))
{
return;
}
String code = productCode.trim();
String key = code.toUpperCase(Locale.ROOT);
if (productMap.containsKey(key))
{
return;
}
PrcPriceSetProduct sp = new PrcPriceSetProduct();
sp.setProductCode(code);
BmsProduct lib = productMapper.selectBmsProductByCode(code);
if (lib != null)
{
sp.setProductName(lib.getProductName());
sp.setServiceLevel(lib.getServiceLevel());
sp.setCargoAttr(lib.getCargoAttr());
sp.setDestRegion(lib.getDestRegion());
sp.setUpdateNote(lib.getUpdateNote());
sp.setTaxMode(StringUtils.isNotEmpty(lib.getTaxMode()) ? lib.getTaxMode() : "TAX_INCLUDED");
sp.setSourceProductId(lib.getProductId());
sp.setSourceSheet(lib.getSourceSheet());
}
else
{
sp.setProductName(StringUtils.isNotEmpty(productName) ? productName : code);
sp.setTaxMode("TAX_INCLUDED");
}
productMap.put(key, sp);
}
@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);
return importDetailPrices(priceSetId, psvId, bytes, filename, operName, null, false);
}
@Override
@Transactional(rollbackFor = Exception.class)
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName, String taskId) throws Exception
{
return importDetailPrices(priceSetId, psvId, bytes, filename, operName, taskId, false);
}
@Override
@Transactional(rollbackFor = Exception.class)
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
String taskId, boolean updateOnly) throws Exception
{
PrcPriceSet db = requireSet(priceSetId);
assertChildEditable(db);
@@ -972,9 +1214,15 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
list.add(d);
}
if (updateOnly)
{
assertUpdateOnlyWeightBandsMatch(byProduct, detailIndex);
}
int updated = 0;
int inserted = 0;
int skippedFee = 0;
int skippedUnmatched = 0;
Set<Long> touched = new HashSet<Long>();
int productTotal = byProduct.size();
int productIndex = 0;
@@ -991,9 +1239,17 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
candidates = new ArrayList<PrcPriceSetDetail>();
detailIndex.put(productCode, candidates);
}
if (updateOnly && candidates.isEmpty())
{
skippedUnmatched += e.getValue() == null ? 0 : e.getValue().size();
continue;
}
String productName = resolveImportProductName(psvId, productCode, candidates);
// Excel import is one of the only two paths that create set-product rows
Long pspId = ensureSetProduct(priceSetId, psvId, productCode, productName);
Long pspId = null;
if (!updateOnly)
{
pspId = ensureSetProduct(priceSetId, psvId, productCode, productName);
}
for (BmsProductCountry row : e.getValue())
{
if (row == null)
@@ -1005,7 +1261,6 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
List<PrcPriceSetDetail> hits = matchImportDetails(candidates, row);
if (!hits.isEmpty())
{
// Existing band: only skip when Excel left both fees blank (keep old prices).
if (freight == null && registration == null)
{
skippedFee++;
@@ -1031,7 +1286,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
continue;
}
// New band: skip when Excel has no usable fee (blank / slash = no service).
if (updateOnly)
{
skippedUnmatched++;
continue;
}
if (freight == null && registration == null)
{
skippedFee++;
@@ -1049,12 +1308,20 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
inserted++;
}
}
setProductMapper.bindDetailPspByPsvId(psvId);
if (!updateOnly)
{
setProductMapper.bindDetailPspByPsvId(psvId);
}
reportImportProgress(taskId, priceSetId, 95, "finishing");
if (inserted == 0 && updated == 0)
{
throw new ServiceException(MessageUtils.message("price.set.import.noDetailWritten"));
}
if (updateOnly)
{
return MessageUtils.message("price.set.import.updateSuccess",
Integer.valueOf(updated), Integer.valueOf(skippedUnmatched), Integer.valueOf(skippedFee));
}
return MessageUtils.message("price.set.import.upsertSuccess",
Integer.valueOf(inserted), Integer.valueOf(updated), Integer.valueOf(skippedFee));
}
@@ -1183,6 +1450,158 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return pick == null ? null : pick.getPsvId();
}
private void assertUpdateOnlyWeightBandsMatch(Map<String, List<BmsProductCountry>> byProduct,
Map<String, List<PrcPriceSetDetail>> detailIndex)
{
if (byProduct == null || byProduct.isEmpty() || detailIndex == null || detailIndex.isEmpty())
{
return;
}
List<String> mismatches = new ArrayList<String>();
for (Map.Entry<String, List<BmsProductCountry>> e : byProduct.entrySet())
{
String productCode = e.getKey();
if (StringUtils.isEmpty(productCode) || e.getValue() == null || e.getValue().isEmpty())
{
continue;
}
String pcKey = productCode.trim().toUpperCase(Locale.ROOT);
List<PrcPriceSetDetail> candidates = detailIndex.get(pcKey);
if (candidates == null)
{
candidates = detailIndex.get(productCode);
}
if (candidates == null || candidates.isEmpty())
{
continue;
}
// Group Excel rows by country (code preferred, else name)
Map<String, List<BmsProductCountry>> excelGroups = new LinkedHashMap<String, List<BmsProductCountry>>();
for (BmsProductCountry row : e.getValue())
{
if (row == null)
{
continue;
}
String ck = countryKeyOfImport(row);
if (StringUtils.isEmpty(ck))
{
continue;
}
List<BmsProductCountry> g = excelGroups.get(ck);
if (g == null)
{
g = new ArrayList<BmsProductCountry>();
excelGroups.put(ck, g);
}
g.add(row);
}
for (Map.Entry<String, List<BmsProductCountry>> ge : excelGroups.entrySet())
{
List<BmsProductCountry> excelRows = ge.getValue();
BmsProductCountry sample = excelRows.get(0);
List<PrcPriceSetDetail> matchedDetails = new ArrayList<PrcPriceSetDetail>();
for (PrcPriceSetDetail d : candidates)
{
if (d != null && sameCountry(d, sample))
{
matchedDetails.add(d);
}
}
if (matchedDetails.isEmpty())
{
continue;
}
Set<String> excelBands = new TreeSet<String>();
for (BmsProductCountry row : excelRows)
{
excelBands.add(weightBandLabel(row.getZoneCode(), row.getWeightMin(), row.getWeightMax()));
}
Set<String> pkgBands = new TreeSet<String>();
for (PrcPriceSetDetail d : matchedDetails)
{
pkgBands.add(weightBandLabel(d.getZoneCode(), d.getWeightMin(), d.getWeightMax()));
}
if (pkgBands.equals(excelBands))
{
continue;
}
mismatches.add(MessageUtils.message("price.set.import.weightBandMismatch.item",
productCode,
displayCountryOfImport(sample),
joinBands(pkgBands),
joinBands(excelBands)));
}
}
if (!mismatches.isEmpty())
{
String joined = String.join(";", mismatches);
if (joined.length() > 800)
{
joined = joined.substring(0, 800) + "...";
}
throw new ServiceException(MessageUtils.message("price.set.import.weightBandMismatch", joined));
}
}
private static String joinBands(Set<String> bands)
{
if (bands == null || bands.isEmpty())
{
return "-";
}
return String.join("、", bands);
}
private static String countryKeyOfImport(BmsProductCountry row)
{
if (row == null)
{
return "";
}
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 static String displayCountryOfImport(BmsProductCountry row)
{
if (row == null)
{
return "-";
}
if (StringUtils.isNotEmpty(row.getCountryName()))
{
return row.getCountryName().trim();
}
if (StringUtils.isNotEmpty(row.getCountryCode()))
{
return row.getCountryCode().trim().toUpperCase(Locale.ROOT);
}
return "-";
}
private static String weightBandLabel(String zoneCode, BigDecimal min, BigDecimal max)
{
String w = formatWeightLabel(min, max);
if (StringUtils.isEmpty(w))
{
w = "-";
}
String z = zoneCode == null ? "" : zoneCode.trim();
if (StringUtils.isNotEmpty(z))
{
return z + ":" + w;
}
return w;
}
private List<PrcPriceSetDetail> matchImportDetails(List<PrcPriceSetDetail> candidates, BmsProductCountry row)
{
List<PrcPriceSetDetail> hits = new ArrayList<PrcPriceSetDetail>();
@@ -1698,7 +2117,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
if (detail.getProductCountryId() != null)
{
syncZoneFromProductCountry(detail);
// Preview/pull already stamped weight bounds — skip per-row product_country reload.
if (detail.getWeightMin() == null || detail.getWeightMax() == null)
{
syncZoneFromProductCountry(detail);
}
return;
}
if (StringUtils.isEmpty(detail.getProductCode()) || StringUtils.isEmpty(detail.getWeightLabel()))
@@ -1732,9 +2155,14 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
return;
}
BmsProductCountry query = new BmsProductCountry();
query.setProductCode(detail.getProductCode());
List<BmsProductCountry> rows = productCountryMapper.selectBmsProductCountryList(query);
List<BmsProductCountry> rows = listOpenProductCountries(detail.getProductCode());
if (rows == null || rows.isEmpty())
{
// Fallback: include expired rows when open-only cache misses
BmsProductCountry query = new BmsProductCountry();
query.setProductCode(detail.getProductCode());
rows = productCountryMapper.selectBmsProductCountryList(query);
}
if (rows == null)
{
return;
@@ -1750,6 +2178,28 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
private List<BmsProductCountry> listOpenProductCountries(String productCode)
{
if (StringUtils.isEmpty(productCode))
{
return java.util.Collections.emptyList();
}
Map<String, List<BmsProductCountry>> cache = countryCacheTl.get();
if (cache != null)
{
String key = productCode.trim().toUpperCase(Locale.ROOT);
List<BmsProductCountry> hit = cache.get(key);
if (hit != null)
{
return hit;
}
List<BmsProductCountry> loaded = loadOpenProductCountries(productCode);
cache.put(key, loaded);
return loaded;
}
return loadOpenProductCountries(productCode);
}
private List<BmsProductCountry> loadOpenProductCountries(String productCode)
{
if (StringUtils.isEmpty(productCode))
{
@@ -2315,6 +2765,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
/**
* Upsert details by psdId. Keeps existing rows when id matches; inserts new; deletes orphans
* not present in the payload. Avoids full delete+reinsert.
* Create/finish path: caches set-product + product_country lookups, batch-inserts new rows.
*/
private void upsertVersionDetails(PrcPriceSet set, PrcPriceSetVersion version, List<PrcPriceSetDetail> details)
{
@@ -2332,93 +2783,156 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
}
}
Set<Long> keepIds = new HashSet<Long>();
int total = details == null ? 0 : details.size();
int index = 0;
Map<String, Long> pspCache = new HashMap<String, Long>();
PrcPriceSetProduct pq0 = new PrcPriceSetProduct();
pq0.setPsvId(version.getPsvId());
List<PrcPriceSetProduct> existingSp = setProductMapper.selectPrcPriceSetProductList(pq0);
if (existingSp != null)
{
for (PrcPriceSetProduct sp : existingSp)
{
if (sp != null && StringUtils.isNotEmpty(sp.getProductCode()) && sp.getPspId() != null)
{
pspCache.put(sp.getProductCode().trim().toUpperCase(Locale.ROOT), sp.getPspId());
}
}
}
Map<String, List<BmsProductCountry>> countryCache = new HashMap<String, List<BmsProductCountry>>();
if (details != null)
{
Set<String> codes = new HashSet<String>();
for (PrcPriceSetDetail d : details)
{
validateDetail(set, d);
d.setPriceSetId(set.getPriceSetId());
d.setPsvId(version.getPsvId());
Long pspId = lookupSetProductId(version.getPsvId(), d.getProductCode());
if (pspId != null)
if (d != null && StringUtils.isNotEmpty(d.getProductCode()))
{
d.setPspId(pspId);
codes.add(d.getProductCode().trim());
}
if (d.getPsdId() != null && byId.containsKey(d.getPsdId()))
}
for (String code : codes)
{
countryCache.put(code.toUpperCase(Locale.ROOT), loadOpenProductCountries(code));
}
}
setProductCacheTl.set(pspCache);
countryCacheTl.set(countryCache);
try
{
Set<Long> keepIds = new HashSet<Long>();
int total = details == null ? 0 : details.size();
int done = 0;
List<PrcPriceSetDetail> toInsert = new ArrayList<PrcPriceSetDetail>();
if (details != null)
{
for (PrcPriceSetDetail d : details)
{
detailMapper.updatePrcPriceSetDetail(d);
keepIds.add(d.getPsdId());
validateDetail(set, d);
d.setPriceSetId(set.getPriceSetId());
d.setPsvId(version.getPsvId());
Long pspId = ensureSetProduct(set.getPriceSetId(), version.getPsvId(),
d.getProductCode(), d.getProductName());
if (pspId != null)
{
d.setPspId(pspId);
}
if (d.getPsdId() != null && byId.containsKey(d.getPsdId()))
{
detailMapper.updatePrcPriceSetDetail(d);
keepIds.add(d.getPsdId());
done++;
reportDetailProgress(set, done, total);
}
else
{
d.setPsdId(null);
toInsert.add(d);
}
}
else
}
for (int i = 0; i < toInsert.size(); i += DETAIL_INSERT_BATCH)
{
int end = Math.min(i + DETAIL_INSERT_BATCH, toInsert.size());
List<PrcPriceSetDetail> chunk = new ArrayList<PrcPriceSetDetail>(toInsert.subList(i, end));
detailMapper.insertPrcPriceSetDetailBatch(chunk);
for (PrcPriceSetDetail d : chunk)
{
d.setPsdId(null);
detailMapper.insertPrcPriceSetDetail(d);
if (d.getPsdId() != null)
{
keepIds.add(d.getPsdId());
}
}
index++;
if (total > 0 && (index == total || index % 50 == 0 || index == 1))
done += chunk.size();
reportDetailProgress(set, done, total);
}
List<Long> orphanIds = new ArrayList<Long>();
for (Long id : byId.keySet())
{
if (!keepIds.contains(id))
{
int pct = 25 + (int) Math.floor(index * 55.0 / total);
if (pct > 80)
orphanIds.add(id);
}
}
if (!orphanIds.isEmpty())
{
for (Long id : orphanIds)
{
versionSurchargeMapper.deleteByPsdId(id);
}
detailMapper.deletePrcPriceSetDetailByIds(orphanIds.toArray(new Long[0]));
}
Set<String> keepCodes = new HashSet<String>();
if (details != null)
{
for (PrcPriceSetDetail d : details)
{
if (d != null && StringUtils.isNotEmpty(d.getProductCode()))
{
pct = 80;
keepCodes.add(d.getProductCode().trim());
}
reportSaveProgress(set, pct, "saving details " + index + "/" + total);
}
}
}
List<Long> orphanIds = new ArrayList<Long>();
for (Long id : byId.keySet())
{
if (!keepIds.contains(id))
PrcPriceSetProduct pq = new PrcPriceSetProduct();
pq.setPsvId(version.getPsvId());
List<PrcPriceSetProduct> setProducts = setProductMapper.selectPrcPriceSetProductList(pq);
if (setProducts != null)
{
orphanIds.add(id);
}
}
if (!orphanIds.isEmpty())
{
for (Long id : orphanIds)
{
versionSurchargeMapper.deleteByPsdId(id);
}
detailMapper.deletePrcPriceSetDetailByIds(orphanIds.toArray(new Long[0]));
}
// drop set-product rows whose codes no longer have any detail
Set<String> keepCodes = new HashSet<String>();
if (details != null)
{
for (PrcPriceSetDetail d : details)
{
if (d != null && StringUtils.isNotEmpty(d.getProductCode()))
for (PrcPriceSetProduct sp : setProducts)
{
keepCodes.add(d.getProductCode().trim());
if (sp == null || StringUtils.isEmpty(sp.getProductCode()))
{
continue;
}
if (!keepCodes.contains(sp.getProductCode()))
{
setProductMapper.deleteByPsvIdAndCode(version.getPsvId(), sp.getProductCode());
}
}
}
version.setDetails(details);
}
PrcPriceSetProduct pq = new PrcPriceSetProduct();
pq.setPsvId(version.getPsvId());
List<PrcPriceSetProduct> setProducts = setProductMapper.selectPrcPriceSetProductList(pq);
if (setProducts != null)
finally
{
for (PrcPriceSetProduct sp : setProducts)
{
if (sp == null || StringUtils.isEmpty(sp.getProductCode()))
{
continue;
}
if (!keepCodes.contains(sp.getProductCode()))
{
setProductMapper.deleteByPsvIdAndCode(version.getPsvId(), sp.getProductCode());
}
}
setProductCacheTl.remove();
countryCacheTl.remove();
}
}
private void reportDetailProgress(PrcPriceSet set, int done, int total)
{
if (total <= 0)
{
return;
}
if (done == total || done == 1 || done % 200 == 0)
{
int pct = 25 + (int) Math.floor(done * 55.0 / total);
if (pct > 80)
{
pct = 80;
}
reportSaveProgress(set, pct, "saving details " + done + "/" + total);
}
version.setDetails(details);
}
/**
@@ -2437,7 +2951,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
/**
* Ensure package-side product master row exists for this version+code; return psp_id.
* Only called from pull-from-library / Excel import.
* Uses request-scoped cache during bulk upsert to avoid N selects on same code.
*/
private Long ensureSetProduct(Long priceSetId, Long psvId, String productCode, String productNameHint)
{
@@ -2446,6 +2960,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return null;
}
String code = productCode.trim();
String key = code.toUpperCase(Locale.ROOT);
Map<String, Long> cache = setProductCacheTl.get();
if (cache != null && cache.containsKey(key))
{
return cache.get(key);
}
PrcPriceSetProduct existing = setProductMapper.selectByPsvIdAndCode(psvId, code);
if (existing != null)
{
@@ -2455,6 +2975,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
existing.setProductName(productNameHint);
setProductMapper.updatePrcPriceSetProduct(existing);
}
if (cache != null)
{
cache.put(key, existing.getPspId());
}
return existing.getPspId();
}
PrcPriceSetProduct row = new PrcPriceSetProduct();
@@ -2487,6 +3011,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
row.setTaxMode("TAX_INCLUDED");
}
setProductMapper.insertPrcPriceSetProduct(row);
if (cache != null)
{
cache.put(key, row.getPspId());
}
return row.getPspId();
}
@@ -80,13 +80,20 @@ 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)
{
runImportPrices(taskId, priceSetId, psvId, bytes, filename, operName, false);
}
@Async("threadPoolTaskExecutor")
public void runImportPrices(String taskId, Long priceSetId, Long psvId, byte[] bytes, String filename, String operName,
boolean updateOnly)
{
try
{
put(taskId, PrcPriceSetSaveTask.STATUS_RUNNING, 10, "parsing excel", priceSetId, null);
String message = priceSetService.importDetailPrices(priceSetId, psvId, bytes, filename, operName, taskId);
String message = priceSetService.importDetailPrices(priceSetId, psvId, bytes, filename, operName, taskId, updateOnly);
put(taskId, PrcPriceSetSaveTask.STATUS_SUCCESS, 100, message, priceSetId, null);
log.info("async import prices done, taskId={}, priceSetId={}", taskId, priceSetId);
log.info("async import prices done, taskId={}, priceSetId={}, updateOnly={}", taskId, priceSetId, updateOnly);
}
catch (Exception e)
{
@@ -185,6 +185,66 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by p.product_code asc, c.country_name asc, c.zone_code asc, c.weight_min asc, c.detail_id asc
</insert>
<select id="selectFixedDetailsFromLibrary" resultMap="PrcPriceSetDetailResult">
select
null as psd_id,
null as price_set_id,
null as psv_id,
null as psp_id,
p.product_code,
p.product_name,
c.country_code,
c.country_name,
c.zone_id,
c.zone_code,
c.zone_name,
null as zd_id,
null as wsd_id,
case
when c.weight_min is null and c.weight_max is null then null
else concat(
ifnull(trim(trailing '0' from trim(trailing '.' from cast(c.weight_min as char))), ''),
'&lt;W≤',
ifnull(trim(trailing '0' from trim(trailing '.' from cast(c.weight_max as char))), '')
)
end as weight_label,
c.weight_min,
c.weight_max,
c.detail_id as product_country_id,
c.freight_price as fv_value,
c.registration_fee as fv_registration_fee,
null as base_freight_price,
null as base_registration_fee,
null as discount_value,
'HALF_UP' as round_mode,
null as pr_id,
c.effective_date,
c.expire_date,
null as create_time,
null as update_time
from product_country c
inner join product p on p.product_id = c.product_id
<where>
and p.status = '1'
and p.product_id = (
select max(p2.product_id) from product p2
where p2.product_code = p.product_code and p2.status = '1'
)
and ifnull(c.status, '1') &lt;&gt; '2'
<choose>
<when test="openOnly">
and (c.effective_date is null or date(c.effective_date) &lt;= curdate())
and (c.expire_date is null or date(c.expire_date) &gt; curdate())
</when>
<otherwise>
and date(c.effective_date) &lt;= date(#{asOf})
and (c.expire_date is null or date(c.expire_date) &gt; date(#{asOf}))
</otherwise>
</choose>
</where>
order by p.product_code asc, c.country_name asc, c.zone_code asc, c.weight_min asc, c.detail_id asc
</select>
<insert id="insertDiscountDetailsFromLibrary">
insert ignore into prc_price_set_detail (
price_set_id, psv_id, product_code, product_name, round_mode, create_time
@@ -261,6 +321,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
)
</insert>
<insert id="insertPrcPriceSetDetailBatch" useGeneratedKeys="true" keyProperty="psdId">
insert into prc_price_set_detail (
price_set_id, psv_id, psp_id, product_code, product_name,
country_code, country_name, zone_id, zone_code, zone_name,
zd_id, wsd_id, weight_label, weight_min, weight_max, product_country_id,
fv_value, fv_registration_fee, discount_value, round_mode, pr_id,
effective_date, expire_date, create_time
) values
<foreach collection="list" item="item" separator=",">
(
#{item.priceSetId}, #{item.psvId}, #{item.pspId}, #{item.productCode}, #{item.productName},
#{item.countryCode}, #{item.countryName}, #{item.zoneId}, #{item.zoneCode}, #{item.zoneName},
#{item.zdId}, #{item.wsdId}, #{item.weightLabel}, #{item.weightMin}, #{item.weightMax}, #{item.productCountryId},
#{item.fvValue}, #{item.fvRegistrationFee}, #{item.discountValue},
ifnull(#{item.roundMode}, 'HALF_UP'), #{item.prId},
#{item.effectiveDate}, #{item.expireDate}, sysdate()
)
</foreach>
</insert>
<!-- Null means keep existing value (import empty freight/registration skips overwrite). -->
<update id="updateDetailPrices">
update prc_price_set_detail
@@ -125,6 +125,31 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by p.product_code asc
</insert>
<select id="selectFromLibrary" resultMap="PrcPriceSetProductResult">
select
null as psp_id,
null as price_set_id,
null as psv_id,
p.product_code,
p.product_name,
p.service_level,
p.cargo_attr,
p.dest_region,
p.update_note,
ifnull(p.tax_mode, 'TAX_INCLUDED') as tax_mode,
p.product_id as source_product_id,
p.source_sheet,
null as create_time,
null as update_time
from product p
where p.status = '1'
and p.product_id = (
select max(p2.product_id) from product p2
where p2.product_code = p.product_code and p2.status = '1'
)
order by p.product_code asc
</select>
<update id="bindDetailPspByPsvId">
update prc_price_set_detail d
inner join prc_price_set_product sp
@@ -12,6 +12,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="effectiveTime" column="effective_time" />
<result property="expireTime" column="expire_time" />
<result property="status" column="status" />
<result property="priceBuildMode" column="price_build_mode"/>
<result property="createBy" column="created_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="updated_by" />
@@ -20,7 +21,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectPrcPriceSetVersionVo">
select psv_id, price_set_id, version_no, version_seq, effective_time, expire_time, status,
created_by, create_time, updated_by, update_time
price_build_mode, created_by, create_time, updated_by, update_time
from prc_price_set_version
</sql>
@@ -57,6 +58,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="effectiveTime != null">effective_time,</if>
<if test="expireTime != null">expire_time,</if>
<if test="status != null and status != ''">status,</if>
<if test="priceBuildMode != null and priceBuildMode != ''">price_build_mode,</if>
<if test="createBy != null and createBy != ''">created_by,</if>
create_time
) values (
@@ -66,6 +68,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="effectiveTime != null">#{effectiveTime},</if>
<if test="expireTime != null">#{expireTime},</if>
<if test="status != null and status != ''">#{status},</if>
<if test="priceBuildMode != null and priceBuildMode != ''">#{priceBuildMode},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
sysdate()
)
@@ -79,6 +82,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="effectiveTime != null">effective_time = #{effectiveTime},</if>
expire_time = #{expireTime},
<if test="status != null and status != ''">status = #{status},</if>
<if test="priceBuildMode != null and priceBuildMode != ''">price_build_mode = #{priceBuildMode},</if>
<if test="updateBy != null and updateBy != ''">updated_by = #{updateBy},</if>
update_time = sysdate()
</set>
+2
View File
@@ -1305,6 +1305,8 @@ CREATE TABLE `prc_price_set_version` (
`effective_time` datetime DEFAULT NULL COMMENT '生效时间',
`expire_time` datetime DEFAULT NULL COMMENT '失效时间(空=长期;下一版本启动时写入该启动时间)',
`status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '0 draft 3 pending 1 effect 2 expired',
`price_build_mode` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建价格方式 PULL=拉取产品库 IMPORT=Excel导入',
`created_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`updated_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新人',
@@ -0,0 +1,3 @@
-- Add create-price mode on package version: PULL / IMPORT
ALTER TABLE prc_price_set_version
ADD COLUMN price_build_mode varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建价格方式 PULL=拉取产品库 IMPORT=Excel导入' AFTER status;