修改产品套餐

This commit is contained in:
wuweihua
2026-09-29 23:07:38 +08:00
parent 38f371742a
commit 52d0a13d83
25 changed files with 1229 additions and 351 deletions
@@ -1,5 +1,6 @@
package com.bms.web.controller.price;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -18,6 +19,8 @@ import com.bms.common.core.controller.BaseController;
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.DateUtils;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.PrcOrderBill;
import com.bms.price.domain.PrcQuote;
import com.bms.price.domain.vo.AddIncomeBillFeeRequest;
@@ -55,6 +58,22 @@ public class BillingController extends BaseController
return getDataTable(list);
}
/**
* Products of the customer's bound effective package version (trial product dropdown).
*/
@PreAuthorize("@ss.hasPermi('price:billing:quote')")
@GetMapping("/quote/products")
public AjaxResult boundProducts(@RequestParam("customerCode") String customerCode,
@RequestParam(value = "bizDate", required = false) String bizDate)
{
Date asOf = null;
if (StringUtils.isNotEmpty(bizDate))
{
asOf = DateUtils.parseDate(bizDate);
}
return success(tmsPriceQuoteService.listBoundPackageProducts(customerCode, asOf));
}
/**
* Historical quote detail with fee lines.
*/
@@ -193,6 +193,15 @@ public class PriceSetController extends BaseController
return getDataTable(list);
}
/** All detail rows for one product (no page); used by edit-price drawer. */
@PreAuthorize("@ss.hasPermi('price:set:list')")
@GetMapping("/detail/byProduct")
public AjaxResult detailByProduct(@RequestParam("psvId") Long psvId,
@RequestParam("productCode") String productCode)
{
return success(priceSetService.selectDetailListByProduct(psvId, productCode));
}
/**
* 版本明细搜索下拉选项:按版本去重后的 产品/国家/分区
*/
@@ -232,6 +241,22 @@ public class PriceSetController extends BaseController
return success(payload);
}
/**
* Async pull product library into version details. Returns taskId; poll /save/task/{taskId}.
*/
@PreAuthorize("@ss.hasPermi('price:set:edit') or @ss.hasPermi('price:set:forceEdit')")
@Log(title = "\u5957\u9910\u62c9\u53d6\u4ea7\u54c1", businessType = BusinessType.IMPORT)
@PostMapping("/detail/pullFromLibrary")
public AjaxResult pullFromLibrary(@RequestParam("priceSetId") Long priceSetId,
@RequestParam(value = "psvId", required = false) Long psvId,
@RequestParam(value = "force", required = false, defaultValue = "false") boolean force)
{
String taskId = priceSetService.submitPullFromLibraryAsync(priceSetId, psvId, force, getUsername());
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/**
* Async batch write of detail surcharge snapshots. Returns taskId; poll /save/task/{taskId}.
*/
@@ -78,6 +78,8 @@ price.set.import.fixedOnly=\u4ec5\u56fa\u5b9a\u4ef7\u5957\u9910\u652f\u6301\u5bf
price.set.import.noDetails=\u5957\u9910\u6682\u65e0\u4ef7\u683c\u660e\u7ec6\uff0c\u8bf7\u5148\u62c9\u53d6\u4ea7\u54c1\u4ef7
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.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.pull.success=\u62c9\u53d6\u5b8c\u6210\uff1a\u5df2\u5199\u5165 {0} \u6761\u5957\u9910\u660e\u7ec6
price.set.customer.notExists=客户绑定不存在
price.set.customer.unbindAudit=审核中的套餐不允许解绑客户
price.set.customer.customerId.empty=客户ID不能为空
@@ -17,6 +17,8 @@ public class PrcPriceSetDetail implements Serializable
private Long priceSetId;
/** prc_price_set_version.psv_id */
private Long psvId;
/** prc_price_set_product.psp_id */
private Long pspId;
private String productCode;
private String productName;
private String countryCode;
@@ -0,0 +1,44 @@
package com.bms.price.domain;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/**
* 价格套餐产品主表 prc_price_set_product(与产品库快照独立)
*/
@Data
public class PrcPriceSetProduct implements Serializable
{
private static final long serialVersionUID = 1L;
/** 套餐产品主表id */
private Long pspId;
/** 价格套餐id */
private Long priceSetId;
/** 套餐版本id */
private Long psvId;
/** 产品对接代码 */
private String productCode;
/** 产品名称 */
private String productName;
/** 时效等级 */
private String serviceLevel;
/** 走货属性 */
private String cargoAttr;
/** 目的区域 */
private String destRegion;
/** 更新说明 */
private String updateNote;
/** TAX_INCLUDED / TAX_EXCLUDED */
private String taxMode;
/** 来源产品库 product.product_id */
private Long sourceProductId;
/** 来源Excel sheet */
private String sourceSheet;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
@@ -28,5 +28,6 @@ public class PrcPriceSetVersion extends BaseEntity
private String status;
private List<PrcPriceSetDetail> details;
private List<PrcPriceSetProduct> products;
private List<PrcPriceSetVersionSurcharge> surcharges;
}
@@ -12,6 +12,9 @@ public interface PrcPriceSetMapper
{
public PrcPriceSet selectPrcPriceSetById(Long priceSetId);
/** Header only (no product/customer count subqueries). */
public PrcPriceSet selectPrcPriceSetHeaderById(Long priceSetId);
public List<PrcPriceSet> selectPrcPriceSetList(PrcPriceSet set);
public int insertPrcPriceSet(PrcPriceSet set);
@@ -0,0 +1,34 @@
package com.bms.price.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.price.domain.PrcPriceSetProduct;
/**
* Package intermediate product mapper
*/
public interface PrcPriceSetProductMapper
{
public PrcPriceSetProduct selectPrcPriceSetProductById(Long pspId);
public List<PrcPriceSetProduct> selectPrcPriceSetProductList(PrcPriceSetProduct query);
public PrcPriceSetProduct selectByPsvIdAndCode(@Param("psvId") Long psvId, @Param("productCode") String productCode);
public int insertPrcPriceSetProduct(PrcPriceSetProduct row);
public int updatePrcPriceSetProduct(PrcPriceSetProduct row);
public int deleteByPsvId(@Param("psvId") Long psvId);
public int deleteByPsvIdAndCode(@Param("psvId") Long psvId, @Param("productCode") String productCode);
/** Copy active library products into this version (ignore duplicates). */
public int insertFromLibrary(@Param("priceSetId") Long priceSetId, @Param("psvId") Long psvId);
/** Copy set products from one version to another. */
public int copyFromVersion(@Param("srcPsvId") Long srcPsvId, @Param("newPsvId") Long newPsvId);
/** Bind detail.psp_id from set product by psv + product_code. */
public int bindDetailPspByPsvId(@Param("psvId") Long psvId);
}
@@ -60,6 +60,11 @@ public interface IPrcPriceSetService
public List<PrcPriceSetDetail> selectDetailList(PrcPriceSetDetail detail);
/**
* Load all detail rows for one product under a version (no pagination).
*/
public List<PrcPriceSetDetail> selectDetailListByProduct(Long psvId, String productCode);
/**
* 版本明细搜索下拉选项:按版本去重后的 产品/国家/分区 列表。
* 返回 { products: [{code,name}], countries: [{code,name}], zones: [{code,name}] }
@@ -105,11 +110,20 @@ public interface IPrcPriceSetService
public String submitImportDetailPricesAsync(Long priceSetId, Long psvId, MultipartFile file, String operName) throws Exception;
/**
* Sync worker: parse bytes and update FIXED detail prices matched by
* product + country + zone(if any) + weight band.
* Sync worker: parse bytes and upsert FIXED detail prices matched by
* product + country + zone(if any) + weight band (insert when missing).
*/
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;
/**
* Submit async pull of product library into version details.
* @return taskId for polling getSaveTask
*/
public String submitPullFromLibraryAsync(Long priceSetId, Long psvId, boolean force, String operName);
/** Sync worker: replace (or skip) version details from product / product_country. */
public String pullFromLibrary(Long priceSetId, Long psvId, boolean force, String operName, String taskId);
}
@@ -1,8 +1,10 @@
package com.bms.price.service;
import com.bms.price.domain.PrcPriceSetProduct;
import com.bms.price.domain.PrcQuote;
import com.bms.price.domain.vo.TmsPriceQuoteRequest;
import com.bms.price.domain.vo.TmsPriceQuoteResult;
import java.util.Date;
import java.util.List;
/**
@@ -27,4 +29,11 @@ public interface ITmsPriceQuoteService
List<PrcQuote> selectQuoteList(PrcQuote query);
TmsPriceQuoteResult getQuote(String quoteId);
/**
* Products under the customer's bound effective package version (for trial dropdown).
* @param customerCode required
* @param bizDate optional; default today (same as-of window as quote)
*/
List<PrcPriceSetProduct> listBoundPackageProducts(String customerCode, Date bizDate);
}
@@ -22,20 +22,24 @@ import com.bms.common.utils.DateUtils;
import com.bms.common.utils.MessageUtils;
import com.bms.common.utils.StringUtils;
import com.bms.common.utils.uuid.IdUtils;
import com.bms.price.domain.BmsProduct;
import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetCheckItem;
import com.bms.price.domain.PrcPriceSetDetail;
import com.bms.price.domain.PrcPriceSetProduct;
import com.bms.price.domain.PrcPriceSetSaveTask;
import com.bms.price.domain.PrcPriceSetVersion;
import com.bms.price.domain.PrcPriceSetVersionSurcharge;
import com.bms.price.domain.PrcSurcharge;
import com.bms.price.domain.PrcSurchargeRel;
import com.bms.price.mapper.BmsProductCountryMapper;
import com.bms.price.mapper.BmsProductMapper;
import com.bms.price.mapper.PrcCustomerPriceSetMapper;
import com.bms.price.mapper.PrcPriceSetDetailMapper;
import com.bms.price.mapper.PrcPriceSetMapper;
import com.bms.price.mapper.PrcPriceSetProductMapper;
import com.bms.price.mapper.PrcPriceSetVersionMapper;
import com.bms.price.mapper.PrcPriceSetVersionSurchargeMapper;
import com.bms.price.mapper.PrcSurchargeMapper;
@@ -90,6 +94,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
@Autowired
private BmsProductCountryMapper productCountryMapper;
@Autowired
private PrcPriceSetProductMapper setProductMapper;
@Autowired
private BmsProductMapper productMapper;
@Lazy
@Autowired
private IBmsProductService productService;
@@ -399,9 +409,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
nv.setCreateBy(createBy);
versionMapper.insertPrcPriceSetVersion(nv);
setProductMapper.copyFromVersion(sourcePsvId, nv.getPsvId());
int detailRows = detailMapper.copyDetailsFromVersion(sourcePsvId, nv.getPsvId());
if (detailRows > 0)
{
setProductMapper.bindDetailPspByPsvId(nv.getPsvId());
versionSurchargeMapper.copySurchargesFromVersion(sourcePsvId, nv.getPsvId(), createBy);
}
refreshVersionStatus(set.getPriceSetId());
@@ -428,6 +440,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
requireSet(version.getPriceSetId());
versionSurchargeMapper.deleteByPsvId(psvId);
detailMapper.deletePrcPriceSetDetailByPsvId(psvId);
setProductMapper.deleteByPsvId(psvId);
int rows = versionMapper.deletePrcPriceSetVersionByIds(new Long[] { psvId });
refreshVersionStatus(version.getPriceSetId());
return rows;
@@ -587,16 +600,16 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
if (details.isEmpty())
{
items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "PASS",
"\u65e0\u5957\u9910\u660e\u7ec6\uff1a\u8ba1\u8d39\u53d6 product / product_country \u9ed8\u8ba4\u8fd0\u8d39\u4e0e\u6302\u53f7\u8d39"));
items.add(new PrcPriceSetCheckItem("FIXED \u660e\u7ec6\u5b8c\u6574\u6027", "FAIL",
"\u65e0\u5957\u9910\u660e\u7ec6\uff1a\u56fa\u5b9a\u4ef7\u987b\u5148\u62c9\u53d6\u4ea7\u54c1\u5217\u8868\u6216\u5bfc\u5165 Excel \u5efa\u4ef7"));
}
else if (incomplete > 0)
{
items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "FAIL", incomplete + " \u6761\u660e\u7ec6\u7f3a\u5c11\u4ea7\u54c1/\u56fd\u5bb6/\u91cd\u91cf\u6bb5"));
items.add(new PrcPriceSetCheckItem("FIXED \u660e\u7ec6\u5b8c\u6574\u6027", "FAIL", incomplete + " \u6761\u660e\u7ec6\u7f3a\u5c11\u4ea7\u54c1/\u56fd\u5bb6/\u91cd\u91cf\u6bb5"));
}
else
{
items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "PASS", details.size() + " \u6761\u8986\u76d6\u660e\u7ec6\uff08\u672a\u8986\u76d6\u7684\u4ea7\u54c1\u56fd\u5bb6\u91cd\u91cf\u6bb5\u4ecd\u8d70\u9ed8\u8ba4\u4ef7\uff09"));
items.add(new PrcPriceSetCheckItem("FIXED \u660e\u7ec6\u5b8c\u6574\u6027", "PASS", details.size() + " \u6761\u5957\u9910\u9500\u552e\u4ef7\u660e\u7ec6\uff08\u4e0d\u56de\u9000 product_country\uff09"));
}
}
else
@@ -673,11 +686,13 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
if (TYPE_FIXED.equals(setType) && !details.isEmpty())
{
items.add(new PrcPriceSetCheckItem("\u8fd0\u8d39\u5355\u4ef7 vs \u9ed8\u8ba4\u7b49\u7ea7\u4ef7\u504f\u5dee", "INFO", "\u6709\u8986\u76d6\u660e\u7ec6\u65f6\u4f18\u5148\u53d6\u5957\u9910\u56fa\u5b9a\u4ef7\uff1b\u672a\u547d\u4e2d\u5219\u56de\u9000 product_country"));
items.add(new PrcPriceSetCheckItem("\u8fd0\u8d39\u5355\u4ef7 vs \u9ed8\u8ba4\u7b49\u7ea7\u4ef7\u504f\u5dee", "INFO",
"\u56fa\u5b9a\u4ef7\u4ec5\u4f7f\u7528\u5957\u9910\u660e\u7ec6\uff1b\u672a\u547d\u4e2d\u5219\u4e0d\u8ba1\u8d39\uff08\u4e0d\u56de\u9000 product_country\uff09"));
}
else if (TYPE_FIXED.equals(setType))
{
items.add(new PrcPriceSetCheckItem("\u8fd0\u8d39\u5355\u4ef7 vs \u9ed8\u8ba4\u7b49\u7ea7\u4ef7\u504f\u5dee", "INFO", "\u65e0\u5957\u9910\u660e\u7ec6\uff0c\u5168\u90e8\u8d70 product_country \u9ed8\u8ba4\u8fd0\u8d39/\u6302\u53f7\u8d39"));
items.add(new PrcPriceSetCheckItem("\u8fd0\u8d39\u5355\u4ef7 vs \u9ed8\u8ba4\u7b49\u7ea7\u4ef7\u504f\u5dee", "INFO",
"\u65e0\u5957\u9910\u660e\u7ec6\u65f6\u56fa\u5b9a\u4ef7\u4e0d\u8ba1\u8d39\uff0c\u8bf7\u5148\u62c9\u53d6\u6216\u5bfc\u5165\u5efa\u4ef7"));
}
else if (TYPE_DISCOUNT.equals(setType))
{
@@ -695,6 +710,19 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return list;
}
@Override
public List<PrcPriceSetDetail> selectDetailListByProduct(Long psvId, String productCode)
{
if (psvId == null || StringUtils.isEmpty(productCode))
{
return new ArrayList<PrcPriceSetDetail>();
}
PrcPriceSetDetail q = new PrcPriceSetDetail();
q.setPsvId(psvId);
q.setProductCode(productCode.trim());
return selectDetailList(q);
}
@Override
public Map<String, Object> selectDetailOptions(Long psvId)
{
@@ -797,6 +825,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
assertChildEditable(db);
validateDetail(db, detail);
detail.setPsvId(resolvePsvId(detail.getPriceSetId(), detail.getPsvId()));
detail.setPspId(lookupSetProductId(detail.getPsvId(), detail.getProductCode()));
int rows = detailMapper.insertPrcPriceSetDetail(detail);
PrcPriceSetVersion ver = new PrcPriceSetVersion();
ver.setPsvId(detail.getPsvId());
@@ -835,6 +864,56 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return taskId;
}
@Override
public String submitPullFromLibraryAsync(Long priceSetId, Long psvId, boolean force, String operName)
{
PrcPriceSet db = requireSet(priceSetId);
assertChildEditable(db);
if (psvId == null)
{
psvId = resolveCurrentPsvId(db);
}
if (psvId == null)
{
throw new ServiceException(MessageUtils.message("price.set.version.notExists"));
}
String taskId = IdUtils.fastSimpleUUID();
saveAsyncRunner.put(taskId, PrcPriceSetSaveTask.STATUS_PENDING, 0, "queued", priceSetId, null);
saveAsyncRunner.runPullLibrary(taskId, priceSetId, psvId, force, operName);
return taskId;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String pullFromLibrary(Long priceSetId, Long psvId, boolean force, String operName, String taskId)
{
PrcPriceSet set = requireSet(priceSetId);
assertChildEditable(set);
set.setUpdateBy(operName);
set.setCreateBy(operName);
set.setSaveTaskId(taskId);
set.setPullFromLibrary("Y");
set.setForcePullLibrary(force ? "Y" : "N");
if (psvId == null)
{
psvId = resolveCurrentPsvId(set);
}
PrcPriceSetVersion version = null;
if (psvId != null)
{
version = versionMapper.selectPrcPriceSetVersionById(psvId);
}
if (version == null)
{
throw new ServiceException(MessageUtils.message("price.set.version.notExists"));
}
reportImportProgress(taskId, priceSetId, 20, "pulling library");
replaceVersionDetailsFromLibrary(set, version);
reportImportProgress(taskId, priceSetId, 95, "finishing");
int count = detailMapper.countByPsvId(version.getPsvId());
return MessageUtils.message("price.set.pull.success", Integer.valueOf(count));
}
@Override
@Transactional(rollbackFor = Exception.class)
public String importDetailPrices(Long priceSetId, Long psvId, byte[] bytes, String filename, String operName) throws Exception
@@ -856,19 +935,24 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
psvId = resolveCurrentPsvId(db);
}
if (psvId == null)
{
throw new ServiceException(MessageUtils.message("price.set.version.notExists"));
}
reportImportProgress(taskId, priceSetId, 20, "parsing excel");
Map<String, List<BmsProductCountry>> byProduct = productService.parseQuoteExcelByProduct(bytes, filename);
reportImportProgress(taskId, priceSetId, 50, "matching details");
if (byProduct == null || byProduct.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows"));
}
PrcPriceSetDetail q = new PrcPriceSetDetail();
q.setPriceSetId(priceSetId);
if (psvId != null)
{
q.setPsvId(psvId);
}
q.setPsvId(psvId);
List<PrcPriceSetDetail> details = detailMapper.selectPrcPriceSetDetailList(q);
if (details == null || details.isEmpty())
if (details == null)
{
throw new ServiceException(MessageUtils.message("price.set.import.noDetails"));
details = new ArrayList<PrcPriceSetDetail>();
}
Map<String, List<PrcPriceSetDetail>> detailIndex = new HashMap<String, List<PrcPriceSetDetail>>();
for (PrcPriceSetDetail d : details)
@@ -886,61 +970,139 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
list.add(d);
}
int updated = 0;
int unmatched = 0;
int skippedProduct = 0;
int inserted = 0;
int skippedFee = 0;
Set<Long> touched = new HashSet<Long>();
int productTotal = byProduct.size();
int productIndex = 0;
for (Map.Entry<String, List<BmsProductCountry>> e : byProduct.entrySet())
{
productIndex++;
String productCode = e.getKey();
reportImportProgress(taskId, priceSetId,
20 + (int) Math.floor(70.0 * productIndex / Math.max(productTotal, 1)),
"importing " + productCode + " (" + productIndex + "/" + productTotal + ")");
List<PrcPriceSetDetail> candidates = detailIndex.get(productCode);
if (candidates == null || candidates.isEmpty())
if (candidates == null)
{
skippedProduct++;
continue;
candidates = new ArrayList<PrcPriceSetDetail>();
detailIndex.put(productCode, candidates);
}
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);
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 (!hits.isEmpty())
{
unmatched++;
continue;
}
for (PrcPriceSetDetail hit : hits)
{
if (hit.getPsdId() == null || touched.contains(hit.getPsdId()))
{
continue;
}
// Empty import freight/registration: do not overwrite existing detail values.
BigDecimal freight = row.getFreightPrice();
BigDecimal registration = row.getRegistrationFee();
if (freight == null && registration == null)
{
skippedFee++;
continue;
}
detailMapper.updateDetailPrices(hit.getPsdId(), freight, registration);
if (freight != null)
for (PrcPriceSetDetail hit : hits)
{
hit.setFvValue(freight);
if (hit.getPsdId() == null || touched.contains(hit.getPsdId()))
{
continue;
}
detailMapper.updateDetailPrices(hit.getPsdId(), freight, registration);
if (freight != null)
{
hit.setFvValue(freight);
}
if (registration != null)
{
hit.setFvRegistrationFee(registration);
}
touched.add(hit.getPsdId());
updated++;
}
if (registration != null)
{
hit.setFvRegistrationFee(registration);
}
touched.add(hit.getPsdId());
updated++;
continue;
}
if (freight == null && registration == null)
{
skippedFee++;
continue;
}
PrcPriceSetDetail created = buildDetailFromImportRow(
priceSetId, psvId, productCode, productName, row, freight, registration);
created.setPspId(pspId);
detailMapper.insertPrcPriceSetDetail(created);
candidates.add(created);
if (created.getPsdId() != null)
{
touched.add(created.getPsdId());
}
inserted++;
}
}
setProductMapper.bindDetailPspByPsvId(psvId);
reportImportProgress(taskId, priceSetId, 95, "finishing");
return MessageUtils.message("price.set.import.upsertSuccess",
Integer.valueOf(inserted), Integer.valueOf(updated), Integer.valueOf(skippedFee));
}
private String resolveImportProductName(Long psvId, String productCode, List<PrcPriceSetDetail> candidates)
{
if (candidates != null)
{
for (PrcPriceSetDetail d : candidates)
{
if (d != null && StringUtils.isNotEmpty(d.getProductName()))
{
return d.getProductName();
}
}
}
reportImportProgress(taskId, priceSetId, 95, "finishing");
return MessageUtils.message("price.set.import.success",
Integer.valueOf(updated), Integer.valueOf(unmatched), Integer.valueOf(skippedProduct));
if (psvId != null && StringUtils.isNotEmpty(productCode))
{
PrcPriceSetProduct sp = setProductMapper.selectByPsvIdAndCode(psvId, productCode);
if (sp != null && StringUtils.isNotEmpty(sp.getProductName()))
{
return sp.getProductName();
}
}
BmsProduct lib = productMapper.selectBmsProductByCode(productCode);
if (lib != null && StringUtils.isNotEmpty(lib.getProductName()))
{
return lib.getProductName();
}
return productCode;
}
private PrcPriceSetDetail buildDetailFromImportRow(Long priceSetId, Long psvId, String productCode,
String productName, BmsProductCountry row, BigDecimal freight, BigDecimal registration)
{
PrcPriceSetDetail d = new PrcPriceSetDetail();
d.setPriceSetId(priceSetId);
d.setPsvId(psvId);
d.setProductCode(productCode);
d.setProductName(productName);
d.setCountryCode(row.getCountryCode());
d.setCountryName(row.getCountryName());
d.setZoneId(row.getZoneId());
d.setZoneCode(row.getZoneCode());
d.setZoneName(row.getZoneName());
d.setWeightMin(row.getWeightMin());
d.setWeightMax(row.getWeightMax());
d.setWeightLabel(formatWeightLabel(row.getWeightMin(), row.getWeightMax()));
d.setProductCountryId(row.getDetailId());
d.setFvValue(freight);
d.setFvRegistrationFee(registration);
d.setRoundMode("HALF_UP");
d.setEffectiveDate(row.getEffectiveDate());
d.setExpireDate(row.getExpireDate());
return d;
}
private void reportImportProgress(String taskId, Long priceSetId, int percent, String message)
@@ -1097,6 +1259,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
detail.setPsvId(old.getPsvId());
}
validateDetail(db, detail);
Long pspId = lookupSetProductId(detail.getPsvId(), detail.getProductCode());
if (pspId == null)
{
pspId = old.getPspId();
}
detail.setPspId(pspId);
int rows = detailMapper.updatePrcPriceSetDetail(detail);
// non-null surcharges = replace snapshot for this detail (empty list clears)
if (detail.getSurcharges() != null)
@@ -1112,6 +1280,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
@Override
public int deleteDetailByIds(Long[] psdIds)
{
Set<String> touchKeys = new HashSet<String>();
for (Long id : psdIds)
{
PrcPriceSetDetail old = detailMapper.selectPrcPriceSetDetailById(id);
@@ -1121,8 +1290,31 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
PrcPriceSet db = requireSet(old.getPriceSetId());
assertChildEditable(db);
if (old.getPsvId() != null && StringUtils.isNotEmpty(old.getProductCode()))
{
touchKeys.add(old.getPsvId() + "|" + old.getProductCode());
}
}
return detailMapper.deletePrcPriceSetDetailByIds(psdIds);
int rows = detailMapper.deletePrcPriceSetDetailByIds(psdIds);
for (String key : touchKeys)
{
int sep = key.indexOf('|');
if (sep <= 0)
{
continue;
}
Long psvId = Long.valueOf(key.substring(0, sep));
String productCode = key.substring(sep + 1);
PrcPriceSetDetail q = new PrcPriceSetDetail();
q.setPsvId(psvId);
q.setProductCode(productCode);
List<PrcPriceSetDetail> left = detailMapper.selectPrcPriceSetDetailList(q);
if (left == null || left.isEmpty())
{
setProductMapper.deleteByPsvIdAndCode(psvId, productCode);
}
}
return rows;
}
@Override
@@ -1818,7 +2010,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
private PrcPriceSet requireSet(Long priceSetId)
{
PrcPriceSet db = setMapper.selectPrcPriceSetById(priceSetId);
// Header only: avoid count(detail) waiting on long pull/import locks.
PrcPriceSet db = setMapper.selectPrcPriceSetHeaderById(priceSetId);
if (db == null)
{
throw new ServiceException(MessageUtils.message("price.set.notExists"));
@@ -1906,16 +2099,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
set.setVersions(versions);
PrcPriceSetVersion current = pickCurrentVersion(versions);
set.setVersion(current);
if (current != null && current.getDetails() != null)
{
set.setDetails(current.getDetails());
}
else
{
PrcPriceSetDetail query = new PrcPriceSetDetail();
query.setPriceSetId(priceSetId);
set.setDetails(detailMapper.selectPrcPriceSetDetailList(query));
}
// Do not load full details here (large sets). Front loads by product via /detail/byProduct.
set.setDetails(new ArrayList<PrcPriceSetDetail>());
PrcCustomerPriceSet cquery = new PrcCustomerPriceSet();
cquery.setPriceSetId(priceSetId);
set.setCustomers(customerMapper.selectPrcCustomerPriceSetList(cquery));
@@ -1923,41 +2108,13 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
private void fillVersionChildren(PrcPriceSetVersion version)
{
PrcPriceSetDetail dq = new PrcPriceSetDetail();
dq.setPsvId(version.getPsvId());
List<PrcPriceSetDetail> details = detailMapper.selectPrcPriceSetDetailList(dq);
PrcPriceSetVersionSurcharge sq = new PrcPriceSetVersionSurcharge();
sq.setPsvId(version.getPsvId());
List<PrcPriceSetVersionSurcharge> surcharges = versionSurchargeMapper.selectPrcPriceSetVersionSurchargeList(sq);
if (surcharges == null)
{
surcharges = new ArrayList<PrcPriceSetVersionSurcharge>();
}
version.setSurcharges(surcharges);
Map<Long, List<PrcPriceSetVersionSurcharge>> byPsd = new HashMap<Long, List<PrcPriceSetVersionSurcharge>>();
for (PrcPriceSetVersionSurcharge sc : surcharges)
{
if (sc.getPsdId() == null)
{
continue;
}
List<PrcPriceSetVersionSurcharge> list = byPsd.get(sc.getPsdId());
if (list == null)
{
list = new ArrayList<PrcPriceSetVersionSurcharge>();
byPsd.put(sc.getPsdId(), list);
}
list.add(sc);
}
if (details != null)
{
for (PrcPriceSetDetail d : details)
{
List<PrcPriceSetVersionSurcharge> list = byPsd.get(d.getPsdId());
d.setSurcharges(list != null ? list : new ArrayList<PrcPriceSetVersionSurcharge>());
}
}
version.setDetails(details);
PrcPriceSetProduct pq = new PrcPriceSetProduct();
pq.setPsvId(version.getPsvId());
List<PrcPriceSetProduct> products = setProductMapper.selectPrcPriceSetProductList(pq);
version.setProducts(products == null ? new ArrayList<PrcPriceSetProduct>() : products);
// details + surcharge snapshots are loaded on demand (edit price / detail drawer)
version.setDetails(new ArrayList<PrcPriceSetDetail>());
version.setSurcharges(new ArrayList<PrcPriceSetVersionSurcharge>());
}
private PrcPriceSetVersion pickCurrentVersion(List<PrcPriceSetVersion> versions)
@@ -1983,14 +2140,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
// null details = leave existing rows alone; empty list = clear / replace with nothing
boolean detailsProvided = set.getDetails() != null;
List<PrcPriceSetDetail> details = filterDetails(set.getDetails());
// Allow creating an empty draft version when client sends version (Step1 -> Step2).
if (version == null && !detailsProvided && !pullLibrary)
{
return;
}
if (version == null && details.isEmpty() && !pullLibrary)
{
return;
}
if (version == null)
{
version = new PrcPriceSetVersion();
@@ -2097,18 +2251,21 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
if (existing > 0 && !force)
{
reportSaveProgress(set, 80, "details exist, skip pull");
PrcPriceSetDetail query = new PrcPriceSetDetail();
query.setPsvId(version.getPsvId());
List<PrcPriceSetDetail> details = detailMapper.selectPrcPriceSetDetailList(query);
set.setDetails(details);
version.setDetails(details);
fillVersionChildren(version);
set.setDetails(new ArrayList<PrcPriceSetDetail>());
return;
}
versionSurchargeMapper.deleteByPsvId(version.getPsvId());
detailMapper.deletePrcPriceSetDetailByPsvId(version.getPsvId());
setProductMapper.deleteByPsvId(version.getPsvId());
// clean legacy rows without psv_id under this set (would look like duplicates in UI)
detailMapper.deleteOrphanDetailsByPriceSetId(set.getPriceSetId());
int productRows = setProductMapper.insertFromLibrary(set.getPriceSetId(), version.getPsvId());
if (productRows <= 0)
{
throw new ServiceException(MessageUtils.message("price.set.library.empty"));
}
reportSaveProgress(set, 45, "pulling library");
int inserted;
if (TYPE_DISCOUNT.equals(set.getSetType()))
{
@@ -2130,15 +2287,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
throw new ServiceException(MessageUtils.message("price.set.library.empty"));
}
setProductMapper.bindDetailPspByPsvId(version.getPsvId());
reportSaveProgress(set, 75, "saving details " + inserted + "/" + inserted);
reportSaveProgress(set, 85, "saving surcharges");
versionSurchargeMapper.insertFromLibraryRels(version.getPsvId(), version.getCreateBy());
PrcPriceSetDetail query = new PrcPriceSetDetail();
query.setPsvId(version.getPsvId());
List<PrcPriceSetDetail> details = detailMapper.selectPrcPriceSetDetailList(query);
set.setDetails(details);
version.setDetails(details);
fillVersionChildren(version);
set.setDetails(new ArrayList<PrcPriceSetDetail>());
}
/**
@@ -2171,6 +2325,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
validateDetail(set, d);
d.setPriceSetId(set.getPriceSetId());
d.setPsvId(version.getPsvId());
Long pspId = lookupSetProductId(version.getPsvId(), d.getProductCode());
if (pspId != null)
{
d.setPspId(pspId);
}
if (d.getPsdId() != null && byId.containsKey(d.getPsdId()))
{
detailMapper.updatePrcPriceSetDetail(d);
@@ -2213,9 +2372,107 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
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()))
{
keepCodes.add(d.getProductCode().trim());
}
}
}
PrcPriceSetProduct pq = new PrcPriceSetProduct();
pq.setPsvId(version.getPsvId());
List<PrcPriceSetProduct> setProducts = setProductMapper.selectPrcPriceSetProductList(pq);
if (setProducts != null)
{
for (PrcPriceSetProduct sp : setProducts)
{
if (sp == null || StringUtils.isEmpty(sp.getProductCode()))
{
continue;
}
if (!keepCodes.contains(sp.getProductCode()))
{
setProductMapper.deleteByPsvIdAndCode(version.getPsvId(), sp.getProductCode());
}
}
}
version.setDetails(details);
}
/**
* Lookup existing set-product id only (no insert).
* Insert happens only on pull-from-library / Excel import.
*/
private Long lookupSetProductId(Long psvId, String productCode)
{
if (psvId == null || StringUtils.isEmpty(productCode))
{
return null;
}
PrcPriceSetProduct existing = setProductMapper.selectByPsvIdAndCode(psvId, productCode.trim());
return existing == null ? null : existing.getPspId();
}
/**
* Ensure package-side product master row exists for this version+code; return psp_id.
* Only called from pull-from-library / Excel import.
*/
private Long ensureSetProduct(Long priceSetId, Long psvId, String productCode, String productNameHint)
{
if (psvId == null || StringUtils.isEmpty(productCode))
{
return null;
}
String code = productCode.trim();
PrcPriceSetProduct existing = setProductMapper.selectByPsvIdAndCode(psvId, code);
if (existing != null)
{
if (StringUtils.isEmpty(existing.getProductName()) && StringUtils.isNotEmpty(productNameHint)
&& !code.equals(productNameHint))
{
existing.setProductName(productNameHint);
setProductMapper.updatePrcPriceSetProduct(existing);
}
return existing.getPspId();
}
PrcPriceSetProduct row = new PrcPriceSetProduct();
row.setPriceSetId(priceSetId);
row.setPsvId(psvId);
row.setProductCode(code);
BmsProduct lib = null;
try
{
lib = productMapper.selectBmsProductByCode(code);
}
catch (Exception ignore)
{
lib = null;
}
if (lib != null)
{
row.setProductName(lib.getProductName());
row.setServiceLevel(lib.getServiceLevel());
row.setCargoAttr(lib.getCargoAttr());
row.setDestRegion(lib.getDestRegion());
row.setUpdateNote(lib.getUpdateNote());
row.setTaxMode(StringUtils.isNotEmpty(lib.getTaxMode()) ? lib.getTaxMode() : "TAX_INCLUDED");
row.setSourceProductId(lib.getProductId());
row.setSourceSheet(lib.getSourceSheet());
}
else
{
row.setProductName(StringUtils.isNotEmpty(productNameHint) ? productNameHint : code);
row.setTaxMode("TAX_INCLUDED");
}
setProductMapper.insertPrcPriceSetProduct(row);
return row.getPspId();
}
/** Only rewrite surcharge snapshots for rows that explicitly sent surcharges. */
private void resnapshotSurchargesIfNeeded(PrcPriceSetVersion version, List<PrcPriceSetDetail> details)
{
@@ -28,6 +28,7 @@ import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetDetail;
import com.bms.price.domain.PrcPriceSetProduct;
import com.bms.price.domain.PrcPriceSetVersion;
import com.bms.price.domain.PrcPriceSetVersionSurcharge;
import com.bms.price.domain.PrcQuote;
@@ -45,6 +46,7 @@ import com.bms.price.mapper.BmsProductMapper;
import com.bms.price.mapper.PrcCustomerPriceSetMapper;
import com.bms.price.mapper.PrcPriceSetDetailMapper;
import com.bms.price.mapper.PrcPriceSetMapper;
import com.bms.price.mapper.PrcPriceSetProductMapper;
import com.bms.price.mapper.PrcPriceSetVersionMapper;
import com.bms.price.mapper.PrcPriceSetVersionSurchargeMapper;
import com.bms.price.mapper.PrcQuoteFeeMapper;
@@ -90,6 +92,9 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
@Autowired
private PrcPriceSetDetailMapper detailMapper;
@Autowired
private PrcPriceSetProductMapper setProductMapper;
@Autowired
private BmsProductCountryMapper productCountryMapper;
@@ -195,6 +200,98 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
return toResult(quote, quoteFeeMapper.selectByQuoteId(quoteId), true);
}
@Override
public List<PrcPriceSetProduct> listBoundPackageProducts(String customerCode, Date bizDate)
{
if (StringUtils.isEmpty(customerCode))
{
return Collections.emptyList();
}
List<PrcCustomerPriceSet> binds = customerPriceSetMapper.selectEffectiveByCustomerCode(customerCode.trim());
if (binds == null || binds.isEmpty())
{
return Collections.emptyList();
}
Date day = bizDate != null ? bizDate : new Date();
Date asOf = endOfDay(day);
for (PrcCustomerPriceSet bind : binds)
{
if (bind == null || bind.getPriceSetId() == null)
{
continue;
}
PrcPriceSet set = priceSetMapper.selectPrcPriceSetHeaderById(bind.getPriceSetId());
if (set == null || !"E".equals(set.getStatus()))
{
continue;
}
List<PrcPriceSetProduct> products = loadProductsForPackage(set.getPriceSetId(), asOf);
if (products != null && !products.isEmpty())
{
return products;
}
}
return Collections.emptyList();
}
/** Prefer version active at asOf; else latest version that has set-product rows. */
private List<PrcPriceSetProduct> loadProductsForPackage(Long priceSetId, Date asOf)
{
PrcPriceSetVersion active = versionMapper.selectActiveByPriceSetAt(priceSetId, asOf);
List<PrcPriceSetProduct> rows = listProductsByPsvId(active == null ? null : active.getPsvId());
if (rows != null && !rows.isEmpty())
{
return rows;
}
PrcPriceSetVersion q = new PrcPriceSetVersion();
q.setPriceSetId(priceSetId);
List<PrcPriceSetVersion> versions = versionMapper.selectPrcPriceSetVersionList(q);
if (versions == null || versions.isEmpty())
{
return Collections.emptyList();
}
// newest seq first
versions.sort((a, b) -> {
int sa = a.getVersionSeq() == null ? 0 : a.getVersionSeq().intValue();
int sb = b.getVersionSeq() == null ? 0 : b.getVersionSeq().intValue();
return Integer.compare(sb, sa);
});
for (PrcPriceSetVersion v : versions)
{
rows = listProductsByPsvId(v == null ? null : v.getPsvId());
if (rows != null && !rows.isEmpty())
{
return rows;
}
}
return Collections.emptyList();
}
private List<PrcPriceSetProduct> listProductsByPsvId(Long psvId)
{
if (psvId == null)
{
return Collections.emptyList();
}
PrcPriceSetProduct q = new PrcPriceSetProduct();
q.setPsvId(psvId);
List<PrcPriceSetProduct> rows = setProductMapper.selectPrcPriceSetProductList(q);
if (rows == null || rows.isEmpty())
{
return Collections.emptyList();
}
Map<String, PrcPriceSetProduct> byCode = new LinkedHashMap<String, PrcPriceSetProduct>();
for (PrcPriceSetProduct row : rows)
{
if (row == null || StringUtils.isEmpty(row.getProductCode()))
{
continue;
}
byCode.putIfAbsent(row.getProductCode(), row);
}
return new ArrayList<PrcPriceSetProduct>(byCode.values());
}
private TmsPriceQuoteResult doQuote(TmsPriceQuoteRequest request, Date bizDate)
{
String productCode = request.getProductCode().trim();
@@ -485,14 +582,8 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
int detailCount = detailMapper.countByPsvId(psvId);
if (detailCount == 0)
{
TmsPriceQuoteResult r = buildFromRates(SRC_DEFAULT, base.getFreightPrice(), null, null,
base.getRegistrationFee(), base, productCode, productName, weight, null);
fillPackage(r, bind, set);
stampVersion(r, version, null);
// Package fixed has no version details; use product default rates
r.setMatchMessage(MessageUtils.message("price.quote.fixedPackageNoDetails"));
r.setCustomerId(bind.getCustomerId());
return r;
// FIXED sales price is version-detail only; no fallback to product_country.
return null;
}
PrcPriceSetDetail detail = detailMapper.selectFixedMatch(
@@ -100,6 +100,29 @@ public class PrcPriceSetSaveAsyncRunner
}
}
/** Background pull of product library into package version details. */
@Async("threadPoolTaskExecutor")
public void runPullLibrary(String taskId, Long priceSetId, Long psvId, boolean force, String operName)
{
try
{
put(taskId, PrcPriceSetSaveTask.STATUS_RUNNING, 10, "pulling library", priceSetId, null);
String message = priceSetService.pullFromLibrary(priceSetId, psvId, force, operName, taskId);
put(taskId, PrcPriceSetSaveTask.STATUS_SUCCESS, 100, message, priceSetId, null);
log.info("async pull library done, taskId={}, priceSetId={}", taskId, priceSetId);
}
catch (Exception e)
{
log.error("async pull library failed, taskId={}, priceSetId={}", taskId, priceSetId, e);
String msg = e.getMessage();
if (StringUtils.isEmpty(msg))
{
msg = e.getClass().getSimpleName();
}
put(taskId, PrcPriceSetSaveTask.STATUS_FAIL, 100, "failed", priceSetId, msg);
}
}
public void put(String taskId, String status, int percent, String message, Long priceSetId, String error)
{
PrcPriceSetSaveTask task = new PrcPriceSetSaveTask();
@@ -8,6 +8,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<id property="psdId" column="psd_id" />
<result property="priceSetId" column="price_set_id" />
<result property="psvId" column="psv_id" />
<result property="pspId" column="psp_id" />
<result property="productCode" column="product_code" />
<result property="productName" column="product_name" />
<result property="countryCode" column="country_code" />
@@ -35,7 +36,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectPrcPriceSetDetailVo">
select psd_id, price_set_id, psv_id, product_code, product_name, country_code, country_name,
select psd_id, 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, update_time
@@ -48,7 +49,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select>
<select id="selectPrcPriceSetDetailList" parameterType="PrcPriceSetDetail" resultMap="PrcPriceSetDetailResult">
select d.psd_id, d.price_set_id, d.psv_id, d.product_code, d.product_name, d.country_code, d.country_name,
select d.psd_id, d.price_set_id, d.psv_id, d.psp_id, d.product_code, d.product_name, d.country_code, d.country_name,
d.zone_id, d.zone_code, d.zone_name,
d.zd_id, d.wsd_id, d.weight_label, d.weight_min, d.weight_max, d.product_country_id,
d.fv_value, d.fv_registration_fee,
@@ -210,6 +211,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
insert into prc_price_set_detail (
<if test="priceSetId != null">price_set_id,</if>
<if test="psvId != null">psv_id,</if>
<if test="pspId != null">psp_id,</if>
<if test="productCode != null and productCode != ''">product_code,</if>
<if test="productName != null">product_name,</if>
<if test="countryCode != null">country_code,</if>
@@ -234,6 +236,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
) values (
<if test="priceSetId != null">#{priceSetId},</if>
<if test="psvId != null">#{psvId},</if>
<if test="pspId != null">#{pspId},</if>
<if test="productCode != null and productCode != ''">#{productCode},</if>
<if test="productName != null">#{productName},</if>
<if test="countryCode != null">#{countryCode},</if>
@@ -273,6 +276,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
update prc_price_set_detail
<set>
<if test="psvId != null">psv_id = #{psvId},</if>
<if test="pspId != null">psp_id = #{pspId},</if>
<if test="productCode != null and productCode != ''">product_code = #{productCode},</if>
<if test="productName != null">product_name = #{productName},</if>
country_code = #{countryCode},
@@ -28,7 +28,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
s.set_type, s.default_discount, s.default_type, s.calculate_sign,
s.status, s.tms_id,
s.created_by, s.create_time, s.updated_by, s.update_time,
(select count(distinct d.product_code) from prc_price_set_detail d where d.price_set_id = s.price_set_id) as product_count,
(select count(distinct p.product_code) from prc_price_set_product p where p.price_set_id = s.price_set_id) as product_count,
(select count(1) from prc_customer_price_set c where c.price_set_id = s.price_set_id) as customer_count
from prc_price_set s
</sql>
@@ -38,6 +38,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where s.price_set_id = #{priceSetId}
</select>
<select id="selectPrcPriceSetHeaderById" parameterType="Long" resultMap="PrcPriceSetResult">
select s.price_set_id, s.price_set_code, s.price_set_name, s.price_set_note,
s.set_type, s.default_discount, s.default_type, s.calculate_sign,
s.status, s.tms_id,
s.created_by, s.create_time, s.updated_by, s.update_time,
null as product_count, null as customer_count
from prc_price_set s
where s.price_set_id = #{priceSetId}
</select>
<select id="selectPrcPriceSetList" parameterType="PrcPriceSet" resultMap="PrcPriceSetResult">
<include refid="selectPrcPriceSetVo"/>
<where>
@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bms.price.mapper.PrcPriceSetProductMapper">
<resultMap type="PrcPriceSetProduct" id="PrcPriceSetProductResult">
<id property="pspId" column="psp_id" />
<result property="priceSetId" column="price_set_id" />
<result property="psvId" column="psv_id" />
<result property="productCode" column="product_code" />
<result property="productName" column="product_name" />
<result property="serviceLevel" column="service_level" />
<result property="cargoAttr" column="cargo_attr" />
<result property="destRegion" column="dest_region" />
<result property="updateNote" column="update_note" />
<result property="taxMode" column="tax_mode" />
<result property="sourceProductId" column="source_product_id" />
<result property="sourceSheet" column="source_sheet" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectVo">
select psp_id, price_set_id, psv_id, product_code, product_name, service_level, cargo_attr,
dest_region, update_note, tax_mode, source_product_id, source_sheet, create_time, update_time
from prc_price_set_product
</sql>
<select id="selectPrcPriceSetProductById" parameterType="Long" resultMap="PrcPriceSetProductResult">
<include refid="selectVo"/>
where psp_id = #{pspId}
</select>
<select id="selectPrcPriceSetProductList" parameterType="PrcPriceSetProduct" resultMap="PrcPriceSetProductResult">
<include refid="selectVo"/>
<where>
<if test="priceSetId != null">AND price_set_id = #{priceSetId}</if>
<if test="psvId != null">AND psv_id = #{psvId}</if>
<if test="productCode != null and productCode != ''">AND product_code = #{productCode}</if>
</where>
order by product_code asc, psp_id asc
</select>
<select id="selectByPsvIdAndCode" resultMap="PrcPriceSetProductResult">
<include refid="selectVo"/>
where psv_id = #{psvId}
and product_code = #{productCode}
limit 1
</select>
<insert id="insertPrcPriceSetProduct" parameterType="PrcPriceSetProduct" useGeneratedKeys="true" keyProperty="pspId">
insert into prc_price_set_product (
price_set_id, psv_id, product_code, product_name, service_level, cargo_attr,
dest_region, update_note, tax_mode, source_product_id, source_sheet, create_time
) values (
#{priceSetId}, #{psvId}, #{productCode}, #{productName}, #{serviceLevel}, #{cargoAttr},
#{destRegion}, #{updateNote}, #{taxMode}, #{sourceProductId}, #{sourceSheet}, sysdate()
)
</insert>
<update id="updatePrcPriceSetProduct" parameterType="PrcPriceSetProduct">
update prc_price_set_product
<set>
<if test="productName != null">product_name = #{productName},</if>
<if test="serviceLevel != null">service_level = #{serviceLevel},</if>
<if test="cargoAttr != null">cargo_attr = #{cargoAttr},</if>
<if test="destRegion != null">dest_region = #{destRegion},</if>
<if test="updateNote != null">update_note = #{updateNote},</if>
<if test="taxMode != null and taxMode != ''">tax_mode = #{taxMode},</if>
<if test="sourceProductId != null">source_product_id = #{sourceProductId},</if>
<if test="sourceSheet != null">source_sheet = #{sourceSheet},</if>
update_time = sysdate()
</set>
where psp_id = #{pspId}
</update>
<delete id="deleteByPsvId">
delete from prc_price_set_product where psv_id = #{psvId}
</delete>
<delete id="deleteByPsvIdAndCode">
delete from prc_price_set_product
where psv_id = #{psvId}
and product_code = #{productCode}
</delete>
<insert id="copyFromVersion">
insert into prc_price_set_product (
price_set_id, psv_id, product_code, product_name, service_level, cargo_attr,
dest_region, update_note, tax_mode, source_product_id, source_sheet, create_time
)
select
price_set_id, #{newPsvId}, product_code, product_name, service_level, cargo_attr,
dest_region, update_note, tax_mode, source_product_id, source_sheet, sysdate()
from prc_price_set_product
where psv_id = #{srcPsvId}
order by psp_id asc
</insert>
<insert id="insertFromLibrary">
insert ignore into prc_price_set_product (
price_set_id, psv_id, product_code, product_name, service_level, cargo_attr,
dest_region, update_note, tax_mode, source_product_id, source_sheet, create_time
)
select
#{priceSetId},
#{psvId},
p.product_code,
p.product_name,
p.service_level,
p.cargo_attr,
p.dest_region,
p.update_note,
ifnull(p.tax_mode, 'TAX_INCLUDED'),
p.product_id,
p.source_sheet,
sysdate()
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
</insert>
<update id="bindDetailPspByPsvId">
update prc_price_set_detail d
inner join prc_price_set_product sp
on sp.psv_id = d.psv_id and sp.product_code = d.product_code
set d.psp_id = sp.psp_id
where d.psv_id = #{psvId}
and (d.psp_id is null or d.psp_id &lt;&gt; sp.psp_id)
</update>
</mapper>
+29
View File
@@ -1257,6 +1257,7 @@ CREATE TABLE `prc_price_set_detail` (
`psd_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '套餐明细id',
`price_set_id` bigint(20) NOT NULL COMMENT '价格套餐id',
`psv_id` bigint(20) DEFAULT NULL COMMENT '套餐版本id',
`psp_id` bigint(20) DEFAULT NULL COMMENT '套餐产品主表id prc_price_set_product.psp_id',
`product_code` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品代码',
`product_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称',
`country_code` char(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '国家二字码',
@@ -1286,6 +1287,7 @@ CREATE TABLE `prc_price_set_detail` (
KEY `idx_psd_zone` (`zone_id`),
KEY `idx_psd_country_zone` (`price_set_id`,`product_code`,`country_code`,`zone_code`),
KEY `idx_psd_psv` (`psv_id`),
KEY `idx_psd_psp` (`psp_id`),
KEY `idx_psd_weight_match` (`price_set_id`,`product_code`,`country_code`,`weight_min`,`weight_max`),
KEY `idx_psd_eff` (`price_set_id`,`effective_date`,`expire_date`),
CONSTRAINT `fk_psd_psv` FOREIGN KEY (`psv_id`) REFERENCES `prc_price_set_version` (`psv_id`) ON DELETE CASCADE,
@@ -1315,6 +1317,33 @@ CREATE TABLE `prc_price_set_version` (
CONSTRAINT `fk_psv_set` FOREIGN KEY (`price_set_id`) REFERENCES `prc_price_set` (`price_set_id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='价格套餐版本';
-- ----------------------------
-- 表结构:`prc_price_set_product`
-- ----------------------------
CREATE TABLE `prc_price_set_product` (
`psp_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '套餐产品主表id',
`price_set_id` bigint(20) NOT NULL COMMENT '价格套餐id',
`psv_id` bigint(20) NOT NULL COMMENT '套餐版本id',
`product_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品对接代码',
`product_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称',
`service_level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '时效等级',
`cargo_attr` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '走货属性',
`dest_region` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的区域',
`update_note` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新说明',
`tax_mode` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'TAX_INCLUDED' COMMENT 'TAX_INCLUDED包税 TAX_EXCLUDED不包税',
`source_product_id` bigint(20) DEFAULT NULL COMMENT '来源产品库 product.product_id',
`source_sheet` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源Excel sheet',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`psp_id`),
UNIQUE KEY `uk_psp_psv_code` (`psv_id`,`product_code`),
KEY `idx_psp_set` (`price_set_id`),
KEY `idx_psp_psv` (`psv_id`),
KEY `idx_psp_code` (`product_code`),
CONSTRAINT `fk_psp_psv` FOREIGN KEY (`psv_id`) REFERENCES `prc_price_set_version` (`psv_id`) ON DELETE CASCADE,
CONSTRAINT `fk_psp_set` FOREIGN KEY (`price_set_id`) REFERENCES `prc_price_set` (`price_set_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='价格套餐产品主表-与产品库快照独立';
-- ----------------------------
-- 表结构:`prc_price_set_version_surcharge`
-- ----------------------------
@@ -0,0 +1,41 @@
-- prc_price_set_product: package-side product master (snapshot like product)
-- + prc_price_set_detail.psp_id FK (run once on existing DB)
CREATE TABLE IF NOT EXISTS `prc_price_set_product` (
`psp_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '套餐产品主表id',
`price_set_id` bigint(20) NOT NULL COMMENT '价格套餐id',
`psv_id` bigint(20) NOT NULL COMMENT '套餐版本id',
`product_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品对接代码',
`product_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称',
`service_level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '时效等级',
`cargo_attr` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '走货属性',
`dest_region` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的区域',
`update_note` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新说明',
`tax_mode` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'TAX_INCLUDED' COMMENT 'TAX_INCLUDED包税 TAX_EXCLUDED不包税',
`source_product_id` bigint(20) DEFAULT NULL COMMENT '来源产品库 product.product_id',
`source_sheet` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源Excel sheet',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`psp_id`),
UNIQUE KEY `uk_psp_psv_code` (`psv_id`,`product_code`),
KEY `idx_psp_set` (`price_set_id`),
KEY `idx_psp_psv` (`psv_id`),
KEY `idx_psp_code` (`product_code`),
CONSTRAINT `fk_psp_psv` FOREIGN KEY (`psv_id`) REFERENCES `prc_price_set_version` (`psv_id`) ON DELETE CASCADE,
CONSTRAINT `fk_psp_set` FOREIGN KEY (`price_set_id`) REFERENCES `prc_price_set` (`price_set_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='价格套餐产品主表-与产品库快照独立';
SET @col_exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prc_price_set_detail' AND COLUMN_NAME = 'psp_id'
);
SET @sql := IF(@col_exists = 0,
CONCAT(
'ALTER TABLE `prc_price_set_detail` ',
'ADD COLUMN `psp_id` bigint(20) DEFAULT NULL COMMENT ''套餐产品主表id prc_price_set_product.psp_id'' AFTER `psv_id`, ',
'ADD KEY `idx_psd_psp` (`psp_id`)'
),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+9
View File
@@ -9,6 +9,15 @@ export function quoteBilling(data) {
})
}
/** Products from customer's bound effective package (trial dropdown) */
export function listQuoteBoundProducts(customerCode, bizDate) {
return request({
url: '/price/billing/quote/products',
method: 'get',
params: { customerCode, bizDate }
})
}
/** Historical quote list */
export function listQuote(query) {
return request({
+36 -2
View File
@@ -172,6 +172,15 @@ export function listSetDetail(query) {
})
}
/** All detail rows for one product under a version (no pagination). */
export function listSetDetailByProduct(psvId, productCode) {
return request({
url: '/price/set/detail/byProduct',
method: 'get',
params: { psvId, productCode }
})
}
/** 版本明细搜索下拉选项:按版本去重后的 产品/国家/分区 */
export function listSetDetailOptions(psvId) {
return request({
@@ -213,6 +222,31 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
if (!taskId) {
throw new Error('missing import taskId')
}
return pollPriceSetSaveTask(taskId, onProgress)
}
/**
* Async pull product library into package version details; poll until done.
* @param {{ priceSetId: string|number, psvId?: string|number, force?: boolean }} query
* @param {(task: object) => void} [onProgress]
*/
export async function pullSetDetailsFromLibraryAsync(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)
params.set('force', query && query.force ? 'true' : 'false')
const submitRes = await request({
url: '/price/set/detail/pullFromLibrary?' + params.toString(),
method: 'post'
})
const taskId = pickSaveTaskId(submitRes)
if (!taskId) {
throw new Error('missing pull taskId')
}
return pollPriceSetSaveTask(taskId, onProgress)
}
async function pollPriceSetSaveTask(taskId, onProgress) {
const started = Date.now()
const maxMs = 30 * 60 * 1000
while (Date.now() - started < maxMs) {
@@ -225,13 +259,13 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
return { task }
}
if (task.status === 'FAIL') {
const err = new Error(task.error || task.message || 'import failed')
const err = new Error(task.error || task.message || 'task failed')
err.task = task
throw err
}
await new Promise(resolve => setTimeout(resolve, 800))
}
throw new Error('import timeout')
throw new Error('task timeout')
}
export function updateSetDetail(data) {
+62 -10
View File
@@ -6,22 +6,24 @@
<template #header><span>试算参数</span></template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="客户" prop="customerCode">
<customer-select v-model="form.customerCode" value-key="customerCode" placeholder="请选择客户" />
<div class="form-hint">与套餐绑定客户一致;未绑定套餐时报错</div>
<customer-select v-model="form.customerCode" value-key="customerCode" placeholder="请选择客户" @change="handleCustomerChange" />
<div class="form-hint">产品列表来自该客户绑定套餐;未绑定或套餐无产品时为空</div>
</el-form-item>
<el-form-item label="产品" prop="productCode">
<el-select
v-model="form.productCode"
filterable
clearable
placeholder="请选择产品"
:placeholder="productPlaceholder"
style="width: 100%"
:disabled="!form.customerCode"
:loading="productLoading"
@change="handleProductChange"
>
<el-option
v-for="item in productOptions"
:key="item.productCode"
:label="item.productName + ' (' + item.productCode + ')'"
:label="(item.productName || item.productCode) + ' (' + item.productCode + ')'"
:value="item.productCode"
/>
</el-select>
@@ -262,8 +264,8 @@
</template>
<script setup name="BillingQuote">
import { quoteBilling, listQuote, getQuote } from '@/api/price/billing'
import { listProduct, listProductCountries } from '@/api/price/product'
import { quoteBilling, listQuoteBoundProducts, listQuote, getQuote } from '@/api/price/billing'
import { listProductCountries } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const { bms_fee_type, bms_currency } = useDict('bms_fee_type', 'bms_currency')
@@ -346,12 +348,62 @@ const rules = {
weight: [{ required: true, message: '请输入计费重量', trigger: 'blur' }]
}
function loadProducts() {
listProduct({ pageNum: 1, pageSize: 500, status: '1' }).then(res => {
productOptions.value = res.rows || []
const productLoading = ref(false)
const productPlaceholder = computed(() => {
if (!form.customerCode) return '请先选择客户'
if (productLoading.value) return '加载中…'
if (!(productOptions.value || []).length) return '该客户绑定套餐暂无产品'
return '请选择产品'
})
function clearProductCascade() {
form.productCode = undefined
form.countryCode = undefined
form.destPostcode = undefined
countryOptions.value = []
countryRows.value = []
productOptions.value = []
}
function handleCustomerChange(code) {
clearProductCascade()
result.value = null
errorMsg.value = ''
if (!code) return
loadProductsByCustomer(code)
}
function loadProductsByCustomer(customerCode) {
if (!customerCode) {
productOptions.value = []
return Promise.resolve([])
}
productLoading.value = true
return listQuoteBoundProducts(customerCode, form.bizDate || undefined).then(res => {
productOptions.value = res.data || []
return productOptions.value
}).catch(() => {
productOptions.value = []
return []
}).finally(() => {
productLoading.value = false
})
}
watch(() => form.bizDate, () => {
if (!form.customerCode) return
const keep = form.productCode
loadProductsByCustomer(form.customerCode).then(list => {
if (keep && !(list || []).some(p => p.productCode === keep)) {
form.productCode = undefined
form.countryCode = undefined
form.destPostcode = undefined
countryOptions.value = []
countryRows.value = []
}
})
})
function uniqueCountries(list) {
const map = new Map()
;(list || []).forEach(item => {
@@ -430,6 +482,7 @@ function resetForm() {
form.destPostcode = undefined
form.weight = undefined
form.bizDate = undefined
productOptions.value = []
countryOptions.value = []
countryRows.value = []
result.value = null
@@ -539,7 +592,6 @@ function sourceTagType(source) {
return ''
}
loadProducts()
</script>
<style scoped>
@@ -18,7 +18,6 @@
border
size="small"
:span-method="spanMethod"
:row-class-name="rowClass"
>
<el-table-column :label="colCountry" min-width="100">
<template #default="{ row }">{{ countryLabel(row) }}</template>
@@ -27,16 +26,6 @@
<template #default="{ row }">{{ row.zoneName || row.zoneCode || '-' }}</template>
</el-table-column>
<el-table-column :label="colWeight" prop="weightLabel" min-width="120" />
<el-table-column :label="colBaseFv" width="130" align="center">
<template #default="{ row }">
<span class="price-text base-price">{{ formatNum(row._baseFreight, 4) }}</span>
</template>
</el-table-column>
<el-table-column :label="colBaseReg" width="130" align="center">
<template #default="{ row }">
<span class="price-text base-price">{{ formatNum(row._baseReg, 2) }}</span>
</template>
</el-table-column>
<el-table-column :label="colSetFv" width="160" align="center">
<template #default="{ row }">
<div class="price-cell">
@@ -99,7 +88,6 @@
<script setup name="PriceEditDrawer">
import SurchargeCountCell from './SurchargeCountCell.vue'
import { isDetailPriceChanged } from '../../utils/priceSet'
const props = defineProps({
modelValue: { type: Boolean, default: false },
@@ -109,8 +97,6 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue', 'pick-surcharge', 'row-change', 'surcharge-change'])
const { proxy } = getCurrentInstance()
const keyword = ref('')
const editingKey = ref('')
const committing = ref(false)
@@ -121,13 +107,11 @@ const title = computed(() => {
const name = props.productName || ''
return code + ' · ' + name + ' · 国家服务价格'
})
const hint = '黄色行=已改价(与产品基础价不同);点编辑图标后修改,失焦即刻写入数据库。单位:运费 RMB/KG,挂号费 RMB/票。'
const hint = '点编辑图标后修改,失焦即刻写入数据库。单位:运费 RMB/KG,挂号费 RMB/票。'
const phSearch = '搜索国家/分区'
const colCountry = '国家'
const colZone = '分区'
const colWeight = '重量段'
const colBaseFv = '产品基础运费'
const colBaseReg = '产品基础挂号费'
const colSetFv = '套餐运费'
const colSetReg = '套餐挂号费'
const colSurcharge = '附加费'
@@ -159,20 +143,6 @@ function startEdit(row, field) {
})
}
function isBelowFloor(value, floor) {
if (value == null || value === '' || floor == null || floor === '') return false
const n = Number(value)
const f = Number(floor)
if (Number.isNaN(n) || Number.isNaN(f)) return false
return n + 1e-8 < f
}
function belowFloorMessage(field, floor) {
const name = field === 'fv' ? '\u5957\u9910\u8fd0\u8d39' : '\u5957\u9910\u6302\u53f7\u8d39'
const prec = field === 'fv' ? 4 : 2
return name + '\u4e0d\u80fd\u4f4e\u4e8e\u5bfc\u5165\u57fa\u51c6\u4ef7 ' + formatNum(floor, prec)
}
async function commitEdit(row, field) {
if (committing.value) return
if (editingKey.value !== cellKey(row, field)) return
@@ -180,16 +150,6 @@ async function commitEdit(row, field) {
editingKey.value = ''
try {
if (!row) return
// Temporarily allow freight / registration below imported base price.
// if (field === 'fv') {
// const floor = row._floorFreight
// const value = row.fvValue
// if (isBelowFloor(value, floor)) {
// row.fvValue = row._editPrevFv
// proxy.$modal.msgError(belowFloorMessage(field, floor))
// return
// }
// }
if (!row.psdId) return
emit('row-change', row)
} finally {
@@ -271,10 +231,6 @@ function spanOf(spans, rowIndex) {
return rowspan > 0 ? { rowspan, colspan: 1 } : { rowspan: 0, colspan: 0 }
}
function rowClass({ row }) {
return isDetailPriceChanged(row) ? 'changed-row' : ''
}
function close() {
editingKey.value = ''
emit('update:modelValue', false)
@@ -345,13 +301,7 @@ watch(() => props.modelValue, v => {
min-width: 64px;
text-align: right;
}
.base-price {
color: #909399;
}
.edit-ico {
padding: 0 2px !important;
}
:deep(.changed-row > td) {
background: #fffbe6 !important;
}
</style>
@@ -19,6 +19,7 @@
<step-details
v-if="loaded"
:details="details"
:products="products"
:price-set-id="priceSetId"
:psv-id="version.psvId"
:set-type="setType"
@@ -34,7 +35,7 @@
<script setup name="PriceEditPanel">
import { ArrowLeft } from '@element-plus/icons-vue'
import StepDetails from './StepDetails.vue'
import { usePriceSetRefData, useDetailCascade } from '../../composables/usePriceSetRefData'
import { usePriceSetRefData } from '../../composables/usePriceSetRefData'
import { getPriceSet } from '@/api/price/set'
const props = defineProps({
@@ -46,13 +47,13 @@ const emit = defineEmits(['close'])
const { proxy } = getCurrentInstance()
const refData = usePriceSetRefData()
const cascade = useDetailCascade(refData)
const loading = ref(false)
const loaded = ref(false)
const setType = ref('FIXED')
const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined })
const details = ref([])
const products = ref([])
const title = computed(() => '编辑产品价格 · ' + (version.versionNo || ''))
@@ -76,8 +77,8 @@ function load() {
version.versionNo = current.versionNo || 'V1'
version.effectiveTime = current.effectiveTime
const src = (current.details && current.details.length) ? current.details : (data.details || [])
details.value = src.map(d => cascade.hydrateRow(d, setType.value))
details.value = []
products.value = (current.products || []).map(p => ({ ...p }))
loaded.value = true
}).finally(() => {
loading.value = false
@@ -9,16 +9,40 @@
</div>
</template>
<div v-if="showBuildActions" class="build-choice">
<p class="build-choice__tip">暂无套餐销售价,请选择一种建价方式</p>
<div class="build-choice__cards">
<button
type="button"
class="build-card build-card--pull"
:disabled="!priceSetId"
@click="handlePullLibrary"
>
<div class="build-card__title">拉取产品列表</div>
<div class="build-card__desc">按产品库基础价生成本版本套餐销售价</div>
</button>
<button
type="button"
class="build-card build-card--import"
:disabled="!priceSetId || !isFixedType(setType)"
@click="openImport"
>
<div class="build-card__title">导入 Excel 建价</div>
<div class="build-card__desc">上传威狮B2C小包价表,直接生成套餐销售价</div>
</button>
</div>
</div>
<template v-else>
<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-option v-for="d in bms_tax_mode" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
<el-button size="small" @click="noop">查询</el-button>
<el-button size="small" type="primary" plain :disabled="!priceSetId || !isFixedType(setType)" @click="openImport">导入修改价格</el-button>
</div>
<el-table :data="filteredProducts" border empty-text="暂无产品价格明细">
<el-table :data="filteredProducts" border>
<el-table-column :label="colCode" min-width="80">
<template #default="{ row }"><b>{{ row.productCode }}</b></template>
</el-table-column>
@@ -39,11 +63,12 @@
<el-table-column :label="colAction" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openPrice(row)">编辑价格</el-button>
<el-button link type="primary" @click="openSurcharge(row)">附加费</el-button>
<el-button link type="primary" @click="openSurchargeForProduct(row)">附加费</el-button>
<el-button link type="danger" @click="removeProduct(row.productCode)">移除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<surcharge-picker-dialog
v-model="scVisible"
@@ -78,7 +103,7 @@
<excel-import-dialog
ref="importRef"
title="导入修改套餐价格"
title="导入 Excel 建价"
width="560px"
:show-update-support="false"
:import-desc="importDesc"
@@ -88,7 +113,7 @@
<el-dialog
v-model="importProgressVisible"
title="正在导入价格"
:title="progressDialogTitle"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
@@ -117,10 +142,11 @@ import {
surchargeAppliesToRow,
toDetailPayload
} from '../../utils/priceSet'
import { getPriceSet, importSetDetailPricesAsync, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
import { getPriceSet, listSetDetailByProduct, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
const props = defineProps({
details: { type: Array, required: true },
products: { type: Array, default: () => [] },
priceSetId: { type: [Number, String], default: undefined },
psvId: { type: [Number, String], default: undefined },
setType: { type: String, default: 'FIXED' },
@@ -170,33 +196,52 @@ const importProgressVisible = ref(false)
const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const progressDialogTitle = ref('正在导入价格')
const importDesc = [
'适用威狮B2C小包价表(含「产品目录」)。',
'按产品+国家+分区(有则匹配)+重量段匹配已有套餐明细,仅更新运费/挂号费。',
'仅固定价套餐可导入;未匹配行不会新增明细。'
'按产品+国家+分区(有则匹配)+重量段:命中则更新运费/挂号费,未命中则新增套餐明细。',
'仅固定价套餐可导入;可与「拉取产品列表」二选一或叠加使用。'
]
const typeHint = computed(() => '· 当前:' + setTypeLabel(props.setType))
const productGroups = computed(() => {
const map = new Map()
for (const d of props.details) {
if (!d.productCode) continue
if (!map.has(d.productCode)) {
const meta = props.refData.productMetaOf(d.productCode) || {}
map.set(d.productCode, {
productCode: d.productCode,
productName: d.productName || meta.name || d.productCode,
serviceLevel: meta.serviceLevel,
cargoAttr: meta.cargoAttr,
destRegion: meta.destRegion,
taxMode: meta.taxMode,
rows: []
})
/** Show pull/import when this version has no set-product rows yet. */
const showBuildActions = computed(() => !(props.products || []).some(p => p && p.productCode))
/** productCode -> detail rows (loaded on demand) */
const detailByProduct = reactive({})
function clearDetailCache() {
Object.keys(detailByProduct).forEach(k => delete detailByProduct[k])
}
async function loadProductDetails(productCode, force) {
if (!productCode) return []
if (!force && detailByProduct[productCode]) return detailByProduct[productCode]
if (!props.psvId) return []
const res = await listSetDetailByProduct(props.psvId, productCode)
const rows = (res.data || []).map(d => hydrateRow(d, props.setType))
detailByProduct[productCode] = rows
// keep parent details array in sync for any legacy callers
if (Array.isArray(props.details)) {
for (let i = props.details.length - 1; i >= 0; i--) {
if (props.details[i] && props.details[i].productCode === productCode) props.details.splice(i, 1)
}
map.get(d.productCode).rows.push(d)
props.details.push(...rows)
}
return [...map.values()]
return rows
}
const productGroups = computed(() => {
return (props.products || []).filter(p => p && p.productCode).map(p => ({
productCode: p.productCode,
productName: p.productName || p.productCode,
serviceLevel: p.serviceLevel,
cargoAttr: p.cargoAttr,
destRegion: p.destRegion,
taxMode: p.taxMode,
rows: detailByProduct[p.productCode] || []
}))
})
const filteredProducts = computed(() => {
@@ -231,6 +276,7 @@ async function runImportAsync(uploadFile) {
if (!raw) {
throw new Error('请先选择文件')
}
progressDialogTitle.value = '正在导入价格'
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = '上传并解析中…'
@@ -239,24 +285,7 @@ async function runImportAsync(uploadFile) {
const { task } = await importSetDetailPricesAsync(
raw,
{ priceSetId: props.priceSetId, psvId: props.psvId },
(t) => {
importProgressPercent.value = Math.min(100, Number(t.percent) || 0)
if (t.status === 'PENDING') importProgressMessage.value = '排队中…'
else if (t.status === 'RUNNING') {
const m = t.message || ''
if (m === 'parsing excel') importProgressMessage.value = '正在解析价表…'
else if (m === 'matching details') importProgressMessage.value = '正在匹配并更新价格…'
else if (m === 'finishing') importProgressMessage.value = '收尾中…'
else if (m === 'queued') importProgressMessage.value = '排队中…'
else importProgressMessage.value = m || '正在导入…'
} else if (t.status === 'SUCCESS') {
importProgressMessage.value = t.message || '导入完成'
importProgressStatus.value = 'success'
} else if (t.status === 'FAIL') {
importProgressMessage.value = t.error || t.message || '导入失败'
importProgressStatus.value = 'exception'
}
}
applyBuildProgress
)
importProgressPercent.value = 100
importProgressStatus.value = 'success'
@@ -272,79 +301,113 @@ async function runImportAsync(uploadFile) {
}
}
function applyBuildProgress(t) {
importProgressPercent.value = Math.min(100, Number(t.percent) || 0)
if (t.status === 'PENDING') importProgressMessage.value = '排队中…'
else if (t.status === 'RUNNING') {
const m = t.message || ''
if (m === 'parsing excel') importProgressMessage.value = '正在解析价表…'
else if (m === 'matching details') importProgressMessage.value = '正在匹配并更新价格…'
else if (m === 'pulling library') importProgressMessage.value = '正在拉取产品库…'
else if (m === 'finishing') importProgressMessage.value = '收尾中…'
else if (m === 'queued') importProgressMessage.value = '排队中…'
else if (m.indexOf('importing ') === 0) importProgressMessage.value = '正在导入产品 ' + m.slice('importing '.length)
else if (m.indexOf('saving details ') === 0) importProgressMessage.value = '正在保存明细 ' + m.slice('saving details '.length)
else if (m === 'details exist, skip pull') importProgressMessage.value = '明细已存在,已跳过拉取'
else if (m === 'saving surcharges') importProgressMessage.value = '正在保存附加费…'
else importProgressMessage.value = m || '处理中…'
} else if (t.status === 'SUCCESS') {
importProgressMessage.value = t.message || '完成'
importProgressStatus.value = 'success'
} else if (t.status === 'FAIL') {
importProgressMessage.value = t.error || t.message || '失败'
importProgressStatus.value = 'exception'
}
}
async function onImportSuccess() {
await reloadDetailsFromServer()
}
async function reloadDetailsFromServer() {
if (!props.priceSetId) return
try {
clearDetailCache()
const res = await getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const qPsv = props.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const current = byQuery || data.version || versions[0] || {}
const src = (current.details && current.details.length) ? current.details : (data.details || [])
props.details.splice(0, props.details.length, ...src.map(d => hydrateRow(d, props.setType)))
if (Array.isArray(props.details)) {
props.details.splice(0, props.details.length)
}
if (Array.isArray(props.products)) {
props.products.splice(0, props.products.length, ...(current.products || []).map(p => ({ ...p })))
}
if (priceVisible.value && editCode.value) {
const group = productGroups.value.find(p => p.productCode === editCode.value)
editRows.value = group ? group.rows : []
editGroup.value = group || null
await stampPriceFloors(editRows.value, editCode.value)
const rows = await loadProductDetails(editCode.value, true)
editRows.value = rows
editGroup.value = productGroups.value.find(p => p.productCode === editCode.value) || null
}
} catch (e) {}
}
/** enter step 3: pull all library products + country prices (skip already added) */
async function handlePullLibrary() {
if (!props.priceSetId) {
proxy.$modal.msgWarning('缺少套餐ID')
return
}
const hasProducts = (props.products || []).some(p => p && p.productCode)
let force = false
if (hasProducts) {
try {
await proxy.$modal.confirm('当前版本已有明细,拉取将清空并按产品库重建,是否继续?')
force = true
} catch (e) {
return
}
}
await pullAllFromLibrary({ force, silent: false })
}
/** Backend pull product/product_country into prc_price_set_detail (async). */
async function pullAllFromLibrary(opts = {}) {
const silent = !!opts.silent
const replace = !!opts.replace
if (!(props.refData.productOptions.value || []).length) {
await props.refData.loadProducts()
}
const products = props.refData.productOptions.value || []
if (!products.length) {
if (!silent) proxy.$modal.msgWarning('产品库无可用产品')
const force = !!opts.force || !!opts.replace
if (!props.priceSetId) {
if (!silent) proxy.$modal.msgWarning('缺少套餐ID')
return { added: 0 }
}
if (replace) {
props.details.splice(0, props.details.length)
}
const used = new Set((props.details || []).map(d => d.productCode).filter(Boolean))
const toAdd = products.filter(p => !used.has(p.value))
if (!toAdd.length) return { added: 0 }
progressDialogTitle.value = '正在拉取产品列表'
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = '排队中…'
importProgressStatus.value = undefined
pulling.value = true
let added = 0
try {
// parallel fetch countries/surcharges, then push rows
await Promise.all(toAdd.map(async (p) => {
const code = p.value
const name = p.name || props.refData.productNameOf(code)
await props.refData.ensureSurcharges(code)
if (isDiscountType(props.setType)) {
const row = createEmptyDetailRow()
row.productCode = code
row.productName = name
row.surcharges = props.refData.surchargesOf(row)
props.details.push(row)
added++
return
}
const open = await props.refData.ensureCountries(code, props.effectiveTime)
if (!open.length) return
open.forEach(item => {
const row = countryToDetail(item, code, name)
row.surcharges = props.refData.surchargesOf(row)
props.details.push(row)
})
added++
}))
const { task } = await pullSetDetailsFromLibraryAsync(
{ priceSetId: props.priceSetId, psvId: props.psvId, force },
applyBuildProgress
)
importProgressPercent.value = 100
importProgressStatus.value = 'success'
await reloadDetailsFromServer()
if (!silent) {
if (added > 0) proxy.$modal.msgSuccess('已从产品库全量拉取 ' + added + ' 个产品')
else proxy.$modal.msgWarning('未拉取到可展示的产品价格明细,请检查产品库国家配置')
proxy.$modal.msgSuccess((task && task.message) ? task.message : '拉取完成')
}
return { added: (props.products || []).length }
} catch (e) {
importProgressStatus.value = 'exception'
const tip = (e && e.task && e.task.error) || (e && e.message) || '拉取失败'
importProgressMessage.value = tip
if (!silent) proxy.$modal.msgError(tip)
await new Promise(resolve => setTimeout(resolve, 600))
return { added: 0 }
} finally {
pulling.value = false
importProgressVisible.value = false
}
return { added }
}
function countryToDetail(item, productCode, productName) {
@@ -362,10 +425,6 @@ function countryToDetail(item, productCode, productName) {
row.productCountryId = item.detailId
const baseFv = item.freightPrice != null ? Number(item.freightPrice) : undefined
const baseReg = item.registrationFee != null ? Number(item.registrationFee) : undefined
row._baseFreight = baseFv
row._baseReg = baseReg
row._floorFreight = baseFv
row._floorReg = baseReg
row.fvValue = baseFv
row.fvRegistrationFee = baseReg
row.effectiveDate = item.effectiveDate
@@ -374,22 +433,39 @@ function countryToDetail(item, productCode, productName) {
return hydrateRow(row, props.setType)
}
function openPrice(row) {
async function openPrice(row) {
if (isDiscountType(props.setType)) {
discCode.value = row.productCode
discTitle.value = row.productCode + ' · ' + (row.productName || '') + ' · 产品折扣'
const hit = (props.details || []).find(d => d.productCode === row.productCode)
discValue.value = hit && hit.discountValue != null ? Number(hit.discountValue) : undefined
try {
const rows = await loadProductDetails(row.productCode)
const hit = rows[0]
discValue.value = hit && hit.discountValue != null ? Number(hit.discountValue) : undefined
} catch (e) {
discValue.value = undefined
}
discVisible.value = true
return
}
editCode.value = row.productCode
editName.value = row.productName || ''
editRows.value = row.rows || []
editGroup.value = row
Promise.resolve(stampPriceFloors(editRows.value, row.productCode)).finally(() => {
try {
editRows.value = await loadProductDetails(row.productCode)
priceVisible.value = true
})
} catch (e) {
proxy.$modal.msgError((e && e.message) ? e.message : '加载产品价格失败')
}
}
/** list-row surcharge: load that product details first */
async function openSurchargeForProduct(row) {
try {
const rows = await loadProductDetails(row.productCode)
await openSurcharge({ ...row, rows })
} catch (e) {
proxy.$modal.msgError((e && e.message) ? e.message : '加载产品明细失败')
}
}
/** 抽屉里点附加费:带行 = 行级(只改该行,国家+重量段),不带 = 产品级(覆盖全部明细行) */
@@ -437,47 +513,6 @@ async function onRowPriceChange(row) {
}
}
function applyImportedFloor(row, hit) {
if (!row || !hit) return
if (hit.freightPrice != null) {
row._floorFreight = Number(hit.freightPrice)
row._baseFreight = Number(hit.freightPrice)
}
if (hit.registrationFee != null) {
row._floorReg = Number(hit.registrationFee)
row._baseReg = Number(hit.registrationFee)
}
}
function findImportedCountry(rows, row) {
if (!rows || !rows.length || !row) return null
if (row.productCountryId != null) {
const byId = rows.find(item => Number(item.detailId) === Number(row.productCountryId))
if (byId) return byId
}
return rows.find(item => {
const sameCountry = (row.countryCode && item.countryCode
&& String(row.countryCode).toUpperCase() === String(item.countryCode).toUpperCase())
|| (!row.countryCode && row.countryName && item.countryName && row.countryName === item.countryName)
if (!sameCountry) return false
const rz = String(row.zoneCode || '').toUpperCase()
const iz = String(item.zoneCode || '').toUpperCase()
if (rz || iz) {
if (rz !== iz) return false
}
if (row.weightMin != null && item.weightMin != null && Number(row.weightMin) !== Number(item.weightMin)) return false
if (row.weightMax != null && item.weightMax != null && Number(row.weightMax) !== Number(item.weightMax)) return false
return row.weightMin != null || row.weightMax != null
}) || null
}
function stampPriceFloors(rows, productCode) {
if (!props.refData || !productCode || !rows || !rows.length) return Promise.resolve()
return props.refData.ensureCountries(productCode, props.effectiveTime).then(list => {
rows.forEach(row => applyImportedFloor(row, findImportedCountry(list, row)))
})
}
async function saveDiscount() {
const hit = (props.details || []).find(d => d.productCode === discCode.value)
if (hit) {
@@ -607,16 +642,25 @@ function onSurchargeConfirm(picked) {
}
async function removeProduct(code) {
const ids = props.details.filter(d => d.productCode === code && d.psdId).map(d => d.psdId)
if (ids.length) {
try {
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)) {
for (let i = props.details.length - 1; i >= 0; i--) {
if (props.details[i] && props.details[i].productCode === code) props.details.splice(i, 1)
}
}
for (let i = props.details.length - 1; i >= 0; i--) {
if (props.details[i].productCode === code) props.details.splice(i, 1)
if (Array.isArray(props.products)) {
for (let i = props.products.length - 1; i >= 0; i--) {
if (props.products[i] && props.products[i].productCode === code) props.products.splice(i, 1)
}
}
proxy.$modal.msgSuccess('已移除产品明细')
}
@@ -647,6 +691,58 @@ defineExpose({ pullAllFromLibrary })
font-size: 13px;
word-break: break-all;
}
.build-choice {
padding: 48px 24px 56px;
text-align: center;
}
.build-choice__tip {
margin: 0 0 28px;
color: #909399;
font-size: 14px;
}
.build-choice__cards {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
}
.build-card {
width: 280px;
min-height: 120px;
padding: 24px 20px;
border: 1px solid #e4e7ed;
border-radius: 10px;
background: #fff;
cursor: pointer;
text-align: left;
transition: border-color 0.2s, box-shadow 0.2s;
}
.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;
}
.filters {
display: flex;
align-items: center;
+9 -18
View File
@@ -13,6 +13,7 @@
v-show="active === 1"
ref="detailsRef"
:details="form.details"
:products="form.products"
:price-set-id="form.priceSetId"
:psv-id="form.version.psvId"
:set-type="form.basic.setType"
@@ -49,7 +50,7 @@
<script setup name="PriceSetCreate">
import StepBasicInfo from './components/create/StepBasicInfo.vue'
import StepDetails from './components/create/StepDetails.vue'
import { usePriceSetRefData, useDetailCascade } from './composables/usePriceSetRefData'
import { usePriceSetRefData } from './composables/usePriceSetRefData'
import {
createEmptyBasicInfo,
nextVersionNo,
@@ -62,7 +63,6 @@ const { proxy } = getCurrentInstance()
const route = useRoute()
const refData = usePriceSetRefData()
const cascade = useDetailCascade(refData)
const active = ref(0)
const saving = ref(false)
@@ -85,6 +85,7 @@ const form = reactive({
basic: createEmptyBasicInfo(),
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined },
details: [],
products: [],
customers: []
})
@@ -140,30 +141,24 @@ async function runSaveWithProgress(payload) {
}
}
/** Step1 -> Step2: create package prices from library when needed */
/** Step1 -> Step2: create empty draft version; pull/import on Step 2 */
async function goStep2() {
const validBasic = await basicRef.value.validate()
if (!validBasic) return
const hasPersistedDetails = (form.details || []).some(d => d && d.psdId)
if (form.priceSetId && form.version.psvId && hasPersistedDetails) {
if (form.priceSetId && form.version.psvId) {
active.value = 1
return
}
creating.value = true
try {
form.details.splice(0, form.details.length)
const priceSetId = await runSaveWithProgress({
...buildPayload(),
details: [],
pullFromLibrary: 'Y'
})
const priceSetId = await runSaveWithProgress(buildPayload())
form.priceSetId = priceSetId
const fresh = await getPriceSet(priceSetId)
applySetData(fresh.data || {}, false)
active.value = 1
proxy.$modal.msgSuccess('已创建套餐价格,可在第 2 步编辑')
proxy.$modal.msgSuccess('已创建空套餐版本,请在第 2 步拉取或导入价格')
} catch (e) {
// surfaced
} finally {
@@ -269,12 +264,8 @@ function applySetData(data, asCopy) {
form.version.effectiveTime = current.effectiveTime
form.version.expireTime = current.expireTime
}
const srcDetails = (current.details && current.details.length) ? current.details : (data.details || [])
form.details = srcDetails.map(d => {
const row = cascade.hydrateRow(d, form.basic.setType)
if (asCopy) row.psdId = undefined
return row
})
form.details = []
form.products = asCopy ? [] : ((current.products || []).map(p => ({ ...p })))
form.customers = (data.customers || []).map(c => ({ ...c }))
}