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:
+36
-2
@@ -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;
|
||||
}
|
||||
|
||||
+601
-73
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+9
-2
@@ -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))), ''),
|
||||
'<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') <> '2'
|
||||
<choose>
|
||||
<when test="openOnly">
|
||||
and (c.effective_date is null or date(c.effective_date) <= curdate())
|
||||
and (c.expire_date is null or date(c.expire_date) > curdate())
|
||||
</when>
|
||||
<otherwise>
|
||||
and date(c.effective_date) <= date(#{asOf})
|
||||
and (c.expire_date is null or date(c.expire_date) > 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>
|
||||
|
||||
@@ -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;
|
||||
@@ -56,7 +56,7 @@ export async function savePriceSetAsync(data, onProgress) {
|
||||
err.task = task
|
||||
throw err
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
}
|
||||
throw new Error('save timeout')
|
||||
}
|
||||
@@ -202,7 +202,7 @@ export function addSetDetail(data) {
|
||||
/**
|
||||
* Async import quote Excel into FIXED package detail prices; poll until done.
|
||||
* @param {File|Blob} file
|
||||
* @param {{ priceSetId: string|number, psvId?: string|number }} query
|
||||
* @param {{ priceSetId: string|number, psvId?: string|number, updateOnly?: boolean }} query
|
||||
* @param {(task: object) => void} [onProgress]
|
||||
* @returns {Promise<{ task: object }>}
|
||||
*/
|
||||
@@ -212,6 +212,7 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
|
||||
const params = new URLSearchParams()
|
||||
if (query && query.priceSetId != null) params.set('priceSetId', query.priceSetId)
|
||||
if (query && query.psvId != null && query.psvId !== '') params.set('psvId', query.psvId)
|
||||
if (query && query.updateOnly) params.set('updateOnly', 'true')
|
||||
const submitRes = await request({
|
||||
url: '/price/set/detail/importPrices?' + params.toString(),
|
||||
method: 'post',
|
||||
@@ -225,6 +226,37 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
|
||||
return pollPriceSetSaveTask(taskId, onProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview pull product library (no DB write). Returns { products, details, message }.
|
||||
*/
|
||||
export function previewPullFromLibrary() {
|
||||
return request({
|
||||
url: '/price/set/detail/previewPullFromLibrary',
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview Excel import (no DB write).
|
||||
* @param {File|Blob} file
|
||||
* @param {{ updateOnly?: boolean, details?: Array }} [opts]
|
||||
*/
|
||||
export function previewImportDetailPrices(file, opts = {}) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (opts.updateOnly) formData.append('updateOnly', 'true')
|
||||
if (opts.updateOnly && Array.isArray(opts.details)) {
|
||||
formData.append('detailsJson', JSON.stringify(opts.details))
|
||||
}
|
||||
return request({
|
||||
url: '/price/set/detail/previewImportPrices',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 10 * 60 * 1000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Async pull product library into package version details; poll until done.
|
||||
* @param {{ priceSetId: string|number, psvId?: string|number, force?: boolean }} query
|
||||
@@ -263,7 +295,7 @@ async function pollPriceSetSaveTask(taskId, onProgress) {
|
||||
err.task = task
|
||||
throw err
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
}
|
||||
throw new Error('task timeout')
|
||||
}
|
||||
@@ -306,7 +338,7 @@ export async function updateSetDetailSurchargeAsync(details, onProgress) {
|
||||
err.task = task
|
||||
throw err
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
}
|
||||
throw new Error('surcharge save timeout')
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
:psv-id="version.psvId"
|
||||
@update:psv-id="v => { if (v != null && v !== '') version.psvId = v }"
|
||||
:set-type="setType"
|
||||
:price-build-mode="version.priceBuildMode"
|
||||
:effective-time="version.effectiveTime"
|
||||
:version-no="version.versionNo"
|
||||
:ref-data="refData"
|
||||
@@ -52,7 +53,7 @@ const refData = usePriceSetRefData()
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const setType = ref('FIXED')
|
||||
const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined })
|
||||
const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined, priceBuildMode: undefined })
|
||||
const details = ref([])
|
||||
const products = ref([])
|
||||
|
||||
@@ -77,6 +78,7 @@ function load() {
|
||||
version.psvId = current.psvId
|
||||
version.versionNo = current.versionNo || 'V1'
|
||||
version.effectiveTime = current.effectiveTime
|
||||
version.priceBuildMode = current.priceBuildMode
|
||||
|
||||
details.value = []
|
||||
products.value = (current.products || []).map(p => ({ ...p }))
|
||||
|
||||
@@ -7,40 +7,6 @@
|
||||
<div class="form-hint">显示给客户的套餐对外名称</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="计价类型" prop="setType">
|
||||
<div class="type-row">
|
||||
<div
|
||||
class="type-card"
|
||||
:class="{ selected: basic.setType === 'FIXED' }"
|
||||
@click="basic.setType = 'FIXED'"
|
||||
>
|
||||
<div class="t-title">FIXED 固定价</div>
|
||||
<div class="t-desc">可不填明细(走产品默认价);有明细则按产品×国家×重量段覆盖运费+挂号费</div>
|
||||
</div>
|
||||
<div
|
||||
class="type-card"
|
||||
:class="{ selected: basic.setType === 'DISCOUNT' }"
|
||||
@click="basic.setType = 'DISCOUNT'"
|
||||
>
|
||||
<div class="t-title">DISCOUNT 折扣</div>
|
||||
<div class="t-desc">按默认等级价统一乘折扣,可选产品范围</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="默认折扣" prop="defaultDiscount">
|
||||
<el-input-number
|
||||
v-model="basic.defaultDiscount"
|
||||
:min="1"
|
||||
:max="99"
|
||||
:disabled="basic.setType !== 'DISCOUNT'"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="unit">%</span>
|
||||
<el-tag v-if="basic.setType !== 'DISCOUNT'" type="warning" size="small" style="margin-left: 8px">仅 DISCOUNT 模式生效</el-tag>
|
||||
<div class="form-hint">范围 1~99,88 表示默认价 × 0.88;FIXED 模式此值忽略</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="说明备注" prop="priceSetNote">
|
||||
<el-input v-model="basic.priceSetNote" type="textarea" :rows="3" maxlength="200" placeholder="套餐业务说明,显示给客户与运营" />
|
||||
</el-form-item>
|
||||
@@ -49,20 +15,15 @@
|
||||
</template>
|
||||
|
||||
<script setup name="StepBasicInfo">
|
||||
/**
|
||||
* 第 1 步 · 基本信息
|
||||
* 直接读写父级传入的 basic 对象(同一引用),校验规则由本组件维护。
|
||||
*/
|
||||
/** Step1 basic info. setType always FIXED on save; priceBuildMode chosen on Step2. */
|
||||
const basic = defineModel({ type: Object, required: true })
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
const rules = {
|
||||
priceSetName: [{ required: true, message: '套餐名称不能为空', trigger: 'blur' }],
|
||||
setType: [{ required: true, message: '请选择计价类型', trigger: 'change' }],
|
||||
}
|
||||
|
||||
/** 供父级在下一步 / 保存前调用 */
|
||||
function validate() {
|
||||
return new Promise(resolve => {
|
||||
if (!formRef.value) return resolve(false)
|
||||
@@ -83,33 +44,4 @@ defineExpose({ validate })
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
.type-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.type-card {
|
||||
flex: 1;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-card.selected {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
.t-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.t-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,24 +6,55 @@
|
||||
{{ headerTitle }}
|
||||
<span class="sub">{{ typeHint }}</span>
|
||||
</span>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" :disabled="!priceSetId || pulling" @click="handlePullLibrary">拉取产品列表</el-button>
|
||||
<div v-if="hasBuildMode" class="card-actions">
|
||||
<el-button
|
||||
v-if="isFixedType(setType)"
|
||||
v-if="showPullAction"
|
||||
size="small"
|
||||
:disabled="(livePersist && !priceSetId) || pulling"
|
||||
@click="handlePullLibrary"
|
||||
>拉取产品列表</el-button>
|
||||
<el-button
|
||||
v-if="showImportAction"
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!priceSetId || pulling"
|
||||
:disabled="(livePersist && !priceSetId) || pulling"
|
||||
@click="openImport"
|
||||
>导入 Excel 建价</el-button>
|
||||
>{{ importActionLabel }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!livePersist" class="build-choice">
|
||||
<p class="build-choice__tip">创建价格方式</p>
|
||||
<div class="build-choice__cards">
|
||||
<button
|
||||
type="button"
|
||||
class="build-card build-card--pull"
|
||||
:class="{ selected: isPullBuildMode(priceBuildMode) }"
|
||||
:disabled="choosingMode"
|
||||
@click="chooseBuildMode('PULL')"
|
||||
>
|
||||
<div class="build-card__title">按产品价建价</div>
|
||||
<div class="build-card__desc">按产品库基础价生成套餐产品列表,可再导入 Excel 修改价格</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="build-card build-card--import"
|
||||
:class="{ selected: isImportBuildMode(priceBuildMode) }"
|
||||
:disabled="!isFixedType(setType) || choosingMode"
|
||||
@click="chooseBuildMode('IMPORT')"
|
||||
>
|
||||
<div class="build-card__title">Excel 导入建价</div>
|
||||
<div class="build-card__desc">上传威狮 B2C 小包价表,直接生成套餐销售价</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<el-input v-model="query.keyword" clearable :placeholder="phKeyword" style="width: 180px" />
|
||||
<el-select v-model="query.taxMode" clearable :placeholder="phTax" style="width: 140px">
|
||||
<!-- <el-select v-model="query.taxMode" clearable :placeholder="phTax" style="width: 140px">
|
||||
<el-option v-for="d in bms_tax_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</el-select> -->
|
||||
<el-button size="small" @click="noop">查询</el-button>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +85,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
|
||||
<surcharge-picker-dialog
|
||||
v-model="scVisible"
|
||||
:title="scTitle"
|
||||
@@ -87,7 +119,7 @@
|
||||
|
||||
<excel-import-dialog
|
||||
ref="importRef"
|
||||
title="导入 Excel 建价"
|
||||
:title="importDialogTitle"
|
||||
width="560px"
|
||||
:show-update-support="false"
|
||||
:import-desc="importDesc"
|
||||
@@ -122,11 +154,13 @@ import {
|
||||
formatWeightLabel,
|
||||
isDiscountType,
|
||||
isFixedType,
|
||||
isImportBuildMode,
|
||||
isPullBuildMode,
|
||||
setTypeLabel,
|
||||
surchargeAppliesToRow,
|
||||
toDetailPayload
|
||||
} from '../../utils/priceSet'
|
||||
import { getPriceSet, listSetDetailByProduct, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
|
||||
import { getPriceSet, listSetDetailByProduct, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, previewPullFromLibrary, previewImportDetailPrices, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
details: { type: Array, required: true },
|
||||
@@ -134,13 +168,16 @@ const props = defineProps({
|
||||
priceSetId: { type: [Number, String], default: undefined },
|
||||
psvId: { type: [Number, String], default: undefined },
|
||||
setType: { type: String, default: 'FIXED' },
|
||||
priceBuildMode: { type: String, default: undefined },
|
||||
effectiveTime: { type: String, default: undefined },
|
||||
versionNo: { type: String, default: '' },
|
||||
refData: { type: Object, required: true },
|
||||
headerTitle: { type: String, default: '第 2 步 · 编辑产品价格' }
|
||||
headerTitle: { type: String, default: '第 2 步 · 编辑产品价格' },
|
||||
/** true=edit live DB; false=create local draft until finish */
|
||||
livePersist: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:psvId'])
|
||||
const emit = defineEmits(['update:psvId', 'update:priceBuildMode'])
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { bms_tax_mode } = useDict('bms_tax_mode')
|
||||
const { hydrateRow } = useDetailCascade(props.refData)
|
||||
@@ -182,14 +219,53 @@ const importProgressPercent = ref(0)
|
||||
const importProgressMessage = ref('')
|
||||
const importProgressStatus = ref(undefined)
|
||||
const progressDialogTitle = ref('正在导入价格')
|
||||
const importDesc = [
|
||||
'适用威狮B2C小包价表(含「产品目录」)。',
|
||||
'按产品+国家+分区(有则匹配)+重量段:命中则更新运费/挂号费,未命中则新增套餐明细。',
|
||||
'仅固定价套餐可导入;可与「拉取产品列表」二选一或叠加使用。'
|
||||
]
|
||||
const hasBuildMode = computed(() => {
|
||||
if (isPullBuildMode(props.priceBuildMode) || isImportBuildMode(props.priceBuildMode)) return true
|
||||
// Legacy / already has products: skip choice cards
|
||||
return (props.products || []).some(p => p && p.productCode)
|
||||
})
|
||||
const choosingMode = ref(false)
|
||||
const isModifyImport = computed(() => {
|
||||
if (isPullBuildMode(props.priceBuildMode)) return true
|
||||
if (isImportBuildMode(props.priceBuildMode)) return false
|
||||
return (props.products || []).some(p => p && p.productCode)
|
||||
})
|
||||
const importActionLabel = computed(() =>
|
||||
isModifyImport.value ? '导入修改价格' : '导入 Excel 建价'
|
||||
)
|
||||
const importDialogTitle = computed(() => importActionLabel.value)
|
||||
const importDesc = computed(() => {
|
||||
if (isModifyImport.value) {
|
||||
return [
|
||||
'适用威狮B2C小包价表(含「产品目录」)。',
|
||||
'按产品+国家+分区(有则匹配)+重量段匹配已有套餐明细,仅更新运费/挂号费。',
|
||||
'仅固定价套餐可导入;未匹配行不会新增明细。'
|
||||
]
|
||||
}
|
||||
return [
|
||||
'适用威狮B2C小包价表(含「产品目录」)。',
|
||||
'按产品+国家+分区(有则匹配)+重量段:命中则更新运费/挂号费,未命中则新增套餐明细。',
|
||||
'仅固定价套餐可导入。'
|
||||
]
|
||||
})
|
||||
|
||||
const typeHint = computed(() => '· 当前:' + setTypeLabel(props.setType))
|
||||
const emptyTip = '暂无产品,请先拉取产品列表或导入 Excel 建价'
|
||||
const typeHint = computed(() => {
|
||||
if (props.livePersist) return ''
|
||||
if (isPullBuildMode(props.priceBuildMode)) return '· 按产品价建价'
|
||||
if (isImportBuildMode(props.priceBuildMode)) return '· Excel 导入'
|
||||
if (hasBuildMode.value) return '· 当前:' + setTypeLabel(props.setType)
|
||||
return '· 请选择创建价格方式'
|
||||
})
|
||||
// PULL: hide pull btn (already pulled), show modify-import.
|
||||
// IMPORT: show create-import.
|
||||
const showPullAction = computed(() => false)
|
||||
const showImportAction = computed(() => hasBuildMode.value && isFixedType(props.setType))
|
||||
const emptyTip = computed(() => {
|
||||
if (props.livePersist) return '暂无产品'
|
||||
if (isPullBuildMode(props.priceBuildMode)) return '暂无产品,请先返回上一步重新拉取或等待拉取完成'
|
||||
if (isImportBuildMode(props.priceBuildMode)) return '暂无产品,请点击右上角导入 Excel 建价'
|
||||
return '暂无产品,请先选择创建价格方式'
|
||||
})
|
||||
|
||||
|
||||
/** Resolve version id: prop first, else reload package current version. */
|
||||
@@ -220,9 +296,30 @@ function clearDetailCache() {
|
||||
Object.keys(detailByProduct).forEach(k => delete detailByProduct[k])
|
||||
}
|
||||
|
||||
/** Clear local product/detail draft when create wizard switches build mode. */
|
||||
function clearLocalDraft() {
|
||||
clearDetailCache()
|
||||
if (Array.isArray(props.details)) {
|
||||
props.details.splice(0, props.details.length)
|
||||
}
|
||||
if (Array.isArray(props.products)) {
|
||||
props.products.splice(0, props.products.length)
|
||||
}
|
||||
priceVisible.value = false
|
||||
editCode.value = ''
|
||||
editName.value = ''
|
||||
editRows.value = []
|
||||
editGroup.value = null
|
||||
}
|
||||
|
||||
async function loadProductDetails(productCode, force) {
|
||||
if (!productCode) return []
|
||||
if (!force && detailByProduct[productCode]) return detailByProduct[productCode]
|
||||
if (!props.livePersist) {
|
||||
const rows = (props.details || []).filter(d => d && d.productCode === productCode)
|
||||
detailByProduct[productCode] = rows
|
||||
return rows
|
||||
}
|
||||
const psvId = await resolvePsvId(false)
|
||||
if (!psvId) return []
|
||||
const res = await listSetDetailByProduct(psvId, productCode)
|
||||
@@ -264,8 +361,46 @@ const pulling = ref(false)
|
||||
|
||||
function noop() {}
|
||||
|
||||
async function chooseBuildMode(mode) {
|
||||
if (!mode || choosingMode.value || props.livePersist) return
|
||||
if (mode === props.priceBuildMode) return
|
||||
choosingMode.value = true
|
||||
try {
|
||||
emit('update:priceBuildMode', mode)
|
||||
} finally {
|
||||
choosingMode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let localKeySeq = 0
|
||||
|
||||
/** Apply preview products/details into parent reactive arrays (create defer mode). */
|
||||
function applyLocalDraft(products, details) {
|
||||
clearDetailCache()
|
||||
const hydrated = (details || []).map(d => {
|
||||
const row = hydrateRow({ ...d }, props.setType)
|
||||
row.psdId = undefined
|
||||
row.pspId = undefined
|
||||
row.priceSetId = undefined
|
||||
row.psvId = undefined
|
||||
row._localKey = 'L' + (++localKeySeq)
|
||||
return row
|
||||
})
|
||||
if (Array.isArray(props.details)) {
|
||||
props.details.splice(0, props.details.length, ...hydrated)
|
||||
}
|
||||
if (Array.isArray(props.products)) {
|
||||
props.products.splice(0, props.products.length, ...((products || []).map(p => ({ ...p }))))
|
||||
}
|
||||
hydrated.forEach(d => {
|
||||
if (!d || !d.productCode) return
|
||||
if (!detailByProduct[d.productCode]) detailByProduct[d.productCode] = []
|
||||
detailByProduct[d.productCode].push(d)
|
||||
})
|
||||
}
|
||||
|
||||
async function openImport() {
|
||||
if (!props.priceSetId) {
|
||||
if (props.livePersist && !props.priceSetId) {
|
||||
proxy.$modal.msgWarning('缺少套餐ID')
|
||||
return
|
||||
}
|
||||
@@ -273,15 +408,16 @@ async function openImport() {
|
||||
proxy.$modal.msgWarning('仅固定价套餐支持导入')
|
||||
return
|
||||
}
|
||||
const psvId = await resolvePsvId(true)
|
||||
if (!psvId) {
|
||||
proxy.$modal.msgWarning('缺少套餐版本,请先保存基本信息')
|
||||
return
|
||||
if (props.livePersist) {
|
||||
const psvId = await resolvePsvId(true)
|
||||
if (!psvId) {
|
||||
proxy.$modal.msgWarning('缺少套餐版本,请先保存基本信息')
|
||||
return
|
||||
}
|
||||
}
|
||||
importRef.value && importRef.value.open()
|
||||
}
|
||||
|
||||
|
||||
async function runImportAsync(uploadFile) {
|
||||
const raw = uploadFile && (uploadFile.raw || uploadFile)
|
||||
if (!raw) {
|
||||
@@ -293,9 +429,23 @@ async function runImportAsync(uploadFile) {
|
||||
importProgressMessage.value = '上传并解析中…'
|
||||
importProgressStatus.value = undefined
|
||||
try {
|
||||
if (!props.livePersist) {
|
||||
importProgressMessage.value = '正在解析价表…'
|
||||
importProgressPercent.value = 30
|
||||
const res = await previewImportDetailPrices(raw, {
|
||||
updateOnly: isModifyImport.value,
|
||||
details: isModifyImport.value ? (props.details || []) : undefined
|
||||
})
|
||||
const data = res.data || {}
|
||||
const products = isModifyImport.value ? (props.products || []) : (data.products || [])
|
||||
applyLocalDraft(products, data.details || [])
|
||||
importProgressPercent.value = 100
|
||||
importProgressStatus.value = 'success'
|
||||
return data.message || '导入完成'
|
||||
}
|
||||
const { task } = await importSetDetailPricesAsync(
|
||||
raw,
|
||||
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false) },
|
||||
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false), updateOnly: isModifyImport.value },
|
||||
applyBuildProgress
|
||||
)
|
||||
importProgressPercent.value = 100
|
||||
@@ -337,6 +487,7 @@ function applyBuildProgress(t) {
|
||||
}
|
||||
|
||||
async function onImportSuccess() {
|
||||
if (!props.livePersist) return
|
||||
await reloadDetailsFromServer()
|
||||
}
|
||||
|
||||
@@ -370,7 +521,7 @@ async function reloadDetailsFromServer() {
|
||||
}
|
||||
|
||||
async function handlePullLibrary() {
|
||||
if (!props.priceSetId) {
|
||||
if (props.livePersist && !props.priceSetId) {
|
||||
proxy.$modal.msgWarning('缺少套餐ID')
|
||||
return
|
||||
}
|
||||
@@ -387,11 +538,11 @@ async function handlePullLibrary() {
|
||||
await pullAllFromLibrary({ force, silent: false })
|
||||
}
|
||||
|
||||
/** Backend pull product/product_country into prc_price_set_detail (async). */
|
||||
/** Pull product library: live -> async DB; create defer -> preview into local state. */
|
||||
async function pullAllFromLibrary(opts = {}) {
|
||||
const silent = !!opts.silent
|
||||
const force = !!opts.force || !!opts.replace
|
||||
if (!props.priceSetId) {
|
||||
if (props.livePersist && !props.priceSetId) {
|
||||
if (!silent) proxy.$modal.msgWarning('缺少套餐ID')
|
||||
return { added: 0 }
|
||||
}
|
||||
@@ -402,6 +553,19 @@ async function pullAllFromLibrary(opts = {}) {
|
||||
importProgressStatus.value = undefined
|
||||
pulling.value = true
|
||||
try {
|
||||
if (!props.livePersist) {
|
||||
importProgressMessage.value = '正在拉取产品库…'
|
||||
importProgressPercent.value = 40
|
||||
const res = await previewPullFromLibrary()
|
||||
const data = res.data || {}
|
||||
applyLocalDraft(data.products || [], data.details || [])
|
||||
importProgressPercent.value = 100
|
||||
importProgressStatus.value = 'success'
|
||||
if (!silent) {
|
||||
proxy.$modal.msgSuccess(data.message || '拉取完成')
|
||||
}
|
||||
return { added: (props.products || []).length }
|
||||
}
|
||||
const { task } = await pullSetDetailsFromLibraryAsync(
|
||||
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false), force },
|
||||
applyBuildProgress
|
||||
@@ -495,8 +659,9 @@ function openSurchargeFromDrawer(row) {
|
||||
}
|
||||
|
||||
|
||||
/** persist one detail row to DB immediately */
|
||||
/** persist one detail row to DB immediately (edit mode only) */
|
||||
async function persistDetail(row, withSurcharge) {
|
||||
if (!props.livePersist) return
|
||||
if (!row || !row.psdId || !props.priceSetId) return
|
||||
const payload = toDetailPayload(row)
|
||||
payload.priceSetId = props.priceSetId
|
||||
@@ -506,7 +671,12 @@ async function persistDetail(row, withSurcharge) {
|
||||
|
||||
/** drawer hover remove surcharge -> write snapshot for this row */
|
||||
async function onRowSurchargeChange(row) {
|
||||
if (!row || !row.psdId) return
|
||||
if (!row) return
|
||||
if (!props.livePersist) {
|
||||
proxy.$modal.msgSuccess('附加费已移除(完成时统一落库)')
|
||||
return
|
||||
}
|
||||
if (!row.psdId) return
|
||||
try {
|
||||
await persistDetail(row, true)
|
||||
proxy.$modal.msgSuccess('附加费已移除')
|
||||
@@ -515,9 +685,14 @@ async function onRowSurchargeChange(row) {
|
||||
}
|
||||
}
|
||||
|
||||
/** drawer field change -> immediate DB update */
|
||||
/** drawer field change -> immediate DB update (edit) or local only (create) */
|
||||
async function onRowPriceChange(row) {
|
||||
if (!row || !row.psdId) return
|
||||
if (!row) return
|
||||
if (!props.livePersist) {
|
||||
proxy.$modal.msgSuccess('价格已更新(完成时统一落库)')
|
||||
return
|
||||
}
|
||||
if (!row.psdId) return
|
||||
const prevFv = row._editPrevFv
|
||||
const prevReg = row._editPrevReg
|
||||
try {
|
||||
@@ -533,10 +708,14 @@ async function saveDiscount() {
|
||||
const hit = (props.details || []).find(d => d.productCode === discCode.value)
|
||||
if (hit) {
|
||||
hit.discountValue = discValue.value
|
||||
try {
|
||||
await persistDetail(hit, false)
|
||||
proxy.$modal.msgSuccess('折扣已写入数据库')
|
||||
} catch (e) {}
|
||||
if (!props.livePersist) {
|
||||
proxy.$modal.msgSuccess('折扣已更新(完成时统一落库)')
|
||||
} else {
|
||||
try {
|
||||
await persistDetail(hit, false)
|
||||
proxy.$modal.msgSuccess('折扣已写入数据库')
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
discVisible.value = false
|
||||
}
|
||||
@@ -640,11 +819,15 @@ function onSurchargeConfirm(picked) {
|
||||
? `已纳入 ${applied} 项附加费${scopeText};${skipped} 项因适用国家不匹配被跳过`
|
||||
: `已纳入 ${ids.size} 项附加费${scopeText}`)
|
||||
: `已清空附加费${scopeText}`
|
||||
scVisible.value = false
|
||||
if (!props.livePersist) {
|
||||
proxy.$modal.msgSuccess(okText + '(完成时统一落库)')
|
||||
return
|
||||
}
|
||||
const payload = targets.filter(r => r.psdId).map(r => toDetailPayload(r)).map(p => {
|
||||
p.priceSetId = props.priceSetId
|
||||
return p
|
||||
})
|
||||
scVisible.value = false
|
||||
if (!payload.length) {
|
||||
proxy.$modal.msgSuccess(okText)
|
||||
return
|
||||
@@ -658,14 +841,16 @@ function onSurchargeConfirm(picked) {
|
||||
}
|
||||
|
||||
async function removeProduct(code) {
|
||||
try {
|
||||
const rows = await loadProductDetails(code)
|
||||
const ids = rows.filter(d => d && d.psdId).map(d => d.psdId)
|
||||
if (ids.length) {
|
||||
await delSetDetail(ids.join(','))
|
||||
if (props.livePersist) {
|
||||
try {
|
||||
const rows = await loadProductDetails(code)
|
||||
const ids = rows.filter(d => d && d.psdId).map(d => d.psdId)
|
||||
if (ids.length) {
|
||||
await delSetDetail(ids.join(','))
|
||||
}
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
delete detailByProduct[code]
|
||||
if (Array.isArray(props.details)) {
|
||||
@@ -681,7 +866,7 @@ async function removeProduct(code) {
|
||||
proxy.$modal.msgSuccess('已移除产品明细')
|
||||
}
|
||||
|
||||
defineExpose({ pullAllFromLibrary })
|
||||
defineExpose({ pullAllFromLibrary, clearLocalDraft })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -707,6 +892,60 @@ defineExpose({ pullAllFromLibrary })
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.build-choice {
|
||||
padding: 8px 0 16px;
|
||||
text-align: left;
|
||||
}
|
||||
.build-choice__tip {
|
||||
margin: 0 0 12px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.build-choice__cards {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.build-card {
|
||||
width: 260px;
|
||||
min-height: 88px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
.build-card.selected {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
.build-card:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.build-card:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.build-card--pull:hover:not(:disabled) { border-color: #67c23a; }
|
||||
.build-card--import:hover:not(:disabled) { border-color: #409eff; }
|
||||
.build-card__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.build-card--pull .build-card__title { color: #67c23a; }
|
||||
.build-card--import .build-card__title { color: #409eff; }
|
||||
.build-card__desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #909399;
|
||||
}
|
||||
.card-actions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
:psv-id="form.version.psvId"
|
||||
@update:psv-id="onPsvIdUpdate"
|
||||
:set-type="form.basic.setType"
|
||||
:price-build-mode="form.version.priceBuildMode"
|
||||
@update:price-build-mode="onPriceBuildModeUpdate"
|
||||
:version-no="form.version.versionNo"
|
||||
:ref-data="refData"
|
||||
:live-persist="livePersist"
|
||||
/>
|
||||
|
||||
<div class="wizard-actions">
|
||||
@@ -27,7 +30,7 @@
|
||||
<el-button v-if="active > 0 && !directStep2" @click="active--">上一步</el-button>
|
||||
<el-button v-if="active === 0" type="primary" :loading="creating" @click="goStep2">创建套餐价格</el-button>
|
||||
<el-button v-if="active === 0" type="info" :loading="saving" @click="saveDraft">保存</el-button>
|
||||
<el-button v-if="active === 1" type="primary" @click="finishEdit">完成</el-button>
|
||||
<el-button v-if="active === 1" type="primary" :loading="creating" @click="finishEdit">完成</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
@@ -77,6 +80,8 @@ const detailsRef = ref()
|
||||
const copyMode = computed(() => String(route.query.copyVersion || '') === '1')
|
||||
/** Entered from detail: continue-config / copy-version jump to step2 — hide 上一步 */
|
||||
const directStep2 = computed(() => copyMode.value || String(route.query.step || '') === '2')
|
||||
/** Edit existing version: live PUT/pull/import. New create / copy-version: defer until finish. */
|
||||
const livePersist = computed(() => !!form.priceSetId && !copyMode.value)
|
||||
|
||||
const step1Title = '基本信息'
|
||||
const step2Title = '编辑产品价格'
|
||||
@@ -84,7 +89,7 @@ const step2Title = '编辑产品价格'
|
||||
const form = reactive({
|
||||
priceSetId: undefined,
|
||||
basic: createEmptyBasicInfo(),
|
||||
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined },
|
||||
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined, priceBuildMode: undefined },
|
||||
details: [],
|
||||
products: [],
|
||||
customers: []
|
||||
@@ -142,45 +147,65 @@ async function runSaveWithProgress(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Step1 -> Step2: create empty draft version only; products/details persist on pull or Excel import */
|
||||
/** Step1 -> Step2: validate only; no DB write on create (defer until finish). */
|
||||
async function goStep2() {
|
||||
const validBasic = await basicRef.value.validate()
|
||||
if (!validBasic) return
|
||||
|
||||
if (form.priceSetId && form.version.psvId) {
|
||||
active.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
// Never auto-pull product library on enter Step2 (even if old clients sent the flag).
|
||||
const payload = buildPayload()
|
||||
delete payload.pullFromLibrary
|
||||
delete payload.forcePullLibrary
|
||||
delete payload.details
|
||||
const priceSetId = await runSaveWithProgress(payload)
|
||||
form.priceSetId = priceSetId
|
||||
const fresh = await getPriceSet(priceSetId)
|
||||
applySetData(fresh.data || {}, false)
|
||||
if (!(form.products || []).some(p => p && p.productCode) && !form.version.priceBuildMode) {
|
||||
form.details = []
|
||||
form.products = []
|
||||
if (!form.version.psvId) {
|
||||
const versions = (fresh.data && fresh.data.versions) || []
|
||||
const v = (fresh.data && fresh.data.version && fresh.data.version.psvId)
|
||||
? fresh.data.version
|
||||
: versions[0]
|
||||
if (v && v.psvId) {
|
||||
form.version.psvId = v.psvId
|
||||
form.version.versionNo = v.versionNo || form.version.versionNo
|
||||
}
|
||||
}
|
||||
if (!form.version.psvId) {
|
||||
proxy.$modal.msgError('套餐版本创建失败,请重试')
|
||||
}
|
||||
active.value = 1
|
||||
}
|
||||
|
||||
/** Step2 finish: create/copy async save all; edit already live-saved. */
|
||||
async function finishEdit() {
|
||||
if (livePersist.value) {
|
||||
if (!form.priceSetId) {
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
active.value = 1
|
||||
proxy.$modal.msgSuccess('已创建空套餐版本,请在第 2 步拉取或导入价格')
|
||||
proxy.$modal.msgSuccess('修改已实时落库')
|
||||
const obj = {
|
||||
path: '/customer/setDetail',
|
||||
query: { priceSetId: form.priceSetId }
|
||||
}
|
||||
if (proxy.$tab) {
|
||||
proxy.$tab.closeOpenPage(obj)
|
||||
} else {
|
||||
proxy.$router.push(obj)
|
||||
}
|
||||
return
|
||||
}
|
||||
const validBasic = await basicRef.value.validate()
|
||||
if (!validBasic) {
|
||||
active.value = 0
|
||||
return
|
||||
}
|
||||
if (!form.version.priceBuildMode) {
|
||||
proxy.$modal.msgWarning('请先选择创建价格方式')
|
||||
return
|
||||
}
|
||||
const detailRows = (form.details || []).filter(d => d && d.productCode)
|
||||
if (!detailRows.length) {
|
||||
proxy.$modal.msgWarning('请先拉取产品或导入 Excel 建价')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const payload = buildPayload({ forceDetails: true })
|
||||
const priceSetId = await runSaveWithProgress(payload)
|
||||
form.priceSetId = priceSetId
|
||||
proxy.$modal.msgSuccess('套餐已保存')
|
||||
const obj = {
|
||||
path: '/customer/setDetail',
|
||||
query: { priceSetId }
|
||||
}
|
||||
if (proxy.$tab) {
|
||||
proxy.$tab.closeOpenPage(obj)
|
||||
} else {
|
||||
proxy.$router.push(obj)
|
||||
}
|
||||
} catch (e) {
|
||||
// surfaced
|
||||
} finally {
|
||||
@@ -188,24 +213,6 @@ async function goStep2() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Step2: prices already live-saved via PUT /detail; go to detail page. */
|
||||
function finishEdit() {
|
||||
if (!form.priceSetId) {
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
proxy.$modal.msgSuccess('修改已实时落库')
|
||||
const obj = {
|
||||
path: '/customer/setDetail',
|
||||
query: { priceSetId: form.priceSetId }
|
||||
}
|
||||
if (proxy.$tab) {
|
||||
proxy.$tab.closeOpenPage(obj)
|
||||
} else {
|
||||
proxy.$router.push(obj)
|
||||
}
|
||||
}
|
||||
|
||||
/** Step0 only: persist basic draft (no detail wipe). */
|
||||
async function saveDraft() {
|
||||
const validBasic = await basicRef.value.validate()
|
||||
@@ -226,27 +233,36 @@ async function saveDraft() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
function buildPayload(opts = {}) {
|
||||
const { basic, version } = form
|
||||
const detailRows = form.details.filter(d => d && d.productCode)
|
||||
// Price edits already persist via PUT /detail; product remove uses DELETE.
|
||||
// Omit details when all rows already have psdId so save only updates header/version.
|
||||
// Edit live-save: omit details when all rows already have psdId.
|
||||
// Create finish: forceDetails sends all rows (psdId cleared) for insert.
|
||||
const hasUnsavedDetail = detailRows.some(d => d.psdId == null)
|
||||
const payload = {
|
||||
priceSetId: form.priceSetId,
|
||||
priceSetName: basic.priceSetName,
|
||||
setType: basic.setType,
|
||||
defaultDiscount: basic.setType === 'DISCOUNT' ? basic.defaultDiscount : undefined,
|
||||
setType: 'FIXED',
|
||||
defaultDiscount: undefined,
|
||||
priceSetNote: basic.priceSetNote,
|
||||
version: {
|
||||
psvId: copyMode.value ? undefined : version.psvId,
|
||||
versionNo: version.versionNo
|
||||
versionNo: version.versionNo,
|
||||
priceBuildMode: version.priceBuildMode || undefined
|
||||
}
|
||||
}
|
||||
if (hasUnsavedDetail) {
|
||||
payload.details = detailRows.map(toDetailPayload)
|
||||
if (opts.forceDetails || hasUnsavedDetail) {
|
||||
payload.details = detailRows.map(d => {
|
||||
const p = toDetailPayload(d)
|
||||
if (opts.forceDetails) {
|
||||
delete p.psdId
|
||||
delete p.pspId
|
||||
delete p.priceSetId
|
||||
delete p.psvId
|
||||
}
|
||||
return p
|
||||
})
|
||||
}
|
||||
// Customer bind is managed on detail page APIs; only send when present to avoid wipe.
|
||||
const customers = form.customers.filter(c => c && c.customerCode).map(toCustomerPayload)
|
||||
if (customers.length) {
|
||||
payload.customers = customers
|
||||
@@ -267,11 +283,50 @@ function onPsvIdUpdate(psvId) {
|
||||
if (psvId != null && psvId !== '') form.version.psvId = psvId
|
||||
}
|
||||
|
||||
/** Step2 chose PULL / IMPORT: edit live-saves mode; create only local + optional preview pull. */
|
||||
async function onPriceBuildModeUpdate(mode) {
|
||||
if (!mode) return
|
||||
const prev = form.version.priceBuildMode
|
||||
if (prev === mode) return
|
||||
form.version.priceBuildMode = mode
|
||||
try {
|
||||
// Create/copy: switching build mode clears the product table before reload/import.
|
||||
if (!livePersist.value && prev) {
|
||||
form.details.splice(0, form.details.length)
|
||||
form.products.splice(0, form.products.length)
|
||||
if (detailsRef.value && detailsRef.value.clearLocalDraft) {
|
||||
detailsRef.value.clearLocalDraft()
|
||||
}
|
||||
}
|
||||
if (livePersist.value) {
|
||||
const payload = buildPayload()
|
||||
delete payload.pullFromLibrary
|
||||
delete payload.forcePullLibrary
|
||||
delete payload.details
|
||||
await runSaveWithProgress(payload)
|
||||
}
|
||||
await nextTick()
|
||||
if (mode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
|
||||
const hasProducts = (form.products || []).some(p => p && p.productCode)
|
||||
if (!hasProducts) {
|
||||
await detailsRef.value.pullAllFromLibrary({ silent: true })
|
||||
proxy.$modal.msgSuccess('已拉取产品列表,可继续编辑价格')
|
||||
} else if (prev !== mode) {
|
||||
proxy.$modal.msgSuccess('已切换为按产品价建价,可导入 Excel 修改价格')
|
||||
}
|
||||
} else if (mode === 'IMPORT' && prev !== mode) {
|
||||
proxy.$modal.msgSuccess('请导入 Excel 建价')
|
||||
}
|
||||
} catch (e) {
|
||||
form.version.priceBuildMode = prev
|
||||
}
|
||||
}
|
||||
|
||||
function applySetData(data, asCopy) {
|
||||
form.priceSetId = data.priceSetId
|
||||
Object.assign(form.basic, {
|
||||
priceSetName: data.priceSetName,
|
||||
setType: data.setType || 'FIXED',
|
||||
setType: 'FIXED',
|
||||
defaultDiscount: data.defaultDiscount != null ? Number(data.defaultDiscount) : 88,
|
||||
priceSetNote: data.priceSetNote
|
||||
})
|
||||
@@ -285,11 +340,13 @@ function applySetData(data, asCopy) {
|
||||
form.version.versionNo = nextVersionNo(versions)
|
||||
form.version.effectiveTime = undefined
|
||||
form.version.expireTime = undefined
|
||||
form.version.priceBuildMode = undefined
|
||||
} else {
|
||||
form.version.psvId = current.psvId
|
||||
form.version.versionNo = current.versionNo || nextVersionNo(versions)
|
||||
form.version.effectiveTime = current.effectiveTime
|
||||
form.version.expireTime = current.expireTime
|
||||
form.version.priceBuildMode = current.priceBuildMode || undefined
|
||||
}
|
||||
form.details = []
|
||||
form.products = asCopy ? [] : ((current.products || []).map(p => ({ ...p })))
|
||||
|
||||
@@ -12,6 +12,20 @@ export const SET_TYPE = {
|
||||
DISCOUNT: 'DISCOUNT'
|
||||
}
|
||||
|
||||
/** Version create-price mode */
|
||||
export const PRICE_BUILD_MODE = {
|
||||
PULL: 'PULL',
|
||||
IMPORT: 'IMPORT'
|
||||
}
|
||||
|
||||
export function isPullBuildMode(mode) {
|
||||
return mode === PRICE_BUILD_MODE.PULL
|
||||
}
|
||||
|
||||
export function isImportBuildMode(mode) {
|
||||
return mode === PRICE_BUILD_MODE.IMPORT
|
||||
}
|
||||
|
||||
/** 套餐状态枚举:D 待启用 / A 审核中 / E 生效 / R 驳回 / S 已停用 */
|
||||
export const SET_STATUS = {
|
||||
DRAFT: 'D',
|
||||
@@ -359,6 +373,7 @@ export function createEmptyBasicInfo() {
|
||||
priceSetName: '',
|
||||
setType: SET_TYPE.FIXED,
|
||||
defaultDiscount: 88,
|
||||
priceBuildMode: undefined,
|
||||
effectiveDate: undefined,
|
||||
expireDate: undefined,
|
||||
priceSetNote: ''
|
||||
|
||||
Reference in New Issue
Block a user