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 42a4719c..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)); } /** 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/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/PrcPriceExcelFileMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceExcelFileMapper.java index bc16572b..a5f1bd06 100644 --- 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 @@ -16,6 +16,8 @@ public interface PrcPriceExcelFileMapper /** 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); 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 index 98433644..c985c228 100644 --- 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 @@ -14,6 +14,8 @@ public interface IPrcPriceExcelFileService /** 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. * 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/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/PrcPriceExcelFileServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcPriceExcelFileServiceImpl.java index 5ea440a9..212a2869 100644 --- 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 @@ -48,6 +48,17 @@ public class PrcPriceExcelFileServiceImpl implements IPrcPriceExcelFileService 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 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 8ae35c81..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 @@ -23,30 +23,20 @@ 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); - List list = relMapper.selectPrcSurchargeRelList(query); - if (list == null || list.isEmpty()) + if (StringUtils.isNotEmpty(priceType)) { - return list; + query.setPriceType(priceType.trim().toUpperCase()); } - // Product drawer: PRODUCT + REDLINE only (SALES binds psp_id/psd_id) - List filtered = new java.util.ArrayList(); - for (PrcSurchargeRel rel : list) - { - if (rel == null) - { - continue; - } - String type = rel.getPriceType(); - if (StringUtils.isEmpty(type) || PrcSurchargeRel.PRICE_TYPE_PRODUCT.equals(type) - || PrcSurchargeRel.PRICE_TYPE_REDLINE.equals(type)) - { - filtered.add(rel); - } - } - return filtered; + return relMapper.selectPrcSurchargeRelList(query); } @Override 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 e280ce20..bb45dbeb 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,6 +25,7 @@ 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; @@ -42,6 +43,7 @@ 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; @@ -94,6 +96,9 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService @Autowired private BmsProductCountryMapper productCountryMapper; + @Autowired + private BmsRedlineProductCountryMapper redlineProductCountryMapper; + @Autowired private BmsProductMapper productMapper; @@ -874,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; } @@ -929,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; } @@ -961,6 +968,30 @@ 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); + r.setRedlineAmount(freight.add(baseReg).setScale(2, rm)); + } + private void applySurchargesAndFees(TmsPriceQuoteResult result) { BigDecimal freightAmount = nvl(result.getFreightAmount()); @@ -1140,6 +1171,10 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService { 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()); } 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 501f8562..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 @@ -40,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 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/PrcPriceExcelFileMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceExcelFileMapper.xml index 2713d744..68ac61d2 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceExcelFileMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceExcelFileMapper.xml @@ -113,4 +113,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + 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 323c7bdc..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 @@ -35,11 +35,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 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.sc_note, - c.country_name, c.weight_min, c.weight_max + 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 - and r.price_type in ('PRODUCT', 'REDLINE') + 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'