diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/ProductController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/ProductController.java index bf2bcd4e..b458ab6c 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/ProductController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/ProductController.java @@ -27,7 +27,9 @@ import com.bms.common.utils.MessageUtils; import com.bms.price.domain.BmsProduct; import com.bms.price.domain.BmsProductCountry; import com.bms.price.domain.BmsProductImportTask; +import com.bms.price.domain.PrcPriceExcelFile; import com.bms.price.service.IBmsProductService; +import com.bms.price.service.IPrcPriceExcelFileService; /** * Product list (async Excel import) @@ -41,6 +43,9 @@ public class ProductController extends BaseController @Autowired private IBmsProductService productService; + @Autowired + private IPrcPriceExcelFileService priceExcelFileService; + @PreAuthorize("@ss.hasPermi('price:product:list')") @GetMapping("/list") public TableDataInfo list(BmsProduct product) @@ -50,6 +55,15 @@ public class ProductController extends BaseController return getDataTable(list); } + /** Imported quote Excel attachments for this product_code (via product.pef_id). */ + @PreAuthorize("@ss.hasPermi('price:product:query')") + @GetMapping("/{productCode}/excelFiles") + public AjaxResult excelFiles(@PathVariable String productCode) + { + List list = priceExcelFileService.selectByProductCode(productCode); + return success(list); + } + /** * 产品下拉选项(非分页)。 * 供计费列表、客户绑定等场景选择产品;不做权限控制(仍需登录)。 diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/RedlineProductController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/RedlineProductController.java new file mode 100644 index 00000000..48ae379d --- /dev/null +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/RedlineProductController.java @@ -0,0 +1,173 @@ +package com.bms.web.controller.price; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +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.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.bms.common.annotation.Log; +import com.bms.common.core.controller.BaseController; +import com.bms.common.core.domain.AjaxResult; +import com.bms.common.core.page.TableDataInfo; +import com.bms.common.enums.BusinessType; +import com.bms.common.utils.MessageUtils; +import com.bms.price.domain.BmsRedlineProduct; +import com.bms.price.domain.BmsRedlineProductCountry; +import com.bms.price.domain.BmsRedlineProductImportTask; +import com.bms.price.domain.PrcPriceExcelFile; +import com.bms.price.service.IBmsRedlineProductService; +import com.bms.price.service.IPrcPriceExcelFileService; + +/** + * redline product list (async Excel import) + * + * @author bms + */ +@RestController +@RequestMapping("/price/redline") +public class RedlineProductController extends BaseController +{ + @Autowired + private IBmsRedlineProductService productService; + + @Autowired + private IPrcPriceExcelFileService priceExcelFileService; + + @PreAuthorize("@ss.hasPermi('price:redline:list')") + @GetMapping("/list") + public TableDataInfo list(BmsRedlineProduct product) + { + startPage(); + List list = productService.selectBmsRedlineProductList(product); + return getDataTable(list); + } + + /** Imported quote Excel attachments for this product_code (via product.pef_id). */ + @PreAuthorize("@ss.hasPermi('price:redline:query')") + @GetMapping("/{productCode}/excelFiles") + public AjaxResult excelFiles(@PathVariable String productCode) + { + List list = priceExcelFileService.selectByRedlineProductCode(productCode); + return success(list); + } + + /** + * 产品下拉选项(非分页)。 + * 供计费列表、客户绑定等场景选择产品;不做权限控制(仍需登录)。 + */ + @GetMapping("/optionselect") + public AjaxResult optionselect(BmsRedlineProduct product) + { + List list = productService.selectBmsRedlineProductList(product); + return success(list); + } + + @PreAuthorize("@ss.hasPermi('price:redline:import')") + @GetMapping("/import/task/{taskId}") + public AjaxResult getImportTask(@PathVariable String taskId) + { + BmsRedlineProductImportTask task = productService.getImportTask(taskId); + if (task == null) + { + return error(MessageUtils.message("price.product.import.task.notExists")); + } + return success(task); + } + + @PreAuthorize("@ss.hasPermi('price:redline:query')") + @GetMapping("/{productCode}/countries") + public AjaxResult countries(@PathVariable String productCode, + @RequestParam(required = false) Date effectiveDate, + @RequestParam(required = false) Long productId) + { + List list = productService.selectCountryListByProductCode(productCode, effectiveDate, productId); + return success(list); + } + + @PreAuthorize("@ss.hasPermi('price:redline:query')") + @GetMapping("/{productId}") + public AjaxResult getInfo(@PathVariable Long productId) + { + return success(productService.selectBmsRedlineProductById(productId)); + } + + @PreAuthorize("@ss.hasPermi('price:redline:add')") + @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@Validated @RequestBody BmsRedlineProduct product) + { + product.setCreateBy(getUsername()); + return toAjax(productService.insertBmsRedlineProduct(product)); + } + + @PreAuthorize("@ss.hasPermi('price:redline:edit')") + @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@Validated @RequestBody BmsRedlineProduct product) + { + product.setUpdateBy(getUsername()); + return toAjax(productService.updateBmsRedlineProduct(product)); + } + + @PreAuthorize("@ss.hasPermi('price:redline:edit')") + @Log(title = "\u4ea7\u54c1\u56fd\u5bb6\u660e\u7ec6", businessType = BusinessType.UPDATE) + @PutMapping("/country") + public AjaxResult editCountry(@RequestBody BmsRedlineProductCountry country) + { + return toAjax(productService.updateBmsRedlineProductCountry(country)); + } + + @PreAuthorize("@ss.hasPermi('price:redline:remove')") + @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.DELETE) + @DeleteMapping("/{productIds}") + public AjaxResult remove(@PathVariable Long[] productIds) + { + return toAjax(productService.deleteBmsRedlineProductByIds(productIds)); + } + + /** + * Async import Weishi B2C workbook. Returns taskId; poll /import/task/{taskId}. + * effectiveDate is required: written to product / product_country; previous open rows expire at this date. + */ + @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.IMPORT) + @PreAuthorize("@ss.hasPermi('price:redline:import')") + @PostMapping("/importData") + public AjaxResult importData(MultipartFile file, boolean updateSupport, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception + { + String taskId = productService.submitImportProductExcelAsync(file, updateSupport, getUsername(), effectiveDate); + Map payload = new HashMap(); + payload.put("taskId", taskId); + return success(payload); + } + + /** + * Async single-country cost-sheet import. Returns taskId; poll /import/task/{taskId}. + * effectiveDate is required: written to product / product_country; previous open rows expire at this date. + */ + @Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.IMPORT) + @PreAuthorize("@ss.hasPermi('price:redline:import')") + @PostMapping("/importCountryData") + public AjaxResult importCountryData(MultipartFile file, String countryCode, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception + { + String taskId = productService.submitImportCountryProductExcelAsync(file, countryCode, getUsername(), + effectiveDate); + Map payload = new HashMap(); + payload.put("taskId", taskId); + return success(payload); + } +} diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/SurchargeController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/SurchargeController.java index 05c71a97..669fc30a 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/SurchargeController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/SurchargeController.java @@ -11,6 +11,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 com.bms.common.annotation.Log; import com.bms.common.core.controller.BaseController; @@ -58,9 +59,10 @@ public class SurchargeController extends BaseController */ @PreAuthorize("@ss.hasPermi('price:surcharge:query') or @ss.hasPermi('price:set:query') or @ss.hasPermi('price:set:add')") @GetMapping("/byProduct/{productCode}") - public AjaxResult listByProduct(@PathVariable String productCode) + public AjaxResult listByProduct(@PathVariable String productCode, + @RequestParam(required = false) String priceType) { - return success(surchargeRelService.selectRelByProductCode(productCode)); + return success(surchargeRelService.selectRelByProductCode(productCode, priceType)); } /** @@ -78,7 +80,7 @@ public class SurchargeController extends BaseController } /** - * 绑定附加费到产品/明细(body: scId, productId, productCode, detailId;detailId 空=产品级) + * 绑定附加费(body: scId, productId, productCode, detailId, priceType, countryCode;priceType 默认 PRODUCT;countryCode 空=全部) */ @PreAuthorize("@ss.hasPermi('price:surcharge:add')") @Log(title = "附加费关联", businessType = BusinessType.INSERT) diff --git a/bms-back/bms-admin/src/main/resources/i18n/messages.properties b/bms-back/bms-admin/src/main/resources/i18n/messages.properties index 2c3d7804..ec80c530 100644 --- a/bms-back/bms-admin/src/main/resources/i18n/messages.properties +++ b/bms-back/bms-admin/src/main/resources/i18n/messages.properties @@ -47,6 +47,8 @@ price.surcharge.chargeValue.negative=计费数值不能为负数 price.surchargeRel.scId.empty=附加费ID不能为空 price.surchargeRel.productId.empty=产品ID不能为空 price.surchargeRel.productCode.empty=产品代码不能为空 +price.surchargeRel.priceType.invalid=价格类型必须为 PRODUCT/SALES/REDLINE +price.surchargeRel.detailId.empty=销售价关联必须指定套餐明细ID # 套餐 PrcPriceSet price.set.notExists=套餐不存在 price.set.name.empty=套餐名称不能为空 @@ -128,6 +130,8 @@ price.product.import.success.detail2=范围:产品目录 + 重量段 + 运费/ price.product.import.noSheet=未找到产品明细sheet(需与产品名称一致):{0} price.product.import.noCountry=产品明细sheet无有效国家/重量段数据:{0} price.product.import.euEmpty=国家表无欧盟成员(is_eu=1),无法展开「欧盟」 +price.product.import.dupCountry.item=产品{0}({1})重复国家+重量段:{2} +price.product.import.dupCountry=导入失败:以下产品明细中存在重复的国家+重量段,请合并后重试:{0} price.product.import.success.unmatched=未匹配国家表(country_code 为空):{0} price.product.import.countryRequired=请选择目的国家 price.product.import.countryNotFound=国家不存在或已停用:{0} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsProduct.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsProduct.java index ea6c0d22..1d69272a 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsProduct.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsProduct.java @@ -33,6 +33,8 @@ public class BmsProduct extends BaseEntity public static final String STATUS_EXPIRED = "2"; private String status; private String sourceSheet; + /** FK prc_price_excel_file.pef_id */ + private Long pefId; private Long tmsId; private Integer countryCount; private Integer surchargeCount; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsRedlineProduct.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsRedlineProduct.java new file mode 100644 index 00000000..303a0166 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsRedlineProduct.java @@ -0,0 +1,46 @@ +package com.bms.price.domain; + +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.bms.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** Redline catalog main table redline_product */ +@Data +@EqualsAndHashCode(callSuper = true) +public class BmsRedlineProduct extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long productId; + private String productCode; + private String productName; + private String serviceLevel; + private String cargoAttr; + private String destRegion; + private String updateNote; + /** TAX_INCLUDED / TAX_EXCLUDED */ + private String taxMode; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date effectiveDate; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + 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; + /** FK prc_price_excel_file.pef_id */ + private Long pefId; + private Long tmsId; + private Integer countryCount; + private Integer surchargeCount; + private List countries; + /** query alias of status (0/1/2) */ + private String effectStatus; + /** query: active = status in (0,1); expired = status 2 */ + private String statusGroup; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsRedlineProductCountry.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsRedlineProductCountry.java new file mode 100644 index 00000000..901a6ef7 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsRedlineProductCountry.java @@ -0,0 +1,49 @@ +package com.bms.price.domain; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** Redline country detail table redline_product_country */ +@Data +public class BmsRedlineProductCountry implements Serializable +{ + private static final long serialVersionUID = 1L; + + private Long detailId; + /** FK to product.product_id */ + private Long productId; + private String productCode; + private String countryName; + private String countryCode; + /** prc_zone.zone_id; null when product country has no zone */ + private Long zoneId; + /** e.g. Z1; null when no zone */ + private String zoneCode; + /** e.g. Australia(Z1); null when no zone */ + private String zoneName; + private BigDecimal weightMin; + private BigDecimal weightMax; + private BigDecimal freightPrice; + private BigDecimal registrationFee; + private String sizeLimit; + private String lastMile; + private String leadTime; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date effectiveDate; + /** null=open; closed to next import effective_date */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date expireDate; + /** 0 pending, 1 active, 2 expired */ + private String status; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; + /** 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/bms-system/src/main/java/com/bms/price/domain/BmsRedlineProductImportTask.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsRedlineProductImportTask.java new file mode 100644 index 00000000..11fce955 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/BmsRedlineProductImportTask.java @@ -0,0 +1,72 @@ +package com.bms.price.domain; + +import java.io.Serializable; + +/** + * Async product Excel import progress (Redis). + */ +public class BmsRedlineProductImportTask implements Serializable +{ + private static final long serialVersionUID = 1L; + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_RUNNING = "RUNNING"; + public static final String STATUS_SUCCESS = "SUCCESS"; + public static final String STATUS_FAIL = "FAIL"; + + private String taskId; + private String status; + private int percent; + private String message; + private String error; + + public String getTaskId() + { + return taskId; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public String getStatus() + { + return status; + } + + public void setStatus(String status) + { + this.status = status; + } + + public int getPercent() + { + return percent; + } + + public void setPercent(int percent) + { + this.percent = percent; + } + + public String getMessage() + { + return message; + } + + public void setMessage(String message) + { + this.message = message; + } + + public String getError() + { + return error; + } + + public void setError(String error) + { + this.error = error; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceExcelFile.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceExcelFile.java new file mode 100644 index 00000000..aa0bfbc5 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceExcelFile.java @@ -0,0 +1,35 @@ +package com.bms.price.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.bms.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Import quote Excel attachment: prc_price_excel_file + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PrcPriceExcelFile extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + public static final String TYPE_PRODUCT = "PRODUCT"; + public static final String TYPE_REDLINE = "REDLINE"; + public static final String TYPE_SALES = "SALES"; + + private Long pefId; + /** PRODUCT / REDLINE / SALES */ + private String excelType; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date effectiveTime; + private String fileName; + private String filePath; + private Long fileSize; + private String contentType; + private String fileExtension; + private String ossId; + /** Comma-separated sheet names (optional) */ + private String sheetNames; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSet.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSet.java index 661693b3..fc0209c6 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSet.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSet.java @@ -48,4 +48,9 @@ public class PrcPriceSet extends BaseEntity private String pullFromLibrary; /** Y: even if version already has details, delete+re-pull from library */ private String forcePullLibrary; + /** + * Request-only: stamp this Excel attachment id onto prc_price_set_product.pef_id + * when saving IMPORT details (not a column on prc_price_set). + */ + private Long pefId; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetDetail.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetDetail.java index f433842a..bfc3cb39 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetDetail.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetDetail.java @@ -52,7 +52,8 @@ public class PrcPriceSetDetail implements Serializable /** null = open */ @JsonFormat(pattern = "yyyy-MM-dd") private Date expireDate; - private List surcharges; + /** SALES rels for this detail (detail_id=psd_id, product_id=psp_id) */ + private List surcharges; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createTime; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetProduct.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetProduct.java index e87e10e4..c73066ca 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetProduct.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetProduct.java @@ -37,6 +37,8 @@ public class PrcPriceSetProduct implements Serializable private Long sourceProductId; /** 来源Excel sheet */ private String sourceSheet; + /** 关联导入价表Excel附件 prc_price_excel_file.pef_id */ + private Long pefId; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createTime; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersion.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersion.java index 4eb440e3..f674724f 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersion.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersion.java @@ -34,5 +34,4 @@ public class PrcPriceSetVersion extends BaseEntity private List details; private List products; - private List surcharges; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersionSurcharge.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersionSurcharge.java deleted file mode 100644 index 1d8106b9..00000000 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPriceSetVersionSurcharge.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.bms.price.domain; - -import java.math.BigDecimal; -import com.bms.common.core.domain.BaseEntity; -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** Price set version surcharge snapshot prc_price_set_version_surcharge */ -@Data -@EqualsAndHashCode(callSuper = true) -public class PrcPriceSetVersionSurcharge extends BaseEntity -{ - private static final long serialVersionUID = 1L; - - private Long psvsId; - private Long psvId; - /** source prc_surcharge.sc_id */ - private Long scId; - /** prc_price_set_detail.psd_id */ - private Long psdId; - /** copied from detail */ - private String productCode; - private String scName; - private String feeType; - /** FIXED / WEIGHT / RATE */ - private String chargeType; - private BigDecimal chargeValue; - private String currency; - private String roundMode; - private String countryCode; - private String scNote; -} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcSurchargeRel.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcSurchargeRel.java index 3432495f..13f32007 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcSurchargeRel.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcSurchargeRel.java @@ -6,13 +6,12 @@ import lombok.Data; import lombok.EqualsAndHashCode; /** - * 附加费与产品/明细关联 prc_surcharge_rel - * 同时携带附加费展示字段(JOIN prc_surcharge)与明细展示字段(LEFT JOIN product_country), - * 便于产品列表抽屉直接渲染。 + * 附加费与产品/明细/套餐关联 prc_surcharge_rel + * 同时携带附加费展示字段(JOIN prc_surcharge)与明细展示字段(LEFT JOIN product_country,仅 PRODUCT/REDLINE)。 * - * 语义: - * detail_id 为空 = 产品级附加费(作用于该产品全部明细) - * detail_id 有值 = 明细级附加费(作用于该产品某个 国家×重量段 明细) + * price_type 语义: + * PRODUCT / REDLINE : product_id=product.product_id,detail_id=product_country.detail_id(空=产品全部明细) + * SALES : product_id=prc_price_set_product.psp_id,detail_id=prc_price_set_detail.psd_id */ @Data @EqualsAndHashCode(callSuper = true) @@ -20,11 +19,24 @@ public class PrcSurchargeRel extends BaseEntity { private static final long serialVersionUID = 1L; + /** 价格类型:产品价 */ + public static final String PRICE_TYPE_PRODUCT = "PRODUCT"; + /** 价格类型:销售价(套餐) */ + public static final String PRICE_TYPE_SALES = "SALES"; + /** 价格类型:红线价 */ + public static final String PRICE_TYPE_REDLINE = "REDLINE"; + private Long relId; private Long scId; + /** PRODUCT/REDLINE=product_id;SALES=psp_id */ private Long productId; private String productCode; + /** PRODUCT/REDLINE=product_country.detail_id;SALES=psd_id */ private Long detailId; + /** PRODUCT / SALES / REDLINE */ + private String priceType; + /** 适用国家ISO2(空=全部),rel 表字段 */ + private String countryCode; /** 附加费冗余展示字段 */ private String scName; @@ -34,10 +46,9 @@ public class PrcSurchargeRel extends BaseEntity private String currency; private String roundMode; private String ssCode; - private String countryCode; private String scNote; - /** 明细展示字段 */ + /** 明细展示字段(PRODUCT/REDLINE LEFT JOIN product_country) */ private String countryName; private BigDecimal weightMin; private BigDecimal weightMax; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/TmsPriceQuoteResult.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/TmsPriceQuoteResult.java index 579fe744..3f23fa76 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/TmsPriceQuoteResult.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/TmsPriceQuoteResult.java @@ -58,8 +58,10 @@ public class TmsPriceQuoteResult implements Serializable private BigDecimal surchargeAmount; /** product catalog total: base freight*weight + base registration (no package surcharge) */ private BigDecimal productAmount; - /** redline total reserved */ + /** redline total from redline_product_country (+ REDLINE surcharges) */ private BigDecimal redlineAmount; + /** matched redline_product_country.detail_id for REDLINE surcharge scope */ + private Long redlineCountryId; /** cost total (currently = product catalog amount) */ private BigDecimal costAmount; /** receivable total (order bill) */ diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/BmsRedlineProductCountryMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/BmsRedlineProductCountryMapper.java new file mode 100644 index 00000000..3df6d950 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/BmsRedlineProductCountryMapper.java @@ -0,0 +1,99 @@ +package com.bms.price.mapper; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.bms.price.domain.BmsRedlineProductCountry; + +/** + * 红线价国家明细 数据层 + * + * @author bms + */ +public interface BmsRedlineProductCountryMapper +{ + public List selectBmsRedlineProductCountryList(BmsRedlineProductCountry country); + + public int insertBmsRedlineProductCountry(BmsRedlineProductCountry country); + + /** 按明细行主键更新可编辑字段(重量段/运费/挂号费/尺寸/尾程/时效/生效失效日期) */ + public int updateBmsRedlineProductCountry(BmsRedlineProductCountry country); + + /** Import overlay: update price/zone/attrs on matched country row */ + public int updateBmsRedlineProductCountryImport(BmsRedlineProductCountry country); + + public int deleteByProductId(Long productId); + + public int deleteByProductCode(String productCode); + + 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); + + /** Close open versions: set expire_date = newEffectiveDate where effective_date < newEffectiveDate */ + public int expireOpenByProductCode( + @Param("productCode") String productCode, + @Param("newEffectiveDate") Date newEffectiveDate); + + /** + * Close open rows for one product + country where effective_date < newEffectiveDate. + * Match by country_code when present, otherwise country_name. + */ + public int expireOpenByProductCodeAndCountry( + @Param("productCode") String productCode, + @Param("countryCode") String countryCode, + @Param("countryName") String countryName, + @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, + @Param("effectiveDate") Date effectiveDate); + + /** + * Delete open rows for one product + country on a given effective_date (same-day replace). + * Needed because unique key keeps expired same-day weight bands. + */ + public int deleteOpenByProductCodeAndCountryOnDate( + @Param("productCode") String productCode, + @Param("countryCode") String countryCode, + @Param("countryName") String countryName, + @Param("effectiveDate") Date effectiveDate); + + /** Open country rows (expire_date is null) for product */ + public List selectOpenByProductCode(@Param("productCode") String productCode); + + /** Copy previously open rows into a new effective_date (caller must expire first) */ + public int copyOpenCountries( + @Param("productCode") String productCode, + @Param("productId") Long productId, + @Param("effectiveDate") Date effectiveDate, + @Param("status") String status); + + /** + * Match by product + country + weight + bizDate (+ optional zoneCode). + * When zoneCode is empty, prefer rows without zone. + * When zoneCode is set, require matching zone_code. + */ + public BmsRedlineProductCountry selectMatchByProductCountryWeight( + @Param("productCode") String productCode, + @Param("countryCode") String countryCode, + @Param("countryName") String countryName, + @Param("zoneCode") String zoneCode, + @Param("weight") BigDecimal weight, + @Param("bizDate") Date bizDate); + + /** Count product-country rows that have a zone for this product+country+bizDate */ + public int countZonedByProductCountry( + @Param("productCode") String productCode, + @Param("countryCode") String countryCode, + @Param("countryName") String countryName, + @Param("bizDate") Date bizDate); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/BmsRedlineProductMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/BmsRedlineProductMapper.java new file mode 100644 index 00000000..be5f243c --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/BmsRedlineProductMapper.java @@ -0,0 +1,44 @@ +package com.bms.price.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.bms.price.domain.BmsRedlineProduct; + +/** + * 产品主表 数据层 + * + * @author bms + */ +public interface BmsRedlineProductMapper +{ + public BmsRedlineProduct selectBmsRedlineProductById(Long productId); + + /** Latest row by product_code (effective_date desc) */ + public BmsRedlineProduct selectBmsRedlineProductByCode(String productCode); + + /** Unique business key: product_code + effective_date */ + public BmsRedlineProduct selectBmsRedlineProductByCodeAndDate(@Param("productCode") String productCode, + @Param("effectiveDate") Date effectiveDate); + + /** Product version effective on bizDate */ + public BmsRedlineProduct selectEffectiveBmsRedlineProductByCode(@Param("productCode") String productCode, + @Param("bizDate") Date bizDate); + + public List selectBmsRedlineProductList(BmsRedlineProduct product); + + public int insertBmsRedlineProduct(BmsRedlineProduct product); + + public int updateBmsRedlineProduct(BmsRedlineProduct product); + + public int deleteBmsRedlineProductByIds(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/bms-system/src/main/java/com/bms/price/mapper/PrcIncomeBillFeeMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcIncomeBillFeeMapper.java index 736c2c8d..a1897aff 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcIncomeBillFeeMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcIncomeBillFeeMapper.java @@ -47,4 +47,7 @@ public interface PrcIncomeBillFeeMapper public int deleteByFeeId(String feeId); public int deleteByBillId(String billId); + + /** Rebill: delete auto fees only; keep manual_flag=1 rows. */ + public int deleteAutoFeesByBillId(String billId); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceExcelFileMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceExcelFileMapper.java new file mode 100644 index 00000000..a5f1bd06 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceExcelFileMapper.java @@ -0,0 +1,26 @@ +package com.bms.price.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.bms.price.domain.PrcPriceExcelFile; + +/** + * Import quote Excel attachment mapper + */ +public interface PrcPriceExcelFileMapper +{ + public PrcPriceExcelFile selectPrcPriceExcelFileById(Long pefId); + + public List selectPrcPriceExcelFileList(PrcPriceExcelFile query); + + /** Distinct PRODUCT excel files linked by product.pef_id for this product_code */ + public List selectByProductCode(@Param("productCode") String productCode); + + public List selectByRedlineProductCode(@Param("productCode") String productCode); + + public int insertPrcPriceExcelFile(PrcPriceExcelFile row); + + public int updatePrcPriceExcelFile(PrcPriceExcelFile row); + + public int deletePrcPriceExcelFileByIds(Long[] pefIds); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetProductMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetProductMapper.java index 10efa5ba..fc4a685a 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetProductMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetProductMapper.java @@ -34,4 +34,8 @@ public interface PrcPriceSetProductMapper /** Bind detail.psp_id from set product by psv + product_code. */ public int bindDetailPspByPsvId(@Param("psvId") Long psvId); + + /** Stamp Excel attachment id on set products under this version (optional code filter). */ + public int updatePefIdByPsvId(@Param("psvId") Long psvId, @Param("pefId") Long pefId, + @Param("productCodes") List productCodes); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetVersionSurchargeMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetVersionSurchargeMapper.java deleted file mode 100644 index 1347df87..00000000 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetVersionSurchargeMapper.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.bms.price.mapper; - -import java.util.List; -import org.apache.ibatis.annotations.Param; -import com.bms.price.domain.PrcPriceSetVersionSurcharge; - -/** - * Price set version surcharge snapshot - */ -public interface PrcPriceSetVersionSurchargeMapper -{ - public PrcPriceSetVersionSurcharge selectPrcPriceSetVersionSurchargeById(Long psvsId); - - public List selectPrcPriceSetVersionSurchargeList(PrcPriceSetVersionSurcharge surcharge); - - public int insertPrcPriceSetVersionSurcharge(PrcPriceSetVersionSurcharge surcharge); - - public int updatePrcPriceSetVersionSurcharge(PrcPriceSetVersionSurcharge surcharge); - - public int deletePrcPriceSetVersionSurchargeByIds(Long[] psvsIds); - - public int deleteByPsvId(Long psvId); - - public int deleteByPsdId(Long psdId); - - public List selectByPsdIds(@Param("psdIds") List psdIds); - - /** Snapshot all applicable product-rel surcharges for details under psvId (library create). */ - public int insertFromLibraryRels(@Param("psvId") Long psvId, @Param("createBy") String createBy); - - /** Copy surcharge snapshots from src version onto new version details (match by detail keys). */ - public int copySurchargesFromVersion( - @Param("srcPsvId") Long srcPsvId, - @Param("newPsvId") Long newPsvId, - @Param("createBy") String createBy); -} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcSurchargeRelMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcSurchargeRelMapper.java index e7505f60..70afde5b 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcSurchargeRelMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcSurchargeRelMapper.java @@ -5,24 +5,36 @@ import org.apache.ibatis.annotations.Param; import com.bms.price.domain.PrcSurchargeRel; /** - * 附加费与产品/明细关联 数据层 + * 附加费与产品/明细/套餐关联 数据层 * * @author bms */ public interface PrcSurchargeRelMapper { /** - * 查询关联列表(JOIN 附加费 + 明细),支持按 productCode / scId 过滤 + * 查询关联列表(JOIN 附加费 + 明细),支持按 productCode / scId / priceType / detailId 过滤 */ public List selectPrcSurchargeRelList(PrcSurchargeRel rel); + public List selectByDetailIdsAndPriceType( + @Param("detailIds") List detailIds, + @Param("priceType") String priceType); + public int insertPrcSurchargeRel(PrcSurchargeRel rel); public int deletePrcSurchargeRelByIds(Long[] relIds); - /** 删除产品时清理其关联 */ + /** 删除产品时清理 PRODUCT/REDLINE 关联 */ public int deleteRelByProductCodes(String[] productCodes); + public int deleteByDetailIdsAndPriceType( + @Param("detailIds") List detailIds, + @Param("priceType") String priceType); + + public int deleteByProductIdsAndPriceType( + @Param("productIds") List productIds, + @Param("priceType") String priceType); + /** 附加费版本化编辑后,把产品关联从旧记录重指向新记录 */ public int rebindRelScId(@Param("oldScId") Long oldScId, @Param("newScId") Long newScId, @Param("operator") String operator); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IBmsRedlineProductService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IBmsRedlineProductService.java new file mode 100644 index 00000000..e4a98756 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IBmsRedlineProductService.java @@ -0,0 +1,57 @@ +package com.bms.price.service; + +import java.util.Date; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; +import com.bms.price.domain.BmsRedlineProduct; +import com.bms.price.domain.BmsRedlineProductCountry; +import com.bms.price.domain.BmsRedlineProductImportTask; + +/** + * redline product list service + * + * @author bms + */ +public interface IBmsRedlineProductService +{ + public BmsRedlineProduct selectBmsRedlineProductById(Long productId); + + public List selectBmsRedlineProductList(BmsRedlineProduct product); + + public int insertBmsRedlineProduct(BmsRedlineProduct product); + + public int updateBmsRedlineProduct(BmsRedlineProduct product); + + public int updateBmsRedlineProductCountry(BmsRedlineProductCountry country); + + public int deleteBmsRedlineProductByIds(Long[] productIds); + + public List selectCountryListByProductCode(String productCode); + + public List selectCountryListByProductCode(String productCode, Date effectiveDate); + + public List selectCountryListByProductCode(String productCode, Date effectiveDate, Long productId); + + public String importProductExcel(MultipartFile file, boolean updateSupport, String operName, Date effectiveDate) throws Exception; + + public String importCountryProductExcel(MultipartFile file, String countryCode, String operName, Date effectiveDate) throws Exception; + + /** Submit async all-country import; returns taskId for polling getImportTask. */ + public String submitImportProductExcelAsync(MultipartFile file, boolean updateSupport, String operName, Date effectiveDate) throws Exception; + + /** Submit async single-country import; returns taskId for polling getImportTask. */ + public String submitImportCountryProductExcelAsync(MultipartFile file, String countryCode, String operName, Date effectiveDate) throws Exception; + + /** Poll Redis progress for product import task. */ + public BmsRedlineProductImportTask getImportTask(String taskId); + + /** Sync import from bytes (used by async runner; taskId may be null). */ + public String importProductExcel(byte[] bytes, String filename, boolean updateSupport, String operName, String taskId, Date effectiveDate) throws Exception; + + /** Sync country import from bytes (used by async runner; taskId may be null). */ + public String importCountryProductExcel(byte[] bytes, String filename, String countryCode, String operName, String taskId, Date effectiveDate) throws Exception; + + public java.util.Map> parseQuoteExcelByProduct(MultipartFile file) throws Exception; + + public java.util.Map> parseQuoteExcelByProduct(byte[] bytes, String filename) throws Exception; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcPriceExcelFileService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcPriceExcelFileService.java new file mode 100644 index 00000000..c985c228 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcPriceExcelFileService.java @@ -0,0 +1,27 @@ +package com.bms.price.service; + +import java.util.Date; +import java.util.List; +import com.bms.price.domain.PrcPriceExcelFile; + +/** + * Import quote Excel attachment service + */ +public interface IPrcPriceExcelFileService +{ + public PrcPriceExcelFile selectPrcPriceExcelFileById(Long pefId); + + /** Import Excel attachments linked to this product_code via product.pef_id */ + public List selectByProductCode(String productCode); + + List selectByRedlineProductCode(String productCode); + + /** + * Persist uploaded Excel under upload/price/excel and insert metadata row. + * + * @param excelType PRODUCT / REDLINE / SALES + * @return inserted row with pefId + */ + public PrcPriceExcelFile saveImportExcel(String excelType, Date effectiveTime, byte[] bytes, + String filename, String operName) throws Exception; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcSurchargeRelService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcSurchargeRelService.java index 67803851..a9d742de 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcSurchargeRelService.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcSurchargeRelService.java @@ -13,6 +13,8 @@ public interface IPrcSurchargeRelService /** 查询某产品已关联的附加费(含附加费与明细展示字段) */ public List selectRelByProductCode(String productCode); + public List selectRelByProductCode(String productCode, String priceType); + public List selectPrcSurchargeRelList(PrcSurchargeRel rel); public int insertPrcSurchargeRel(PrcSurchargeRel rel); diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsProductServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsProductServiceImpl.java index f3a085d5..f0904e82 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsProductServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsProductServiceImpl.java @@ -41,7 +41,9 @@ import com.bms.price.domain.PrcZone; import com.bms.price.mapper.BmsProductCountryMapper; import com.bms.price.mapper.BmsProductMapper; import com.bms.price.mapper.PrcZoneMapper; +import com.bms.price.domain.PrcPriceExcelFile; import com.bms.price.service.IBmsProductService; +import com.bms.price.service.IPrcPriceExcelFileService; import com.bms.price.service.IPrcSurchargeRelService; import com.bms.price.task.BmsProductImportAsyncRunner; import com.bms.price.util.ZoneCodeAbbrevs; @@ -94,6 +96,9 @@ public class BmsProductServiceImpl implements IBmsProductService @Autowired private BmsProductImportAsyncRunner importAsyncRunner; + @Autowired + private IPrcPriceExcelFileService priceExcelFileService; + @Override public BmsProduct selectBmsProductById(Long productId) { @@ -309,6 +314,13 @@ public class BmsProductServiceImpl implements IBmsProductService } List euRegions = loadEuRegions(regionByNameCn); Map zoneByCountryAndCode = loadZoneByCountryAndCode(); + // Pre-check: same product cannot have duplicate country + weight band in Excel + assertImportProductsNoDupCountryWeight(workbook, products, regionByNameCn, euRegions, + zoneByCountryAndCode); + + PrcPriceExcelFile excelFile = priceExcelFileService.saveImportExcel( + PrcPriceExcelFile.TYPE_PRODUCT, forcedEff, bytes, filename, operName); + Long pefId = excelFile != null ? excelFile.getPefId() : null; int successNum = 0; int skipNum = 0; @@ -328,6 +340,7 @@ public class BmsProductServiceImpl implements IBmsProductService try { product.setEffectiveDate(forcedEff); + product.setPefId(pefId); int[] nums = importOneProduct(workbook, product, operName, regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries, forcedEff); transactionManager.commit(tx); @@ -428,6 +441,10 @@ public class BmsProductServiceImpl implements IBmsProductService // Pre-check all products: reject whole import before any write if any is earlier than prev version assertImportProductsEffectiveNotBeforePrev(products, forcedEff); + PrcPriceExcelFile excelFile = priceExcelFileService.saveImportExcel( + PrcPriceExcelFile.TYPE_PRODUCT, forcedEff, bytes, filename, operName); + Long pefId = excelFile != null ? excelFile.getPefId() : null; + int successNum = 0; int skipNum = 0; int countryNum = 0; @@ -455,11 +472,13 @@ public class BmsProductServiceImpl implements IBmsProductService product.setDestRegion(countryLabel); product.setSourceSheet(block.sheetName); product.setEffectiveDate(forcedEff); + product.setPefId(pefId); if (StringUtils.isEmpty(product.getTaxMode())) { product.setTaxMode(resolveTaxMode(product.getProductName())); } List countries = stampSelectedCountry(block.rows, region, forcedEff); + assertNoDuplicateCountryWeightRows(product, countries); int[] nums = persistImportedProduct(product, countries, operName); transactionManager.commit(tx); successNum += nums[0]; @@ -597,6 +616,7 @@ public class BmsProductServiceImpl implements IBmsProductService { continue; } + assertNoDuplicateCountryWeightRows(product, rows); String code = product.getProductCode().trim().toUpperCase(Locale.ROOT); out.put(code, rows); } @@ -632,6 +652,7 @@ public class BmsProductServiceImpl implements IBmsProductService throw new ServiceException(MessageUtils.message("price.product.import.noCountry", productSheet.getSheetName())); } + assertNoDuplicateCountryWeightRows(product, countries); // UI-selected effective date wins over Excel; previous open product/country rows expire at this date Date newEff = requireImportEffectiveDate(forcedEffectiveDate != null ? forcedEffectiveDate : product.getEffectiveDate()); @@ -643,6 +664,7 @@ public class BmsProductServiceImpl implements IBmsProductService if (existing != null) { product.setProductId(existing.getProductId()); + patchProductPefId(product, operName); int countryRows = saveCountries(product, countries); return new int[] { 0, 1, countryRows }; } @@ -666,6 +688,19 @@ public class BmsProductServiceImpl implements IBmsProductService return new int[] { 1, 0, countryRows }; } + private void patchProductPefId(BmsProduct product, String operName) + { + if (product == null || product.getProductId() == null || product.getPefId() == null) + { + return; + } + BmsProduct pefPatch = new BmsProduct(); + pefPatch.setProductId(product.getProductId()); + pefPatch.setPefId(product.getPefId()); + pefPatch.setUpdateBy(operName); + productMapper.updateBmsProduct(pefPatch); + } + /** * Save country rows by effective_date. @@ -1988,6 +2023,134 @@ public class BmsProductServiceImpl implements IBmsProductService } } + /** + * Workbook pre-check for full import: reject before any write when a product sheet + * has duplicate country + weight band (same zone) rows. + */ + private void assertImportProductsNoDupCountryWeight(Workbook workbook, List products, + Map regionByNameCn, List euRegions, + Map zoneByCountryAndCode) + { + if (workbook == null || products == null || products.isEmpty()) + { + return; + } + List tips = new ArrayList(); + Set unmatchedCountries = new HashSet(); + for (BmsProduct product : products) + { + if (product == null || StringUtils.isEmpty(product.getProductCode())) + { + continue; + } + Sheet productSheet = findProductSheet(workbook, product); + if (productSheet == null) + { + continue; + } + List rows = parseProductSheet(productSheet, product, + regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries); + String tip = buildDuplicateCountryWeightTip(product, rows); + if (StringUtils.isNotEmpty(tip)) + { + tips.add(tip); + } + } + if (!tips.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.dupCountry", + String.join("
", tips))); + } + } + + /** Fail when Excel rows for one product contain duplicate country + weight band. */ + private void assertNoDuplicateCountryWeightRows(BmsProduct product, List rows) + { + String tip = buildDuplicateCountryWeightTip(product, rows); + if (StringUtils.isNotEmpty(tip)) + { + throw new ServiceException(tip); + } + } + + private String buildDuplicateCountryWeightTip(BmsProduct product, List rows) + { + List dups = findDuplicateCountryWeightLabels(rows); + if (dups.isEmpty()) + { + return null; + } + String code = product == null || StringUtils.isEmpty(product.getProductCode()) + ? "-" : product.getProductCode(); + String name = product == null || StringUtils.isEmpty(product.getProductName()) + ? "-" : product.getProductName(); + return MessageUtils.message("price.product.import.dupCountry.item", + code, name, String.join("\u3001", dups)); + } + + private List findDuplicateCountryWeightLabels(List rows) + { + List dups = new ArrayList(); + if (rows == null || rows.isEmpty()) + { + return dups; + } + Map counts = new LinkedHashMap(); + Map labels = new LinkedHashMap(); + for (BmsProductCountry c : rows) + { + if (c == null) + { + continue; + } + String key = countryWeightDupKey(c); + Integer n = counts.get(key); + counts.put(key, n == null ? 1 : n.intValue() + 1); + if (!labels.containsKey(key)) + { + labels.put(key, formatCountryWeightTip(c)); + } + } + for (Map.Entry e : counts.entrySet()) + { + if (e.getValue() != null && e.getValue().intValue() > 1) + { + dups.add(labels.get(e.getKey())); + } + } + return dups; + } + + /** Align with uk_product_country_zone_weight_eff (country + zone + weight min/max). */ + private String countryWeightDupKey(BmsProductCountry c) + { + String country = StringUtils.isNotEmpty(c.getCountryCode()) + ? c.getCountryCode().trim().toUpperCase(Locale.ROOT) + : (c.getCountryName() == null ? "" : c.getCountryName().trim()); + String zone = c.getZoneCode() == null ? "" : c.getZoneCode().trim().toUpperCase(Locale.ROOT); + return country + "|" + zone + "|" + decimalKey(c.getWeightMin()) + "|" + decimalKey(c.getWeightMax()); + } + + private String formatCountryWeightTip(BmsProductCountry c) + { + String name = StringUtils.isNotEmpty(c.getCountryName()) ? c.getCountryName() + : (StringUtils.isNotEmpty(c.getCountryCode()) ? c.getCountryCode() : "-"); + if (StringUtils.isNotEmpty(c.getZoneCode())) + { + name = name + "(" + c.getZoneCode() + ")"; + } + return name + " " + decimalKey(c.getWeightMin()) + "~" + decimalKey(c.getWeightMax()); + } + + private String decimalKey(BigDecimal v) + { + if (v == null) + { + return ""; + } + return v.stripTrailingZeros().toPlainString(); + } + /** Stamp product + all country detail rows with the same effective date. */ private void applyForcedEffectiveDate(BmsProduct product, List countries, Date newEff) { @@ -2025,6 +2188,7 @@ public class BmsProductServiceImpl implements IBmsProductService if (existing != null) { product.setProductId(existing.getProductId()); + patchProductPefId(product, operName); int countryRows = saveCountries(product, countries); return new int[] { 0, 1, countryRows }; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsRedlineProductServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsRedlineProductServiceImpl.java new file mode 100644 index 00000000..a4eafd0a --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/BmsRedlineProductServiceImpl.java @@ -0,0 +1,2290 @@ +package com.bms.price.service.impl; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.web.multipart.MultipartFile; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.MessageUtils; +import com.bms.common.utils.StringUtils; +import com.bms.common.utils.uuid.IdUtils; +import com.bms.price.domain.BmsRedlineProduct; +import com.bms.price.domain.BmsRedlineProductCountry; +import com.bms.price.domain.BmsRedlineProductImportTask; +import com.bms.price.domain.PrcZone; +import com.bms.price.domain.PrcSurchargeRel; +import com.bms.price.mapper.BmsRedlineProductCountryMapper; +import com.bms.price.mapper.BmsRedlineProductMapper; +import com.bms.price.mapper.PrcSurchargeRelMapper; +import com.bms.price.mapper.PrcZoneMapper; +import com.bms.price.domain.PrcPriceExcelFile; +import com.bms.price.service.IBmsRedlineProductService; +import com.bms.price.service.IPrcPriceExcelFileService; +import com.bms.price.task.BmsRedlineProductImportAsyncRunner; +import com.bms.price.util.ZoneCodeAbbrevs; +import com.bms.system.domain.BmsRegion; +import com.bms.system.mapper.BmsRegionMapper; + +/** + * Product service with Weishi Excel import (catalog + country weight bands) + * + * @author bms + */ +@Service +public class BmsRedlineProductServiceImpl implements IBmsRedlineProductService +{ + private static final String CATALOG_SHEET = "\u4ea7\u54c1\u76ee\u5f55"; + private static final Set SKIP_SHEETS = new HashSet<>(Arrays.asList( + CATALOG_SHEET, "\u9000\u4ef6\u91cd\u6d3e", "\u7533\u62a5\u8bf4\u660e", "\u8d54\u507f\u6807\u51c6")); + + 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 selectBmsRedlineProductList(BmsRedlineProduct product) + { + productMapper.refreshStatusByDates(); + countryMapper.refreshStatusByDates(); + if (product != null) + { + product.setStatus(normalizeStatusCode(product.getStatus())); + product.setEffectStatus(normalizeStatusCode(product.getEffectStatus())); + } + return productMapper.selectBmsRedlineProductList(product); + } + + @Override + public int insertBmsRedlineProduct(BmsRedlineProduct product) + { + if (StringUtils.isEmpty(product.getTaxMode())) + { + product.setTaxMode(resolveTaxMode(product.getProductName())); + } + if (product.getEffectiveDate() == null) + { + throw new ServiceException(MessageUtils.message("price.product.effectiveDate.empty")); + } + applyProductStatus(product); + assertCodeDateUnique(product, null); + int rows = productMapper.insertBmsRedlineProduct(product); + productMapper.expireOpenByProductCode(product.getProductCode(), product.getEffectiveDate(), product.getProductId()); + productMapper.refreshStatusByDates(); + return rows; + } + + @Override + public int updateBmsRedlineProduct(BmsRedlineProduct product) + { + if (product.getEffectiveDate() == null) + { + throw new ServiceException(MessageUtils.message("price.product.effectiveDate.empty")); + } + applyProductStatus(product); + assertCodeDateUnique(product, product.getProductId()); + return productMapper.updateBmsRedlineProduct(product); + } + + @Override + public int updateBmsRedlineProductCountry(BmsRedlineProductCountry country) + { + if (country == null || country.getDetailId() == null) + { + throw new ServiceException("明细行主键缺失,无法修改"); + } + int rows = countryMapper.updateBmsRedlineProductCountry(country); + if (rows > 0) + { + // 生效/失效日期可能变化,状态按日期重算 + countryMapper.refreshStatusByDates(); + } + return rows; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteBmsRedlineProductByIds(Long[] productIds) + { + if (productIds == null || productIds.length == 0) + { + return 0; + } + List relProductIds = new ArrayList(); + for (Long id : productIds) + { + BmsRedlineProduct p = productMapper.selectBmsRedlineProductById(id); + if (p == null || StringUtils.isEmpty(p.getProductCode())) + { + continue; + } + if (p.getProductId() != null) + { + countryMapper.deleteByProductId(p.getProductId()); + relProductIds.add(p.getProductId()); + } + countryMapper.deleteByProductCodeAndEffectiveDate(p.getProductCode(), p.getEffectiveDate()); + } + if (!relProductIds.isEmpty()) + { + // REDLINE rels bind redline_product.product_id (not shared with product catalog) + surchargeRelMapper.deleteByProductIdsAndPriceType(relProductIds, PrcSurchargeRel.PRICE_TYPE_REDLINE); + } + return productMapper.deleteBmsRedlineProductByIds(productIds); + } + + @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) + { + BmsRedlineProductCountry byId = new BmsRedlineProductCountry(); + byId.setProductId(productId); + List list = countryMapper.selectBmsRedlineProductCountryList(byId); + if (!list.isEmpty()) + { + return list; + } + } + BmsRedlineProductCountry query = new BmsRedlineProductCountry(); + query.setProductCode(productCode); + query.setEffectiveDate(effectiveDate); + return countryMapper.selectBmsRedlineProductCountryList(query); + } + + private void assertCodeDateUnique(BmsRedlineProduct product, Long excludeId) + { + if (product == null || StringUtils.isEmpty(product.getProductCode())) + { + return; + } + BmsRedlineProduct dup = productMapper.selectBmsRedlineProductByCodeAndDate(product.getProductCode(), product.getEffectiveDate()); + if (dup != null && (excludeId == null || !excludeId.equals(dup.getProductId()))) + { + throw new ServiceException(MessageUtils.message("price.product.codeDate.exists")); + } + } + + @Override + public String importProductExcel(MultipartFile file, boolean updateSupport, String operName, Date effectiveDate) + throws Exception + { + validateImportFile(file); + return importProductExcel(file.getBytes(), file.getOriginalFilename(), updateSupport, operName, null, + effectiveDate); + } + + @Override + public String submitImportProductExcelAsync(MultipartFile file, boolean updateSupport, String operName, + Date effectiveDate) throws Exception + { + validateImportFile(file); + Date eff = requireImportEffectiveDate(effectiveDate); + byte[] bytes = file.getBytes(); + String filename = file.getOriginalFilename(); + String taskId = IdUtils.fastSimpleUUID(); + importAsyncRunner.put(taskId, BmsRedlineProductImportTask.STATUS_PENDING, 0, "queued", null); + importAsyncRunner.runImportAll(taskId, bytes, filename, updateSupport, operName, eff); + return taskId; + } + + @Override + public BmsRedlineProductImportTask getImportTask(String taskId) + { + if (StringUtils.isEmpty(taskId) || importAsyncRunner == null) + { + return null; + } + return importAsyncRunner.get(taskId); + } + + @Override + public String importProductExcel(byte[] bytes, String filename, boolean updateSupport, String operName, + String taskId, Date effectiveDate) throws Exception + { + validateImportBytes(bytes, filename); + Date forcedEff = requireImportEffectiveDate(effectiveDate); + reportImportProgress(taskId, 10, "parsing excel"); + + try (InputStream is = new java.io.ByteArrayInputStream(bytes); Workbook workbook = WorkbookFactory.create(is)) + { + Sheet catalog = workbook.getSheet(CATALOG_SHEET); + if (catalog == null) + { + throw new ServiceException(MessageUtils.message("price.product.import.sheetNotFound")); + } + + List products = parseCatalog(catalog); + if (products.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.noData")); + } + products = dedupeCatalogProducts(products); + reportImportProgress(taskId, 20, "parsing excel"); + // Pre-check all products: reject whole import before any write if any is earlier than prev version + assertImportProductsEffectiveNotBeforePrev(products, forcedEff); + + Map regionByNameCn = loadRegionByNameCn(); + if (regionByNameCn.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.regionEmpty")); + } + List euRegions = loadEuRegions(regionByNameCn); + Map zoneByCountryAndCode = loadZoneByCountryAndCode(); + // Pre-check: same product cannot have duplicate country + weight band in Excel + assertImportProductsNoDupCountryWeight(workbook, products, regionByNameCn, euRegions, + zoneByCountryAndCode); + + PrcPriceExcelFile excelFile = priceExcelFileService.saveImportExcel( + PrcPriceExcelFile.TYPE_REDLINE, forcedEff, bytes, filename, operName); + Long pefId = excelFile != null ? excelFile.getPefId() : null; + + int successNum = 0; + int skipNum = 0; + int countryNum = 0; + Set unmatchedCountries = new HashSet<>(); + StringBuilder failureMsg = new StringBuilder(); + int total = products.size(); + int index = 0; + + for (BmsRedlineProduct product : products) + { + index++; + reportImportProductProgress(taskId, index, total, product.getProductCode()); + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + TransactionStatus tx = transactionManager.getTransaction(def); + try + { + product.setEffectiveDate(forcedEff); + product.setPefId(pefId); + int[] nums = importOneProduct(workbook, product, operName, regionByNameCn, euRegions, + zoneByCountryAndCode, unmatchedCountries, forcedEff); + transactionManager.commit(tx); + successNum += nums[0]; + skipNum += nums[1]; + countryNum += nums[2]; + } + catch (Exception e) + { + transactionManager.rollback(tx); + String tip = e.getMessage(); + if (StringUtils.isEmpty(tip)) + { + tip = e.getClass().getSimpleName(); + } + failureMsg.append("
").append(product.getProductCode()).append("\uff1a").append(tip); + } + } + + reportImportProgress(taskId, 95, "finishing"); + if (failureMsg.length() > 0) + { + // Do not throw: each product already committed/rolled back independently + return MessageUtils.message("price.product.import.partialFail", + Integer.valueOf(successNum), Integer.valueOf(skipNum), failureMsg.toString()); + } + 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 += "
" + MessageUtils.message("price.product.import.success.unmatched", String.join("\u3001", unmatchedCountries)); + } + return msg; + } + } + + @Override + public String importCountryProductExcel(MultipartFile file, String countryCode, String operName, Date effectiveDate) + throws Exception + { + validateImportFile(file); + return importCountryProductExcel(file.getBytes(), file.getOriginalFilename(), countryCode, operName, null, + effectiveDate); + } + + @Override + public String submitImportCountryProductExcelAsync(MultipartFile file, String countryCode, String operName, + Date effectiveDate) throws Exception + { + validateImportFile(file); + if (StringUtils.isEmpty(countryCode)) + { + throw new ServiceException(MessageUtils.message("price.product.import.countryRequired")); + } + Date eff = requireImportEffectiveDate(effectiveDate); + byte[] bytes = file.getBytes(); + String filename = file.getOriginalFilename(); + String taskId = IdUtils.fastSimpleUUID(); + importAsyncRunner.put(taskId, BmsRedlineProductImportTask.STATUS_PENDING, 0, "queued", null); + importAsyncRunner.runImportCountry(taskId, bytes, filename, countryCode, operName, eff); + return taskId; + } + + @Override + public String importCountryProductExcel(byte[] bytes, String filename, String countryCode, String operName, + String taskId, Date effectiveDate) throws Exception + { + validateImportBytes(bytes, filename); + Date forcedEff = requireImportEffectiveDate(effectiveDate); + if (StringUtils.isEmpty(countryCode)) + { + throw new ServiceException(MessageUtils.message("price.product.import.countryRequired")); + } + BmsRegion region = regionMapper.selectRegionByCode2(countryCode.trim().toUpperCase(Locale.ROOT)); + if (region == null || "2".equals(region.getDelFlag()) + || (StringUtils.isNotEmpty(region.getStatus()) && !"0".equals(region.getStatus()))) + { + throw new ServiceException(MessageUtils.message("price.product.import.countryNotFound", countryCode)); + } + reportImportProgress(taskId, 10, "parsing excel"); + + try (InputStream is = new java.io.ByteArrayInputStream(bytes); Workbook workbook = WorkbookFactory.create(is)) + { + Sheet catalog = workbook.getSheet(CATALOG_SHEET); + if (catalog == null) + { + throw new ServiceException(MessageUtils.message("price.product.import.countrySheetNotFound")); + } + List products = parseCountryCatalog(catalog); + if (products.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.noData")); + } + products = dedupeCatalogProducts(products); + Map priceBlocks = parseCountryPriceBlocks(workbook); + reportImportProgress(taskId, 20, "parsing excel"); + // Pre-check all products: reject whole import before any write if any is earlier than prev version + assertImportProductsEffectiveNotBeforePrev(products, forcedEff); + + PrcPriceExcelFile excelFile = priceExcelFileService.saveImportExcel( + PrcPriceExcelFile.TYPE_REDLINE, forcedEff, bytes, filename, operName); + Long pefId = excelFile != null ? excelFile.getPefId() : null; + + int successNum = 0; + int skipNum = 0; + int countryNum = 0; + StringBuilder failureMsg = new StringBuilder(); + String countryLabel = StringUtils.isNotEmpty(region.getCountryNameCn()) + ? region.getCountryNameCn() : region.getCountryCode2(); + int total = products.size(); + int index = 0; + + for (BmsRedlineProduct product : products) + { + index++; + reportImportProductProgress(taskId, index, total, product.getProductCode()); + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + TransactionStatus tx = transactionManager.getTransaction(def); + try + { + PriceBlock block = findPriceBlock(product.getProductName(), priceBlocks); + if (block == null || block.rows.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.countryNoPrice", + product.getProductName())); + } + product.setDestRegion(countryLabel); + product.setSourceSheet(block.sheetName); + product.setEffectiveDate(forcedEff); + product.setPefId(pefId); + if (StringUtils.isEmpty(product.getTaxMode())) + { + product.setTaxMode(resolveTaxMode(product.getProductName())); + } + List countries = stampSelectedCountry(block.rows, region, forcedEff); + assertNoDuplicateCountryWeightRows(product, countries); + int[] nums = persistImportedProduct(product, countries, operName); + transactionManager.commit(tx); + successNum += nums[0]; + skipNum += nums[1]; + countryNum += nums[2]; + } + catch (Exception e) + { + transactionManager.rollback(tx); + String tip = e.getMessage(); + if (StringUtils.isEmpty(tip)) + { + tip = e.getClass().getSimpleName(); + } + failureMsg.append("
").append(product.getProductCode()).append("\uff1a").append(tip); + } + } + + reportImportProgress(taskId, 95, "finishing"); + if (failureMsg.length() > 0) + { + return MessageUtils.message("price.product.import.partialFail", + Integer.valueOf(successNum), Integer.valueOf(skipNum), failureMsg.toString()); + } + return MessageUtils.message("price.product.import.countrySuccess", + countryLabel, Integer.valueOf(successNum), Integer.valueOf(skipNum), Integer.valueOf(countryNum)); + } + } + + private void validateImportFile(MultipartFile file) + { + if (file == null || file.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty")); + } + validateImportFilename(file.getOriginalFilename()); + } + + private void validateImportBytes(byte[] bytes, String filename) + { + if (bytes == null || bytes.length == 0) + { + throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty")); + } + validateImportFilename(filename); + } + + private void validateImportFilename(String filename) + { + if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx") + || filename.toLowerCase(Locale.ROOT).endsWith(".xls"))) + { + throw new ServiceException(MessageUtils.message("price.product.import.fileType")); + } + } + + private void reportImportProductProgress(String taskId, int index, int total, String productCode) + { + if (total <= 0) + { + reportImportProgress(taskId, 50, "importing"); + return; + } + int percent = 20 + (int) Math.floor(70.0 * index / total); + if (percent > 90) + { + percent = 90; + } + String code = StringUtils.isEmpty(productCode) ? "-" : productCode; + reportImportProgress(taskId, percent, "importing " + code + " (" + index + "/" + total + ")"); + } + + private void reportImportProgress(String taskId, int percent, String message) + { + if (StringUtils.isEmpty(taskId) || importAsyncRunner == null) + { + return; + } + importAsyncRunner.put(taskId, BmsRedlineProductImportTask.STATUS_RUNNING, percent, message, null); + } + + @Override + public Map> parseQuoteExcelByProduct(MultipartFile file) throws Exception + { + if (file == null || file.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty")); + } + return parseQuoteExcelByProduct(file.getBytes(), file.getOriginalFilename()); + } + + @Override + public Map> parseQuoteExcelByProduct(byte[] bytes, String filename) throws Exception + { + if (bytes == null || bytes.length == 0) + { + throw new ServiceException(MessageUtils.message("price.product.import.fileEmpty")); + } + if (filename == null || !(filename.toLowerCase(Locale.ROOT).endsWith(".xlsx") + || filename.toLowerCase(Locale.ROOT).endsWith(".xls"))) + { + throw new ServiceException(MessageUtils.message("price.product.import.fileType")); + } + try (InputStream is = new java.io.ByteArrayInputStream(bytes); Workbook workbook = WorkbookFactory.create(is)) + { + Sheet catalog = workbook.getSheet(CATALOG_SHEET); + if (catalog == null) + { + throw new ServiceException(MessageUtils.message("price.product.import.sheetNotFound")); + } + List products = dedupeCatalogProducts(parseCatalog(catalog)); + if (products.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.noData")); + } + Map regionByNameCn = loadRegionByNameCn(); + List euRegions = loadEuRegions(regionByNameCn); + Map zoneByCountryAndCode = loadZoneByCountryAndCode(); + Set unmatchedCountries = new HashSet<>(); + Map> out = new LinkedHashMap<>(); + for (BmsRedlineProduct product : products) + { + if (product == null || StringUtils.isEmpty(product.getProductCode())) + { + continue; + } + Sheet productSheet = findProductSheet(workbook, product); + if (productSheet == null) + { + continue; + } + List rows = parseProductSheet(productSheet, product, + regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries); + if (rows == null || rows.isEmpty()) + { + continue; + } + assertNoDuplicateCountryWeightRows(product, rows); + String code = product.getProductCode().trim().toUpperCase(Locale.ROOT); + out.put(code, rows); + } + if (out.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.set.import.noPriceRows")); + } + return out; + } + } + + + /** @return int[]{added, skipped, countryRows} */ + private int[] importOneProduct(Workbook workbook, BmsRedlineProduct product, String operName, + Map regionByNameCn, List euRegions, + Map zoneByCountryAndCode, Set unmatchedCountries, Date forcedEffectiveDate) + { + product.setSourceSheet(product.getProductName()); + product.setCreateBy(operName); + product.setUpdateBy(operName); + + Sheet productSheet = findProductSheet(workbook, product); + if (productSheet == null) + { + throw new ServiceException(MessageUtils.message("price.product.import.noSheet", + product.getProductName())); + } + product.setSourceSheet(productSheet.getSheetName()); + List countries = parseProductSheet(productSheet, product, + regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries); + if (countries == null || countries.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.noCountry", + productSheet.getSheetName())); + } + assertNoDuplicateCountryWeightRows(product, countries); + // UI-selected effective date wins over Excel; previous open product/country rows expire at this date + Date newEff = requireImportEffectiveDate(forcedEffectiveDate != null ? forcedEffectiveDate + : product.getEffectiveDate()); + applyForcedEffectiveDate(product, countries, newEff); + assertImportEffectiveNotBeforePrev(product, newEff); + BmsRedlineProduct existing = productMapper.selectBmsRedlineProductByCodeAndDate( + product.getProductCode(), newEff); + + if (existing != null) + { + product.setProductId(existing.getProductId()); + patchProductPefId(product, operName); + int countryRows = saveCountries(product, countries); + return new int[] { 0, 1, countryRows }; + } + + applyProductStatus(product); + try + { + productMapper.insertBmsRedlineProduct(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(); + int countryRows = saveCountries(product, countries); + return new int[] { 1, 0, countryRows }; + } + + private void patchProductPefId(BmsRedlineProduct product, String operName) + { + if (product == null || product.getProductId() == null || product.getPefId() == null) + { + return; + } + BmsRedlineProduct pefPatch = new BmsRedlineProduct(); + pefPatch.setProductId(product.getProductId()); + pefPatch.setPefId(product.getPefId()); + pefPatch.setUpdateBy(operName); + productMapper.updateBmsRedlineProduct(pefPatch); + } + + + /** + * Save country rows by effective_date. + * For each product_code + country in the Excel: + * - expire all earlier open weight bands under that country + * - delete same-day open weight bands (unique key blocks insert if only expired) + * - insert Excel weight bands only (no copy / merge of old bands) + * Reject when newEff is earlier than the product's previous version effective time. + */ + private int saveCountries(BmsRedlineProduct product, List countries) + { + if (product == null || countries == null || countries.isEmpty()) + { + return 0; + } + String productCode = product.getProductCode(); + Long productId = product.getProductId(); + Date newEff = normalizeToHour(product.getEffectiveDate()); + if (newEff == null) + { + newEff = normalizeToHour(resolveImportEffectiveDate(countries)); + } + if (newEff == null) + { + throw new ServiceException(MessageUtils.message("price.product.import.effRequired")); + } + assertImportEffectiveNotBeforePrev(product, newEff); + + Set replacedCountries = new HashSet<>(); + String rowStatus = resolveStoredStatus(newEff, null); + int touched = 0; + for (BmsRedlineProductCountry incoming : countries) + { + if (incoming == null) + { + continue; + } + String countryKey = countryIdentityKey(incoming); + if (StringUtils.isNotEmpty(countryKey) && !replacedCountries.contains(countryKey)) + { + countryMapper.expireOpenByProductCodeAndCountry( + productCode, incoming.getCountryCode(), incoming.getCountryName(), newEff); + countryMapper.deleteOpenByProductCodeAndCountryOnDate( + productCode, incoming.getCountryCode(), incoming.getCountryName(), newEff); + replacedCountries.add(countryKey); + } + + incoming.setProductId(productId); + incoming.setProductCode(productCode); + incoming.setEffectiveDate(newEff); + incoming.setExpireDate(null); + incoming.setStatus(rowStatus); + try + { + countryMapper.insertBmsRedlineProductCountry(incoming); + touched++; + } + catch (Exception e) + { + String tip = e.getMessage() == null ? "" : e.getMessage(); + if (tip.contains("freight_price") || tip.contains("registration_fee") || tip.contains("Unknown column")) + { + throw new ServiceException(MessageUtils.message("price.product.import.needFeeAlter")); + } + if (tip.contains("expire_date") || tip.contains("uk_product_country_weight") + || tip.contains("uk_product_country_zone_weight")) + { + throw new ServiceException(MessageUtils.message("price.product.import.needExpireAlter")); + } + throw e; + } + } + countryMapper.refreshStatusByDates(); + return touched; + } + + /** Identity for replace scope: prefer country_code, else country_name. */ + private String countryIdentityKey(BmsRedlineProductCountry row) + { + if (row == null) + { + return ""; + } + if (StringUtils.isNotEmpty(row.getCountryCode())) + { + return "C:" + row.getCountryCode().trim().toUpperCase(Locale.ROOT); + } + if (StringUtils.isNotEmpty(row.getCountryName())) + { + return "N:" + row.getCountryName().trim(); + } + return ""; + } + + private Date resolveImportEffectiveDate(List countries) + { + Date max = null; + for (BmsRedlineProductCountry c : countries) + { + if (c.getEffectiveDate() == null) + { + continue; + } + if (max == null || c.getEffectiveDate().after(max)) + { + max = c.getEffectiveDate(); + } + } + return max; + } + + private String formatDate(Date d) + { + if (d == null) + { + return "-"; + } + return new SimpleDateFormat("yyyy-MM-dd").format(d); + } + + private String formatDateTime(Date d) + { + if (d == null) + { + return "-"; + } + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").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; + } + } + + /** Truncate to hour precision (mm:ss = 00). */ + private Date normalizeToHour(Date d) + { + if (d == null) + { + return null; + } + try + { + String s = new SimpleDateFormat("yyyy-MM-dd HH").format(d) + ":00:00"; + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(s); + } + catch (Exception e) + { + return d; + } + } + + /** 0 pending / 1 active / 2 expired from datetime vs now */ + private String resolveStoredStatus(Date effectiveDate, Date expireDate) + { + Date now = new Date(); + Date expire = expireDate; + Date eff = effectiveDate; + if (expire != null && !expire.after(now)) + { + return BmsRedlineProduct.STATUS_EXPIRED; + } + if (eff == null || eff.after(now)) + { + return BmsRedlineProduct.STATUS_PENDING; + } + return BmsRedlineProduct.STATUS_ACTIVE; + } + + private void applyProductStatus(BmsRedlineProduct 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) || BmsRedlineProduct.STATUS_ACTIVE.equals(status)) + { + return BmsRedlineProduct.STATUS_ACTIVE; + } + if ("PENDING".equalsIgnoreCase(status) || BmsRedlineProduct.STATUS_PENDING.equals(status)) + { + return BmsRedlineProduct.STATUS_PENDING; + } + if ("EXPIRED".equalsIgnoreCase(status) || BmsRedlineProduct.STATUS_EXPIRED.equals(status)) + { + return BmsRedlineProduct.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) + { + BmsRedlineProduct p = new BmsRedlineProduct(); + p.setProductCode(productCode); + saveCountries(p, countries); + } + + private List parseCatalog(Sheet sheet) + { + List list = new ArrayList<>(); + // Header is "\u5bf9\u63a5\u4ee3\u7801" or legacy "\u4ea7\u54c1\u5bf9\u63a5\u4ee3\u7801". Column A is often empty. + // 20260928 layout inserts "\u8ba1\u6ce1\u89c4\u5219" before country, so map by header name. + int[] header = findHeaderCell(sheet, "\u5bf9\u63a5\u4ee3\u7801"); + if (header == null) + { + return list; + } + int headerRowIdx = header[0]; + int colCode = header[1]; + Row head = sheet.getRow(headerRowIdx); + int colName = catalogColumn(head, "\u4ea7\u54c1\u540d\u79f0", colCode + 1); + int colLevel = catalogColumn(head, "\u65f6\u6548", colCode + 2); + int colCargo = catalogColumn(head, "\u8d70\u8d27\u5c5e\u6027", colCode + 3); + int colDest = findColByHeader(head, "\u56fd\u5bb6", colCode); + if (colDest < 0) + { + colDest = findColByHeader(head, "\u76ee\u7684", colCode); + } + if (colDest < 0) + { + colDest = colCode + 4; + } + int colNote = catalogColumn(head, "\u66f4\u65b0\u5185\u5bb9", colCode + 5); + int colDate = catalogColumn(head, "\u751f\u6548", colCode + 6); + int lastRow = sheet.getLastRowNum(); + for (int i = headerRowIdx + 1; i <= lastRow; i++) + { + Row row = sheet.getRow(i); + if (row == null) + { + continue; + } + String code = cellString(row.getCell(colCode)); + if (StringUtils.isEmpty(code) || code.contains("\u5bf9\u63a5\u4ee3\u7801")) + { + continue; + } + if (!code.matches("(?i)^[A-Z0-9_-]+$")) + { + continue; + } + BmsRedlineProduct p = new BmsRedlineProduct(); + p.setProductCode(code.trim()); + p.setProductName(cellString(row.getCell(colName))); + p.setServiceLevel(cellString(row.getCell(colLevel))); + p.setCargoAttr(cellString(row.getCell(colCargo))); + p.setDestRegion(cellString(row.getCell(colDest))); + p.setUpdateNote(cellString(row.getCell(colNote))); + p.setEffectiveDate(cellDate(row.getCell(colDate))); + p.setTaxMode(resolveTaxMode(p.getProductName())); + if (StringUtils.isEmpty(p.getProductName())) + { + continue; + } + list.add(p); + } + return list; + } + + /** Header column, or the legacy fixed offset when the title is absent. */ + private static int catalogColumn(Row headerRow, String keyword, int fallback) + { + int col = findColByHeader(headerRow, keyword, 0); + return col >= 0 ? col : fallback; + } + + /** Keep first row per product_code (catalog often repeats code across country rows). */ + private List dedupeCatalogProducts(List products) + { + Map map = new HashMap<>(); + for (BmsRedlineProduct p : products) + { + if (p == null || StringUtils.isEmpty(p.getProductCode())) + { + continue; + } + String code = p.getProductCode().trim().toUpperCase(Locale.ROOT); + if (!map.containsKey(code)) + { + map.put(code, p); + } + } + return new ArrayList<>(map.values()); + } + + private Sheet findProductSheet(Workbook workbook, BmsRedlineProduct product) + { + if (workbook == null || product == null) + { + return null; + } + String wantName = product.getProductName(); + String wantCode = product.getProductCode(); + if (StringUtils.isNotEmpty(wantName)) + { + Sheet exact = workbook.getSheet(wantName.trim()); + if (exact != null) + { + return exact; + } + } + String nameKey = normalizeSheetKey(wantName); + String codeKey = normalizeSheetKey(wantCode); + Sheet best = null; + int bestScore = 0; + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String name = workbook.getSheetName(i); + if (name == null || SKIP_SHEETS.contains(name)) + { + continue; + } + String key = normalizeSheetKey(name); + if (StringUtils.isEmpty(key)) + { + continue; + } + int score = 0; + if (StringUtils.isNotEmpty(nameKey) && key.equals(nameKey)) + { + return workbook.getSheetAt(i); + } + if (StringUtils.isNotEmpty(nameKey) && key.contains(nameKey)) + { + score = 100; + } + else if (StringUtils.isNotEmpty(nameKey) && nameKey.contains(key) && key.length() >= 4) + { + score = 80; + } + else if (StringUtils.isNotEmpty(codeKey) && key.contains(codeKey)) + { + score = 60; + } + if (score > bestScore) + { + bestScore = score; + best = workbook.getSheetAt(i); + } + } + return bestScore >= 60 ? best : null; + } + + private static String normalizeSheetKey(String s) + { + if (s == null) + { + return ""; + } + String out = s.trim().toLowerCase(Locale.ROOT); + // unify dash variants: - \u2013 \u2014 \uff0d + out = out.replace('\u2013', '-').replace('\u2014', '-').replace('\uff0d', '-'); + out = out.replace(" ", "").replace("\u3000", ""); + return out; + } + + private List loadEuRegions(Map regionByNameCn) + { + List list = new ArrayList<>(); + if (regionByNameCn == null) + { + return list; + } + Set seen = new HashSet<>(); + for (BmsRegion region : regionByNameCn.values()) + { + if (region == null || !"1".equals(region.getIsEu())) + { + continue; + } + if (StringUtils.isEmpty(region.getCountryNameCn()) || StringUtils.isEmpty(region.getCountryCode2())) + { + continue; + } + String code = region.getCountryCode2().toUpperCase(Locale.ROOT); + if (seen.contains(code)) + { + continue; + } + seen.add(code); + list.add(region); + } + return list; + } + + private static boolean isEuZoneName(String name) + { + if (StringUtils.isEmpty(name)) + { + return false; + } + String n = name.trim(); + return "\u6b27\u76df".equals(n) + || "EU".equalsIgnoreCase(n) + || "\u6b27\u6d32\u8054\u76df".equals(n) + || "\u6b27\u76df\u56fd\u5bb6".equals(n); + } + + private List parseProductSheet(Sheet sheet, BmsRedlineProduct product, + Map regionByNameCn, List euRegions, + Map zoneByCountryAndCode, Set unmatchedCountries) + { + if (sheet == null) + { + return new ArrayList<>(); + } + + Date sheetEffective = parseSheetEffectiveDate(sheet); + List list = new ArrayList<>(); + int[] countryHeader = findHeaderCell(sheet, "\u56fd\u5bb6"); + if (countryHeader == null) + { + return list; + } + int headerRowIdx = countryHeader[0]; + Row headerRow = sheet.getRow(headerRowIdx); + int colCountry = countryHeader[1]; + int colWeight = findColByHeader(headerRow, "\u91cd\u91cf", colCountry + 1); + int colFreight = findColByHeader(headerRow, "\u8fd0\u8d39", colCountry + 1); + int colRegister = findColByHeader(headerRow, "\u6302\u53f7", colCountry + 1); + int colSize = findColByHeader(headerRow, "\u5c3a\u5bf8", colCountry + 1); + int colLastMile = findColByHeader(headerRow, "\u5c3e\u7a0b", colCountry + 1); + int colLeadTime = findColByHeader(headerRow, "\u65f6\u6548", colCountry + 1); + if (colWeight < 0) + { + colWeight = colCountry + 1; + } + if (colFreight < 0) + { + colFreight = colCountry + 2; + } + if (colRegister < 0) + { + colRegister = colCountry + 3; + } + if (colSize < 0) + { + colSize = colCountry + 4; + } + if (colLastMile < 0) + { + colLastMile = colCountry + 5; + } + if (colLeadTime < 0) + { + colLeadTime = colCountry + 6; + } + + String currentCountryRaw = null; + String currentSize = null; + String currentLastMile = null; + String currentLeadTime = null; + int lastRow = sheet.getLastRowNum(); + for (int i = headerRowIdx + 1; i <= lastRow; i++) + { + Row row = sheet.getRow(i); + if (row == null) + { + continue; + } + String countryCell = cellString(row.getCell(colCountry)); + String weightLabel = cellString(row.getCell(colWeight)); + if ("\u56fd\u5bb6".equals(countryCell) || weightLabel.contains("\u91cd\u91cf\u6bb5")) + { + continue; + } + if (StringUtils.isNotEmpty(countryCell)) + { + currentCountryRaw = countryCell; + currentSize = emptyToNull(cellString(row.getCell(colSize))); + currentLastMile = emptyToNull(cellString(row.getCell(colLastMile))); + currentLeadTime = emptyToNull(cellString(row.getCell(colLeadTime))); + } + if (StringUtils.isEmpty(currentCountryRaw)) + { + continue; + } + BigDecimal[] range = parseWeightRange(weightLabel); + if (range == null) + { + continue; + } + BigDecimal freight = cellDecimal(row.getCell(colFreight)); + BigDecimal registration = cellDecimal(row.getCell(colRegister)); + Date eff = sheetEffective != null ? sheetEffective : product.getEffectiveDate(); + + ZoneCodeAbbrevs.CountryZoneParts parts = ZoneCodeAbbrevs.splitCountryZone(currentCountryRaw); + if (parts == null || StringUtils.isEmpty(parts.countryName)) + { + continue; + } + String baseCountry = parts.countryName; + String zoneLabel = parts.zoneLabel; + + BmsRegion region = resolveRegion(baseCountry, regionByNameCn); + if (region != null) + { + list.add(buildCountryRow(region.getCountryNameCn(), region.getCountryCode2(), + zoneLabel, zoneByCountryAndCode, + range, freight, registration, currentSize, currentLastMile, currentLeadTime, eff)); + continue; + } + if (isEuZoneName(baseCountry) && StringUtils.isEmpty(zoneLabel)) + { + if (euRegions == null || euRegions.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.euEmpty")); + } + for (BmsRegion eu : euRegions) + { + list.add(buildCountryRow(eu.getCountryNameCn(), eu.getCountryCode2(), + null, zoneByCountryAndCode, + range, freight, registration, currentSize, currentLastMile, currentLeadTime, eff)); + } + continue; + } + unmatchedCountries.add(currentCountryRaw.trim().replace("\n", " ").replace("\r", " ")); + list.add(buildCountryRow(baseCountry, null, zoneLabel, zoneByCountryAndCode, + range, freight, registration, currentSize, currentLastMile, currentLeadTime, eff)); + } + return list; + } + + private BmsRedlineProductCountry buildCountryRow(String countryName, String countryCode, String zoneLabel, + Map zoneByCountryAndCode, BigDecimal[] range, + BigDecimal freight, BigDecimal registration, String sizeLimit, String lastMile, String leadTime, Date eff) + { + BmsRedlineProductCountry c = new BmsRedlineProductCountry(); + c.setCountryName(countryName); + c.setCountryCode(countryCode); + applyZoneFields(c, countryCode, zoneLabel, zoneByCountryAndCode); + c.setWeightMin(range[0]); + c.setWeightMax(range[1]); + c.setFreightPrice(freight); + c.setRegistrationFee(registration); + c.setSizeLimit(sizeLimit); + c.setLastMile(lastMile); + c.setLeadTime(leadTime); + c.setEffectiveDate(eff); + return c; + } + + private void applyZoneFields(BmsRedlineProductCountry c, String countryCode, String zoneLabel, + Map zoneByCountryAndCode) + { + if (StringUtils.isEmpty(zoneLabel)) + { + c.setZoneId(null); + c.setZoneCode(null); + c.setZoneName(null); + return; + } + String zoneCode = ZoneCodeAbbrevs.toAbbrev(zoneLabel); + c.setZoneCode(zoneCode); + PrcZone zone = null; + if (StringUtils.isNotEmpty(countryCode) && zoneByCountryAndCode != null) + { + zone = zoneByCountryAndCode.get(zoneKey(countryCode, zoneCode)); + } + if (zone != null) + { + c.setZoneId(zone.getZoneId()); + c.setZoneCode(zone.getZoneCode()); + c.setZoneName(zone.getZoneName()); + } + else + { + c.setZoneId(null); + c.setZoneName(StringUtils.isNotEmpty(c.getCountryName()) + ? c.getCountryName() + "(" + zoneCode + ")" + : zoneCode); + } + } + + private Map loadZoneByCountryAndCode() + { + Map map = new HashMap<>(); + List list = zoneMapper.selectPrcZoneList(new PrcZone()); + if (list == null) + { + return map; + } + for (PrcZone zone : list) + { + if (zone == null || StringUtils.isEmpty(zone.getZoneCode())) + { + continue; + } + String code2 = zone.getCountryCode(); + if (StringUtils.isEmpty(code2)) + { + continue; + } + map.put(zoneKey(code2, zone.getZoneCode()), zone); + } + return map; + } + + private static String zoneKey(String countryCode, String zoneCode) + { + return String.valueOf(countryCode).trim().toUpperCase(Locale.ROOT) + + "|" + String.valueOf(zoneCode).trim().toUpperCase(Locale.ROOT); + } + + /** + * Load region map: country_name_cn -> region (for country_code_2). + */ + private Map loadRegionByNameCn() + { + BmsRegion query = new BmsRegion(); + query.setStatus("0"); + List regions = regionMapper.selectRegionList(query); + Map map = new HashMap<>(); + if (regions == null) + { + return map; + } + for (BmsRegion region : regions) + { + if (region == null || StringUtils.isEmpty(region.getCountryNameCn())) + { + continue; + } + map.put(region.getCountryNameCn().trim(), region); + } + return map; + } + + /** + * Exact country_name_cn match first; single contains match as fallback (e.g. Czech / Czech Republic). + */ + private BmsRegion resolveRegion(String excelName, Map regionByNameCn) + { + if (StringUtils.isEmpty(excelName) || regionByNameCn == null || regionByNameCn.isEmpty()) + { + return null; + } + String name = excelName.trim(); + BmsRegion exact = regionByNameCn.get(name); + if (exact != null) + { + return exact; + } + List candidates = new ArrayList<>(); + for (BmsRegion region : regionByNameCn.values()) + { + String cn = region.getCountryNameCn(); + if (StringUtils.isEmpty(cn)) + { + continue; + } + if (cn.contains(name) || name.contains(cn)) + { + candidates.add(region); + } + } + if (candidates.isEmpty()) + { + return null; + } + if (candidates.size() == 1) + { + return candidates.get(0); + } + BmsRegion best = candidates.get(0); + for (int i = 1; i < candidates.size(); i++) + { + String cn = candidates.get(i).getCountryNameCn(); + if (cn != null && cn.length() > best.getCountryNameCn().length()) + { + best = candidates.get(i); + } + } + return best; + } + + private BigDecimal[] parseWeightRange(String label) + { + if (StringUtils.isEmpty(label)) + { + return null; + } + Matcher m = WEIGHT_RANGE_PATTERN.matcher(label.replace(" ", "")); + if (!m.find()) + { + return null; + } + try + { + BigDecimal min = new BigDecimal(m.group(1)); + BigDecimal max = new BigDecimal(m.group(2)); + if (min.compareTo(max) > 0) + { + BigDecimal tmp = min; + min = max; + max = tmp; + } + return new BigDecimal[] { min, max }; + } + catch (Exception e) + { + return null; + } + } + + private Date parseSheetEffectiveDate(Sheet sheet) + { + int lastRow = Math.min(sheet.getLastRowNum(), 4); + for (int r = 0; r <= lastRow; r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + int lastCell = Math.max(row.getLastCellNum(), 12); + lastCell = Math.min(lastCell, 20); + for (int c = 0; c < lastCell; c++) + { + Date parsed = parseEffectiveDateText(cellString(row.getCell(c))); + if (parsed != 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; + } + + private static String resolveTaxMode(String productName) + { + if (productName != null && productName.contains("\u5305\u7a0e")) + { + return "TAX_INCLUDED"; + } + if (productName != null && productName.contains("\u4e0d\u5305\u7a0e")) + { + return "TAX_EXCLUDED"; + } + return "TAX_INCLUDED"; + } + + /** + * Find column index on header row whose text contains keyword; -1 if not found. + */ + private static int findColByHeader(Row headerRow, String keyword, int fromCol) + { + if (headerRow == null || StringUtils.isEmpty(keyword)) + { + return -1; + } + int last = Math.max(headerRow.getLastCellNum(), fromCol + 12); + last = Math.min(last, 30); + for (int c = Math.max(fromCol, 0); c < last; c++) + { + String v = cellString(headerRow.getCell(c)); + if (StringUtils.isNotEmpty(v) && v.contains(keyword)) + { + return c; + } + } + return -1; + } + + private static String emptyToNull(String s) + { + return StringUtils.isEmpty(s) ? null : s; + } + + /** + * Scan top-left area for a header cell; returns {rowIndex, colIndex} or null. + * Prefers exact match, then contains. + */ + private static int[] findHeaderCell(Sheet sheet, String headerText) + { + if (sheet == null || StringUtils.isEmpty(headerText)) + { + return null; + } + int lastRow = Math.min(sheet.getLastRowNum(), 30); + int[] containsHit = null; + for (int r = 0; r <= lastRow; r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + short lastCell = row.getLastCellNum(); + if (lastCell < 0) + { + continue; + } + // Also scan a few leading empty columns beyond lastCellNum edge cases + int maxCol = Math.max(lastCell, 12); + maxCol = Math.min(maxCol, 20); + for (int c = 0; c < maxCol; c++) + { + String v = cellString(row.getCell(c)); + if (StringUtils.isEmpty(v)) + { + continue; + } + if (headerText.equals(v)) + { + return new int[] { r, c }; + } + if (containsHit == null && v.contains(headerText)) + { + containsHit = new int[] { r, c }; + } + } + } + return containsHit; + } + + private static String cellString(Cell cell) + { + if (cell == null) + { + return ""; + } + CellType type = cell.getCellType(); + if (type == CellType.FORMULA) + { + type = cell.getCachedFormulaResultType(); + } + switch (type) + { + case STRING: + return cell.getStringCellValue() == null ? "" : cell.getStringCellValue().trim(); + case NUMERIC: + if (DateUtil.isCellDateFormatted(cell)) + { + Date d = cell.getDateCellValue(); + return d == null ? "" : new SimpleDateFormat("yyyy-MM-dd").format(d); + } + double num = cell.getNumericCellValue(); + if (Math.floor(num) == num) + { + return String.valueOf((long) num); + } + return String.valueOf(num); + case BOOLEAN: + return String.valueOf(cell.getBooleanCellValue()); + case BLANK: + return ""; + default: + return ""; + } + } + + private static BigDecimal cellDecimal(Cell cell) + { + if (cell == null) + { + return null; + } + CellType type = cell.getCellType(); + if (type == CellType.FORMULA) + { + type = cell.getCachedFormulaResultType(); + } + try + { + if (type == CellType.NUMERIC) + { + return BigDecimal.valueOf(cell.getNumericCellValue()); + } + String s = cellString(cell); + if (StringUtils.isEmpty(s)) + { + return null; + } + s = s.trim(); + // Excel NA markers: / 、- 、— 、N/A + if ("/".equals(s) || "-".equals(s) || "\u2014".equals(s) || "\uff0d".equals(s) + || "N/A".equalsIgnoreCase(s) || "NA".equalsIgnoreCase(s)) + { + return null; + } + // strip currency / unit suffixes: 56、56元、56 RMB + s = s.replace(",", "").replace("\uffe5", "").replace("\u5143", "") + .replace("RMB", "").replace("rmb", "").trim(); + if (s.isEmpty() || "/".equals(s)) + { + return null; + } + int cut = -1; + for (int i = 0; i < s.length(); i++) + { + char ch = s.charAt(i); + if (!(ch == '.' || ch == '-' || (ch >= '0' && ch <= '9'))) + { + cut = i; + break; + } + } + if (cut > 0) + { + s = s.substring(0, cut).trim(); + } + return new BigDecimal(s); + } + catch (Exception ignored) + { + return null; + } + } + + /** + * Country cost sheet catalog: product id / name / service / cargo / note / update date. + * Destination country is not in the sheet. + */ + private List parseCountryCatalog(Sheet sheet) + { + List list = new ArrayList<>(); + int[] header = findHeaderCell(sheet, "\u4ea7\u54c1\u540d\u79f0"); + if (header == null) + { + header = findHeaderCell(sheet, "\u4ea7\u54c1ID"); + } + if (header == null) + { + return list; + } + Row head = sheet.getRow(header[0]); + int colCode = firstCol(head, "\u4ea7\u54c1ID", "\u4ea7\u54c1\u5bf9\u63a5\u4ee3\u7801", "\u4ea7\u54c1\u4ee3\u7801"); + int colName = findColByHeader(head, "\u4ea7\u54c1\u540d\u79f0", 0); + int colService = firstCol(head, "\u670d\u52a1", "\u65f6\u6548\u7b49\u7ea7"); + int colCargo = findColByHeader(head, "\u8d70\u8d27\u5c5e\u6027", 0); + int colNote = firstCol(head, "\u5907\u6ce8", "\u66f4\u65b0\u5185\u5bb9"); + int colDate = firstCol(head, "\u66f4\u65b0\u65f6\u95f4", "\u751f\u6548\u65e5\u671f"); + if (colCode < 0 || colName < 0) + { + return list; + } + int lastRow = sheet.getLastRowNum(); + for (int i = header[0] + 1; i <= lastRow; i++) + { + Row row = sheet.getRow(i); + if (row == null) + { + continue; + } + String code = cellString(row.getCell(colCode)); + if (StringUtils.isEmpty(code) || !code.matches("(?i)^[A-Z0-9_-]+$")) + { + continue; + } + String name = cellString(row.getCell(colName)); + if (StringUtils.isEmpty(name)) + { + continue; + } + BmsRedlineProduct p = new BmsRedlineProduct(); + p.setProductCode(code.trim()); + p.setProductName(name.trim()); + if (colService >= 0) + { + p.setServiceLevel(emptyToNull(cellString(row.getCell(colService)))); + } + if (colCargo >= 0) + { + p.setCargoAttr(emptyToNull(cellString(row.getCell(colCargo)))); + } + if (colNote >= 0) + { + p.setUpdateNote(emptyToNull(cellString(row.getCell(colNote)))); + } + if (colDate >= 0) + { + Date eff = cellDate(row.getCell(colDate)); + if (eff == null) + { + eff = parseLooseDate(cellString(row.getCell(colDate))); + } + p.setEffectiveDate(toDateOnly(eff)); + } + p.setTaxMode(resolveTaxMode(p.getProductName())); + list.add(p); + } + return list; + } + + private Map parseCountryPriceBlocks(Workbook workbook) + { + Map map = new HashMap<>(); + if (workbook == null) + { + return map; + } + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String sheetName = workbook.getSheetName(i); + if (shouldSkipCountryPriceSheet(sheetName)) + { + continue; + } + Sheet sheet = workbook.getSheetAt(i); + int[] header = findHeaderCell(sheet, "\u4ea7\u54c1\u540d\u79f0"); + if (header == null) + { + continue; + } + Row headerRow = sheet.getRow(header[0]); + int colName = header[1]; + int colWeight = findColByHeader(headerRow, "\u91cd\u91cf", 0); + if (colWeight < 0) + { + continue; + } + int colFreight = findColByHeader(headerRow, "\u8fd0\u8d39", 0); + int colRegister = findColByHeader(headerRow, "\u6302\u53f7", 0); + int colSize = findColByHeader(headerRow, "\u5c3a\u5bf8", 0); + int colLastMile = findColByHeader(headerRow, "\u5c3e\u7a0b", 0); + int colLeadTime = findColByHeader(headerRow, "\u65f6\u6548", 0); + + String currentName = null; + String currentSize = null; + String currentLastMile = null; + String currentLeadTime = null; + int lastRow = sheet.getLastRowNum(); + for (int r = header[0] + 1; r <= lastRow; r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + String nameCell = cellString(row.getCell(colName)); + if (StringUtils.isNotEmpty(nameCell)) + { + if (isSectionTitle(nameCell)) + { + currentName = null; + continue; + } + currentName = nameCell.replace("\n", " ").replace("\r", " ").trim(); + currentSize = colSize >= 0 ? emptyToNull(cellString(row.getCell(colSize))) : null; + currentLastMile = colLastMile >= 0 ? emptyToNull(cellString(row.getCell(colLastMile))) : null; + currentLeadTime = colLeadTime >= 0 ? emptyToNull(cellString(row.getCell(colLeadTime))) : null; + } + if (StringUtils.isEmpty(currentName)) + { + continue; + } + String weightLabel = cellString(row.getCell(colWeight)); + BigDecimal[] range = parseWeightRange(weightLabel); + if (range == null) + { + continue; + } + if (colSize >= 0 && StringUtils.isNotEmpty(cellString(row.getCell(colSize)))) + { + currentSize = emptyToNull(cellString(row.getCell(colSize))); + } + if (colLastMile >= 0 && StringUtils.isNotEmpty(cellString(row.getCell(colLastMile)))) + { + currentLastMile = emptyToNull(cellString(row.getCell(colLastMile))); + } + if (colLeadTime >= 0 && StringUtils.isNotEmpty(cellString(row.getCell(colLeadTime)))) + { + currentLeadTime = emptyToNull(cellString(row.getCell(colLeadTime))); + } + BmsRedlineProductCountry c = new BmsRedlineProductCountry(); + c.setWeightMin(range[0]); + c.setWeightMax(range[1]); + c.setFreightPrice(colFreight >= 0 ? cellDecimal(row.getCell(colFreight)) : null); + c.setRegistrationFee(colRegister >= 0 ? cellDecimal(row.getCell(colRegister)) : null); + c.setSizeLimit(currentSize); + c.setLastMile(currentLastMile); + c.setLeadTime(currentLeadTime); + PriceBlock block = map.get(currentName); + if (block == null) + { + block = new PriceBlock(); + block.sheetName = sheetName; + map.put(currentName, block); + } + block.rows.add(c); + } + } + return map; + } + + private static boolean shouldSkipCountryPriceSheet(String name) + { + if (StringUtils.isEmpty(name)) + { + return true; + } + if (CATALOG_SHEET.equals(name) || SKIP_SHEETS.contains(name)) + { + return true; + } + return name.contains("\u90ae\u7f16") || name.contains("\u8d54\u507f"); + } + + private static boolean isSectionTitle(String name) + { + if (StringUtils.isEmpty(name)) + { + return false; + } + String n = name.trim(); + return n.startsWith("\u4e00\u3001") || n.startsWith("\u4e8c\u3001") || n.startsWith("\u4e09\u3001") + || n.startsWith("\u56db\u3001") || n.startsWith("\u4e94\u3001") || n.startsWith("\u516d\u3001") + || n.startsWith("\u7279\u522b"); + } + + private PriceBlock findPriceBlock(String productName, Map blocks) + { + if (StringUtils.isEmpty(productName) || blocks == null || blocks.isEmpty()) + { + return null; + } + String name = productName.trim(); + PriceBlock exact = blocks.get(name); + if (exact != null) + { + return exact; + } + String stripped = name.replace("\u6807\u51c6", "").trim(); + exact = blocks.get(stripped); + if (exact != null) + { + return exact; + } + String want = compactName(name); + PriceBlock hit = null; + int hits = 0; + for (Map.Entry entry : blocks.entrySet()) + { + String key = compactName(entry.getKey()); + if (key.equals(want) || (want.contains(key) && key.length() >= 4) || (key.contains(want) && want.length() >= 4)) + { + hit = entry.getValue(); + hits++; + } + } + return hits == 1 ? hit : null; + } + + private static String compactName(String s) + { + if (s == null) + { + return ""; + } + return s.trim().replace(" ", "").replace("\u3000", "") + .replace("-", "").replace("\u2013", "").replace("\u2014", "").replace("\uff0d", ""); + } + + private List stampSelectedCountry(List rows, BmsRegion region, Date eff) + { + List out = new ArrayList<>(); + if (rows == null) + { + return out; + } + String countryName = region == null ? null : region.getCountryNameCn(); + String countryCode = region == null ? null : region.getCountryCode2(); + for (BmsRedlineProductCountry src : rows) + { + BmsRedlineProductCountry c = new BmsRedlineProductCountry(); + c.setCountryName(countryName); + c.setCountryCode(countryCode); + c.setWeightMin(src.getWeightMin()); + c.setWeightMax(src.getWeightMax()); + c.setFreightPrice(src.getFreightPrice()); + c.setRegistrationFee(src.getRegistrationFee()); + c.setSizeLimit(src.getSizeLimit()); + c.setLastMile(src.getLastMile()); + c.setLeadTime(src.getLeadTime()); + c.setEffectiveDate(eff); + out.add(c); + } + return out; + } + + /** Require import effective time (hour precision). */ + private Date requireImportEffectiveDate(Date effectiveDate) + { + Date d = normalizeToHour(effectiveDate); + if (d == null) + { + throw new ServiceException(MessageUtils.message("price.product.import.effRequired")); + } + return d; + } + + /** + * Reject import when selected effective time is earlier than the product's latest previous version. + * Same effective time (re-import that version) is allowed. + */ + private void assertImportEffectiveNotBeforePrev(BmsRedlineProduct product, Date newEff) + { + if (product == null || StringUtils.isEmpty(product.getProductCode()) || newEff == null) + { + return; + } + BmsRedlineProduct latest = productMapper.selectBmsRedlineProductByCode(product.getProductCode()); + if (latest == null || latest.getEffectiveDate() == null) + { + return; + } + Date prevEff = normalizeToHour(latest.getEffectiveDate()); + if (prevEff == null || !newEff.before(prevEff)) + { + return; + } + String code = product.getProductCode(); + String name = StringUtils.isNotEmpty(product.getProductName()) + ? product.getProductName() + : (StringUtils.isNotEmpty(latest.getProductName()) ? latest.getProductName() : "-"); + throw new ServiceException(MessageUtils.message("price.product.import.effTooEarly", + code, name, formatDateTime(newEff), formatDateTime(prevEff))); + } + + /** Batch pre-check: fail whole import before any DB write. */ + private void assertImportProductsEffectiveNotBeforePrev(List products, Date forcedEff) + { + Date newEff = requireImportEffectiveDate(forcedEff); + if (products == null || products.isEmpty()) + { + return; + } + for (BmsRedlineProduct product : products) + { + if (product == null) + { + continue; + } + product.setEffectiveDate(newEff); + assertImportEffectiveNotBeforePrev(product, newEff); + } + } + + /** + * Workbook pre-check for full import: reject before any write when a product sheet + * has duplicate country + weight band (same zone) rows. + */ + private void assertImportProductsNoDupCountryWeight(Workbook workbook, List products, + Map regionByNameCn, List euRegions, + Map zoneByCountryAndCode) + { + if (workbook == null || products == null || products.isEmpty()) + { + return; + } + List tips = new ArrayList(); + Set unmatchedCountries = new HashSet(); + for (BmsRedlineProduct product : products) + { + if (product == null || StringUtils.isEmpty(product.getProductCode())) + { + continue; + } + Sheet productSheet = findProductSheet(workbook, product); + if (productSheet == null) + { + continue; + } + List rows = parseProductSheet(productSheet, product, + regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries); + String tip = buildDuplicateCountryWeightTip(product, rows); + if (StringUtils.isNotEmpty(tip)) + { + tips.add(tip); + } + } + if (!tips.isEmpty()) + { + throw new ServiceException(MessageUtils.message("price.product.import.dupCountry", + String.join("
", tips))); + } + } + + /** Fail when Excel rows for one product contain duplicate country + weight band. */ + private void assertNoDuplicateCountryWeightRows(BmsRedlineProduct product, List rows) + { + String tip = buildDuplicateCountryWeightTip(product, rows); + if (StringUtils.isNotEmpty(tip)) + { + throw new ServiceException(tip); + } + } + + private String buildDuplicateCountryWeightTip(BmsRedlineProduct product, List rows) + { + List dups = findDuplicateCountryWeightLabels(rows); + if (dups.isEmpty()) + { + return null; + } + String code = product == null || StringUtils.isEmpty(product.getProductCode()) + ? "-" : product.getProductCode(); + String name = product == null || StringUtils.isEmpty(product.getProductName()) + ? "-" : product.getProductName(); + return MessageUtils.message("price.product.import.dupCountry.item", + code, name, String.join("\u3001", dups)); + } + + private List findDuplicateCountryWeightLabels(List rows) + { + List dups = new ArrayList(); + if (rows == null || rows.isEmpty()) + { + return dups; + } + Map counts = new LinkedHashMap(); + Map labels = new LinkedHashMap(); + for (BmsRedlineProductCountry c : rows) + { + if (c == null) + { + continue; + } + String key = countryWeightDupKey(c); + Integer n = counts.get(key); + counts.put(key, n == null ? 1 : n.intValue() + 1); + if (!labels.containsKey(key)) + { + labels.put(key, formatCountryWeightTip(c)); + } + } + for (Map.Entry e : counts.entrySet()) + { + if (e.getValue() != null && e.getValue().intValue() > 1) + { + dups.add(labels.get(e.getKey())); + } + } + return dups; + } + + /** Align with uk_product_country_zone_weight_eff (country + zone + weight min/max). */ + private String countryWeightDupKey(BmsRedlineProductCountry c) + { + String country = StringUtils.isNotEmpty(c.getCountryCode()) + ? c.getCountryCode().trim().toUpperCase(Locale.ROOT) + : (c.getCountryName() == null ? "" : c.getCountryName().trim()); + String zone = c.getZoneCode() == null ? "" : c.getZoneCode().trim().toUpperCase(Locale.ROOT); + return country + "|" + zone + "|" + decimalKey(c.getWeightMin()) + "|" + decimalKey(c.getWeightMax()); + } + + private String formatCountryWeightTip(BmsRedlineProductCountry c) + { + String name = StringUtils.isNotEmpty(c.getCountryName()) ? c.getCountryName() + : (StringUtils.isNotEmpty(c.getCountryCode()) ? c.getCountryCode() : "-"); + if (StringUtils.isNotEmpty(c.getZoneCode())) + { + name = name + "(" + c.getZoneCode() + ")"; + } + return name + " " + decimalKey(c.getWeightMin()) + "~" + decimalKey(c.getWeightMax()); + } + + private String decimalKey(BigDecimal v) + { + if (v == null) + { + return ""; + } + return v.stripTrailingZeros().toPlainString(); + } + + /** Stamp product + all country detail rows with the same effective date. */ + private void applyForcedEffectiveDate(BmsRedlineProduct product, List countries, Date newEff) + { + Date eff = requireImportEffectiveDate(newEff); + if (product != null) + { + product.setEffectiveDate(eff); + } + if (countries == null) + { + return; + } + for (BmsRedlineProductCountry c : countries) + { + if (c != null) + { + c.setEffectiveDate(eff); + } + } + } + + /** @return int[]{added, skipped, countryRows} */ + private int[] persistImportedProduct(BmsRedlineProduct product, List countries, String operName) + { + product.setCreateBy(operName); + product.setUpdateBy(operName); + if (StringUtils.isEmpty(product.getSourceSheet())) + { + product.setSourceSheet(product.getProductName()); + } + Date newEff = requireImportEffectiveDate(product.getEffectiveDate()); + applyForcedEffectiveDate(product, countries, newEff); + assertImportEffectiveNotBeforePrev(product, newEff); + BmsRedlineProduct existing = productMapper.selectBmsRedlineProductByCodeAndDate(product.getProductCode(), newEff); + if (existing != null) + { + product.setProductId(existing.getProductId()); + patchProductPefId(product, operName); + int countryRows = saveCountries(product, countries); + return new int[] { 0, 1, countryRows }; + } + applyProductStatus(product); + try + { + productMapper.insertBmsRedlineProduct(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(); + int countryRows = saveCountries(product, countries); + return new int[] { 1, 0, countryRows }; + } + + private Date parseLooseDate(String text) + { + if (StringUtils.isEmpty(text)) + { + return null; + } + String s = text.trim(); + if (s.length() > 20) + { + return null; + } + Matcher ymd = EFFECTIVE_DATE_YMD.matcher(s); + if (ymd.matches()) + { + return parseYmd(ymd.group(1), ymd.group(2), ymd.group(3)); + } + return null; + } + + private static int firstCol(Row row, String... keywords) + { + if (keywords == null) + { + return -1; + } + for (String keyword : keywords) + { + int col = findColByHeader(row, keyword, 0); + if (col >= 0) + { + return col; + } + } + return -1; + } + + private static final class PriceBlock + { + private String sheetName; + private final List rows = new ArrayList<>(); + } + + private static Date cellDate(Cell cell) + { + if (cell == null) + { + return null; + } + CellType type = cell.getCellType(); + if (type == CellType.FORMULA) + { + type = cell.getCachedFormulaResultType(); + } + if (type == CellType.NUMERIC) + { + if (DateUtil.isCellDateFormatted(cell)) + { + return cell.getDateCellValue(); + } + return DateUtil.getJavaDate(cell.getNumericCellValue()); + } + if (type == CellType.STRING) + { + String s = cell.getStringCellValue(); + if (StringUtils.isEmpty(s)) + { + return null; + } + String[] patterns = { "yyyy-MM-dd", "yyyy/M/d", "yyyy/MM/dd", "yyyy-M-d", + "M/d/yy", "M/d/yyyy", "M/d/yy H:mm", "M/d/yyyy H:mm", "yyyy/M/d H:mm" }; + for (String p : patterns) + { + try + { + return new SimpleDateFormat(p).parse(s.trim()); + } + catch (Exception ignored) + { + } + } + } + return null; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java index 9ef6a685..e9bbc805 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java @@ -8,8 +8,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Arrays; import java.util.Date; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; @@ -1115,18 +1117,39 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService else { orderBillMapper.updatePrcOrderBill(bill); - incomeBillFeeMapper.deleteByBillId(bill.getBillId()); + // Keep manually added fees (manual_flag=1) across rebill + incomeBillFeeMapper.deleteAutoFeesByBillId(bill.getBillId()); // Payable not in scope yet: skip prc_pay_bill_fee cleanup/write. // payBillFeeMapper.deleteByBillId(bill.getBillId()); } PrcIncomeBill incomeBill = persistIncomeBill(bill, result); List fees = toFeeRows(bill.getBillId(), incomeBill == null ? null : incomeBill.getIncomeBillId(), result); + if (existed != null) + { + fees = filterAutoFeesAgainstManual(bill.getBillId(), fees); + } if (!fees.isEmpty()) { incomeBillFeeMapper.insertBatch(fees); } - orderBillMapper.refreshArBillStatusByBillIds(Arrays.asList(bill.getBillId())); + if (existed != null) + { + // Quote totals were written above; recalc so retained manual fees join income/credit + TmsPriceQuoteResult refreshed = refreshBillIncomeTotals(bill); + if (refreshed != null) + { + result.setSurchargeAmount(refreshed.getSurchargeAmount()); + result.setIncomeTotal(refreshed.getIncomeTotal()); + result.setPayableTotal(refreshed.getPayableTotal()); + result.setCreditArrearsStatus(refreshed.getCreditArrearsStatus()); + result.setFeeDetails(refreshed.getFeeDetails()); + } + } + else + { + orderBillMapper.refreshArBillStatusByBillIds(Arrays.asList(bill.getBillId())); + } // Payable not in scope yet: do not write prc_pay_bill / prc_pay_bill_fee. // PrcPayBill payBill = persistPayBill(bill, result); // List payFees = toPayFeeRows(bill.getBillId(), @@ -1141,6 +1164,53 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService billSearchStore.save(EsLogType.BILL, EsBizLogSupport.billBizId(request), requestJson, toResponseJson(result)); } + /** + * Drop auto fee lines that collide with retained manual rows on uk_bill_fee_item + * (bill_id, fee_type, sc_id). Manual fees win on rebill. + */ + private List filterAutoFeesAgainstManual(String billId, List autoFees) + { + if (autoFees == null || autoFees.isEmpty() || StringUtils.isEmpty(billId)) + { + return autoFees == null ? new ArrayList() : autoFees; + } + List existing = incomeBillFeeMapper.selectByBillId(billId); + if (existing == null || existing.isEmpty()) + { + return autoFees; + } + Set manualKeys = new HashSet(); + for (PrcIncomeBillFee row : existing) + { + if (row != null && "1".equals(row.getManualFlag())) + { + manualKeys.add(feeUniqueKey(row.getFeeType(), row.getScId())); + } + } + if (manualKeys.isEmpty()) + { + return autoFees; + } + List out = new ArrayList(); + for (PrcIncomeBillFee fee : autoFees) + { + if (fee == null) + { + continue; + } + if (!manualKeys.contains(feeUniqueKey(fee.getFeeType(), fee.getScId()))) + { + out.add(fee); + } + } + return out; + } + + private String feeUniqueKey(String feeType, Long scId) + { + return StringUtils.nvl(feeType, "") + "|" + (scId == null ? "0" : String.valueOf(scId)); + } + /** * Occupy/release credit by income total delta (bizType=ORDER). * Missing/disabled customer: skip occupy, return NORMAL. @@ -1347,7 +1417,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService return fees; } - /** 应收主表与订单计费一对一落库;销售价=套餐计费,产品价=产品目录价;红线价预留。 */ + /** 应收主表与订单计费一对一落库;销售价=套餐计费,产品价=产品目录价,红线价=红线目录价(含 REDLINE 附加费)。 */ private PrcIncomeBill persistIncomeBill(PrcOrderBill bill, TmsPriceQuoteResult result) { if (bill == null || StringUtils.isEmpty(bill.getBillId())) @@ -1364,8 +1434,9 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService row.setCreateBy(bill.getCreateBy()); } row.setCurrency(StringUtils.isEmpty(bill.getCurrency()) ? "CNY" : bill.getCurrency()); - // 产品价=目录价;红线价预留 + // product catalog + redline from quote row.setProductAmount(result == null ? null : result.getProductAmount()); + row.setRedlineAmount(result == null ? null : result.getRedlineAmount()); row.setSalesAmount(sales); row.setIncomeTotal(sales); row.setBelongSaler(bill.getBelongSaler()); @@ -1410,7 +1481,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService return fees; } - /** 应付主表与订单计费一对一落库;成本价/应付=产品目录价,同步写入 product_amount;红线价预留。 */ + /** 应付主表与订单计费一对一落库;成本价/应付=产品目录价,同步写入 product_amount;红线价同步写入 redline_amount。 */ private PrcPayBill persistPayBill(PrcOrderBill bill, TmsPriceQuoteResult result) { if (bill == null || StringUtils.isEmpty(bill.getBillId())) @@ -1427,9 +1498,10 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService row.setCreateBy(bill.getCreateBy()); } row.setCurrency(StringUtils.isEmpty(bill.getCurrency()) ? "CNY" : bill.getCurrency()); - // 产品价/成本价=目录价;红线价预留 + // product/cost = catalog; redline from quote BigDecimal product = result == null ? null : result.getProductAmount(); row.setProductAmount(product); + row.setRedlineAmount(result == null ? null : result.getRedlineAmount()); row.setCostAmount(pay); row.setPayTotal(pay); row.setBelongSaler(bill.getBelongSaler()); @@ -1493,6 +1565,12 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService r.setBizDate(bill.getBizDate()); r.setFeeDetails(toIncomeFeeLines(incomeFees)); r.setPayFeeDetails(toPayFeeLines(payFees)); + PrcIncomeBill incomeBill = incomeBillMapper.selectByBillId(bill.getBillId()); + if (incomeBill != null) + { + r.setProductAmount(incomeBill.getProductAmount()); + r.setRedlineAmount(incomeBill.getRedlineAmount()); + } return r; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceExcelFileServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceExcelFileServiceImpl.java new file mode 100644 index 00000000..212a2869 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceExcelFileServiceImpl.java @@ -0,0 +1,126 @@ +package com.bms.price.service.impl; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.IOUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.bms.common.config.BmsConfig; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.DateUtils; +import com.bms.common.utils.StringUtils; +import com.bms.common.utils.file.FileUploadUtils; +import com.bms.common.utils.uuid.IdUtils; +import com.bms.price.domain.PrcPriceExcelFile; +import com.bms.price.mapper.PrcPriceExcelFileMapper; +import com.bms.price.service.IPrcPriceExcelFileService; + +/** + * Persist import Excel bytes and metadata + */ +@Service +public class PrcPriceExcelFileServiceImpl implements IPrcPriceExcelFileService +{ + private static final String SUB_DIR = "price/excel"; + + @Autowired + private PrcPriceExcelFileMapper excelFileMapper; + + @Override + public PrcPriceExcelFile selectPrcPriceExcelFileById(Long pefId) + { + return excelFileMapper.selectPrcPriceExcelFileById(pefId); + } + + @Override + public List selectByProductCode(String productCode) + { + if (StringUtils.isEmpty(productCode)) + { + return Collections.emptyList(); + } + List list = excelFileMapper.selectByProductCode(productCode.trim()); + return list == null ? Collections.emptyList() : list; + } + + @Override + public List selectByRedlineProductCode(String productCode) + { + if (productCode == null || productCode.trim().isEmpty()) + { + return java.util.Collections.emptyList(); + } + List list = excelFileMapper.selectByRedlineProductCode(productCode.trim()); + return list == null ? java.util.Collections.emptyList() : list; + } + + @Override + public PrcPriceExcelFile saveImportExcel(String excelType, Date effectiveTime, byte[] bytes, + String filename, String operName) throws Exception + { + if (StringUtils.isEmpty(excelType)) + { + throw new ServiceException("excelType required"); + } + if (bytes == null || bytes.length == 0) + { + throw new ServiceException("excel empty"); + } + if (effectiveTime == null) + { + effectiveTime = new Date(); + } + String originalName = StringUtils.isNotEmpty(filename) ? filename : "import.xlsx"; + String ext = FilenameUtils.getExtension(originalName); + if (StringUtils.isEmpty(ext)) + { + ext = "xlsx"; + } + ext = ext.toLowerCase(Locale.ROOT); + + String uploadDir = BmsConfig.getUploadPath() + File.separator + SUB_DIR; + String pathName = DateUtils.datePath() + "/" + IdUtils.fastSimpleUUID() + "." + ext; + File dest = FileUploadUtils.getAbsoluteFile(uploadDir, pathName); + FileOutputStream fos = null; + try + { + fos = new FileOutputStream(dest); + fos.write(bytes); + } + finally + { + IOUtils.close(fos); + } + String storedPath = FileUploadUtils.getPathFileName(uploadDir, pathName); + + PrcPriceExcelFile row = new PrcPriceExcelFile(); + row.setExcelType(excelType.trim().toUpperCase(Locale.ROOT)); + row.setEffectiveTime(effectiveTime); + row.setFileName(originalName); + row.setFilePath(storedPath); + row.setFileSize(Long.valueOf(bytes.length)); + row.setContentType(resolveContentType(ext)); + row.setFileExtension(ext); + row.setCreateBy(operName); + excelFileMapper.insertPrcPriceExcelFile(row); + return row; + } + + private static String resolveContentType(String ext) + { + if ("xls".equals(ext)) + { + return "application/vnd.ms-excel"; + } + if ("xlsx".equals(ext)) + { + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } + return ""; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java index 55096802..b28ee9e3 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceSetServiceImpl.java @@ -22,17 +22,18 @@ import com.bms.common.exception.ServiceException; import com.bms.common.utils.DateUtils; import com.bms.common.utils.MessageUtils; import com.bms.common.utils.StringUtils; +import com.bms.common.utils.SecurityUtils; import com.bms.common.utils.uuid.IdUtils; import com.bms.price.domain.BmsProduct; import com.bms.price.domain.BmsProductCountry; import com.bms.price.domain.PrcCustomerPriceSet; +import com.bms.price.domain.PrcPriceExcelFile; import com.bms.price.domain.PrcPriceSet; import com.bms.price.domain.PrcPriceSetCheckItem; import com.bms.price.domain.PrcPriceSetDetail; import com.bms.price.domain.PrcPriceSetProduct; import com.bms.price.domain.PrcPriceSetSaveTask; import com.bms.price.domain.PrcPriceSetVersion; -import com.bms.price.domain.PrcPriceSetVersionSurcharge; import com.bms.price.domain.PrcSurcharge; import com.bms.price.domain.PrcSurchargeRel; import com.bms.price.mapper.BmsProductCountryMapper; @@ -42,10 +43,10 @@ import com.bms.price.mapper.PrcPriceSetDetailMapper; import com.bms.price.mapper.PrcPriceSetMapper; import com.bms.price.mapper.PrcPriceSetProductMapper; import com.bms.price.mapper.PrcPriceSetVersionMapper; -import com.bms.price.mapper.PrcPriceSetVersionSurchargeMapper; import com.bms.price.mapper.PrcSurchargeMapper; import com.bms.price.mapper.PrcSurchargeRelMapper; import com.bms.price.service.IBmsProductService; +import com.bms.price.service.IPrcPriceExcelFileService; import com.bms.price.service.IPrcPriceSetService; import com.bms.price.task.PrcPriceSetSaveAsyncRunner; @@ -88,9 +89,6 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService @Autowired private PrcPriceSetVersionMapper versionMapper; - @Autowired - private PrcPriceSetVersionSurchargeMapper versionSurchargeMapper; - @Autowired private PrcSurchargeRelMapper surchargeRelMapper; @@ -117,6 +115,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService @Autowired private PrcPriceSetSaveAsyncRunner saveAsyncRunner; + @Autowired + private IPrcPriceExcelFileService priceExcelFileService; + @Override public PrcPriceSet selectPrcPriceSetById(Long priceSetId) { @@ -174,10 +175,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { continue; } - // Always replace snapshot for this batch API (null would skip in updateDetail) + // Always replace SALES rels for this batch API (null would skip in updateDetail) if (detail.getSurcharges() == null) { - detail.setSurcharges(new ArrayList()); + detail.setSurcharges(new ArrayList()); } rows += updateDetail(detail); } @@ -468,7 +469,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService if (detailRows > 0) { setProductMapper.bindDetailPspByPsvId(nv.getPsvId()); - versionSurchargeMapper.copySurchargesFromVersion(sourcePsvId, nv.getPsvId(), createBy); + copySalesSurchargeRelsFromVersion(sourcePsvId, nv.getPsvId(), createBy); } refreshVersionStatus(set.getPriceSetId()); return nv.getPsvId(); @@ -521,7 +522,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService throw new ServiceException(MessageUtils.message("price.set.version.delete.statusNotAllowed")); } requireSet(version.getPriceSetId()); - versionSurchargeMapper.deleteByPsvId(psvId); + deleteSalesRelsByPsvId(psvId); detailMapper.deletePrcPriceSetDetailByPsvId(psvId); setProductMapper.deleteByPsvId(psvId); int rows = versionMapper.deletePrcPriceSetVersionByIds(new Long[] { psvId }); @@ -852,7 +853,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService return result; } - /** Attach version surcharge snapshots to a page of details (for drawer list). */ + /** Attach SALES surcharge rels (detail_id=psd_id) to a page of details. */ private void fillDetailSurcharges(List details) { if (details == null || details.isEmpty()) @@ -871,21 +872,22 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { return; } - List surcharges = versionSurchargeMapper.selectByPsdIds(psdIds); - Map> byPsd = new HashMap>(); + List surcharges = + surchargeRelMapper.selectByDetailIdsAndPriceType(psdIds, PrcSurchargeRel.PRICE_TYPE_SALES); + Map> byPsd = new HashMap>(); if (surcharges != null) { - for (PrcPriceSetVersionSurcharge sc : surcharges) + for (PrcSurchargeRel sc : surcharges) { - if (sc == null || sc.getPsdId() == null) + if (sc == null || sc.getDetailId() == null) { continue; } - List list = byPsd.get(sc.getPsdId()); + List list = byPsd.get(sc.getDetailId()); if (list == null) { - list = new ArrayList(); - byPsd.put(sc.getPsdId(), list); + list = new ArrayList(); + byPsd.put(sc.getDetailId(), list); } list.add(sc); } @@ -896,8 +898,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { continue; } - List list = byPsd.get(d.getPsdId()); - d.setSurcharges(list != null ? list : new ArrayList()); + List list = byPsd.get(d.getPsdId()); + d.setSurcharges(list != null ? list : new ArrayList()); } } @@ -910,9 +912,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService detail.setPsvId(resolvePsvId(detail.getPriceSetId(), detail.getPsvId())); detail.setPspId(lookupSetProductId(detail.getPsvId(), detail.getProductCode())); int rows = detailMapper.insertPrcPriceSetDetail(detail); - PrcPriceSetVersion ver = new PrcPriceSetVersion(); - ver.setPsvId(detail.getPsvId()); - snapshotVersionSurcharges(ver, java.util.Collections.singletonList(detail)); + if (detail.getSurcharges() != null) + { + replaceSalesSurchargeRels(java.util.Collections.singletonList(detail)); + } return rows; } @@ -1071,6 +1074,18 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { assertUpdateOnlyWeightBandsMatch(byProduct, detailIndex); } + String operName = null; + try + { + operName = SecurityUtils.getUsername(); + } + catch (Exception ignored) + { + operName = null; + } + PrcPriceExcelFile excelFile = priceExcelFileService.saveImportExcel( + PrcPriceExcelFile.TYPE_SALES, new Date(), bytes, filename, operName); + Long pefId = excelFile != null ? excelFile.getPefId() : null; Map productMap = new LinkedHashMap(); int updated = 0; @@ -1172,9 +1187,20 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService throw new ServiceException(MessageUtils.message("price.set.import.noDetailWritten")); } + if (pefId != null) + { + for (PrcPriceSetProduct sp : productMap.values()) + { + if (sp != null) + { + sp.setPefId(pefId); + } + } + } Map result = new HashMap(); result.put("products", new ArrayList(productMap.values())); result.put("details", details); + result.put("pefId", pefId); if (updateOnly) { result.put("message", MessageUtils.message("price.set.import.updateSuccess", @@ -1291,6 +1317,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { assertUpdateOnlyWeightBandsMatch(byProduct, detailIndex); } + PrcPriceSetVersion verForFile = versionMapper.selectPrcPriceSetVersionById(psvId); + Date excelEff = verForFile != null && verForFile.getEffectiveTime() != null + ? verForFile.getEffectiveTime() : new Date(); + PrcPriceExcelFile excelFile = priceExcelFileService.saveImportExcel( + PrcPriceExcelFile.TYPE_SALES, excelEff, bytes, filename, operName); + Long pefId = excelFile != null ? excelFile.getPefId() : null; int updated = 0; int inserted = 0; @@ -1321,7 +1353,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService Long pspId = null; if (!updateOnly) { - pspId = ensureSetProduct(priceSetId, psvId, productCode, productName); + pspId = ensureSetProduct(priceSetId, psvId, productCode, productName, pefId); } for (BmsProductCountry row : e.getValue()) { @@ -1385,6 +1417,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { setProductMapper.bindDetailPspByPsvId(psvId); } + if (pefId != null && (inserted > 0 || updated > 0)) + { + List codes = new ArrayList(byProduct.keySet()); + setProductMapper.updatePefIdByPsvId(psvId, pefId, codes); + } reportImportProgress(taskId, priceSetId, 95, "finishing"); if (inserted == 0 && updated == 0) { @@ -1779,13 +1816,10 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } detail.setPspId(pspId); int rows = detailMapper.updatePrcPriceSetDetail(detail); - // non-null surcharges = replace snapshot for this detail (empty list clears) + // non-null surcharges = replace SALES rels for this detail (empty list clears) if (detail.getSurcharges() != null) { - versionSurchargeMapper.deleteByPsdId(detail.getPsdId()); - PrcPriceSetVersion ver = new PrcPriceSetVersion(); - ver.setPsvId(detail.getPsvId()); - snapshotVersionSurcharges(ver, java.util.Collections.singletonList(detail)); + replaceSalesSurchargeRels(java.util.Collections.singletonList(detail)); } return rows; } @@ -1808,6 +1842,21 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService touchKeys.add(old.getPsvId() + "|" + old.getProductCode()); } } + if (psdIds != null && psdIds.length > 0) + { + List ids = new ArrayList(); + for (Long id : psdIds) + { + if (id != null) + { + ids.add(id); + } + } + if (!ids.isEmpty()) + { + surchargeRelMapper.deleteByDetailIdsAndPriceType(ids, PrcSurchargeRel.PRICE_TYPE_SALES); + } + } int rows = detailMapper.deletePrcPriceSetDetailByIds(psdIds); for (String key : touchKeys) { @@ -1824,6 +1873,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService List left = detailMapper.selectPrcPriceSetDetailList(q); if (left == null || left.isEmpty()) { + Long pspId = lookupSetProductId(psvId, productCode); + if (pspId != null) + { + surchargeRelMapper.deleteByProductIdsAndPriceType( + java.util.Collections.singletonList(pspId), PrcSurchargeRel.PRICE_TYPE_SALES); + } setProductMapper.deleteByPsvIdAndCode(psvId, productCode); } } @@ -2656,9 +2711,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService pq.setPsvId(version.getPsvId()); List products = setProductMapper.selectPrcPriceSetProductList(pq); version.setProducts(products == null ? new ArrayList() : products); - // details + surcharge snapshots are loaded on demand (edit price / detail drawer) + // details + SALES surcharge rels are loaded on demand (edit price / detail drawer) version.setDetails(new ArrayList()); - version.setSurcharges(new ArrayList()); } private PrcPriceSetVersion pickCurrentVersion(List versions) @@ -2733,6 +2787,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { version.setExpireTime(db.getExpireTime()); } + // Draft + build mode changed: wipe old product/detail/surcharge before mode switch + clearDraftVersionChildrenIfBuildModeChanged(db, version); versionMapper.updatePrcPriceSetVersion(version); } set.setVersion(version); @@ -2740,7 +2796,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { upsertVersionDetails(set, version, details); reportSaveProgress(set, 85, "saving surcharges"); - resnapshotSurchargesIfNeeded(version, details); + replaceSalesSurchargesIfNeeded(details); } else { @@ -2749,6 +2805,36 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService refreshVersionStatus(set.getPriceSetId()); } + /** + * Draft only: when priceBuildMode changes (PULL/IMPORT), delete this version's + * product / detail / SALES surcharge rel rows. Same mode or non-draft: no-op. + */ + private void clearDraftVersionChildrenIfBuildModeChanged(PrcPriceSetVersion db, PrcPriceSetVersion incoming) + { + if (db == null || incoming == null || db.getPsvId() == null) + { + return; + } + if (!VER_DRAFT.equals(db.getStatus())) + { + return; + } + String newMode = StringUtils.trim(incoming.getPriceBuildMode()); + if (StringUtils.isEmpty(newMode)) + { + return; + } + String oldMode = StringUtils.trim(db.getPriceBuildMode()); + if (newMode.equalsIgnoreCase(StringUtils.nvl(oldMode, ""))) + { + return; + } + Long psvId = db.getPsvId(); + deleteSalesRelsByPsvId(psvId); + detailMapper.deletePrcPriceSetDetailByPsvId(psvId); + setProductMapper.deleteByPsvId(psvId); + } + /** Prefer same effective_time, else draft, else first version of the set. */ private PrcPriceSetVersion findReusableVersion(Long priceSetId, Date effectiveTime) { @@ -2780,8 +2866,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } /** - * Bulk create details + surcharge snapshots via SQL joins (no per-product HTTP roundtrips). + * Bulk create details via SQL joins (no per-product HTTP roundtrips). * Idempotent: if this version already has details and forcePullLibrary!=Y, skip re-insert. + * SALES surcharge rels are not auto-created; configure via package surcharge picker. */ private void replaceVersionDetailsFromLibrary(PrcPriceSet set, PrcPriceSetVersion version) { @@ -2795,7 +2882,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService set.setDetails(new ArrayList()); return; } - versionSurchargeMapper.deleteByPsvId(version.getPsvId()); + deleteSalesRelsByPsvId(version.getPsvId()); detailMapper.deletePrcPriceSetDetailByPsvId(version.getPsvId()); setProductMapper.deleteByPsvId(version.getPsvId()); // clean legacy rows without psv_id under this set (would look like duplicates in UI) @@ -2829,8 +2916,6 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } setProductMapper.bindDetailPspByPsvId(version.getPsvId()); reportSaveProgress(set, 75, "saving details " + inserted + "/" + inserted); - reportSaveProgress(set, 85, "saving surcharges"); - versionSurchargeMapper.insertFromLibraryRels(version.getPsvId(), version.getCreateBy()); fillVersionChildren(version); set.setDetails(new ArrayList()); } @@ -2903,7 +2988,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService d.setPriceSetId(set.getPriceSetId()); d.setPsvId(version.getPsvId()); Long pspId = ensureSetProduct(set.getPriceSetId(), version.getPsvId(), - d.getProductCode(), d.getProductName()); + d.getProductCode(), d.getProductName(), set.getPefId()); if (pspId != null) { d.setPspId(pspId); @@ -2948,10 +3033,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService } if (!orphanIds.isEmpty()) { - for (Long id : orphanIds) - { - versionSurchargeMapper.deleteByPsdId(id); - } + surchargeRelMapper.deleteByDetailIdsAndPriceType(orphanIds, PrcSurchargeRel.PRICE_TYPE_SALES); detailMapper.deletePrcPriceSetDetailByIds(orphanIds.toArray(new Long[0])); } Set keepCodes = new HashSet(); @@ -3027,6 +3109,12 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService * Uses request-scoped cache during bulk upsert to avoid N selects on same code. */ private Long ensureSetProduct(Long priceSetId, Long psvId, String productCode, String productNameHint) + { + return ensureSetProduct(priceSetId, psvId, productCode, productNameHint, null); + } + + private Long ensureSetProduct(Long priceSetId, Long psvId, String productCode, String productNameHint, + Long pefId) { if (psvId == null || StringUtils.isEmpty(productCode)) { @@ -3037,15 +3125,32 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService Map cache = setProductCacheTl.get(); if (cache != null && cache.containsKey(key)) { + if (pefId != null) + { + PrcPriceSetProduct pefPatch = new PrcPriceSetProduct(); + pefPatch.setPspId(cache.get(key)); + pefPatch.setPefId(pefId); + setProductMapper.updatePrcPriceSetProduct(pefPatch); + } return cache.get(key); } PrcPriceSetProduct existing = setProductMapper.selectByPsvIdAndCode(psvId, code); if (existing != null) { + boolean needUpdate = false; if (StringUtils.isEmpty(existing.getProductName()) && StringUtils.isNotEmpty(productNameHint) && !code.equals(productNameHint)) { existing.setProductName(productNameHint); + needUpdate = true; + } + if (pefId != null && (existing.getPefId() == null || !pefId.equals(existing.getPefId()))) + { + existing.setPefId(pefId); + needUpdate = true; + } + if (needUpdate) + { setProductMapper.updatePrcPriceSetProduct(existing); } if (cache != null) @@ -3058,6 +3163,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService row.setPriceSetId(priceSetId); row.setPsvId(psvId); row.setProductCode(code); + row.setPefId(pefId); BmsProduct lib = null; try { @@ -3091,8 +3197,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService return row.getPspId(); } - /** Only rewrite surcharge snapshots for rows that explicitly sent surcharges. */ - private void resnapshotSurchargesIfNeeded(PrcPriceSetVersion version, List details) + /** Only rewrite SALES surcharge rels for rows that explicitly sent surcharges. */ + private void replaceSalesSurchargesIfNeeded(List details) { if (details == null || details.isEmpty()) { @@ -3103,153 +3209,296 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService { if (d != null && d.getPsdId() != null && d.getSurcharges() != null) { - versionSurchargeMapper.deleteByPsdId(d.getPsdId()); withSc.add(d); } } if (!withSc.isEmpty()) { - snapshotVersionSurcharges(version, withSc); + replaceSalesSurchargeRels(withSc); } } - private void snapshotVersionSurcharges(PrcPriceSetVersion version, List details) + /** + * Replace SALES prc_surcharge_rel rows for the given package details. + * product_id = psp_id, detail_id = psd_id, price_type = SALES. + * Empty surcharges list clears associations for that detail. + */ + private void replaceSalesSurchargeRels(List details) { if (details == null || details.isEmpty()) { return; } - Set codes = new HashSet(); + Map masterById = null; for (PrcPriceSetDetail d : details) { - if (StringUtils.isNotEmpty(d.getProductCode())) - { - codes.add(d.getProductCode()); - } - } - Map> relsByProduct = new HashMap>(); - for (String code : codes) - { - PrcSurchargeRel q = new PrcSurchargeRel(); - q.setProductCode(code); - List rels = surchargeRelMapper.selectPrcSurchargeRelList(q); - relsByProduct.put(code, rels == null ? new ArrayList() : rels); - } - // 勾选列表来自「价格基础 → 附加费配置」主表(prc_surcharge),不要求产品已做 rel 关联; - // rel 里没有的勾选项,回退主表取值落快照。 - List surchargeMasters = java.util.Collections.emptyList(); - for (PrcPriceSetDetail d0 : details) - { - if (d0.getSurcharges() != null && !d0.getSurcharges().isEmpty()) - { - surchargeMasters = surchargeMapper.selectPrcSurchargeList(new PrcSurcharge()); - break; - } - } - List saved = new ArrayList(); - for (PrcPriceSetDetail d : details) - { - List rels = relsByProduct.get(d.getProductCode()); - if (rels == null) + if (d == null || d.getPsdId() == null || d.getSurcharges() == null) { continue; } - // 前端勾选结果:明细带了 surcharges 就按勾选保存(空集合 = 一条都不要); - // 为 null 表示调用方没传勾选(如详情页新增单条明细),沿用「全部命中即快照」。 - Set pickedScIds = null; - if (d.getSurcharges() != null) + Long pspId = d.getPspId(); + if (pspId == null && d.getPsvId() != null && StringUtils.isNotEmpty(d.getProductCode())) { - pickedScIds = new HashSet(); - for (PrcPriceSetVersionSurcharge picked : d.getSurcharges()) - { - if (picked != null && picked.getScId() != null) - { - pickedScIds.add(picked.getScId()); - } - } + pspId = lookupSetProductId(d.getPsvId(), d.getProductCode()); + d.setPspId(pspId); } - List rowSc = new ArrayList(); + if (pspId == null) + { + continue; + } + surchargeRelMapper.deleteByDetailIdsAndPriceType( + java.util.Collections.singletonList(d.getPsdId()), PrcSurchargeRel.PRICE_TYPE_SALES); + + List saved = new ArrayList(); Set written = new HashSet(); - for (PrcSurchargeRel rel : rels) + for (PrcSurchargeRel picked : d.getSurcharges()) { - if (rel == null || StringUtils.isEmpty(rel.getScName()) || rel.getChargeValue() == null) + if (picked == null || picked.getScId() == null || written.contains(picked.getScId())) { continue; } - if ("of".equalsIgnoreCase(rel.getSsCode())) + PrcSurchargeRel row = buildSalesRel(d, pspId, picked.getScId(), picked.getCountryCode()); + if (row == null) { - continue; - } - if (pickedScIds != null && !pickedScIds.contains(rel.getScId())) - { - continue; - } - if (rel.getDetailId() != null && d.getProductCountryId() != null - && !rel.getDetailId().equals(d.getProductCountryId())) - { - continue; - } - if (rel.getDetailId() != null && d.getProductCountryId() == null) - { - continue; - } - PrcPriceSetVersionSurcharge sc = new PrcPriceSetVersionSurcharge(); - sc.setPsvId(version.getPsvId()); - sc.setPsdId(d.getPsdId()); - sc.setScId(rel.getScId()); - sc.setProductCode(d.getProductCode()); - sc.setScName(rel.getScName()); - sc.setFeeType(StringUtils.isEmpty(rel.getFeeType()) ? "SURCHARGE" : rel.getFeeType()); - sc.setChargeType(StringUtils.isEmpty(rel.getChargeType()) ? "FIXED" : rel.getChargeType()); - sc.setChargeValue(rel.getChargeValue()); - sc.setCurrency(rel.getCurrency()); - sc.setRoundMode(rel.getRoundMode()); - sc.setCountryCode(rel.getCountryCode()); - sc.setScNote(rel.getScNote()); - sc.setCreateBy(version.getCreateBy()); - versionSurchargeMapper.insertPrcPriceSetVersionSurcharge(sc); - saved.add(sc); - rowSc.add(sc); - written.add(rel.getScId()); - } - // 勾选了但产品未关联(rel 无此 scId)的附加费:回退 prc_surcharge 主表配置 - if (pickedScIds != null) - { - for (PrcSurcharge m : surchargeMasters) - { - if (m == null || m.getScId() == null || written.contains(m.getScId()) - || !pickedScIds.contains(m.getScId())) + if (masterById == null) { - continue; + masterById = loadSurchargeMasterMap(); } - if (StringUtils.isEmpty(m.getScName()) || m.getChargeValue() == null + PrcSurcharge m = masterById.get(picked.getScId()); + if (m == null || StringUtils.isEmpty(m.getScName()) || m.getChargeValue() == null || "of".equalsIgnoreCase(m.getSsCode())) { continue; } - PrcPriceSetVersionSurcharge sc = new PrcPriceSetVersionSurcharge(); - sc.setPsvId(version.getPsvId()); - sc.setPsdId(d.getPsdId()); - sc.setScId(m.getScId()); - sc.setProductCode(d.getProductCode()); - sc.setScName(m.getScName()); - sc.setFeeType(StringUtils.isEmpty(m.getFeeType()) ? "SURCHARGE" : m.getFeeType()); - sc.setChargeType(StringUtils.isEmpty(m.getChargeType()) ? "FIXED" : m.getChargeType()); - sc.setChargeValue(m.getChargeValue()); - sc.setCurrency(m.getCurrency()); - sc.setRoundMode(m.getRoundMode()); - sc.setCountryCode(m.getCountryCode()); - sc.setScNote(m.getScNote()); - sc.setCreateBy(version.getCreateBy()); - versionSurchargeMapper.insertPrcPriceSetVersionSurcharge(sc); - saved.add(sc); - rowSc.add(sc); - written.add(m.getScId()); + row = new PrcSurchargeRel(); + row.setScId(m.getScId()); + row.setProductId(pspId); + row.setProductCode(d.getProductCode()); + row.setDetailId(d.getPsdId()); + row.setPriceType(PrcSurchargeRel.PRICE_TYPE_SALES); + row.setCountryCode(normalizeCountry(picked.getCountryCode() != null + ? picked.getCountryCode() : m.getCountryCode())); + row.setScName(m.getScName()); + row.setFeeType(m.getFeeType()); + row.setChargeType(m.getChargeType()); + row.setChargeValue(m.getChargeValue()); + row.setCurrency(m.getCurrency()); + row.setRoundMode(m.getRoundMode()); + row.setSsCode(m.getSsCode()); + row.setScNote(m.getScNote()); + row.setCreateBy(SecurityUtils.getUsername()); + } + surchargeRelMapper.insertPrcSurchargeRel(row); + saved.add(row); + written.add(picked.getScId()); + } + d.setSurcharges(saved); + } + } + + private PrcSurchargeRel buildSalesRel(PrcPriceSetDetail d, Long pspId, Long scId, String countryCode) + { + if (d == null || pspId == null || scId == null) + { + return null; + } + PrcSurcharge m = surchargeMapper.selectPrcSurchargeById(scId); + if (m == null || StringUtils.isEmpty(m.getScName()) || m.getChargeValue() == null + || "of".equalsIgnoreCase(m.getSsCode())) + { + return null; + } + PrcSurchargeRel row = new PrcSurchargeRel(); + row.setScId(m.getScId()); + row.setProductId(pspId); + row.setProductCode(d.getProductCode()); + row.setDetailId(d.getPsdId()); + row.setPriceType(PrcSurchargeRel.PRICE_TYPE_SALES); + row.setCountryCode(normalizeCountry(countryCode != null ? countryCode : m.getCountryCode())); + row.setScName(m.getScName()); + row.setFeeType(m.getFeeType()); + row.setChargeType(m.getChargeType()); + row.setChargeValue(m.getChargeValue()); + row.setCurrency(m.getCurrency()); + row.setRoundMode(m.getRoundMode()); + row.setSsCode(m.getSsCode()); + row.setScNote(m.getScNote()); + row.setCreateBy(SecurityUtils.getUsername()); + return row; + } + + private Map loadSurchargeMasterMap() + { + Map map = new HashMap(); + List all = surchargeMapper.selectPrcSurchargeList(new PrcSurcharge()); + if (all == null) + { + return map; + } + for (PrcSurcharge m : all) + { + if (m != null && m.getScId() != null) + { + map.put(m.getScId(), m); + } + } + return map; + } + + private String normalizeCountry(String code) + { + if (StringUtils.isEmpty(code)) + { + return null; + } + return code.trim().toUpperCase(); + } + + /** Delete all SALES surcharge rels under a package version (by detail + set-product ids). */ + private void deleteSalesRelsByPsvId(Long psvId) + { + if (psvId == null) + { + return; + } + PrcPriceSetDetail dq = new PrcPriceSetDetail(); + dq.setPsvId(psvId); + List details = detailMapper.selectPrcPriceSetDetailList(dq); + if (details != null && !details.isEmpty()) + { + List psdIds = new ArrayList(); + for (PrcPriceSetDetail d : details) + { + if (d != null && d.getPsdId() != null) + { + psdIds.add(d.getPsdId()); } } - d.setSurcharges(rowSc); + if (!psdIds.isEmpty()) + { + surchargeRelMapper.deleteByDetailIdsAndPriceType(psdIds, PrcSurchargeRel.PRICE_TYPE_SALES); + } } - version.setSurcharges(saved); + PrcPriceSetProduct pq = new PrcPriceSetProduct(); + pq.setPsvId(psvId); + List products = setProductMapper.selectPrcPriceSetProductList(pq); + if (products != null && !products.isEmpty()) + { + List pspIds = new ArrayList(); + for (PrcPriceSetProduct p : products) + { + if (p != null && p.getPspId() != null) + { + pspIds.add(p.getPspId()); + } + } + if (!pspIds.isEmpty()) + { + surchargeRelMapper.deleteByProductIdsAndPriceType(pspIds, PrcSurchargeRel.PRICE_TYPE_SALES); + } + } + } + + /** + * Copy SALES surcharge rels from source version details onto newly copied details + * (match by product_code + country + zone + weight + product_country_id). + */ + private void copySalesSurchargeRelsFromVersion(Long srcPsvId, Long newPsvId, String createBy) + { + if (srcPsvId == null || newPsvId == null) + { + return; + } + PrcPriceSetDetail sq = new PrcPriceSetDetail(); + sq.setPsvId(srcPsvId); + List srcDetails = detailMapper.selectPrcPriceSetDetailList(sq); + PrcPriceSetDetail nq = new PrcPriceSetDetail(); + nq.setPsvId(newPsvId); + List newDetails = detailMapper.selectPrcPriceSetDetailList(nq); + if (srcDetails == null || srcDetails.isEmpty() || newDetails == null || newDetails.isEmpty()) + { + return; + } + List srcPsdIds = new ArrayList(); + for (PrcPriceSetDetail d : srcDetails) + { + if (d != null && d.getPsdId() != null) + { + srcPsdIds.add(d.getPsdId()); + } + } + List srcRels = + surchargeRelMapper.selectByDetailIdsAndPriceType(srcPsdIds, PrcSurchargeRel.PRICE_TYPE_SALES); + if (srcRels == null || srcRels.isEmpty()) + { + return; + } + Map> bySrcPsd = new HashMap>(); + for (PrcSurchargeRel rel : srcRels) + { + if (rel == null || rel.getDetailId() == null) + { + continue; + } + List list = bySrcPsd.get(rel.getDetailId()); + if (list == null) + { + list = new ArrayList(); + bySrcPsd.put(rel.getDetailId(), list); + } + list.add(rel); + } + Map newByKey = new HashMap(); + for (PrcPriceSetDetail nd : newDetails) + { + if (nd != null) + { + newByKey.put(detailMatchKey(nd), nd); + } + } + for (PrcPriceSetDetail od : srcDetails) + { + if (od == null || od.getPsdId() == null) + { + continue; + } + PrcPriceSetDetail nd = newByKey.get(detailMatchKey(od)); + if (nd == null || nd.getPsdId() == null || nd.getPspId() == null) + { + continue; + } + List rels = bySrcPsd.get(od.getPsdId()); + if (rels == null || rels.isEmpty()) + { + continue; + } + for (PrcSurchargeRel src : rels) + { + PrcSurchargeRel row = new PrcSurchargeRel(); + row.setScId(src.getScId()); + row.setProductId(nd.getPspId()); + row.setProductCode(nd.getProductCode()); + row.setDetailId(nd.getPsdId()); + row.setPriceType(PrcSurchargeRel.PRICE_TYPE_SALES); + row.setCountryCode(normalizeCountry(src.getCountryCode())); + row.setCreateBy(createBy); + surchargeRelMapper.insertPrcSurchargeRel(row); + } + } + } + + private String detailMatchKey(PrcPriceSetDetail d) + { + if (d == null) + { + return ""; + } + return StringUtils.nvl(d.getProductCode(), "") + + "|" + StringUtils.nvl(d.getCountryCode(), "") + + "|" + StringUtils.nvl(d.getZoneCode(), "") + + "|" + StringUtils.nvl(d.getWeightLabel(), "") + + "|" + (d.getProductCountryId() == null ? "0" : d.getProductCountryId().toString()) + + "|" + (d.getDiscountValue() == null ? "-1" : d.getDiscountValue().toPlainString()); } /** True when package already has a true draft (status=0). Pending(3) does not block copy. */ diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcSurchargeRelServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcSurchargeRelServiceImpl.java index 0b352851..dba1a3d0 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcSurchargeRelServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcSurchargeRelServiceImpl.java @@ -11,7 +11,7 @@ import com.bms.price.mapper.PrcSurchargeRelMapper; import com.bms.price.service.IPrcSurchargeRelService; /** - * 附加费与产品/明细关联 服务层实现 + * 附加费与产品/明细/套餐关联 服务层实现 * * @author bms */ @@ -23,9 +23,19 @@ public class PrcSurchargeRelServiceImpl implements IPrcSurchargeRelService @Override public List selectRelByProductCode(String productCode) + { + return selectRelByProductCode(productCode, PrcSurchargeRel.PRICE_TYPE_PRODUCT); + } + + @Override + public List selectRelByProductCode(String productCode, String priceType) { PrcSurchargeRel query = new PrcSurchargeRel(); query.setProductCode(productCode); + if (StringUtils.isNotEmpty(priceType)) + { + query.setPriceType(priceType.trim().toUpperCase()); + } return relMapper.selectPrcSurchargeRelList(query); } @@ -39,6 +49,18 @@ public class PrcSurchargeRelServiceImpl implements IPrcSurchargeRelService public int insertPrcSurchargeRel(PrcSurchargeRel rel) { validate(rel); + if (StringUtils.isEmpty(rel.getPriceType())) + { + rel.setPriceType(PrcSurchargeRel.PRICE_TYPE_PRODUCT); + } + if (StringUtils.isNotEmpty(rel.getCountryCode())) + { + rel.setCountryCode(rel.getCountryCode().trim().toUpperCase()); + } + else + { + rel.setCountryCode(null); + } return relMapper.insertPrcSurchargeRel(rel); } @@ -76,5 +98,18 @@ public class PrcSurchargeRelServiceImpl implements IPrcSurchargeRelService { throw new ServiceException(MessageUtils.message("price.surchargeRel.productCode.empty")); } + String type = StringUtils.isEmpty(rel.getPriceType()) + ? PrcSurchargeRel.PRICE_TYPE_PRODUCT : rel.getPriceType().trim().toUpperCase(); + if (!PrcSurchargeRel.PRICE_TYPE_PRODUCT.equals(type) + && !PrcSurchargeRel.PRICE_TYPE_SALES.equals(type) + && !PrcSurchargeRel.PRICE_TYPE_REDLINE.equals(type)) + { + throw new ServiceException(MessageUtils.message("price.surchargeRel.priceType.invalid")); + } + rel.setPriceType(type); + if (PrcSurchargeRel.PRICE_TYPE_SALES.equals(type) && rel.getDetailId() == null) + { + throw new ServiceException(MessageUtils.message("price.surchargeRel.detailId.empty")); + } } } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java index 6accd715..38ca7a1a 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java @@ -25,12 +25,12 @@ import com.bms.customer.customerinfo.domain.BmsCustomer; import com.bms.customer.customerinfo.mapper.BmsCustomerMapper; import com.bms.price.domain.BmsProduct; import com.bms.price.domain.BmsProductCountry; +import com.bms.price.domain.BmsRedlineProductCountry; import com.bms.price.domain.PrcCustomerPriceSet; import com.bms.price.domain.PrcPriceSet; import com.bms.price.domain.PrcPriceSetDetail; import com.bms.price.domain.PrcPriceSetProduct; import com.bms.price.domain.PrcPriceSetVersion; -import com.bms.price.domain.PrcPriceSetVersionSurcharge; import com.bms.price.domain.PrcQuote; import com.bms.price.domain.PrcQuoteFee; import com.bms.price.domain.PrcSurchargeRel; @@ -43,12 +43,12 @@ import com.bms.price.domain.vo.TmsPriceQuoteResult; import com.bms.price.enums.BillCustomerActiveStatus; import com.bms.price.mapper.BmsProductCountryMapper; import com.bms.price.mapper.BmsProductMapper; +import com.bms.price.mapper.BmsRedlineProductCountryMapper; import com.bms.price.mapper.PrcCustomerPriceSetMapper; import com.bms.price.mapper.PrcPriceSetDetailMapper; import com.bms.price.mapper.PrcPriceSetMapper; import com.bms.price.mapper.PrcPriceSetProductMapper; import com.bms.price.mapper.PrcPriceSetVersionMapper; -import com.bms.price.mapper.PrcPriceSetVersionSurchargeMapper; import com.bms.price.mapper.PrcQuoteFeeMapper; import com.bms.price.mapper.PrcQuoteMapper; import com.bms.price.mapper.PrcSurchargeRelMapper; @@ -87,9 +87,6 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService @Autowired private PrcPriceSetVersionMapper versionMapper; - @Autowired - private PrcPriceSetVersionSurchargeMapper versionSurchargeMapper; - @Autowired private PrcPriceSetDetailMapper detailMapper; @@ -99,6 +96,9 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService @Autowired private BmsProductCountryMapper productCountryMapper; + @Autowired + private BmsRedlineProductCountryMapper redlineProductCountryMapper; + @Autowired private BmsProductMapper productMapper; @@ -879,6 +879,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService r.setRoundMode(mode); r.setFromCache(false); fillProductAmountAndPay(r, catalog, weight, rm, mode); + fillRedlineAmount(r, weight, rm); return r; } @@ -934,6 +935,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService r.setRoundMode(mode); r.setFromCache(false); fillProductAmountAndPay(r, base, weight, rm, mode); + fillRedlineAmount(r, weight, rm); return r; } @@ -966,6 +968,50 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService r.setPayFeeDetails(payLines); } + /** + * Redline catalog amount from redline_product_country (same match keys as product catalog). + */ + private void fillRedlineAmount(TmsPriceQuoteResult r, BigDecimal weight, RoundingMode rm) + { + if (r == null || weight == null || StringUtils.isEmpty(r.getProductCode())) + { + return; + } + BmsRedlineProductCountry base = redlineProductCountryMapper.selectMatchByProductCountryWeight( + r.getProductCode(), r.getCountryCode(), r.getCountryName(), r.getZoneCode(), weight, null); + if (base == null || base.getFreightPrice() == null) + { + return; + } + if (base.getDetailId() != null) + { + r.setRedlineCountryId(base.getDetailId()); + } + BigDecimal baseReg = base.getRegistrationFee() == null ? BigDecimal.ZERO : base.getRegistrationFee(); + BigDecimal freight = base.getFreightPrice().multiply(weight).setScale(2, rm); + BigDecimal regScaled = baseReg.setScale(2, rm); + r.setRedlineAmount(freight.add(regScaled).setScale(2, rm)); + // Stash freight/reg on existing payFeeDetails for income fee-line apportionment + if (r.getPayFeeDetails() != null) + { + for (TmsPriceQuoteFeeLine line : r.getPayFeeDetails()) + { + if (line == null || line.getFeeType() == null) + { + continue; + } + if (FEE_BASIC.equals(line.getFeeType())) + { + line.setRedlineAmount(freight); + } + else if (FEE_REG.equals(line.getFeeType())) + { + line.setRedlineAmount(regScaled); + } + } + } + } + private void applySurchargesAndFees(TmsPriceQuoteResult result) { BigDecimal freightAmount = nvl(result.getFreightAmount()); @@ -973,20 +1019,27 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService RoundingMode rm = resolveRoundingMode(result.getRoundMode()); String currency = normalizeCurrency(result.getCurrency()); - List rels = loadMatchedSurcharges(result); + List salesRels = loadMatchedSurcharges(result, PrcSurchargeRel.PRICE_TYPE_SALES); + List productRels = loadMatchedSurcharges(result, PrcSurchargeRel.PRICE_TYPE_PRODUCT); + List redlineRels = loadMatchedSurcharges(result, PrcSurchargeRel.PRICE_TYPE_REDLINE); + List lines = new ArrayList(); BigDecimal productFreight = productAmountOf(result.getPayFeeDetails(), FEE_BASIC); BigDecimal productReg = productAmountOf(result.getPayFeeDetails(), FEE_REG); + BigDecimal redlineFreight = redlineAmountOf(result.getPayFeeDetails(), FEE_BASIC); + BigDecimal redlineReg = redlineAmountOf(result.getPayFeeDetails(), FEE_REG); TmsPriceQuoteFeeLine basicLine = feeLine(FEE_BASIC, feeNameOf(FEE_BASIC), 0L, freightAmount, currency, result.getRoundMode(), null); basicLine.setProductAmount(productFreight); + basicLine.setRedlineAmount(redlineFreight); lines.add(basicLine); TmsPriceQuoteFeeLine regLine = feeLine(FEE_REG, feeNameOf(FEE_REG), 0L, registrationFee, currency, result.getRoundMode(), null); regLine.setProductAmount(productReg); + regLine.setRedlineAmount(redlineReg); lines.add(regLine); BigDecimal surchargeTotal = BigDecimal.ZERO; Map surchargeLines = new LinkedHashMap(); - for (PrcSurchargeRel rel : rels) + for (PrcSurchargeRel rel : salesRels) { BigDecimal itemAmount = calcSurchargeAmount(rel, result.getWeight(), freightAmount); if (itemAmount == null) @@ -1017,41 +1070,90 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService result.setPayableTotal(freightAmount.add(registrationFee).add(surchargeTotal).setScale(2, rm)); result.setIncomeTotal(result.getPayableTotal()); result.setFeeDetails(lines); + + BigDecimal productExtra = sumSurchargeAmounts(productRels, result.getWeight(), freightAmount, rm); + BigDecimal redlineExtra = sumSurchargeAmounts(redlineRels, result.getWeight(), freightAmount, rm); + if (result.getProductAmount() != null || productExtra.compareTo(BigDecimal.ZERO) > 0) + { + result.setProductAmount(nvl(result.getProductAmount()).add(productExtra).setScale(2, rm)); + } + if (result.getCostAmount() != null || productExtra.compareTo(BigDecimal.ZERO) > 0) + { + result.setCostAmount(nvl(result.getCostAmount()).add(productExtra).setScale(2, rm)); + } + if (result.getPayTotal() != null || productExtra.compareTo(BigDecimal.ZERO) > 0) + { + result.setPayTotal(nvl(result.getPayTotal()).add(productExtra).setScale(2, rm)); + } + if (result.getRedlineAmount() != null || redlineExtra.compareTo(BigDecimal.ZERO) > 0) + { + result.setRedlineAmount(nvl(result.getRedlineAmount()).add(redlineExtra).setScale(2, rm)); + } + else if (redlineExtra.compareTo(BigDecimal.ZERO) > 0) + { + result.setRedlineAmount(redlineExtra); + } } - private List loadMatchedSurcharges(TmsPriceQuoteResult result) + private BigDecimal sumSurchargeAmounts(List rels, BigDecimal weight, + BigDecimal freightAmount, RoundingMode rm) + { + BigDecimal total = BigDecimal.ZERO; + if (rels == null) + { + return total; + } + for (PrcSurchargeRel rel : rels) + { + BigDecimal item = calcSurchargeAmount(rel, weight, freightAmount); + if (item != null) + { + total = total.add(item); + } + } + return total.setScale(2, rm); + } + + /** + * Load matched surcharge rels by price_type. + * SALES: package detail_id = psd_id. + * PRODUCT / REDLINE: product catalog associations by product_code + country/detail. + */ + private List loadMatchedSurcharges(TmsPriceQuoteResult result, String priceType) { List matched = new ArrayList(); - if (result == null || StringUtils.isEmpty(result.getProductCode())) + if (result == null || StringUtils.isEmpty(priceType)) { return matched; } - boolean packageHit = SRC_FIXED.equals(result.getMatchSource()) || SRC_DISCOUNT.equals(result.getMatchSource()); - // Package sales use version surcharge snapshot only (empty snapshot = no surcharge). - if (packageHit) + if (PrcSurchargeRel.PRICE_TYPE_SALES.equals(priceType)) { if (result.getPsdId() == null) { return matched; } - List snaps = - versionSurchargeMapper.selectByPsdIds(Collections.singletonList(result.getPsdId())); - if (snaps == null || snaps.isEmpty()) + List rels = surchargeRelMapper.selectByDetailIdsAndPriceType( + Collections.singletonList(result.getPsdId()), PrcSurchargeRel.PRICE_TYPE_SALES); + if (rels == null) { return matched; } - for (PrcPriceSetVersionSurcharge snap : snaps) + for (PrcSurchargeRel rel : rels) { - PrcSurchargeRel rel = toRelFromVersionSnap(snap); - if (surchargeApplies(rel, result)) + if (surchargeApplies(rel, result, true)) { matched.add(rel); } } return matched; } + if (StringUtils.isEmpty(result.getProductCode())) + { + return matched; + } PrcSurchargeRel query = new PrcSurchargeRel(); query.setProductCode(result.getProductCode()); + query.setPriceType(priceType); List rels = surchargeRelMapper.selectPrcSurchargeRelList(query); if (rels == null) { @@ -1059,7 +1161,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService } for (PrcSurchargeRel rel : rels) { - if (surchargeApplies(rel, result)) + if (surchargeApplies(rel, result, false)) { matched.add(rel); } @@ -1067,28 +1169,10 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService return matched; } - private PrcSurchargeRel toRelFromVersionSnap(PrcPriceSetVersionSurcharge snap) - { - PrcSurchargeRel rel = new PrcSurchargeRel(); - if (snap == null) - { - return rel; - } - rel.setScId(snap.getScId()); - rel.setProductCode(snap.getProductCode()); - rel.setScName(snap.getScName()); - rel.setFeeType(snap.getFeeType()); - rel.setChargeType(snap.getChargeType()); - rel.setChargeValue(snap.getChargeValue()); - rel.setCurrency(snap.getCurrency()); - rel.setRoundMode(snap.getRoundMode()); - rel.setCountryCode(snap.getCountryCode()); - rel.setScNote(snap.getScNote()); - rel.setSsCode("on"); - return rel; - } - - private boolean surchargeApplies(PrcSurchargeRel rel, TmsPriceQuoteResult result) + /** + * @param salesRel true = SALES (detail_id already = psd_id); false = PRODUCT/REDLINE detail match + */ + private boolean surchargeApplies(PrcSurchargeRel rel, TmsPriceQuoteResult result, boolean salesRel) { if (rel == null) { @@ -1103,10 +1187,18 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService { return false; } + if (salesRel) + { + return true; + } if (rel.getDetailId() == null) { return true; } + if (PrcSurchargeRel.PRICE_TYPE_REDLINE.equals(rel.getPriceType())) + { + return result.getRedlineCountryId() != null && rel.getDetailId().equals(result.getRedlineCountryId()); + } return result.getProductCountryId() != null && rel.getDetailId().equals(result.getProductCountryId()); } @@ -1409,6 +1501,23 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService return null; } + /** Read redline catalog amount for a fee type from payFeeDetails. */ + private static BigDecimal redlineAmountOf(List payLines, String feeType) + { + if (payLines == null || feeType == null) + { + return null; + } + for (TmsPriceQuoteFeeLine line : payLines) + { + if (line != null && feeType.equals(line.getFeeType())) + { + return line.getRedlineAmount(); + } + } + return null; + } + private static TmsPriceQuoteFeeLine feeLine(String feeType, String feeName, Long scId, BigDecimal amount, String currency, String roundMode, String remark) { diff --git a/bms-back/bms-system/src/main/java/com/bms/price/task/BmsRedlineProductImportAsyncRunner.java b/bms-back/bms-system/src/main/java/com/bms/price/task/BmsRedlineProductImportAsyncRunner.java new file mode 100644 index 00000000..d85f10fc --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/task/BmsRedlineProductImportAsyncRunner.java @@ -0,0 +1,97 @@ +package com.bms.price.task; + +import java.util.Date; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import com.bms.common.core.redis.RedisCache; +import com.bms.common.utils.StringUtils; +import com.bms.price.domain.BmsRedlineProductImportTask; +import com.bms.price.service.IBmsRedlineProductService; + +/** + * Async runner for product Excel import (avoids HTTP timeout on large workbooks). + */ +@Component +public class BmsRedlineProductImportAsyncRunner +{ + private static final Logger log = LoggerFactory.getLogger(BmsRedlineProductImportAsyncRunner.class); + + public static final String CACHE_KEY_PREFIX = "price:redline:import:task:"; + public static final int TTL_MINUTES = 60; + + @Autowired + private IBmsRedlineProductService productService; + + @Autowired + private RedisCache redisCache; + + /** Background all-country (Weishi catalog) import. */ + @Async("threadPoolTaskExecutor") + public void runImportAll(String taskId, byte[] bytes, String filename, boolean updateSupport, String operName, + Date effectiveDate) + { + try + { + put(taskId, BmsRedlineProductImportTask.STATUS_RUNNING, 5, "parsing excel", null); + String message = productService.importProductExcel(bytes, filename, updateSupport, operName, taskId, + effectiveDate); + put(taskId, BmsRedlineProductImportTask.STATUS_SUCCESS, 100, message, null); + log.info("async product import done, taskId={}", taskId); + } + catch (Exception e) + { + log.error("async product import failed, taskId={}", taskId, e); + put(taskId, BmsRedlineProductImportTask.STATUS_FAIL, 100, "failed", errorMessage(e)); + } + } + + /** Background single-country cost-sheet import. */ + @Async("threadPoolTaskExecutor") + public void runImportCountry(String taskId, byte[] bytes, String filename, String countryCode, String operName, + Date effectiveDate) + { + try + { + put(taskId, BmsRedlineProductImportTask.STATUS_RUNNING, 5, "parsing excel", null); + String message = productService.importCountryProductExcel(bytes, filename, countryCode, operName, taskId, + effectiveDate); + put(taskId, BmsRedlineProductImportTask.STATUS_SUCCESS, 100, message, null); + log.info("async product country import done, taskId={}, country={}", taskId, countryCode); + } + catch (Exception e) + { + log.error("async product country import failed, taskId={}, country={}", taskId, countryCode, e); + put(taskId, BmsRedlineProductImportTask.STATUS_FAIL, 100, "failed", errorMessage(e)); + } + } + + public void put(String taskId, String status, int percent, String message, String error) + { + BmsRedlineProductImportTask task = new BmsRedlineProductImportTask(); + task.setTaskId(taskId); + task.setStatus(status); + task.setPercent(percent); + task.setMessage(message); + task.setError(error); + redisCache.setCacheObject(CACHE_KEY_PREFIX + taskId, task, TTL_MINUTES, TimeUnit.MINUTES); + } + + public BmsRedlineProductImportTask get(String taskId) + { + return redisCache.getCacheObject(CACHE_KEY_PREFIX + taskId); + } + + private static String errorMessage(Exception e) + { + String msg = e.getMessage(); + if (StringUtils.isEmpty(msg)) + { + msg = e.getClass().getSimpleName(); + } + return msg; + } +} diff --git a/bms-back/bms-system/src/main/resources/mapper/price/BmsProductMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/BmsProductMapper.xml index 93466b8b..a1b75638 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/BmsProductMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/BmsProductMapper.xml @@ -17,6 +17,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -30,7 +31,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select p.product_id, p.product_code, p.product_name, p.service_level, p.cargo_attr, p.dest_region, p.update_note, p.tax_mode, p.effective_date, p.expire_date, p.status, - p.source_sheet, p.remark, p.tms_id, p.created_by, p.create_time, p.updated_by, p.update_time, + p.source_sheet, p.pef_id, p.remark, p.tms_id, p.created_by, p.create_time, p.updated_by, p.update_time, (select count(distinct c.country_name) from product_country c where c.product_id = p.product_id or (c.product_id is null and c.product_code = p.product_code @@ -39,7 +40,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" or c.effective_date = p.effective_date )) ) as country_count, - (select count(*) from prc_surcharge_rel r where r.product_id = p.product_id) as surcharge_count + (select count(*) from prc_surcharge_rel r where r.product_id = p.product_id and r.price_type = 'PRODUCT') as surcharge_count from product p @@ -139,6 +140,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" expire_date, status, source_sheet, + pef_id, remark, tms_id, created_by, @@ -155,6 +157,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{expireDate}, #{status}, #{sourceSheet}, + #{pefId}, #{remark}, #{tmsId}, #{createBy}, @@ -176,6 +179,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" expire_date = #{expireDate}, status = #{status}, source_sheet = #{sourceSheet}, + pef_id = #{pefId}, remark = #{remark}, tms_id = #{tmsId}, updated_by = #{updateBy}, diff --git a/bms-back/bms-system/src/main/resources/mapper/price/BmsRedlineProductCountryMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/BmsRedlineProductCountryMapper.xml new file mode 100644 index 00000000..68a1f41e --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/price/BmsRedlineProductCountryMapper.xml @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select c.detail_id, c.product_id, c.product_code, c.country_name, c.country_code, + c.zone_id, c.zone_code, c.zone_name, + c.weight_min, c.weight_max, + c.freight_price, c.registration_fee, c.size_limit, c.last_mile, c.lead_time, + c.effective_date, c.expire_date, c.status, c.create_time, c.update_time + from redline_product_country c + + + + + + insert into redline_product_country ( + product_id, product_code, country_name, country_code, zone_id, zone_code, zone_name, + weight_min, weight_max, + freight_price, registration_fee, size_limit, last_mile, lead_time, + effective_date, expire_date, status, create_time + ) values ( + #{productId}, #{productCode}, #{countryName}, #{countryCode}, #{zoneId}, #{zoneCode}, #{zoneName}, + #{weightMin}, #{weightMax}, + #{freightPrice}, #{registrationFee}, #{sizeLimit}, #{lastMile}, #{leadTime}, + #{effectiveDate}, #{expireDate}, IFNULL(#{status}, '0'), sysdate() + ) + + + + + update redline_product_country + set weight_min = #{weightMin}, + weight_max = #{weightMax}, + freight_price = #{freightPrice}, + registration_fee = #{registrationFee}, + size_limit = #{sizeLimit}, + last_mile = #{lastMile}, + lead_time = #{leadTime}, + effective_date = #{effectiveDate}, + expire_date = #{expireDate}, + update_time = sysdate() + where detail_id = #{detailId} + + + + update redline_product_country + set product_id = #{productId}, + country_code = #{countryCode}, + zone_id = #{zoneId}, + zone_code = #{zoneCode}, + zone_name = #{zoneName}, + weight_min = #{weightMin}, + weight_max = #{weightMax}, + freight_price = #{freightPrice}, + registration_fee = #{registrationFee}, + size_limit = #{sizeLimit}, + last_mile = #{lastMile}, + lead_time = #{leadTime}, + effective_date = #{effectiveDate}, + expire_date = #{expireDate}, + status = IFNULL(#{status}, status), + update_time = sysdate() + where detail_id = #{detailId} + + + + delete from redline_product_country where product_id = #{productId} + + + + delete from redline_product_country where product_code = #{productCode} + + + + delete from redline_product_country where product_code in + + #{code} + + + + + delete from redline_product_country + where product_code = #{productCode} + and ( + (#{effectiveDate} is null and effective_date is null) + or effective_date = #{effectiveDate} + ) + + + + + + update redline_product_country + set expire_date = #{newEffectiveDate}, + update_time = sysdate() + where product_code = #{productCode} + and expire_date is null + and (effective_date is null or effective_date < #{newEffectiveDate}) + + + + update redline_product_country + set expire_date = #{newEffectiveDate}, + update_time = sysdate() + where product_code = #{productCode} + and expire_date is null + and (effective_date is null or effective_date < #{newEffectiveDate}) + + + and upper(ifnull(country_code,'')) = upper(#{countryCode}) + + + and country_name = #{countryName} + + + + + + update redline_product_country + set status = case + when expire_date is not null and expire_date <= now() then '2' + when effective_date is null or effective_date > now() then '0' + else '1' + end + + + + delete from redline_product_country + where product_code = #{productCode} + and expire_date is null + and ( + (#{effectiveDate} is null and effective_date is null) + or effective_date = #{effectiveDate} + ) + + + + delete from redline_product_country + where product_code = #{productCode} + and expire_date is null + and ( + (#{effectiveDate} is null and effective_date is null) + or effective_date = #{effectiveDate} + ) + + + and upper(ifnull(country_code,'')) = upper(#{countryCode}) + + + and country_name = #{countryName} + + + + + + + + + + + + insert into redline_product_country ( + product_id, product_code, country_name, country_code, zone_id, zone_code, zone_name, + weight_min, weight_max, freight_price, registration_fee, size_limit, last_mile, lead_time, + effective_date, expire_date, status, create_time + ) + select + #{productId}, product_code, country_name, country_code, zone_id, zone_code, zone_name, + weight_min, weight_max, freight_price, registration_fee, size_limit, last_mile, lead_time, + #{effectiveDate}, null, IFNULL(#{status}, '0'), sysdate() + from redline_product_country + where product_code = #{productCode} + and expire_date is null + and (effective_date is null or effective_date < #{effectiveDate}) + order by detail_id asc + + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/BmsRedlineProductMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/BmsRedlineProductMapper.xml new file mode 100644 index 00000000..c1064d60 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/price/BmsRedlineProductMapper.xml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select p.product_id, p.product_code, p.product_name, p.service_level, p.cargo_attr, + p.dest_region, p.update_note, p.tax_mode, p.effective_date, p.expire_date, p.status, + p.source_sheet, p.pef_id, p.remark, p.tms_id, p.created_by, p.create_time, p.updated_by, p.update_time, + (select count(distinct c.country_name) from redline_product_country c + where c.product_id = p.product_id + or (c.product_id is null and c.product_code = p.product_code + and ( + (p.effective_date is null and c.effective_date is null) + or c.effective_date = p.effective_date + )) + ) as country_count, + (select count(*) from prc_surcharge_rel r where r.product_id = p.product_id and r.price_type = 'REDLINE') as surcharge_count + from redline_product p + + + + + + + + + + + + + + insert into redline_product ( + product_code, + product_name, + service_level, + cargo_attr, + dest_region, + update_note, + tax_mode, + effective_date, + expire_date, + status, + source_sheet, + pef_id, + remark, + tms_id, + created_by, + create_time + ) values ( + #{productCode}, + #{productName}, + #{serviceLevel}, + #{cargoAttr}, + #{destRegion}, + #{updateNote}, + #{taxMode}, + #{effectiveDate}, + #{expireDate}, + #{status}, + #{sourceSheet}, + #{pefId}, + #{remark}, + #{tmsId}, + #{createBy}, + sysdate() + ) + + + + update product + + product_code = #{productCode}, + product_name = #{productName}, + service_level = #{serviceLevel}, + cargo_attr = #{cargoAttr}, + dest_region = #{destRegion}, + update_note = #{updateNote}, + tax_mode = #{taxMode}, + effective_date = #{effectiveDate}, + expire_date = #{expireDate}, + status = #{status}, + source_sheet = #{sourceSheet}, + pef_id = #{pefId}, + remark = #{remark}, + tms_id = #{tmsId}, + updated_by = #{updateBy}, + update_time = sysdate() + + where product_id = #{productId} + + + + delete from redline_product where product_id in + + #{productId} + + + + + update product + set expire_date = #{newEffectiveDate}, + update_time = sysdate() + where product_code = #{productCode} + and expire_date is null + and (effective_date is null or effective_date < #{newEffectiveDate}) + + and product_id <> #{excludeId} + + + + + update product + set status = case + when expire_date is not null and expire_date <= now() then '2' + when effective_date is null or effective_date > now() then '0' + else '1' + end + + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcIncomeBillFeeMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcIncomeBillFeeMapper.xml index 208c858d..dea6e05d 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcIncomeBillFeeMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcIncomeBillFeeMapper.xml @@ -180,4 +180,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" delete from prc_income_bill_fee where bill_id = #{billId} + + + delete from prc_income_bill_fee + where bill_id = #{billId} + and (manual_flag is null or manual_flag <> '1') + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceExcelFileMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceExcelFileMapper.xml new file mode 100644 index 00000000..68ac61d2 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceExcelFileMapper.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + select pef_id, excel_type, effective_time, file_name, file_path, file_size, content_type, + file_extension, oss_id, sheet_names, remark, created_by, create_time, updated_by, update_time + from prc_price_excel_file + + + + + + + + + + insert into prc_price_excel_file ( + excel_type, + effective_time, + file_name, + file_path, + file_size, + content_type, + file_extension, + oss_id, + sheet_names, + remark, + created_by, + create_time + ) values ( + #{excelType}, + #{effectiveTime}, + #{fileName}, + #{filePath}, + #{fileSize}, + #{contentType}, + #{fileExtension}, + #{ossId}, + #{sheetNames}, + #{remark}, + #{createBy}, + sysdate() + ) + + + + update prc_price_excel_file + + excel_type = #{excelType}, + effective_time = #{effectiveTime}, + file_name = #{fileName}, + file_path = #{filePath}, + file_size = #{fileSize}, + content_type = #{contentType}, + file_extension = #{fileExtension}, + oss_id = #{ossId}, + sheet_names = #{sheetNames}, + remark = #{remark}, + updated_by = #{updateBy}, + update_time = sysdate() + + where pef_id = #{pefId} + + + + delete from prc_price_excel_file where pef_id in + + #{pefId} + + + + + + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetProductMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetProductMapper.xml index 382778e1..2b3a0495 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetProductMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetProductMapper.xml @@ -17,13 +17,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + select psp_id, price_set_id, psv_id, product_code, product_name, service_level, cargo_attr, - dest_region, update_note, tax_mode, source_product_id, source_sheet, create_time, update_time + dest_region, update_note, tax_mode, source_product_id, source_sheet, pef_id, + create_time, update_time from prc_price_set_product @@ -52,10 +54,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" insert into prc_price_set_product ( price_set_id, psv_id, product_code, product_name, service_level, cargo_attr, - dest_region, update_note, tax_mode, source_product_id, source_sheet, create_time + dest_region, update_note, tax_mode, source_product_id, source_sheet, pef_id, create_time ) values ( #{priceSetId}, #{psvId}, #{productCode}, #{productName}, #{serviceLevel}, #{cargoAttr}, - #{destRegion}, #{updateNote}, #{taxMode}, #{sourceProductId}, #{sourceSheet}, sysdate() + #{destRegion}, #{updateNote}, #{taxMode}, #{sourceProductId}, #{sourceSheet}, #{pefId}, sysdate() ) @@ -70,6 +72,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" tax_mode = #{taxMode}, source_product_id = #{sourceProductId}, source_sheet = #{sourceSheet}, + pef_id = #{pefId}, update_time = sysdate() where psp_id = #{pspId} @@ -88,11 +91,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" insert into prc_price_set_product ( price_set_id, psv_id, product_code, product_name, service_level, cargo_attr, - dest_region, update_note, tax_mode, source_product_id, source_sheet, create_time + dest_region, update_note, tax_mode, source_product_id, source_sheet, pef_id, create_time ) select price_set_id, #{newPsvId}, product_code, product_name, service_level, cargo_attr, - dest_region, update_note, tax_mode, source_product_id, source_sheet, sysdate() + dest_region, update_note, tax_mode, source_product_id, source_sheet, pef_id, sysdate() from prc_price_set_product where psv_id = #{srcPsvId} order by psp_id asc @@ -159,4 +162,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and (d.psp_id is null or d.psp_id <> sp.psp_id) + + update prc_price_set_product + set pef_id = #{pefId}, update_time = sysdate() + where psv_id = #{psvId} + + and product_code in + + #{code} + + + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetVersionSurchargeMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetVersionSurchargeMapper.xml deleted file mode 100644 index 65acb22e..00000000 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetVersionSurchargeMapper.xml +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - select psvs_id, psv_id, sc_id, product_code, psd_id, sc_name, fee_type, charge_type, - charge_value, currency, round_mode, country_code, sc_note, - created_by, create_time, updated_by, update_time - from prc_price_set_version_surcharge - - - - - - - - insert into prc_price_set_version_surcharge ( - psv_id, - psd_id, - sc_id, - product_code, - sc_name, - fee_type, - charge_type, - charge_value, - currency, - round_mode, - country_code, - sc_note, - created_by, - create_time - ) values ( - #{psvId}, - #{psdId}, - #{scId}, - #{productCode}, - #{scName}, - #{feeType}, - #{chargeType}, - #{chargeValue}, - #{currency}, - #{roundMode}, - #{countryCode}, - #{scNote}, - #{createBy}, - sysdate() - ) - - - - update prc_price_set_version_surcharge - - psd_id = #{psdId}, - sc_id = #{scId}, - product_code = #{productCode}, - sc_name = #{scName}, - fee_type = #{feeType}, - charge_type = #{chargeType}, - charge_value = #{chargeValue}, - currency = #{currency}, - round_mode = #{roundMode}, - country_code = #{countryCode}, - sc_note = #{scNote}, - updated_by = #{updateBy}, - update_time = sysdate() - - where psvs_id = #{psvsId} - - - - delete from prc_price_set_version_surcharge where psvs_id in - - #{psvsId} - - - - - delete from prc_price_set_version_surcharge where psv_id = #{psvId} - - - - delete from prc_price_set_version_surcharge where psd_id = #{psdId} - - - - - - - insert into prc_price_set_version_surcharge ( - psv_id, psd_id, sc_id, product_code, sc_name, fee_type, charge_type, - charge_value, currency, round_mode, country_code, sc_note, - created_by, create_time - ) - select - d.psv_id, - d.psd_id, - r.sc_id, - d.product_code, - s.sc_name, - ifnull(nullif(s.fee_type, ''), 'SURCHARGE'), - ifnull(nullif(s.charge_type, ''), 'FIXED'), - s.charge_value, - ifnull(nullif(s.currency, ''), 'CNY'), - ifnull(nullif(s.round_mode, ''), 'HALF_UP'), - s.country_code, - s.sc_note, - #{createBy}, - sysdate() - from prc_price_set_detail d - inner join prc_surcharge_rel r on r.product_code = d.product_code - inner join prc_surcharge s on s.sc_id = r.sc_id - where d.psv_id = #{psvId} - and ifnull(s.sc_name, '') <> '' - and s.charge_value is not null - and lower(ifnull(s.ss_code, '')) not in ('of', 'vd') - and (r.detail_id is null or r.detail_id = d.product_country_id) - and ( - s.country_code is null or s.country_code = '' - or d.country_code is null or d.country_code = '' - or upper(s.country_code) = upper(d.country_code) - ) - - - - insert into prc_price_set_version_surcharge ( - psv_id, psd_id, sc_id, product_code, sc_name, fee_type, charge_type, - charge_value, currency, round_mode, country_code, sc_note, - created_by, create_time - ) - select - #{newPsvId}, - nd.psd_id, - sc.sc_id, - sc.product_code, - sc.sc_name, - sc.fee_type, - sc.charge_type, - sc.charge_value, - sc.currency, - sc.round_mode, - sc.country_code, - sc.sc_note, - #{createBy}, - sysdate() - from prc_price_set_version_surcharge sc - inner join prc_price_set_detail od - on od.psd_id = sc.psd_id and od.psv_id = #{srcPsvId} - inner join prc_price_set_detail nd - on nd.psv_id = #{newPsvId} - and nd.product_code = od.product_code - and ifnull(nd.country_code, '') = ifnull(od.country_code, '') - and ifnull(nd.zone_code, '') = ifnull(od.zone_code, '') - and ifnull(nd.weight_label, '') = ifnull(od.weight_label, '') - and ifnull(nd.product_country_id, 0) = ifnull(od.product_country_id, 0) - and ifnull(nd.discount_value, -1) = ifnull(od.discount_value, -1) - where sc.psv_id = #{srcPsvId} - - - diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcSurchargeRelMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcSurchargeRelMapper.xml index a6736928..21bfe649 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcSurchargeRelMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcSurchargeRelMapper.xml @@ -10,6 +10,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + @@ -17,7 +19,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - @@ -30,13 +31,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select r.rel_id, r.sc_id, r.product_id, r.product_code, r.detail_id, + r.price_type, r.country_code, r.created_by, r.create_time, r.updated_by, r.update_time, s.sc_name, s.fee_type, s.charge_type, s.charge_value, s.currency, - s.round_mode, s.ss_code, s.country_code, s.sc_note, - c.country_name, c.weight_min, c.weight_max + s.round_mode, s.ss_code, s.sc_note, + ifnull(c.country_name, rc.country_name) as country_name, ifnull(c.weight_min, rc.weight_min) as weight_min, ifnull(c.weight_max, rc.weight_max) as weight_max from prc_surcharge_rel r join prc_surcharge s on s.sc_id = r.sc_id - left join product_country c on c.detail_id = r.detail_id + left join product_country c on c.detail_id = r.detail_id and r.price_type = 'PRODUCT' + left join redline_product_country rc on rc.detail_id = r.detail_id and r.price_type = 'REDLINE' + + insert into prc_surcharge_rel ( - sc_id, product_id, product_code, detail_id, + sc_id, product_id, product_code, detail_id, price_type, country_code, created_by, create_time ) values ( #{scId}, #{productId}, #{productCode}, #{detailId}, + #{priceType}, #{countryCode}, #{createBy}, sysdate() ) @@ -79,6 +102,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{code} + and price_type = 'PRODUCT' + + + + delete from prc_surcharge_rel + where price_type = #{priceType} + and detail_id in + + #{id} + + + + + delete from prc_surcharge_rel + where price_type = #{priceType} + and product_id in + + #{id} + diff --git a/bms-back/sql/bms_init.sql b/bms-back/sql/bms_init.sql index 84de9eb0..d0338252 100644 --- a/bms-back/sql/bms_init.sql +++ b/bms-back/sql/bms_init.sql @@ -345,6 +345,68 @@ CREATE TABLE `product_country` ( CONSTRAINT `fk_product_country_pid` FOREIGN KEY (`product_id`) REFERENCES `product` (`product_id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=1609 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品国家服务明细-重量段+尺寸/尾程/时效(不含运费)'; +-- redline_product / redline_product_country: mirror of product / product_country +-- Run once on existing DB + +CREATE TABLE IF NOT EXISTS `redline_product` ( + `product_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '红线价产品ID', + `product_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品对接代码,如 WSP037', + `product_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品名称', + `service_level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '时效等级', + `cargo_attr` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '走货属性', + `dest_region` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的区域', + `update_note` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新内容说明', + `tax_mode` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'TAX_INCLUDED' COMMENT 'TAX_INCLUDED包税 TAX_EXCLUDED不包税', + `effective_date` datetime DEFAULT NULL COMMENT '目录生效时间(到小时)', + `expire_date` datetime DEFAULT NULL COMMENT '失效时间(空=长期)', + `status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '0待生效 1已生效 2已失效', + `source_sheet` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源Excel sheet名', + `pef_id` bigint(20) DEFAULT NULL COMMENT '关联导入价表Excel附件 prc_price_excel_file.pef_id', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tms_id` int(11) DEFAULT NULL COMMENT '多租户ID', + `created_by` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_by` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`product_id`) USING BTREE, + UNIQUE KEY `uk_redline_product_code_eff` (`product_code`,`effective_date`), + KEY `idx_redline_tax_status` (`tax_mode`,`status`) USING BTREE, + KEY `idx_redline_product_code` (`product_code`), + KEY `idx_redline_product_pef_id` (`pef_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='红线价产品主表'; + +CREATE TABLE IF NOT EXISTS `redline_product_country` ( + `detail_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '明细ID', + `product_id` bigint(20) DEFAULT NULL COMMENT '红线价产品ID redline_product.product_id', + `product_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品代码', + `country_name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国家中文名', + `country_code` char(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ISO2', + `zone_id` bigint(20) DEFAULT NULL COMMENT 'prc_zone.zone_id', + `zone_code` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `zone_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `weight_min` decimal(10,3) DEFAULT NULL COMMENT '重量段最小值KG(含)', + `weight_max` decimal(10,3) DEFAULT NULL COMMENT '重量段最大值KG(含)', + `freight_price` decimal(10,4) DEFAULT NULL COMMENT '运费 RMB/KG', + `registration_fee` decimal(10,2) DEFAULT NULL COMMENT '挂号费 RMB/票', + `size_limit` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `last_mile` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `lead_time` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `effective_date` datetime DEFAULT NULL COMMENT '生效时间(到小时)', + `expire_date` datetime DEFAULT NULL COMMENT '失效时间(空=开放)', + `status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '0待生效 1已生效 2已失效', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`detail_id`) USING BTREE, + UNIQUE KEY `uk_redline_country_zone_weight_eff` (`product_code`,`country_name`,`zone_code`,`weight_min`,`weight_max`,`effective_date`), + KEY `idx_redline_country_code` (`country_code`) USING BTREE, + KEY `idx_redline_weight_match` (`product_code`,`country_code`,`weight_min`,`weight_max`) USING BTREE, + KEY `idx_redline_product_eff` (`product_code`,`effective_date`,`expire_date`), + KEY `idx_redline_country_pid` (`product_id`), + KEY `idx_redline_country_zone` (`zone_id`), + CONSTRAINT `fk_redline_country_pid` FOREIGN KEY (`product_id`) REFERENCES `redline_product` (`product_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='红线价国家服务明细'; + + -- ---------------------------- -- 表结构:`region` -- ---------------------------- @@ -1351,36 +1413,6 @@ CREATE TABLE `prc_price_set_product` ( CONSTRAINT `fk_psp_set` FOREIGN KEY (`price_set_id`) REFERENCES `prc_price_set` (`price_set_id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='价格套餐产品主表-与产品库快照独立'; --- ---------------------------- --- 表结构:`prc_price_set_version_surcharge` --- ---------------------------- -CREATE TABLE `prc_price_set_version_surcharge` ( - `psvs_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '版本附加费id', - `psv_id` bigint(20) NOT NULL COMMENT '套餐版本id', - `psd_id` bigint(20) NOT NULL COMMENT '套餐明细id', - `sc_id` bigint(20) DEFAULT NULL COMMENT '来源附加费配置id', - `product_code` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品代码(与明细一致)', - `sc_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '附加费名称快照', - `fee_type` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'SURCHARGE' COMMENT '费用类型(字典bms_fee_type)', - `charge_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '计费方式 FIXED固定金额/WEIGHT按重量/RATE按比例', - `charge_value` decimal(18,6) NOT NULL COMMENT '计费数值快照', - `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '币种', - `round_mode` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'HALF_UP' COMMENT '取值方式(字典bms_round_mode)', - `country_code` char(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '适用国家ISO2(空=全部)', - `sc_note` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', - `created_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建人', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - `updated_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新人', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`psvs_id`), - KEY `idx_psvs_psv` (`psv_id`), - KEY `idx_psvs_psd` (`psd_id`), - KEY `idx_psvs_product` (`psv_id`,`product_code`), - KEY `idx_psvs_sc` (`sc_id`), - CONSTRAINT `fk_psvs_psd` FOREIGN KEY (`psd_id`) REFERENCES `prc_price_set_detail` (`psd_id`) ON DELETE CASCADE, - CONSTRAINT `fk_psvs_psv` FOREIGN KEY (`psv_id`) REFERENCES `prc_price_set_version` (`psv_id`) ON DELETE CASCADE -) ENGINE=InnoDB AUTO_INCREMENT=487 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='价格套餐版本附加费快照'; - -- ---------------------------- -- 表结构:`prc_quote` -- ---------------------------- @@ -1488,22 +1520,25 @@ CREATE TABLE `prc_surcharge` ( CREATE TABLE `prc_surcharge_rel` ( `rel_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '关联ID', `sc_id` bigint(20) NOT NULL COMMENT '附加费配置ID prc_surcharge.sc_id', - `product_id` bigint(20) NOT NULL COMMENT '产品ID product.product_id', + `product_id` bigint(20) NOT NULL COMMENT '产品ID或套餐产品ID:PRODUCT/REDLINE=product.product_id;SALES=prc_price_set_product.psp_id', `product_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品对接代码 product.product_code', - `detail_id` bigint(20) DEFAULT NULL COMMENT '产品明细ID product_country.detail_id(空=作用于产品全部明细)', + `detail_id` bigint(20) DEFAULT NULL COMMENT '明细ID:PRODUCT/REDLINE=product_country.detail_id(空=产品全部);SALES=prc_price_set_detail.psd_id', + `price_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PRODUCT' COMMENT '价格类型 PRODUCT产品价/SALES销售价/REDLINE红线价', + `country_code` char(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '适用国家ISO2(空=全部)', `created_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建人', `create_time` datetime DEFAULT NULL, `updated_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新人', `update_time` datetime DEFAULT NULL, PRIMARY KEY (`rel_id`), - UNIQUE KEY `uk_rel_scope` (`sc_id`,`product_id`,`detail_id`), + UNIQUE KEY `uk_rel_scope` (`sc_id`,`product_id`,`detail_id`,`price_type`,`country_code`), KEY `idx_rel_sc` (`sc_id`), KEY `idx_rel_product` (`product_id`), KEY `idx_rel_product_code` (`product_code`), KEY `idx_rel_detail` (`detail_id`), - CONSTRAINT `fk_rel_product` FOREIGN KEY (`product_id`) REFERENCES `product` (`product_id`) ON DELETE CASCADE, + KEY `idx_rel_price_type` (`price_type`), + KEY `idx_rel_country` (`country_code`), CONSTRAINT `fk_rel_sc` FOREIGN KEY (`sc_id`) REFERENCES `prc_surcharge` (`sc_id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='附加费与产品/明细关联表'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='附加费关联表(按price_type多态关联产品或套餐)'; -- ---------------------------- -- 表结构:`prc_weight_section_detail` diff --git a/bms-back/sql/price/prc_price_excel_file.sql b/bms-back/sql/price/prc_price_excel_file.sql new file mode 100644 index 00000000..033e5101 --- /dev/null +++ b/bms-back/sql/price/prc_price_excel_file.sql @@ -0,0 +1,37 @@ +-- ------------------------------------------------------------ +-- 导入价表 Excel 附件记录(建表/字段) +-- 用途:产品价/红线价/销售价 Excel 导入时落库文件元数据 +-- 关联:product.pef_id、prc_price_set_product.pef_id -> prc_price_excel_file.pef_id +-- ------------------------------------------------------------ + +-- 1) 主表:导入价表 Excel 附件 +CREATE TABLE IF NOT EXISTS prc_price_excel_file ( + pef_id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + excel_type varchar(20) NOT NULL COMMENT '价表类型 PRODUCT=产品价 REDLINE=红线价 SALES=销售价', + effective_time datetime NOT NULL COMMENT '生效时间(导入时选择/解析)', + file_name varchar(255) NOT NULL COMMENT '文件原始名称', + file_path varchar(500) NOT NULL COMMENT '文件存储路径(相对路径或完整URL)', + file_size bigint(20) DEFAULT 0 COMMENT '文件大小(字节)', + content_type varchar(100) DEFAULT '' COMMENT 'MIME类型', + file_extension varchar(20) DEFAULT '' COMMENT '扩展名,如 xlsx/xls', + oss_id varchar(64) DEFAULT NULL COMMENT 'OSS/对象存储ID(可空)', + sheet_names varchar(1000) DEFAULT NULL COMMENT '导入涉及的 sheet 名(多个逗号分隔,可空)', + remark varchar(500) DEFAULT NULL COMMENT '备注', + created_by varchar(64) DEFAULT '' COMMENT '创建人', + create_time datetime DEFAULT NULL COMMENT '创建时间', + updated_by varchar(64) DEFAULT '' COMMENT '更新人', + update_time datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (pef_id), + KEY idx_pef_type_eff (excel_type, effective_time), + KEY idx_pef_create_time (create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导入价表Excel附件'; + +-- 2) 产品主表:关联导入附件 +ALTER TABLE product + ADD COLUMN pef_id bigint(20) DEFAULT NULL COMMENT '关联导入价表Excel附件 prc_price_excel_file.pef_id' AFTER source_sheet, + ADD KEY idx_product_pef_id (pef_id); + +-- 3) 套餐产品表:关联导入附件(销售价 Excel 建价) +ALTER TABLE prc_price_set_product + ADD COLUMN pef_id bigint(20) DEFAULT NULL COMMENT '关联导入价表Excel附件 prc_price_excel_file.pef_id' AFTER source_sheet, + ADD KEY idx_psp_pef_id (pef_id); diff --git a/bms-back/sql/price/prc_surcharge_rel_price_type_alter.sql b/bms-back/sql/price/prc_surcharge_rel_price_type_alter.sql new file mode 100644 index 00000000..bb70e5e6 --- /dev/null +++ b/bms-back/sql/price/prc_surcharge_rel_price_type_alter.sql @@ -0,0 +1,80 @@ +-- prc_surcharge_rel: add price_type + country_code; drop version surcharge snapshot +-- Run once on existing DB + +-- 1) drop FK to product (SALES rows store psp_id in product_id) +SET @fk_exists := ( + SELECT COUNT(1) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prc_surcharge_rel' + AND CONSTRAINT_NAME = 'fk_rel_product' AND CONSTRAINT_TYPE = 'FOREIGN KEY' +); +SET @sql := IF(@fk_exists > 0, + 'ALTER TABLE `prc_surcharge_rel` DROP FOREIGN KEY `fk_rel_product`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2) price_type +SET @col_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prc_surcharge_rel' AND COLUMN_NAME = 'price_type' +); +SET @sql := IF(@col_exists = 0, + CONCAT( + 'ALTER TABLE `prc_surcharge_rel` ', + 'ADD COLUMN `price_type` varchar(20) NOT NULL DEFAULT ''PRODUCT'' ', + 'COMMENT ''价格类型 PRODUCT产品价/SALES销售价/REDLINE红线价'' AFTER `detail_id`' + ), + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 3) country_code on rel +SET @col_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prc_surcharge_rel' AND COLUMN_NAME = 'country_code' +); +SET @sql := IF(@col_exists = 0, + CONCAT( + 'ALTER TABLE `prc_surcharge_rel` ', + 'ADD COLUMN `country_code` char(2) DEFAULT NULL ', + 'COMMENT ''适用国家ISO2(空=全部)'' AFTER `price_type`' + ), + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4) backfill country from surcharge master when rel country empty +UPDATE prc_surcharge_rel r +INNER JOIN prc_surcharge s ON s.sc_id = r.sc_id +SET r.country_code = s.country_code +WHERE (r.country_code IS NULL OR r.country_code = '') + AND s.country_code IS NOT NULL AND s.country_code <> ''; + +-- 5) rebuild unique key +SET @uk_exists := ( + SELECT COUNT(1) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prc_surcharge_rel' AND INDEX_NAME = 'uk_rel_scope' +); +SET @sql := IF(@uk_exists > 0, + 'ALTER TABLE `prc_surcharge_rel` DROP INDEX `uk_rel_scope`', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @uk_exists2 := ( + SELECT COUNT(1) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prc_surcharge_rel' AND INDEX_NAME = 'uk_rel_scope' +); +SET @sql := IF(@uk_exists2 = 0, + 'ALTER TABLE `prc_surcharge_rel` ADD UNIQUE KEY `uk_rel_scope` (`sc_id`, `product_id`, `detail_id`, `price_type`, `country_code`)', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 6) drop version surcharge snapshot table +DROP TABLE IF EXISTS `prc_price_set_version_surcharge`; diff --git a/bms-back/sql/price/redline_product.sql b/bms-back/sql/price/redline_product.sql new file mode 100644 index 00000000..87981282 --- /dev/null +++ b/bms-back/sql/price/redline_product.sql @@ -0,0 +1,105 @@ +-- redline_product / redline_product_country: mirror of product / product_country +-- Run once on existing DB + +CREATE TABLE IF NOT EXISTS `redline_product` ( + `product_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '红线价产品ID', + `product_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品对接代码,如 WSP037', + `product_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品名称', + `service_level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '时效等级', + `cargo_attr` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '走货属性', + `dest_region` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的区域', + `update_note` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新内容说明', + `tax_mode` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'TAX_INCLUDED' COMMENT 'TAX_INCLUDED包税 TAX_EXCLUDED不包税', + `effective_date` datetime DEFAULT NULL COMMENT '目录生效时间(到小时)', + `expire_date` datetime DEFAULT NULL COMMENT '失效时间(空=长期)', + `status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '0待生效 1已生效 2已失效', + `source_sheet` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源Excel sheet名', + `pef_id` bigint(20) DEFAULT NULL COMMENT '关联导入价表Excel附件 prc_price_excel_file.pef_id', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tms_id` int(11) DEFAULT NULL COMMENT '多租户ID', + `created_by` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_by` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`product_id`) USING BTREE, + UNIQUE KEY `uk_redline_product_code_eff` (`product_code`,`effective_date`), + KEY `idx_redline_tax_status` (`tax_mode`,`status`) USING BTREE, + KEY `idx_redline_product_code` (`product_code`), + KEY `idx_redline_product_pef_id` (`pef_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='红线价产品主表'; + +CREATE TABLE IF NOT EXISTS `redline_product_country` ( + `detail_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '明细ID', + `product_id` bigint(20) DEFAULT NULL COMMENT '红线价产品ID redline_product.product_id', + `product_code` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品代码', + `country_name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国家中文名', + `country_code` char(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ISO2', + `zone_id` bigint(20) DEFAULT NULL COMMENT 'prc_zone.zone_id', + `zone_code` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `zone_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `weight_min` decimal(10,3) DEFAULT NULL COMMENT '重量段最小值KG(含)', + `weight_max` decimal(10,3) DEFAULT NULL COMMENT '重量段最大值KG(含)', + `freight_price` decimal(10,4) DEFAULT NULL COMMENT '运费 RMB/KG', + `registration_fee` decimal(10,2) DEFAULT NULL COMMENT '挂号费 RMB/票', + `size_limit` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `last_mile` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `lead_time` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `effective_date` datetime DEFAULT NULL COMMENT '生效时间(到小时)', + `expire_date` datetime DEFAULT NULL COMMENT '失效时间(空=开放)', + `status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '0待生效 1已生效 2已失效', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`detail_id`) USING BTREE, + UNIQUE KEY `uk_redline_country_zone_weight_eff` (`product_code`,`country_name`,`zone_code`,`weight_min`,`weight_max`,`effective_date`), + KEY `idx_redline_country_code` (`country_code`) USING BTREE, + KEY `idx_redline_weight_match` (`product_code`,`country_code`,`weight_min`,`weight_max`) USING BTREE, + KEY `idx_redline_product_eff` (`product_code`,`effective_date`,`expire_date`), + KEY `idx_redline_country_pid` (`product_id`), + KEY `idx_redline_country_zone` (`zone_id`), + CONSTRAINT `fk_redline_country_pid` FOREIGN KEY (`product_id`) REFERENCES `redline_product` (`product_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='红线价国家服务明细'; + +-- Menu: under 价栶基础 parent (lookup by path price/basic or menu_name) +-- Adjust @parentId if your env differs +SET @parentId := ( + SELECT menu_id FROM sys_menu + WHERE path = 'basic' AND menu_type = 'M' + ORDER BY menu_id ASC LIMIT 1 +); +SET @parentId := IFNULL(@parentId, ( + SELECT parent_id FROM sys_menu + WHERE perms = 'price:product:list' OR component LIKE '%price/basic/product%' + ORDER BY menu_id ASC LIMIT 1 +)); + +-- skip insert if already exists +SET @exists := ( + SELECT COUNT(1) FROM sys_menu WHERE perms = 'price:redline:list' OR component = 'price/basic/redline/index' +); + +-- Only insert when parent found and not exists +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '红线价', @parentId, 15, 'redline', 'price/basic/redline/index', 1, 0, 'C', '0', '0', 'price:redline:list', 'money', 'admin', sysdate(), '红线价目录' +WHERE @parentId IS NOT NULL AND @exists = 0; + +SET @menuId := (SELECT menu_id FROM sys_menu WHERE perms = 'price:redline:list' LIMIT 1); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '红线价查询', @menuId, 1, '', '', 1, 0, 'F', '0', '0', 'price:redline:query', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:redline:query'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '红线价新增', @menuId, 2, '', '', 1, 0, 'F', '0', '0', 'price:redline:add', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:redline:add'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '红线价修改', @menuId, 3, '', '', 1, 0, 'F', '0', '0', 'price:redline:edit', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:redline:edit'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '红线价删除', @menuId, 4, '', '', 1, 0, 'F', '0', '0', 'price:redline:remove', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:redline:remove'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '红线价导入', @menuId, 5, '', '', 1, 0, 'F', '0', '0', 'price:redline:import', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:redline:import'); diff --git a/bms-front/src/api/price/product.js b/bms-front/src/api/price/product.js index c798bc96..a90b5634 100644 --- a/bms-front/src/api/price/product.js +++ b/bms-front/src/api/price/product.js @@ -167,3 +167,11 @@ export async function importProductCountryExcelAsync(file, countryCode, effectiv } return pollProductImportTask(taskId, onProgress) } + +/** List imported Excel attachments for product_code */ +export function listProductExcelFiles(productCode) { + return request({ + url: '/price/product/' + encodeURIComponent(productCode) + '/excelFiles', + method: 'get' + }) +} diff --git a/bms-front/src/api/price/redline.js b/bms-front/src/api/price/redline.js new file mode 100644 index 00000000..4500a80b --- /dev/null +++ b/bms-front/src/api/price/redline.js @@ -0,0 +1,177 @@ +import request from '@/utils/request' + +/** 查询红线价列表 */ +export function listRedline(query) { + return request({ + url: '/price/redline/list', + method: 'get', + params: query + }) +} + +/** 产品下拉选项(非分页,不要求 price:redline:list 权限) */ +export function listRedlineOptions(query) { + return request({ + url: '/price/redline/optionselect', + method: 'get', + params: query + }) +} + +/** 查询产品详情 */ +export function getRedline(productId) { + return request({ + url: '/price/redline/' + productId, + method: 'get' + }) +} + +/** 查询产品国家明细(优先 productId,绑定到具体产品版本行) */ +export function listRedlineCountries(productCode, effectiveDate, productId) { + const params = {} + if (effectiveDate) params.effectiveDate = effectiveDate + if (productId) params.productId = productId + return request({ + url: '/price/redline/' + productCode + '/countries', + method: 'get', + params + }) +} + +/** 修改产品国家明细行(重量段/运费/挂号费/尺寸/尾程/时效/生效失效日期) */ +export function updateRedlineCountry(data) { + return request({ + url: '/price/redline/country', + method: 'put', + data: data + }) +} + +/** 新增红线价 */ +export function addRedline(data) { + return request({ + url: '/price/redline', + method: 'post', + data: data + }) +} + +/** 修改产品 */ +export function updateRedline(data) { + return request({ + url: '/price/redline', + method: 'put', + data: data + }) +} + +/** 删除产品 */ +export function delRedline(productIds) { + return request({ + url: '/price/redline/' + productIds, + method: 'delete' + }) +} + +export function getRedlineImportTask(taskId) { + return request({ + url: '/price/redline/import/task/' + taskId, + method: 'get' + }) +} + +function pickImportTaskId(res) { + if (!res) return null + const d = res.data + if (d && typeof d === 'object' && d.taskId) return String(d.taskId) + if (typeof d === 'string' && d.trim()) return d.trim() + if (typeof res.msg === 'string') { + const m = res.msg.trim() + if (/^[a-f0-9]{32}$/i.test(m)) return m + if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(m)) { + return m.replace(/-/g, '') + } + } + return null +} + +async function pollProductImportTask(taskId, onProgress) { + const started = Date.now() + const maxMs = 30 * 60 * 1000 + while (Date.now() - started < maxMs) { + const res = await getRedlineImportTask(taskId) + const task = res.data || {} + if (typeof onProgress === 'function') { + onProgress(task) + } + if (task.status === 'SUCCESS') { + return { task } + } + if (task.status === 'FAIL') { + const err = new Error(task.error || task.message || 'import failed') + err.task = task + throw err + } + await new Promise(resolve => setTimeout(resolve, 800)) + } + throw new Error('import timeout') +} + +/** + * Async all-country product import; poll until done. + * @param {File|Blob} file + * @param {string} effectiveDate yyyy-MM-dd (required) + * @param {(task: object) => void} [onProgress] + */ +export async function importRedlineExcelAsync(file, effectiveDate, onProgress) { + const formData = new FormData() + formData.append('file', file) + const params = new URLSearchParams() + params.set('updateSupport', '0') + if (effectiveDate) params.set('effectiveDate', effectiveDate) + const submitRes = await request({ + url: '/price/redline/importData?' + params.toString(), + method: 'post', + data: formData, + headers: { 'Content-Type': 'multipart/form-data' } + }) + const taskId = pickImportTaskId(submitRes) + if (!taskId) { + throw new Error('missing import taskId') + } + return pollProductImportTask(taskId, onProgress) +} + +/** + * Async single-country product import; poll until done. + * @param {File|Blob} file + * @param {string} countryCode + * @param {string} effectiveDate yyyy-MM-dd + * @param {(task: object) => void} [onProgress] + */ +export async function importRedlineCountryExcelAsync(file, countryCode, effectiveDate, onProgress) { + const formData = new FormData() + formData.append('file', file) + const params = new URLSearchParams() + params.set('countryCode', countryCode) + if (effectiveDate) params.set('effectiveDate', effectiveDate) + const submitRes = await request({ + url: '/price/redline/importCountryData?' + params.toString(), + method: 'post', + data: formData, + headers: { 'Content-Type': 'multipart/form-data' } + }) + const taskId = pickImportTaskId(submitRes) + if (!taskId) { + throw new Error('missing import taskId') + } + return pollProductImportTask(taskId, onProgress) +} + +/** List imported Excel attachments for product_code */ +export function listProductExcelFiles(productCode) { + return request({ + url: '/price/redline/' + encodeURIComponent(productCode) + '/excelFiles', + method: 'get' + }) +} diff --git a/bms-front/src/api/price/surcharge.js b/bms-front/src/api/price/surcharge.js index cae0d1ad..8adf20f5 100644 --- a/bms-front/src/api/price/surcharge.js +++ b/bms-front/src/api/price/surcharge.js @@ -20,10 +20,11 @@ export function getSurcharge(scId) { } /** 查询指定产品的附加费关联(非分页,含附加费与明细展示字段) */ -export function listSurchargeByProduct(productCode) { +export function listSurchargeByProduct(productCode, priceType) { return request({ url: '/price/surcharge/byProduct/' + productCode, - method: 'get' + method: 'get', + params: priceType ? { priceType } : undefined }) } diff --git a/bms-front/src/views/price/basic/product/components/ProductExcelFileDrawer.vue b/bms-front/src/views/price/basic/product/components/ProductExcelFileDrawer.vue new file mode 100644 index 00000000..ac329c47 --- /dev/null +++ b/bms-front/src/views/price/basic/product/components/ProductExcelFileDrawer.vue @@ -0,0 +1,65 @@ + + + diff --git a/bms-front/src/views/price/basic/product/components/ProductExpiredDrawer.vue b/bms-front/src/views/price/basic/product/components/ProductExpiredDrawer.vue index 44310150..8631cfeb 100644 --- a/bms-front/src/views/price/basic/product/components/ProductExpiredDrawer.vue +++ b/bms-front/src/views/price/basic/product/components/ProductExpiredDrawer.vue @@ -52,8 +52,16 @@ - + + + diff --git a/bms-front/src/views/price/basic/redline/components/RedlineExcelFileDrawer.vue b/bms-front/src/views/price/basic/redline/components/RedlineExcelFileDrawer.vue new file mode 100644 index 00000000..0704a0bd --- /dev/null +++ b/bms-front/src/views/price/basic/redline/components/RedlineExcelFileDrawer.vue @@ -0,0 +1,65 @@ + + + diff --git a/bms-front/src/views/price/basic/redline/components/RedlineExpiredDrawer.vue b/bms-front/src/views/price/basic/redline/components/RedlineExpiredDrawer.vue new file mode 100644 index 00000000..cc96c16c --- /dev/null +++ b/bms-front/src/views/price/basic/redline/components/RedlineExpiredDrawer.vue @@ -0,0 +1,148 @@ + + + diff --git a/bms-front/src/views/price/basic/redline/components/RedlineFormDialog.vue b/bms-front/src/views/price/basic/redline/components/RedlineFormDialog.vue new file mode 100644 index 00000000..df7eefcd --- /dev/null +++ b/bms-front/src/views/price/basic/redline/components/RedlineFormDialog.vue @@ -0,0 +1,167 @@ + + + diff --git a/bms-front/src/views/price/basic/redline/components/RedlineSurchargeDrawer.vue b/bms-front/src/views/price/basic/redline/components/RedlineSurchargeDrawer.vue new file mode 100644 index 00000000..d6e66183 --- /dev/null +++ b/bms-front/src/views/price/basic/redline/components/RedlineSurchargeDrawer.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/bms-front/src/views/price/basic/redline/components/RedlineSurchargeRelDialog.vue b/bms-front/src/views/price/basic/redline/components/RedlineSurchargeRelDialog.vue new file mode 100644 index 00000000..2b3a498b --- /dev/null +++ b/bms-front/src/views/price/basic/redline/components/RedlineSurchargeRelDialog.vue @@ -0,0 +1,121 @@ + + + diff --git a/bms-front/src/views/price/basic/redline/index.vue b/bms-front/src/views/price/basic/redline/index.vue new file mode 100644 index 00000000..8b6f472b --- /dev/null +++ b/bms-front/src/views/price/basic/redline/index.vue @@ -0,0 +1,375 @@ + + + + + diff --git a/bms-front/src/views/price/set/components/create/PriceEditPanel.vue b/bms-front/src/views/price/set/components/create/PriceEditPanel.vue index c35f46e2..6dfe8d2c 100644 --- a/bms-front/src/views/price/set/components/create/PriceEditPanel.vue +++ b/bms-front/src/views/price/set/components/create/PriceEditPanel.vue @@ -11,13 +11,14 @@
关闭 - 完成 + 完成
@@ -38,7 +43,8 @@ import { ArrowLeft } from '@element-plus/icons-vue' import StepDetails from './StepDetails.vue' import { usePriceSetRefData } from '../../composables/usePriceSetRefData' -import { getPriceSet } from '@/api/price/set' +import { isBuildModeLockedVersion } from '../../utils/priceSet' +import { getPriceSet, savePriceSetAsync } from '@/api/price/set' const props = defineProps({ priceSetId: { type: [Number, String], default: undefined }, @@ -53,9 +59,18 @@ const refData = usePriceSetRefData() const loading = ref(false) const loaded = ref(false) const setType = ref('FIXED') -const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined, priceBuildMode: undefined }) +const priceSetName = ref('') +const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined, status: undefined, priceBuildMode: undefined }) +const savedPriceBuildMode = ref(undefined) +const saving = ref(false) const details = ref([]) const products = ref([]) +const detailsRef = ref() +/** Snapshot of products under the last saved build mode (restore if switch back before Complete) */ +const savedProductsSnapshot = ref([]) +const buildModeDirty = computed(() => + String(version.priceBuildMode || '') !== String(savedPriceBuildMode.value || '') +) const title = computed(() => '编辑产品价格 · ' + (version.versionNo || '')) @@ -70,6 +85,7 @@ function load() { getPriceSet(id).then(res => { const data = res.data || {} setType.value = data.setType || 'FIXED' + priceSetName.value = data.priceSetName || '' const versions = data.versions || [] const qPsv = props.psvId @@ -78,16 +94,107 @@ function load() { version.psvId = current.psvId version.versionNo = current.versionNo || 'V1' version.effectiveTime = current.effectiveTime + version.status = current.status version.priceBuildMode = current.priceBuildMode + savedPriceBuildMode.value = current.priceBuildMode details.value = [] products.value = (current.products || []).map(p => ({ ...p })) + savedProductsSnapshot.value = products.value.map(p => ({ ...p })) loaded.value = true }).finally(() => { loading.value = false }) } +function onPriceBuildModeUpdate(mode) { + if (!mode || isBuildModeLockedVersion(version.status)) return + if (mode === version.priceBuildMode) return + const leavingSaved = String(version.priceBuildMode || '') === String(savedPriceBuildMode.value || '') + if (leavingSaved) { + savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p })) + } + version.priceBuildMode = mode + if (detailsRef.value && detailsRef.value.clearLocalDraft) { + detailsRef.value.clearLocalDraft() + } + details.value = [] + const backToSaved = String(mode || '') === String(savedPriceBuildMode.value || '') + if (backToSaved) { + products.value = (savedProductsSnapshot.value || []).map(p => ({ ...p })) + if (!products.value.length) { + reloadSavedProducts() + } else { + proxy.$modal.msgSuccess("已恢复原建价方式及产品列表(未保存,库数据未变)") + } + return + } + products.value = [] + proxy.$modal.msgSuccess("已切换建价方式(本地),请点「完成」保存后再拉取或导入;切回原方式可恢复列表") +} + +function reloadSavedProducts() { + const id = props.priceSetId + if (!id) return + loading.value = true + getPriceSet(id).then(res => { + const data = res.data || {} + const versions = data.versions || [] + const qPsv = props.psvId || version.psvId + const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null + const current = byQuery || data.version || versions[0] || {} + products.value = (current.products || []).map(p => ({ ...p })) + savedProductsSnapshot.value = products.value.map(p => ({ ...p })) + proxy.$modal.msgSuccess("已恢复原建价方式及产品列表(未保存,库数据未变)") + }).finally(() => { + loading.value = false + }) +} + +async function finish() { + if (!props.priceSetId) { + close() + return + } + if (!buildModeDirty.value) { + close() + return + } + if (!version.priceBuildMode) { + proxy.$modal.msgWarning("请先选择创建价格方式") + return + } + saving.value = true + try { + const payload = { + priceSetId: props.priceSetId, + priceSetName: priceSetName.value, + setType: setType.value || 'FIXED', + version: { + psvId: version.psvId, + versionNo: version.versionNo, + priceBuildMode: version.priceBuildMode + } + } + await savePriceSetAsync(payload) + savedPriceBuildMode.value = version.priceBuildMode + savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p })) + await nextTick() + if (version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) { + await detailsRef.value.pullAllFromLibrary({ silent: true }) + proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,已重新拉取产品列表") + } else { + proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,可导入 Excel 建价") + } + close() + } catch (e) { + const msg = (e && e.task && e.task.error) || (e && e.message) || "保存失败" + proxy.$modal.msgError(msg) + } finally { + saving.value = false + } +} + function close() { emit('close') } diff --git a/bms-front/src/views/price/set/components/create/StepDetails.vue b/bms-front/src/views/price/set/components/create/StepDetails.vue index 216dd676..56b7839e 100644 --- a/bms-front/src/views/price/set/components/create/StepDetails.vue +++ b/bms-front/src/views/price/set/components/create/StepDetails.vue @@ -17,21 +17,21 @@ v-if="showImportAction" type="primary" size="small" - :disabled="(livePersist && !priceSetId) || pulling" + :disabled="(livePersist && !priceSetId) || pulling || buildModeDirty" @click="openImport" >{{ importActionLabel }} -
+

创建价格方式