diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/BillingController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/BillingController.java index f62c99ef..a382d026 100644 --- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/BillingController.java +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/BillingController.java @@ -1,14 +1,19 @@ package com.ruoyi.web.controller.price; +import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.price.domain.PrcQuote; import com.ruoyi.price.domain.vo.TmsPriceQuoteRequest; import com.ruoyi.price.domain.vo.TmsPriceQuoteResult; import com.ruoyi.price.service.ITmsPriceQuoteService; @@ -25,6 +30,28 @@ public class BillingController extends BaseController @Autowired private ITmsPriceQuoteService tmsPriceQuoteService; + /** + * Historical quote list. + */ + @PreAuthorize("@ss.hasPermi('price:billing:quote')") + @GetMapping("/quote/list") + public TableDataInfo list(PrcQuote query) + { + startPage(); + List list = tmsPriceQuoteService.selectQuoteList(query); + return getDataTable(list); + } + + /** + * Historical quote detail with fee lines. + */ + @PreAuthorize("@ss.hasPermi('price:billing:quote')") + @GetMapping("/quote/{quoteId}") + public AjaxResult getInfo(@PathVariable Long quoteId) + { + return success(tmsPriceQuoteService.getQuote(quoteId)); + } + /** * Same matching logic as TMS open API, for BMS console trial. */ diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/ProductController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/ProductController.java index 73aa33e4..6f794ae0 100644 --- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/ProductController.java +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/price/ProductController.java @@ -1,5 +1,6 @@ package com.ruoyi.web.controller.price; +import java.util.Date; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.annotation.Log; @@ -45,9 +47,11 @@ public class ProductController extends BaseController @PreAuthorize("@ss.hasPermi('price:product:query')") @GetMapping("/{productCode}/countries") - public AjaxResult countries(@PathVariable String productCode) + public AjaxResult countries(@PathVariable String productCode, + @RequestParam(required = false) Date effectiveDate, + @RequestParam(required = false) Long productId) { - List list = productService.selectCountryListByProductCode(productCode); + List list = productService.selectCountryListByProductCode(productCode, effectiveDate, productId); return success(list); } 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 93de0055..c4863965 100644 --- a/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties +++ b/bms-back/ruoyi-admin/src/main/resources/i18n/messages.properties @@ -36,3 +36,83 @@ no.update.permission=您没有修改数据的权限,请联系管理员添加 no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}] no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}] no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}] + +##价格模块提示语(price) +# 附加费 PrcSurcharge +price.surcharge.scName.empty=附加费名称不能为空 +price.surcharge.chargeType.empty=计费方式不能为空 +price.surcharge.chargeValue.empty=计费数值不能为空 +price.surcharge.chargeValue.negative=计费数值不能为负数 +# 附加费关联 PrcSurchargeRel +price.surchargeRel.scId.empty=附加费ID不能为空 +price.surchargeRel.productId.empty=产品ID不能为空 +price.surchargeRel.productCode.empty=产品代码不能为空 +# 套餐 PrcPriceSet +price.set.notExists=套餐不存在 +price.set.name.empty=套餐名称不能为空 +price.set.name.exists=套餐名称已存在 +price.set.effectiveDate.empty=生效日期不能为空 +price.set.date.invalid=生效日期不能晚于失效日期 +price.set.discount.range=折扣模式默认折扣需在 1~99 +price.set.delete.statusNotAllowed=仅草稿或驳回状态的套餐允许删除:{0} +price.set.delete.boundCustomer=套餐【{0}】已绑定客户,请先解绑后再删除 +price.set.submit.statusNotAllowed=当前状态不允许提交审核 +price.set.approve.statusNotAllowed=仅审核中的套餐可以生效 +price.set.reject.statusNotAllowed=仅审核中的套餐可以驳回 +price.set.disable.statusNotAllowed=仅生效中的套餐可以停用 +price.set.edit.audit=审核中的套餐不允许修改 +price.set.edit.stopped=已停用的套餐不允许修改,请使用复用新建 +price.set.childEdit.audit=审核中的套餐不允许改明细或客户 +price.set.childEdit.stopped=已停用的套餐不允许改明细或客户 +price.set.detail.notExists=套餐明细不存在 +price.set.detail.productCode.empty=明细产品代码不能为空 +price.set.detail.country.empty=固定价明细必须填写国家 +price.set.detail.weight.empty=固定价明细必须填写重量段 +price.set.detail.freight.empty=固定价明细必须填写运费单价 +price.set.detail.registration.empty=固定价明细必须填写挂号费 +price.set.detail.roundMode.invalid=取值方式仅支持 UP / DOWN / HALF_UP +price.set.customer.notExists=客户绑定不存在 +price.set.customer.unbindAudit=审核中的套餐不允许解绑客户 +price.set.customer.customerId.empty=客户ID不能为空 +price.set.customer.beginDate.empty=客户绑定生效日期不能为空 +price.set.customer.date.invalid=客户绑定生效日期不能晚于失效日期 +price.set.customer.alreadyBound=该客户已绑定套餐,一个客户只能绑定一个套餐 +price.set.customer.priority.invalid=优先级必须大于等于 1 +# 重量段 PrcWeight +price.weight.range.empty=起始重量和结束重量不能为空 +price.weight.range.invalid=起始重量必须小于结束重量 +# 分区 XtdZone +price.zone.delete.hasDivision=分区方案【{0}】存在区划明细,不允许删除 +# 产品导入 BmsProduct +price.product.codeDate.exists=同一产品代码与生效日期已存在,不能覆盖历史数据 +price.product.effectiveDate.empty=生效日期不能为空 +price.product.import.effEmpty=缺少生效日期,已跳过 +price.product.import.fileEmpty=上传文件不能为空 +price.product.import.fileType=仅支持 xls / xlsx 文件 +price.product.import.sheetNotFound=未找到「产品目录」sheet,请上传威狮B2C小包价表格格式文件 +price.product.import.noData=产品目录无有效数据行 +price.product.import.regionEmpty=bms_region 无可用国家数据,请先同步/维护国家表后再导入 +price.product.import.partialFail=导入完成但存在失败项:新增 {0},跳过 {1};失败明细:{2} +price.product.import.effTooEarly=生效日期 {0} 早于当前开放版 {1},请使用更晚的生效日期导入 +price.product.import.needFeeAlter=请先执行 SQL:bms-back/sql/price/product_country_fee_alter.sql(缺少运费/挂号费字段) +price.product.import.needExpireAlter=请先执行 SQL:bms-back/sql/price/product_country_expire_alter.sql(缺少失效日期/版本唯一键) +price.product.import.needCodeEffUnique=无法新增「同代码、不同生效日」的产品。请先执行 SQL:bms-back/sql/price/product_code_eff_unique_alter.sql(去掉 product_code 单列唯一,改为代码+生效日唯一) +price.product.import.success=导入成功:新增 {0} 个产品,跳过 {1} 个(同代码同生效日不覆盖);写入国家/重量段明细 {2} 行。 +price.product.import.success.detail1=产品主数据按「产品代码+生效日期」新增,不覆盖已有记录。国家明细:新生效日晚于上次则保留历史,上次失效日=本次生效日。 +price.product.import.success.detail2=范围:产品目录 + 重量段 + 运费/挂号费 + 尺寸/尾程/时效;国家码来自 bms_region。 +price.product.import.success.unmatched=未匹配国家表(country_code 为空):{0} +# 报价试算 TmsPriceQuote(对外 TMS API 错误码,保持英文契约) +price.quote.priceNotHit=没有产品/国家/重量段绑定,暂无价格 +price.quote.freightMissing1=产品默认运费为空 +price.quote.freightMissing2=产品默认运费或挂号费为空 +price.quote.freightMissing3=运费为空 +price.quote.requestBodyRequired=请求体为空 +price.quote.customerIdRequired=客户ID为空 +price.quote.productCodeRequired=产品代码为空 +price.quote.countryRequired=国家代码或国家名称为空 +price.quote.weightInvalid=重量必须大于0 +price.quote.bizDateInvalid=业务日期格式必须为yyyy-MM-dd +price.quote.fixedPackageNoDetails=套餐固定价格没有明细,使用产品默认价格 +price.quote.noCustomerPackageOverride=没有客户套餐覆盖,使用产品默认运费/挂号费 +price.quote.quoteIdRequired=报价ID为空 +price.quote.hitCustomerFixedPackageDetail=命中客户套餐固定价格明细 \ No newline at end of file diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/BmsProduct.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/BmsProduct.java index f9811633..289bbc56 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/BmsProduct.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/BmsProduct.java @@ -25,16 +25,18 @@ public class BmsProduct extends BaseEntity private String taxMode; @JsonFormat(pattern = "yyyy-MM-dd") private Date effectiveDate; - /** 0启用(按日期展示已生效/待生效) 1已失效 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date expireDate; + /** 0 pending, 1 active, 2 expired */ + public static final String STATUS_PENDING = "0"; + public static final String STATUS_ACTIVE = "1"; + public static final String STATUS_EXPIRED = "2"; private String status; private String sourceSheet; private Long tmsId; private Integer countryCount; private Integer surchargeCount; private List countries; - /** - * 查询用:ACTIVE已生效 / PENDING待生效 / EXPIRED已失效 - * (非表字段,由生效日期+status 推导) - */ + /** query alias of status (0/1/2) */ private String effectStatus; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/BmsProductCountry.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/BmsProductCountry.java index 39a62a42..0bde8b5c 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/BmsProductCountry.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/BmsProductCountry.java @@ -13,6 +13,8 @@ public class BmsProductCountry implements Serializable private static final long serialVersionUID = 1L; private Long detailId; + /** FK to bms_product.product_id (one country version belongs to one product row) */ + private Long productId; private String productCode; private String countryName; private String countryCode; @@ -28,10 +30,14 @@ public class BmsProductCountry implements Serializable /** null=open; closed to next import effective_date */ @JsonFormat(pattern = "yyyy-MM-dd") private Date expireDate; + /** 0 pending, 1 active, 2 expired */ + private String status; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createdAt; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date updatedAt; /** query only: true = expire_date is null */ private Boolean openOnly; + /** query alias of status (0/1/2) */ + private String effectStatus; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuote.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuote.java new file mode 100644 index 00000000..250a6110 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuote.java @@ -0,0 +1,56 @@ +package com.ruoyi.price.domain; + +import java.math.BigDecimal; +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; + +/** billing quote header prc_quote */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PrcQuote extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long quoteId; + private String quoteKey; + private Long customerId; + private String productCode; + private String productName; + private String countryCode; + private String countryName; + private BigDecimal weight; + @JsonFormat(pattern = "yyyy-MM-dd") + private Date bizDate; + private Long tmsId; + + private String matchSource; + private String matchMessage; + private Long priceSetId; + private String priceSetCode; + private String priceSetName; + private String setType; + private Integer bindPriority; + private Long customerPricesetId; + private Long productCountryId; + private BigDecimal weightMin; + private BigDecimal weightMax; + private String weightLabel; + @JsonFormat(pattern = "yyyy-MM-dd") + private Date effectiveDate; + private BigDecimal chargeWeight; + private BigDecimal freightRate; + private BigDecimal baseFreightRate; + private BigDecimal discountPercent; + private BigDecimal registrationFee; + private BigDecimal freightAmount; + private BigDecimal surchargeAmount; + private BigDecimal payableTotal; + private String currency; + private String roundMode; + + private List fees; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuoteFee.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuoteFee.java new file mode 100644 index 00000000..9ce02bf0 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/PrcQuoteFee.java @@ -0,0 +1,28 @@ +package com.ruoyi.price.domain; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** billing quote fee detail prc_quote_fee, one row per bms_fee_type */ +@Data +public class PrcQuoteFee implements Serializable +{ + private static final long serialVersionUID = 1L; + + private Long feeId; + private Long quoteId; + /** bms_fee_type: BASIC_FREIGHT / REGISTRATION_FEE / SURCHARGE */ + private String feeType; + private String feeName; + /** prc_surcharge.sc_id, 0 for freight/registration */ + private Long scId; + private BigDecimal amount; + private String currency; + private String roundMode; + private String remark; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createdAt; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteFeeLine.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteFeeLine.java new file mode 100644 index 00000000..8f79e7a3 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteFeeLine.java @@ -0,0 +1,21 @@ +package com.ruoyi.price.domain.vo; + +import java.io.Serializable; +import java.math.BigDecimal; +import lombok.Data; + +/** quote fee line keyed by bms_fee_type */ +@Data +public class TmsPriceQuoteFeeLine implements Serializable +{ + private static final long serialVersionUID = 1L; + + private String feeType; + private String feeName; + /** surcharge config id, 0 for freight/registration */ + private Long scId; + private BigDecimal amount; + private String currency; + private String roundMode; + private String remark; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteResult.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteResult.java index c8e67302..dc00323d 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteResult.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/domain/vo/TmsPriceQuoteResult.java @@ -3,6 +3,7 @@ package com.ruoyi.price.domain.vo; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; +import java.util.List; import lombok.Data; /** @@ -43,8 +44,15 @@ public class TmsPriceQuoteResult implements Serializable private BigDecimal discountPercent; private BigDecimal registrationFee; private BigDecimal freightAmount; + /** 附加费合计(按产品关联命中后汇总) */ + private BigDecimal surchargeAmount; private BigDecimal payableTotal; - private String currency = "RMB"; + private String currency = "CNY"; /** UP / DOWN / HALF_UP */ private String roundMode; + /** 费用明细:运费/挂号费各一行,附加费按具体名称各一行 */ + private List feeDetails; + private Long quoteId; + /** true=命中已落库试算 */ + private Boolean fromCache; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/BmsProductCountryMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/BmsProductCountryMapper.java index 61f2528d..90911c7e 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/BmsProductCountryMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/BmsProductCountryMapper.java @@ -17,9 +17,13 @@ public interface BmsProductCountryMapper public int insertBmsProductCountry(BmsProductCountry country); + public int deleteByProductId(Long productId); + public int deleteByProductCode(String productCode); - public int deleteByProductCodes(String[] productCodes); + public int deleteByProductCodeAndEffectiveDate( + @Param("productCode") String productCode, + @Param("effectiveDate") Date effectiveDate); /** Max open (expire_date is null) effective_date for product */ public Date selectMaxOpenEffectiveDate(@Param("productCode") String productCode); @@ -29,6 +33,9 @@ public interface BmsProductCountryMapper @Param("productCode") String productCode, @Param("newEffectiveDate") Date newEffectiveDate); + /** Recompute status from effective_date / expire_date vs today */ + public int refreshStatusByDates(); + /** Replace same-day version only */ public int deleteOpenByProductCodeAndEffectiveDate( @Param("productCode") String productCode, diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/BmsProductMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/BmsProductMapper.java index 24988a62..1601668e 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/BmsProductMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/BmsProductMapper.java @@ -1,6 +1,8 @@ package com.ruoyi.price.mapper; +import java.util.Date; import java.util.List; +import org.apache.ibatis.annotations.Param; import com.ruoyi.price.domain.BmsProduct; /** @@ -12,8 +14,17 @@ public interface BmsProductMapper { public BmsProduct selectBmsProductById(Long productId); + /** Latest row by product_code (effective_date desc) */ public BmsProduct selectBmsProductByCode(String productCode); + /** Unique business key: product_code + effective_date */ + public BmsProduct selectBmsProductByCodeAndDate(@Param("productCode") String productCode, + @Param("effectiveDate") Date effectiveDate); + + /** Product version effective on bizDate */ + public BmsProduct selectEffectiveBmsProductByCode(@Param("productCode") String productCode, + @Param("bizDate") Date bizDate); + public List selectBmsProductList(BmsProduct product); public int insertBmsProduct(BmsProduct product); @@ -21,4 +32,13 @@ public interface BmsProductMapper public int updateBmsProduct(BmsProduct product); public int deleteBmsProductByIds(Long[] productIds); + + /** Close previous open versions: expire_date = newEffectiveDate */ + public int expireOpenByProductCode( + @Param("productCode") String productCode, + @Param("newEffectiveDate") Date newEffectiveDate, + @Param("excludeId") Long excludeId); + + /** Recompute status from effective_date / expire_date vs today */ + public int refreshStatusByDates(); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcCustomerPriceSetMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcCustomerPriceSetMapper.java index 9afe926d..2e965b6a 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcCustomerPriceSetMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcCustomerPriceSetMapper.java @@ -25,6 +25,11 @@ public interface PrcCustomerPriceSetMapper public int countByPriceSetId(Long priceSetId); + /** Count binds of this customer; optional exclude current bind or current set */ + public int countByCustomerId(@Param("customerId") Long customerId, + @Param("excludeId") Long excludeId, + @Param("excludePriceSetId") Long excludePriceSetId); + public int countOverlap(@Param("customerId") Long customerId, @Param("priceSetId") Long priceSetId, @Param("beginDate") java.util.Date beginDate, diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcQuoteFeeMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcQuoteFeeMapper.java new file mode 100644 index 00000000..ffb19238 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcQuoteFeeMapper.java @@ -0,0 +1,17 @@ +package com.ruoyi.price.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.price.domain.PrcQuoteFee; + +/** billing quote fee detail mapper */ +public interface PrcQuoteFeeMapper +{ + public List selectByQuoteId(Long quoteId); + + public int insertPrcQuoteFee(PrcQuoteFee fee); + + public int insertBatch(@Param("list") List fees); + + public int deleteByQuoteId(Long quoteId); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcQuoteMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcQuoteMapper.java new file mode 100644 index 00000000..20e43beb --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcQuoteMapper.java @@ -0,0 +1,22 @@ +package com.ruoyi.price.mapper; + +import java.math.BigDecimal; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.price.domain.PrcQuote; + +/** billing quote header mapper */ +public interface PrcQuoteMapper +{ + public PrcQuote selectPrcQuoteByKey(String quoteKey); + + public PrcQuote selectPrcQuoteById(Long quoteId); + + public List selectPrcQuoteList(PrcQuote quote); + + public int insertPrcQuote(PrcQuote quote); + + public int updateQuoteAmounts(@Param("quoteId") Long quoteId, + @Param("surchargeAmount") BigDecimal surchargeAmount, + @Param("payableTotal") BigDecimal payableTotal); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IBmsProductService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IBmsProductService.java index afb1fca4..125d0524 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IBmsProductService.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/IBmsProductService.java @@ -1,5 +1,6 @@ package com.ruoyi.price.service; +import java.util.Date; import java.util.List; import org.springframework.web.multipart.MultipartFile; import com.ruoyi.price.domain.BmsProduct; @@ -24,11 +25,15 @@ public interface IBmsProductService public List selectCountryListByProductCode(String productCode); + public List selectCountryListByProductCode(String productCode, Date effectiveDate); + + public List selectCountryListByProductCode(String productCode, Date effectiveDate, Long productId); + /** * 导入威狮B2C小包价表:产品目录 + 各产品 sheet 国家×重量段(含运费/挂号费/尺寸/尾程/时效) * * @param file 上传的 xlsx/xls - * @param updateSupport 是否更新已存在产品 + * @param updateSupport unused; same code+date never overwrites, different date always inserts * @param operName 操作人 * @return 导入结果说明(可含 HTML 换行) */ diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/ITmsPriceQuoteService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/ITmsPriceQuoteService.java index 0669d5ac..4a118d79 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/ITmsPriceQuoteService.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/ITmsPriceQuoteService.java @@ -1,17 +1,24 @@ package com.ruoyi.price.service; +import com.ruoyi.price.domain.PrcQuote; import com.ruoyi.price.domain.vo.TmsPriceQuoteRequest; import com.ruoyi.price.domain.vo.TmsPriceQuoteResult; +import java.util.List; /** * TMS freight quote service. * * Match order: effective customer package (bind_priority ASC) FIXED override * then DISCOUNT, else bms_product_country default freight/registration. + * After calc, persist fee lines by bms_fee_type; same trial params return stored result. * * @author ruoyi */ public interface ITmsPriceQuoteService { TmsPriceQuoteResult quote(TmsPriceQuoteRequest request); + + List selectQuoteList(PrcQuote query); + + TmsPriceQuoteResult getQuote(Long quoteId); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/BmsProductServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/BmsProductServiceImpl.java index c921643d..fbd2c490 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/BmsProductServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/BmsProductServiceImpl.java @@ -26,6 +26,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.price.domain.BmsProduct; import com.ruoyi.price.domain.BmsProductCountry; @@ -50,6 +51,10 @@ public class BmsProductServiceImpl implements IBmsProductService private static final Pattern EFFECTIVE_DATE_PATTERN = Pattern.compile( "\u751f\u6548\u65e5\u671f[\uff1a:]\\s*(\\d{4})[/.-](\\d{1,2})[/.-](\\d{1,2})"); + private static final Pattern EFFECTIVE_DATE_YMD = Pattern.compile( + "(\\d{4})[/.-](\\d{1,2})[/.-](\\d{1,2})"); + private static final Pattern EFFECTIVE_DATE_CN = Pattern.compile( + "(\\d{4})\\s*\u5e74\\s*(\\d{1,2})\\s*\u6708\\s*(\\d{1,2})"); /** Parse 0.01\u2264W\u22640.3 / 0.3 selectBmsProductList(BmsProduct product) { + productMapper.refreshStatusByDates(); + countryMapper.refreshStatusByDates(); + if (product != null) + { + product.setStatus(normalizeStatusCode(product.getStatus())); + product.setEffectStatus(normalizeStatusCode(product.getEffectStatus())); + } return productMapper.selectBmsProductList(product); } @Override public int insertBmsProduct(BmsProduct product) { - if (StringUtils.isEmpty(product.getStatus())) - { - product.setStatus("0"); - } if (StringUtils.isEmpty(product.getTaxMode())) { product.setTaxMode(resolveTaxMode(product.getProductName())); } - return productMapper.insertBmsProduct(product); + if (product.getEffectiveDate() == null) + { + throw new ServiceException(MessageUtils.message("price.product.effectiveDate.empty")); + } + applyProductStatus(product); + assertCodeDateUnique(product, null); + int rows = productMapper.insertBmsProduct(product); + productMapper.expireOpenByProductCode(product.getProductCode(), product.getEffectiveDate(), product.getProductId()); + productMapper.refreshStatusByDates(); + return rows; } @Override public int updateBmsProduct(BmsProduct product) { + if (product.getEffectiveDate() == null) + { + throw new ServiceException(MessageUtils.message("price.product.effectiveDate.empty")); + } + applyProductStatus(product); + assertCodeDateUnique(product, product.getProductId()); return productMapper.updateBmsProduct(product); } @@ -108,44 +131,94 @@ public class BmsProductServiceImpl implements IBmsProductService { return 0; } - List codes = new ArrayList<>(); + List codesNoRemain = new ArrayList<>(); for (Long id : productIds) { BmsProduct p = productMapper.selectBmsProductById(id); - if (p != null && StringUtils.isNotEmpty(p.getProductCode())) + if (p == null || StringUtils.isEmpty(p.getProductCode())) { - codes.add(p.getProductCode()); + continue; + } + if (p.getProductId() != null) + { + countryMapper.deleteByProductId(p.getProductId()); + } + countryMapper.deleteByProductCodeAndEffectiveDate(p.getProductCode(), p.getEffectiveDate()); + codesNoRemain.add(p.getProductCode()); + } + int rows = productMapper.deleteBmsProductByIds(productIds); + List orphanCodes = new ArrayList<>(); + for (String code : codesNoRemain) + { + if (productMapper.selectBmsProductByCode(code) == null) + { + orphanCodes.add(code); } } - if (!codes.isEmpty()) + if (!orphanCodes.isEmpty()) { - countryMapper.deleteByProductCodes(codes.toArray(new String[0])); - surchargeRelService.deleteRelByProductCodes(codes.toArray(new String[0])); + surchargeRelService.deleteRelByProductCodes(orphanCodes.toArray(new String[0])); } - return productMapper.deleteBmsProductByIds(productIds); + return rows; } @Override public List selectCountryListByProductCode(String productCode) { + return selectCountryListByProductCode(productCode, null); + } + + @Override + public List selectCountryListByProductCode(String productCode, Date effectiveDate) + { + return selectCountryListByProductCode(productCode, effectiveDate, null); + } + + @Override + public List selectCountryListByProductCode(String productCode, Date effectiveDate, Long productId) + { + if (productId != null) + { + BmsProductCountry byId = new BmsProductCountry(); + byId.setProductId(productId); + List list = countryMapper.selectBmsProductCountryList(byId); + if (!list.isEmpty()) + { + return list; + } + } BmsProductCountry query = new BmsProductCountry(); query.setProductCode(productCode); + query.setEffectiveDate(effectiveDate); return countryMapper.selectBmsProductCountryList(query); } + private void assertCodeDateUnique(BmsProduct product, Long excludeId) + { + if (product == null || StringUtils.isEmpty(product.getProductCode())) + { + return; + } + BmsProduct dup = productMapper.selectBmsProductByCodeAndDate(product.getProductCode(), product.getEffectiveDate()); + if (dup != null && (excludeId == null || !excludeId.equals(dup.getProductId()))) + { + throw new ServiceException(MessageUtils.message("price.product.codeDate.exists")); + } + } + @Override @Transactional(rollbackFor = Exception.class) public String importProductExcel(MultipartFile file, boolean updateSupport, String operName) throws Exception { if (file == null || file.isEmpty()) { - throw new ServiceException("\u4e0a\u4f20\u6587\u4ef6\u4e0d\u80fd\u4e3a\u7a7a"); + throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty")); } String filename = file.getOriginalFilename(); if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx") || filename.toLowerCase(Locale.ROOT).endsWith(".xls"))) { - throw new ServiceException("\u4ec5\u652f\u6301 xls / xlsx \u6587\u4ef6"); + throw new ServiceException(MessageUtils.message("price.product.import.fileType")); } try (InputStream is = file.getInputStream(); Workbook workbook = WorkbookFactory.create(is)) @@ -153,23 +226,22 @@ public class BmsProductServiceImpl implements IBmsProductService Sheet catalog = workbook.getSheet(CATALOG_SHEET); if (catalog == null) { - throw new ServiceException("\u672a\u627e\u5230\u300c\u4ea7\u54c1\u76ee\u5f55\u300dsheet\uff0c\u8bf7\u4e0a\u4f20\u5a01\u72eeB2C\u5c0f\u5305\u4ef7\u8868\u683c\u5f0f\u6587\u4ef6"); + throw new ServiceException(MessageUtils.message("price.product.import.sheetNotFound")); } List products = parseCatalog(catalog); if (products.isEmpty()) { - throw new ServiceException("\u4ea7\u54c1\u76ee\u5f55\u65e0\u6709\u6548\u6570\u636e\u884c"); + throw new ServiceException(MessageUtils.message("price.product.import.noData")); } Map regionByNameCn = loadRegionByNameCn(); if (regionByNameCn.isEmpty()) { - throw new ServiceException("bms_region \u65e0\u53ef\u7528\u56fd\u5bb6\u6570\u636e\uff0c\u8bf7\u5148\u540c\u6b65/\u7ef4\u62a4\u56fd\u5bb6\u8868\u540e\u518d\u5bfc\u5165"); + throw new ServiceException(MessageUtils.message("price.product.import.regionEmpty")); } int successNum = 0; - int updateNum = 0; int skipNum = 0; int countryNum = 0; Set unmatchedCountries = new HashSet<>(); @@ -184,31 +256,42 @@ public class BmsProductServiceImpl implements IBmsProductService product.setUpdateBy(operName); List countries = parseProductSheet(workbook, product, regionByNameCn, unmatchedCountries); - BmsProduct existing = productMapper.selectBmsProductByCode(product.getProductCode()); + // Sheet header date is the price version; catalog column is often stale + Date sheetEff = resolveImportEffectiveDate(countries); + Date newEff = toDateOnly(sheetEff != null ? sheetEff : product.getEffectiveDate()); + product.setEffectiveDate(newEff); + if (newEff == null) + { + failureMsg.append("
").append(product.getProductCode()).append("\uff1a") + .append(MessageUtils.message("price.product.import.effEmpty")); + continue; + } + BmsProduct existing = productMapper.selectBmsProductByCodeAndDate( + product.getProductCode(), newEff); - if (existing == null) + if (existing != null) { - product.setStatus("0"); - productMapper.insertBmsProduct(product); - countryNum += saveCountriesVersioned(product.getProductCode(), countries); - successNum++; - } - else if (updateSupport) - { - product.setProductId(existing.getProductId()); - productMapper.updateBmsProduct(product); - countryNum += saveCountriesVersioned(product.getProductCode(), countries); - updateNum++; - } - else if (!countries.isEmpty()) - { - // Keep product master; version country rows by effective_date - countryNum += saveCountriesVersioned(product.getProductCode(), countries); - updateNum++; + skipNum++; } else { - skipNum++; + applyProductStatus(product); + try + { + productMapper.insertBmsProduct(product); + } + catch (Exception e) + { + if (isProductCodeUniqueViolation(e)) + { + throw new ServiceException(MessageUtils.message("price.product.import.needCodeEffUnique")); + } + throw e; + } + productMapper.expireOpenByProductCode(product.getProductCode(), newEff, product.getProductId()); + productMapper.refreshStatusByDates(); + countryNum += saveCountriesVersioned(product, countries); + successNum++; } } catch (Exception e) @@ -219,16 +302,15 @@ public class BmsProductServiceImpl implements IBmsProductService if (failureMsg.length() > 0) { - throw new ServiceException("\u5bfc\u5165\u5b8c\u6210\u4f46\u5b58\u5728\u5931\u8d25\u9879\uff1a\u65b0\u589e " + successNum + "\uff0c\u66f4\u65b0 " + updateNum - + "\uff0c\u8df3\u8fc7 " + skipNum + "\uff1b\u5931\u8d25\u660e\u7ec6\uff1a" + failureMsg); + throw new ServiceException(MessageUtils.message("price.product.import.partialFail", + successNum, skipNum, failureMsg.toString())); } - String msg = "\u5bfc\u5165\u6210\u529f\uff1a\u65b0\u589e " + successNum + " \u4e2a\u4ea7\u54c1\uff0c\u66f4\u65b0 " + updateNum + " \u4e2a\uff0c\u8df3\u8fc7 " - + skipNum + " \u4e2a\uff1b\u5199\u5165\u56fd\u5bb6/\u91cd\u91cf\u6bb5\u660e\u7ec6 " + countryNum + " \u884c\u3002" - + "
\u56fd\u5bb6\u660e\u7ec6\uff1a\u82e5\u65b0\u751f\u6548\u65e5\u665a\u4e8e\u4e0a\u6b21\uff0c\u4fdd\u7559\u5386\u53f2\u7248\u4e14\u4e0a\u6b21\u5931\u6548\u65e5=\u672c\u6b21\u751f\u6548\u65e5\uff1b\u540c\u65e5\u5219\u66ff\u6362\u5f53\u524d\u5f00\u653e\u7248\u3002" - + "
\u8303\u56f4\uff1a\u4ea7\u54c1\u76ee\u5f55 + \u91cd\u91cf\u6bb5 + \u8fd0\u8d39/\u6302\u53f7\u8d39 + \u5c3a\u5bf8/\u5c3e\u7a0b/\u65f6\u6548\uff1b\u56fd\u5bb6\u7801\u6765\u81ea bms_region\u3002"; + String msg = MessageUtils.message("price.product.import.success", successNum, skipNum, countryNum) + + "
" + MessageUtils.message("price.product.import.success.detail1") + + "
" + MessageUtils.message("price.product.import.success.detail2"); if (!unmatchedCountries.isEmpty()) { - msg += "
\u672a\u5339\u914d\u56fd\u5bb6\u8868\uff08country_code \u4e3a\u7a7a\uff09\uff1a" + String.join("\u3001", unmatchedCountries); + msg += "
" + MessageUtils.message("price.product.import.success.unmatched", String.join("\u3001", unmatchedCountries)); } return msg; } @@ -236,45 +318,49 @@ public class BmsProductServiceImpl implements IBmsProductService /** * Versioned country save: - * - newEff > last open eff: close old (expire_date = newEff), insert new (keep history) - * - newEff equals last open: replace that open version only + * - newEff > last open: close old (expire_date = newEff), insert new (keep history) + * - newEff equals last open: skip (never overwrite) * - newEff < last open: reject */ - private int saveCountriesVersioned(String productCode, List countries) + private int saveCountriesVersioned(BmsProduct product, List countries) { - if (countries == null || countries.isEmpty()) + if (product == null || countries == null || countries.isEmpty()) { return 0; } + String productCode = product.getProductCode(); + Long productId = product.getProductId(); Date newEff = resolveImportEffectiveDate(countries); Date lastOpenEff = countryMapper.selectMaxOpenEffectiveDate(productCode); if (lastOpenEff != null && newEff != null && newEff.before(lastOpenEff)) { - throw new ServiceException("\u751f\u6548\u65e5\u671f " - + formatDate(newEff) + " \u65e9\u4e8e\u5f53\u524d\u5f00\u653e\u7248 " - + formatDate(lastOpenEff) + "\uff0c\u8bf7\u4f7f\u7528\u66f4\u665a\u7684\u751f\u6548\u65e5\u671f\u5bfc\u5165"); + throw new ServiceException(MessageUtils.message("price.product.import.effTooEarly", + formatDate(newEff), formatDate(lastOpenEff))); } if (lastOpenEff != null && newEff != null && newEff.after(lastOpenEff)) { // Previous open version expire_date = this effective_date; keep old rows countryMapper.expireOpenByProductCode(productCode, newEff); + countryMapper.refreshStatusByDates(); } else if (lastOpenEff != null) { - // Same effective day (or both null): replace current open version only - countryMapper.deleteOpenByProductCodeAndEffectiveDate(productCode, newEff); + // Same effective day: never overwrite existing country rows + return 0; } for (BmsProductCountry c : countries) { + c.setProductId(productId); c.setProductCode(productCode); c.setExpireDate(null); if (c.getEffectiveDate() == null) { c.setEffectiveDate(newEff); } + c.setStatus(resolveStoredStatus(c.getEffectiveDate(), c.getExpireDate())); try { countryMapper.insertBmsProductCountry(c); @@ -284,13 +370,11 @@ public class BmsProductServiceImpl implements IBmsProductService String tip = e.getMessage() == null ? "" : e.getMessage(); if (tip.contains("freight_price") || tip.contains("registration_fee") || tip.contains("Unknown column")) { - throw new ServiceException( - "\u8bf7\u5148\u6267\u884c SQL\uff1abms-back/sql/price/product_country_fee_alter.sql\uff08\u7f3a\u5c11\u8fd0\u8d39/\u6302\u53f7\u8d39\u5b57\u6bb5\uff09"); + throw new ServiceException(MessageUtils.message("price.product.import.needFeeAlter")); } if (tip.contains("expire_date") || tip.contains("uk_product_country_weight")) { - throw new ServiceException( - "\u8bf7\u5148\u6267\u884c SQL\uff1abms-back/sql/price/product_country_expire_alter.sql\uff08\u7f3a\u5c11\u5931\u6548\u65e5\u671f/\u7248\u672c\u552f\u4e00\u952e\uff09"); + throw new ServiceException(MessageUtils.message("price.product.import.needExpireAlter")); } throw e; } @@ -324,9 +408,93 @@ public class BmsProductServiceImpl implements IBmsProductService return new SimpleDateFormat("yyyy-MM-dd").format(d); } + private Date toDateOnly(Date d) + { + if (d == null) + { + return null; + } + try + { + return new SimpleDateFormat("yyyy-MM-dd").parse(formatDate(d)); + } + catch (Exception e) + { + return d; + } + } + + /** 0 pending / 1 active / 2 expired from dates vs today */ + private String resolveStoredStatus(Date effectiveDate, Date expireDate) + { + Date today = toDateOnly(new Date()); + Date expire = toDateOnly(expireDate); + Date eff = toDateOnly(effectiveDate); + if (expire != null && today != null && !expire.after(today)) + { + return BmsProduct.STATUS_EXPIRED; + } + if (eff == null || (today != null && eff.after(today))) + { + return BmsProduct.STATUS_PENDING; + } + return BmsProduct.STATUS_ACTIVE; + } + + private void applyProductStatus(BmsProduct product) + { + if (product == null) + { + return; + } + product.setStatus(resolveStoredStatus(product.getEffectiveDate(), product.getExpireDate())); + } + + /** Map ACTIVE/PENDING/EXPIRED or 0/1/2 to stored status code. */ + private String normalizeStatusCode(String status) + { + if (status == null || status.isEmpty()) + { + return status; + } + if ("ACTIVE".equalsIgnoreCase(status) || BmsProduct.STATUS_ACTIVE.equals(status)) + { + return BmsProduct.STATUS_ACTIVE; + } + if ("PENDING".equalsIgnoreCase(status) || BmsProduct.STATUS_PENDING.equals(status)) + { + return BmsProduct.STATUS_PENDING; + } + if ("EXPIRED".equalsIgnoreCase(status) || BmsProduct.STATUS_EXPIRED.equals(status)) + { + return BmsProduct.STATUS_EXPIRED; + } + return status; + } + + private boolean isProductCodeUniqueViolation(Throwable e) + { + while (e != null) + { + String m = e.getMessage(); + if (m != null && m.contains("uk_product_code") && !m.contains("uk_product_code_eff")) + { + return true; + } + if (m != null && m.contains("Duplicate") && m.contains("product_code") && !m.contains("effective")) + { + return true; + } + e = e.getCause(); + } + return false; + } + private void saveCountries(String productCode, List countries) { - saveCountriesVersioned(productCode, countries); + BmsProduct p = new BmsProduct(); + p.setProductCode(productCode); + saveCountriesVersioned(p, countries); } private List parseCatalog(Sheet sheet) @@ -593,35 +761,67 @@ public class BmsProductServiceImpl implements IBmsProductService private Date parseSheetEffectiveDate(Sheet sheet) { - Row row0 = sheet.getRow(0); - if (row0 == null) + int lastRow = Math.min(sheet.getLastRowNum(), 4); + for (int r = 0; r <= lastRow; r++) { - return null; - } - for (int c = 0; c < 12; c++) - { - String text = cellString(row0.getCell(c)); - if (StringUtils.isEmpty(text)) + Row row = sheet.getRow(r); + if (row == null) { continue; } - Matcher m = EFFECTIVE_DATE_PATTERN.matcher(text); - if (m.find()) + int lastCell = Math.max(row.getLastCellNum(), 12); + lastCell = Math.min(lastCell, 20); + for (int c = 0; c < lastCell; c++) { - try + Date parsed = parseEffectiveDateText(cellString(row.getCell(c))); + if (parsed != null) { - String d = m.group(1) + "-" + pad2(m.group(2)) + "-" + pad2(m.group(3)); - return new SimpleDateFormat("yyyy-MM-dd").parse(d); - } - catch (Exception ignored) - { - return null; + return parsed; } } } return null; } + private Date parseEffectiveDateText(String text) + { + if (StringUtils.isEmpty(text)) + { + return null; + } + Matcher labeled = EFFECTIVE_DATE_PATTERN.matcher(text); + if (labeled.find()) + { + return parseYmd(labeled.group(1), labeled.group(2), labeled.group(3)); + } + Matcher cn = EFFECTIVE_DATE_CN.matcher(text); + if (cn.find()) + { + return parseYmd(cn.group(1), cn.group(2), cn.group(3)); + } + if (text.contains("\u751f\u6548")) + { + Matcher ymd = EFFECTIVE_DATE_YMD.matcher(text); + if (ymd.find()) + { + return parseYmd(ymd.group(1), ymd.group(2), ymd.group(3)); + } + } + return null; + } + + private Date parseYmd(String y, String m, String d) + { + try + { + return new SimpleDateFormat("yyyy-MM-dd").parse(y + "-" + pad2(m) + "-" + pad2(d)); + } + catch (Exception ignored) + { + return null; + } + } + private static String pad2(String n) { return n.length() == 1 ? "0" + n : n; 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 1c7a711f..9a3b0d20 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 @@ -11,6 +11,7 @@ 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.price.domain.BmsProductCountry; import com.ruoyi.price.domain.PrcCustomerPriceSet; @@ -139,12 +140,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet db = requireSet(id); if (!STATUS_DRAFT.equals(db.getStatus()) && !STATUS_REJECT.equals(db.getStatus())) { - throw new ServiceException("仅草稿或驳回状态的套餐允许删除:" + displayName(db)); + throw new ServiceException(MessageUtils.message("price.set.delete.statusNotAllowed", displayName(db))); } int bound = customerMapper.countByPriceSetId(id); if (bound > 0) { - throw new ServiceException("套餐【" + displayName(db) + "】已绑定客户,请先解绑后再删除"); + throw new ServiceException(MessageUtils.message("price.set.delete.boundCustomer", displayName(db))); } detailMapper.deletePrcPriceSetDetailByPriceSetId(id); } @@ -157,7 +158,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet db = requireSet(priceSetId); if (!STATUS_DRAFT.equals(db.getStatus()) && !STATUS_REJECT.equals(db.getStatus())) { - throw new ServiceException("当前状态不允许提交审核"); + throw new ServiceException(MessageUtils.message("price.set.submit.statusNotAllowed")); } PrcPriceSet payload = selectPrcPriceSetById(priceSetId); List items = validate(payload); @@ -175,7 +176,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet db = requireSet(priceSetId); if (!STATUS_AUDIT.equals(db.getStatus())) { - throw new ServiceException("仅审核中的套餐可以生效"); + throw new ServiceException(MessageUtils.message("price.set.approve.statusNotAllowed")); } PrcPriceSet upd = new PrcPriceSet(); upd.setPriceSetId(priceSetId); @@ -190,7 +191,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet db = requireSet(priceSetId); if (!STATUS_AUDIT.equals(db.getStatus())) { - throw new ServiceException("仅审核中的套餐可以驳回"); + throw new ServiceException(MessageUtils.message("price.set.reject.statusNotAllowed")); } PrcPriceSet upd = new PrcPriceSet(); upd.setPriceSetId(priceSetId); @@ -205,7 +206,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet db = requireSet(priceSetId); if (!STATUS_EFFECT.equals(db.getStatus())) { - throw new ServiceException("仅生效中的套餐可以停用"); + throw new ServiceException(MessageUtils.message("price.set.disable.statusNotAllowed")); } PrcPriceSet upd = new PrcPriceSet(); upd.setPriceSetId(priceSetId); @@ -221,7 +222,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet src = selectPrcPriceSetById(priceSetId); if (src == null) { - throw new ServiceException("套餐不存在"); + throw new ServiceException(MessageUtils.message("price.set.notExists")); } PrcPriceSet copy = new PrcPriceSet(); copy.setPriceSetName(src.getPriceSetName() + "-副本"); @@ -405,24 +406,37 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } List customers = filterCustomers(set.getCustomers()); - int overlap = 0; + Set seenCustomers = new HashSet(); + int bound = 0; + boolean dupInSet = false; for (PrcCustomerPriceSet c : customers) { - overlap += customerMapper.countOverlap(c.getCustomerId(), set.getPriceSetId(), - c.getPrBegindate() != null ? c.getPrBegindate() : set.getEffectiveDate(), - c.getPrEnddate() != null ? c.getPrEnddate() : set.getExpireDate()); + if (c.getCustomerId() == null) + { + continue; + } + if (!seenCustomers.add(c.getCustomerId())) + { + dupInSet = true; + continue; + } + bound += customerMapper.countByCustomerId(c.getCustomerId(), c.getCustomerPricesetId(), + set.getPriceSetId()); } - if (overlap > 0) + if (dupInSet || bound > 0) { - items.add(new PrcPriceSetCheckItem("与同客户现有套餐是否冲突", "WARN", "存在 " + overlap + " 条与已生效套餐日期重叠的客户绑定,可共存但需确认优先级")); + items.add(new PrcPriceSetCheckItem("\u4e00\u4e2a\u5ba2\u6237\u53ea\u80fd\u7ed1\u5b9a\u4e00\u4e2a\u5957\u9910", "FAIL", + MessageUtils.message("price.set.customer.alreadyBound"))); } else if (customers.isEmpty()) { - items.add(new PrcPriceSetCheckItem("与同客户现有套餐是否冲突", "INFO", "暂未绑定客户,可后续补充")); + items.add(new PrcPriceSetCheckItem("\u4e00\u4e2a\u5ba2\u6237\u53ea\u80fd\u7ed1\u5b9a\u4e00\u4e2a\u5957\u9910", "INFO", + "\u6682\u672a\u7ed1\u5b9a\u5ba2\u6237\uff0c\u53ef\u540e\u7eed\u8865\u5145")); } else { - items.add(new PrcPriceSetCheckItem("与同客户现有套餐是否冲突", "PASS", "未发现与已生效套餐的日期重叠")); + items.add(new PrcPriceSetCheckItem("\u4e00\u4e2a\u5ba2\u6237\u53ea\u80fd\u7ed1\u5b9a\u4e00\u4e2a\u5957\u9910", "PASS", + "\u5ba2\u6237\u672a\u7ed1\u5b9a\u5176\u4ed6\u5957\u9910")); } if (TYPE_FIXED.equals(setType) && !details.isEmpty()) @@ -462,7 +476,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSetDetail old = detailMapper.selectPrcPriceSetDetailById(detail.getPsdId()); if (old == null) { - throw new ServiceException("套餐明细不存在"); + throw new ServiceException(MessageUtils.message("price.set.detail.notExists")); } PrcPriceSet db = requireSet(old.getPriceSetId()); assertChildEditable(db); @@ -503,7 +517,18 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { customer.setBindPriority(1); } - return customerMapper.insertPrcCustomerPriceSet(customer); + try + { + return customerMapper.insertPrcCustomerPriceSet(customer); + } + catch (Exception e) + { + if (isCustomerUniqueViolation(e)) + { + throw new ServiceException(MessageUtils.message("price.set.customer.alreadyBound")); + } + throw e; + } } @Override @@ -512,7 +537,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcCustomerPriceSet old = customerMapper.selectPrcCustomerPriceSetById(customer.getCustomerPricesetId()); if (old == null) { - throw new ServiceException("客户绑定不存在"); + throw new ServiceException(MessageUtils.message("price.set.customer.notExists")); } PrcPriceSet db = requireSet(old.getPriceSetId()); assertChildEditable(db); @@ -534,7 +559,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet db = requireSet(old.getPriceSetId()); if (STATUS_AUDIT.equals(db.getStatus())) { - throw new ServiceException("审核中的套餐不允许解绑客户"); + throw new ServiceException(MessageUtils.message("price.set.customer.unbindAudit")); } } return customerMapper.deletePrcCustomerPriceSetByIds(customerPricesetIds); @@ -556,8 +581,13 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService if (set.getCustomers() != null) { customerMapper.deletePrcCustomerPriceSetByPriceSetId(set.getPriceSetId()); + Set seenCustomers = new HashSet(); for (PrcCustomerPriceSet c : filterCustomers(set.getCustomers())) { + if (c.getCustomerId() != null && !seenCustomers.add(c.getCustomerId())) + { + throw new ServiceException(MessageUtils.message("price.set.customer.alreadyBound")); + } validateCustomer(c, set); c.setCustomerPricesetId(null); c.setPriceSetId(set.getPriceSetId()); @@ -566,7 +596,18 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { c.setBindPriority(1); } - customerMapper.insertPrcCustomerPriceSet(c); + try + { + customerMapper.insertPrcCustomerPriceSet(c); + } + catch (Exception e) + { + if (isCustomerUniqueViolation(e)) + { + throw new ServiceException(MessageUtils.message("price.set.customer.alreadyBound")); + } + throw e; + } } } } @@ -575,15 +616,15 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { if (StringUtils.isEmpty(set.getPriceSetName())) { - throw new ServiceException("套餐名称不能为空"); + throw new ServiceException(MessageUtils.message("price.set.name.empty")); } if (set.getEffectiveDate() == null) { - throw new ServiceException("生效日期不能为空"); + throw new ServiceException(MessageUtils.message("price.set.effectiveDate.empty")); } if (set.getExpireDate() != null && set.getEffectiveDate().after(set.getExpireDate())) { - throw new ServiceException("生效日期不能晚于失效日期"); + throw new ServiceException(MessageUtils.message("price.set.date.invalid")); } if (TYPE_DISCOUNT.equals(set.getSetType())) { @@ -591,12 +632,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService || set.getDefaultDiscount().compareTo(BigDecimal.ONE) < 0 || set.getDefaultDiscount().compareTo(new BigDecimal("99")) > 0) { - throw new ServiceException("折扣模式默认折扣需在 1~99"); + throw new ServiceException(MessageUtils.message("price.set.discount.range")); } } if (setMapper.countByName(set) > 0) { - throw new ServiceException("套餐名称已存在"); + throw new ServiceException(MessageUtils.message("price.set.name.exists")); } if (strictSubmit) { @@ -608,25 +649,25 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { if (StringUtils.isEmpty(detail.getProductCode())) { - throw new ServiceException("明细产品代码不能为空"); + throw new ServiceException(MessageUtils.message("price.set.detail.productCode.empty")); } if (TYPE_FIXED.equals(set.getSetType())) { if (StringUtils.isEmpty(detail.getCountryCode()) && StringUtils.isEmpty(detail.getCountryName())) { - throw new ServiceException("\u56fa\u5b9a\u4ef7\u660e\u7ec6\u5fc5\u987b\u586b\u5199\u56fd\u5bb6"); + throw new ServiceException(MessageUtils.message("price.set.detail.country.empty")); } if (detail.getWsdId() == null && StringUtils.isEmpty(detail.getWeightLabel())) { - throw new ServiceException("固定价明细必须填写重量段"); + throw new ServiceException(MessageUtils.message("price.set.detail.weight.empty")); } if (detail.getFvValue() == null) { - throw new ServiceException("固定价明细必须填写运费单价"); + throw new ServiceException(MessageUtils.message("price.set.detail.freight.empty")); } if (detail.getFvRegistrationFee() == null) { - throw new ServiceException("固定价明细必须填写挂号费"); + throw new ServiceException(MessageUtils.message("price.set.detail.registration.empty")); } fillProductCountryId(detail); } @@ -639,7 +680,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService String mode = detail.getRoundMode().trim().toUpperCase(); if (!"UP".equals(mode) && !"DOWN".equals(mode) && !"HALF_UP".equals(mode)) { - throw new ServiceException("取值方式仅支持 UP / DOWN / HALF_UP"); + throw new ServiceException(MessageUtils.message("price.set.detail.roundMode.invalid")); } detail.setRoundMode(mode); } @@ -720,7 +761,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { if (customer.getCustomerId() == null || customer.getCustomerId().longValue() <= 0) { - throw new ServiceException("客户ID不能为空"); + throw new ServiceException(MessageUtils.message("price.set.customer.customerId.empty")); } if (customer.getPrBegindate() == null) { @@ -728,15 +769,21 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } if (customer.getPrBegindate() == null) { - throw new ServiceException("客户绑定生效日期不能为空"); + throw new ServiceException(MessageUtils.message("price.set.customer.beginDate.empty")); } if (customer.getPrEnddate() != null && customer.getPrBegindate().after(customer.getPrEnddate())) { - throw new ServiceException("客户绑定生效日期不能晚于失效日期"); + throw new ServiceException(MessageUtils.message("price.set.customer.date.invalid")); } if (customer.getBindPriority() != null && customer.getBindPriority().intValue() < 1) { - throw new ServiceException("优先级必须大于等于 1"); + throw new ServiceException(MessageUtils.message("price.set.customer.priority.invalid")); + } + int exists = customerMapper.countByCustomerId(customer.getCustomerId(), + customer.getCustomerPricesetId(), null); + if (exists > 0) + { + throw new ServiceException(MessageUtils.message("price.set.customer.alreadyBound")); } } @@ -744,11 +791,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { if (STATUS_AUDIT.equals(db.getStatus())) { - throw new ServiceException("审核中的套餐不允许修改"); + throw new ServiceException(MessageUtils.message("price.set.edit.audit")); } if (STATUS_STOP.equals(db.getStatus())) { - throw new ServiceException("已停用的套餐不允许修改,请使用复用新建"); + throw new ServiceException(MessageUtils.message("price.set.edit.stopped")); } } @@ -756,11 +803,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { if (STATUS_AUDIT.equals(db.getStatus())) { - throw new ServiceException("审核中的套餐不允许改明细或客户"); + throw new ServiceException(MessageUtils.message("price.set.childEdit.audit")); } if (STATUS_STOP.equals(db.getStatus())) { - throw new ServiceException("已停用的套餐不允许改明细或客户"); + throw new ServiceException(MessageUtils.message("price.set.childEdit.stopped")); } } @@ -780,11 +827,25 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService PrcPriceSet db = setMapper.selectPrcPriceSetById(priceSetId); if (db == null) { - throw new ServiceException("套餐不存在"); + throw new ServiceException(MessageUtils.message("price.set.notExists")); } return db; } + private boolean isCustomerUniqueViolation(Throwable e) + { + while (e != null) + { + String m = e.getMessage(); + if (m != null && (m.contains("uk_customer_one_set") || m.contains("Duplicate"))) + { + return m.contains("uk_customer_one_set") || m.contains("customer_id"); + } + e = e.getCause(); + } + return false; + } + private void fillExpired(PrcPriceSet set) { Date today = DateUtils.parseDate(DateUtils.getDate()); diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcSurchargeRelServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcSurchargeRelServiceImpl.java index 94c2c4fa..153c6c74 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcSurchargeRelServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcSurchargeRelServiceImpl.java @@ -4,6 +4,7 @@ import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.price.domain.PrcSurchargeRel; import com.ruoyi.price.mapper.PrcSurchargeRelMapper; @@ -65,15 +66,15 @@ public class PrcSurchargeRelServiceImpl implements IPrcSurchargeRelService { if (rel.getScId() == null) { - throw new ServiceException("附加费ID不能为空"); + throw new ServiceException(MessageUtils.message("price.surchargeRel.scId.empty")); } if (rel.getProductId() == null) { - throw new ServiceException("产品ID不能为空"); + throw new ServiceException(MessageUtils.message("price.surchargeRel.productId.empty")); } if (StringUtils.isEmpty(rel.getProductCode())) { - throw new ServiceException("产品代码不能为空"); + throw new ServiceException(MessageUtils.message("price.surchargeRel.productCode.empty")); } } } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcSurchargeServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcSurchargeServiceImpl.java index 9e8b08d1..069e54f5 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcSurchargeServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcSurchargeServiceImpl.java @@ -4,6 +4,7 @@ import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.price.domain.PrcSurcharge; import com.ruoyi.price.mapper.PrcSurchargeMapper; @@ -68,19 +69,19 @@ public class PrcSurchargeServiceImpl implements IPrcSurchargeService { if (StringUtils.isEmpty(surcharge.getScName())) { - throw new ServiceException("附加费名称不能为空"); + throw new ServiceException(MessageUtils.message("price.surcharge.scName.empty")); } if (StringUtils.isEmpty(surcharge.getChargeType())) { - throw new ServiceException("计费方式不能为空"); + throw new ServiceException(MessageUtils.message("price.surcharge.chargeType.empty")); } if (surcharge.getChargeValue() == null) { - throw new ServiceException("计费数值不能为空"); + throw new ServiceException(MessageUtils.message("price.surcharge.chargeValue.empty")); } if (surcharge.getChargeValue().compareTo(java.math.BigDecimal.ZERO) < 0) { - throw new ServiceException("计费数值不能为负数"); + throw new ServiceException(MessageUtils.message("price.surcharge.chargeValue.negative")); } } } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcWeightServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcWeightServiceImpl.java index 5a0e63e4..741dc70d 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcWeightServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/PrcWeightServiceImpl.java @@ -4,6 +4,7 @@ import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.price.domain.PrcWeightSectionDetail; import com.ruoyi.price.domain.PrcWeightSectionTemplate; @@ -106,11 +107,11 @@ public class PrcWeightServiceImpl implements IPrcWeightService { if (detail.getWlStartweight() == null || detail.getWlEndweight() == null) { - throw new ServiceException("起始重量和结束重量不能为空"); + throw new ServiceException(MessageUtils.message("price.weight.range.empty")); } if (detail.getWlStartweight().compareTo(detail.getWlEndweight()) >= 0) { - throw new ServiceException("起始重量必须小于结束重量"); + throw new ServiceException(MessageUtils.message("price.weight.range.invalid")); } } } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/TmsPriceQuoteServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/TmsPriceQuoteServiceImpl.java index fb274573..0a7338f6 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/TmsPriceQuoteServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/TmsPriceQuoteServiceImpl.java @@ -4,17 +4,28 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.sign.Md5Utils; import com.ruoyi.price.domain.BmsProduct; import com.ruoyi.price.domain.BmsProductCountry; import com.ruoyi.price.domain.PrcCustomerPriceSet; import com.ruoyi.price.domain.PrcPriceSet; import com.ruoyi.price.domain.PrcPriceSetDetail; +import com.ruoyi.price.domain.PrcQuote; +import com.ruoyi.price.domain.PrcQuoteFee; +import com.ruoyi.price.domain.PrcSurchargeRel; +import com.ruoyi.price.domain.vo.TmsPriceQuoteFeeLine; import com.ruoyi.price.domain.vo.TmsPriceQuoteRequest; import com.ruoyi.price.domain.vo.TmsPriceQuoteResult; import com.ruoyi.price.mapper.BmsProductCountryMapper; @@ -22,10 +33,14 @@ import com.ruoyi.price.mapper.BmsProductMapper; import com.ruoyi.price.mapper.PrcCustomerPriceSetMapper; import com.ruoyi.price.mapper.PrcPriceSetDetailMapper; import com.ruoyi.price.mapper.PrcPriceSetMapper; +import com.ruoyi.price.mapper.PrcQuoteFeeMapper; +import com.ruoyi.price.mapper.PrcQuoteMapper; +import com.ruoyi.price.mapper.PrcSurchargeRelMapper; import com.ruoyi.price.service.ITmsPriceQuoteService; /** * TMS freight quote: customer package first, then product default. + * After calc: persist fee lines by bms_fee_type; same trial params return stored rows. * * @author ruoyi */ @@ -38,6 +53,10 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService private static final String SRC_DISCOUNT = "PACKAGE_DISCOUNT"; private static final String SRC_DEFAULT = "PRODUCT_DEFAULT"; + private static final String FEE_BASIC = "BASIC_FREIGHT"; + private static final String FEE_REG = "REGISTRATION_FEE"; + private static final String FEE_SUR = "SURCHARGE"; + @Autowired private PrcCustomerPriceSetMapper customerPriceSetMapper; @@ -53,11 +72,69 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService @Autowired private BmsProductMapper productMapper; + @Autowired + private PrcSurchargeRelMapper surchargeRelMapper; + + @Autowired + private PrcQuoteMapper quoteMapper; + + @Autowired + private PrcQuoteFeeMapper quoteFeeMapper; + @Override + @Transactional(rollbackFor = Exception.class) public TmsPriceQuoteResult quote(TmsPriceQuoteRequest request) { validateRequest(request); + Date bizDate = resolveBizDate(request.getBizDate()); + BigDecimal weight = request.getWeight().setScale(3, RoundingMode.HALF_UP); + String quoteKey = buildQuoteKey(request, weight, bizDate); + PrcQuote cached = quoteMapper.selectPrcQuoteByKey(quoteKey); + if (cached != null) + { + List fees = quoteFeeMapper.selectByQuoteId(cached.getQuoteId()); + if (!isStaleSurchargeFees(fees)) + { + return toResult(cached, fees, true); + } + TmsPriceQuoteResult refreshed = doQuote(request, bizDate); + applySurchargesAndFees(refreshed); + replaceQuoteFees(cached.getQuoteId(), refreshed); + refreshed.setQuoteId(cached.getQuoteId()); + refreshed.setFromCache(false); + return refreshed; + } + + TmsPriceQuoteResult result = doQuote(request, bizDate); + applySurchargesAndFees(result); + persistQuote(quoteKey, request, weight, bizDate, result); + return result; + } + + @Override + public List selectQuoteList(PrcQuote query) + { + return quoteMapper.selectPrcQuoteList(query); + } + + @Override + public TmsPriceQuoteResult getQuote(Long quoteId) + { + if (quoteId == null) + { + throw new ServiceException(MessageUtils.message("price.quote.quoteIdRequired")); + } + PrcQuote quote = quoteMapper.selectPrcQuoteById(quoteId); + if (quote == null) + { + throw new ServiceException("QUOTE_NOT_FOUND: quoteId=" + quoteId); + } + return toResult(quote, quoteFeeMapper.selectByQuoteId(quoteId), true); + } + + private TmsPriceQuoteResult doQuote(TmsPriceQuoteRequest request, Date bizDate) + { String productCode = request.getProductCode().trim(); String countryCode = emptyToNull(request.getCountryCode()); String countryName = emptyToNull(request.getCountryName()); @@ -66,16 +143,15 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService countryCode = countryCode.toUpperCase(); } BigDecimal weight = request.getWeight(); - Date bizDate = resolveBizDate(request.getBizDate()); BmsProductCountry base = productCountryMapper.selectMatchByProductCountryWeight( productCode, countryCode, countryName, weight, bizDate); if (base == null) { - throw new ServiceException("PRICE_NOT_HIT: no bms_product_country band for product/country/weight"); + throw new ServiceException(MessageUtils.message("price.quote.priceNotHit")); } - String productName = resolveProductName(productCode); + String productName = resolveProductName(productCode, bizDate); List binds = customerPriceSetMapper.selectEffectiveByCustomer( request.getCustomerId(), bizDate); @@ -109,7 +185,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService } return buildDefault(request.getCustomerId(), base, productCode, productName, weight, - "no customer package override; use product default freight/registration"); + MessageUtils.message("price.quote.noCustomerPackageOverride")); } private TmsPriceQuoteResult tryFixed(PrcCustomerPriceSet bind, PrcPriceSet set, @@ -121,7 +197,8 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService TmsPriceQuoteResult r = buildFromRates(SRC_DEFAULT, base.getFreightPrice(), null, null, base.getRegistrationFee(), base, productCode, productName, weight, null); fillPackage(r, bind, set); - r.setMatchMessage("FIXED package has no details; use bms_product_country default"); + // 套餐固定价格没有明细,使用产品默认价格 + r.setMatchMessage(MessageUtils.message("price.quote.fixedPackageNoDetails")); r.setCustomerId(bind.getCustomerId()); return r; } @@ -153,7 +230,8 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService { r.setProductCountryId(detail.getProductCountryId()); } - r.setMatchMessage("hit customer FIXED package detail"); + // 命中客户套餐固定价格明细 + r.setMatchMessage(MessageUtils.message("price.quote.hitCustomerFixedPackageDetail")); r.setCustomerId(bind.getCustomerId()); return r; } @@ -185,7 +263,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService } if (base.getFreightPrice() == null) { - throw new ServiceException("FREIGHT_VALUE_MISSING: product default freight rate is null"); + throw new ServiceException(MessageUtils.message("price.quote.freightMissing1")); } BigDecimal baseRate = base.getFreightPrice(); RoundingMode rm = resolveRoundingMode(roundMode); @@ -204,7 +282,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService { if (base.getFreightPrice() == null || base.getRegistrationFee() == null) { - throw new ServiceException("FREIGHT_VALUE_MISSING: product default freight or registration is null"); + throw new ServiceException(MessageUtils.message("price.quote.freightMissing2")); } TmsPriceQuoteResult r = buildFromRates(SRC_DEFAULT, base.getFreightPrice(), null, null, base.getRegistrationFee(), base, productCode, productName, weight, null); @@ -220,7 +298,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService { if (freightRate == null) { - throw new ServiceException("FREIGHT_VALUE_MISSING: freight rate is null"); + throw new ServiceException(MessageUtils.message("price.quote.freightMissing3")); } if (registrationFee == null) { @@ -249,12 +327,393 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService r.setDiscountPercent(discountPercent); r.setRegistrationFee(registrationFee); r.setFreightAmount(freightAmount); + r.setSurchargeAmount(BigDecimal.ZERO); r.setPayableTotal(payable); - r.setCurrency("RMB"); + r.setCurrency("CNY"); r.setRoundMode(mode); + r.setFromCache(false); return r; } + private void applySurchargesAndFees(TmsPriceQuoteResult result) + { + BigDecimal freightAmount = nvl(result.getFreightAmount()); + BigDecimal registrationFee = nvl(result.getRegistrationFee()); + RoundingMode rm = resolveRoundingMode(result.getRoundMode()); + String currency = normalizeCurrency(result.getCurrency()); + + List rels = loadMatchedSurcharges(result); + List lines = new ArrayList(); + lines.add(feeLine(FEE_BASIC, feeNameOf(FEE_BASIC), 0L, freightAmount, currency, result.getRoundMode(), null)); + lines.add(feeLine(FEE_REG, feeNameOf(FEE_REG), 0L, registrationFee, currency, result.getRoundMode(), null)); + + BigDecimal surchargeTotal = BigDecimal.ZERO; + Map surchargeLines = new LinkedHashMap(); + for (PrcSurchargeRel rel : rels) + { + BigDecimal itemAmount = calcSurchargeAmount(rel, result.getWeight(), freightAmount); + if (itemAmount == null) + { + continue; + } + Long scId = rel.getScId() == null ? 0L : rel.getScId(); + TmsPriceQuoteFeeLine exist = surchargeLines.get(scId); + if (exist != null) + { + exist.setAmount(nvl(exist.getAmount()).add(itemAmount)); + continue; + } + String feeType = StringUtils.isEmpty(rel.getFeeType()) ? FEE_SUR : rel.getFeeType(); + String feeName = StringUtils.isEmpty(rel.getScName()) ? feeNameOf(FEE_SUR) : rel.getScName(); + String itemCcy = normalizeCurrency(StringUtils.isEmpty(rel.getCurrency()) ? currency : rel.getCurrency()); + String itemRound = StringUtils.isEmpty(rel.getRoundMode()) ? result.getRoundMode() : rel.getRoundMode(); + surchargeLines.put(scId, feeLine(feeType, feeName, scId, itemAmount, itemCcy, itemRound, rel.getScNote())); + } + for (TmsPriceQuoteFeeLine line : surchargeLines.values()) + { + line.setAmount(nvl(line.getAmount()).setScale(2, rm)); + surchargeTotal = surchargeTotal.add(nvl(line.getAmount())); + lines.add(line); + } + surchargeTotal = surchargeTotal.setScale(2, rm); + result.setSurchargeAmount(surchargeTotal); + result.setPayableTotal(freightAmount.add(registrationFee).add(surchargeTotal).setScale(2, rm)); + result.setFeeDetails(lines); + } + + private List loadMatchedSurcharges(TmsPriceQuoteResult result) + { + List matched = new ArrayList(); + if (result == null || StringUtils.isEmpty(result.getProductCode())) + { + return matched; + } + PrcSurchargeRel query = new PrcSurchargeRel(); + query.setProductCode(result.getProductCode()); + List rels = surchargeRelMapper.selectPrcSurchargeRelList(query); + if (rels == null) + { + return matched; + } + for (PrcSurchargeRel rel : rels) + { + if (surchargeApplies(rel, result)) + { + matched.add(rel); + } + } + return matched; + } + + private boolean surchargeApplies(PrcSurchargeRel rel, TmsPriceQuoteResult result) + { + if (rel == null) + { + return false; + } + if (StringUtils.isNotEmpty(rel.getSsCode()) && !"on".equalsIgnoreCase(rel.getSsCode())) + { + return false; + } + if (StringUtils.isNotEmpty(rel.getCountryCode()) && StringUtils.isNotEmpty(result.getCountryCode()) + && !rel.getCountryCode().equalsIgnoreCase(result.getCountryCode())) + { + return false; + } + if (rel.getDetailId() == null) + { + return true; + } + return result.getProductCountryId() != null && rel.getDetailId().equals(result.getProductCountryId()); + } + + private BigDecimal calcSurchargeAmount(PrcSurchargeRel rel, BigDecimal weight, BigDecimal freightAmount) + { + if (rel.getChargeValue() == null) + { + return BigDecimal.ZERO; + } + RoundingMode rm = resolveRoundingMode(rel.getRoundMode()); + String chargeType = rel.getChargeType() == null ? "FIXED" : rel.getChargeType().toUpperCase(); + BigDecimal amount; + if ("WEIGHT".equals(chargeType)) + { + amount = rel.getChargeValue().multiply(nvl(weight)); + } + else if ("RATE".equals(chargeType)) + { + amount = nvl(freightAmount).multiply(rel.getChargeValue()); + } + else + { + amount = rel.getChargeValue(); + } + return amount.setScale(2, rm); + } + + private void persistQuote(String quoteKey, TmsPriceQuoteRequest request, BigDecimal weight, + Date bizDate, TmsPriceQuoteResult result) + { + PrcQuote quote = new PrcQuote(); + quote.setQuoteKey(quoteKey); + quote.setCustomerId(request.getCustomerId()); + quote.setProductCode(result.getProductCode()); + quote.setProductName(result.getProductName()); + quote.setCountryCode(result.getCountryCode()); + quote.setCountryName(result.getCountryName()); + quote.setWeight(weight); + quote.setBizDate(bizDate); + quote.setTmsId(request.getTmsId() == null ? null : Long.valueOf(request.getTmsId())); + quote.setMatchSource(result.getMatchSource()); + quote.setMatchMessage(result.getMatchMessage()); + quote.setPriceSetId(result.getPriceSetId()); + quote.setPriceSetCode(result.getPriceSetCode()); + quote.setPriceSetName(result.getPriceSetName()); + quote.setSetType(result.getSetType()); + quote.setBindPriority(result.getBindPriority()); + quote.setCustomerPricesetId(result.getCustomerPricesetId()); + quote.setProductCountryId(result.getProductCountryId()); + quote.setWeightMin(result.getWeightMin()); + quote.setWeightMax(result.getWeightMax()); + quote.setWeightLabel(result.getWeightLabel()); + quote.setEffectiveDate(result.getEffectiveDate()); + quote.setChargeWeight(result.getChargeWeight()); + quote.setFreightRate(result.getFreightRate()); + quote.setBaseFreightRate(result.getBaseFreightRate()); + quote.setDiscountPercent(result.getDiscountPercent()); + quote.setRegistrationFee(result.getRegistrationFee()); + quote.setFreightAmount(result.getFreightAmount()); + quote.setSurchargeAmount(result.getSurchargeAmount()); + quote.setPayableTotal(result.getPayableTotal()); + quote.setCurrency(result.getCurrency()); + quote.setRoundMode(result.getRoundMode()); + try + { + quoteMapper.insertPrcQuote(quote); + } + catch (DuplicateKeyException ex) + { + PrcQuote existed = quoteMapper.selectPrcQuoteByKey(quoteKey); + if (existed == null) + { + throw ex; + } + TmsPriceQuoteResult cached = toResult(existed, quoteFeeMapper.selectByQuoteId(existed.getQuoteId()), true); + copyResult(cached, result); + return; + } + + List fees = toFeeRows(quote.getQuoteId(), result); + if (!fees.isEmpty()) + { + quoteFeeMapper.insertBatch(fees); + } + result.setQuoteId(quote.getQuoteId()); + result.setFromCache(false); + } + + private boolean isStaleSurchargeFees(List fees) + { + if (fees == null || fees.isEmpty()) + { + return false; + } + for (PrcQuoteFee fee : fees) + { + if (!FEE_SUR.equals(fee.getFeeType())) + { + continue; + } + if (fee.getScId() == null || fee.getScId().longValue() == 0L) + { + return true; + } + String name = fee.getFeeName() == null ? "" : fee.getFeeName().trim(); + if (name.isEmpty() || "SURCHARGE".equalsIgnoreCase(name) + || "\u9644\u52a0\u8d39".equals(name)) + { + return true; + } + } + return false; + } + + private void replaceQuoteFees(Long quoteId, TmsPriceQuoteResult result) + { + quoteFeeMapper.deleteByQuoteId(quoteId); + List fees = toFeeRows(quoteId, result); + if (!fees.isEmpty()) + { + quoteFeeMapper.insertBatch(fees); + } + quoteMapper.updateQuoteAmounts(quoteId, result.getSurchargeAmount(), result.getPayableTotal()); + } + + private List toFeeRows(Long quoteId, TmsPriceQuoteResult result) + { + List fees = new ArrayList(); + if (result.getFeeDetails() == null) + { + return fees; + } + for (TmsPriceQuoteFeeLine line : result.getFeeDetails()) + { + PrcQuoteFee fee = new PrcQuoteFee(); + fee.setQuoteId(quoteId); + fee.setFeeType(line.getFeeType()); + fee.setFeeName(line.getFeeName()); + fee.setScId(line.getScId() == null ? 0L : line.getScId()); + fee.setAmount(nvl(line.getAmount())); + fee.setCurrency(line.getCurrency()); + fee.setRoundMode(line.getRoundMode()); + fee.setRemark(line.getRemark()); + fees.add(fee); + } + return fees; + } + + private TmsPriceQuoteResult toResult(PrcQuote quote, List fees, boolean fromCache) + { + TmsPriceQuoteResult r = new TmsPriceQuoteResult(); + r.setQuoteId(quote.getQuoteId()); + r.setFromCache(fromCache); + r.setMatchSource(quote.getMatchSource()); + r.setMatchMessage(quote.getMatchMessage()); + r.setCustomerId(quote.getCustomerId()); + r.setPriceSetId(quote.getPriceSetId()); + r.setPriceSetCode(quote.getPriceSetCode()); + r.setPriceSetName(quote.getPriceSetName()); + r.setSetType(quote.getSetType()); + r.setBindPriority(quote.getBindPriority()); + r.setCustomerPricesetId(quote.getCustomerPricesetId()); + r.setProductCode(quote.getProductCode()); + r.setProductName(quote.getProductName()); + r.setCountryCode(quote.getCountryCode()); + r.setCountryName(quote.getCountryName()); + r.setProductCountryId(quote.getProductCountryId()); + r.setWeightMin(quote.getWeightMin()); + r.setWeightMax(quote.getWeightMax()); + r.setWeightLabel(quote.getWeightLabel()); + r.setEffectiveDate(quote.getEffectiveDate()); + r.setWeight(quote.getWeight()); + r.setChargeWeight(quote.getChargeWeight()); + r.setFreightRate(quote.getFreightRate()); + r.setBaseFreightRate(quote.getBaseFreightRate()); + r.setDiscountPercent(quote.getDiscountPercent()); + r.setRegistrationFee(quote.getRegistrationFee()); + r.setFreightAmount(quote.getFreightAmount()); + r.setSurchargeAmount(quote.getSurchargeAmount()); + r.setPayableTotal(quote.getPayableTotal()); + r.setCurrency(normalizeCurrency(quote.getCurrency())); + r.setRoundMode(quote.getRoundMode()); + List lines = new ArrayList(); + if (fees != null) + { + for (PrcQuoteFee fee : fees) + { + lines.add(feeLine(fee.getFeeType(), fee.getFeeName(), fee.getScId(), fee.getAmount(), + fee.getCurrency(), fee.getRoundMode(), fee.getRemark())); + } + } + r.setFeeDetails(lines); + return r; + } + + private void copyResult(TmsPriceQuoteResult from, TmsPriceQuoteResult to) + { + to.setQuoteId(from.getQuoteId()); + to.setFromCache(from.getFromCache()); + to.setMatchSource(from.getMatchSource()); + to.setMatchMessage(from.getMatchMessage()); + to.setCustomerId(from.getCustomerId()); + to.setPriceSetId(from.getPriceSetId()); + to.setPriceSetCode(from.getPriceSetCode()); + to.setPriceSetName(from.getPriceSetName()); + to.setSetType(from.getSetType()); + to.setBindPriority(from.getBindPriority()); + to.setCustomerPricesetId(from.getCustomerPricesetId()); + to.setProductCode(from.getProductCode()); + to.setProductName(from.getProductName()); + to.setCountryCode(from.getCountryCode()); + to.setCountryName(from.getCountryName()); + to.setProductCountryId(from.getProductCountryId()); + to.setWeightMin(from.getWeightMin()); + to.setWeightMax(from.getWeightMax()); + to.setWeightLabel(from.getWeightLabel()); + to.setEffectiveDate(from.getEffectiveDate()); + to.setWeight(from.getWeight()); + to.setChargeWeight(from.getChargeWeight()); + to.setFreightRate(from.getFreightRate()); + to.setBaseFreightRate(from.getBaseFreightRate()); + to.setDiscountPercent(from.getDiscountPercent()); + to.setRegistrationFee(from.getRegistrationFee()); + to.setFreightAmount(from.getFreightAmount()); + to.setSurchargeAmount(from.getSurchargeAmount()); + to.setPayableTotal(from.getPayableTotal()); + to.setCurrency(from.getCurrency()); + to.setRoundMode(from.getRoundMode()); + to.setFeeDetails(from.getFeeDetails()); + } + + private String buildQuoteKey(TmsPriceQuoteRequest request, BigDecimal weight, Date bizDate) + { + String countryCode = emptyToNull(request.getCountryCode()); + if (countryCode != null) + { + countryCode = countryCode.toUpperCase(); + } + String countryName = emptyToNull(request.getCountryName()); + String biz = new SimpleDateFormat("yyyy-MM-dd").format(bizDate); + String raw = request.getCustomerId() + "|" + + request.getProductCode().trim() + "|" + + nvlStr(countryCode) + "|" + + nvlStr(countryName) + "|" + + weight.toPlainString() + "|" + + biz + "|" + + (request.getTmsId() == null ? "" : request.getTmsId()); + return Md5Utils.hash(raw); + } + + private static TmsPriceQuoteFeeLine feeLine(String feeType, String feeName, Long scId, BigDecimal amount, + String currency, String roundMode, String remark) + { + TmsPriceQuoteFeeLine line = new TmsPriceQuoteFeeLine(); + line.setFeeType(feeType); + line.setFeeName(feeName); + line.setScId(scId == null ? 0L : scId); + line.setAmount(nvl(amount)); + line.setCurrency(normalizeCurrency(currency)); + line.setRoundMode(roundMode); + line.setRemark(remark); + return line; + } + + private static String normalizeCurrency(String currency) + { + if (StringUtils.isEmpty(currency) || "RMB".equalsIgnoreCase(currency)) + { + return "CNY"; + } + return currency.trim().toUpperCase(); + } + + private static String feeNameOf(String feeType) + { + if (FEE_BASIC.equals(feeType)) + { + return "\u57fa\u7840\u8fd0\u8d39"; + } + if (FEE_REG.equals(feeType)) + { + return "\u6302\u53f7\u8d39"; + } + if (FEE_SUR.equals(feeType)) + { + return "\u9644\u52a0\u8d39"; + } + return feeType; + } + private static String normalizeRoundMode(String roundMode) { if (StringUtils.isEmpty(roundMode)) @@ -297,23 +756,23 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService { if (request == null) { - throw new ServiceException("request body is required"); + throw new ServiceException(MessageUtils.message("price.quote.requestBodyRequired")); } if (request.getCustomerId() == null) { - throw new ServiceException("customerId is required"); + throw new ServiceException(MessageUtils.message("price.quote.customerIdRequired")); } if (StringUtils.isEmpty(request.getProductCode())) { - throw new ServiceException("productCode is required"); + throw new ServiceException(MessageUtils.message("price.quote.productCodeRequired")); } if (StringUtils.isEmpty(request.getCountryCode()) && StringUtils.isEmpty(request.getCountryName())) { - throw new ServiceException("countryCode or countryName is required"); + throw new ServiceException(MessageUtils.message("price.quote.countryRequired")); } if (request.getWeight() == null || request.getWeight().compareTo(BigDecimal.ZERO) <= 0) { - throw new ServiceException("weight must be > 0"); + throw new ServiceException(MessageUtils.message("price.quote.weightInvalid")); } } @@ -321,7 +780,15 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService { if (StringUtils.isEmpty(bizDate)) { - return new Date(); + try + { + return new SimpleDateFormat("yyyy-MM-dd").parse( + new SimpleDateFormat("yyyy-MM-dd").format(new Date())); + } + catch (ParseException e) + { + return new Date(); + } } try { @@ -329,13 +796,17 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService } catch (ParseException e) { - throw new ServiceException("bizDate format must be yyyy-MM-dd"); + throw new ServiceException(MessageUtils.message("price.quote.bizDateInvalid")); } } - private String resolveProductName(String productCode) + private String resolveProductName(String productCode, Date bizDate) { - BmsProduct p = productMapper.selectBmsProductByCode(productCode); + BmsProduct p = productMapper.selectEffectiveBmsProductByCode(productCode, bizDate); + if (p == null) + { + p = productMapper.selectBmsProductByCode(productCode); + } return p == null ? null : p.getProductName(); } @@ -344,6 +815,16 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService return StringUtils.isEmpty(s) ? null : s.trim(); } + private static String nvlStr(String s) + { + return s == null ? "" : s; + } + + private static BigDecimal nvl(BigDecimal v) + { + return v == null ? BigDecimal.ZERO : v; + } + private static String formatWeightLabel(BigDecimal min, BigDecimal max) { return strip(min) + "\u2264W\u2264" + strip(max); diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/XtdZoneServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/XtdZoneServiceImpl.java index 575c7130..b18843c2 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/XtdZoneServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/service/impl/XtdZoneServiceImpl.java @@ -4,6 +4,7 @@ import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.price.domain.XtdZoneCountry; import com.ruoyi.price.domain.XtdZoneDivision; @@ -78,7 +79,7 @@ public class XtdZoneServiceImpl implements IXtdZoneService { XtdZoneScheme scheme = schemeMapper.selectXtdZoneSchemeById(zsId); String name = scheme != null ? scheme.getZsName() : String.valueOf(zsId); - throw new ServiceException("分区方案【" + name + "】存在区划明细,不允许删除"); + throw new ServiceException(MessageUtils.message("price.zone.delete.hasDivision", name)); } } return schemeMapper.deleteXtdZoneSchemeByIds(zsIds); diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/price/BmsProductCountryMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/price/BmsProductCountryMapper.xml index ce8d09c3..6d1c959f 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/price/BmsProductCountryMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/price/BmsProductCountryMapper.xml @@ -6,6 +6,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -18,20 +19,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + - select detail_id, product_code, country_name, country_code, weight_min, weight_max, + select detail_id, product_id, product_code, country_name, country_code, weight_min, weight_max, freight_price, registration_fee, size_limit, last_mile, lead_time, - effective_date, expire_date, created_at, updated_at + effective_date, expire_date, status, created_at, updated_at from bms_product_country insert into bms_product_country ( - product_code, country_name, country_code, weight_min, weight_max, + product_id, product_code, country_name, country_code, weight_min, weight_max, freight_price, registration_fee, size_limit, last_mile, lead_time, - effective_date, expire_date, created_at + effective_date, expire_date, status, created_at ) values ( - #{productCode}, #{countryName}, #{countryCode}, #{weightMin}, #{weightMax}, + #{productId}, #{productCode}, #{countryName}, #{countryCode}, #{weightMin}, #{weightMax}, #{freightPrice}, #{registrationFee}, #{sizeLimit}, #{lastMile}, #{leadTime}, - #{effectiveDate}, #{expireDate}, sysdate() + #{effectiveDate}, #{expireDate}, IFNULL(#{status}, '0'), sysdate() ) + + delete from bms_product_country where product_id = #{productId} + + delete from bms_product_country where product_code = #{productCode} @@ -80,6 +96,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + delete from bms_product_country + where product_code = #{productCode} + and ( + (#{effectiveDate} is null and effective_date is null) + or effective_date = #{effectiveDate} + ) + + where p.product_code = #{productCode} + order by p.effective_date desc, p.product_id desc + limit 1 + + + + + @@ -97,6 +117,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update_note, tax_mode, effective_date, + expire_date, status, source_sheet, remark, @@ -112,6 +133,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{updateNote}, #{taxMode}, #{effectiveDate}, + #{expireDate}, #{status}, #{sourceSheet}, #{remark}, @@ -132,6 +154,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update_note = #{updateNote}, tax_mode = #{taxMode}, effective_date = #{effectiveDate}, + expire_date = #{expireDate}, status = #{status}, source_sheet = #{sourceSheet}, remark = #{remark}, @@ -149,4 +172,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + update bms_product + set expire_date = #{newEffectiveDate}, + updated_at = sysdate() + where product_code = #{productCode} + and expire_date is null + and (effective_date is null or effective_date < #{newEffectiveDate}) + + and product_id <> #{excludeId} + + + + + update bms_product + set status = case + when expire_date is not null and expire_date <= curdate() then '2' + when effective_date is null or effective_date > curdate() then '0' + else '1' + end + + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcCustomerPriceSetMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcCustomerPriceSetMapper.xml index c96844d5..cfb9e56d 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcCustomerPriceSetMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcCustomerPriceSetMapper.xml @@ -51,6 +51,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select count(1) from prc_customer_price_set where price_set_id = #{priceSetId} + + + select fee_id, quote_id, fee_type, fee_name, sc_id, amount, currency, round_mode, remark, created_at + from prc_quote_fee + where quote_id = #{quoteId} + order by field(fee_type, 'BASIC_FREIGHT', 'REGISTRATION_FEE', 'SURCHARGE'), fee_id + + + + insert into prc_quote_fee ( + quote_id, fee_type, fee_name, sc_id, amount, currency, round_mode, remark, created_at + ) values ( + #{quoteId}, #{feeType}, #{feeName}, #{scId}, #{amount}, #{currency}, #{roundMode}, #{remark}, sysdate() + ) + + + + insert into prc_quote_fee ( + quote_id, fee_type, fee_name, sc_id, amount, currency, round_mode, remark, created_at + ) values + + (#{item.quoteId}, #{item.feeType}, #{item.feeName}, #{item.scId}, #{item.amount}, + #{item.currency}, #{item.roundMode}, #{item.remark}, sysdate()) + + + + + delete from prc_quote_fee where quote_id = #{quoteId} + + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcQuoteMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcQuoteMapper.xml new file mode 100644 index 00000000..9d7d3122 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcQuoteMapper.xml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select quote_id, quote_key, customer_id, product_code, product_name, + country_code, country_name, weight, biz_date, tms_id, + match_source, match_message, price_set_id, price_set_code, price_set_name, + set_type, bind_priority, customer_priceset_id, product_country_id, + weight_min, weight_max, weight_label, effective_date, charge_weight, + freight_rate, base_freight_rate, discount_percent, registration_fee, + freight_amount, surcharge_amount, payable_total, currency, round_mode, + created_by, created_at, updated_by, updated_at + from prc_quote + + + + + + + + + + insert into prc_quote ( + quote_key, customer_id, product_code, product_name, + country_code, country_name, weight, biz_date, tms_id, + match_source, match_message, price_set_id, price_set_code, price_set_name, + set_type, bind_priority, customer_priceset_id, product_country_id, + weight_min, weight_max, weight_label, effective_date, charge_weight, + freight_rate, base_freight_rate, discount_percent, registration_fee, + freight_amount, surcharge_amount, payable_total, currency, round_mode, + created_at + ) values ( + #{quoteKey}, #{customerId}, #{productCode}, #{productName}, + #{countryCode}, #{countryName}, #{weight}, #{bizDate}, #{tmsId}, + #{matchSource}, #{matchMessage}, #{priceSetId}, #{priceSetCode}, #{priceSetName}, + #{setType}, #{bindPriority}, #{customerPricesetId}, #{productCountryId}, + #{weightMin}, #{weightMax}, #{weightLabel}, #{effectiveDate}, #{chargeWeight}, + #{freightRate}, #{baseFreightRate}, #{discountPercent}, #{registrationFee}, + #{freightAmount}, #{surchargeAmount}, #{payableTotal}, #{currency}, #{roundMode}, + sysdate() + ) + + + + update prc_quote + set surcharge_amount = #{surchargeAmount}, + payable_total = #{payableTotal}, + updated_at = sysdate() + where quote_id = #{quoteId} + + diff --git a/bms-back/sql/price/billing_menu.sql b/bms-back/sql/price/billing_menu.sql index 1c9035e6..445b30f9 100644 --- a/bms-back/sql/price/billing_menu.sql +++ b/bms-back/sql/price/billing_menu.sql @@ -13,6 +13,10 @@ INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, SELECT 2302, N'试算执行', 2301, 1, '', NULL, 1, 0, 'F', '0', '0', 'price:billing:quote', '#', 'admin', sysdate() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2302); +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT 2303, N'试算查询', 2301, 2, '', NULL, 1, 0, 'F', '0', '0', 'price:billing:quote', '#', 'admin', sysdate() +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2303); + INSERT INTO sys_role_menu (role_id, menu_id) -SELECT 1, m.menu_id FROM (SELECT 2300 AS menu_id UNION SELECT 2301 UNION SELECT 2302) m +SELECT 1, m.menu_id FROM (SELECT 2300 AS menu_id UNION SELECT 2301 UNION SELECT 2302 UNION SELECT 2303) m WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id); diff --git a/bms-back/sql/price/customer_one_set_unique_alter.sql b/bms-back/sql/price/customer_one_set_unique_alter.sql new file mode 100644 index 00000000..690a237c --- /dev/null +++ b/bms-back/sql/price/customer_one_set_unique_alter.sql @@ -0,0 +1,5 @@ +-- prc_customer_price_set:一个客户只能绑定一个套餐 +SET NAMES utf8mb4; + +ALTER TABLE prc_customer_price_set + ADD UNIQUE KEY uk_customer_one_set (customer_id); diff --git a/bms-back/sql/price/dict_product_effect_status.sql b/bms-back/sql/price/dict_product_effect_status.sql index 6852d587..9645fe54 100644 --- a/bms-back/sql/price/dict_product_effect_status.sql +++ b/bms-back/sql/price/dict_product_effect_status.sql @@ -1,6 +1,6 @@ -- BMS product effect status dict -- dict_type: bms_product_effect_status --- values: ACTIVE / PENDING / EXPIRED +-- values: 1 active / 0 pending / 2 expired SET NAMES utf8mb4; INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) @@ -8,13 +8,13 @@ SELECT N'产品生效状态', 'bms_product_effect_status', '0', 'admin', sysdate FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'bms_product_effect_status'); INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) -SELECT 1, N'已生效', 'ACTIVE', 'bms_product_effect_status', '', 'success', 'N', '0', 'admin', sysdate(), N'启用且生效日期<=今天' -FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value = 'ACTIVE'); +SELECT 1, N'已生效', '1', 'bms_product_effect_status', '', 'success', 'Y', '0', 'admin', sysdate(), N'1 已生效' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value = '1'); INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) -SELECT 2, N'待生效', 'PENDING', 'bms_product_effect_status', '', 'warning', 'N', '0', 'admin', sysdate(), N'启用且生效日期为空或>今天' -FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value = 'PENDING'); +SELECT 2, N'待生效', '0', 'bms_product_effect_status', '', 'warning', 'N', '0', 'admin', sysdate(), N'0 待生效' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value = '0'); INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) -SELECT 3, N'已失效', 'EXPIRED', 'bms_product_effect_status', '', 'info', 'N', '0', 'admin', sysdate(), N'手动标记失效 status=1' -FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value = 'EXPIRED'); +SELECT 3, N'已失效', '2', 'bms_product_effect_status', '', 'info', 'N', '0', 'admin', sysdate(), N'2 已失效' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value = '2'); diff --git a/bms-back/sql/price/price_set.sql b/bms-back/sql/price/price_set.sql index ea7f5f00..838f4147 100644 --- a/bms-back/sql/price/price_set.sql +++ b/bms-back/sql/price/price_set.sql @@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS prc_customer_price_set ( updated_by VARCHAR(64) DEFAULT '' COMMENT '更新人', updated_at DATETIME DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (customer_priceset_id), + UNIQUE KEY uk_customer_one_set (customer_id), INDEX idx_customer_set (customer_id, pr_begindate, pr_enddate), INDEX idx_cps_set (price_set_id), CONSTRAINT fk_customer_price_set FOREIGN KEY (price_set_id) REFERENCES prc_price_set (price_set_id) ON DELETE RESTRICT diff --git a/bms-back/sql/price/product.sql b/bms-back/sql/price/product.sql index 19de973b..dc915116 100644 --- a/bms-back/sql/price/product.sql +++ b/bms-back/sql/price/product.sql @@ -13,7 +13,8 @@ CREATE TABLE IF NOT EXISTS bms_product ( update_note varchar(500) DEFAULT NULL COMMENT '更新内容说明', tax_mode varchar(20) NOT NULL DEFAULT 'TAX_INCLUDED' COMMENT 'TAX_INCLUDED包税 TAX_EXCLUDED不包税', effective_date date DEFAULT NULL COMMENT '目录生效日期', - status char(1) NOT NULL DEFAULT '0' COMMENT '0正常 1停用', + expire_date date DEFAULT NULL COMMENT '产品失效日期(空=长期;下一版生效日=本版失效日)', + status char(1) NOT NULL DEFAULT '0' COMMENT '0待生效 1已生效 2已失效', source_sheet varchar(100) DEFAULT NULL COMMENT '来源Excel sheet名', remark varchar(500) DEFAULT NULL, tms_id int DEFAULT NULL COMMENT '多租户ID', @@ -22,12 +23,14 @@ CREATE TABLE IF NOT EXISTS bms_product ( updated_by varchar(50) NOT NULL DEFAULT '', updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (product_id), - UNIQUE KEY uk_product_code (product_code), + UNIQUE KEY uk_product_code_eff (product_code, effective_date), + KEY idx_product_code (product_code), KEY idx_tax_status (tax_mode, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BMS产品主表-来自产品目录'; CREATE TABLE IF NOT EXISTS bms_product_country ( detail_id bigint NOT NULL AUTO_INCREMENT COMMENT '明细ID', + product_id bigint DEFAULT NULL COMMENT '产品ID(bms_product.product_id)', product_code varchar(20) NOT NULL COMMENT '产品代码', country_name varchar(50) NOT NULL COMMENT '国家中文名', country_code char(2) DEFAULT NULL COMMENT 'ISO2,可空待补齐', @@ -40,14 +43,15 @@ CREATE TABLE IF NOT EXISTS bms_product_country ( lead_time varchar(100) DEFAULT NULL COMMENT '参考时效', effective_date date DEFAULT NULL COMMENT '产品价表生效日期', expire_date date DEFAULT NULL COMMENT '失效日期(空=长期;新导入生效日=上一版失效日)', + status char(1) NOT NULL DEFAULT '0' COMMENT '0待生效 1已生效 2已失效', created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (detail_id), UNIQUE KEY uk_product_country_weight_eff (product_code, country_name, weight_min, weight_max, effective_date), + KEY idx_product_country_pid (product_id), KEY idx_country_code (country_code), KEY idx_weight_match (product_code, country_code, weight_min, weight_max), - KEY idx_product_eff (product_code, effective_date, expire_date), - CONSTRAINT fk_product_country_code FOREIGN KEY (product_code) REFERENCES bms_product (product_code) ON DELETE CASCADE ON UPDATE CASCADE + KEY idx_product_eff (product_code, effective_date, expire_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品国家明细-重量段+运费挂号费+尺寸/尾程/时效'; -- 已建表升级(幂等:列已存在时跳过报错可忽略) @@ -59,6 +63,7 @@ CREATE TABLE IF NOT EXISTS bms_product_country ( -- ALTER TABLE bms_product_country ADD COLUMN expire_date date DEFAULT NULL COMMENT '失效日期' AFTER effective_date; -- ALTER TABLE bms_product_country DROP INDEX uk_product_country_weight; -- ALTER TABLE bms_product_country ADD UNIQUE KEY uk_product_country_weight_eff (product_code, country_name, weight_min, weight_max, effective_date); +-- product_id: run product_country_product_id_alter.sql -- 菜单:挂在价格基础 2100 下(order_num=0 置顶) INSERT INTO sys_menu(menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) diff --git a/bms-back/sql/price/product_code_eff_unique_alter.sql b/bms-back/sql/price/product_code_eff_unique_alter.sql new file mode 100644 index 00000000..a5f1aecc --- /dev/null +++ b/bms-back/sql/price/product_code_eff_unique_alter.sql @@ -0,0 +1,20 @@ +-- ---------------------------- +-- bms_product:产品代码不单独唯一 +-- 唯一键改为 (product_code, effective_date) +-- 导入:相同代码+相同生效日不覆盖;不同生效日新增一行 +-- ---------------------------- +SET NAMES utf8mb4; + +-- 1) 国家明细外键依赖 uk_product_code,必须先删 +ALTER TABLE bms_product_country DROP FOREIGN KEY fk_product_country_code; + +-- 2) 删除产品代码单列唯一 +ALTER TABLE bms_product DROP INDEX uk_product_code; + +-- 3) 按代码+生效日期唯一 +ALTER TABLE bms_product + ADD UNIQUE KEY uk_product_code_eff (product_code, effective_date); + +-- 4) 保留产品代码查询索引 +ALTER TABLE bms_product + ADD KEY idx_product_code (product_code); diff --git a/bms-back/sql/price/product_country_product_id_alter.sql b/bms-back/sql/price/product_country_product_id_alter.sql new file mode 100644 index 00000000..7fb2e07f --- /dev/null +++ b/bms-back/sql/price/product_country_product_id_alter.sql @@ -0,0 +1,27 @@ +-- bms_product_country:增加 bms_product.product_id,明细绑定到具体产品版本行 +SET NAMES utf8mb4; + +-- 1) 新增 product_id 列 +ALTER TABLE bms_product_country + ADD COLUMN product_id bigint DEFAULT NULL COMMENT '产品ID(bms_product.product_id,绑定具体产品版本行)' AFTER detail_id; + +-- 2) 按 product_code + effective_date 回填既有明细 +UPDATE bms_product_country c + INNER JOIN bms_product p + ON p.product_code = c.product_code + AND ( + (p.effective_date IS NULL AND c.effective_date IS NULL) + OR p.effective_date = c.effective_date + ) +SET c.product_id = p.product_id +WHERE c.product_id IS NULL; + +-- 3) 索引 +ALTER TABLE bms_product_country + ADD KEY idx_product_country_pid (product_id); + +-- 4) 外键(删除产品时级联删除明细;若已存在或有孤儿数据报错可忽略) +ALTER TABLE bms_product_country + ADD CONSTRAINT fk_product_country_pid + FOREIGN KEY (product_id) REFERENCES bms_product (product_id) + ON DELETE CASCADE; diff --git a/bms-back/sql/price/product_expire_status_alter.sql b/bms-back/sql/price/product_expire_status_alter.sql new file mode 100644 index 00000000..46b7eed6 --- /dev/null +++ b/bms-back/sql/price/product_expire_status_alter.sql @@ -0,0 +1,58 @@ +-- bms_product 增加失效日期;产品/国家明细 status:0待生效 1已生效 2已失效 +SET NAMES utf8mb4; + +-- 1) 产品主表增加 expire_date +ALTER TABLE bms_product + ADD COLUMN expire_date date DEFAULT NULL COMMENT '产品失效日期(空=长期;下一版生效日=本版失效日)' AFTER effective_date; + +ALTER TABLE bms_product + MODIFY COLUMN status char(1) NOT NULL DEFAULT '0' COMMENT '0待生效 1已生效 2已失效'; + +-- 2) 国家明细增加 status +ALTER TABLE bms_product_country + ADD COLUMN status char(1) NOT NULL DEFAULT '0' COMMENT '0待生效 1已生效 2已失效' AFTER expire_date; + +-- 3) 按同代码下一版生效日回填产品失效日 +UPDATE bms_product p +INNER JOIN ( + SELECT p1.product_id, + (SELECT MIN(p2.effective_date) + FROM bms_product p2 + WHERE p2.product_code = p1.product_code + AND p2.effective_date IS NOT NULL + AND p1.effective_date IS NOT NULL + AND p2.effective_date > p1.effective_date) AS next_eff + FROM bms_product p1 +) t ON t.product_id = p.product_id +SET p.expire_date = t.next_eff +WHERE p.expire_date IS NULL AND t.next_eff IS NOT NULL; + +-- 4) 按生效/失效日期回写 status +UPDATE bms_product +SET status = CASE + WHEN expire_date IS NOT NULL AND expire_date <= CURDATE() THEN '2' + WHEN effective_date IS NULL OR effective_date > CURDATE() THEN '0' + ELSE '1' +END; + +UPDATE bms_product_country +SET status = CASE + WHEN expire_date IS NOT NULL AND expire_date <= CURDATE() THEN '2' + WHEN effective_date IS NULL OR effective_date > CURDATE() THEN '0' + ELSE '1' +END; + +-- 5) 字典值 ACTIVE/PENDING/EXPIRED 改为 1/0/2 +DELETE FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value IN ('ACTIVE', 'PENDING', 'EXPIRED'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT 1, N'已生效', '1', 'bms_product_effect_status', '', 'success', 'Y', '0', 'admin', sysdate(), N'1 已生效' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value = '1'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT 2, N'待生效', '0', 'bms_product_effect_status', '', 'warning', 'N', '0', 'admin', sysdate(), N'0 待生效' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value = '0'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT 3, N'已失效', '2', 'bms_product_effect_status', '', 'info', 'N', '0', 'admin', sysdate(), N'2 已失效' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'bms_product_effect_status' AND dict_value = '2'); diff --git a/bms-back/sql/price/quote_fee.sql b/bms-back/sql/price/quote_fee.sql new file mode 100644 index 00000000..c95962b5 --- /dev/null +++ b/bms-back/sql/price/quote_fee.sql @@ -0,0 +1,68 @@ +-- ---------------------------- +-- BMS 下单计费试算落库 +-- charset: utf8mb4 +-- prc_quote:相同试算参数唯一,命中则直接返回 +-- prc_quote_fee:运费/挂号费各一行,附加费按具体名称各一行 +-- ---------------------------- +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS prc_quote ( + quote_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '试算ID', + quote_key CHAR(32) NOT NULL COMMENT '试算指纹 MD5(客户|产品|国家|重量|计费日期)', + customer_id BIGINT(20) NOT NULL COMMENT '客户ID', + product_code VARCHAR(20) NOT NULL COMMENT '产品代码', + product_name VARCHAR(100) DEFAULT NULL COMMENT '产品名称', + country_code CHAR(2) DEFAULT NULL COMMENT '国家二字码', + country_name VARCHAR(50) DEFAULT NULL COMMENT '国家名称', + weight DECIMAL(10,3) NOT NULL COMMENT '计费重量KG', + biz_date DATE NOT NULL COMMENT '计费日期', + tms_id BIGINT(20) DEFAULT NULL COMMENT '多租户系统ID', + match_source VARCHAR(30) DEFAULT NULL COMMENT '命中来源 PACKAGE_FIXED/套餐固定价 PACKAGE_DISCOUNT/套餐折扣 PRODUCT_DEFAULT/产品默认价', + match_message VARCHAR(200) DEFAULT NULL COMMENT '命中说明', + price_set_id BIGINT(20) DEFAULT NULL COMMENT '价格套餐ID', + price_set_code VARCHAR(20) DEFAULT NULL COMMENT '套餐编码', + price_set_name VARCHAR(50) DEFAULT NULL COMMENT '套餐名称', + set_type VARCHAR(10) DEFAULT NULL COMMENT '计价类型 FIXED=固定价 DISCOUNT=折扣', + bind_priority INT(11) DEFAULT NULL COMMENT '客户套餐优先级', + customer_priceset_id BIGINT(20) DEFAULT NULL COMMENT '客户套餐绑定ID', + product_country_id BIGINT(20) DEFAULT NULL COMMENT '关联 bms_product_country.detail_id', + weight_min DECIMAL(10,4) DEFAULT NULL COMMENT '重量段下限', + weight_max DECIMAL(10,4) DEFAULT NULL COMMENT '重量段上限', + weight_label VARCHAR(50) DEFAULT NULL COMMENT '重量段展示', + effective_date DATE DEFAULT NULL COMMENT '产品明细生效日', + charge_weight DECIMAL(10,3) DEFAULT NULL COMMENT '计费重量', + freight_rate DECIMAL(10,4) DEFAULT NULL COMMENT '运费单价', + base_freight_rate DECIMAL(10,4) DEFAULT NULL COMMENT '折扣前单价', + discount_percent DECIMAL(5,2) DEFAULT NULL COMMENT '折扣百分数', + registration_fee DECIMAL(10,2) DEFAULT NULL COMMENT '挂号费', + freight_amount DECIMAL(10,2) DEFAULT NULL COMMENT '运费金额', + surcharge_amount DECIMAL(10,2) DEFAULT NULL COMMENT '附加费合计', + payable_total DECIMAL(10,2) DEFAULT NULL COMMENT '应付总额', + currency VARCHAR(10) NOT NULL DEFAULT 'RMB' COMMENT '币种', + round_mode VARCHAR(20) NOT NULL DEFAULT 'HALF_UP' COMMENT '取值方式(字典bms_round_mode)', + created_by VARCHAR(64) DEFAULT '' COMMENT '创建人', + created_at DATETIME DEFAULT NULL COMMENT '创建时间', + updated_by VARCHAR(64) DEFAULT '' COMMENT '更新人', + updated_at DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (quote_id), + UNIQUE KEY uk_quote_key (quote_key), + INDEX idx_quote_customer (customer_id, biz_date), + INDEX idx_quote_product (product_code, country_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='计费试算主表'; + +CREATE TABLE IF NOT EXISTS prc_quote_fee ( + fee_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '费用明细ID', + quote_id BIGINT(20) NOT NULL COMMENT '试算ID', + fee_type VARCHAR(30) NOT NULL COMMENT '费用类型(字典bms_fee_type)', + fee_name VARCHAR(100) DEFAULT NULL COMMENT '费用名称(附加费取配置名称)', + sc_id BIGINT(20) NOT NULL DEFAULT 0 COMMENT '附加费配置ID,运费/挂号费为0', + amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '金额', + currency VARCHAR(10) NOT NULL DEFAULT 'RMB' COMMENT '币种', + round_mode VARCHAR(20) DEFAULT 'HALF_UP' COMMENT '取值方式(字典bms_round_mode)', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_at DATETIME DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (fee_id), + UNIQUE KEY uk_quote_fee_item (quote_id, fee_type, sc_id), + INDEX idx_quote_fee_type (fee_type), + CONSTRAINT fk_quote_fee_quote FOREIGN KEY (quote_id) REFERENCES prc_quote (quote_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='计费试算费用明细-附加费按具体名称一行'; diff --git a/bms-back/sql/price/quote_fee_surcharge_name_alter.sql b/bms-back/sql/price/quote_fee_surcharge_name_alter.sql new file mode 100644 index 00000000..f4519ebe --- /dev/null +++ b/bms-back/sql/price/quote_fee_surcharge_name_alter.sql @@ -0,0 +1,21 @@ +-- ---------------------------- +-- prc_quote_fee:附加费按具体名称多行 +-- 原 uk_quote_fee_type(quote_id, fee_type) 被外键 fk_quote_fee_quote 依赖, +-- 必须先补 quote_id 索引再删旧唯一键 +-- ---------------------------- +SET NAMES utf8mb4; + +-- 1) 外键所需的 quote_id 索引(与旧唯一键分离) +ALTER TABLE prc_quote_fee ADD INDEX idx_quote_fee_quote_id (quote_id); + +-- 2) 附加费配置ID +ALTER TABLE prc_quote_fee ADD COLUMN sc_id BIGINT(20) NOT NULL DEFAULT 0 COMMENT '附加费配置ID,运费/挂号费为0' AFTER fee_name; + +-- 3) 新唯一:同一试算可有多条附加费 +ALTER TABLE prc_quote_fee ADD UNIQUE KEY uk_quote_fee_item (quote_id, fee_type, sc_id); + +-- 4) 现可安全删掉旧唯一键 +ALTER TABLE prc_quote_fee DROP INDEX uk_quote_fee_type; + +-- 5) 更新费用名称注释 +ALTER TABLE prc_quote_fee MODIFY COLUMN fee_name VARCHAR(100) DEFAULT NULL COMMENT '费用名称(附加费取配置名称)'; diff --git a/bms-front/src/api/price/billing.js b/bms-front/src/api/price/billing.js index 98f34218..5cfca5b1 100644 --- a/bms-front/src/api/price/billing.js +++ b/bms-front/src/api/price/billing.js @@ -8,3 +8,20 @@ export function quoteBilling(data) { data }) } + +/** Historical quote list */ +export function listQuote(query) { + return request({ + url: '/price/billing/quote/list', + method: 'get', + params: query + }) +} + +/** Historical quote detail */ +export function getQuote(quoteId) { + return request({ + url: '/price/billing/quote/' + quoteId, + method: 'get' + }) +} diff --git a/bms-front/src/api/price/product.js b/bms-front/src/api/price/product.js index 0b6c4306..57cf153a 100644 --- a/bms-front/src/api/price/product.js +++ b/bms-front/src/api/price/product.js @@ -17,11 +17,15 @@ export function getProduct(productId) { }) } -/** 查询产品国家明细 */ -export function listProductCountries(productCode) { +/** 查询产品国家明细(优先 productId,绑定到具体产品版本行) */ +export function listProductCountries(productCode, effectiveDate, productId) { + const params = {} + if (effectiveDate) params.effectiveDate = effectiveDate + if (productId) params.productId = productId return request({ url: '/price/product/' + productCode + '/countries', - method: 'get' + method: 'get', + params }) } diff --git a/bms-front/src/components/ExcelImportDialog/index.vue b/bms-front/src/components/ExcelImportDialog/index.vue index e3c6c92d..3b6adf2f 100644 --- a/bms-front/src/components/ExcelImportDialog/index.vue +++ b/bms-front/src/components/ExcelImportDialog/index.vue @@ -50,7 +50,10 @@
- {{ updateSupportLabel }} + + {{ updateSupportLabel }} + +
{{ updateSupportHint }}
@@ -105,6 +108,11 @@ const props = defineProps({ type: String, default: '是否更新已经存在的数据' }, + // 勾选框下方补充说明(长文案放这里,避免与复选框挤叠) + updateSupportHint: { + type: String, + default: '' + }, // 打开时是否默认勾选“更新已存在” defaultUpdateSupport: { type: Boolean, @@ -350,8 +358,38 @@ defineExpose({ open }) } .update-checkbox { + display: flex !important; + align-items: flex-start; + height: auto !important; + white-space: normal; font-weight: 500; color: var(--el-text-color-primary); + width: 100%; +} + +.update-checkbox :deep(.el-checkbox__input) { + margin-top: 3px; + flex-shrink: 0; +} + +.update-checkbox :deep(.el-checkbox__label) { + white-space: normal; + line-height: 1.5; + word-break: break-word; + padding-left: 8px; +} + +.update-label { + display: inline-block; + line-height: 1.5; +} + +.update-hint { + margin: 6px 0 0 22px; + font-size: 12px; + color: var(--el-text-color-secondary); + line-height: 1.6; + word-break: break-word; } .import-tips { diff --git a/bms-front/src/components/SimpleTable/index.vue b/bms-front/src/components/SimpleTable/index.vue new file mode 100644 index 00000000..be8d3aa6 --- /dev/null +++ b/bms-front/src/components/SimpleTable/index.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/bms-front/src/main.js b/bms-front/src/main.js index c665eab8..a77e923d 100644 --- a/bms-front/src/main.js +++ b/bms-front/src/main.js @@ -47,6 +47,7 @@ import DictTag from '@/components/DictTag' import BasicForm from '@/components/BasicForm' // 公共表格组件 import BasicTable from '@/components/BasicTable' +import SimpleTable from '@/components/SimpleTable' const app = createApp(App) @@ -71,6 +72,7 @@ app.component('RightToolbar', RightToolbar) app.component('Editor', Editor) app.component('BasicForm', BasicForm) app.component('BasicTable', BasicTable) +app.component('SimpleTable', SimpleTable) app.use(router) app.use(store) diff --git a/bms-front/src/views/price/basic/product/components/ProductCountryDrawer.vue b/bms-front/src/views/price/basic/product/components/ProductCountryDrawer.vue new file mode 100644 index 00000000..3a21b0e9 --- /dev/null +++ b/bms-front/src/views/price/basic/product/components/ProductCountryDrawer.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/bms-front/src/views/price/basic/product/components/ProductFormDialog.vue b/bms-front/src/views/price/basic/product/components/ProductFormDialog.vue new file mode 100644 index 00000000..2a34bf4a --- /dev/null +++ b/bms-front/src/views/price/basic/product/components/ProductFormDialog.vue @@ -0,0 +1,167 @@ + + + diff --git a/bms-front/src/views/price/basic/product/components/ProductSurchargeDrawer.vue b/bms-front/src/views/price/basic/product/components/ProductSurchargeDrawer.vue new file mode 100644 index 00000000..25c18bdf --- /dev/null +++ b/bms-front/src/views/price/basic/product/components/ProductSurchargeDrawer.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/bms-front/src/views/price/basic/product/components/SurchargeRelDialog.vue b/bms-front/src/views/price/basic/product/components/SurchargeRelDialog.vue new file mode 100644 index 00000000..a8b61054 --- /dev/null +++ b/bms-front/src/views/price/basic/product/components/SurchargeRelDialog.vue @@ -0,0 +1,98 @@ + + + diff --git a/bms-front/src/views/price/basic/product/index.vue b/bms-front/src/views/price/basic/product/index.vue index 17a40002..6c460b56 100644 --- a/bms-front/src/views/price/basic/product/index.vue +++ b/bms-front/src/views/price/basic/product/index.vue @@ -5,14 +5,24 @@ :closable="false" show-icon class="mb16" - title="Excel 导入:上传威狮B2C小包价表.xlsx。入库产品目录 + 各产品 sheet 的国家×重量段(运费/挂号费/尺寸/尾程/时效);不导入退件重派、申报说明、赔偿标准。" + title="Excel 导入:相同产品代码+生效日期不覆盖;不同生效日期新增一条产品。同时入库国家×重量段(运费/挂号费/尺寸/尾程/时效)。" /> + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 启用(按生效日期自动:待生效/已生效) - 已失效 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- 新增关联 -
- - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - 产品级(全部明细) - 明细级(指定国家×重量段) - - - - - - - - - - - + + +
@@ -232,43 +72,54 @@ diff --git a/bms-front/src/views/price/billing/quote.vue b/bms-front/src/views/price/billing/quote.vue index 2b2050dc..8c2e871c 100644 --- a/bms-front/src/views/price/billing/quote.vue +++ b/bms-front/src/views/price/billing/quote.vue @@ -95,37 +95,194 @@ {{ result.priceSetCode || '-' }} {{ result.priceSetName || '-' }} {{ result.setType || '-' }} - {{ result.currency || 'RMB' }} + + + {{ formatMoney(result.freightRate) }} {{ formatMoney(result.baseFreightRate) }} {{ result.discountPercent != null ? result.discountPercent : '-' }} {{ roundModeLabel(result.roundMode) }} {{ formatMoney(result.freightAmount) }} {{ formatMoney(result.registrationFee) }} + {{ formatMoney(result.surchargeAmount) }} {{ formatMoney(result.payableTotal) }} + + 命中历史试算 + 实时计算并落库 + {{ result.matchMessage || '-' }} -
计算公式:运费金额 = 运费单价 × 计费重量;应付总额 = 运费金额 + 挂号费
+ + + + + + + + + + + + + + + + + + + + +
计算公式:运费金额 = 运费单价 × 计费重量;应付总额 = 运费金额 + 挂号费 + 附加费。相同试算参数直接返回已落库费用明细。
+ + + + + + + + + + + + + +
+ +
+ + {{ viewResult.customerId }} + {{ viewResult.productName || '-' }} ({{ viewResult.productCode }}) + {{ viewResult.countryName || '-' }} ({{ viewResult.countryCode }}) + {{ viewResult.chargeWeight ?? viewResult.weight }} + {{ viewResult.weightLabel || '-' }} + + {{ sourceLabel(viewResult.matchSource) }} + + {{ viewResult.priceSetName || '-' }} {{ viewResult.priceSetCode ? '(' + viewResult.priceSetCode + ')' : '' }} + {{ formatMoney(viewResult.freightAmount) }} + {{ formatMoney(viewResult.registrationFee) }} + {{ formatMoney(viewResult.surchargeAmount) }} + + {{ formatMoney(viewResult.payableTotal) }} + + {{ viewResult.matchMessage || '-' }} + + + + + + + + + + + + + + + + + + +
+
+