diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/BillingController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/BillingController.java index 30da11e0..e36f5bfb 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/BillingController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/BillingController.java @@ -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. */ diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PriceSetController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PriceSetController.java index 9b292323..cf4a3080 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PriceSetController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PriceSetController.java @@ -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 payload = new HashMap(); + payload.put("taskId", taskId); + return success(payload); + } + /** * Async batch write of detail surcharge snapshots. Returns taskId; poll /save/task/{taskId}. */ diff --git a/bms-back/bms-admin/src/main/resources/i18n/messages.properties b/bms-back/bms-admin/src/main/resources/i18n/messages.properties index a03777a5..14ecda1e 100644 --- a/bms-back/bms-admin/src/main/resources/i18n/messages.properties +++ b/bms-back/bms-admin/src/main/resources/i18n/messages.properties @@ -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不能为空 diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetDetail.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetDetail.java index f7b89760..f433842a 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetDetail.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetDetail.java @@ -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; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetProduct.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetProduct.java new file mode 100644 index 00000000..e87e10e4 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetProduct.java @@ -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; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersion.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersion.java index 47af59c0..ec4ee3ca 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersion.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersion.java @@ -28,5 +28,6 @@ public class PrcPriceSetVersion extends BaseEntity private String status; private List details; + private List products; private List surcharges; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetMapper.java index 23819032..86f20570 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetMapper.java @@ -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 selectPrcPriceSetList(PrcPriceSet set); public int insertPrcPriceSet(PrcPriceSet set); diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetProductMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetProductMapper.java new file mode 100644 index 00000000..90cd6013 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetProductMapper.java @@ -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 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); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcPriceSetService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcPriceSetService.java index 29df6fa8..813ca921 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcPriceSetService.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcPriceSetService.java @@ -60,6 +60,11 @@ public interface IPrcPriceSetService public List selectDetailList(PrcPriceSetDetail detail); + /** + * Load all detail rows for one product under a version (no pagination). + */ + public List 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); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/ITmsPriceQuoteService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/ITmsPriceQuoteService.java index 1475ebcf..3ca2e4b7 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/ITmsPriceQuoteService.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/ITmsPriceQuoteService.java @@ -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 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 listBoundPackageProducts(String customerCode, Date bizDate); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java index 9265dabd..cda30ada 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java @@ -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 selectDetailListByProduct(Long psvId, String productCode) + { + if (psvId == null || StringUtils.isEmpty(productCode)) + { + return new ArrayList(); + } + PrcPriceSetDetail q = new PrcPriceSetDetail(); + q.setPsvId(psvId); + q.setProductCode(productCode.trim()); + return selectDetailList(q); + } + @Override public Map 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> 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 details = detailMapper.selectPrcPriceSetDetailList(q); - if (details == null || details.isEmpty()) + if (details == null) { - throw new ServiceException(MessageUtils.message("price.set.import.noDetails")); + details = new ArrayList(); } Map> detailIndex = new HashMap>(); 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 touched = new HashSet(); + int productTotal = byProduct.size(); + int productIndex = 0; for (Map.Entry> 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 candidates = detailIndex.get(productCode); - if (candidates == null || candidates.isEmpty()) + if (candidates == null) { - skippedProduct++; - continue; + candidates = new ArrayList(); + 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 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 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 touchKeys = new HashSet(); 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 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()); 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 details = detailMapper.selectPrcPriceSetDetailList(dq); - PrcPriceSetVersionSurcharge sq = new PrcPriceSetVersionSurcharge(); - sq.setPsvId(version.getPsvId()); - List surcharges = versionSurchargeMapper.selectPrcPriceSetVersionSurchargeList(sq); - if (surcharges == null) - { - surcharges = new ArrayList(); - } - version.setSurcharges(surcharges); - Map> byPsd = new HashMap>(); - for (PrcPriceSetVersionSurcharge sc : surcharges) - { - if (sc.getPsdId() == null) - { - continue; - } - List list = byPsd.get(sc.getPsdId()); - if (list == null) - { - list = new ArrayList(); - byPsd.put(sc.getPsdId(), list); - } - list.add(sc); - } - if (details != null) - { - for (PrcPriceSetDetail d : details) - { - List list = byPsd.get(d.getPsdId()); - d.setSurcharges(list != null ? list : new ArrayList()); - } - } - version.setDetails(details); + PrcPriceSetProduct pq = new PrcPriceSetProduct(); + pq.setPsvId(version.getPsvId()); + List products = setProductMapper.selectPrcPriceSetProductList(pq); + version.setProducts(products == null ? new ArrayList() : products); + // details + surcharge snapshots are loaded on demand (edit price / detail drawer) + version.setDetails(new ArrayList()); + version.setSurcharges(new ArrayList()); } private PrcPriceSetVersion pickCurrentVersion(List 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 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 details = detailMapper.selectPrcPriceSetDetailList(query); - set.setDetails(details); - version.setDetails(details); fillVersionChildren(version); + set.setDetails(new ArrayList()); 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 details = detailMapper.selectPrcPriceSetDetailList(query); - set.setDetails(details); - version.setDetails(details); fillVersionChildren(version); + set.setDetails(new ArrayList()); } /** @@ -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 keepCodes = new HashSet(); + 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 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 details) { diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java index fa9e9ec4..a21c181b 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java @@ -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 listBoundPackageProducts(String customerCode, Date bizDate) + { + if (StringUtils.isEmpty(customerCode)) + { + return Collections.emptyList(); + } + List 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 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 loadProductsForPackage(Long priceSetId, Date asOf) + { + PrcPriceSetVersion active = versionMapper.selectActiveByPriceSetAt(priceSetId, asOf); + List rows = listProductsByPsvId(active == null ? null : active.getPsvId()); + if (rows != null && !rows.isEmpty()) + { + return rows; + } + PrcPriceSetVersion q = new PrcPriceSetVersion(); + q.setPriceSetId(priceSetId); + List 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 listProductsByPsvId(Long psvId) + { + if (psvId == null) + { + return Collections.emptyList(); + } + PrcPriceSetProduct q = new PrcPriceSetProduct(); + q.setPsvId(psvId); + List rows = setProductMapper.selectPrcPriceSetProductList(q); + if (rows == null || rows.isEmpty()) + { + return Collections.emptyList(); + } + Map byCode = new LinkedHashMap(); + for (PrcPriceSetProduct row : rows) + { + if (row == null || StringUtils.isEmpty(row.getProductCode())) + { + continue; + } + byCode.putIfAbsent(row.getProductCode(), row); + } + return new ArrayList(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( diff --git a/bms-back/bms-system/src/main/java/com/bms/price/task/PrcPriceSetSaveAsyncRunner.java b/bms-back/bms-system/src/main/java/com/bms/price/task/PrcPriceSetSaveAsyncRunner.java index 9e54f37e..192d8d86 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/task/PrcPriceSetSaveAsyncRunner.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/task/PrcPriceSetSaveAsyncRunner.java @@ -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(); diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml index a50ac8ad..6457acc9 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml @@ -8,6 +8,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -35,7 +36,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - 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" + + + + where psp_id = #{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() + ) + + + + update prc_price_set_product + + product_name = #{productName}, + service_level = #{serviceLevel}, + cargo_attr = #{cargoAttr}, + dest_region = #{destRegion}, + update_note = #{updateNote}, + tax_mode = #{taxMode}, + source_product_id = #{sourceProductId}, + source_sheet = #{sourceSheet}, + update_time = sysdate() + + where psp_id = #{pspId} + + + + delete from prc_price_set_product where psv_id = #{psvId} + + + + delete from prc_price_set_product + where psv_id = #{psvId} + and product_code = #{productCode} + + + + 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 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 + + + + 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 <> sp.psp_id) + + + diff --git a/bms-back/sql/bms_init.sql b/bms-back/sql/bms_init.sql index 5aa7e1a7..d020ac36 100644 --- a/bms-back/sql/bms_init.sql +++ b/bms-back/sql/bms_init.sql @@ -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` -- ---------------------------- diff --git a/bms-back/sql/price/prc_price_set_product_alter.sql b/bms-back/sql/price/prc_price_set_product_alter.sql new file mode 100644 index 00000000..a25228c3 --- /dev/null +++ b/bms-back/sql/price/prc_price_set_product_alter.sql @@ -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; diff --git a/bms-front/src/api/price/billing.js b/bms-front/src/api/price/billing.js index 6bfe81e4..66d9c803 100644 --- a/bms-front/src/api/price/billing.js +++ b/bms-front/src/api/price/billing.js @@ -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({ diff --git a/bms-front/src/api/price/set.js b/bms-front/src/api/price/set.js index 226993b7..62301de0 100644 --- a/bms-front/src/api/price/set.js +++ b/bms-front/src/api/price/set.js @@ -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) { diff --git a/bms-front/src/views/price/billing/quote.vue b/bms-front/src/views/price/billing/quote.vue index 37c020c8..45c75b9d 100644 --- a/bms-front/src/views/price/billing/quote.vue +++ b/bms-front/src/views/price/billing/quote.vue @@ -6,22 +6,24 @@ - -
与套餐绑定客户一致;未绑定套餐时报错
+ +
产品列表来自该客户绑定套餐;未绑定或套餐无产品时为空
@@ -262,8 +264,8 @@ diff --git a/bms-front/src/views/price/set/components/create/PriceEditPanel.vue b/bms-front/src/views/price/set/components/create/PriceEditPanel.vue index 04d21c57..9915d262 100644 --- a/bms-front/src/views/price/set/components/create/PriceEditPanel.vue +++ b/bms-front/src/views/price/set/components/create/PriceEditPanel.vue @@ -19,6 +19,7 @@ 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 diff --git a/bms-front/src/views/price/set/components/create/StepDetails.vue b/bms-front/src/views/price/set/components/create/StepDetails.vue index d08b950f..7631eb3a 100644 --- a/bms-front/src/views/price/set/components/create/StepDetails.vue +++ b/bms-front/src/views/price/set/components/create/StepDetails.vue @@ -9,16 +9,40 @@ +
+

暂无套餐销售价,请选择一种建价方式

+
+ + +
+
+ + [] }, 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; diff --git a/bms-front/src/views/price/set/create.vue b/bms-front/src/views/price/set/create.vue index e56a496c..4a804e05 100644 --- a/bms-front/src/views/price/set/create.vue +++ b/bms-front/src/views/price/set/create.vue @@ -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 @@