From 4441aa12bf2d3ff0d0648819e0d683aaf23ce354 Mon Sep 17 00:00:00 2001 From: wuweihua <88888888> Date: Wed, 16 Sep 2026 17:14:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=A5=97=E9=A4=90=E4=BB=B7?= =?UTF-8?q?=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/price/PriceSetController.java | 48 +- .../price/PriceStatusController.java | 3 +- .../controller/price/SurchargeController.java | 13 + .../main/resources/i18n/messages.properties | 13 +- .../com/ruoyi/price/domain/PrcPriceSet.java | 9 + .../ruoyi/price/domain/PrcPriceSetDetail.java | 14 + .../price/domain/PrcPriceSetSaveTask.java | 83 ++ .../price/domain/PrcPriceSetVersion.java | 32 + .../domain/PrcPriceSetVersionSurcharge.java | 32 + .../price/mapper/PrcPriceSetDetailMapper.java | 26 + .../mapper/PrcPriceSetVersionMapper.java | 27 + .../PrcPriceSetVersionSurchargeMapper.java | 36 + .../price/service/IPrcPriceSetService.java | 23 + .../service/impl/PrcPriceSetServiceImpl.java | 1071 +++++++++++++++-- .../price/task/BmsStatusRefreshTask.java | 33 +- .../task/PrcPriceSetSaveAsyncRunner.java | 70 ++ .../mapper/price/PrcPriceSetDetailMapper.xml | 145 ++- .../mapper/price/PrcPriceSetMapper.xml | 1 + .../mapper/price/PrcPriceSetVersionMapper.xml | 103 ++ .../PrcPriceSetVersionSurchargeMapper.xml | 208 ++++ .../sql/price/README_price_set_version.md | 34 + bms-back/sql/price/price_set.sql | 8 +- .../price/price_set_detail_unique_alter.sql | 22 + .../price_set_detail_weight_eff_alter.sql | 38 + bms-back/sql/price/price_set_version.sql | 152 +++ .../price/price_set_version_eff_nullable.sql | 23 + bms-front/src/api/price/set.js | 75 ++ bms-front/src/api/price/surcharge.js | 9 + .../set/components/create/PriceEditDrawer.vue | 305 +++++ .../set/components/create/StepBasicInfo.vue | 10 - .../set/components/create/StepCustomers.vue | 89 +- .../set/components/create/StepDetails.vue | 603 +++++++--- .../set/components/create/StepVersion.vue | 146 +++ .../components/create/SurchargeCountCell.vue | 54 + .../create/SurchargePickerDialog.vue | 137 +++ .../components/detail/SetBasicInfoCard.vue | 5 - .../components/detail/SetCustomerTable.vue | 63 +- .../set/components/detail/SetDetailDialog.vue | 1 + .../set/components/detail/SetDetailHeader.vue | 62 +- .../set/components/detail/SetDetailTable.vue | 18 +- .../detail/SetVersionDetailDrawer.vue | 222 ++++ .../set/components/detail/SetVersionTable.vue | 131 ++ .../set/composables/usePriceSetRefData.js | 173 ++- bms-front/src/views/price/set/create.vue | 272 +++-- bms-front/src/views/price/set/detail.vue | 107 +- bms-front/src/views/price/set/priceSet.vue | 88 +- .../src/views/price/set/styles/dialog.scss | 21 + .../src/views/price/set/utils/priceSet.js | 82 +- 48 files changed, 4258 insertions(+), 682 deletions(-) create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetSaveTask.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersion.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersionSurcharge.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetVersionMapper.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetVersionSurchargeMapper.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/PrcPriceSetSaveAsyncRunner.java create mode 100644 bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetVersionMapper.xml create mode 100644 bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetVersionSurchargeMapper.xml create mode 100644 bms-back/sql/price/README_price_set_version.md create mode 100644 bms-back/sql/price/price_set_detail_unique_alter.sql create mode 100644 bms-back/sql/price/price_set_detail_weight_eff_alter.sql create mode 100644 bms-back/sql/price/price_set_version.sql create mode 100644 bms-back/sql/price/price_set_version_eff_nullable.sql create mode 100644 bms-front/src/views/price/set/components/create/PriceEditDrawer.vue create mode 100644 bms-front/src/views/price/set/components/create/StepVersion.vue create mode 100644 bms-front/src/views/price/set/components/create/SurchargeCountCell.vue create mode 100644 bms-front/src/views/price/set/components/create/SurchargePickerDialog.vue create mode 100644 bms-front/src/views/price/set/components/detail/SetVersionDetailDrawer.vue create mode 100644 bms-front/src/views/price/set/components/detail/SetVersionTable.vue diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceSetController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceSetController.java index a266e676..25c9895f 100644 --- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceSetController.java +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceSetController.java @@ -1,6 +1,8 @@ package com.ruoyi.web.controller.price; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; @@ -17,9 +19,11 @@ import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.price.domain.PrcCustomerPriceSet; import com.ruoyi.price.domain.PrcPriceSet; import com.ruoyi.price.domain.PrcPriceSetDetail; +import com.ruoyi.price.domain.PrcPriceSetSaveTask; import com.ruoyi.price.service.IPrcPriceSetService; /** @@ -57,8 +61,23 @@ public class PriceSetController extends BaseController { set.setCreateBy(getUsername()); set.setUpdateBy(getUsername()); - Long id = priceSetService.savePrcPriceSet(set); - return success(id); + String taskId = priceSetService.submitSaveAsync(set); + // Do not pass bare String to success(): overload puts it into msg, not data. + Map payload = new HashMap(); + payload.put("taskId", taskId); + return success(payload); + } + + @PreAuthorize("@ss.hasPermi('price:set:query')") + @GetMapping("/save/task/{taskId}") + public AjaxResult getSaveTask(@PathVariable String taskId) + { + PrcPriceSetSaveTask task = priceSetService.getSaveTask(taskId); + if (task == null) + { + return error(MessageUtils.message("price.set.save.task.notExists")); + } + return success(task); } @PreAuthorize("@ss.hasPermi('price:set:edit')") @@ -102,6 +121,31 @@ public class PriceSetController extends BaseController return toAjax(priceSetService.reject(priceSetId, getUsername())); } + @PreAuthorize("@ss.hasPermi('price:set:edit')") + @Log(title = "Price set version activate", businessType = BusinessType.UPDATE) + @PutMapping("/version/activate/{psvId}") + public AjaxResult activateVersion(@PathVariable Long psvId) + { + return toAjax(priceSetService.activateVersion(psvId, getUsername())); + } + + @PreAuthorize("@ss.hasPermi('price:set:add')") + @Log(title = "Price set version copy", businessType = BusinessType.INSERT) + @PostMapping("/version/copy/{psvId}") + public AjaxResult copyVersion(@PathVariable Long psvId) + { + Long newPsvId = priceSetService.copyVersion(psvId, getUsername()); + return success(newPsvId); + } + + @PreAuthorize("@ss.hasPermi('price:set:edit')") + @Log(title = "Price set version delete", businessType = BusinessType.DELETE) + @DeleteMapping("/version/{psvId}") + public AjaxResult deleteVersion(@PathVariable Long psvId) + { + return toAjax(priceSetService.deleteVersion(psvId, getUsername())); + } + @PreAuthorize("@ss.hasPermi('price:set:audit')") @Log(title = "价格套餐停用", businessType = BusinessType.UPDATE) @PutMapping("/disable/{priceSetId}") diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceStatusController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceStatusController.java index c3a05a8c..8e9224a5 100644 --- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceStatusController.java +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/PriceStatusController.java @@ -33,7 +33,8 @@ public class PriceStatusController extends BaseController { int[] counts = refreshTask.refreshNow(); String msg = MessageUtils.message("price.status.refresh.success", - String.valueOf(counts[0]), String.valueOf(counts[1])); + String.valueOf(counts[0]), String.valueOf(counts[1]), + String.valueOf(counts.length > 2 ? counts[2] : 0)); return success(msg); } } \ No newline at end of file diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/SurchargeController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/SurchargeController.java index 6c08b22f..525700cc 100644 --- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/SurchargeController.java +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/SurchargeController.java @@ -63,6 +63,19 @@ public class SurchargeController extends BaseController return success(surchargeRelService.selectRelByProductCode(productCode)); } + /** + * 附加费配置全量(非分页,供套餐明细勾选使用) + * + * 不要求 price:surcharge:list 权限,套餐维护角色(price:set:*)也能读,避免套餐页拿到 403 空列表。 + */ + @PreAuthorize("@ss.hasPermi('price:surcharge:list') or @ss.hasPermi('price:surcharge:query')" + + " or @ss.hasPermi('price:set:query') or @ss.hasPermi('price:set:add') or @ss.hasPermi('price:set:edit')") + @GetMapping("/options") + public AjaxResult listOptions(PrcSurcharge surcharge) + { + return success(surchargeService.selectPrcSurchargeList(surcharge)); + } + /** * 绑定附加费到产品/明细(body: scId, productId, productCode, detailId;detailId 空=产品级) */ diff --git a/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties b/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties index 3c975a5e..a48b5b97 100644 --- a/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties +++ b/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties @@ -136,9 +136,20 @@ price.quote.quoteIdRequired=报价ID为空 price.quote.hitCustomerFixedPackageDetail=命中客户套餐固定价格明细 # ---- 状态自动刷新 ---- -price.status.refresh.success=状态刷新完成:bms_product 更新 {0} 行,bms_product_country 更新 {1} 行 +price.status.refresh.success=状态刷新完成:bms_product 更新 {0} 行,bms_product_country 更新 {1} 行,价格套餐版本 {2} 套 # receivable bill price.bill.orderNoRequired=订单号为空 price.bill.billIdRequired=计费ID为空 price.bill.notFound=计费记录不存在 price.bill.bizDateRequired=业务日期为空 + +price.set.version.effectiveTime.empty=版本生效时间不能为空 +price.set.version.notExists=套餐版本不存在 +price.set.version.activate.statusNotAllowed=仅草稿版本可启动 +price.set.version.delete.statusNotAllowed=仅草稿版本可删除 +price.set.save.task.notExists=保存任务不存在或已过期 +price.set.library.empty=产品库无可用价格明细,请检查产品国家配置 +price.set.detail.duplicate.fixed=产品{0} 国家{1} 重量段{2} {3}(共{4}条) +price.set.detail.duplicate.discount=产品{0}(共{1}条) +price.set.detail.duplicate.hasZone=有分区({0}) +price.set.detail.duplicate.noZone=无分区 diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSet.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSet.java index 90c9a939..54f9ddba 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSet.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSet.java @@ -37,6 +37,15 @@ public class PrcPriceSet extends BaseEntity private Boolean expired; private String keyword; private String submitFlag; + /** current version on create/edit save */ + private PrcPriceSetVersion version; private List details; + private List versions; private List customers; + /** async save task id (not persisted) */ + private String saveTaskId; + /** Y: backend joins product library into details (skip front pull) */ + private String pullFromLibrary; + /** Y: even if version already has details, delete+re-pull from library */ + private String forcePullLibrary; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetDetail.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetDetail.java index e0b4e68a..b6347a69 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetDetail.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetDetail.java @@ -3,6 +3,7 @@ package com.ruoyi.price.domain; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; +import java.util.List; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; @@ -14,6 +15,8 @@ public class PrcPriceSetDetail implements Serializable private Long psdId; private Long priceSetId; + /** prc_price_set_version.psv_id */ + private Long psvId; private String productCode; private String productName; private String countryCode; @@ -27,6 +30,9 @@ public class PrcPriceSetDetail implements Serializable private Long zdId; private Long wsdId; private String weightLabel; + /** KG inclusive, same as bms_product_country */ + private BigDecimal weightMin; + private BigDecimal weightMax; /** bms_product_country.detail_id */ private Long productCountryId; private BigDecimal fvValue; @@ -35,8 +41,16 @@ public class PrcPriceSetDetail implements Serializable /** UP / DOWN / HALF_UP */ private String roundMode; private Integer prId; + @JsonFormat(pattern = "yyyy-MM-dd") + private Date effectiveDate; + /** null = open */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date expireDate; + private List surcharges; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createTime; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date updateTime; + /** query only: product/country/zone/weight keyword */ + private String keyword; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetSaveTask.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetSaveTask.java new file mode 100644 index 00000000..5b916e5f --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetSaveTask.java @@ -0,0 +1,83 @@ +package com.ruoyi.price.domain; + +import java.io.Serializable; + +/** + * Async save progress for price set (Redis). + */ +public class PrcPriceSetSaveTask implements Serializable +{ + private static final long serialVersionUID = 1L; + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_RUNNING = "RUNNING"; + public static final String STATUS_SUCCESS = "SUCCESS"; + public static final String STATUS_FAIL = "FAIL"; + + private String taskId; + private String status; + private int percent; + private String message; + private Long priceSetId; + private String error; + + public String getTaskId() + { + return taskId; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public String getStatus() + { + return status; + } + + public void setStatus(String status) + { + this.status = status; + } + + public int getPercent() + { + return percent; + } + + public void setPercent(int percent) + { + this.percent = percent; + } + + public String getMessage() + { + return message; + } + + public void setMessage(String message) + { + this.message = message; + } + + public Long getPriceSetId() + { + return priceSetId; + } + + public void setPriceSetId(Long priceSetId) + { + this.priceSetId = priceSetId; + } + + public String getError() + { + return error; + } + + public void setError(String error) + { + this.error = error; + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersion.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersion.java new file mode 100644 index 00000000..b0d64122 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersion.java @@ -0,0 +1,32 @@ +package com.ruoyi.price.domain; + +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** Price set version prc_price_set_version */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PrcPriceSetVersion extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long psvId; + private Long priceSetId; + /** V1 / V2 */ + private String versionNo; + private Integer versionSeq; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date effectiveTime; + /** null = open; set to next version effective_time when that version starts */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date expireTime; + /** 0 draft / 1 active / 2 expired */ + private String status; + + private List details; + private List surcharges; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersionSurcharge.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersionSurcharge.java new file mode 100644 index 00000000..5922099d --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcPriceSetVersionSurcharge.java @@ -0,0 +1,32 @@ +package com.ruoyi.price.domain; + +import java.math.BigDecimal; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** Price set version surcharge snapshot prc_price_set_version_surcharge */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PrcPriceSetVersionSurcharge extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long psvsId; + private Long psvId; + /** source prc_surcharge.sc_id */ + private Long scId; + /** prc_price_set_detail.psd_id */ + private Long psdId; + /** copied from detail */ + private String productCode; + private String scName; + private String feeType; + /** FIXED / WEIGHT / RATE */ + private String chargeType; + private BigDecimal chargeValue; + private String currency; + private String roundMode; + private String countryCode; + private String scNote; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetDetailMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetDetailMapper.java index d1667dc0..404bb492 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetDetailMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetDetailMapper.java @@ -1,5 +1,6 @@ package com.ruoyi.price.mapper; +import java.util.Date; import java.util.List; import org.apache.ibatis.annotations.Param; import com.ruoyi.price.domain.PrcPriceSetDetail; @@ -23,10 +24,35 @@ public interface PrcPriceSetDetailMapper public int deletePrcPriceSetDetailByPriceSetId(Long priceSetId); + public int deletePrcPriceSetDetailByPsvId(Long psvId); + + /** legacy rows: price_set_id match but psv_id is null */ + public int deleteOrphanDetailsByPriceSetId(Long priceSetId); + public int countProductByPriceSetId(Long priceSetId); public int countByPriceSetId(Long priceSetId); + public int countByPsvId(Long psvId); + + /** Copy all details from src version into new version (new psd_id, same price_set_id) */ + public int copyDetailsFromVersion(@Param("srcPsvId") Long srcPsvId, @Param("newPsvId") Long newPsvId); + + /** + * FIXED: insert details from active product + country library (asOf window or openOnly fallback). + * @return inserted row count + */ + public int insertFixedDetailsFromLibrary( + @Param("priceSetId") Long priceSetId, + @Param("psvId") Long psvId, + @Param("asOf") Date asOf, + @Param("openOnly") boolean openOnly); + + /** DISCOUNT: one detail row per active product */ + public int insertDiscountDetailsFromLibrary( + @Param("priceSetId") Long priceSetId, + @Param("psvId") Long psvId); + /** FIXED: product + country + zone + productCountryId / weight */ public PrcPriceSetDetail selectFixedMatch( @Param("priceSetId") Long priceSetId, diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetVersionMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetVersionMapper.java new file mode 100644 index 00000000..24003232 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetVersionMapper.java @@ -0,0 +1,27 @@ +package com.ruoyi.price.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.price.domain.PrcPriceSetVersion; + +/** + * Price set version + */ +public interface PrcPriceSetVersionMapper +{ + public PrcPriceSetVersion selectPrcPriceSetVersionById(Long psvId); + + public List selectPrcPriceSetVersionList(PrcPriceSetVersion version); + + public Integer selectMaxVersionSeq(@Param("priceSetId") Long priceSetId); + + public int insertPrcPriceSetVersion(PrcPriceSetVersion version); + + public int updatePrcPriceSetVersion(PrcPriceSetVersion version); + + public int deletePrcPriceSetVersionByIds(Long[] psvIds); + + public int deletePrcPriceSetVersionByPriceSetId(Long priceSetId); + + public List selectPriceSetIds(); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetVersionSurchargeMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetVersionSurchargeMapper.java new file mode 100644 index 00000000..b8c0cee9 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcPriceSetVersionSurchargeMapper.java @@ -0,0 +1,36 @@ +package com.ruoyi.price.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.price.domain.PrcPriceSetVersionSurcharge; + +/** + * Price set version surcharge snapshot + */ +public interface PrcPriceSetVersionSurchargeMapper +{ + public PrcPriceSetVersionSurcharge selectPrcPriceSetVersionSurchargeById(Long psvsId); + + public List selectPrcPriceSetVersionSurchargeList(PrcPriceSetVersionSurcharge surcharge); + + public int insertPrcPriceSetVersionSurcharge(PrcPriceSetVersionSurcharge surcharge); + + public int updatePrcPriceSetVersionSurcharge(PrcPriceSetVersionSurcharge surcharge); + + public int deletePrcPriceSetVersionSurchargeByIds(Long[] psvsIds); + + public int deleteByPsvId(Long psvId); + + public int deleteByPsdId(Long psdId); + + public List selectByPsdIds(@Param("psdIds") List psdIds); + + /** Snapshot all applicable product-rel surcharges for details under psvId (library create). */ + public int insertFromLibraryRels(@Param("psvId") Long psvId, @Param("createBy") String createBy); + + /** Copy surcharge snapshots from src version onto new version details (match by detail keys). */ + public int copySurchargesFromVersion( + @Param("srcPsvId") Long srcPsvId, + @Param("newPsvId") Long newPsvId, + @Param("createBy") String createBy); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IPrcPriceSetService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IPrcPriceSetService.java index 7a5f2685..57c698eb 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IPrcPriceSetService.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IPrcPriceSetService.java @@ -5,6 +5,7 @@ import com.ruoyi.price.domain.PrcCustomerPriceSet; import com.ruoyi.price.domain.PrcPriceSet; import com.ruoyi.price.domain.PrcPriceSetCheckItem; import com.ruoyi.price.domain.PrcPriceSetDetail; +import com.ruoyi.price.domain.PrcPriceSetSaveTask; /** * 价格套餐 服务层 @@ -31,6 +32,19 @@ public interface IPrcPriceSetService public int disable(Long priceSetId, String updateBy); + /** Activate a draft version: set effective_time=now, refresh version 0/1/2 */ + public int activateVersion(Long psvId, String updateBy); + + /** + * Copy source version into a new draft version under the same package: + * insert prc_price_set_version + copy prc_price_set_detail (+ surcharge snapshots). + * @return new psvId + */ + public Long copyVersion(Long sourcePsvId, String createBy); + + /** Delete a draft version and its details / surcharge snapshots */ + public int deleteVersion(Long psvId, String updateBy); + public Long copy(Long priceSetId, String createBy); public List validate(PrcPriceSet set); @@ -50,4 +64,13 @@ public interface IPrcPriceSetService public int updateCustomer(PrcCustomerPriceSet customer); public int deleteCustomerByIds(Long[] customerPricesetIds); + + /** chain expire_time = next version effective_time, then refresh 0/1/2 */ + public int refreshAllVersionStatus(); + + /** submit async save, return taskId immediately */ + public String submitSaveAsync(PrcPriceSet set); + + /** poll async save progress */ + public PrcPriceSetSaveTask getSaveTask(String taskId); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcPriceSetServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcPriceSetServiceImpl.java index 4f7193e4..48fe1f85 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcPriceSetServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcPriceSetServiceImpl.java @@ -4,27 +4,41 @@ import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.zip.CRC32; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.DateUtils; import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; import com.ruoyi.price.domain.BmsProductCountry; import com.ruoyi.price.domain.PrcCustomerPriceSet; import com.ruoyi.price.domain.PrcPriceSet; import com.ruoyi.price.domain.PrcPriceSetCheckItem; import com.ruoyi.price.domain.PrcPriceSetDetail; +import com.ruoyi.price.domain.PrcPriceSetSaveTask; +import com.ruoyi.price.domain.PrcPriceSetVersion; +import com.ruoyi.price.domain.PrcPriceSetVersionSurcharge; +import com.ruoyi.price.domain.PrcSurcharge; +import com.ruoyi.price.domain.PrcSurchargeRel; import com.ruoyi.price.mapper.BmsProductCountryMapper; import com.ruoyi.price.mapper.PrcCustomerPriceSetMapper; import com.ruoyi.price.mapper.PrcPriceSetDetailMapper; import com.ruoyi.price.mapper.PrcPriceSetMapper; +import com.ruoyi.price.mapper.PrcPriceSetVersionMapper; +import com.ruoyi.price.mapper.PrcPriceSetVersionSurchargeMapper; +import com.ruoyi.price.mapper.PrcSurchargeMapper; +import com.ruoyi.price.mapper.PrcSurchargeRelMapper; import com.ruoyi.price.service.IPrcPriceSetService; +import com.ruoyi.price.task.PrcPriceSetSaveAsyncRunner; /** * 价格套餐 服务层实现 @@ -41,6 +55,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService private static final String STATUS_STOP = "S"; private static final String TYPE_FIXED = "FIXED"; private static final String TYPE_DISCOUNT = "DISCOUNT"; + private static final String VER_DRAFT = "0"; + private static final String VER_EFFECT = "1"; + private static final String VER_EXPIRED = "2"; @Autowired private PrcPriceSetMapper setMapper; @@ -48,12 +65,28 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService @Autowired private PrcPriceSetDetailMapper detailMapper; + @Autowired + private PrcPriceSetVersionMapper versionMapper; + + @Autowired + private PrcPriceSetVersionSurchargeMapper versionSurchargeMapper; + + @Autowired + private PrcSurchargeRelMapper surchargeRelMapper; + + @Autowired + private PrcSurchargeMapper surchargeMapper; + @Autowired private PrcCustomerPriceSetMapper customerMapper; @Autowired private BmsProductCountryMapper productCountryMapper; + @Lazy + @Autowired + private PrcPriceSetSaveAsyncRunner saveAsyncRunner; + @Override public PrcPriceSet selectPrcPriceSetById(Long priceSetId) { @@ -61,12 +94,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService if (set != null) { fillExpired(set); - PrcPriceSetDetail query = new PrcPriceSetDetail(); - query.setPriceSetId(priceSetId); - set.setDetails(detailMapper.selectPrcPriceSetDetailList(query)); - PrcCustomerPriceSet cquery = new PrcCustomerPriceSet(); - cquery.setPriceSetId(priceSetId); - set.setCustomers(customerMapper.selectPrcCustomerPriceSetList(cquery)); + fillVersions(set); } return set; } @@ -82,11 +110,32 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService return list; } + @Override + public String submitSaveAsync(PrcPriceSet set) + { + String taskId = IdUtils.fastSimpleUUID(); + saveAsyncRunner.put(taskId, PrcPriceSetSaveTask.STATUS_PENDING, 0, "queued", null, null); + saveAsyncRunner.run(taskId, set); + return taskId; + } + + @Override + public PrcPriceSetSaveTask getSaveTask(String taskId) + { + if (StringUtils.isEmpty(taskId)) + { + return null; + } + return saveAsyncRunner.get(taskId); + } + @Override @Transactional public Long savePrcPriceSet(PrcPriceSet set) { + reportSaveProgress(set, 10, "saving basic"); validateBasic(set, false); + applyVersionDates(set); if (set.getPriceSetId() == null) { if (StringUtils.isEmpty(set.getStatus())) @@ -115,11 +164,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService set.setStatus(db.getStatus()); setMapper.updatePrcPriceSet(set); } + reportSaveProgress(set, 20, "saving version"); replaceChildren(set); - if ("Y".equalsIgnoreCase(set.getSubmitFlag())) - { - submit(set.getPriceSetId(), set.getUpdateBy() != null ? set.getUpdateBy() : set.getCreateBy()); - } + // submitFlag / audit flow removed: versions activate via activateVersion return set.getPriceSetId(); } @@ -184,7 +231,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService upd.setPriceSetId(priceSetId); upd.setStatus(STATUS_EFFECT); upd.setUpdateBy(updateBy); - return setMapper.updatePrcPriceSetStatus(upd); + int rows = setMapper.updatePrcPriceSetStatus(upd); + refreshVersionStatus(priceSetId); + return rows; } @Override @@ -217,6 +266,125 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService return setMapper.updatePrcPriceSetStatus(upd); } + @Override + @Transactional + public int activateVersion(Long psvId, String updateBy) + { + if (psvId == null) + { + throw new ServiceException(MessageUtils.message("price.set.version.notExists")); + } + PrcPriceSetVersion version = versionMapper.selectPrcPriceSetVersionById(psvId); + if (version == null) + { + throw new ServiceException(MessageUtils.message("price.set.version.notExists")); + } + if (!VER_DRAFT.equals(version.getStatus())) + { + throw new ServiceException(MessageUtils.message("price.set.version.activate.statusNotAllowed")); + } + PrcPriceSet set = requireSet(version.getPriceSetId()); + if (STATUS_STOP.equals(set.getStatus())) + { + throw new ServiceException(MessageUtils.message("price.set.edit.stopped")); + } + Date now = new Date(); + // Expire currently effective versions under the same package + PrcPriceSetVersion q = new PrcPriceSetVersion(); + q.setPriceSetId(set.getPriceSetId()); + List siblings = versionMapper.selectPrcPriceSetVersionList(q); + if (siblings != null) + { + for (PrcPriceSetVersion other : siblings) + { + if (other.getPsvId() == null || other.getPsvId().equals(psvId)) + { + continue; + } + if (VER_EFFECT.equals(other.getStatus())) + { + other.setStatus(VER_EXPIRED); + other.setExpireTime(now); + other.setUpdateBy(updateBy); + versionMapper.updatePrcPriceSetVersion(other); + } + } + } + version.setEffectiveTime(now); + version.setExpireTime(null); + version.setStatus(VER_EFFECT); + version.setUpdateBy(updateBy); + versionMapper.updatePrcPriceSetVersion(version); + + refreshVersionStatus(set.getPriceSetId()); + return 1; + } + + @Override + @Transactional + public Long copyVersion(Long sourcePsvId, String createBy) + { + if (sourcePsvId == null) + { + throw new ServiceException(MessageUtils.message("price.set.version.notExists")); + } + PrcPriceSetVersion src = versionMapper.selectPrcPriceSetVersionById(sourcePsvId); + if (src == null) + { + throw new ServiceException(MessageUtils.message("price.set.version.notExists")); + } + PrcPriceSet set = requireSet(src.getPriceSetId()); + if (STATUS_STOP.equals(set.getStatus())) + { + throw new ServiceException(MessageUtils.message("price.set.edit.stopped")); + } + + Integer maxSeq = versionMapper.selectMaxVersionSeq(set.getPriceSetId()); + int seq = maxSeq == null ? 1 : maxSeq.intValue() + 1; + PrcPriceSetVersion nv = new PrcPriceSetVersion(); + nv.setPriceSetId(set.getPriceSetId()); + nv.setVersionSeq(Integer.valueOf(seq)); + nv.setVersionNo("V" + seq); + nv.setStatus(VER_DRAFT); + nv.setEffectiveTime(null); + nv.setExpireTime(null); + nv.setCreateBy(createBy); + versionMapper.insertPrcPriceSetVersion(nv); + + int detailRows = detailMapper.copyDetailsFromVersion(sourcePsvId, nv.getPsvId()); + if (detailRows > 0) + { + versionSurchargeMapper.copySurchargesFromVersion(sourcePsvId, nv.getPsvId(), createBy); + } + refreshVersionStatus(set.getPriceSetId()); + return nv.getPsvId(); + } + + @Override + @Transactional + public int deleteVersion(Long psvId, String updateBy) + { + if (psvId == null) + { + throw new ServiceException(MessageUtils.message("price.set.version.notExists")); + } + PrcPriceSetVersion version = versionMapper.selectPrcPriceSetVersionById(psvId); + if (version == null) + { + throw new ServiceException(MessageUtils.message("price.set.version.notExists")); + } + if (!VER_DRAFT.equals(version.getStatus())) + { + throw new ServiceException(MessageUtils.message("price.set.version.delete.statusNotAllowed")); + } + requireSet(version.getPriceSetId()); + versionSurchargeMapper.deleteByPsvId(psvId); + detailMapper.deletePrcPriceSetDetailByPsvId(psvId); + int rows = versionMapper.deletePrcPriceSetVersionByIds(new Long[] { psvId }); + refreshVersionStatus(version.getPriceSetId()); + return rows; + } + @Override @Transactional public Long copy(Long priceSetId, String createBy) @@ -254,23 +422,38 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService nd.setZdId(d.getZdId()); nd.setWsdId(d.getWsdId()); nd.setWeightLabel(d.getWeightLabel()); + nd.setWeightMin(d.getWeightMin()); + nd.setWeightMax(d.getWeightMax()); nd.setProductCountryId(d.getProductCountryId()); nd.setFvValue(d.getFvValue()); nd.setFvRegistrationFee(d.getFvRegistrationFee()); nd.setDiscountValue(d.getDiscountValue()); nd.setRoundMode(d.getRoundMode()); nd.setPrId(d.getPrId()); + nd.setEffectiveDate(d.getEffectiveDate()); + nd.setExpireDate(d.getExpireDate()); + nd.setPsvId(null); + nd.setPsdId(null); details.add(nd); } } copy.setDetails(details); copy.setCustomers(new ArrayList()); + PrcPriceSetVersion srcVer = src.getVersion(); + if (srcVer != null) + { + PrcPriceSetVersion nv = new PrcPriceSetVersion(); + nv.setEffectiveTime(srcVer.getEffectiveTime()); + nv.setDetails(details); + copy.setVersion(nv); + } return savePrcPriceSet(copy); } @Override public List validate(PrcPriceSet set) { + applyVersionDates(set); List items = new ArrayList(); if (set.getPriceSetId() != null && (set.getDetails() == null || set.getCustomers() == null)) { @@ -324,18 +507,18 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService if (set.getEffectiveDate() == null) { - items.add(new PrcPriceSetCheckItem("生效日期 ≤ 失效日期", "FAIL", "生效日期不能为空")); + items.add(new PrcPriceSetCheckItem("version effective time", "PASS", "draft may omit effective time")); } else if (set.getExpireDate() != null && set.getEffectiveDate().after(set.getExpireDate())) { - items.add(new PrcPriceSetCheckItem("生效日期 ≤ 失效日期", "FAIL", "生效日期不能晚于失效日期")); + items.add(new PrcPriceSetCheckItem("effective <= expire", "FAIL", "effective date cannot be after expire date")); } else { String msg = DateUtils.parseDateToStr("yyyy-MM-dd", set.getEffectiveDate()) - + " ≤ " - + (set.getExpireDate() == null ? "长期" : DateUtils.parseDateToStr("yyyy-MM-dd", set.getExpireDate())); - items.add(new PrcPriceSetCheckItem("生效日期 ≤ 失效日期", "PASS", msg)); + + " / " + + (set.getExpireDate() == null ? "open" : DateUtils.parseDateToStr("yyyy-MM-dd", set.getExpireDate())); + items.add(new PrcPriceSetCheckItem("effective date", "PASS", msg)); } String setType = StringUtils.isEmpty(set.getSetType()) ? TYPE_FIXED : set.getSetType(); @@ -346,9 +529,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService for (PrcPriceSetDetail d : details) { boolean noCountry = StringUtils.isEmpty(d.getCountryCode()) && StringUtils.isEmpty(d.getCountryName()); + // 运费单价 / 挂号费不参与完整性校验:原基础表 bms_product_country 可能没有值, + // 留空表示该条明细不覆盖该项,计费回退产品默认价(见 TmsPriceQuoteServiceImpl.tryFixed)。 if (StringUtils.isEmpty(d.getProductCode()) || noCountry - || (d.getWsdId() == null && StringUtils.isEmpty(d.getWeightLabel())) - || d.getFvValue() == null || d.getFvRegistrationFee() == null) + || (d.getWsdId() == null && StringUtils.isEmpty(d.getWeightLabel()))) { incomplete++; } @@ -360,7 +544,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } else if (incomplete > 0) { - items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "FAIL", incomplete + " \u6761\u660e\u7ec6\u7f3a\u5c11\u4ea7\u54c1/\u56fd\u5bb6/\u91cd\u91cf\u6bb5/\u8fd0\u8d39/\u6302\u53f7\u8d39")); + items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "FAIL", incomplete + " \u6761\u660e\u7ec6\u7f3a\u5c11\u4ea7\u54c1/\u56fd\u5bb6/\u91cd\u91cf\u6bb5")); } else { @@ -381,29 +565,24 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } } - Set uniq = new HashSet(); - boolean dup = false; - for (PrcPriceSetDetail d : details) + List dupTips = findDetailDuplicateTips(setType, details); + if (!dupTips.isEmpty()) { - String key; - if (TYPE_FIXED.equals(setType)) + StringBuilder sb = new StringBuilder(); + int show = Math.min(dupTips.size(), 10); + for (int i = 0; i < show; i++) { - key = nvl(d.getProductCode()) + "|" + nvl(d.getCountryCode()) + "|" - + (d.getWsdId() != null ? d.getWsdId() : nvl(d.getWeightLabel())); + if (i > 0) + { + sb.append(";"); + } + sb.append(dupTips.get(i)); } - else + if (dupTips.size() > show) { - key = nvl(d.getProductCode()); + sb.append(";...(+").append(dupTips.size() - show).append(")"); } - if (!uniq.add(key) && StringUtils.isNotEmpty(d.getProductCode())) - { - dup = true; - break; - } - } - if (dup) - { - items.add(new PrcPriceSetCheckItem("同产品同国家同重量段是否重复", "FAIL", "存在重复明细")); + items.add(new PrcPriceSetCheckItem("同产品同国家同重量段是否重复", "FAIL", sb.toString())); } else { @@ -463,7 +642,58 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService @Override public List selectDetailList(PrcPriceSetDetail detail) { - return detailMapper.selectPrcPriceSetDetailList(detail); + List list = detailMapper.selectPrcPriceSetDetailList(detail); + fillDetailSurcharges(list); + return list; + } + + /** Attach version surcharge snapshots to a page of details (for drawer list). */ + private void fillDetailSurcharges(List details) + { + if (details == null || details.isEmpty()) + { + return; + } + List psdIds = new ArrayList(); + for (PrcPriceSetDetail d : details) + { + if (d != null && d.getPsdId() != null) + { + psdIds.add(d.getPsdId()); + } + } + if (psdIds.isEmpty()) + { + return; + } + List surcharges = versionSurchargeMapper.selectByPsdIds(psdIds); + Map> byPsd = new HashMap>(); + if (surcharges != null) + { + for (PrcPriceSetVersionSurcharge sc : surcharges) + { + if (sc == null || sc.getPsdId() == null) + { + continue; + } + List list = byPsd.get(sc.getPsdId()); + if (list == null) + { + list = new ArrayList(); + byPsd.put(sc.getPsdId(), list); + } + list.add(sc); + } + } + for (PrcPriceSetDetail d : details) + { + if (d == null) + { + continue; + } + List list = byPsd.get(d.getPsdId()); + d.setSurcharges(list != null ? list : new ArrayList()); + } } @Override @@ -472,7 +702,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet db = requireSet(detail.getPriceSetId()); assertChildEditable(db); validateDetail(db, detail); - return detailMapper.insertPrcPriceSetDetail(detail); + detail.setPsvId(resolvePsvId(detail.getPriceSetId(), detail.getPsvId())); + int rows = detailMapper.insertPrcPriceSetDetail(detail); + PrcPriceSetVersion ver = new PrcPriceSetVersion(); + ver.setPsvId(detail.getPsvId()); + snapshotVersionSurcharges(ver, java.util.Collections.singletonList(detail)); + return rows; } @Override @@ -486,8 +721,21 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet db = requireSet(old.getPriceSetId()); assertChildEditable(db); detail.setPriceSetId(old.getPriceSetId()); + if (detail.getPsvId() == null) + { + detail.setPsvId(old.getPsvId()); + } validateDetail(db, detail); - return detailMapper.updatePrcPriceSetDetail(detail); + int rows = detailMapper.updatePrcPriceSetDetail(detail); + // non-null surcharges = replace snapshot for this detail (empty list clears) + if (detail.getSurcharges() != null) + { + versionSurchargeMapper.deleteByPsdId(detail.getPsdId()); + PrcPriceSetVersion ver = new PrcPriceSetVersion(); + ver.setPsvId(detail.getPsvId()); + snapshotVersionSurcharges(ver, java.util.Collections.singletonList(detail)); + } + return rows; } @Override @@ -572,17 +820,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService private void replaceChildren(PrcPriceSet set) { - if (set.getDetails() != null) - { - detailMapper.deletePrcPriceSetDetailByPriceSetId(set.getPriceSetId()); - for (PrcPriceSetDetail d : filterDetails(set.getDetails())) - { - validateDetail(set, d); - d.setPsdId(null); - d.setPriceSetId(set.getPriceSetId()); - detailMapper.insertPrcPriceSetDetail(d); - } - } + saveOrUpdateVersion(set); + reportSaveProgress(set, 90, "saving customers"); if (set.getCustomers() != null) { customerMapper.deletePrcCustomerPriceSetByPriceSetId(set.getPriceSetId()); @@ -615,6 +854,17 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } } } + reportSaveProgress(set, 96, "finishing"); + } + + private void reportSaveProgress(PrcPriceSet set, int percent, String message) + { + if (set == null || StringUtils.isEmpty(set.getSaveTaskId()) || saveAsyncRunner == null) + { + return; + } + saveAsyncRunner.put(set.getSaveTaskId(), PrcPriceSetSaveTask.STATUS_RUNNING, percent, message, + set.getPriceSetId(), null); } private void validateBasic(PrcPriceSet set, boolean strictSubmit) @@ -623,11 +873,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { throw new ServiceException(MessageUtils.message("price.set.name.empty")); } - if (set.getEffectiveDate() == null) - { - throw new ServiceException(MessageUtils.message("price.set.effectiveDate.empty")); - } - if (set.getExpireDate() != null && set.getEffectiveDate().after(set.getExpireDate())) + applyVersionDates(set); + if (set.getEffectiveDate() != null && set.getExpireDate() != null + && set.getEffectiveDate().after(set.getExpireDate())) { throw new ServiceException(MessageUtils.message("price.set.date.invalid")); } @@ -667,15 +915,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { throw new ServiceException(MessageUtils.message("price.set.detail.weight.empty")); } - if (detail.getFvValue() == null) - { - throw new ServiceException(MessageUtils.message("price.set.detail.freight.empty")); - } - if (detail.getFvRegistrationFee() == null) - { - throw new ServiceException(MessageUtils.message("price.set.detail.registration.empty")); - } + // 运费单价 / 挂号费不做必填校验:原基础表 bms_product_country 可能没有值, + // 前端对应单元格会禁用(PriceEditDrawer.isBaseMissing)。 + // 任一为空时该条明细不参与覆盖,计费按「未命中」回退产品默认价 + // (见 TmsPriceQuoteServiceImpl.tryFixed)。 fillProductCountryId(detail); + snapshotWeightAndDates(detail); } if (StringUtils.isEmpty(detail.getRoundMode())) { @@ -760,10 +1005,6 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { return; } - if (detail.getZoneId() != null || StringUtils.isNotEmpty(detail.getZoneCode())) - { - return; - } BmsProductCountry query = new BmsProductCountry(); query.setProductCode(detail.getProductCode()); List rows = productCountryMapper.selectBmsProductCountryList(query); @@ -819,6 +1060,22 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { detail.setZoneName(row.getZoneName()); } + if (detail.getWeightMin() == null) + { + detail.setWeightMin(row.getWeightMin()); + } + if (detail.getWeightMax() == null) + { + detail.setWeightMax(row.getWeightMax()); + } + if (detail.getEffectiveDate() == null) + { + detail.setEffectiveDate(row.getEffectiveDate()); + } + if (detail.getExpireDate() == null) + { + detail.setExpireDate(row.getExpireDate()); + } } private static boolean matchDetailCountryOnly(PrcPriceSetDetail detail, BmsProductCountry row) @@ -887,6 +1144,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { customer.setPrBegindate(set.getEffectiveDate()); } + if (customer.getPrBegindate() == null && set.getVersion() != null) + { + customer.setPrBegindate(set.getVersion().getEffectiveTime()); + } if (customer.getPrBegindate() == null) { throw new ServiceException(MessageUtils.message("price.set.customer.beginDate.empty")); @@ -949,6 +1210,87 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } } + + /** + * Detect duplicate details; FIXED key = product + country + zone + weight (or productCountryId). + */ + private List findDetailDuplicateTips(String setType, List details) + { + List tips = new ArrayList(); + if (details == null || details.isEmpty()) + { + return tips; + } + Map> groups = new HashMap>(); + for (PrcPriceSetDetail d : details) + { + if (d == null || StringUtils.isEmpty(d.getProductCode())) + { + continue; + } + String key = TYPE_FIXED.equals(setType) ? fixedDetailDupKey(d) : ("D|" + nvl(d.getProductCode())); + List list = groups.get(key); + if (list == null) + { + list = new ArrayList(); + groups.put(key, list); + } + list.add(d); + } + for (List list : groups.values()) + { + if (list == null || list.size() < 2) + { + continue; + } + tips.add(formatDetailDupTip(setType, list.get(0), list.size())); + } + return tips; + } + + private String fixedDetailDupKey(PrcPriceSetDetail d) + { + if (d.getProductCountryId() != null) + { + return "PC|" + d.getProductCountryId(); + } + String weight = d.getWsdId() != null ? ("WSD|" + d.getWsdId()) : nvl(d.getWeightLabel()); + if (StringUtils.isEmpty(weight) && (d.getWeightMin() != null || d.getWeightMax() != null)) + { + weight = String.valueOf(d.getWeightMin()) + "~" + String.valueOf(d.getWeightMax()); + } + return nvl(d.getProductCode()) + "|" + nvl(d.getCountryCode()) + "|" + nvl(d.getCountryName()) + + "|" + nvl(d.getZoneCode()) + "|" + weight; + } + + private String formatDetailDupTip(String setType, PrcPriceSetDetail d, int count) + { + if (!TYPE_FIXED.equals(setType)) + { + return MessageUtils.message("price.set.detail.duplicate.discount", d.getProductCode(), Integer.valueOf(count)); + } + String country = StringUtils.isNotEmpty(d.getCountryName()) ? d.getCountryName() + : (StringUtils.isNotEmpty(d.getCountryCode()) ? d.getCountryCode() : "-"); + String weight = StringUtils.isNotEmpty(d.getWeightLabel()) ? d.getWeightLabel() : "-"; + if ("-".equals(weight) && (d.getWeightMin() != null || d.getWeightMax() != null)) + { + weight = String.valueOf(d.getWeightMin()) + " items) { for (PrcPriceSetCheckItem item : items) @@ -984,6 +1326,23 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService return false; } + private Long resolvePsvId(Long priceSetId, Long psvId) + { + if (psvId != null) + { + return psvId; + } + PrcPriceSetVersion q = new PrcPriceSetVersion(); + q.setPriceSetId(priceSetId); + List versions = versionMapper.selectPrcPriceSetVersionList(q); + PrcPriceSetVersion current = pickCurrentVersion(versions); + if (current == null) + { + throw new ServiceException(MessageUtils.message("price.set.version.notExists")); + } + return current.getPsvId(); + } + private void fillExpired(PrcPriceSet set) { Date today = DateUtils.parseDate(DateUtils.getDate()); @@ -994,6 +1353,582 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService set.setExpired(Boolean.valueOf(expired)); } + private void applyVersionDates(PrcPriceSet set) + { + PrcPriceSetVersion version = resolveSaveVersion(set); + if (version != null && version.getEffectiveTime() != null && set.getEffectiveDate() == null) + { + set.setEffectiveDate(version.getEffectiveTime()); + } + } + + private PrcPriceSetVersion resolveSaveVersion(PrcPriceSet set) + { + if (set.getVersion() != null) + { + return set.getVersion(); + } + if (set.getVersions() != null && !set.getVersions().isEmpty()) + { + return set.getVersions().get(0); + } + return null; + } + + private void fillVersions(PrcPriceSet set) + { + Long priceSetId = set.getPriceSetId(); + PrcPriceSetVersion q = new PrcPriceSetVersion(); + q.setPriceSetId(priceSetId); + List versions = versionMapper.selectPrcPriceSetVersionList(q); + if (versions == null) + { + versions = new ArrayList(); + } + for (PrcPriceSetVersion version : versions) + { + fillVersionChildren(version); + } + 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)); + } + PrcCustomerPriceSet cquery = new PrcCustomerPriceSet(); + cquery.setPriceSetId(priceSetId); + set.setCustomers(customerMapper.selectPrcCustomerPriceSetList(cquery)); + } + + 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); + } + + private PrcPriceSetVersion pickCurrentVersion(List versions) + { + if (versions == null || versions.isEmpty()) + { + return null; + } + for (PrcPriceSetVersion v : versions) + { + if (VER_EFFECT.equals(v.getStatus())) + { + return v; + } + } + return versions.get(0); + } + + private void saveOrUpdateVersion(PrcPriceSet set) + { + PrcPriceSetVersion version = resolveSaveVersion(set); + boolean pullLibrary = "Y".equalsIgnoreCase(set.getPullFromLibrary()); + List details = filterDetails(set.getDetails()); + if (version == null && details.isEmpty() && !pullLibrary) + { + return; + } + if (version == null) + { + version = new PrcPriceSetVersion(); + version.setEffectiveTime(set.getEffectiveDate()); + } + // Draft versions may omit effective_time; set on activate (启动) + version.setPriceSetId(set.getPriceSetId()); + version.setCreateBy(set.getCreateBy()); + version.setUpdateBy(set.getUpdateBy()); + // Reuse existing version when client omitted psvId (avoids V2/V3 + leftover details) + if (version.getPsvId() == null && set.getPriceSetId() != null) + { + PrcPriceSetVersion reusable = findReusableVersion(set.getPriceSetId(), version.getEffectiveTime()); + if (reusable != null) + { + version.setPsvId(reusable.getPsvId()); + } + } + if (version.getPsvId() == null) + { + Integer maxSeq = versionMapper.selectMaxVersionSeq(set.getPriceSetId()); + int seq = maxSeq == null ? 1 : maxSeq.intValue() + 1; + version.setVersionSeq(Integer.valueOf(seq)); + version.setVersionNo("V" + seq); + version.setStatus(VER_DRAFT); + versionMapper.insertPrcPriceSetVersion(version); + } + else + { + PrcPriceSetVersion db = versionMapper.selectPrcPriceSetVersionById(version.getPsvId()); + if (db == null) + { + throw new ServiceException(MessageUtils.message("price.set.version.notExists")); + } + version.setVersionSeq(db.getVersionSeq()); + version.setVersionNo(db.getVersionNo()); + if (StringUtils.isEmpty(version.getStatus())) + { + version.setStatus(db.getStatus()); + } + if (version.getExpireTime() == null) + { + version.setExpireTime(db.getExpireTime()); + } + versionMapper.updatePrcPriceSetVersion(version); + } + set.setVersion(version); + if (pullLibrary) + { + replaceVersionDetailsFromLibrary(set, version); + } + else + { + replaceVersionDetails(set, version, details); + reportSaveProgress(set, 85, "saving surcharges"); + snapshotVersionSurcharges(version, details); + } + refreshVersionStatus(set.getPriceSetId()); + } + + /** Prefer same effective_time, else draft, else first version of the set. */ + private PrcPriceSetVersion findReusableVersion(Long priceSetId, Date effectiveTime) + { + PrcPriceSetVersion q = new PrcPriceSetVersion(); + q.setPriceSetId(priceSetId); + List list = versionMapper.selectPrcPriceSetVersionList(q); + if (list == null || list.isEmpty()) + { + return null; + } + if (effectiveTime != null) + { + for (PrcPriceSetVersion v : list) + { + if (sameTime(v.getEffectiveTime(), effectiveTime)) + { + return v; + } + } + } + for (PrcPriceSetVersion v : list) + { + if (VER_DRAFT.equals(v.getStatus())) + { + return v; + } + } + return list.get(0); + } + + /** + * Bulk create details + surcharge snapshots via SQL joins (no per-product HTTP roundtrips). + * Idempotent: if this version already has details and forcePullLibrary!=Y, skip re-insert. + */ + private void replaceVersionDetailsFromLibrary(PrcPriceSet set, PrcPriceSetVersion version) + { + reportSaveProgress(set, 30, "pulling library"); + boolean force = "Y".equalsIgnoreCase(set.getForcePullLibrary()); + int existing = detailMapper.countByPsvId(version.getPsvId()); + 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); + return; + } + versionSurchargeMapper.deleteByPsvId(version.getPsvId()); + detailMapper.deletePrcPriceSetDetailByPsvId(version.getPsvId()); + // clean legacy rows without psv_id under this set (would look like duplicates in UI) + detailMapper.deleteOrphanDetailsByPriceSetId(set.getPriceSetId()); + int inserted; + if (TYPE_DISCOUNT.equals(set.getSetType())) + { + inserted = detailMapper.insertDiscountDetailsFromLibrary(set.getPriceSetId(), version.getPsvId()); + } + else + { + inserted = detailMapper.insertFixedDetailsFromLibrary( + set.getPriceSetId(), version.getPsvId(), version.getEffectiveTime(), false); + if (inserted <= 0) + { + inserted = detailMapper.insertFixedDetailsFromLibrary( + set.getPriceSetId(), version.getPsvId(), version.getEffectiveTime(), true); + } + } + if (inserted <= 0) + { + throw new ServiceException(MessageUtils.message("price.set.library.empty")); + } + 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); + } + + private void replaceVersionDetails(PrcPriceSet set, PrcPriceSetVersion version, List details) + { + versionSurchargeMapper.deleteByPsvId(version.getPsvId()); + detailMapper.deletePrcPriceSetDetailByPsvId(version.getPsvId()); + int total = details == null ? 0 : details.size(); + int index = 0; + for (PrcPriceSetDetail d : details) + { + validateDetail(set, d); + d.setPsdId(null); + d.setPriceSetId(set.getPriceSetId()); + d.setPsvId(version.getPsvId()); + detailMapper.insertPrcPriceSetDetail(d); + index++; + if (total > 0 && (index == total || index % 50 == 0 || index == 1)) + { + int pct = 25 + (int) Math.floor(index * 55.0 / total); + if (pct > 80) + { + pct = 80; + } + reportSaveProgress(set, pct, "saving details " + index + "/" + total); + } + } + version.setDetails(details); + } + + private void snapshotVersionSurcharges(PrcPriceSetVersion version, List details) + { + if (details == null || details.isEmpty()) + { + return; + } + Set codes = new HashSet(); + for (PrcPriceSetDetail d : details) + { + if (StringUtils.isNotEmpty(d.getProductCode())) + { + codes.add(d.getProductCode()); + } + } + Map> relsByProduct = new HashMap>(); + for (String code : codes) + { + PrcSurchargeRel q = new PrcSurchargeRel(); + q.setProductCode(code); + List rels = surchargeRelMapper.selectPrcSurchargeRelList(q); + relsByProduct.put(code, rels == null ? new ArrayList() : rels); + } + // 勾选列表来自「价格基础 → 附加费配置」主表(prc_surcharge),不要求产品已做 rel 关联; + // rel 里没有的勾选项,回退主表取值落快照。 + List surchargeMasters = java.util.Collections.emptyList(); + for (PrcPriceSetDetail d0 : details) + { + if (d0.getSurcharges() != null && !d0.getSurcharges().isEmpty()) + { + surchargeMasters = surchargeMapper.selectPrcSurchargeList(new PrcSurcharge()); + break; + } + } + List saved = new ArrayList(); + for (PrcPriceSetDetail d : details) + { + List rels = relsByProduct.get(d.getProductCode()); + if (rels == null) + { + continue; + } + // 前端勾选结果:明细带了 surcharges 就按勾选保存(空集合 = 一条都不要); + // 为 null 表示调用方没传勾选(如详情页新增单条明细),沿用「全部命中即快照」。 + Set pickedScIds = null; + if (d.getSurcharges() != null) + { + pickedScIds = new HashSet(); + for (PrcPriceSetVersionSurcharge picked : d.getSurcharges()) + { + if (picked != null && picked.getScId() != null) + { + pickedScIds.add(picked.getScId()); + } + } + } + List rowSc = new ArrayList(); + Set written = new HashSet(); + for (PrcSurchargeRel rel : rels) + { + if (rel == null || StringUtils.isEmpty(rel.getScName()) || rel.getChargeValue() == null) + { + continue; + } + if ("of".equalsIgnoreCase(rel.getSsCode())) + { + continue; + } + if (pickedScIds != null && !pickedScIds.contains(rel.getScId())) + { + continue; + } + if (rel.getDetailId() != null && d.getProductCountryId() != null + && !rel.getDetailId().equals(d.getProductCountryId())) + { + continue; + } + if (rel.getDetailId() != null && d.getProductCountryId() == null) + { + continue; + } + PrcPriceSetVersionSurcharge sc = new PrcPriceSetVersionSurcharge(); + sc.setPsvId(version.getPsvId()); + sc.setPsdId(d.getPsdId()); + sc.setScId(rel.getScId()); + sc.setProductCode(d.getProductCode()); + sc.setScName(rel.getScName()); + sc.setFeeType(StringUtils.isEmpty(rel.getFeeType()) ? "SURCHARGE" : rel.getFeeType()); + sc.setChargeType(StringUtils.isEmpty(rel.getChargeType()) ? "FIXED" : rel.getChargeType()); + sc.setChargeValue(rel.getChargeValue()); + sc.setCurrency(rel.getCurrency()); + sc.setRoundMode(rel.getRoundMode()); + sc.setCountryCode(rel.getCountryCode()); + sc.setScNote(rel.getScNote()); + sc.setCreateBy(version.getCreateBy()); + versionSurchargeMapper.insertPrcPriceSetVersionSurcharge(sc); + saved.add(sc); + rowSc.add(sc); + written.add(rel.getScId()); + } + // 勾选了但产品未关联(rel 无此 scId)的附加费:回退 prc_surcharge 主表配置 + if (pickedScIds != null) + { + for (PrcSurcharge m : surchargeMasters) + { + if (m == null || m.getScId() == null || written.contains(m.getScId()) + || !pickedScIds.contains(m.getScId())) + { + continue; + } + if (StringUtils.isEmpty(m.getScName()) || m.getChargeValue() == null + || "of".equalsIgnoreCase(m.getSsCode())) + { + continue; + } + PrcPriceSetVersionSurcharge sc = new PrcPriceSetVersionSurcharge(); + sc.setPsvId(version.getPsvId()); + sc.setPsdId(d.getPsdId()); + sc.setScId(m.getScId()); + sc.setProductCode(d.getProductCode()); + sc.setScName(m.getScName()); + sc.setFeeType(StringUtils.isEmpty(m.getFeeType()) ? "SURCHARGE" : m.getFeeType()); + sc.setChargeType(StringUtils.isEmpty(m.getChargeType()) ? "FIXED" : m.getChargeType()); + sc.setChargeValue(m.getChargeValue()); + sc.setCurrency(m.getCurrency()); + sc.setRoundMode(m.getRoundMode()); + sc.setCountryCode(m.getCountryCode()); + sc.setScNote(m.getScNote()); + sc.setCreateBy(version.getCreateBy()); + versionSurchargeMapper.insertPrcPriceSetVersionSurcharge(sc); + saved.add(sc); + rowSc.add(sc); + written.add(m.getScId()); + } + } + d.setSurcharges(rowSc); + } + version.setSurcharges(saved); + } + + private void refreshVersionStatus(Long priceSetId) + { + PrcPriceSetVersion q = new PrcPriceSetVersion(); + q.setPriceSetId(priceSetId); + List versions = versionMapper.selectPrcPriceSetVersionList(q); + if (versions == null || versions.isEmpty()) + { + return; + } + // Timed versions first (by effective_time), drafts without time last (by seq) + versions.sort((a, b) -> { + Date ta = a.getEffectiveTime(); + Date tb = b.getEffectiveTime(); + if (ta == null && tb == null) + { + return Integer.compare(nullToZero(a.getVersionSeq()), nullToZero(b.getVersionSeq())); + } + if (ta == null) + { + return 1; + } + if (tb == null) + { + return -1; + } + int c = ta.compareTo(tb); + if (c != 0) + { + return c; + } + return Integer.compare(nullToZero(a.getVersionSeq()), nullToZero(b.getVersionSeq())); + }); + Date now = new Date(); + for (int i = 0; i < versions.size(); i++) + { + PrcPriceSetVersion v = versions.get(i); + // Next start only among versions that already have effective_time + Date nextStart = null; + for (int j = i + 1; j < versions.size(); j++) + { + Date t = versions.get(j).getEffectiveTime(); + if (t != null) + { + nextStart = t; + break; + } + } + String status; + if (v.getEffectiveTime() == null) + { + status = VER_DRAFT; + } + else if (startedNotAfter(v.getEffectiveTime(), now) + && (nextStart == null || nextStart.after(now))) + { + status = VER_EFFECT; + } + else if (startedNotAfter(v.getEffectiveTime(), now)) + { + status = VER_EXPIRED; + } + else + { + // future-dated: keep as draft until wall-clock reaches effective_time + status = VER_DRAFT; + } + boolean statusChanged = !status.equals(v.getStatus()); + boolean expireChanged = !sameTime(nextStart, v.getExpireTime()); + if (statusChanged || expireChanged) + { + v.setStatus(status); + v.setExpireTime(nextStart); + versionMapper.updatePrcPriceSetVersion(v); + } + } + } + + /** effective_time <= now (+1s skew tolerance) */ + private static boolean startedNotAfter(Date effectiveTime, Date now) + { + if (effectiveTime == null || now == null) + { + return false; + } + return effectiveTime.getTime() <= now.getTime() + 1000L; + } + + @Override + public int refreshAllVersionStatus() + { + List ids = versionMapper.selectPriceSetIds(); + if (ids == null || ids.isEmpty()) + { + return 0; + } + for (Long id : ids) + { + refreshVersionStatus(id); + } + return ids.size(); + } + + private static boolean sameTime(Date a, Date b) + { + if (a == null && b == null) + { + return true; + } + if (a == null || b == null) + { + return false; + } + return a.getTime() == b.getTime(); + } + + private static int nullToZero(Integer v) + { + return v == null ? 0 : v.intValue(); + } + + private void snapshotWeightAndDates(PrcPriceSetDetail detail) + { + if (detail.getProductCountryId() == null) + { + return; + } + if (detail.getWeightMin() != null && detail.getWeightMax() != null + && detail.getEffectiveDate() != null) + { + return; + } + BmsProductCountry query = new BmsProductCountry(); + query.setProductCode(detail.getProductCode()); + List rows = productCountryMapper.selectBmsProductCountryList(query); + if (rows == null) + { + return; + } + for (BmsProductCountry row : rows) + { + if (detail.getProductCountryId().equals(row.getDetailId())) + { + applyZoneFromRow(detail, row); + return; + } + } + } + private List filterDetails(List details) { List list = new ArrayList(); diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/BmsStatusRefreshTask.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/BmsStatusRefreshTask.java index cebd0e94..1481e79c 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/BmsStatusRefreshTask.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/BmsStatusRefreshTask.java @@ -3,10 +3,13 @@ package com.ruoyi.price.task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import com.ruoyi.price.mapper.BmsProductCountryMapper; import com.ruoyi.price.mapper.BmsProductMapper; +import com.ruoyi.price.service.IPrcPriceSetService; /** * bms_product / bms_product_country 状态自动刷新 @@ -26,7 +29,7 @@ import com.ruoyi.price.mapper.BmsProductMapper; * @author ruoyi */ @Component -public class BmsStatusRefreshTask +public class BmsStatusRefreshTask implements ApplicationRunner { private static final Logger log = LoggerFactory.getLogger(BmsStatusRefreshTask.class); @@ -36,6 +39,9 @@ public class BmsStatusRefreshTask @Autowired private BmsProductCountryMapper productCountryMapper; + @Autowired + private IPrcPriceSetService priceSetService; + /** 每天 02:00 跑(cron: sec min hour DOM month DOW) */ @Scheduled(cron = "0 0 2 * * ?") public void scheduled() @@ -43,16 +49,31 @@ public class BmsStatusRefreshTask refreshNow(); } + @Override + public void run(ApplicationArguments args) + { + try + { + int n = priceSetService.refreshAllVersionStatus(); + log.info("startup prc_price_set_version expire/status refreshed, sets={}", n); + } + catch (Exception e) + { + log.error("startup prc_price_set_version refresh failed", e); + } + } + /** - * 立即刷新两表状态。返回 [product 影响行数, country 影响行数]。 - * 异常时打印 ERROR 并原样抛出,便于调用方(手动触发接口)回滚事务。 + * Refresh product/country status and chain version expire_time. + * Returns [product rows, country rows, price-set count]. */ public int[] refreshNow() { int productCount = productMapper.refreshStatusByDates(); int countryCount = productCountryMapper.refreshStatusByDates(); - log.info("BmsStatusRefreshTask done: bms_product={}, bms_product_country={}", - productCount, countryCount); - return new int[] { productCount, countryCount }; + int versionSets = priceSetService.refreshAllVersionStatus(); + log.info("BmsStatusRefreshTask done: bms_product={}, bms_product_country={}, price_set_version_sets={}", + productCount, countryCount, versionSets); + return new int[] { productCount, countryCount, versionSets }; } } \ No newline at end of file diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/PrcPriceSetSaveAsyncRunner.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/PrcPriceSetSaveAsyncRunner.java new file mode 100644 index 00000000..be45df97 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/task/PrcPriceSetSaveAsyncRunner.java @@ -0,0 +1,70 @@ +package com.ruoyi.price.task; + +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import com.ruoyi.common.core.redis.RedisCache; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.price.domain.PrcPriceSet; +import com.ruoyi.price.domain.PrcPriceSetSaveTask; +import com.ruoyi.price.service.IPrcPriceSetService; + +/** + * Async runner for price-set save (avoids HTTP timeout on large detail snapshots). + */ +@Component +public class PrcPriceSetSaveAsyncRunner +{ + private static final Logger log = LoggerFactory.getLogger(PrcPriceSetSaveAsyncRunner.class); + + public static final String CACHE_KEY_PREFIX = "price:set:save:task:"; + public static final int TTL_MINUTES = 60; + + @Autowired + private IPrcPriceSetService priceSetService; + + @Autowired + private RedisCache redisCache; + + @Async("threadPoolTaskExecutor") + public void run(String taskId, PrcPriceSet set) + { + try + { + put(taskId, PrcPriceSetSaveTask.STATUS_RUNNING, 8, "saving basic", null, null); + set.setSaveTaskId(taskId); + Long id = priceSetService.savePrcPriceSet(set); + put(taskId, PrcPriceSetSaveTask.STATUS_SUCCESS, 100, "done", id, null); + } + catch (Exception e) + { + log.error("async save price set failed, taskId={}", taskId, e); + String msg = e.getMessage(); + if (StringUtils.isEmpty(msg)) + { + msg = e.getClass().getSimpleName(); + } + put(taskId, PrcPriceSetSaveTask.STATUS_FAIL, 100, "failed", null, msg); + } + } + + public void put(String taskId, String status, int percent, String message, Long priceSetId, String error) + { + PrcPriceSetSaveTask task = new PrcPriceSetSaveTask(); + task.setTaskId(taskId); + task.setStatus(status); + task.setPercent(percent); + task.setMessage(message); + task.setPriceSetId(priceSetId); + task.setError(error); + redisCache.setCacheObject(CACHE_KEY_PREFIX + taskId, task, TTL_MINUTES, TimeUnit.MINUTES); + } + + public PrcPriceSetSaveTask get(String taskId) + { + return redisCache.getCacheObject(CACHE_KEY_PREFIX + taskId); + } +} diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml index 76a7d279..62dba9cc 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml @@ -7,6 +7,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -17,21 +18,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + - select psd_id, price_set_id, product_code, product_name, country_code, country_name, + select psd_id, price_set_id, psv_id, product_code, product_name, country_code, country_name, zone_id, zone_code, zone_name, - zd_id, wsd_id, weight_label, product_country_id, fv_value, fv_registration_fee, - discount_value, round_mode, pr_id, create_time, update_time + 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 from prc_price_set_detail @@ -46,6 +51,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" AND price_set_id = #{priceSetId} + + AND psv_id = #{psvId} + AND product_code = #{productCode} @@ -55,6 +63,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" AND zone_code = #{zoneCode} + + AND ( + product_code like concat('%', #{keyword}, '%') + or product_name like concat('%', #{keyword}, '%') + or country_code like concat('%', #{keyword}, '%') + or country_name like concat('%', #{keyword}, '%') + or zone_code like concat('%', #{keyword}, '%') + or zone_name like concat('%', #{keyword}, '%') + or weight_label like concat('%', #{keyword}, '%') + ) + order by psd_id asc @@ -67,9 +86,90 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select count(1) from prc_price_set_detail where price_set_id = #{priceSetId} + + + + + insert ignore into prc_price_set_detail ( + price_set_id, psv_id, product_code, product_name, + country_code, country_name, zone_id, zone_code, zone_name, + weight_label, weight_min, weight_max, product_country_id, + fv_value, fv_registration_fee, round_mode, + effective_date, expire_date, create_time + ) + select + #{priceSetId}, + #{psvId}, + p.product_code, + p.product_name, + c.country_code, + c.country_name, + c.zone_id, + c.zone_code, + c.zone_name, + case + when c.weight_min is null and c.weight_max is null then null + else concat( + ifnull(trim(trailing '0' from trim(trailing '.' from cast(c.weight_min as char))), ''), + '<W≤', + ifnull(trim(trailing '0' from trim(trailing '.' from cast(c.weight_max as char))), '') + ) + end, + c.weight_min, + c.weight_max, + c.detail_id, + c.freight_price, + c.registration_fee, + 'HALF_UP', + c.effective_date, + c.expire_date, + sysdate() + from bms_product p + inner join bms_product_country c on c.product_id = p.product_id + where p.status = '1' + and p.product_id = ( + select max(p2.product_id) from bms_product p2 + where p2.product_code = p.product_code and p2.status = '1' + ) + and ifnull(c.status, '1') <> '2' + + + and (c.expire_date is null or date(c.expire_date) > curdate()) + + + and date(c.effective_date) <= date(#{asOf}) + and (c.expire_date is null or date(c.expire_date) > date(#{asOf})) + + + order by p.product_code asc, c.country_name asc, c.zone_code asc, c.weight_min asc, c.detail_id asc + + + + insert ignore into prc_price_set_detail ( + price_set_id, psv_id, product_code, product_name, round_mode, create_time + ) + select + #{priceSetId}, + #{psvId}, + p.product_code, + p.product_name, + 'HALF_UP', + sysdate() + from bms_product p + where p.status = '1' + and p.product_id = ( + select max(p2.product_id) from bms_product p2 + where p2.product_code = p.product_code and p2.status = '1' + ) + order by p.product_code asc + + insert into prc_price_set_detail ( price_set_id, + psv_id, product_code, product_name, country_code, @@ -80,15 +180,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" zd_id, wsd_id, weight_label, + weight_min, + weight_max, product_country_id, fv_value, fv_registration_fee, discount_value, round_mode, pr_id, + effective_date, + expire_date, create_time ) values ( #{priceSetId}, + #{psvId}, #{productCode}, #{productName}, #{countryCode}, @@ -99,12 +204,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{zdId}, #{wsdId}, #{weightLabel}, + #{weightMin}, + #{weightMax}, #{productCountryId}, #{fvValue}, #{fvRegistrationFee}, #{discountValue}, #{roundMode}, #{prId}, + #{effectiveDate}, + #{expireDate}, sysdate() ) @@ -112,6 +221,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update prc_price_set_detail + psv_id = #{psvId}, product_code = #{productCode}, product_name = #{productName}, country_code = #{countryCode}, @@ -122,12 +232,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" zd_id = #{zdId}, wsd_id = #{wsdId}, weight_label = #{weightLabel}, + weight_min = #{weightMin}, + weight_max = #{weightMax}, product_country_id = #{productCountryId}, fv_value = #{fvValue}, fv_registration_fee = #{fvRegistrationFee}, discount_value = #{discountValue}, round_mode = #{roundMode}, pr_id = #{prId}, + effective_date = #{effectiveDate}, + expire_date = #{expireDate}, update_time = sysdate() where psd_id = #{psdId} @@ -144,6 +258,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" delete from prc_price_set_detail where price_set_id = #{priceSetId} + + delete from prc_price_set_detail where psv_id = #{psvId} + + + + delete from prc_price_set_detail where price_set_id = #{priceSetId} and psv_id is null + + + + insert into prc_price_set_detail ( + price_set_id, psv_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 + ) + select + price_set_id, #{newPsvId}, 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, sysdate() + from prc_price_set_detail + where psv_id = #{srcPsvId} + order by psd_id asc + + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetMapper.xml index 2e190a16..1855e4b4 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetMapper.xml @@ -140,6 +140,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update prc_price_set status = #{status}, + effective_date = #{effectiveDate}, updated_by = #{updateBy}, update_time = sysdate() diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetVersionMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetVersionMapper.xml new file mode 100644 index 00000000..b713ea05 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetVersionMapper.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + select psv_id, price_set_id, version_no, version_seq, effective_time, expire_time, status, + created_by, create_time, updated_by, update_time + from prc_price_set_version + + + + + + + + + + insert into prc_price_set_version ( + price_set_id, + version_no, + version_seq, + effective_time, + expire_time, + status, + created_by, + create_time + ) values ( + #{priceSetId}, + #{versionNo}, + #{versionSeq}, + #{effectiveTime}, + #{expireTime}, + #{status}, + #{createBy}, + sysdate() + ) + + + + update prc_price_set_version + + version_no = #{versionNo}, + version_seq = #{versionSeq}, + effective_time = #{effectiveTime}, + expire_time = #{expireTime}, + status = #{status}, + updated_by = #{updateBy}, + update_time = sysdate() + + where psv_id = #{psvId} + + + + delete from prc_price_set_version where psv_id in + + #{psvId} + + + + + delete from prc_price_set_version where price_set_id = #{priceSetId} + + + + + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetVersionSurchargeMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetVersionSurchargeMapper.xml new file mode 100644 index 00000000..23212985 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcPriceSetVersionSurchargeMapper.xml @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + select psvs_id, psv_id, sc_id, product_code, psd_id, sc_name, fee_type, charge_type, + charge_value, currency, round_mode, country_code, sc_note, + created_by, create_time, updated_by, update_time + from prc_price_set_version_surcharge + + + + + + + + insert into prc_price_set_version_surcharge ( + psv_id, + psd_id, + sc_id, + product_code, + sc_name, + fee_type, + charge_type, + charge_value, + currency, + round_mode, + country_code, + sc_note, + created_by, + create_time + ) values ( + #{psvId}, + #{psdId}, + #{scId}, + #{productCode}, + #{scName}, + #{feeType}, + #{chargeType}, + #{chargeValue}, + #{currency}, + #{roundMode}, + #{countryCode}, + #{scNote}, + #{createBy}, + sysdate() + ) + + + + update prc_price_set_version_surcharge + + psd_id = #{psdId}, + sc_id = #{scId}, + product_code = #{productCode}, + sc_name = #{scName}, + fee_type = #{feeType}, + charge_type = #{chargeType}, + charge_value = #{chargeValue}, + currency = #{currency}, + round_mode = #{roundMode}, + country_code = #{countryCode}, + sc_note = #{scNote}, + updated_by = #{updateBy}, + update_time = sysdate() + + where psvs_id = #{psvsId} + + + + delete from prc_price_set_version_surcharge where psvs_id in + + #{psvsId} + + + + + delete from prc_price_set_version_surcharge where psv_id = #{psvId} + + + + delete from prc_price_set_version_surcharge where psd_id = #{psdId} + + + + + + + insert into prc_price_set_version_surcharge ( + psv_id, psd_id, sc_id, product_code, sc_name, fee_type, charge_type, + charge_value, currency, round_mode, country_code, sc_note, + created_by, create_time + ) + select + d.psv_id, + d.psd_id, + r.sc_id, + d.product_code, + s.sc_name, + ifnull(nullif(s.fee_type, ''), 'SURCHARGE'), + ifnull(nullif(s.charge_type, ''), 'FIXED'), + s.charge_value, + ifnull(nullif(s.currency, ''), 'CNY'), + ifnull(nullif(s.round_mode, ''), 'HALF_UP'), + s.country_code, + s.sc_note, + #{createBy}, + sysdate() + from prc_price_set_detail d + inner join prc_surcharge_rel r on r.product_code = d.product_code + inner join prc_surcharge s on s.sc_id = r.sc_id + where d.psv_id = #{psvId} + and ifnull(s.sc_name, '') <> '' + and s.charge_value is not null + and lower(ifnull(s.ss_code, '')) <> 'of' + and (r.detail_id is null or r.detail_id = d.product_country_id) + and ( + s.country_code is null or s.country_code = '' + or d.country_code is null or d.country_code = '' + or upper(s.country_code) = upper(d.country_code) + ) + + + + insert into prc_price_set_version_surcharge ( + psv_id, psd_id, sc_id, product_code, sc_name, fee_type, charge_type, + charge_value, currency, round_mode, country_code, sc_note, + created_by, create_time + ) + select + #{newPsvId}, + nd.psd_id, + sc.sc_id, + sc.product_code, + sc.sc_name, + sc.fee_type, + sc.charge_type, + sc.charge_value, + sc.currency, + sc.round_mode, + sc.country_code, + sc.sc_note, + #{createBy}, + sysdate() + from prc_price_set_version_surcharge sc + inner join prc_price_set_detail od + on od.psd_id = sc.psd_id and od.psv_id = #{srcPsvId} + inner join prc_price_set_detail nd + on nd.psv_id = #{newPsvId} + and nd.product_code = od.product_code + and ifnull(nd.country_code, '') = ifnull(od.country_code, '') + and ifnull(nd.zone_code, '') = ifnull(od.zone_code, '') + and ifnull(nd.weight_label, '') = ifnull(od.weight_label, '') + and ifnull(nd.product_country_id, 0) = ifnull(od.product_country_id, 0) + and ifnull(nd.discount_value, -1) = ifnull(od.discount_value, -1) + where sc.psv_id = #{srcPsvId} + + + diff --git a/bms-back/sql/price/README_price_set_version.md b/bms-back/sql/price/README_price_set_version.md new file mode 100644 index 00000000..86dfa120 --- /dev/null +++ b/bms-back/sql/price/README_price_set_version.md @@ -0,0 +1,34 @@ +# Price set version tables + +## Wizard (2 steps) + +1. Basic info + view bound customers (read-only) +2. Edit product prices (library pull on next) + +- No manual effective_time input. +- Draft version: status=0, effective_time null until activate. +- Activate: sets effective_time=now; previous version expires. Does not change package header status. +- Copy as new version: `POST /price/set/version/copy/{psvId}` inserts new draft `prc_price_set_version` and copies `prc_price_set_detail` (+ surcharge snapshots) from source psv_id. +- Audit submit/approve/reject removed from UI save path. + +## Graph + +``` +prc_price_set + -> prc_price_set_version draft / active / expired + -> prc_price_set_detail product prices (psv_id) + -> prc_price_set_version_surcharge +``` + +## Status + +Version: 0 draft, 1 active, 2 expired. Active when effective_time <= now and (expire_time null or > now). + +## SQL + +1. `price_set_version.sql` +2. `price_set_version_eff_nullable.sql` (allow null `prc_price_set.effective_date` and `prc_price_set_version.effective_time` for drafts) + +## Billing + +Match by price_set_id; multi-version quote should filter active psv_id by bill time. diff --git a/bms-back/sql/price/price_set.sql b/bms-back/sql/price/price_set.sql index 5807678b..5b1b09bf 100644 --- a/bms-back/sql/price/price_set.sql +++ b/bms-back/sql/price/price_set.sql @@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS prc_price_set ( default_discount DECIMAL(5,2) DEFAULT NULL COMMENT '默认折扣百分数 1-99,仅 DISCOUNT', default_type CHAR(1) DEFAULT NULL COMMENT '默认套餐 Y=默认', calculate_sign CHAR(1) DEFAULT NULL COMMENT '试算标记 Y=外部试算套餐', - effective_date DATE NOT NULL COMMENT '生效日期', + effective_date DATE DEFAULT NULL COMMENT 'effective date (null until activate)', expire_date DATE DEFAULT NULL COMMENT '失效日期(空=长期)', status CHAR(1) NOT NULL DEFAULT 'D' COMMENT 'D草稿 A审核中 E生效 R驳回 S停用', tms_id BIGINT(20) DEFAULT NULL COMMENT '多租户系统ID', @@ -46,17 +46,23 @@ CREATE TABLE IF NOT EXISTS prc_price_set_detail ( zd_id BIGINT(20) DEFAULT NULL COMMENT 'legacy zone division id', wsd_id BIGINT(20) DEFAULT NULL COMMENT '重量段明细id', weight_label VARCHAR(50) DEFAULT NULL COMMENT '重量段展示', + weight_min DECIMAL(10,3) DEFAULT NULL COMMENT '重量段最小值KG(含)', + weight_max DECIMAL(10,3) DEFAULT NULL COMMENT '重量段最大值KG(含)', product_country_id BIGINT(20) DEFAULT NULL COMMENT '关联 bms_product_country.detail_id', fv_value DECIMAL(10,4) DEFAULT NULL COMMENT '运费单价 RMB/KG', fv_registration_fee DECIMAL(10,2) DEFAULT NULL COMMENT '挂号费 RMB/票', discount_value DECIMAL(5,2) DEFAULT NULL COMMENT '明细折扣百分数,DISCOUNT 可覆盖默认', round_mode VARCHAR(20) NOT NULL DEFAULT 'HALF_UP' COMMENT '取值方式(字典bms_round_mode)', pr_id INT(11) DEFAULT NULL COMMENT '关联价格规则id', + effective_date DATE DEFAULT NULL COMMENT '生效日期', + expire_date DATE DEFAULT NULL COMMENT '失效日期(空=长期)', create_time DATETIME DEFAULT NULL COMMENT '创建时间', update_time DATETIME DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (psd_id), INDEX idx_psd_set (price_set_id), INDEX idx_psd_product (price_set_id, product_code, country_code), + INDEX idx_psd_weight_match (price_set_id, product_code, country_code, weight_min, weight_max), + INDEX idx_psd_eff (price_set_id, effective_date, expire_date), INDEX idx_psd_product_country (product_country_id), CONSTRAINT fk_psd_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='价格套餐明细-固定价/折扣范围'; diff --git a/bms-back/sql/price/price_set_detail_unique_alter.sql b/bms-back/sql/price/price_set_detail_unique_alter.sql new file mode 100644 index 00000000..570bbee0 --- /dev/null +++ b/bms-back/sql/price/price_set_detail_unique_alter.sql @@ -0,0 +1,22 @@ +-- Prevent duplicate FIXED details within one version (same product_country snapshot row) +-- MySQL allows multiple NULLs in UNIQUE, so DISCOUNT rows (product_country_id null) are OK. +SET NAMES utf8mb4; +SET @db := DATABASE(); + +-- Clean exact duplicates before adding unique key (keep min psd_id) +DELETE d1 FROM prc_price_set_detail d1 +INNER JOIN prc_price_set_detail d2 + ON d1.psv_id = d2.psv_id + AND d1.product_country_id = d2.product_country_id + AND d1.psd_id > d2.psd_id +WHERE d1.psv_id IS NOT NULL + AND d1.product_country_id IS NOT NULL; + +SET @has := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_price_set_detail' AND INDEX_NAME = 'uk_psd_psv_product_country' +); +SET @sql := IF(@has = 0, + 'ALTER TABLE prc_price_set_detail ADD UNIQUE KEY uk_psd_psv_product_country (psv_id, product_country_id)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/bms-back/sql/price/price_set_detail_weight_eff_alter.sql b/bms-back/sql/price/price_set_detail_weight_eff_alter.sql new file mode 100644 index 00000000..bfee6991 --- /dev/null +++ b/bms-back/sql/price/price_set_detail_weight_eff_alter.sql @@ -0,0 +1,38 @@ +-- prc_price_set_detail: weight_min/weight_max + effective_date/expire_date +-- same types as bms_product_country +SET NAMES utf8mb4; +SET @db := DATABASE(); + +SET @has := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='prc_price_set_detail' AND COLUMN_NAME='weight_min'); +SET @sql := IF(@has=0, 'ALTER TABLE prc_price_set_detail ADD COLUMN weight_min DECIMAL(10,3) DEFAULT NULL COMMENT ''重量段最小值KG(含)'' AFTER weight_label', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='prc_price_set_detail' AND COLUMN_NAME='weight_max'); +SET @sql := IF(@has=0, 'ALTER TABLE prc_price_set_detail ADD COLUMN weight_max DECIMAL(10,3) DEFAULT NULL COMMENT ''重量段最大值KG(含)'' AFTER weight_min', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='prc_price_set_detail' AND COLUMN_NAME='effective_date'); +SET @sql := IF(@has=0, 'ALTER TABLE prc_price_set_detail ADD COLUMN effective_date DATE DEFAULT NULL COMMENT ''生效日期'' AFTER pr_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='prc_price_set_detail' AND COLUMN_NAME='expire_date'); +SET @sql := IF(@has=0, 'ALTER TABLE prc_price_set_detail ADD COLUMN expire_date DATE DEFAULT NULL COMMENT ''失效日期(空=长期)'' AFTER effective_date', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='prc_price_set_detail' AND INDEX_NAME='idx_psd_weight_match'); +SET @sql := IF(@has=0, 'ALTER TABLE prc_price_set_detail ADD INDEX idx_psd_weight_match (price_set_id, product_code, country_code, weight_min, weight_max)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='prc_price_set_detail' AND INDEX_NAME='idx_psd_eff'); +SET @sql := IF(@has=0, 'ALTER TABLE prc_price_set_detail ADD INDEX idx_psd_eff (price_set_id, effective_date, expire_date)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 回填: 从 bms_product_country 带入重量段与有效期 +UPDATE prc_price_set_detail d +INNER JOIN bms_product_country c ON c.detail_id = d.product_country_id +SET + d.weight_min = IFNULL(d.weight_min, c.weight_min), + d.weight_max = IFNULL(d.weight_max, c.weight_max), + d.effective_date = IFNULL(d.effective_date, c.effective_date), + d.expire_date = IFNULL(d.expire_date, c.expire_date) +WHERE d.product_country_id IS NOT NULL; diff --git a/bms-back/sql/price/price_set_version.sql b/bms-back/sql/price/price_set_version.sql new file mode 100644 index 00000000..2945e458 --- /dev/null +++ b/bms-back/sql/price/price_set_version.sql @@ -0,0 +1,152 @@ +-- 价格套餐版本中间表 + 版本附加费 +-- 关系: +-- prc_price_set +-- -> prc_price_set_version (步骤2: 版本号自动生成, 生效时间) +-- -> prc_price_set_detail (步骤3: 产品价格, 通过 psv_id) +-- -> prc_price_set_version_surcharge (psd_id 关联明细) +-- 可重复执行 +SET NAMES utf8mb4; +SET @db := DATABASE(); + +CREATE TABLE IF NOT EXISTS prc_price_set_version ( + psv_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '套餐版本id', + price_set_id BIGINT(20) NOT NULL COMMENT '价格套餐id', + version_no VARCHAR(20) NOT NULL COMMENT '版本号 V1/V2,自动+1', + version_seq INT(11) NOT NULL COMMENT '版本序号,用于自动+1', + effective_time DATETIME DEFAULT NULL COMMENT 'version start time (null until activate)', + expire_time DATETIME DEFAULT NULL COMMENT '失效时间(空=长期;下一版本启动时写入该启动时间)', + status CHAR(1) NOT NULL DEFAULT '0' COMMENT '0草稿 1生效 2失效', + created_by VARCHAR(64) DEFAULT '' COMMENT '创建人', + create_time DATETIME DEFAULT NULL COMMENT '创建时间', + updated_by VARCHAR(64) DEFAULT '' COMMENT '更新人', + update_time DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (psv_id), + UNIQUE KEY uk_psv_set_no (price_set_id, version_no), + UNIQUE KEY uk_psv_set_seq (price_set_id, version_seq), + INDEX idx_psv_set_eff (price_set_id, effective_time), + INDEX idx_psv_status (status), + CONSTRAINT fk_psv_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='价格套餐版本'; + +CREATE TABLE IF NOT EXISTS prc_price_set_version_surcharge ( + psvs_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '版本附加费id', + psv_id BIGINT(20) NOT NULL COMMENT '套餐版本id', + psd_id BIGINT(20) NOT NULL COMMENT '套餐明细id', + sc_id BIGINT(20) DEFAULT NULL COMMENT '来源附加费配置id', + product_code VARCHAR(20) DEFAULT NULL COMMENT '产品代码(与明细一致)', + sc_name VARCHAR(100) NOT NULL COMMENT '附加费名称快照', + fee_type VARCHAR(30) NOT NULL DEFAULT 'SURCHARGE' COMMENT '费用类型(字典bms_fee_type)', + charge_type VARCHAR(20) NOT NULL COMMENT '计费方式 FIXED固定金额/WEIGHT按重量/RATE按比例', + charge_value DECIMAL(18,6) NOT NULL COMMENT '计费数值快照', + currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种', + round_mode VARCHAR(20) NOT NULL DEFAULT 'HALF_UP' COMMENT '取值方式(字典bms_round_mode)', + country_code CHAR(2) DEFAULT NULL COMMENT '适用国家ISO2(空=全部)', + sc_note VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by VARCHAR(64) DEFAULT '' COMMENT '创建人', + create_time DATETIME DEFAULT NULL COMMENT '创建时间', + updated_by VARCHAR(64) DEFAULT '' COMMENT '更新人', + update_time DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (psvs_id), + INDEX idx_psvs_psv (psv_id), + INDEX idx_psvs_psd (psd_id), + INDEX idx_psvs_product (psv_id, product_code), + INDEX idx_psvs_sc (sc_id), + CONSTRAINT fk_psvs_psv FOREIGN KEY (psv_id) REFERENCES prc_price_set_version (psv_id) ON DELETE CASCADE, + CONSTRAINT fk_psvs_psd FOREIGN KEY (psd_id) REFERENCES prc_price_set_detail (psd_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='价格套餐版本附加费快照'; + + +-- expire_time: previous version expire = next version effective_time (start) +SET @has := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_price_set_version' AND COLUMN_NAME = 'expire_time' +); +SET @sql := IF(@has = 0, + 'ALTER TABLE prc_price_set_version ADD COLUMN expire_time DATETIME DEFAULT NULL COMMENT ''失效时间(空=长期;下一版本启动时写入该启动时间)'' AFTER effective_time', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- backfill: expire_time = next version start time (by version_seq) +UPDATE prc_price_set_version cur +INNER JOIN prc_price_set_version nxt + ON nxt.price_set_id = cur.price_set_id + AND nxt.version_seq = cur.version_seq + 1 +SET cur.expire_time = nxt.effective_time; + +-- prc_price_set_detail.psv_id +SET @has := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_price_set_detail' AND COLUMN_NAME = 'psv_id' +); +SET @sql := IF(@has = 0, + 'ALTER TABLE prc_price_set_detail ADD COLUMN psv_id BIGINT(20) DEFAULT NULL COMMENT ''套餐版本id'' AFTER price_set_id', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_price_set_detail' AND INDEX_NAME = 'idx_psd_psv' +); +SET @sql := IF(@has = 0, + 'ALTER TABLE prc_price_set_detail ADD INDEX idx_psd_psv (psv_id)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has := ( + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_price_set_detail' AND CONSTRAINT_NAME = 'fk_psd_psv' +); +SET @sql := IF(@has = 0, + 'ALTER TABLE prc_price_set_detail ADD CONSTRAINT fk_psd_psv FOREIGN KEY (psv_id) REFERENCES prc_price_set_version (psv_id) ON DELETE CASCADE', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 已建表补关联: 附加费.psd_id -> 套餐明细 +DELETE FROM prc_price_set_version_surcharge +WHERE psd_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM prc_price_set_detail d WHERE d.psd_id = prc_price_set_version_surcharge.psd_id + ); + +SET @sql := CONCAT( + 'ALTER TABLE prc_price_set_version_surcharge MODIFY COLUMN psd_id BIGINT(20) NOT NULL COMMENT ''套餐明细id''' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @has := ( + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_price_set_version_surcharge' AND CONSTRAINT_NAME = 'fk_psvs_psd' +); +SET @sql := IF(@has = 0, + 'ALTER TABLE prc_price_set_version_surcharge ADD CONSTRAINT fk_psvs_psd FOREIGN KEY (psd_id) REFERENCES prc_price_set_detail (psd_id) ON DELETE CASCADE', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 回填: 每个已有套餐生成一条 V1 +INSERT INTO prc_price_set_version ( + price_set_id, version_no, version_seq, effective_time, status, created_by, create_time +) +SELECT + s.price_set_id, + 'V1', + 1, + COALESCE(CAST(s.effective_date AS DATETIME), s.create_time, NOW()), + CASE + WHEN s.status = 'S' THEN '2' + WHEN s.expire_date IS NOT NULL AND s.expire_date <= CURDATE() THEN '2' + WHEN s.status = 'E' AND (s.effective_date IS NULL OR s.effective_date <= CURDATE()) THEN '1' + ELSE '0' + END, + IFNULL(s.created_by, ''), + NOW() +FROM prc_price_set s +WHERE NOT EXISTS ( + SELECT 1 FROM prc_price_set_version v WHERE v.price_set_id = s.price_set_id +); + +-- 将已有明细绑定到同一套餐的 V1 +UPDATE prc_price_set_detail d +INNER JOIN prc_price_set_version v + ON v.price_set_id = d.price_set_id AND v.version_seq = 1 +SET d.psv_id = v.psv_id +WHERE d.psv_id IS NULL; diff --git a/bms-back/sql/price/price_set_version_eff_nullable.sql b/bms-back/sql/price/price_set_version_eff_nullable.sql new file mode 100644 index 00000000..11a7edc7 --- /dev/null +++ b/bms-back/sql/price/price_set_version_eff_nullable.sql @@ -0,0 +1,23 @@ +-- Draft package / version: effective dates nullable until activate +SET NAMES utf8mb4; +SET @db := DATABASE(); + +-- prc_price_set.effective_date +SET @nullable := ( + SELECT IS_NULLABLE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_price_set' AND COLUMN_NAME = 'effective_date' +); +SET @sql := IF(@nullable = 'NO', + 'ALTER TABLE prc_price_set MODIFY COLUMN effective_date DATE DEFAULT NULL COMMENT ''effective date (null until version activate)''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- prc_price_set_version.effective_time +SET @nullable := ( + SELECT IS_NULLABLE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'prc_price_set_version' AND COLUMN_NAME = 'effective_time' +); +SET @sql := IF(@nullable = 'NO', + 'ALTER TABLE prc_price_set_version MODIFY COLUMN effective_time DATETIME DEFAULT NULL COMMENT ''version start time (null until activate)''', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/bms-front/src/api/price/set.js b/bms-front/src/api/price/set.js index ce2cf84c..3507a847 100644 --- a/bms-front/src/api/price/set.js +++ b/bms-front/src/api/price/set.js @@ -23,6 +23,60 @@ export function savePriceSet(data) { }) } +export function getPriceSetSaveTask(taskId) { + return request({ + url: '/price/set/save/task/' + taskId, + method: 'get' + }) +} + +/** + * Submit save async and poll Redis progress until SUCCESS/FAIL. + * @returns {Promise<{ priceSetId: number|string, task: object }>} + */ +export async function savePriceSetAsync(data, onProgress) { + const submitRes = await savePriceSet(data) + const taskId = pickSaveTaskId(submitRes) + if (!taskId) { + throw new Error('missing save taskId') + } + const started = Date.now() + const maxMs = 30 * 60 * 1000 + while (Date.now() - started < maxMs) { + const res = await getPriceSetSaveTask(taskId) + const task = res.data || {} + if (typeof onProgress === 'function') { + onProgress(task) + } + if (task.status === 'SUCCESS') { + return { priceSetId: task.priceSetId, task } + } + if (task.status === 'FAIL') { + const err = new Error(task.error || task.message || 'save failed') + err.task = task + throw err + } + await new Promise(resolve => setTimeout(resolve, 800)) + } + throw new Error('save timeout') +} + +/** Accept data.taskId / data string / legacy msg UUID (RuoYi String overload). */ +function pickSaveTaskId(res) { + if (!res) return null + const d = res.data + if (d && typeof d === 'object' && d.taskId) return String(d.taskId) + if (typeof d === 'string' && d.trim()) return d.trim() + if (typeof res.msg === 'string') { + const m = res.msg.trim() + if (/^[a-f0-9]{32}$/i.test(m)) return m + if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(m)) { + return m.replace(/-/g, '') + } + } + return null +} + export function updatePriceSet(data) { return request({ url: '/price/set', @@ -59,6 +113,27 @@ export function rejectPriceSet(priceSetId) { }) } +export function activatePriceSetVersion(psvId) { + return request({ + url: '/price/set/version/activate/' + psvId, + method: 'put' + }) +} + +export function copyPriceSetVersion(psvId) { + return request({ + url: '/price/set/version/copy/' + psvId, + method: 'post' + }) +} + +export function deletePriceSetVersion(psvId) { + return request({ + url: '/price/set/version/' + psvId, + method: 'delete' + }) +} + export function disablePriceSet(priceSetId) { return request({ url: '/price/set/disable/' + priceSetId, diff --git a/bms-front/src/api/price/surcharge.js b/bms-front/src/api/price/surcharge.js index d8ccdca0..06ea5207 100644 --- a/bms-front/src/api/price/surcharge.js +++ b/bms-front/src/api/price/surcharge.js @@ -27,6 +27,15 @@ export function listSurchargeByProduct(productCode) { }) } +/** 附加费配置全量(非分页,套餐明细勾选用;不要求 price:surcharge:list 权限) */ +export function listSurchargeOptions(query) { + return request({ + url: '/price/surcharge/options', + method: 'get', + params: query + }) +} + /** 绑定附加费到产品/明细(scId + productCode + detailId) */ export function addSurchargeRel(data) { return request({ diff --git a/bms-front/src/views/price/set/components/create/PriceEditDrawer.vue b/bms-front/src/views/price/set/components/create/PriceEditDrawer.vue new file mode 100644 index 00000000..849f4374 --- /dev/null +++ b/bms-front/src/views/price/set/components/create/PriceEditDrawer.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/bms-front/src/views/price/set/components/create/StepBasicInfo.vue b/bms-front/src/views/price/set/components/create/StepBasicInfo.vue index abde7ea6..0392fe6e 100644 --- a/bms-front/src/views/price/set/components/create/StepBasicInfo.vue +++ b/bms-front/src/views/price/set/components/create/StepBasicInfo.vue @@ -41,15 +41,6 @@
范围 1~99,88 表示默认价 × 0.88;FIXED 模式此值忽略
- - - - - - -
空表示长期有效
-
- @@ -69,7 +60,6 @@ const formRef = ref() const rules = { priceSetName: [{ required: true, message: '套餐名称不能为空', trigger: 'blur' }], setType: [{ required: true, message: '请选择计价类型', trigger: 'change' }], - effectiveDate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }] } /** 供父级在下一步 / 保存前调用 */ diff --git a/bms-front/src/views/price/set/components/create/StepCustomers.vue b/bms-front/src/views/price/set/components/create/StepCustomers.vue index cd4a3fb2..88cc94c0 100644 --- a/bms-front/src/views/price/set/components/create/StepCustomers.vue +++ b/bms-front/src/views/price/set/components/create/StepCustomers.vue @@ -2,95 +2,23 @@ - - - - - - - - - - - - - - - - - - - + + + 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 5723f9ca..3ed133ec 100644 --- a/bms-front/src/views/price/set/components/create/StepDetails.vue +++ b/bms-front/src/views/price/set/components/create/StepDetails.vue @@ -1,191 +1,473 @@ diff --git a/bms-front/src/views/price/set/components/create/StepVersion.vue b/bms-front/src/views/price/set/components/create/StepVersion.vue new file mode 100644 index 00000000..2618e079 --- /dev/null +++ b/bms-front/src/views/price/set/components/create/StepVersion.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/bms-front/src/views/price/set/components/create/SurchargeCountCell.vue b/bms-front/src/views/price/set/components/create/SurchargeCountCell.vue new file mode 100644 index 00000000..51caf47d --- /dev/null +++ b/bms-front/src/views/price/set/components/create/SurchargeCountCell.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/bms-front/src/views/price/set/components/create/SurchargePickerDialog.vue b/bms-front/src/views/price/set/components/create/SurchargePickerDialog.vue new file mode 100644 index 00000000..55f62901 --- /dev/null +++ b/bms-front/src/views/price/set/components/create/SurchargePickerDialog.vue @@ -0,0 +1,137 @@ + + + + + + diff --git a/bms-front/src/views/price/set/components/detail/SetBasicInfoCard.vue b/bms-front/src/views/price/set/components/detail/SetBasicInfoCard.vue index 16b373c3..8d74d2fa 100644 --- a/bms-front/src/views/price/set/components/detail/SetBasicInfoCard.vue +++ b/bms-front/src/views/price/set/components/detail/SetBasicInfoCard.vue @@ -22,10 +22,7 @@ */ import SetSectionCard from './SetSectionCard.vue' import { - formatDateRange, isDiscountType, - setStatusLabel, - setStatusTagType, setTypeLabel } from '../../utils/priceSet' @@ -50,8 +47,6 @@ const items = computed(() => { value: discount ? `${s.defaultDiscount}%` : '不适用(FIXED)', muted: !discount }, - { label: '状态', value: setStatusLabel(s), tag: setStatusTagType(s) }, - { label: '有效期', value: formatDateRange(s.effectiveDate, s.expireDate) }, { label: '创建人', value: s.createBy || '-' }, { label: '创建时间', value: s.createTime || '-' }, { label: '最后更新', value: (s.updateTime || '-') + ' ' + updater } diff --git a/bms-front/src/views/price/set/components/detail/SetCustomerTable.vue b/bms-front/src/views/price/set/components/detail/SetCustomerTable.vue index b2581c56..b63113db 100644 --- a/bms-front/src/views/price/set/components/detail/SetCustomerTable.vue +++ b/bms-front/src/views/price/set/components/detail/SetCustomerTable.vue @@ -5,17 +5,6 @@ :count="customers.length" sub="一个客户只能绑定一个套餐" > - - - - - - - - - -
解绑后该客户可绑定其他套餐;生效中的套餐修改绑定会即时影响计费命中。
- - diff --git a/bms-front/src/views/price/set/components/detail/SetDetailDialog.vue b/bms-front/src/views/price/set/components/detail/SetDetailDialog.vue index df001309..34190bb6 100644 --- a/bms-front/src/views/price/set/components/detail/SetDetailDialog.vue +++ b/bms-front/src/views/price/set/components/detail/SetDetailDialog.vue @@ -82,6 +82,7 @@ /> RMB/KG +
可留空:原基础表无值时表示不覆盖运费,按产品基础价计费
diff --git a/bms-front/src/views/price/set/components/detail/SetDetailHeader.vue b/bms-front/src/views/price/set/components/detail/SetDetailHeader.vue index f2a3394b..56063bae 100644 --- a/bms-front/src/views/price/set/components/detail/SetDetailHeader.vue +++ b/bms-front/src/views/price/set/components/detail/SetDetailHeader.vue @@ -6,60 +6,40 @@ {{ set.priceSetCode }}
- {{ statusText }} {{ setTypeLabel(set.setType) }} - - - {{ dateRange }} -
返回列表 - - 停用 - 复用新建 + 复用新建
diff --git a/bms-front/src/views/price/set/components/detail/SetVersionTable.vue b/bms-front/src/views/price/set/components/detail/SetVersionTable.vue new file mode 100644 index 00000000..6c6fc24e --- /dev/null +++ b/bms-front/src/views/price/set/components/detail/SetVersionTable.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/bms-front/src/views/price/set/composables/usePriceSetRefData.js b/bms-front/src/views/price/set/composables/usePriceSetRefData.js index edfcd9ed..6cdd8fd4 100644 --- a/bms-front/src/views/price/set/composables/usePriceSetRefData.js +++ b/bms-front/src/views/price/set/composables/usePriceSetRefData.js @@ -5,7 +5,7 @@ * 避免产品 / 产品国家 / 附加费 被重复请求。 */ import { listProduct, listProductCountries } from '@/api/price/product' -import { listSurchargeByProduct } from '@/api/price/surcharge' +import { listSurchargeByProduct, listSurchargeOptions } from '@/api/price/surcharge' import { buildCountryOptions, buildZoneOptions, @@ -14,6 +14,7 @@ import { countryKeyOf, formatWeightLabel, matchCountryZone, + surchargeAppliesToRow, toEditableDetailRow } from '../utils/priceSet' @@ -25,20 +26,30 @@ export function usePriceSetRefData() { const productOptions = ref([]) /** productCode -> bms_product_country 行 */ const countryCache = reactive({}) + /** productCode -> 进行中的国家明细请求(去重,避免 hydrate 按行打爆接口) */ + const countryPending = {} /** productCode -> 有效附加费关联 */ const surchargeCache = reactive({}) /** productCode -> 进行中的请求 */ const surchargePending = {} + /** 价格基础 → 附加费配置 主表全量(勾选弹窗数据源,页面内共享一份) */ + const allSurchargeList = ref(null) + let allSurchargePending = null const productMap = computed(() => new Map(productOptions.value.map(p => [p.value, p]))) - /** 加载可选产品(已生效) */ + /** 加载可选产品(已生效),保留列表展示字段 */ function loadProducts() { return listProduct({ status: '1', pageNum: 1, pageSize: 1000 }).then(res => { productOptions.value = (res.rows || []).map(p => ({ value: p.productCode, label: `${p.productCode} ${p.productName || ''}`.trim(), - name: p.productName + name: p.productName, + serviceLevel: p.serviceLevel, + cargoAttr: p.cargoAttr, + destRegion: p.destRegion, + taxMode: p.taxMode, + countryCount: p.countryCount })) return productOptions.value }) @@ -50,15 +61,72 @@ export function usePriceSetRefData() { return hit ? hit.name : productCode } - /** 确保产品的国家×重量段配置已加载(过滤已失效配置) */ - function ensureCountries(productCode) { + /** product meta for step-3 product list */ + function productMetaOf(productCode) { + return productMap.value.get(productCode) || null + } + + /** YYYY-MM-DD from datetime / date */ + function dateOnly(v) { + if (!v) return null + return String(v).slice(0, 10) + } + + /** + * Country row active at asOf (version effective time). + * Window: effective_date <= asOf AND (expire_date is null OR expire_date > asOf) + */ + function isCountryActiveAt(item, asOf) { + const t = dateOnly(asOf) + if (!t) return true + const eff = dateOnly(item.effectiveDate) + const exp = dateOnly(item.expireDate) + if (eff && eff > t) return false + if (exp && exp <= t) return false + return true + } + + /** currently open country rows (not expired) */ + function isCountryOpen(item) { + if (String(item.status) === '2') return false + const exp = dateOnly(item.expireDate) + if (!exp) return true + const today = dateOnly(new Date().toISOString()) + return exp > today + } + + /** + * Load product country×weight rows. + * Cache keeps full list; pass asOf (version effective_time) to filter the active window. + * If asOf match is empty, fall back to currently open rows so step-3 can still show full product prices. + * In-flight requests are deduped per productCode (hydrateRow is per-detail-row). + */ + function ensureCountries(productCode, asOf) { if (!productCode) return Promise.resolve([]) - if (countryCache[productCode]) return Promise.resolve(countryCache[productCode]) - return listProductCountries(productCode).then(res => { - const rows = (res.data || []).filter(item => !item.expireDate) + const filterRows = (rows) => { + const all = rows || [] + if (!asOf) return all.filter(isCountryOpen) + const hit = all.filter(item => isCountryActiveAt(item, asOf)) + if (hit.length) return hit + return all.filter(isCountryOpen) + } + if (countryCache[productCode]) { + return Promise.resolve(filterRows(countryCache[productCode])) + } + if (countryPending[productCode]) { + return countryPending[productCode].then(filterRows) + } + countryPending[productCode] = listProductCountries(productCode).then(res => { + const rows = res.data || [] countryCache[productCode] = rows return rows + }).catch(() => { + // do not cache failure; allow later retry without stampeding while in-flight + return [] + }).finally(() => { + delete countryPending[productCode] }) + return countryPending[productCode].then(filterRows) } /** 确保产品的有效附加费已加载(剔除 OF 燃油,结果缓存 + 请求去重) */ @@ -71,7 +139,7 @@ export function usePriceSetRefData() { surchargeCache[productCode] = rows return rows }).catch(() => { - surchargeCache[productCode] = [] + // 失败不写缓存,后续可重试(避免一次抖动就让该产品永久显示无附加费) return [] }).finally(() => { delete surchargePending[productCode] @@ -79,18 +147,41 @@ export function usePriceSetRefData() { return surchargePending[productCode] } + /** 产品已关联的附加费全量(不按国家/重量段过滤),供套餐勾选使用 */ + function allSurchargesOf(productCode) { + if (!productCode) return [] + return surchargeCache[productCode] || [] + } + + /** 确保附加费配置主表已加载(剔除 OF 燃油,页面内共享一份缓存) */ + function ensureAllSurcharges() { + if (allSurchargeList.value) return Promise.resolve(allSurchargeList.value) + if (!allSurchargePending) { + allSurchargePending = listSurchargeOptions().then(res => { + // /options 返回 AjaxResult(data),兼容分页接口的 rows 写法 + const raw = res.data || res.rows || [] + const rows = raw.filter(item => String(item.ssCode || '').toLowerCase() !== 'of') + allSurchargeList.value = rows + return rows + }).catch(() => { + // 失败不缓存,允许下次重试;调用方拿空数组也不至于报错中断 + return [] + }).finally(() => { + allSurchargePending = null + }) + } + return allSurchargePending + } + + /** 附加费配置主表全量(需先 ensureAllSurcharges) */ + function allSurchargeConfigs() { + return allSurchargeList.value || [] + } + /** 明细行命中的附加费(按产品 + 国家 + 重量段过滤) */ function surchargesOf(row) { if (!row || !row.productCode) return [] - const all = surchargeCache[row.productCode] || [] - return all.filter(s => { - if (s.countryCode && row.countryCode && String(s.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) { - return false - } - if (s.detailId == null) return true - if (row.productCountryId != null && Number(s.detailId) === Number(row.productCountryId)) return true - return false - }) + return (surchargeCache[row.productCode] || []).filter(s => surchargeAppliesToRow(s, row)) } /** 明细行可选国家 */ @@ -115,8 +206,12 @@ export function usePriceSetRefData() { productOptions, loadProducts, productNameOf, + productMetaOf, ensureCountries, ensureSurcharges, + allSurchargesOf, + ensureAllSurcharges, + allSurchargeConfigs, surchargesOf, countryOptionsOf, zoneOptionsOf, @@ -140,9 +235,13 @@ export function useDetailCascade(refData) { row.zoneCode = undefined row.zoneName = undefined row.weightLabel = undefined + row.weightMin = undefined + row.weightMax = undefined row.productCountryId = undefined row.fvValue = undefined row.fvRegistrationFee = undefined + row.effectiveDate = undefined + row.expireDate = undefined } /** 选择产品:回填名称、清空级联、预取附加费与国家配置 */ @@ -163,9 +262,13 @@ export function useDetailCascade(refData) { row.zoneCode = undefined row.zoneName = undefined row.weightLabel = undefined + row.weightMin = undefined + row.weightMax = undefined row.productCountryId = undefined row.fvValue = undefined row.fvRegistrationFee = undefined + row.effectiveDate = undefined + row.expireDate = undefined } function onZoneChange(row) { @@ -174,9 +277,13 @@ export function useDetailCascade(refData) { row.zoneCode = opt ? opt.zoneCode : undefined row.zoneName = opt ? opt.zoneName : undefined row.weightLabel = undefined + row.weightMin = undefined + row.weightMax = undefined row.productCountryId = undefined row.fvValue = undefined row.fvRegistrationFee = undefined + row.effectiveDate = undefined + row.expireDate = undefined } /** 选择重量段:绑定产品国家明细,并带出默认运费 / 挂号费 */ @@ -184,6 +291,10 @@ export function useDetailCascade(refData) { const opt = refData.weightOptionsOf(row).find(w => w.value === row.weightLabel) if (!opt) { row.productCountryId = undefined + row.weightMin = undefined + row.weightMax = undefined + row.effectiveDate = undefined + row.expireDate = undefined return } row.productCountryId = opt.detailId @@ -192,14 +303,32 @@ export function useDetailCascade(refData) { if (opt.zoneName) row.zoneName = opt.zoneName if (opt.freightPrice != null) row.fvValue = Number(opt.freightPrice) if (opt.registrationFee != null) row.fvRegistrationFee = Number(opt.registrationFee) + row.weightMin = opt.weightMin + row.weightMax = opt.weightMax + row.effectiveDate = opt.effectiveDate + row.expireDate = opt.expireDate } /** hydrate detail row; normalize weight label to min { if (!row.weightLabel) return const hit = rows.find(item => { @@ -217,6 +346,12 @@ export function useDetailCascade(refData) { if (hit.zoneId != null) row.zoneId = hit.zoneId if (hit.zoneCode) row.zoneCode = hit.zoneCode if (hit.zoneName) row.zoneName = hit.zoneName + row.weightMin = hit.weightMin + row.weightMax = hit.weightMax + row.effectiveDate = hit.effectiveDate + row.expireDate = hit.expireDate + if (row._baseFreight == null && hit.freightPrice != null) row._baseFreight = Number(hit.freightPrice) + if (row._baseReg == null && hit.registrationFee != null) row._baseReg = Number(hit.registrationFee) }) return row } diff --git a/bms-front/src/views/price/set/create.vue b/bms-front/src/views/price/set/create.vue index 1a0f412f..cee4259c 100644 --- a/bms-front/src/views/price/set/create.vue +++ b/bms-front/src/views/price/set/create.vue @@ -1,59 +1,61 @@