diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/LabelPackageController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/LabelPackageController.java index e8fab8af..a56c953f 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/LabelPackageController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/LabelPackageController.java @@ -8,6 +8,7 @@ import java.util.Locale; import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -24,16 +25,19 @@ import com.bms.common.core.domain.AjaxResult; import com.bms.common.core.page.TableDataInfo; import com.bms.common.enums.BusinessType; import com.bms.common.exception.ServiceException; -import com.bms.price.domain.PayProduct; -import com.bms.price.domain.PaySaveCommand; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblSurcharge; +import com.bms.price.domain.LblWeightBand; +import com.bms.price.domain.LblSaveCommand; + import com.bms.price.domain.PrcCustomerPriceSet; import com.bms.price.domain.PrcCustomerPriceSetBatch; import com.bms.price.domain.PrcPriceSet; -import com.bms.price.service.IPayProductService; +import com.bms.price.service.ILblProductService; import com.bms.price.service.IPrcPriceSetService; /** - * 打单套餐:套餐壳 + 应收价卡(pay_*)。 + * 打单套餐:套餐壳 + 独立打单价卡(prc_label_*)。 */ @RestController @RequestMapping("/price/label") @@ -43,7 +47,7 @@ public class LabelPackageController extends BaseController private IPrcPriceSetService priceSetService; @Autowired - private IPayProductService payProductService; + private ILblProductService lblProductService; @PreAuthorize("@ss.hasPermi('price:label:list')") @GetMapping("/list") @@ -127,30 +131,37 @@ public class LabelPackageController extends BaseController priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_LABEL); } // 打单独立预览:已存在按本套餐判断,不与应付公共模板互挡 - return success(payProductService.previewLabel(file.getBytes(), name, dictValue, progressId, priceSetId)); + return success(lblProductService.preview(file.getBytes(), name, dictValue, progressId, priceSetId)); } @PreAuthorize("@ss.hasPermi('price:label:import')") @GetMapping("/preview/progress") public AjaxResult parseProgress(@RequestParam("progressId") String progressId) { - return success(payProductService.parseProgress(progressId)); + return success(lblProductService.parseProgress(progressId)); } @PreAuthorize("@ss.hasPermi('price:label:import')") @Log(title = "打单套餐导入", businessType = BusinessType.IMPORT) @PostMapping("/importSave") - public AjaxResult importSave(@RequestBody PaySaveCommand command) + public AjaxResult importSave(@RequestBody LblSaveCommand command) { if (command == null) { throw new ServiceException("保存参数不能为空"); } - String message = payProductService.saveLabel(command); + String taskId = lblProductService.submitSave(command); Map payload = new HashMap(); - payload.put("message", message); - payload.put("priceSetId", command.getPriceSetId()); - return success(payload); + payload.put("taskId", taskId); + // data.taskId + 顶层 taskId,兼容前端不同取值方式 + return success(payload).put("taskId", taskId); + } + + @PreAuthorize("@ss.hasPermi('price:label:import')") + @GetMapping("/importSave/task/{taskId}") + public AjaxResult importSaveTask(@PathVariable String taskId) + { + return success(lblProductService.getSaveTask(taskId)); } @PreAuthorize("@ss.hasPermi('price:label:list')") @@ -158,7 +169,7 @@ public class LabelPackageController extends BaseController public AjaxResult versions(@PathVariable Long priceSetId) { priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_LABEL); - return success(payProductService.selectLabelVersions(priceSetId)); + return success(lblProductService.selectVersions(priceSetId)); } @PreAuthorize("@ss.hasPermi('price:label:edit')") @@ -169,7 +180,7 @@ public class LabelPackageController extends BaseController { priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_LABEL); Date effectiveTime = parseEffectiveTime(body == null ? null : body.get("effectiveTime")); - String message = payProductService.activateLabelVersion(priceSetId, versionNo, effectiveTime); + String message = lblProductService.activateVersion(priceSetId, versionNo, effectiveTime); Map payload = new HashMap(); payload.put("message", message); return success(payload); @@ -181,7 +192,7 @@ public class LabelPackageController extends BaseController public AjaxResult deleteVersion(@PathVariable Long priceSetId, @PathVariable Integer versionNo) { priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_LABEL); - return toAjax(payProductService.deleteLabelVersion(priceSetId, versionNo)); + return toAjax(lblProductService.deleteVersion(priceSetId, versionNo)); } @PreAuthorize("@ss.hasPermi('price:label:list')") @@ -192,32 +203,53 @@ public class LabelPackageController extends BaseController priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_LABEL); if (versionNo != null) { - return success(payProductService.selectLabelProductsByVersion(priceSetId, versionNo)); + return success(lblProductService.selectProductsByVersion(priceSetId, versionNo)); } - return success(payProductService.selectLabelProducts(priceSetId)); + return success(lblProductService.selectProducts(priceSetId)); } @PreAuthorize("@ss.hasPermi('price:label:query')") @GetMapping("/product/{productId}") public AjaxResult productDetail(@PathVariable Long productId, - @RequestParam(value = "termId", required = false) Long termId) + @RequestParam(value = "termId", required = false) Long termId, + @RequestParam(value = "versionId", required = false) Long versionId) { - if (termId != null) + Long vid = versionId != null ? versionId : termId; + if (vid != null) { - return success(payProductService.selectPayProductDetailByTerm(termId)); + return success(lblProductService.selectDetailByVersionId(vid)); } - return success(payProductService.selectPayProductDetail(productId)); + throw new ServiceException("请传入 versionId"); } - /** 保存打单产品明细(重量段/附加费等),按当前 term 覆盖子表 */ + /** 保存打单产品明细(重量段/附加费等),按当前 version 覆盖子表 */ @PreAuthorize("@ss.hasPermi('price:label:edit')") @Log(title = "打单产品明细", businessType = BusinessType.UPDATE) @PutMapping("/product") - public AjaxResult updateProduct(@RequestBody PayProduct product) + public AjaxResult updateProduct(@RequestBody LblProduct product) { - return toAjax(payProductService.updateDetail(product)); + return toAjax(lblProductService.updateDetail(product)); } + /** 仅保存当前重量段行 */ + @PreAuthorize("@ss.hasPermi('price:label:edit')") + @Log(title = "打单重量段", businessType = BusinessType.UPDATE) + @PutMapping("/product/band") + public AjaxResult updateWeightBand(@RequestBody LblWeightBand band) + { + return toAjax(lblProductService.updateWeightBand(band)); + } + + /** 仅保存当前附加费行 */ + @PreAuthorize("@ss.hasPermi('price:label:edit')") + @Log(title = "打单附加费", businessType = BusinessType.UPDATE) + @PutMapping("/product/surcharge") + public AjaxResult updateSurcharge(@RequestBody LblSurcharge surcharge) + { + return toAjax(lblProductService.updateSurcharge(surcharge)); + } + + /** 查询打单产品已绑定渠道(默认应收 AR) */ @PreAuthorize("@ss.hasPermi('price:label:query')") @GetMapping("/product/{productId}/channels") @@ -225,7 +257,7 @@ public class LabelPackageController extends BaseController @RequestParam(value = "payType", required = false) String payType) { String type = (payType == null || payType.trim().isEmpty()) ? "AR" : payType.trim(); - return success(payProductService.listProductChannelBinds(productId, type)); + return success(lblProductService.listProductChannelBinds(productId, type)); } /** 覆盖保存打单产品渠道绑定,写入 srv_channel_product_map */ @@ -247,7 +279,7 @@ public class LabelPackageController extends BaseController } } } - String msg = payProductService.replaceProductChannelBinds(productId, payType, channelIds); + String msg = lblProductService.replaceProductChannelBinds(productId, payType, channelIds); return AjaxResult.success(msg); } @@ -257,7 +289,7 @@ public class LabelPackageController extends BaseController public AjaxResult removeProducts(@PathVariable Long priceSetId, @PathVariable Long[] productIds) { priceSetService.assertSetCategory(priceSetId, PrcPriceSet.CATEGORY_LABEL); - return toAjax(payProductService.removeLabelProducts(priceSetId, productIds)); + return toAjax(lblProductService.removeProducts(priceSetId, productIds)); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblChargeMode.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblChargeMode.java new file mode 100644 index 00000000..5345f3af --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblChargeMode.java @@ -0,0 +1,59 @@ +package com.bms.price.domain; + +/** + * 打单重量段计费条件(字典 lbl_charge_mode)。 + */ +public final class LblChargeMode +{ + private LblChargeMode() + { + } + + /** 按票 */ + public static final String ITEM = "ITEM"; + + /** 单价*公斤数 */ + public static final String PER_KG = "PER_KG"; + + /** 首重+续重 */ + public static final String EXTRA_KG = "EXTRA_KG"; + + /** 邮编 */ + public static final String POSTCODE = "POSTCODE"; + + /** @deprecated 映射为 ITEM */ + @Deprecated + public static final String ALWAYS = "ALWAYS"; + + /** @deprecated 映射为 ITEM(分区用 zone 列过滤) */ + @Deprecated + public static final String ZONE = "ZONE"; + + /** @deprecated 映射为 ITEM */ + @Deprecated + public static final String FLAT = "FLAT"; + + /** @deprecated 映射为 PER_KG */ + @Deprecated + public static final String UNIT_PLUS_HANDLE = "UNIT_PLUS_HANDLE"; + + /** @deprecated 映射为 EXTRA_KG */ + @Deprecated + public static final String FIRST_EXTRA = "FIRST_EXTRA"; + + /** @deprecated */ + @Deprecated + public static final String WEIGHT = "WEIGHT"; + + /** @deprecated */ + @Deprecated + public static final String SIZE = "SIZE"; + + /** @deprecated */ + @Deprecated + public static final String RETURN = "RETURN"; + + /** @deprecated */ + @Deprecated + public static final String MANUAL = "MANUAL"; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblExtraLine.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblExtraLine.java new file mode 100644 index 00000000..e1dafa2c --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblExtraLine.java @@ -0,0 +1,22 @@ +package com.bms.price.domain; + +import lombok.Data; + +/** + * 打单解析过程中的邮编/配置原文行(二期落库;当前仅预览内存态)。 + */ +@Data +public class LblExtraLine +{ + private Long lineId; + + private Long productId; + + private Long versionId; + + private String lineType; + + private String lineText; + + private Integer sortNo; +} \ No newline at end of file diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblFeeType.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblFeeType.java new file mode 100644 index 00000000..221faa26 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblFeeType.java @@ -0,0 +1,20 @@ +package com.bms.price.domain; + +/** + * 打单重量段费用类型(字典 bms_fee_type)。 + */ +public final class LblFeeType +{ + private LblFeeType() + { + } + + /** 运费 */ + public static final String FREIGHT = "FREIGHT"; + + /** 处理费/挂号 */ + public static final String REGISTRATION = "REGISTRATION"; + + /** 偏远费 */ + public static final String REMOTE = "REMOTE"; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblPreview.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblPreview.java new file mode 100644 index 00000000..4bf6a5c8 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblPreview.java @@ -0,0 +1,29 @@ +package com.bms.price.domain; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** + * 打单价卡导入预览。 + */ +@Data +public class LblPreview +{ + private String sourceFile; + + private String filePath; + + private String fileMd5; + + private String supplierName; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveDate; + + private List products = new ArrayList<>(); + + private List skippedSheets = new ArrayList<>(); +} \ No newline at end of file diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblProduct.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblProduct.java new file mode 100644 index 00000000..9da02825 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblProduct.java @@ -0,0 +1,110 @@ +package com.bms.price.domain; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import com.bms.common.annotation.Excel; +import com.bms.common.core.domain.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 打单价卡产品主数据。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LblProduct extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long productId; + + @Excel(name = "产品名称", width = 24) + private String productName; + + private String dictValue; + + private Long versionId; + + private Integer versionNo; + + private String versionStatus; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date versionStart; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date versionEnd; + + private String countryScope; + + private String chargeType; + + private String extraPerKg; + + private String serviceType; + + private String classification; + + private String catalogRemark; + + private String updateContent; + + private String weightLimit; + + private String sheetName; + + private String relatedSheet; + + private String sourceFile; + + private String filePath; + + private String fileMd5; + + private String supplierName; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveDate; + + private String currency; + + private String serviceNote; + + private String parseStatus; + + private Integer weightCount; + + private Integer surchargeCount; + + private Integer extraCount; + + private String boundChannelText; + + private Integer boundChannelCount; + + private Boolean duplicated; + + private Long existsId; + + private List weightBands; + + private List surcharges; + + /** 兼容旧前端 termId,等同 versionId */ + public Long getTermId() + { + return versionId; + } + + public void setTermId(Long termId) + { + this.versionId = termId; + } + + private List extraLines; + + /** 邮编→分区(保存时同步到 prc_zone,不落产品子表) */ + private Map> zonePostcodes; +} \ No newline at end of file diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblProductVersion.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblProductVersion.java new file mode 100644 index 00000000..4e25ea0d --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblProductVersion.java @@ -0,0 +1,48 @@ +package com.bms.price.domain; + +import java.util.Date; +import com.bms.common.core.domain.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 打单价卡产品版本周期。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class LblProductVersion extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long versionId; + + private Long priceSetId; + + private Long productId; + + private String dictValue; + + private Integer versionNo; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveStart; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveEnd; + + private String filePath; + + /** 0待生效 1生效 2已失效 */ + private String status; + + private String productName; + + private String sourceFile; + + private Integer weightCount; + + private Integer surchargeCount; + + private String currency; +} \ No newline at end of file diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblSaveCommand.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblSaveCommand.java new file mode 100644 index 00000000..ef750fb4 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblSaveCommand.java @@ -0,0 +1,24 @@ +package com.bms.price.domain; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * 打单价卡预览确认后的保存请求。 + */ +@Data +public class LblSaveCommand +{ + private boolean overwrite; + + private Long priceSetId; + + private String priceSetName; + + private String priceSetNote; + + private Integer versionNo; + + private List products = new ArrayList<>(); +} \ No newline at end of file diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblSaveTask.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblSaveTask.java new file mode 100644 index 00000000..af9fc028 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblSaveTask.java @@ -0,0 +1,83 @@ +package com.bms.price.domain; + +import java.io.Serializable; + +/** + * 打单价卡异步保存进度(Redis)。 + */ +public class LblSaveTask implements Serializable +{ + private static final long serialVersionUID = 1L; + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_RUNNING = "RUNNING"; + public static final String STATUS_SUCCESS = "SUCCESS"; + public static final String STATUS_FAIL = "FAIL"; + + private String taskId; + private String status; + private int percent; + private String message; + private Long priceSetId; + private String error; + + public String getTaskId() + { + return taskId; + } + + public void setTaskId(String taskId) + { + this.taskId = taskId; + } + + public String getStatus() + { + return status; + } + + public void setStatus(String status) + { + this.status = status; + } + + public int getPercent() + { + return percent; + } + + public void setPercent(int percent) + { + this.percent = percent; + } + + public String getMessage() + { + return message; + } + + public void setMessage(String message) + { + this.message = message; + } + + public Long getPriceSetId() + { + return priceSetId; + } + + public void setPriceSetId(Long priceSetId) + { + this.priceSetId = priceSetId; + } + + public String getError() + { + return error; + } + + public void setError(String error) + { + this.error = error; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblSurcharge.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblSurcharge.java new file mode 100644 index 00000000..88989351 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblSurcharge.java @@ -0,0 +1,62 @@ +package com.bms.price.domain; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** + * 打单价卡附加费(超重/超尺寸/% 等;偏远重量价不进本表)。 + */ +@Data +public class LblSurcharge +{ + private Long surchargeId; + + private Long productId; + + private Long versionId; + + /** 解析中间态区块名,不落库 */ + private String sectionName; + + private String feeName; + + private Long scId; + + private String zoneCode; + + private String zoneName; + + private String chargeText; + + private BigDecimal amount; + + private String currency; + + private String priceUnit; + + private String feeDesc; + + private String periodText; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveStart; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveEnd; + + private String chargeTiming; + + private String calcRule; + + private String calcParam; + + private String postcodeParam; + + private BigDecimal weightMin; + + private BigDecimal weightMax; + + private Integer sortNo; +} \ No newline at end of file diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/LblWeightBand.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblWeightBand.java new file mode 100644 index 00000000..a954fd31 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/LblWeightBand.java @@ -0,0 +1,60 @@ +package com.bms.price.domain; + +import java.math.BigDecimal; +import lombok.Data; + +/** + * 打单价卡重量段明细。 + * 维度:产品+国家+分区+重量段+fee_type;无 handle 字段。 + */ +@Data +public class LblWeightBand +{ + private Long bandId; + + private Long productId; + + private Long versionId; + + /** 解析中间态区块名,不落库 */ + private String sectionName; + + /** FREIGHT / REGISTRATION / REMOTE */ + private String feeType; + + private String countryName; + + private String countryCode; + + private String zoneCode; + + private String zoneName; + + private String postcodeParam; + + private String weightLabel; + + private BigDecimal weightMin; + + private BigDecimal weightMax; + + private String volumeText; + + private String chargeName; + + /** 字典 lbl_charge_mode:ITEM/PER_KG/EXTRA_KG/POSTCODE */ + private String chargeMode; + + /** 条件参数(如首重KG、尺寸表达) */ + private String calcParam; + + private BigDecimal price; + + private String priceUnit; + + private BigDecimal extraPrice; + + private String extraPriceUnit; + + private Integer sortNo; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/label/LblBandNormalizer.java b/bms-back/bms-system/src/main/java/com/bms/price/label/LblBandNormalizer.java new file mode 100644 index 00000000..f615c858 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/label/LblBandNormalizer.java @@ -0,0 +1,341 @@ +package com.bms.price.label; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import com.bms.price.domain.LblChargeMode; +import com.bms.price.domain.LblFeeType; +import com.bms.price.domain.LblPreview; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblWeightBand; + +/** + * 归并解析重量段:按 fee_type 分行;首重+续重合 EXTRA_KG;补 chargeMode/zoneCode。 + * 不再把处理费合并进运费行。 + */ +public final class LblBandNormalizer +{ + private LblBandNormalizer() + { + } + + public static void normalize(LblPreview preview) + { + if (preview == null || preview.getProducts() == null) + { + return; + } + for (LblProduct product : preview.getProducts()) + { + normalizeProduct(product); + } + } + + public static void normalizeProduct(LblProduct product) + { + if (product == null || product.getWeightBands() == null || product.getWeightBands().isEmpty()) + { + return; + } + List rows = splitByFeeType(product.getWeightBands()); + rows = mergeFirstExtra(rows); + int sort = 0; + for (LblWeightBand band : rows) + { + if (band.getFeeType() == null || band.getFeeType().isEmpty()) + { + band.setFeeType(inferFeeType(band)); + } + if (band.getChargeMode() == null || band.getChargeMode().isEmpty()) + { + band.setChargeMode(inferMode(band)); + } + band.setChargeMode(migrateMode(band.getChargeMode())); + if (band.getZoneCode() == null || band.getZoneCode().isEmpty()) + { + band.setZoneCode(toZoneCode(band.getZoneName())); + } + if (band.getPostcodeParam() == null || band.getPostcodeParam().isEmpty()) + { + String prefixes = LblSurchargeRules.remotePrefixes(band.getZoneName()); + band.setPostcodeParam(prefixes == null ? "" : prefixes); + } + if (band.getCountryCode() == null) + { + band.setCountryCode(""); + } + if (band.getZoneCode() == null) + { + band.setZoneCode(""); + } + if (band.getCalcParam() == null) + { + band.setCalcParam(""); + } + band.setSortNo(sort++); + } + product.setWeightBands(rows); + product.setWeightCount(rows.size()); + product.setChargeType(summarizeChargeType(rows)); + } + + /** 处理费/挂号落 REGISTRATION;偏远落 REMOTE;其余落 FREIGHT。 */ + private static List splitByFeeType(List source) + { + List out = new ArrayList(); + for (LblWeightBand band : source) + { + if (band == null) + { + continue; + } + String name = nz(band.getChargeName()); + String fee = nz(band.getFeeType()); + if (!fee.isEmpty()) + { + LblWeightBand kept = copyBand(band); + // 解析器若误标 FREIGHT,按费用名回正为 REGISTRATION + if (name.contains("处理费") || name.contains("挂号")) + { + kept.setFeeType(LblFeeType.REGISTRATION); + if (kept.getChargeMode() == null || kept.getChargeMode().isEmpty()) + { + kept.setChargeMode(LblChargeMode.ITEM); + } + } + out.add(kept); + continue; + } + if (nameContainsRemote(name)) + { + LblWeightBand row = copyBand(band); + row.setFeeType(LblFeeType.REMOTE); + if (row.getChargeMode() == null || row.getChargeMode().isEmpty()) + { + row.setChargeMode(nz(row.getPostcodeParam()).isEmpty() ? LblChargeMode.ITEM : LblChargeMode.POSTCODE); + } + out.add(row); + continue; + } + if (name.contains("处理费") || name.contains("挂号")) + { + // 处理费/挂号固定 REGISTRATION,不再改写为 FREIGHT(避免与运费同维度唯一键冲突) + LblWeightBand row = copyBand(band); + row.setFeeType(LblFeeType.REGISTRATION); + row.setChargeMode(LblChargeMode.ITEM); + out.add(row); + continue; + } + LblWeightBand row = copyBand(band); + row.setFeeType(LblFeeType.FREIGHT); + out.add(row); + } + return out; + } + + private static boolean nameContainsRemote(String name) + { + String n = name.toLowerCase(Locale.ROOT); + return name.contains("偏远") || n.contains("remote") || name.contains("岛屿") || n.contains("island"); + } + + private static List mergeFirstExtra(List source) + { + Map firsts = new LinkedHashMap(); + List extras = new ArrayList(); + List rest = new ArrayList(); + for (LblWeightBand band : source) + { + String label = nz(band.getWeightLabel()).toLowerCase(Locale.ROOT); + boolean first = label.contains("首重") || label.contains("first"); + boolean extra = label.contains("每增加") || label.contains("续重") || label.contains("additional"); + if (first) + { + String key = zoneFeeKey(band); + LblWeightBand row = copyBand(band); + row.setChargeMode(LblChargeMode.EXTRA_KG); + if (row.getWeightMax() != null && (row.getCalcParam() == null || row.getCalcParam().isEmpty())) + { + row.setCalcParam(row.getWeightMax().stripTrailingZeros().toPlainString()); + } + firsts.put(key, row); + } + else if (extra) + { + extras.add(band); + } + else + { + rest.add(band); + } + } + for (LblWeightBand extra : extras) + { + String key = zoneFeeKey(extra); + LblWeightBand row = firsts.get(key); + if (row == null) + { + row = copyBand(extra); + row.setExtraPrice(extra.getPrice()); + row.setExtraPriceUnit(extra.getPriceUnit()); + row.setPrice(null); + row.setChargeMode(LblChargeMode.EXTRA_KG); + firsts.put(key, row); + } + else + { + row.setExtraPrice(extra.getPrice()); + row.setExtraPriceUnit(extra.getPriceUnit()); + row.setChargeMode(LblChargeMode.EXTRA_KG); + } + } + List out = new ArrayList(); + out.addAll(rest); + out.addAll(firsts.values()); + return out; + } + + private static String inferFeeType(LblWeightBand band) + { + String name = nz(band.getChargeName()); + if (nameContainsRemote(name)) + { + return LblFeeType.REMOTE; + } + if (name.contains("处理费") || name.contains("挂号")) + { + return LblFeeType.REGISTRATION; + } + return LblFeeType.FREIGHT; + } + + private static String inferMode(LblWeightBand band) + { + if (band.getExtraPrice() != null) + { + return LblChargeMode.EXTRA_KG; + } + if (!nz(band.getPostcodeParam()).isEmpty()) + { + return LblChargeMode.POSTCODE; + } + String unit = nz(band.getPriceUnit()).toUpperCase(Locale.ROOT); + if (unit.contains("KG") || unit.contains("公斤") || unit.contains("千克")) + { + return LblChargeMode.PER_KG; + } + return LblChargeMode.ITEM; + } + + private static String migrateMode(String mode) + { + if (mode == null || mode.isEmpty()) + { + return LblChargeMode.ITEM; + } + if (LblChargeMode.FIRST_EXTRA.equals(mode) || "EXTRA_KG".equals(mode)) + { + return LblChargeMode.EXTRA_KG; + } + if (LblChargeMode.POSTCODE.equals(mode)) + { + return LblChargeMode.POSTCODE; + } + if (LblChargeMode.PER_KG.equals(mode) || LblChargeMode.UNIT_PLUS_HANDLE.equals(mode)) + { + return LblChargeMode.PER_KG; + } + if (LblChargeMode.ITEM.equals(mode) || LblChargeMode.FLAT.equals(mode) || LblChargeMode.ALWAYS.equals(mode) + || LblChargeMode.ZONE.equals(mode) || LblChargeMode.WEIGHT.equals(mode) || LblChargeMode.SIZE.equals(mode) + || LblChargeMode.RETURN.equals(mode) || LblChargeMode.MANUAL.equals(mode)) + { + return LblChargeMode.ITEM; + } + return mode; + } + + private static String summarizeChargeType(List bands) + { + boolean firstExtra = false; + boolean unitKg = false; + for (LblWeightBand band : bands) + { + if (LblChargeMode.EXTRA_KG.equals(band.getChargeMode()) || LblChargeMode.FIRST_EXTRA.equals(band.getChargeMode())) + { + firstExtra = true; + } + String unit = nz(band.getPriceUnit()).toUpperCase(Locale.ROOT); + if (unit.contains("KG") || unit.contains("公斤")) + { + unitKg = true; + } + if (LblFeeType.REGISTRATION.equals(band.getFeeType())) + { + unitKg = true; + } + } + if (firstExtra) + { + return "续重计费"; + } + if (unitKg) + { + return "常规计重价"; + } + return "单票价"; + } + + private static String zoneFeeKey(LblWeightBand band) + { + return nz(band.getCountryCode()) + "|" + nz(band.getZoneName()) + "|" + nz(band.getPostcodeParam()) + "|" + + nz(band.getSectionName()) + "|" + nz(band.getFeeType()); + } + + private static String toZoneCode(String zoneName) + { + if (zoneName == null || zoneName.trim().isEmpty()) + { + return ""; + } + String s = zoneName.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]+", "_"); + if (s.length() > 64) + { + s = s.substring(0, 64); + } + return s; + } + + private static LblWeightBand copyBand(LblWeightBand src) + { + LblWeightBand b = new LblWeightBand(); + b.setProductId(src.getProductId()); + b.setVersionId(src.getVersionId()); + b.setSectionName(src.getSectionName()); + b.setFeeType(src.getFeeType()); + b.setCountryName(src.getCountryName()); + b.setCountryCode(src.getCountryCode()); + b.setZoneCode(src.getZoneCode()); + b.setZoneName(src.getZoneName()); + b.setPostcodeParam(src.getPostcodeParam()); + b.setWeightLabel(src.getWeightLabel()); + b.setWeightMin(src.getWeightMin()); + b.setWeightMax(src.getWeightMax()); + b.setVolumeText(src.getVolumeText()); + b.setChargeName(src.getChargeName()); + b.setChargeMode(src.getChargeMode()); + b.setCalcParam(src.getCalcParam()); + b.setPrice(src.getPrice()); + b.setPriceUnit(src.getPriceUnit()); + b.setExtraPrice(src.getExtraPrice()); + b.setExtraPriceUnit(src.getExtraPriceUnit()); + b.setSortNo(src.getSortNo()); + return b; + } + + private static String nz(String v) + { + return v == null ? "" : v.trim(); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/label/LblCostCalculator.java b/bms-back/bms-system/src/main/java/com/bms/price/label/LblCostCalculator.java new file mode 100644 index 00000000..4e79c928 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/label/LblCostCalculator.java @@ -0,0 +1,469 @@ +package com.bms.price.label; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import com.bms.common.enums.PaymentMesEnum; +import com.bms.price.domain.LblChargeMode; +import com.bms.price.domain.LblFeeType; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblWeightBand; +import com.bms.price.domain.PayCostLine; +import com.bms.price.domain.PayOrderCostResult; +import com.bms.price.domain.PayProduct; +import com.bms.price.pay.LabelArCostCalculator; +import com.bms.price.pay.PaySurchargeRules; +import com.bms.price.pay.PayZoneMatcher; + +/** + * 打单新计费:直读 {@link LblWeightBand},按 fee_type 分行 + charge_mode(=calc_rule)。 + * 附加费仍复用 {@link LabelArCostCalculator#appendSurcharges}。 + */ +public final class LblCostCalculator +{ + private static final Set REFUND_STATUS_SET = Set.of(9, 10); + + private LblCostCalculator() + { + } + + public static PayOrderCostResult calculate(LblProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus) + { + PayOrderCostResult result = new PayOrderCostResult(); + result.setFees(new ArrayList()); + result.setSkipped(new ArrayList()); + if (product == null) + { + result.setMessage(PaymentMesEnum.BMS_PAY_NO_PRODUCT.getCnMes()); + return result; + } + result.setProductId(product.getProductId()); + result.setProductName(product.getProductName()); + if (weight == null || weight.compareTo(BigDecimal.ZERO) <= 0) + { + result.setMessage(PaymentMesEnum.BMS_PAY_NO_WEIGHT.getCnMes()); + return result; + } + + List all = product.getWeightBands() == null ? List.of() : product.getWeightBands(); + Set zones = zoneNames(all); + if (zones.size() > 1 && (zoneName == null || zoneName.isBlank())) + { + result.setMessage("产品「" + nz(product.getProductName()) + "」有多个地区(" + + String.join("、", zones) + "),邮编没有对上其中的地区,无法确定运费"); + return result; + } + + String currency = nz(product.getCurrency()); + if (currency.isEmpty()) + { + currency = "EUR"; + } + + BigDecimal freight = BigDecimal.ZERO; + String freightSection = ""; + freight = addFeeGroup(result, all, LblFeeType.FREIGHT, weight, length, width, height, postcode, zoneName, + orderStatus, currency, true); + if (result.getMessage() != null && !result.getMessage().isEmpty() && freight.compareTo(BigDecimal.ZERO) == 0 + && result.getFees().isEmpty()) + { + return result; + } + for (PayCostLine fee : result.getFees()) + { + if ("BASIC_FREIGHT".equals(fee.getFeeType())) + { + freightSection = ""; + break; + } + } + addFeeGroup(result, all, LblFeeType.REGISTRATION, weight, length, width, height, postcode, zoneName, + orderStatus, currency, false); + addFeeGroup(result, all, LblFeeType.REMOTE, weight, length, width, height, postcode, zoneName, + orderStatus, currency, false); + + if (freight.compareTo(BigDecimal.ZERO) == 0) + { + boolean hasFreightLine = false; + for (PayCostLine fee : result.getFees()) + { + if ("BASIC_FREIGHT".equals(fee.getFeeType())) + { + hasFreightLine = true; + freight = freight.add(fee.getAmount() == null ? BigDecimal.ZERO : fee.getAmount()); + } + } + if (!hasFreightLine) + { + result.setMessage(outOfRange(product, all, weight, zoneName)); + result.getFees().clear(); + return result; + } + } + + result.setCurrency(currency); + PayProduct pay = LblProductPayAdapter.toPayProductForSurcharge(product); + LabelArCostCalculator.appendSurcharges(pay, weight, length, width, height, postcode, zoneName, bizDate, + orderStatus, freight, currency, freightSection, null, result); + + BigDecimal total = BigDecimal.ZERO; + for (PayCostLine fee : result.getFees()) + { + if (fee != null && fee.getAmount() != null) + { + total = total.add(fee.getAmount()); + } + } + result.setPayTotal(total.setScale(2, RoundingMode.HALF_UP)); + result.setMessage(summary(result)); + return result; + } + + /** + * @return FREIGHT 组金额汇总;其它组返回 ZERO + */ + private static BigDecimal addFeeGroup(PayOrderCostResult result, List all, String feeType, + BigDecimal weight, BigDecimal length, BigDecimal width, BigDecimal height, String postcode, String zoneName, + Integer orderStatus, String currency, boolean required) + { + List candidates = filterCandidates(all, feeType, weight, zoneName); + LblWeightBand best = pickBest(candidates, weight, postcode, zoneName, length, width, height, orderStatus); + if (best == null) + { + if (required) + { + // message set by caller + } + return BigDecimal.ZERO; + } + if (!modeApplies(best, weight, length, width, height, postcode, zoneName, orderStatus)) + { + return BigDecimal.ZERO; + } + BigDecimal amount = amountOf(best, weight); + if (amount == null) + { + return BigDecimal.ZERO; + } + amount = amount.setScale(2, RoundingMode.HALF_UP); + String quoteType = quoteFeeType(feeType); + String feeName = blank(best.getChargeName(), defaultFeeName(feeType)); + String remark = nz(best.getWeightLabel()) + " " + plain(best.getPrice()) + " " + nz(best.getPriceUnit()); + if (zoneName != null && !zoneName.isBlank()) + { + remark = remark + ",地区 " + zoneName.replace('\n', ' '); + } + result.getFees().add(line(quoteType, feeName, 0L, amount, currency, remark.trim())); + return LblFeeType.FREIGHT.equals(feeType) ? amount : BigDecimal.ZERO; + } + + private static List filterCandidates(List all, String feeType, BigDecimal weight, + String zoneName) + { + List out = new ArrayList(); + for (LblWeightBand band : all) + { + if (band == null) + { + continue; + } + String ft = nz(band.getFeeType()); + if (ft.isEmpty()) + { + ft = LblFeeType.FREIGHT; + } + if (!feeType.equals(ft)) + { + continue; + } + String mode = migrateMode(band.getChargeMode()); + boolean zoned = !nz(band.getZoneName()).isEmpty() || !nz(band.getZoneCode()).isEmpty(); + boolean postcodeDriven = LblChargeMode.POSTCODE.equals(mode) + || ((LblChargeMode.EXTRA_KG.equals(mode) || LblFeeType.REMOTE.equals(ft)) + && !nz(band.getPostcodeParam()).isEmpty()); + if (zoned && zoneName != null && !zoneName.isBlank() && !postcodeDriven) + { + if (!PayZoneMatcher.sameZone(band.getZoneName(), zoneName) + && !PayZoneMatcher.sameZone(band.getZoneCode(), zoneName)) + { + continue; + } + } + if (LblChargeMode.EXTRA_KG.equals(mode)) + { + out.add(band); + continue; + } + if (band.getWeightMin() != null && weight.compareTo(band.getWeightMin()) < 0) + { + continue; + } + if (band.getWeightMax() != null && weight.compareTo(band.getWeightMax()) > 0) + { + continue; + } + out.add(band); + } + return out; + } + + private static LblWeightBand pickBest(List candidates, BigDecimal weight, String postcode, + String zoneName, BigDecimal length, BigDecimal width, BigDecimal height, Integer orderStatus) + { + List ok = new ArrayList(); + for (LblWeightBand band : candidates) + { + if (modeApplies(band, weight, length, width, height, postcode, zoneName, orderStatus)) + { + ok.add(band); + } + } + if (ok.isEmpty()) + { + return null; + } + ok.sort(Comparator.comparingInt(LblCostCalculator::specificity).reversed()); + return ok.get(0); + } + + private static int specificity(LblWeightBand band) + { + int score = 0; + if (!nz(band.getZoneName()).isEmpty() || !nz(band.getZoneCode()).isEmpty()) + { + score += 4; + } + if (!nz(band.getPostcodeParam()).isEmpty()) + { + score += 2; + } + if (band.getWeightMin() != null || band.getWeightMax() != null) + { + score += 1; + } + return score; + } + + private static boolean modeApplies(LblWeightBand band, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, String zoneName, Integer orderStatus) + { + String mode = migrateMode(band.getChargeMode()); + if (LblChargeMode.POSTCODE.equals(mode)) + { + return LblSurchargeRules.matchesPostcode(band.getPostcodeParam(), postcode) + || PaySurchargeRules.matchesPostcode(band.getPostcodeParam(), postcode); + } + if (LblChargeMode.EXTRA_KG.equals(mode)) + { + if (!nz(band.getPostcodeParam()).isEmpty() + && !PaySurchargeRules.matchesPostcode(band.getPostcodeParam(), postcode) + && !LblSurchargeRules.matchesPostcode(band.getPostcodeParam(), postcode)) + { + return false; + } + return true; + } + // ITEM / PER_KG:维度筛选后即适用 + return true; + } + + private static BigDecimal amountOf(LblWeightBand band, BigDecimal weight) + { + String mode = migrateMode(band.getChargeMode()); + if (LblChargeMode.EXTRA_KG.equals(mode)) + { + BigDecimal first = firstKg(band); + BigDecimal base = band.getPrice() == null ? BigDecimal.ZERO : band.getPrice(); + BigDecimal extra = band.getExtraPrice() == null ? BigDecimal.ZERO : band.getExtraPrice(); + if (weight == null) + { + return base; + } + BigDecimal over = weight.subtract(first); + if (over.compareTo(BigDecimal.ZERO) < 0) + { + over = BigDecimal.ZERO; + } + return base.add(extra.multiply(over)); + } + if (band.getPrice() == null) + { + return null; + } + if (LblChargeMode.PER_KG.equals(mode)) + { + return band.getPrice().multiply(weight); + } + // ITEM / POSTCODE:按票 + return band.getPrice(); + } + + private static BigDecimal firstKg(LblWeightBand band) + { + if (band.getCalcParam() != null && !band.getCalcParam().isBlank()) + { + try + { + return new BigDecimal(band.getCalcParam().trim()); + } + catch (NumberFormatException ignored) + { + } + } + if (band.getWeightMax() != null) + { + return band.getWeightMax(); + } + return BigDecimal.ZERO; + } + + private static boolean perKilogram(LblWeightBand band) + { + String unit = nz(band.getPriceUnit()).toUpperCase(Locale.ROOT); + return unit.contains("KG") || unit.contains("公斤") || unit.contains("千克"); + } + + private static String migrateMode(String mode) + { + if (mode == null || mode.isEmpty()) + { + return LblChargeMode.ITEM; + } + if (LblChargeMode.FIRST_EXTRA.equals(mode)) + { + return LblChargeMode.EXTRA_KG; + } + if (LblChargeMode.UNIT_PLUS_HANDLE.equals(mode)) + { + return LblChargeMode.PER_KG; + } + if (LblChargeMode.FLAT.equals(mode) || LblChargeMode.ALWAYS.equals(mode) || LblChargeMode.ZONE.equals(mode) + || LblChargeMode.WEIGHT.equals(mode) || LblChargeMode.SIZE.equals(mode) || LblChargeMode.RETURN.equals(mode) + || LblChargeMode.MANUAL.equals(mode)) + { + return LblChargeMode.ITEM; + } + return mode; + } + + private static String quoteFeeType(String feeType) + { + if (LblFeeType.REGISTRATION.equals(feeType)) + { + return "REGISTRATION_FEE"; + } + if (LblFeeType.REMOTE.equals(feeType)) + { + return "SURCHARGE"; + } + return "BASIC_FREIGHT"; + } + + private static String defaultFeeName(String feeType) + { + if (LblFeeType.REGISTRATION.equals(feeType)) + { + return "处理费"; + } + if (LblFeeType.REMOTE.equals(feeType)) + { + return "偏远费"; + } + return "基础运费"; + } + + private static Set zoneNames(List bands) + { + Set zones = new LinkedHashSet(); + for (LblWeightBand band : bands) + { + if (band != null && band.getZoneName() != null && !band.getZoneName().isBlank()) + { + zones.add(band.getZoneName().trim()); + } + } + return zones; + } + + private static String outOfRange(LblProduct product, List bands, BigDecimal weight, String zoneName) + { + String name = nz(product.getProductName()); + List freight = filterCandidates(bands, LblFeeType.FREIGHT, weight, zoneName); + if (freight.isEmpty()) + { + // try without weight filter for message + int n = 0; + BigDecimal max = null; + for (LblWeightBand band : bands) + { + if (band == null) + { + continue; + } + String ft = nz(band.getFeeType()); + if (!ft.isEmpty() && !LblFeeType.FREIGHT.equals(ft)) + { + continue; + } + n++; + if (band.getWeightMax() != null && (max == null || band.getWeightMax().compareTo(max) > 0)) + { + max = band.getWeightMax(); + } + } + if (n == 0) + { + return "产品「" + name + "」没有可匹配的重量段"; + } + if (max != null && weight.compareTo(max) > 0) + { + return "计费重 " + plain(weight) + "kg 超出「" + name + "」的重量段,最大 " + plain(max) + "kg"; + } + } + return "计费重 " + plain(weight) + "kg 没有落在「" + name + "」的重量段里"; + } + + private static String summary(PayOrderCostResult result) + { + if (result.getFees() == null || result.getFees().isEmpty()) + { + return nz(result.getMessage()); + } + return "计费成功,共 " + result.getFees().size() + " 项"; + } + + private static PayCostLine line(String feeType, String feeName, long scId, BigDecimal amount, String currency, + String remark) + { + PayCostLine line = new PayCostLine(); + line.setFeeType(feeType); + line.setFeeName(feeName); + line.setScId(scId); + line.setAmount(amount); + line.setCurrency(currency); + line.setRemark(remark); + return line; + } + + private static String blank(String v, String fallback) + { + return v == null || v.trim().isEmpty() ? fallback : v.trim(); + } + + private static String nz(String v) + { + return v == null ? "" : v.trim(); + } + + private static String plain(BigDecimal v) + { + return v == null ? "" : v.stripTrailingZeros().toPlainString(); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/label/LblProductPayAdapter.java b/bms-back/bms-system/src/main/java/com/bms/price/label/LblProductPayAdapter.java new file mode 100644 index 00000000..fa499c89 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/label/LblProductPayAdapter.java @@ -0,0 +1,133 @@ +package com.bms.price.label; + +import java.util.ArrayList; +import java.util.List; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblSurcharge; +import com.bms.price.domain.LblWeightBand; +import com.bms.price.domain.PayProduct; +import com.bms.price.domain.PaySurcharge; +import com.bms.price.domain.PayWeightBand; + +/** + * 兼容适配:打单产品转 PayProduct(附加费/旧对照)。 + * 计费主路请用 {@link LblCostCalculator}。 + */ +public final class LblProductPayAdapter +{ + private LblProductPayAdapter() + { + } + + public static PayProduct toPayProduct(LblProduct src) + { + if (src == null) + { + return null; + } + PayProduct p = new PayProduct(); + p.setProductId(src.getProductId()); + p.setProductName(src.getProductName()); + p.setDictValue(src.getDictValue()); + p.setChargeType(src.getChargeType()); + p.setExtraPerKg(src.getExtraPerKg()); + p.setCurrency(src.getCurrency()); + p.setCountryScope(src.getCountryScope()); + p.setTermId(src.getVersionId()); + p.setVersionNo(src.getVersionNo()); + p.setEffectiveDate(src.getEffectiveDate()); + p.setTermStart(src.getVersionStart()); + p.setTermEnd(src.getVersionEnd()); + List bands = new ArrayList(); + if (src.getWeightBands() != null) + { + for (LblWeightBand band : src.getWeightBands()) + { + if (band == null) + { + continue; + } + bands.add(toBand(band, blank(band.getChargeName(), "派送费"), band.getPrice(), band.getPriceUnit())); + } + } + p.setWeightBands(bands); + p.setSurcharges(toSurcharges(src)); + return p; + } + + /** 仅转附加费,供新计费追加附加费。 */ + public static PayProduct toPayProductForSurcharge(LblProduct src) + { + PayProduct p = toPayProduct(src); + if (p != null) + { + p.setWeightBands(new ArrayList()); + } + return p; + } + + private static List toSurcharges(LblProduct src) + { + List fees = new ArrayList(); + if (src.getSurcharges() == null) + { + return fees; + } + for (LblSurcharge fee : src.getSurcharges()) + { + if (fee == null) + { + continue; + } + PaySurcharge s = new PaySurcharge(); + s.setSurchargeId(fee.getSurchargeId()); + s.setProductId(fee.getProductId()); + s.setTermId(fee.getVersionId()); + s.setFeeName(fee.getFeeName()); + s.setScId(fee.getScId()); + s.setZoneName(fee.getZoneName()); + s.setChargeText(fee.getChargeText()); + s.setAmount(fee.getAmount()); + s.setCurrency(fee.getCurrency()); + s.setPriceUnit(fee.getPriceUnit()); + s.setFeeDesc(fee.getFeeDesc()); + s.setPeriodText(fee.getPeriodText()); + s.setEffectiveStart(fee.getEffectiveStart()); + s.setEffectiveEnd(fee.getEffectiveEnd()); + s.setChargeTiming(fee.getChargeTiming()); + s.setCalcRule(fee.getCalcRule()); + s.setCalcParam(fee.getCalcParam()); + s.setPostcodeParam(fee.getPostcodeParam()); + s.setWeightMin(fee.getWeightMin()); + s.setWeightMax(fee.getWeightMax()); + s.setSortNo(fee.getSortNo()); + fees.add(s); + } + return fees; + } + + private static PayWeightBand toBand(LblWeightBand src, String chargeName, java.math.BigDecimal price, String unit) + { + PayWeightBand b = new PayWeightBand(); + b.setProductId(src.getProductId()); + b.setTermId(src.getVersionId()); + b.setSectionName(src.getSectionName()); + b.setCountryName(src.getCountryName()); + b.setCountryCode(src.getCountryCode()); + b.setZoneName(blank(src.getZoneName(), src.getZoneCode())); + b.setWeightLabel(src.getWeightLabel()); + b.setWeightMin(src.getWeightMin()); + b.setWeightMax(src.getWeightMax()); + b.setVolumeText(src.getVolumeText()); + b.setChargeName(chargeName); + b.setPrice(price); + b.setPriceUnit(unit); + b.setSortNo(src.getSortNo()); + return b; + } + + private static String blank(String v, String fallback) + { + return v == null || v.trim().isEmpty() ? fallback : v.trim(); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/label/LblSurchargeRules.java b/bms-back/bms-system/src/main/java/com/bms/price/label/LblSurchargeRules.java new file mode 100644 index 00000000..8b7fb7fc --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/label/LblSurchargeRules.java @@ -0,0 +1,617 @@ +package com.bms.price.label; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import com.bms.price.domain.LblSurcharge; + +/** + * 把附加费说明收成可计算的条件。认不出的标为暂不计费,避免把签名、重复单号当成每票必收。 + */ +public final class LblSurchargeRules +{ + public static final String ALWAYS = "ALWAYS"; + + public static final String POSTCODE = "POSTCODE"; + + public static final String WEIGHT = "WEIGHT"; + + public static final String SIZE = "SIZE"; + + public static final String RETURN = "RETURN"; + + public static final String MANUAL = "MANUAL"; + + /** 超过重量段上限后,按超出的公斤数乘单价 */ + public static final String EXTRA_KG = "EXTRA_KG"; + + /** 邮编已经落到这个分区,再按重量区间收取,例如英国高地 */ + public static final String ZONE = "ZONE"; + + private static final Pattern REMOTE_PREFIX = Pattern.compile("(\\d{1,5}(?:\\s*[、,,]\\s*\\d{1,5})*)\\s*邮编"); + + private static final Pattern RANGE = Pattern.compile("([A-Z]{1,2})(\\d{1,2})\\s*-\\s*(\\d{1,2})"); + + private static final Pattern DISTRICT = Pattern.compile("([A-Z]{1,2})(\\d{1,2})"); + + private static final Pattern AREA = Pattern.compile("^[A-Z]{1,2}$"); + + private static final Pattern UK_FULL = Pattern.compile("^([A-Z]{1,2})(\\d{1,2})[A-Z]?\\d[A-Z]{2}$"); + + private static final Pattern UK_OUTWARD = Pattern.compile("^([A-Z]{1,2})(\\d{1,2})$"); + + private static final Pattern BOX = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*\\*\\s*(\\d+(?:\\.\\d+)?)\\s*\\*\\s*(\\d+(?:\\.\\d+)?)"); + + private static final Pattern VOLUME = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*(?:m3|m³|立方米)"); + + private static final Pattern CM = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*(?:cm|厘米)"); + + private static final Pattern KG = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤|千克)"); + + private LblSurchargeRules() + { + } + + public static void apply(LblSurcharge fee) + { + if (fee == null) + { + return; + } + String name = fee.getFeeName() == null ? "" : fee.getFeeName(); + String desc = fee.getFeeDesc() == null ? "" : fee.getFeeDesc(); + String charge = fee.getChargeText() == null ? "" : fee.getChargeText(); + String blob = name + " " + desc + " " + charge; + if (name.contains("报价说明")) + { + fee.setCalcRule(MANUAL); + return; + } + if (name.contains("退件")) + { + fee.setCalcRule(RETURN); + return; + } + if (name.contains("签名") || name.contains("重复") || name.contains("未预报") || name.contains("重新贴")) + { + fee.setCalcRule(MANUAL); + return; + } + String postcodes = postcodeParam(desc); + if (name.contains("偏远") && !postcodes.isEmpty()) + { + fee.setCalcRule(POSTCODE); + fee.setCalcParam(postcodes); + return; + } + Matcher kg = KG.matcher(blob); + if ((name.contains("超重") || blob.contains("重量>") || blob.toLowerCase(Locale.ROOT).contains("weight")) && kg.find()) + { + fee.setCalcRule(WEIGHT); + fee.setCalcParam(kg.group(1)); + return; + } + String size = sizeParam(blob); + if (!size.isEmpty() && (name.contains("超大") || name.contains("超尺寸") || name.contains("特大") || blob.contains("厘米") || blob.toLowerCase(Locale.ROOT).contains("cm"))) + { + fee.setCalcRule(SIZE); + fee.setCalcParam(size); + return; + } + if (blob.contains("固定收取") || "固定收取".equals(fee.getChargeTiming())) + { + fee.setCalcRule(ALWAYS); + return; + } + fee.setCalcRule(MANUAL); + } + + public static boolean matchesPostcode(String param, String postcode) + { + if (param == null || param.isBlank() || postcode == null || postcode.isBlank()) + { + return false; + } + UkPostcode parsed = parseUk(postcode); + String digits = postcode.replaceAll("\\D", ""); + for (String token : param.split(",")) + { + String value = token.trim().toUpperCase(Locale.ROOT); + if (value.isEmpty()) + { + continue; + } + String compact = value.replaceAll("[^A-Z0-9]", ""); + String whole = postcode.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", ""); + if (!compact.isEmpty() && compact.equals(whole)) + { + return true; + } + if (parsed != null && tokenMatches(value, parsed)) + { + return true; + } + if (parsed == null && value.matches("\\d{1,5}") && !digits.isEmpty()) + { + boolean full = value.length() >= 4 ? digits.equals(value) : digits.startsWith(value); + if (full) + { + return true; + } + } + } + return false; + } + + /** 从“07邮编开头”“35、38邮编开头”取出数字前缀。没有邮编字样时不猜。 */ + public static String remotePrefixes(String text) + { + if (text == null || !text.contains("邮编")) + { + return ""; + } + List tokens = new ArrayList<>(); + Matcher matcher = REMOTE_PREFIX.matcher(text); + while (matcher.find()) + { + for (String part : matcher.group(1).split("[、,,\\s]+")) + { + if (part.matches("\\d{1,5}") && !tokens.contains(part)) + { + tokens.add(part); + } + } + } + return String.join(",", tokens); + } + + /** + * 按附加费配置里的超尺寸明细判断。上限表示超过这个值才命中。箱规按从大到小比,任一边超过对应边即命中。 + */ + public static boolean matchesConfiguredSize(java.util.Map detail, BigDecimal length, BigDecimal width, + BigDecimal height, BigDecimal weight) + { + if (detail == null || detail.isEmpty() || length == null || width == null || height == null) + { + return false; + } + BigDecimal weightMin = number(detail.get("weightMin")); + BigDecimal weightMax = number(detail.get("weightMax")); + if (weightMin != null || weightMax != null) + { + if (weight == null) + { + return false; + } + if (weightMin != null && weight.compareTo(weightMin) <= 0) + { + return false; + } + if (weightMax != null && weight.compareTo(weightMax) > 0) + { + return false; + } + } + BigDecimal[] sides = desc(length, width, height); + List checks = new ArrayList<>(); + addBound(checks, sides[0], detail.get("longestMin"), detail.get("longestMax")); + addBound(checks, sides[1], detail.get("secondMin"), detail.get("secondMax")); + addBound(checks, length.add(width).add(height), detail.get("sumMin"), detail.get("sumMax")); + addBound(checks, sides[0].add(sides[1].add(sides[2]).multiply(new BigDecimal("2"))), detail.get("girthMin"), detail.get("girthMax")); + BigDecimal volumeMin = number(detail.get("volumeMin")); + BigDecimal volumeMax = number(detail.get("volumeMax")); + if (volumeMin != null || volumeMax != null) + { + BigDecimal cubic = length.multiply(width).multiply(height).divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP); + addBound(checks, cubic, volumeMin, volumeMax); + } + BigDecimal cubicMax = number(detail.get("cubicMax")); + if (cubicMax != null) + { + checks.add(length.multiply(width).multiply(height).compareTo(cubicMax) > 0); + } + BigDecimal boxLength = number(detail.get("boxLength")); + BigDecimal boxWidth = number(detail.get("boxWidth")); + BigDecimal boxHeight = number(detail.get("boxHeight")); + if (boxLength != null && boxWidth != null && boxHeight != null) + { + boolean under = "UNDER".equals(String.valueOf(detail.get("boxMode"))); + checks.add(under ? withinBox(sides, boxLength, boxWidth, boxHeight) : exceedsBox(sides, boxLength, boxWidth, boxHeight)); + } + if (checks.isEmpty()) + { + return false; + } + if ("ALL".equals(String.valueOf(detail.get("matchMode")))) + { + return !checks.contains(Boolean.FALSE); + } + return checks.contains(Boolean.TRUE); + } + + /** 每票都收。写了费用名的规则只约束这一项;带生效月日的规则只在这段日期内收取。 */ + public static boolean allowsAlways(List> details, String feeName, java.util.Date bizDate) + { + if (details == null || details.isEmpty()) + { + return true; + } + String name = feeName == null ? "" : feeName.trim(); + List> named = new ArrayList<>(); + List> shared = new ArrayList<>(); + for (java.util.Map detail : details) + { + if (detail == null) + { + continue; + } + String ruleName = detail.get("feeName") == null ? "" : String.valueOf(detail.get("feeName")).trim(); + if (ruleName.isEmpty()) + { + shared.add(detail); + } + else if (ruleName.equals(name)) + { + named.add(detail); + } + } + List> mine = named.isEmpty() ? shared : named; + if (mine.isEmpty()) + { + return shared.isEmpty(); + } + java.time.LocalDate day = bizDate == null ? null : bizDate.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate(); + for (java.util.Map detail : mine) + { + if (!hasPeriod(detail)) + { + return true; + } + if (day != null && inPeriod(day, detail)) + { + return true; + } + } + return false; + } + + private static void addBound(List checks, BigDecimal actual, Object min, Object max) + { + BigDecimal lower = min instanceof BigDecimal ? (BigDecimal) min : number(min); + BigDecimal upper = max instanceof BigDecimal ? (BigDecimal) max : number(max); + if (lower == null && upper == null) + { + return; + } + if (lower != null && upper != null) + { + checks.add(actual.compareTo(lower) > 0 && actual.compareTo(upper) <= 0); + } + else if (upper != null) + { + checks.add(actual.compareTo(upper) > 0); + } + else + { + checks.add(actual.compareTo(lower) > 0); + } + } + + private static boolean withinBox(BigDecimal[] parcel, BigDecimal a, BigDecimal b, BigDecimal c) + { + BigDecimal[] limit = desc(a, b, c); + return parcel[0].compareTo(limit[0]) < 0 && parcel[1].compareTo(limit[1]) < 0 && parcel[2].compareTo(limit[2]) < 0; + } + + private static boolean hasPeriod(java.util.Map detail) + { + return text(detail.get("periodStart")) != null || text(detail.get("periodEnd")) != null; + } + + private static boolean inPeriod(java.time.LocalDate day, java.util.Map detail) + { + int[] start = monthDay(text(detail.get("periodStart"))); + int[] end = monthDay(text(detail.get("periodEnd"))); + int current = day.getMonthValue() * 100 + day.getDayOfMonth(); + int from = start == null ? 101 : start[0] * 100 + start[1]; + int to = end == null ? 1231 : end[0] * 100 + end[1]; + if (from <= to) + { + return current >= from && current <= to; + } + return current >= from || current <= to; + } + + private static int[] monthDay(String text) + { + if (text == null) + { + return null; + } + String[] parts = text.split("-"); + try + { + if (parts.length == 2) + { + return new int[] { Integer.parseInt(parts[0]), Integer.parseInt(parts[1]) }; + } + if (parts.length == 3) + { + return new int[] { Integer.parseInt(parts[1]), Integer.parseInt(parts[2]) }; + } + } + catch (NumberFormatException ex) + { + return null; + } + return null; + } + + private static String text(Object value) + { + if (value == null) + { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + private static BigDecimal number(Object value) + { + if (value == null || (value instanceof String text && text.isBlank())) + { + return null; + } + if (value instanceof BigDecimal decimal) + { + return decimal; + } + try + { + return new BigDecimal(String.valueOf(value).trim()); + } + catch (NumberFormatException ex) + { + return null; + } + } + + public static boolean matchesWeight(String param, BigDecimal weight) + { + if (param == null || param.isBlank() || weight == null) + { + return false; + } + try + { + return weight.compareTo(new BigDecimal(param.trim())) > 0; + } + catch (NumberFormatException ex) + { + return false; + } + } + + /** 长宽高单位厘米。箱规按从大到小比较,体积用立方米,总和是三边相加。 */ + public static boolean matchesSize(String param, BigDecimal length, BigDecimal width, BigDecimal height) + { + if (param == null || param.isBlank() || length == null || width == null || height == null) + { + return false; + } + BigDecimal[] sides = desc(length, width, height); + boolean sideHit = false; + boolean sumHit = false; + boolean sideSeen = false; + boolean sumSeen = false; + String join = "or"; + for (String part : param.split(";")) + { + String[] pair = part.split(":", 2); + if (pair.length < 2) + { + continue; + } + String key = pair[0].trim(); + String value = pair[1].trim(); + if ("join".equals(key)) + { + join = value; + continue; + } + if ("box".equals(key)) + { + String[] dims = value.split("\\*"); + if (dims.length == 3 && exceedsBox(sides, decimal(dims[0]), decimal(dims[1]), decimal(dims[2]))) + { + return true; + } + } + else if ("volume".equals(key)) + { + BigDecimal cubic = length.multiply(width).multiply(height) + .divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP); + if (cubic.compareTo(decimal(value)) >= 0) + { + return true; + } + } + else if ("side".equals(key)) + { + sideSeen = true; + sideHit = sides[0].compareTo(decimal(value)) > 0; + } + else if ("sum".equals(key)) + { + sumSeen = true; + sumHit = length.add(width).add(height).compareTo(decimal(value)) > 0; + } + } + if (sideSeen || sumSeen) + { + if ("and".equals(join)) + { + return (!sideSeen || sideHit) && (!sumSeen || sumHit); + } + return sideHit || sumHit; + } + return false; + } + + private static String postcodeParam(String desc) + { + if (desc == null || desc.isBlank()) + { + return ""; + } + int cut = Math.max(desc.lastIndexOf(';'), desc.lastIndexOf(';')); + String source = cut >= 0 ? desc.substring(cut + 1) : desc; + String upper = source.replace("邮编开头", " ").toUpperCase(Locale.ROOT).replace(',', ',').replace('、', ',').replace('.', ','); + List tokens = new ArrayList<>(); + for (String raw : upper.split("[,\\s]+")) + { + String token = raw.trim(); + if (token.isEmpty()) + { + continue; + } + if (RANGE.matcher(token).matches() || DISTRICT.matcher(token).matches() || AREA.matcher(token).matches()) + { + tokens.add(token); + } + } + return String.join(",", tokens); + } + + private static String sizeParam(String blob) + { + StringBuilder param = new StringBuilder(); + Matcher box = BOX.matcher(blob); + if (box.find()) + { + param.append("box:").append(box.group(1)).append('*').append(box.group(2)).append('*').append(box.group(3)); + } + Matcher volume = VOLUME.matcher(blob); + if (volume.find()) + { + if (param.length() > 0) + { + param.append(';'); + } + param.append("volume:").append(volume.group(1)); + } + List cms = new ArrayList<>(); + Matcher cm = CM.matcher(blob); + while (cm.find()) + { + cms.add(cm.group(1)); + } + if (cms.size() >= 1 && (blob.contains("一边") || blob.toLowerCase(Locale.ROOT).contains("length") || blob.contains("最长"))) + { + if (param.length() > 0) + { + param.append(';'); + } + param.append("side:").append(cms.get(0)); + if (cms.size() >= 2 && (blob.contains("总和") || blob.toLowerCase(Locale.ROOT).contains("sum"))) + { + param.append(";sum:").append(cms.get(1)); + param.append(";join:").append(join(blob)); + } + } + return param.toString(); + } + + private static String join(String blob) + { + if (blob.contains("或") || Pattern.compile("(?i)\\bor\\b").matcher(blob).find()) + { + return "or"; + } + return "and"; + } + + private static boolean tokenMatches(String token, UkPostcode postcode) + { + Matcher range = RANGE.matcher(token); + if (range.matches()) + { + int low = Integer.parseInt(range.group(2)); + int high = Integer.parseInt(range.group(3)); + return postcode.area.equals(range.group(1)) && postcode.district >= low && postcode.district <= high; + } + Matcher district = DISTRICT.matcher(token); + if (district.matches()) + { + return postcode.area.equals(district.group(1)) && postcode.district == Integer.parseInt(district.group(2)); + } + return AREA.matcher(token).matches() && postcode.area.equals(token); + } + + private static UkPostcode parseUk(String postcode) + { + if (postcode == null) + { + return null; + } + String compact = postcode.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", ""); + Matcher full = UK_FULL.matcher(compact); + if (full.matches()) + { + return new UkPostcode(full.group(1), Integer.parseInt(full.group(2))); + } + Matcher outward = UK_OUTWARD.matcher(compact); + if (outward.matches()) + { + return new UkPostcode(outward.group(1), Integer.parseInt(outward.group(2))); + } + return null; + } + + private static boolean exceedsBox(BigDecimal[] parcel, BigDecimal a, BigDecimal b, BigDecimal c) + { + BigDecimal[] limit = desc(a, b, c); + return parcel[0].compareTo(limit[0]) > 0 || parcel[1].compareTo(limit[1]) > 0 || parcel[2].compareTo(limit[2]) > 0; + } + + private static BigDecimal[] desc(BigDecimal a, BigDecimal b, BigDecimal c) + { + BigDecimal[] values = new BigDecimal[] { a, b, c }; + for (int i = 0; i < values.length; i++) + { + for (int j = i + 1; j < values.length; j++) + { + if (values[j].compareTo(values[i]) > 0) + { + BigDecimal swap = values[i]; + values[i] = values[j]; + values[j] = swap; + } + } + } + return values; + } + + private static BigDecimal decimal(String text) + { + return new BigDecimal(text.trim()); + } + + private static final class UkPostcode + { + private final String area; + + private final int district; + + private UkPostcode(String area, int district) + { + this.area = area; + this.district = district; + } + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/label/LblTexts.java b/bms-back/bms-system/src/main/java/com/bms/price/label/LblTexts.java new file mode 100644 index 00000000..f1a9bce9 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/label/LblTexts.java @@ -0,0 +1,377 @@ +package com.bms.price.label; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; +import java.util.Date; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 价卡单元格里的重量段、金额和时效原文解析。供导入预览和保存共用。 + */ +public final class LblTexts +{ + private static final Pattern WEIGHT_RANGE = Pattern.compile( + "(?i)(\\d+(?:\\.\\d+)?)\\s*[<<]\\s*[wW]\\s*[≤<=<]\\s*(\\d+(?:\\.\\d+)?)"); + + private static final Pattern WEIGHT_DASH = Pattern.compile( + "(?i)(\\d+(?:\\.\\d+)?)\\s*[-~~]\\s*(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤)?"); + + private static final Pattern FIRST_WEIGHT = Pattern.compile("(?i)首重\\s*(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤)?"); + + private static final Pattern STEP_WEIGHT = Pattern.compile("(?i)每增加\\s*(\\d+(?:\\.\\d+)?)"); + + private static final Pattern ONLY_KG = Pattern.compile("(?i)^\\s*(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤)\\s*$"); + + private static final Pattern CN_PERIOD = Pattern.compile( + "(20\\d{2})\\s*年\\s*(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*[日号]?\\s*[至到\\-~~]+\\s*(?:(20\\d{2})\\s*年\\s*)?(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*[日号]?"); + + private static final Pattern CN_MONTH = Pattern.compile( + "(20\\d{2})\\s*年\\s*(\\d{1,2})\\s*月\\s*[至到\\-~~]+\\s*(\\d{1,2})\\s*月"); + + private static final Pattern ISO_DATE = Pattern.compile("(20\\d{2})[./-](\\d{1,2})[./-](\\d{1,2})"); + + /** 20260928-20270117 这种连在一起的日期窗口 */ + private static final Pattern COMPACT_PERIOD = Pattern.compile("(20\\d{2})(\\d{2})(\\d{2})\\s*-\\s*(20\\d{2})(\\d{2})(\\d{2})"); + + private static final Pattern EN_PERIOD = Pattern.compile( + "(?i)(January|February|March|April|May|June|July|August|September|October|November|December)" + + "\\s+(\\d{1,2})\\w*\\s+to\\s+" + + "(January|February|March|April|May|June|July|August|September|October|November|December)" + + "\\s+(\\d{1,2})\\w*[,\\s]+(20\\d{2})"); + + private static final Pattern MONEY = Pattern.compile( + "(?i)(\\d+(?:\\.\\d+)?)\\s*(EUR|GBP|USD|RMB|CNY|欧元|英镑|人民币)?"); + + private static final Pattern PERCENT = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*%"); + + private LblTexts() + { + } + + /** 从重量段原文取出上下限。每增加一公斤只保留原文,不编造上下限。 */ + public static BigDecimal[] weightBounds(String label) + { + BigDecimal[] bounds = new BigDecimal[] { null, null }; + if (label == null || label.isBlank()) + { + return bounds; + } + Matcher range = WEIGHT_RANGE.matcher(label); + if (range.find()) + { + bounds[0] = decimal(range.group(1)); + bounds[1] = decimal(range.group(2)); + return bounds; + } + Matcher dash = WEIGHT_DASH.matcher(label); + if (dash.find()) + { + bounds[0] = decimal(dash.group(1)); + bounds[1] = decimal(dash.group(2)); + return bounds; + } + Matcher first = FIRST_WEIGHT.matcher(label); + if (first.find()) + { + bounds[0] = BigDecimal.ZERO; + bounds[1] = decimal(first.group(1)); + return bounds; + } + if (STEP_WEIGHT.matcher(label).find()) + { + return bounds; + } + Matcher only = ONLY_KG.matcher(label.trim()); + if (only.find()) + { + bounds[0] = BigDecimal.ZERO; + bounds[1] = decimal(only.group(1)); + } + return bounds; + } + + public static boolean looksLikeWeight(String text) + { + if (text == null) + { + return false; + } + return WEIGHT_RANGE.matcher(text).find() + || WEIGHT_DASH.matcher(text).find() + || FIRST_WEIGHT.matcher(text).find() + || STEP_WEIGHT.matcher(text).find() + || ONLY_KG.matcher(text.trim()).find(); + } + + /** + * 从说明里取第一段时效。defaultYear 用于只有月日、没有年份的句子。 + */ + public static Period period(String text, int defaultYear) + { + if (text == null || text.isBlank()) + { + return Period.NONE; + } + Matcher full = CN_PERIOD.matcher(text); + if (full.find()) + { + int y1 = Integer.parseInt(full.group(1)); + int y2 = full.group(4) == null ? y1 : Integer.parseInt(full.group(4)); + LocalDate start = date(y1, full.group(2), full.group(3)); + LocalDate end = date(y2, full.group(5), full.group(6)); + return new Period(start, end, full.group()); + } + Matcher month = CN_MONTH.matcher(text); + if (month.find()) + { + int year = Integer.parseInt(month.group(1)); + int m1 = Integer.parseInt(month.group(2)); + int m2 = Integer.parseInt(month.group(3)); + LocalDate start = LocalDate.of(year, m1, 1); + LocalDate end = YearMonth.of(year, m2).atEndOfMonth(); + return new Period(start, end, month.group()); + } + Matcher english = EN_PERIOD.matcher(text); + if (english.find()) + { + int year = Integer.parseInt(english.group(5)); + LocalDate start = date(year, monthNumber(english.group(1)), english.group(2)); + LocalDate end = date(year, monthNumber(english.group(3)), english.group(4)); + return new Period(start, end, english.group()); + } + Matcher compact = COMPACT_PERIOD.matcher(text); + if (compact.find()) + { + LocalDate start = date(compact.group(1), compact.group(2), compact.group(3)); + LocalDate end = date(compact.group(4), compact.group(5), compact.group(6)); + return new Period(start, end, compact.group()); + } + Matcher iso = ISO_DATE.matcher(text); + if (iso.find()) + { + LocalDate day = date(iso.group(1), iso.group(2), iso.group(3)); + return new Period(day, day, iso.group()); + } + return Period.NONE; + } + + /** 去掉时效句子后再取金额,避免把 2026 当成费用。 */ + public static Money money(String text, Period period) + { + if (text == null || text.isBlank()) + { + return Money.NONE; + } + String source = text; + if (period != null && period.text != null) + { + source = source.replace(period.text, " "); + } + Matcher percent = PERCENT.matcher(source); + if (percent.find() && !source.matches(".*(?i)(EUR|GBP|USD|RMB|CNY|欧元|英镑).*")) + { + return new Money(decimal(percent.group(1)), "", "%"); + } + Matcher matcher = MONEY.matcher(source); + Money found = Money.NONE; + while (matcher.find()) + { + String raw = matcher.group(1); + if (raw.startsWith("20") && raw.length() == 4) + { + continue; + } + String currency = currency(matcher.group(2)); + String unit = unit(source); + found = new Money(decimal(raw), currency, unit); + if (!currency.isEmpty()) + { + return found; + } + } + return found; + } + + public static String timing(String text, Period period) + { + String value = text == null ? "" : text; + if (value.contains("产生时") || value.contains("实报") || value.contains("实付")) + { + return "产生时收取"; + } + if (value.contains("旺季") || (period != null && period.start != null && value.contains("期间"))) + { + return "旺季期间"; + } + if (value.contains("固定收取") || value.contains("每票固定")) + { + return "固定收取"; + } + if (period != null && period.start != null) + { + return "旺季期间"; + } + return ""; + } + + public static String currencies(String text) + { + String source = text == null ? "" : text.toUpperCase(Locale.ROOT); + StringBuilder joined = new StringBuilder(); + appendCurrency(joined, source, "GBP", "英镑"); + appendCurrency(joined, source, "EUR", "欧元"); + appendCurrency(joined, source, "USD", "美元"); + if (source.contains("RMB") || source.contains("CNY") || source.contains("人民币")) + { + appendCurrency(joined, "CNY", "CNY", ""); + } + return joined.toString(); + } + + public static Date toDate(LocalDate day) + { + if (day == null) + { + return null; + } + // 用普通日期,避免 java.sql.Date 在接口序列化时抛出不支持的时间类型 + return Date.from(day.atStartOfDay(ZoneId.of("GMT+8")).toInstant()); + } + + private static void appendCurrency(StringBuilder joined, String source, String code, String alias) + { + if (source.contains(code) || (!alias.isEmpty() && source.contains(alias))) + { + if (joined.length() > 0) + { + joined.append(','); + } + joined.append(code); + } + } + + private static String currency(String token) + { + if (token == null) + { + return ""; + } + String value = token.toUpperCase(Locale.ROOT); + if (value.contains("GBP") || token.contains("英镑")) + { + return "GBP"; + } + if (value.contains("EUR") || token.contains("欧元")) + { + return "EUR"; + } + if (value.contains("USD") || token.contains("美元")) + { + return "USD"; + } + if (value.contains("RMB") || value.contains("CNY") || token.contains("人民币")) + { + return "CNY"; + } + return ""; + } + + private static String unit(String text) + { + String value = text.toUpperCase(Locale.ROOT); + if (value.contains("/ITEM") || value.contains("每票") || text.contains("/票") || value.contains("/PARCEL")) + { + return "ITEM"; + } + if (value.contains("/KG") || text.contains("/公斤") || text.contains("每公斤")) + { + return "KG"; + } + if (text.contains("/次")) + { + return "次"; + } + if (text.contains("%")) + { + return "%"; + } + return ""; + } + + private static int monthNumber(String name) + { + String[] months = { "JANUARY", "FEBRUARY", "MARCH", "APRIL", "MAY", "JUNE", "JULY", "AUGUST", + "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER" }; + String key = name.toUpperCase(Locale.ROOT); + for (int i = 0; i < months.length; i++) + { + if (months[i].equals(key)) + { + return i + 1; + } + } + return 1; + } + + private static LocalDate date(String year, String month, String day) + { + return date(Integer.parseInt(year), month, day); + } + + private static LocalDate date(int year, String month, String day) + { + return date(year, Integer.parseInt(month), day); + } + + private static LocalDate date(int year, int month, String day) + { + return LocalDate.of(year, month, Integer.parseInt(day)); + } + + private static BigDecimal decimal(String raw) + { + return new BigDecimal(raw); + } + + /** 一段可以落库的生效窗口。text 是原文片段。 */ + public static final class Period + { + public static final Period NONE = new Period(null, null, null); + + public final LocalDate start; + + public final LocalDate end; + + public final String text; + + public Period(LocalDate start, LocalDate end, String text) + { + this.start = start; + this.end = end; + this.text = text; + } + } + + /** 收费标准里的金额。unit 为 % 时 amount 是百分数本身。 */ + public static final class Money + { + public static final Money NONE = new Money(null, "", ""); + + public final BigDecimal amount; + + public final String currency; + + public final String unit; + + public Money(BigDecimal amount, String currency, String unit) + { + this.amount = amount; + this.currency = currency == null ? "" : currency; + this.unit = unit == null ? "" : unit; + } + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/label/LblWorkbookParser.java b/bms-back/bms-system/src/main/java/com/bms/price/label/LblWorkbookParser.java new file mode 100644 index 00000000..5a710562 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/label/LblWorkbookParser.java @@ -0,0 +1,5169 @@ +package com.bms.price.label; + +import com.bms.price.pay.PayParseProgressListener; + +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +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.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.util.DigestUtils; +import com.bms.common.enums.PaymentMesEnum; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.poi.ExcelGrid; + +import com.bms.price.domain.LblExtraLine; +import com.bms.price.domain.LblPreview; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblSurcharge; +import com.bms.price.domain.LblWeightBand; +import com.bms.price.label.LblTexts.Money; +import com.bms.price.label.LblTexts.Period; + +/** + * 读取打单价卡。第一张表是目录,只解析目录指向的产品报价表。 + * 分区表留在产品的关联字段里,不另建一条产品。高风险国家和燃油费率表写入对应产品的附加费。 + */ +public class LblWorkbookParser +{ + private static final int MAX_BYTES = 30 * 1024 * 1024; + + private static final int MAX_EXTRA = 20000; + + /** iMile 邮编分区一行一个邮编,行数超过普通价卡 */ + private static final int POSTCODE_ROWS = 12000; + + private static final Pattern FILE_DATE = Pattern.compile("(20\\d{2})[.\\-/年](\\d{1,2})[.\\-/月](\\d{1,2})"); + + private static final Pattern FILE_COMPACT = Pattern.compile("(20\\d{2})(\\d{2})(\\d{2})"); + + private static final Pattern FILE_MONTH_DAY = Pattern.compile("生效日期\\s*[::]?\\s*(\\d{1,2})[.\\-/](\\d{1,2})"); + + private static final Pattern COUNTRY_CODE = Pattern.compile("\\(([A-Z]{2})\\)"); + + private static final Pattern POSTCODE = Pattern.compile("(?i)([A-Z]{1,2}\\d|\\d{4,6}|[A-Z]{2}\\*)"); + + /** 偏远地区附加费单元格里的 Area A / Area B */ + private static final Pattern AREA_LABEL = Pattern.compile("(?i)\\b(area\\s*[A-Z])\\b"); + + /** 1.80 GBP/parcel 这种写在地区表左侧的收费标准 */ + private static final Pattern FEE_PRICE = Pattern.compile( + "(?i)(\\d+(?:\\.\\d+)?)\\s*(GBP|EUR|USD|CNY|RMB|欧元|英镑)?\\s*(/\\s*[A-Za-z]+)?"); + + /** + * 解析整本价卡,返回可直接预览、确认后再保存的产品列表。 + */ + public LblPreview parse(byte[] bytes, String filename) + { + if (bytes == null || bytes.length == 0) + { + throw PaymentMesEnum.BMS_PAY_PICK_FILE.exception(); + } + if (bytes.length > MAX_BYTES) + { + throw PaymentMesEnum.BMS_PAY_FILE_30M.exception(); + } + String safeName = filename == null ? "价卡.xlsx" : filename; + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) + { + if (workbook.getNumberOfSheets() == 0) + { + throw PaymentMesEnum.BMS_PAY_NO_SHEET.exception(); + } + ExcelGrid catalog = ExcelGrid.read(workbook.getSheetAt(0)); + FileMeta meta = fileMeta(safeName); + List items = readCatalog(catalog, workbook, meta); + if (items.isEmpty()) + { + throw PaymentMesEnum.BMS_PAY_NO_QUOTE.exception(); + } + LblPreview preview = new LblPreview(); + preview.setSourceFile(safeName); + preview.setFileMd5(DigestUtils.md5DigestAsHex(bytes)); + preview.setSupplierName(meta.supplier); + preview.setEffectiveDate(LblTexts.toDate(meta.effective)); + for (CatalogItem item : items) + { + preview.getProducts().add(buildProduct(workbook, item, meta, preview)); + } + attachImilePostcodes(workbook, preview.getProducts()); + attachCatalogRates(workbook, preview.getProducts()); + LblZonePostcodeAttach.attach(workbook, preview.getProducts()); + LblBandNormalizer.normalize(preview); + return preview; + } + catch (ServiceException ex) + { + throw ex; + } + catch (Exception ex) + { + throw PaymentMesEnum.BMS_PAY_BAD_XLS.exception(); + } + } + + /** + * 按字典值匹配工作表。选了单个产品时只读对应表;不选时读所有能对上的表。 + * 单元格里用分号隔开的国家、分区或费用名称,拆成多条。 + */ + public LblPreview parseMatched(byte[] bytes, String filename, String selected, Collection dictValues) + { + return parseMatched(bytes, filename, selected, dictValues, null); + } + + /** + * 与 {@link #parseMatched(byte[], String, String, Collection)} 相同。listener 只接收进度,不改变识别结果。 + */ + public LblPreview parseMatched(byte[] bytes, String filename, String selected, Collection dictValues, + PayParseProgressListener listener) + { + if (bytes == null || bytes.length == 0) + { + throw PaymentMesEnum.BMS_PAY_PICK_FILE.exception(); + } + if (bytes.length > MAX_BYTES) + { + throw PaymentMesEnum.BMS_PAY_FILE_30M.exception(); + } + Map dict = dictIndex(dictValues); + if (dict.isEmpty()) + { + throw PaymentMesEnum.BMS_PAY_DICT_EMPTY.exception(); + } + String safeName = filename == null ? "价卡.xlsx" : filename; + String wanted = norm(selected); + report(listener, 8, "正在打开工作簿"); + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) + { + if (workbook.getNumberOfSheets() == 0) + { + throw PaymentMesEnum.BMS_PAY_NO_SHEET.exception(); + } + FileMeta meta = fileMeta(safeName); + LblPreview preview = new LblPreview(); + preview.setSourceFile(safeName); + preview.setFileMd5(DigestUtils.md5DigestAsHex(bytes)); + preview.setSupplierName(meta.supplier); + preview.setEffectiveDate(LblTexts.toDate(meta.effective)); + List skipped = new ArrayList<>(); + int sheetCount = workbook.getNumberOfSheets(); + for (int i = 0; i < sheetCount; i++) + { + Sheet sheet = workbook.getSheetAt(i); + String sheetName = sheet.getSheetName() == null ? "" : sheet.getSheetName().trim(); + int start = 12 + i * 78 / sheetCount; + int end = 12 + (i + 1) * 78 / sheetCount; + String dictValue = dict.get(norm(sheetName)); + boolean holdForFallback = sheetCount == 1 && !wanted.isEmpty(); + if (dictValue == null) + { + skipped.add(sheetName); + if (!holdForFallback) + { + report(listener, end, "已跳过「" + sheetName + "」"); + } + continue; + } + if (!wanted.isEmpty() && !wanted.equals(norm(dictValue)) && !wanted.equals(norm(sheetName))) + { + skipped.add(sheetName); + if (!holdForFallback) + { + report(listener, end, "已跳过「" + sheetName + "」"); + } + continue; + } + report(listener, start, "正在解析「" + sheetName + "」"); + preview.getProducts().add(readMatchedSheet(sheet, sheetName, dictValue, meta, preview, listener, start, end)); + } + if (preview.getProducts().isEmpty() && !wanted.isEmpty() && sheetCount == 1) + { + Sheet sheet = workbook.getSheetAt(0); + String sheetName = sheet.getSheetName() == null ? "" : sheet.getSheetName().trim(); + String dictValue = dict.containsValue(selected.trim()) ? selected.trim() : labelOf(dict, wanted); + report(listener, 12, "正在解析「" + sheetName + "」"); + preview.getProducts().add(readMatchedSheet(sheet, sheetName, dictValue, meta, preview, listener, 12, 90)); + skipped.clear(); + } + if (preview.getProducts().isEmpty()) + { + throw new ServiceException(wanted.isEmpty() + ? "没有工作表能对上产品字典" + : "文件里没有「" + selected.trim() + "」这张工作表"); + } + report(listener, 92, "正在整理附加费"); + attachWorkbookRates(workbook, preview.getProducts()); + LblZonePostcodeAttach.attach(workbook, preview.getProducts()); + preview.setSkippedSheets(skipped); + report(listener, 96, "工作表解析完成"); + LblBandNormalizer.normalize(preview); + return preview; + } + catch (ServiceException ex) + { + throw ex; + } + catch (Exception ex) + { + throw PaymentMesEnum.BMS_PAY_BAD_XLS.exception(); + } + } + + private static void report(PayParseProgressListener listener, int percent, String message) + { + if (listener == null) + { + return; + } + try + { + listener.onProgress(percent, message); + } + catch (RuntimeException ignored) + { + // 进度写失败不影响价卡识别 + } + } + + private LblProduct readMatchedSheet(Sheet sheet, String sheetName, String dictValue, FileMeta meta, LblPreview preview, + PayParseProgressListener listener, int start, int end) + { + LblProduct product = new LblProduct(); + product.setProductName(dictValue); + product.setDictValue(dictValue); + product.setSheetName(sheetName); + product.setSourceFile(preview.getSourceFile()); + product.setFileMd5(preview.getFileMd5()); + product.setEffectiveDate(preview.getEffectiveDate()); + product.setSupplierName(supplierOf(meta, sheetName)); + product.setWeightBands(new ArrayList<>()); + product.setSurcharges(new ArrayList<>()); + product.setExtraLines(new ArrayList<>()); + fillSheet(readGrid(sheet, listener, start, end, sheetName), product); + expandSeparated(product); + report(listener, end, "已解析「" + sheetName + "」"); + return product; + } + + /** 读取这一张表。前半段是统计列宽,后半段是读取单元格。 */ + private ExcelGrid readGrid(Sheet sheet, PayParseProgressListener listener, int start, int end, String sheetName) + { + int span = Math.max(1, end - start); + int scanSpan = Math.max(1, span * 2 / 5); + return ExcelGrid.read(sheet, ExcelGrid.MAX_ROWS, (phase, row, total) -> { + if (total < 1) + { + return; + } + int done = Math.min(row + 1, total); + int percent = phase == 0 + ? start + scanSpan * done / total + : start + scanSpan + (span - scanSpan) * done / total; + report(listener, Math.min(end, percent), "正在解析「" + sheetName + "」"); + }); + } + + /** 分号拆开的是多条数据,不是一句话。句子太长时保持原样。 */ + private void expandSeparated(LblProduct product) + { + List bands = new ArrayList<>(); + for (LblWeightBand band : product.getWeightBands()) + { + List codes = splitList(band.getCountryCode()); + List countries = splitList(band.getCountryName()); + List zones = splitList(band.getZoneName()); + if (codes.size() > 1) + { + for (String code : codes) + { + LblWeightBand copy = copyBand(band); + copy.setCountryCode(code); + bands.add(copy); + } + } + else if (countries.size() > 1) + { + for (String country : countries) + { + LblWeightBand copy = copyBand(band); + copy.setCountryName(country); + bands.add(copy); + } + } + else if (zones.size() > 1) + { + for (String zone : zones) + { + LblWeightBand copy = copyBand(band); + copy.setZoneName(zone); + bands.add(copy); + } + } + else + { + bands.add(band); + } + } + int sort = 1; + for (LblWeightBand band : bands) + { + band.setSortNo(sort++); + } + product.setWeightBands(bands); + + List fees = new ArrayList<>(); + for (LblSurcharge fee : product.getSurcharges()) + { + List names = splitList(fee.getFeeName()); + List zones = splitList(fee.getZoneName()); + if (names.size() > 1) + { + for (String name : names) + { + LblSurcharge copy = copyFee(fee); + copy.setFeeName(name); + fees.add(copy); + } + } + else if (zones.size() > 1) + { + for (String zone : zones) + { + LblSurcharge copy = copyFee(fee); + copy.setZoneName(zone); + fees.add(copy); + } + } + else + { + fees.add(fee); + } + } + sort = 1; + for (LblSurcharge fee : fees) + { + fee.setSortNo(sort++); + } + product.setSurcharges(fees); + product.setWeightCount(bands.size()); + product.setSurchargeCount(fees.size()); + product.setExtraCount(product.getExtraLines() == null ? 0 : product.getExtraLines().size()); + if (product.getWeightCount() + product.getSurchargeCount() + product.getExtraCount() == 0) + { + product.setParseStatus("EMPTY"); + } + else + { + product.setParseStatus("OK"); + } + } + + private static Map dictIndex(Collection dictValues) + { + Map dict = new LinkedHashMap<>(); + if (dictValues == null) + { + return dict; + } + for (String value : dictValues) + { + if (value == null || value.isBlank()) + { + continue; + } + dict.put(norm(value), value.trim()); + } + return dict; + } + + private static String labelOf(Map dict, String wanted) + { + String label = dict.get(wanted); + return label == null ? wanted : label; + } + + private static String norm(String value) + { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT).replace(" ", ""); + } + + private static List splitList(String value) + { + if (value == null || !value.matches(".*[;;].*")) + { + return List.of(value == null ? "" : value); + } + String[] parts = value.split("[;;]"); + List items = new ArrayList<>(); + for (String part : parts) + { + String text = part.trim(); + if (!text.isEmpty()) + { + items.add(text); + } + } + if (items.size() < 2) + { + return List.of(value); + } + for (String item : items) + { + if (item.length() > 80) + { + return List.of(value); + } + } + return items; + } + + private static LblWeightBand copyBand(LblWeightBand band) + { + LblWeightBand copy = new LblWeightBand(); + copy.setSectionName(band.getSectionName()); + copy.setCountryName(band.getCountryName()); + copy.setCountryCode(band.getCountryCode()); + copy.setZoneName(band.getZoneName()); + copy.setWeightLabel(band.getWeightLabel()); + copy.setWeightMin(band.getWeightMin()); + copy.setWeightMax(band.getWeightMax()); + copy.setVolumeText(band.getVolumeText()); + copy.setChargeName(band.getChargeName()); + copy.setPrice(band.getPrice()); + copy.setPriceUnit(band.getPriceUnit()); + return copy; + } + + private static LblSurcharge copyFee(LblSurcharge fee) + { + LblSurcharge copy = new LblSurcharge(); + copy.setSectionName(fee.getSectionName()); + copy.setFeeName(fee.getFeeName()); + copy.setScId(fee.getScId()); + copy.setZoneName(fee.getZoneName()); + copy.setChargeText(fee.getChargeText()); + copy.setAmount(fee.getAmount()); + copy.setCurrency(fee.getCurrency()); + copy.setPriceUnit(fee.getPriceUnit()); + copy.setFeeDesc(fee.getFeeDesc()); + copy.setPeriodText(fee.getPeriodText()); + copy.setEffectiveStart(fee.getEffectiveStart()); + copy.setEffectiveEnd(fee.getEffectiveEnd()); + copy.setChargeTiming(fee.getChargeTiming()); + copy.setCalcRule(fee.getCalcRule()); + copy.setCalcParam(fee.getCalcParam()); + copy.setPostcodeParam(fee.getPostcodeParam()); + copy.setWeightMin(fee.getWeightMin()); + copy.setWeightMax(fee.getWeightMax()); + return copy; + } + + private LblProduct buildProduct(Workbook workbook, CatalogItem item, FileMeta meta, LblPreview preview) + { + LblProduct product = new LblProduct(); + product.setProductName(item.name); + product.setCountryScope(item.country); + product.setServiceType(item.serviceType); + product.setClassification(item.classification); + product.setCatalogRemark(item.remark); + product.setUpdateContent(item.updateContent); + product.setWeightLimit(item.weightLimit); + product.setSheetName(item.sheetName); + product.setRelatedSheet(item.relatedSheet); + product.setSourceFile(preview.getSourceFile()); + product.setFileMd5(preview.getFileMd5()); + product.setEffectiveDate(LblTexts.toDate(item.effective != null ? item.effective : meta.effective)); + product.setSupplierName(supplierOf(meta, item.sheetName)); + product.setWeightBands(new ArrayList<>()); + product.setSurcharges(new ArrayList<>()); + product.setExtraLines(new ArrayList<>()); + Sheet sheet = sheetNamed(workbook, item.sheetName); + if (sheet == null) + { + product.setParseStatus("MISSING"); + product.setRemark("目录指向的工作表不存在"); + product.setWeightCount(0); + product.setSurchargeCount(0); + product.setExtraCount(0); + return product; + } + fillSheet(ExcelGrid.read(sheet), product); + return product; + } + + /** 工作表名末尾多一个空格时,目录链接里的名字对不上,按去掉空格后再找一次。 */ + private static Sheet sheetNamed(Workbook workbook, String name) + { + if (name == null || name.isBlank()) + { + return null; + } + Sheet sheet = workbook.getSheet(name); + if (sheet != null) + { + return sheet; + } + String wanted = name.trim(); + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String current = workbook.getSheetName(i); + if (current != null && current.trim().equals(wanted)) + { + return workbook.getSheetAt(i); + } + } + return null; + } + + /** + * iMile邮编分区左右两块分别挂到 IT-iMile、DE-iMile,不另建产品。 + * 意大利用邮编找到州、城市,并把分区换成价卡上的 City、Province、Rural。 + * 德国这块没有州和城市,只保留 Active 的邮编覆盖。 + */ + private void attachImilePostcodes(Workbook workbook, List products) + { + Sheet sheet = null; + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String name = workbook.getSheetName(i); + if (name != null && name.contains("iMile") && name.contains("邮编")) + { + sheet = workbook.getSheetAt(i); + break; + } + } + if (sheet == null) + { + return; + } + LblProduct italy = productOf(products, "IT-iMile"); + LblProduct germany = productOf(products, "DE-iMile"); + if (italy == null && germany == null) + { + return; + } + ExcelGrid grid = ExcelGrid.read(sheet, POSTCODE_ROWS); + int header = -1; + for (int row = 0; row < Math.min(grid.rows(), 6); row++) + { + String line = joinRow(grid, row); + if (line.contains("邮编") && (line.contains("城市") || line.toLowerCase(Locale.ROOT).contains("zip"))) + { + header = row; + break; + } + } + if (header < 0) + { + return; + } + int regionCol = headerColumn(grid, header, text -> text.contains("地区") && !text.contains("代码")); + int cityCol = headerColumn(grid, header, text -> text.contains("城市")); + int codeCol = headerColumn(grid, header, text -> text.contains("地区代码")); + int postcodeCol = headerColumn(grid, header, text -> text.contains("邮编")); + int zoneCol = headerColumn(grid, header, text -> "分区".equals(text) || text.startsWith("分区")); + int countryCol = headerColumn(grid, header, text -> text.toLowerCase(Locale.ROOT).contains("country")); + int productCol = headerColumn(grid, header, text -> "product".equalsIgnoreCase(text) || text.toLowerCase(Locale.ROOT).startsWith("product")); + int zipCol = headerColumn(grid, header, text -> text.toLowerCase(Locale.ROOT).contains("zip")); + int statusCol = headerColumn(grid, header, text -> text.toLowerCase(Locale.ROOT).contains("status")); + for (int row = header + 1; row < grid.rows(); row++) + { + if (italy != null && postcodeCol >= 0) + { + String postcode = postcodeOf(grid, row, postcodeCol); + String zone = imileZone(grid.text(row, zoneCol)); + if (!postcode.isEmpty() && !zone.isEmpty()) + { + addPostcode(italy, cell(grid, row, regionCol), cell(grid, row, cityCol), cell(grid, row, codeCol), postcode, zone); + } + } + if (germany != null && zipCol >= 0) + { + String postcode = postcodeOf(grid, row, zipCol); + String status = cell(grid, row, statusCol); + if (!postcode.isEmpty() && (statusCol < 0 || status.equalsIgnoreCase("Active"))) + { + addPostcode(germany, cell(grid, row, countryCol), cell(grid, row, productCol), postcode, status.isEmpty() ? "Active" : status); + } + } + } + String sheetName = sheet.getSheetName(); + if (italy != null && !italy.getExtraLines().isEmpty()) + { + linkSheet(italy, sheetName); + italy.setExtraCount(italy.getExtraLines().size()); + } + if (germany != null && !germany.getExtraLines().isEmpty()) + { + linkSheet(germany, sheetName); + germany.setExtraCount(germany.getExtraLines().size()); + } + if (sheet.getLastRowNum() >= POSTCODE_ROWS) + { + String remark = "邮编分区超过" + POSTCODE_ROWS + "行,已保留前" + POSTCODE_ROWS + "行"; + if (italy != null) + { + italy.setRemark(remark); + } + if (germany != null) + { + germany.setRemark(remark); + } + } + } + + private static LblProduct productOf(List products, String name) + { + for (LblProduct product : products) + { + if (name.equals(product.getSheetName()) || name.equals(product.getProductName())) + { + return product; + } + } + return null; + } + + private static int headerColumn(ExcelGrid grid, int row, java.util.function.Predicate test) + { + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (!text.isEmpty() && test.test(text)) + { + return col; + } + } + return -1; + } + + /** city(dense)、province(no dense)、rural(remote) 对应价卡从左到右的三列。 */ + private static String imileZone(String text) + { + String value = text == null ? "" : text.trim().toLowerCase(Locale.ROOT); + if (value.startsWith("city")) + { + return "City"; + } + if (value.startsWith("province")) + { + return "Province"; + } + if (value.startsWith("rural")) + { + return "Rural"; + } + return ""; + } + + private static String postcodeOf(ExcelGrid grid, int row, int col) + { + if (col < 0) + { + return ""; + } + Double number = grid.number(row, col); + if (number != null) + { + long value = Math.round(number); + if (value >= 0 && value <= 99999) + { + return String.format("%05d", value); + } + } + String text = grid.text(row, col).trim(); + if (text.matches("\\d{1,5}")) + { + return String.format("%05d", Integer.parseInt(text)); + } + return text; + } + + private static String cell(ExcelGrid grid, int row, int col) + { + if (col < 0) + { + return ""; + } + return grid.text(row, col).replace('\n', ' ').trim(); + } + + private void addPostcode(LblProduct product, String... parts) + { + if (product.getExtraLines().size() >= MAX_EXTRA) + { + product.setRemark("邮编超过" + MAX_EXTRA + "行,已保留前" + MAX_EXTRA + "行"); + return; + } + StringBuilder text = new StringBuilder(); + for (String part : parts) + { + if (text.length() > 0) + { + text.append(" | "); + } + text.append(part == null ? "" : part); + } + LblExtraLine line = new LblExtraLine(); + line.setLineType("邮编"); + line.setLineText(text.toString()); + line.setSortNo(product.getExtraLines().size() + 1); + product.getExtraLines().add(line); + } + + private static void linkSheet(LblProduct product, String sheetName) + { + String current = product.getRelatedSheet(); + if (current == null || current.isBlank()) + { + product.setRelatedSheet(sheetName); + return; + } + if (!current.contains(sheetName)) + { + product.setRelatedSheet(current + "、" + sheetName); + } + } + + /** + * 目录分区表里的高风险国家,以及报价里写了危机燃油、但费率在单独表上的阶梯,挂回原产品。 + * 不另建产品,服务类型、国家和销售产品仍以目录那一行为准。 + */ + private void attachCatalogRates(Workbook workbook, List products) + { + for (LblProduct product : products) + { + String related = product.getRelatedSheet(); + if (related == null || related.isBlank()) + { + continue; + } + for (String name : related.split("、")) + { + Sheet sheet = workbook.getSheet(name.trim()); + if (sheet == null) + { + continue; + } + addHighRisk(product, ExcelGrid.read(sheet, 500), sheet.getSheetName()); + } + } + Sheet fuel = null; + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String name = workbook.getSheetName(i); + if (name != null && name.contains("危机燃油")) + { + fuel = workbook.getSheetAt(i); + break; + } + } + if (fuel == null) + { + return; + } + ExcelGrid grid = ExcelGrid.read(fuel, 80); + for (LblProduct product : products) + { + String relatedName = product.getRelatedSheet() == null ? "" : product.getRelatedSheet(); + String sheetName = product.getSheetName() == null ? "" : product.getSheetName(); + // 费率表只挂到目录关联了它的产品,以及德国本地 DPD。国际件自己的说明行不能把整张阶梯再复制一份 + boolean linked = relatedName.contains("危机燃油"); + boolean domesticDpd = sheetName.contains("DPD挂号") && !sheetName.contains("国际"); + if (!linked && !domesticDpd) + { + continue; + } + int before = product.getSurcharges().size(); + addFuelLadder(product, grid, fuel.getSheetName()); + if (product.getSurcharges().size() > before) + { + linkSheet(product, fuel.getSheetName()); + product.setSurchargeCount(product.getSurcharges().size()); + } + } + } + + /** + * 只导入其中一张产品表时,同一本里的高风险国家表仍挂回提到它的产品。 + * 整本目录解析走关联字段,不从这里再挂一次。 + */ + private void attachWorkbookRates(Workbook workbook, List products) + { + Sheet risk = null; + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String name = workbook.getSheetName(i); + if (name != null && name.contains("高风险")) + { + risk = workbook.getSheetAt(i); + break; + } + } + if (risk == null) + { + return; + } + String riskName = risk.getSheetName(); + ExcelGrid grid = ExcelGrid.read(risk, 500); + for (LblProduct product : products) + { + if (riskName.equals(product.getSheetName()) || !mentionsHighRisk(product)) + { + continue; + } + int before = product.getSurcharges().size(); + addHighRisk(product, grid, riskName); + if (product.getSurcharges().size() > before) + { + linkSheet(product, riskName); + product.setSurchargeCount(product.getSurcharges().size()); + } + } + } + + private static boolean mentionsHighRisk(LblProduct product) + { + if (product.getSurcharges() == null) + { + return false; + } + for (LblSurcharge fee : product.getSurcharges()) + { + String text = (fee.getFeeName() == null ? "" : fee.getFeeName()) + + (fee.getChargeText() == null ? "" : fee.getChargeText()); + if (text.contains("高风险")) + { + return true; + } + } + return false; + } + + /** 国家、国家简码、每公斤金额。说明行没有二字码,写进费用解释。 */ + private void addHighRisk(LblProduct product, ExcelGrid grid, String sheetName) + { + int header = -1; + int countryCol = -1; + int isoCol = -1; + int amountCol = -1; + for (int row = 0; row < Math.min(grid.rows(), 8); row++) + { + int country = -1; + int iso = -1; + int amount = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.contains("简码") || text.contains("二字")) + { + iso = col; + } + else if (text.contains("国家")) + { + country = col; + } + else if (text.toUpperCase(Locale.ROOT).contains("RMB") || text.contains("/kg") || text.toLowerCase(Locale.ROOT).contains("surcharge")) + { + amount = col; + } + } + if (iso >= 0 && amount >= 0) + { + header = row; + countryCol = country; + isoCol = iso; + amountCol = amount; + break; + } + } + if (header < 0) + { + return; + } + String unit = grid.text(header, amountCol).replace('\n', ' ').trim(); + String currency = unit.toUpperCase(Locale.ROOT).contains("GBP") ? "GBP" : unit.toUpperCase(Locale.ROOT).contains("EUR") ? "EUR" : "RMB"; + StringBuilder notes = new StringBuilder(); + for (int row = 0; row < grid.rows(); row++) + { + if (row == header) + { + continue; + } + for (int col = 0; col < grid.cols(); col++) + { + if (col == countryCol || col == isoCol || col == amountCol) + { + continue; + } + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.length() > 12) + { + if (notes.length() > 0) + { + notes.append(' '); + } + notes.append(text); + } + } + } + String desc = cut(notes, 2000); + for (int row = header + 1; row < grid.rows(); row++) + { + String code = grid.text(row, isoCol).trim().toUpperCase(Locale.ROOT); + BigDecimal amount = plainDecimal(grid.text(row, amountCol)); + if (!code.matches("[A-Z]{2}") || amount == null) + { + continue; + } + String country = countryCol < 0 ? "" : grid.text(row, countryCol).replace('\n', ' ').trim(); + LblSurcharge fee = new LblSurcharge(); + fee.setSectionName(cut(new StringBuilder(sheetName), 200)); + fee.setFeeName("高风险地区附加费"); + fee.setZoneName(cut(new StringBuilder(country.isEmpty() ? code : country), 200)); + fee.setAmount(amount); + fee.setCurrency(currency); + fee.setPriceUnit(currency + (unit.toLowerCase(Locale.ROOT).contains("/kg") ? "/KG" : "")); + fee.setChargeText(cut(new StringBuilder(amount.toPlainString() + " " + currency + (unit.contains("/kg") ? "/kg" : "")), 500)); + fee.setFeeDesc(desc); + fee.setCalcRule(LblSurchargeRules.MANUAL); + fee.setCalcParam(code); + fee.setSortNo(product.getSurcharges().size() + 1); + product.getSurcharges().add(fee); + } + product.setSurchargeCount(product.getSurcharges().size()); + } + + /** 燃油公开价区间对应的危机燃油费率。低于下限的那一行费率是 0。 */ + private void addFuelLadder(LblProduct product, ExcelGrid grid, String sheetName) + { + int header = -1; + int fromCol = -1; + int toCol = -1; + int rateCol = -1; + for (int row = 0; row < Math.min(grid.rows(), 6); row++) + { + int from = -1; + int to = -1; + int rate = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).toLowerCase(Locale.ROOT); + if (text.contains("from")) + { + from = col; + } + else if (text.contains("less")) + { + to = col; + } + else if (text.contains("surcharge") || text.contains("费率")) + { + rate = col; + } + } + if (from >= 0 && to >= 0 && rate >= 0) + { + header = row; + fromCol = from; + toCol = to; + rateCol = rate; + break; + } + } + if (header < 0) + { + return; + } + for (int row = header + 1; row < grid.rows(); row++) + { + BigDecimal rate = plainDecimal(grid.text(row, rateCol)); + if (rate == null) + { + continue; + } + String from = boundText(grid.text(row, fromCol)); + String to = boundText(grid.text(row, toCol)); + if (from.isEmpty() && to.isEmpty()) + { + continue; + } + String charge; + if (from.isEmpty()) + { + charge = "油价 < " + to; + } + else if (to.isEmpty()) + { + charge = "油价 ≥ " + from; + } + else + { + charge = from + " ≤ 油价 < " + to; + } + LblSurcharge fee = new LblSurcharge(); + fee.setSectionName(cut(new StringBuilder(sheetName), 200)); + fee.setFeeName("危机燃油附加费"); + fee.setChargeText(charge); + fee.setAmount(rate); + fee.setFeeDesc("燃油公开价落在该区间时的危机燃油费率"); + fee.setCalcRule(LblSurchargeRules.MANUAL); + fee.setCalcParam(from + "|" + to); + fee.setSortNo(product.getSurcharges().size() + 1); + product.getSurcharges().add(fee); + } + } + + private static String boundText(String text) + { + String value = text == null ? "" : text.trim(); + if (value.isEmpty() || "...".equals(value) || "-".equals(value) || "—".equals(value)) + { + return ""; + } + return value; + } + + private static BigDecimal plainDecimal(String text) + { + String value = text == null ? "" : text.trim().replace(",", ""); + if (!value.matches("-?\\d+(\\.\\d+)?")) + { + return null; + } + return new BigDecimal(value); + } + + /** 文件名里的易德、威狮;8.1 这种只把 DHL 泛欧换成 GWE。 */ + private String supplierOf(FileMeta meta, String sheetName) + { + if (meta.gweFile && sheetName != null && sheetName.contains("DHL泛欧")) + { + return "GWE"; + } + return meta.supplier; + } + + private void fillSheet(ExcelGrid grid, LblProduct product) + { + StringBuilder note = new StringBuilder(); + String pending = ""; + int row = 0; + // PosteItaliane 等:已读过一块主派送费后,跳过后续普通派送费表(如 Crono CE) + boolean mainDeliveryRead = false; + while (row < grid.rows()) + { + if (grid.blankRow(row)) + { + row++; + continue; + } + int zoneCodeRow = countryZoneCodeRow(grid, row); + if (zoneCodeRow >= 0) + { + row = readCountryZoneTable(grid, zoneCodeRow, product); + pending = ""; + continue; + } + int serviceHeader = servicePriceHeader(grid, row); + if (serviceHeader >= 0) + { + row = readServicePrices(grid, serviceHeader, product); + pending = ""; + continue; + } + if (isBillableConceptHeader(grid, row)) + { + row = readBillableConcepts(grid, row, product); + pending = ""; + continue; + } + if (isInsularServiceHeader(grid, row)) + { + row = readInsularRates(grid, row, product); + pending = ""; + continue; + } + if (isKgStepHeader(grid, row)) + { + row = readKgStepTable(grid, row, product); + pending = ""; + continue; + } + int remoteHeader = remoteFeeHeader(grid, row); + if (remoteHeader >= 0) + { + row = readRemoteFeeTable(grid, remoteHeader, product); + pending = ""; + continue; + } + if (isWeightColumnHeader(grid, row)) + { + row = readWeightColumns(grid, row, pending, product); + pending = ""; + continue; + } + if (isRegionWeightHeader(grid, row)) + { + row = readRegionWeightTable(grid, row, product); + pending = ""; + continue; + } + if (isSplitWeightHeader(grid, row)) + { + row = readSplitWeightTable(grid, row, product); + pending = ""; + continue; + } + if (isFeeSectionTitle(grid, row)) + { + row = readFeeSection(grid, row, product); + pending = ""; + continue; + } + if (isSurchargeHeader(grid, row)) + { + row = readSurchargeTable(grid, row, pending, product, note); + pending = ""; + continue; + } + if (isSurchargeMatrix(grid, row)) + { + row = readSurchargeMatrix(grid, row, pending, product); + pending = ""; + continue; + } + if (isExtraLineHaulHeader(grid, row)) + { + row = skipPriceTable(grid, row); + pending = ""; + continue; + } + if (isFeeRateHeader(grid, row)) + { + row = readFeeRateTable(grid, row, product); + pending = ""; + continue; + } + if (isPriceHeader(grid, row)) + { + boolean secondary = isSecondaryServicePriceHeader(grid, row); + boolean repeatDelivery = mainDeliveryRead && isMainDeliveryPriceHeader(grid, row); + if (secondary || repeatDelivery) + { + row = skipPriceTable(grid, row); + pending = ""; + continue; + } + boolean mainDelivery = isMainDeliveryPriceHeader(grid, row); + row = readPriceTable(grid, row, pending, product, note); + if (mainDelivery) + { + mainDeliveryRead = true; + } + pending = ""; + continue; + } + if (isZonePriceHeader(grid, row)) + { + row = readZonePrices(grid, row, product); + pending = ""; + continue; + } + if (isPostcodeHeader(grid, row)) + { + row = readLines(grid, row, "邮编", product); + pending = ""; + continue; + } + if (isNoteTitle(grid, row)) + { + row = readNoteBlock(grid, row, product); + pending = ""; + continue; + } + if (isLooseSurcharge(grid, row)) + { + addLooseSurcharge(grid, row, pending, product); + row++; + continue; + } + String line = joinRow(grid, row); + // 备注、说明段落不是表格,不写入附加费,也不收进服务说明 + if (countText(grid, row) <= 2 && line.length() <= 40 && !line.contains("特别申明") && !line.contains("。")) + { + pending = line; + } + row++; + } + readFuelMark(grid, product); + if (product.getWeightBands().isEmpty() && product.getSurcharges().isEmpty() && product.getExtraLines().isEmpty() + && postcodeHits(grid) >= 5) + { + readLines(grid, 0, "邮编", product); + } + product.setServiceNote(cut(note, 4000)); + String detectedCurrency = currencyOf(grid); + if ((detectedCurrency == null || detectedCurrency.isBlank()) && product.getCurrency() != null && !product.getCurrency().isBlank()) + { + detectedCurrency = product.getCurrency(); + } + if (detectedCurrency == null || detectedCurrency.isBlank()) + { + detectedCurrency = "EUR"; + } + product.setCurrency(detectedCurrency); + fillSurchargeCurrency(product, detectedCurrency); + product.setWeightCount(product.getWeightBands().size()); + product.setSurchargeCount(product.getSurcharges().size()); + product.setExtraCount(product.getExtraLines().size()); + applyFileCountry(product); + product.setChargeType(chargeTypeOf(product)); + product.setExtraPerKg("续重计费".equals(product.getChargeType()) ? extraPerKgOf(product) : null); + if (product.getWeightCount() + product.getSurchargeCount() + product.getExtraCount() == 0) + { + product.setParseStatus("EMPTY"); + product.setRemark("报价表里没有识别到重量段、附加费或邮编配置"); + } + else + { + product.setParseStatus("OK"); + } + } + + /** + * 和派送费同一版式但仅有额外线路等表头时仍走此路径。偏远列落 fee_type=REMOTE。 + */ + private int readFeeRateTable(ExcelGrid grid, int header, LblProduct product) + { + int data = header + 1; + int subHeader = -1; + while (data < grid.rows() && !grid.blankRow(data) && !isHardHeader(grid, data)) + { + if (countNumbers(grid, data) > 0 || rowHasWeight(grid, data)) + { + break; + } + if (countText(grid, data) >= 2) + { + subHeader = data; + } + data++; + } + int[] kind = new int[grid.cols()]; + String[] parent = new String[grid.cols()]; + String[] sub = new String[grid.cols()]; + String carry = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (strayColumn(grid, header, col, subHeader)) + { + parent[col] = ""; + sub[col] = ""; + kind[col] = -1; + continue; + } + String head = grid.text(header, col); + if (isPriceHead(head)) + { + carry = head; + } + if (head.isBlank()) + { + head = carry; + } + parent[col] = head; + sub[col] = subHeader < 0 ? "" : grid.text(subHeader, col); + kind[col] = columnKind(head, numericBelow(grid, col, data)); + } + int added = product.getSurcharges().size(); + Map includedKg = new LinkedHashMap<>(); + while (data < grid.rows()) + { + if (grid.blankRow(data) || isHardHeader(grid, data)) + { + break; + } + String weight = ""; + String country = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(data, col); + if (text.isBlank()) + { + continue; + } + if (kind[col] == 1 || (weight.isEmpty() && LblTexts.looksLikeWeight(text))) + { + if (weight.isEmpty() || kind[col] == 1) + { + weight = text.replace('\n', ' ').trim(); + } + } + else if (kind[col] == 2 && country.isEmpty()) + { + country = text.replace('\n', ' ').trim(); + } + } + boolean priced = false; + for (int col = 0; col < grid.cols(); col++) + { + if (kind[col] != 4) + { + continue; + } + BigDecimal price = priceOf(grid, data, col); + if (price == null) + { + continue; + } + priced = true; + String feeName = shortCharge(parent[col]); + String zone = zoneOf(sub[col], parent[col]).replace('\n', ' ').replaceAll("\\s+", " ").trim(); + String standard = price.stripTrailingZeros().toPlainString() + " " + feeUnit(parent[col], weight); + String desc = country.isBlank() ? weight : country + ";" + weight; + addSurcharge(product, feeName, feeName, zone, standard.trim(), desc, ""); + LblSurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + applyFeeRateRule(fee, feeName, zone, weight, includedKg); + } + if (!priced && joinRow(grid, data).length() > 40) + { + break; + } + data++; + } + if (data < grid.rows() && !grid.blankRow(data) && !isHardHeader(grid, data)) + { + String note = joinRow(grid, data).replace('\n', ' ').trim(); + if (note.length() > 20 && countNumbers(grid, data) == 0) + { + for (int i = added; i < product.getSurcharges().size(); i++) + { + LblSurcharge fee = product.getSurcharges().get(i); + String desc = fee.getFeeDesc() == null ? "" : fee.getFeeDesc(); + fee.setFeeDesc(cut(new StringBuilder(desc.isBlank() ? note : desc + "。" + note), 2000)); + } + data++; + } + } + return data; + } + + private int readPriceTable(ExcelGrid grid, int header, String pending, LblProduct product, StringBuilder note) + { + int data = header + 1; + int groupHeader = -1; + int subHeader = -1; + String section = cleanTitle(pending); + while (data < grid.rows() && !grid.blankRow(data) && !isHardHeader(grid, data)) + { + if (countNumbers(grid, data) > 0 || rowHasWeight(grid, data)) + { + break; + } + if (countText(grid, data) >= 2) + { + if (subHeader >= 0) + { + groupHeader = subHeader; + } + subHeader = data; + } + else if (section.isEmpty()) + { + section = joinRow(grid, data); + } + data++; + } + int[] kind = new int[grid.cols()]; + String[] parent = new String[grid.cols()]; + String[] sub = new String[grid.cols()]; + String[] group = new String[grid.cols()]; + String carry = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (strayColumn(grid, header, col, subHeader)) + { + parent[col] = ""; + sub[col] = ""; + group[col] = ""; + kind[col] = -1; + continue; + } + String head = grid.text(header, col); + if (isPriceHead(head)) + { + carry = head; + } + if (head.isBlank()) + { + head = carry; + } + parent[col] = head; + sub[col] = subHeader < 0 ? "" : headerCellText(grid, subHeader, col); + group[col] = groupHeader < 0 ? "" : headerCellText(grid, groupHeader, col); + kind[col] = columnKind(head, numericBelow(grid, col, data)); + } + // 横向合并国家行(如西班牙跨多列)向右继承 + String groupCarry = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (kind[col] != 4) + { + continue; + } + if (group[col] != null && !group[col].isBlank()) + { + groupCarry = group[col]; + } + else if (!groupCarry.isEmpty()) + { + group[col] = groupCarry; + } + } + if (section.isEmpty()) + { + section = firstFeeHead(parent); + } + while (data < grid.rows()) + { + if (grid.blankRow(data) || isHardHeader(grid, data)) + { + break; + } + if (isLooseSurcharge(grid, data) && countNumbers(grid, data) <= 1 && !rowHasWeight(grid, data)) + { + break; + } + String weight = ""; + String rowCountry = ""; + String rowCode = ""; + String rowLabel = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(data, col); + if ((kind[col] == 2 || kind[col] == 3) && text.isBlank()) + { + text = grid.mergedText(data, col); + } + if (text.isBlank()) + { + continue; + } + if (kind[col] == 0 && parent[col].contains("尺寸")) + { + keepLimit(product, parent[col], text); + } + if (kind[col] == 1 || (weight.isEmpty() && LblTexts.looksLikeWeight(text))) + { + if (weight.isEmpty() || kind[col] == 1) + { + weight = text; + } + } + else if (kind[col] == 2) + { + rowCountry = text; + } + else if (kind[col] == 3) + { + rowCode = text; + } + else if (kind[col] == 0 && rowLabel.isEmpty() && text.length() <= 40) + { + rowLabel = text; + } + if (kind[col] == 0 && (text.contains("已含") || text.contains("燃油"))) + { + appendNote(note, parent[col] + ":" + text); + } + } + // 国家、产品只取本行单元格。纵向合并不再抄到后面的重量段和超重行 + String country = rowCountry; + String countryCode = rowCode.isBlank() ? codesIn(rowCountry) : rowCode.trim(); + boolean priced = false; + String volume = volumeOnRow(grid, data, kind, parent); + BigDecimal baseFreight = null; + String baseFreightUnit = null; + String baseFreightName = "派送费"; + for (int col = 0; col < grid.cols(); col++) + { + if (kind[col] != 4 || isRemoteHandleHead(parent[col]) || isSurchargePriceHead(parent[col])) + { + continue; + } + if (!isBaseFreightHead(parent[col])) + { + continue; + } + BigDecimal basePrice = priceOf(grid, data, col); + if (basePrice == null) + { + continue; + } + baseFreight = basePrice; + baseFreightUnit = unitOf(parent[col] + " " + rowLabel); + baseFreightName = chargeTitle(parent[col]); + break; + } + for (int col = 0; col < grid.cols(); col++) + { + if (kind[col] != 4) + { + continue; + } + BigDecimal price = priceOf(grid, data, col); + if (price == null) + { + continue; + } + if (col > 0 && parent[col].equals(parent[col - 1]) + && zoneOf(sub[col], parent[col]).equals(zoneOf(sub[col - 1], parent[col - 1]))) + { + BigDecimal previous = priceOf(grid, data, col - 1); + if (previous != null && previous.compareTo(price) == 0) + { + continue; + } + } + priced = true; + if (isSurchargePriceHead(parent[col])) + { + addPricedSurcharge(product, blankTo(section, "价格"), parent[col], zoneOf(sub[col], parent[col]), weight, price); + continue; + } + boolean feeHead = isPriceHead(parent[col]); + boolean remoteHandle = isRemoteHandleHead(parent[col]); + String rawZone = zoneOf(sub[col], parent[col]); + String zoneName = resolveZoneName(rawZone, remoteHandle); + String[] countryPair = countryFromGroup(group[col], country, countryCode); + LblWeightBand band = new LblWeightBand(); + band.setSectionName(blankTo(section, "价格")); + band.setCountryName(countryPair[0]); + band.setCountryCode(countryPair[1]); + band.setWeightLabel(weight); + BigDecimal[] bounds = LblTexts.weightBounds(weight); + band.setWeightMin(bounds[0]); + band.setWeightMax(bounds[1]); + if (remoteHandle && !isFirstOrExtraWeight(weight)) + { + // Evri 等:大陆 FREIGHT + 偏远列 REMOTE(分行) + band.setFeeType(com.bms.price.domain.LblFeeType.FREIGHT); + band.setChargeName(baseFreightName); + band.setZoneName(zoneName); + band.setPrice(baseFreight); + band.setPriceUnit(baseFreightUnit); + band.setChargeMode(com.bms.price.domain.LblChargeMode.ITEM); + LblWeightBand remote = new LblWeightBand(); + remote.setSectionName(band.getSectionName()); + remote.setCountryName(countryPair[0]); + remote.setCountryCode(countryPair[1]); + remote.setWeightLabel(weight); + remote.setWeightMin(bounds[0]); + remote.setWeightMax(bounds[1]); + remote.setFeeType(com.bms.price.domain.LblFeeType.REMOTE); + remote.setChargeName(chargeTitle(parent[col])); + remote.setZoneName(zoneName); + applyRemotePostcode(remote, rawZone); + remote.setPrice(price); + remote.setPriceUnit(unitOf(parent[col] + " " + rowLabel)); + remote.setChargeMode(com.bms.price.domain.LblChargeMode.ITEM); + product.getWeightBands().add(remote); + } + else if (remoteHandle) + { + band.setFeeType(com.bms.price.domain.LblFeeType.REMOTE); + band.setChargeName(chargeTitle(parent[col])); + band.setZoneName(zoneName); + applyRemotePostcode(band, rawZone); + band.setPrice(price); + band.setPriceUnit(unitOf(parent[col] + " " + rowLabel)); + band.setChargeMode(com.bms.price.domain.LblChargeMode.ITEM); + } + else if (feeHead) + { + // 处理费/挂号落 REGISTRATION,避免与运费 FREIGHT 同国家同重量段撞 uk_lbl_band_dim + String feeTitle = chargeTitle(parent[col]); + band.setFeeType(isRegistrationHead(parent[col]) + ? com.bms.price.domain.LblFeeType.REGISTRATION + : com.bms.price.domain.LblFeeType.FREIGHT); + band.setChargeName(feeTitle); + band.setZoneName(zoneName); + band.setPrice(price); + band.setPriceUnit(unitOf(parent[col] + " " + rowLabel)); + if (isRegistrationHead(parent[col])) + { + band.setChargeMode(com.bms.price.domain.LblChargeMode.ITEM); + } + } + else + { + band.setChargeName(rowLabel.isBlank() ? "" : shortCharge(rowLabel)); + band.setZoneName(parent[col].isBlank() ? zoneName : parent[col]); + band.setPrice(price); + band.setPriceUnit(unitOf(parent[col] + " " + rowLabel)); + } + band.setVolumeText(volume.isBlank() ? null : volume); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + if (!priced && joinRow(grid, data).length() > 40) + { + break; + } + data++; + } + return data; + } + + private int readWeightColumns(ExcelGrid grid, int header, String pending, LblProduct product) + { + List weightCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + if (LblTexts.looksLikeWeight(grid.text(header, col))) + { + weightCols.add(col); + } + } + int labelCol = 0; + for (int col = 0; col < grid.cols(); col++) + { + if (!weightCols.contains(col) && !grid.text(header, col).isBlank()) + { + labelCol = col; + break; + } + } + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isSurchargeHeader(grid, row) && !isPriceHeader(grid, row)) + { + int numbers = 0; + for (int col : weightCols) + { + if (priceOf(grid, row, col) != null) + { + numbers++; + } + } + String label = grid.text(row, labelCol); + if (numbers <= 1 && label.contains("费")) + { + break; + } + if (numbers == 0) + { + break; + } + for (int col : weightCols) + { + BigDecimal price = priceOf(grid, row, col); + if (price == null) + { + continue; + } + LblWeightBand band = new LblWeightBand(); + band.setSectionName(blankTo(cleanTitle(pending), "价格")); + band.setChargeName(label.isBlank() ? "" : shortCharge(label)); + band.setWeightLabel(grid.text(header, col)); + BigDecimal[] bounds = LblTexts.weightBounds(band.getWeightLabel()); + band.setWeightMin(bounds[0]); + band.setWeightMax(bounds[1]); + band.setPrice(price); + band.setPriceUnit(unitOf(grid.text(header, labelCol) + label)); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + row++; + } + return row; + } + + /** + * 表头「尾程(公斤)」下面两列是重量段起止,右边一列是每票价格。 + * 英国 RM 没有「0-5kg」原文,两列数字要拼成一个重量段。 + */ + private int readSplitWeightTable(ExcelGrid grid, int header, LblProduct product) + { + int weightCol = -1; + int priceCol = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col); + String lower = text.toLowerCase(Locale.ROOT); + if (weightCol < 0 && (text.contains("公斤") || text.contains("重量") || text.contains("尾程") || lower.contains("kg"))) + { + weightCol = col; + } + if (text.contains("每件") || text.contains("每票") || text.contains("单价")) + { + priceCol = col; + } + } + if (weightCol < 0 || priceCol < 0) + { + return header + 1; + } + int maxCol = -1; + if (grid.colSpan(header, weightCol) >= 2 && weightCol + 1 < priceCol) + { + maxCol = weightCol + 1; + } + else + { + for (int col = weightCol + 1; col < priceCol; col++) + { + if (grid.text(header, col).isBlank()) + { + maxCol = col; + break; + } + } + } + String section = shortCharge(grid.text(header, weightCol)); + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isHardHeader(grid, row)) + { + BigDecimal price = priceOf(grid, row, priceCol); + String min = grid.text(row, weightCol).trim(); + String max = maxCol < 0 ? "" : grid.text(row, maxCol).trim(); + if (price == null || min.isBlank() || min.contains("费")) + { + break; + } + LblWeightBand band = new LblWeightBand(); + band.setSectionName(blankTo(section, "价格")); + band.setWeightLabel(max.isBlank() ? min : min + "-" + max); + BigDecimal[] bounds = LblTexts.weightBounds(band.getWeightLabel()); + band.setWeightMin(bounds[0]); + band.setWeightMax(bounds[1]); + band.setPrice(price); + band.setChargeName("每票"); + band.setPriceUnit(perTicketUnit(grid)); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + row++; + } + return row; + } + + /** + * 上一行是地区名称,本行每个地区一列「每件」。西邮西班牙主岛就是这种表。 + */ + private int readRegionWeightTable(ExcelGrid grid, int header, LblProduct product) + { + int areaRow = header - 1; + while (areaRow >= 0 && grid.blankRow(areaRow)) + { + areaRow--; + } + int weightCol = 0; + List priceCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col); + String lower = text.toLowerCase(Locale.ROOT); + if (text.contains("重量") || text.contains("公斤") || lower.contains("weight") || lower.contains("kg")) + { + weightCol = col; + } + if (text.contains("每件") || text.contains("每票") || lower.contains("per item")) + { + priceCols.add(col); + } + } + String unit = pieceUnit(grid); + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isHardHeader(grid, row)) + { + String weight = grid.text(row, weightCol).replace('\n', ' ').trim(); + if (!LblTexts.looksLikeWeight(weight)) + { + break; + } + for (int col : priceCols) + { + BigDecimal price = priceOf(grid, row, col); + if (price == null) + { + continue; + } + LblWeightBand band = new LblWeightBand(); + band.setSectionName("重量段"); + band.setZoneName(zoneLabel(areaRow < 0 ? "" : grid.text(areaRow, col))); + band.setWeightLabel(weight); + BigDecimal[] bounds = LblTexts.weightBounds(weight); + band.setWeightMin(bounds[0]); + band.setWeightMax(bounds[1]); + band.setPrice(price); + band.setChargeName("每件"); + band.setPriceUnit(unit); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + row++; + } + return row; + } + + /** + * 西班牙 GLS:上一行是分区字母,本行用短横线把国家代码串在一起,说明里再写「德国(DE)」。 + * 每个国家各记一条价格,分区字母留在分区列。 + */ + private int readCountryZoneTable(ExcelGrid grid, int codeRow, LblProduct product) + { + int zoneRow = codeRow - 1; + while (zoneRow >= 0 && grid.blankRow(zoneRow)) + { + zoneRow--; + } + int header = codeRow + 1; + while (header < grid.rows() && !joinRow(grid, header).contains("重量")) + { + header++; + if (header > codeRow + 3) + { + return codeRow + 1; + } + } + Map names = countryNames(grid); + String unit = pieceUnit(grid); + List priceCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + if (!countryCodes(grid.text(codeRow, col)).isEmpty()) + { + priceCols.add(col); + } + } + BigDecimal previousMax = null; + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String line = joinRow(grid, row); + if (line.contains("说明") || line.contains("附加费") || line.contains("备注") || line.contains("禁运")) + { + break; + } + String label = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (!priceCols.contains(col) && !grid.text(row, col).isBlank()) + { + label = grid.text(row, col).replace('\n', ' ').trim(); + break; + } + } + boolean extraKg = label.toLowerCase(Locale.ROOT).contains("ad") && label.toLowerCase(Locale.ROOT).contains("kg"); + BigDecimal maxKg = extraKg ? null : leadingKg(label); + if (!extraKg && maxKg == null) + { + break; + } + BigDecimal minKg = extraKg || previousMax == null ? BigDecimal.ZERO : previousMax; + for (int col : priceCols) + { + BigDecimal price = cellPrice(grid, row, col); + if (price == null) + { + continue; + } + String zone = zoneRow < 0 ? "" : grid.text(zoneRow, col).trim(); + for (String code : namedCodes(countryCodes(grid.text(codeRow, col)), names)) + { + LblWeightBand band = new LblWeightBand(); + band.setSectionName("重量段"); + band.setZoneName(zone); + band.setCountryCode(code); + band.setCountryName(names.getOrDefault(code, code)); + band.setWeightLabel(label); + if (!extraKg) + { + band.setWeightMin(minKg); + band.setWeightMax(maxKg); + band.setChargeName("每件"); + band.setPriceUnit(unit); + } + else + { + band.setChargeName("每公斤"); + band.setPriceUnit(unit.startsWith("EUR") ? "EUR/公斤" : unit.startsWith("GBP") ? "GBP/公斤" : "每公斤"); + } + band.setPrice(price); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + } + if (!extraKg) + { + previousMax = maxKg; + } + row++; + } + return row; + } + + /** + * Additional Billable Concepts:表头右侧是地区。每个地区有价格时按地区各记一条; + * 右侧只有一句说明时只记一条,例如 Pudo。 + */ + private int readBillableConcepts(ExcelGrid grid, int header, LblProduct product) + { + String section = ""; + List zoneCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col).replace('\n', ' ').trim(); + if (text.isBlank()) + { + continue; + } + if (text.toLowerCase(Locale.ROOT).contains("additional billable")) + { + section = text; + } + else + { + zoneCols.add(col); + } + } + if (section.isBlank()) + { + section = "Additional Billable Concepts"; + } + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String line = joinRow(grid, row); + if (line.contains("备注") || line.contains("禁运") || line.contains("条款")) + { + break; + } + String name = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (zoneCols.contains(col)) + { + continue; + } + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (!text.isBlank()) + { + name = text; + break; + } + } + if (name.isBlank() || name.contains("备注")) + { + break; + } + List zones = new ArrayList<>(); + List prices = new ArrayList<>(); + String sentence = ""; + for (int col : zoneCols) + { + BigDecimal price = cellPrice(grid, row, col); + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (price != null && text.matches("\\d+(?:\\.\\d+)?")) + { + zones.add(grid.text(header, col).replace('\n', ' ').trim()); + prices.add(price); + } + else if (sentence.isBlank() && !text.isBlank()) + { + sentence = text; + } + } + if (!prices.isEmpty()) + { + for (int i = 0; i < prices.size(); i++) + { + addSurcharge(product, section, name, zones.get(i), prices.get(i).toPlainString() + " EUR", "", ""); + markManual(product); + } + } + else if (!sentence.isBlank()) + { + addSurcharge(product, section, name, "", sentence, sentence, ""); + markManual(product); + keepSingleEuro(product.getSurcharges().get(product.getSurcharges().size() - 1), sentence); + } + row++; + } + return row; + } + + /** 说明句里只有一个欧元金额时保留这个价格。百分号、体积规则不当成收费金额。 */ + private static void keepSingleEuro(LblSurcharge fee, String sentence) + { + fee.setChargeText(sentence); + fee.setFeeDesc(sentence); + if (sentence.contains("%")) + { + fee.setAmount(null); + fee.setCurrency(""); + fee.setPriceUnit(""); + return; + } + Matcher matcher = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*欧元").matcher(sentence); + String amount = ""; + int hits = 0; + while (matcher.find()) + { + hits++; + amount = matcher.group(1); + } + if (hits == 1) + { + BigDecimal price = new BigDecimal(amount); + fee.setAmount(price); + fee.setCurrency("EUR"); + fee.setPriceUnit(surchargeUnit(new LblTexts.Money(price, "EUR", ""), sentence)); + return; + } + fee.setAmount(null); + fee.setCurrency(""); + fee.setPriceUnit(""); + } + + private static void markManual(LblProduct product) + { + LblSurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + fee.setCalcRule(LblSurchargeRules.MANUAL); + fee.setCalcParam(""); + } + + private static boolean isBillableConceptHeader(ExcelGrid grid, int row) + { + return joinRow(grid, row).toLowerCase(Locale.ROOT).contains("additional billable concepts"); + } + + /** + * 岛屿价卡一张表有两级表头:上面是群岛,下面是具体岛屿。 + * Up to 1 kg 是首重,记入重量段。Additional Kg 是超出首重后的每公斤加收,记入附加费。 + * Cubic Capacity 是体积重系数,不是运费。 + */ + private int readInsularRates(ExcelGrid grid, int header, LblProduct product) + { + int groupRow = header - 1; + while (groupRow >= 0 && grid.blankRow(groupRow)) + { + groupRow--; + } + String service = grid.text(header, 0).replace('\n', ' ').trim(); + String title = insularTitle(grid, header); + String section = title.isBlank() ? service : title + " / " + service; + product.setCurrency("EUR"); + List zoneCols = new ArrayList<>(); + for (int col = 1; col < grid.cols(); col++) + { + if (!grid.text(header, col).isBlank() && cellPrice(grid, header, col) == null) + { + zoneCols.add(col); + } + } + BigDecimal firstLimit = null; + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String label = grid.text(row, 0).replace('\n', ' ').trim(); + String lower = label.toLowerCase(Locale.ROOT); + boolean firstKg = lower.contains("up to"); + boolean extraKg = lower.contains("additional"); + boolean cubic = lower.contains("cubic"); + if (!firstKg && !extraKg && !cubic) + { + break; + } + if (cubic) + { + addCubicCapacity(grid, row, header, groupRow, title, section, zoneCols, product); + row++; + continue; + } + if (firstKg) + { + firstLimit = firstKgLimit(label); + } + for (int col : zoneCols) + { + String child = grid.text(header, col).replace('\n', ' ').trim(); + String parent = groupLabel(grid, groupRow, col, title); + String zone = parent.isBlank() ? child : parent + " / " + child; + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.isBlank()) + { + continue; + } + BigDecimal price = cellPrice(grid, row, col); + if (price == null) + { + continue; + } + if (extraKg) + { + addExtraKg(product, section, zone, label, price, firstLimit == null ? BigDecimal.ONE : firstLimit); + continue; + } + LblWeightBand band = new LblWeightBand(); + band.setSectionName(section); + band.setZoneName(zone); + band.setWeightLabel(label); + band.setPrice(price); + band.setWeightMin(BigDecimal.ZERO); + band.setWeightMax(firstKgLimit(label)); + band.setChargeName("每件"); + band.setPriceUnit("EUR/件"); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + row++; + } + return row; + } + + /** + * 西班牙 CTT:上面一行是大陆或群岛,下一行是密度和岛屿。 + * 1kg 到 5kg 是该重量的票价,记入重量段。Kg.ad. 是超出最高档后的每公斤加收,记入附加费。 + */ + private int readKgStepTable(ExcelGrid grid, int header, LblProduct product) + { + int groupRow = header - 1; + while (groupRow >= 0 && grid.blankRow(groupRow)) + { + groupRow--; + } + String service = grid.text(header, 0).replace('\n', ' ').trim(); + product.setCurrency("EUR"); + List zoneCols = new ArrayList<>(); + for (int col = 1; col < grid.cols(); col++) + { + if (!grid.text(header, col).isBlank() && cellPrice(grid, header, col) == null) + { + zoneCols.add(col); + } + } + BigDecimal previousMax = null; + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String label = grid.text(row, 0).replace('\n', ' ').trim(); + String compact = label.toLowerCase(Locale.ROOT).replace(" ", ""); + boolean extra = compact.contains("kg.ad") || compact.contains("ad.kg") || compact.contains("additionalkg"); + BigDecimal maxKg = extra ? null : leadingKg(label); + if (!extra && maxKg == null) + { + break; + } + BigDecimal minKg = extra || previousMax == null ? BigDecimal.ZERO : previousMax; + for (int col : zoneCols) + { + BigDecimal price = cellPrice(grid, row, col); + if (price == null) + { + continue; + } + String child = grid.text(header, col).replace('\n', ' ').trim(); + String parent = groupLabel(grid, groupRow, col, service); + String zone = parent.isBlank() ? child : parent + " / " + child; + if (extra) + { + addExtraKg(product, service, zone, label, price, previousMax); + } + else + { + LblWeightBand band = new LblWeightBand(); + band.setSectionName(service); + band.setZoneName(zone); + band.setWeightLabel(label); + band.setPrice(price); + band.setWeightMin(minKg); + band.setWeightMax(maxKg); + band.setChargeName("每件"); + band.setPriceUnit("EUR/件"); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + } + if (!extra) + { + previousMax = maxKg; + } + row++; + } + return row; + } + + /** Kg.ad. 按地区各记一条每公斤续重,不进入重量段。 */ + private static void addExtraKg(LblProduct product, String section, String zone, String label, BigDecimal price, BigDecimal threshold) + { + BigDecimal from = threshold == null ? BigDecimal.ZERO : threshold; + LblSurcharge fee = new LblSurcharge(); + fee.setSectionName(section); + fee.setFeeName("每公斤续重"); + fee.setZoneName(zone); + fee.setChargeText(label + " " + price.stripTrailingZeros().toPlainString() + " EUR/KG"); + fee.setAmount(price); + fee.setCurrency("EUR"); + fee.setPriceUnit("EUR/KG"); + fee.setFeeDesc("超过" + from.stripTrailingZeros().toPlainString() + "kg后,按超出的重量每公斤加收"); + fee.setChargeTiming("产生时收取"); + fee.setCalcRule(LblSurchargeRules.EXTRA_KG); + fee.setCalcParam(from.stripTrailingZeros().toPlainString()); + fee.setSortNo(product.getSurcharges().size() + 1); + product.getSurcharges().add(fee); + } + + private static boolean isKgStepHeader(ExcelGrid grid, int row) + { + int names = 0; + for (int col = 1; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank() && cellPrice(grid, row, col) == null && text.length() <= 40) + { + names++; + } + } + if (names < 3) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + if (next >= grid.rows()) + { + return false; + } + return leadingKg(grid.text(next, 0)) != null; + } + + /** 体积重系数是这一行的属性,同一张表只记一条,地区写在说明里。 */ + private void addCubicCapacity(ExcelGrid grid, int row, int header, int groupRow, String title, String section, + List zoneCols, LblProduct product) + { + Map> factors = new LinkedHashMap<>(); + for (int col : zoneCols) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.isBlank()) + { + continue; + } + String child = grid.text(header, col).replace('\n', ' ').trim(); + String parent = groupLabel(grid, groupRow, col, title); + String zone = parent.isBlank() ? child : parent + " / " + child; + factors.computeIfAbsent(text, key -> new ArrayList<>()).add(zone); + } + if (factors.isEmpty()) + { + return; + } + StringBuilder desc = new StringBuilder(); + for (Map.Entry> entry : factors.entrySet()) + { + if (desc.length() > 0) + { + desc.append(";"); + } + desc.append(String.join("、", entry.getValue())).append(" ").append(entry.getKey()); + } + addSurcharge(product, section, "Cubic Capacity", "", String.join("、", factors.keySet()), desc.toString(), ""); + LblSurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + fee.setChargeText(String.join("、", factors.keySet())); + fee.setAmount(null); + fee.setCurrency(""); + fee.setPriceUnit(""); + markManual(product); + } + + private static boolean isInsularServiceHeader(ExcelGrid grid, int row) + { + String first = grid.text(row, 0).trim().toUpperCase(Locale.ROOT); + boolean service = "STANDARD".equals(first) || "EXPRESS".equals(first) || first.contains("WITHOUT INYECTION") + || first.contains("WITHOUT INJECTION"); + if (!service) + { + return false; + } + int names = 0; + for (int col = 1; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank() && cellPrice(grid, row, col) == null && text.length() <= 40) + { + names++; + } + } + if (names < 2) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + return next < grid.rows() && grid.text(next, 0).toLowerCase(Locale.ROOT).contains("up to"); + } + + private static String insularTitle(ExcelGrid grid, int header) + { + for (int row = header - 1; row >= 0; row--) + { + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + String upper = text.toUpperCase(Locale.ROOT); + if (upper.contains("INSULAR RATES") || upper.contains("CUSTOMS CLEARANCE") || upper.contains("CEUTA")) + { + return text; + } + } + } + return ""; + } + + /** 合并单元格里的群岛名称覆盖到右侧岛屿列。标题行本身不当成群岛。 */ + private static String groupLabel(ExcelGrid grid, int groupRow, int col, String title) + { + if (groupRow < 0) + { + return ""; + } + String text = ""; + if (!grid.text(groupRow, col).isBlank()) + { + text = grid.text(groupRow, col).replace('\n', ' ').trim(); + } + else + { + for (int left = col - 1; left >= 0; left--) + { + if (grid.colSpan(groupRow, left) > 1 && left + grid.colSpan(groupRow, left) - 1 >= col) + { + text = grid.text(groupRow, left).replace('\n', ' ').trim(); + break; + } + } + } + if (text.isBlank() || text.equals(title)) + { + return ""; + } + String upper = text.toUpperCase(Locale.ROOT); + if (upper.contains("INSULAR RATES") || upper.contains("CUSTOMS CLEARANCE") || upper.contains("CEUTA")) + { + return ""; + } + return text; + } + + private static BigDecimal firstKgLimit(String label) + { + Matcher matcher = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)").matcher(label == null ? "" : label); + if (!matcher.find()) + { + return BigDecimal.ONE; + } + return new BigDecimal(matcher.group(1)); + } + + /** 文件名或产品名里带国家时,只补空白的国家,不覆盖表里已经写明的目的国。 */ + private static void applyFileCountry(LblProduct product) + { + String[] country = countryInName(product.getProductName(), product.getSheetName(), product.getSourceFile()); + if (country == null) + { + return; + } + if (product.getCountryScope() == null || product.getCountryScope().isBlank()) + { + product.setCountryScope(country[0]); + } + if (product.getWeightBands() == null) + { + return; + } + for (LblWeightBand band : product.getWeightBands()) + { + if (band.getCountryName() == null || band.getCountryName().isBlank()) + { + band.setCountryName(country[0]); + if (band.getCountryCode() == null || band.getCountryCode().isBlank()) + { + band.setCountryCode(country[1]); + } + } + } + } + + private static String[] countryInName(String... texts) + { + String[][] countries = { + { "西班牙", "ES" }, { "英国", "GB" }, { "德国", "DE" }, { "法国", "FR" }, { "意大利", "IT" }, + { "荷兰", "NL" }, { "比利时", "BE" }, { "葡萄牙", "PT" }, { "波兰", "PL" }, { "奥地利", "AT" }, + { "瑞士", "CH" }, { "瑞典", "SE" }, { "丹麦", "DK" }, { "芬兰", "FI" }, { "爱尔兰", "IE" }, + { "希腊", "GR" }, { "匈牙利", "HU" }, { "捷克", "CZ" }, { "罗马尼亚", "RO" }, { "卢森堡", "LU" } + }; + String[][] codes = { + { "DE-", "德国", "DE" }, { "IT-", "意大利", "IT" }, { "NL-", "荷兰", "NL" }, { "FR-", "法国", "FR" }, + { "ES-", "西班牙", "ES" }, { "UK-", "英国", "GB" }, { "GB-", "英国", "GB" } + }; + String foundName = ""; + String foundCode = ""; + int foundAt = Integer.MAX_VALUE; + for (String text : texts) + { + if (text == null || text.isBlank()) + { + continue; + } + String name = text.replaceAll("(?i)\\.xlsx$", "").trim(); + String upper = name.toUpperCase(Locale.ROOT); + for (String[] code : codes) + { + if (upper.startsWith(code[0]) && 0 < foundAt) + { + foundAt = 0; + foundName = code[1]; + foundCode = code[2]; + } + } + for (String[] country : countries) + { + int at = name.indexOf(country[0]); + if (at >= 0 && (at < foundAt || (at == foundAt && country[0].length() > foundName.length()))) + { + foundAt = at; + foundName = country[0]; + foundCode = country[1]; + } + } + } + return foundName.isBlank() ? null : new String[] { foundName, foundCode }; + } + + /** + * 续重:有超出后的每公斤价格。常规计重:同一产品既有每公斤运费,又有处理费或挂号费。其余按重量段收一票的价格。 + */ + private static String chargeTypeOf(LblProduct product) + { + if (product.getWeightBands() == null || product.getWeightBands().isEmpty()) + { + return ""; + } + boolean extraKg = false; + boolean perKg = false; + boolean itemFee = false; + for (LblWeightBand band : product.getWeightBands()) + { + String unit = band.getPriceUnit() == null ? "" : band.getPriceUnit().toUpperCase(Locale.ROOT); + if (continuedKg(band)) + { + extraKg = true; + } + if (unit.contains("KG") || unit.contains("公斤")) + { + perKg = true; + } + String charge = band.getChargeName() == null ? "" : band.getChargeName(); + if (charge.contains("处理费") || charge.contains("挂号")) + { + itemFee = true; + } + } + if (product.getSurcharges() != null) + { + for (LblSurcharge fee : product.getSurcharges()) + { + if (fee != null && LblSurchargeRules.EXTRA_KG.equals(fee.getCalcRule())) + { + extraKg = true; + } + } + } + if (extraKg) + { + return "续重计费"; + } + if (perKg && itemFee) + { + return "常规计重价"; + } + return "单票价"; + } + + /** + * 续重产品的每公斤加价。各分区金额相同则返回该金额,不同则返回最小~最大。 + */ + public static String extraPerKgOf(LblProduct product) + { + if (product == null || product.getWeightBands() == null) + { + return null; + } + BigDecimal low = null; + BigDecimal high = null; + for (LblWeightBand band : product.getWeightBands()) + { + if (band == null || band.getPrice() == null || !continuedKg(band)) + { + continue; + } + if (low == null || band.getPrice().compareTo(low) < 0) + { + low = band.getPrice(); + } + if (high == null || band.getPrice().compareTo(high) > 0) + { + high = band.getPrice(); + } + } + if (product.getSurcharges() != null) + { + for (LblSurcharge fee : product.getSurcharges()) + { + if (fee == null || fee.getAmount() == null || !LblSurchargeRules.EXTRA_KG.equals(fee.getCalcRule())) + { + continue; + } + if (low == null || fee.getAmount().compareTo(low) < 0) + { + low = fee.getAmount(); + } + if (high == null || fee.getAmount().compareTo(high) > 0) + { + high = fee.getAmount(); + } + } + } + if (low == null) + { + return null; + } + String lowText = low.stripTrailingZeros().toPlainString(); + if (low.compareTo(high) == 0) + { + return lowText; + } + return lowText + "~" + high.stripTrailingZeros().toPlainString(); + } + + private static boolean continuedKg(LblWeightBand band) + { + String label = ((band.getWeightLabel() == null ? "" : band.getWeightLabel()) + " " + + (band.getChargeName() == null ? "" : band.getChargeName())).toLowerCase(Locale.ROOT).replace(" ", ""); + return label.contains("kg.ad") || label.contains("ad.kg") || label.contains("additionalkg") || label.contains("续重") + || "每公斤".equals(band.getChargeName()); + } + + /** + * 「其他附加费」下面是类型、服务说明和价格。备注段落不是表格,不收成附加费。 + */ + private int readServicePrices(ExcelGrid grid, int header, LblProduct product) + { + int nameCol = -1; + int priceCol = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col).trim(); + if (nameCol < 0 && (text.contains("类型") || text.contains("服务") || text.contains("项目"))) + { + nameCol = col; + } + if (text.contains("价格") || text.contains("收费")) + { + priceCol = col; + } + } + if (nameCol < 0) + { + nameCol = 0; + } + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String line = joinRow(grid, row); + if (line.contains("备注") || line.contains("说明") || line.contains("禁运") || line.contains("条款") || line.contains("免责")) + { + return skipNotes(grid, row); + } + String name = grid.text(row, nameCol).replace('\n', ' ').trim(); + String priceText = ""; + if (priceCol >= 0) + { + priceText = grid.text(row, priceCol).replace('\n', ' ').trim(); + } + if (priceText.isBlank()) + { + for (int col = grid.cols() - 1; col > nameCol; col--) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank()) + { + priceText = text; + break; + } + } + } + String desc = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (col == nameCol || col == priceCol) + { + continue; + } + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.length() > desc.length()) + { + desc = text; + } + } + String charge = serviceCharge(priceText); + if (name.isBlank() || charge.isEmpty() || name.contains("类型") || name.contains("价格")) + { + break; + } + addSurcharge(product, "其他附加费", name, "", charge, desc, ""); + if ("free".equalsIgnoreCase(priceText.trim()) || priceText.contains("免费")) + { + LblSurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + fee.setChargeText(priceText.trim()); + fee.setAmount(BigDecimal.ZERO); + fee.setCurrency(fee.getCurrency() == null || fee.getCurrency().isBlank() ? "EUR" : fee.getCurrency()); + if (fee.getPriceUnit() == null || fee.getPriceUnit().isBlank()) + { + fee.setPriceUnit(fee.getCurrency()); + } + } + row++; + } + return row; + } + + /** 备注、说明后面的句子不再当成费用,直到下一张真正的表。 */ + private static int skipNotes(ExcelGrid grid, int row) + { + while (row < grid.rows()) + { + if (!grid.blankRow(row) && (isHardHeader(grid, row) || isTypePriceRow(grid, row) || countryZoneCodeRow(grid, row) >= 0)) + { + return row; + } + row++; + } + return row; + } + + /** 分区字母行的下一行是 DE-BE-NL 这种国家代码,并且随后写着重量段。 */ + private static int countryZoneCodeRow(ExcelGrid grid, int row) + { + int codeRow = -1; + if (isZoneLetterRow(grid, row)) + { + int next = row + 1; + if (next < grid.rows() && codeGroupCount(grid, next) >= 2) + { + codeRow = next; + } + } + else if (row > 0 && isZoneLetterRow(grid, row - 1) && codeGroupCount(grid, row) >= 2) + { + codeRow = row; + } + if (codeRow < 0) + { + return -1; + } + int end = Math.min(grid.rows(), codeRow + 4); + for (int next = codeRow + 1; next < end; next++) + { + if (joinRow(grid, next).contains("重量")) + { + return codeRow; + } + } + return -1; + } + + private static int servicePriceHeader(ExcelGrid grid, int row) + { + if (isTypePriceRow(grid, row)) + { + return isSurchargeHeader(grid, row) ? -1 : row; + } + String line = joinRow(grid, row); + if (line.length() > 40 || line.contains("。") || (!line.contains("附加费") && !line.contains("其他费用"))) + { + return -1; + } + for (int next = row + 1; next < grid.rows() && next <= row + 3; next++) + { + if (grid.blankRow(next)) + { + continue; + } + // 收费项、收费标准、费用说明是附加费表,不是「类型 + 价格」那种服务价 + return isTypePriceRow(grid, next) && !isSurchargeHeader(grid, next) ? next : -1; + } + return -1; + } + + private static boolean isZoneLetterRow(ExcelGrid grid, int row) + { + boolean zone = false; + int letters = 0; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (text.equalsIgnoreCase("zone")) + { + zone = true; + } + else if (text.matches("(?i)[A-H]")) + { + letters++; + } + } + return zone && letters >= 2; + } + + private static boolean isTypePriceRow(ExcelGrid grid, int row) + { + boolean kind = false; + boolean price = false; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (text.contains("类型") || text.equals("服务") || text.contains("收费项")) + { + kind = true; + } + if (text.contains("价格") || text.contains("收费标准")) + { + price = true; + } + } + return kind && price; + } + + private static int codeGroupCount(ExcelGrid grid, int row) + { + int count = 0; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if ((text.contains("-") || text.contains("⁻") || text.contains("-")) && countryCodes(text).size() >= 2) + { + count++; + } + } + return count; + } + + /** 表头上的脚注数字去掉,但说明里单独写明的 FR2 这类代码保留。 */ + private static List namedCodes(List rawCodes, Map names) + { + List codes = new ArrayList<>(); + for (String raw : rawCodes) + { + String code = names.containsKey(raw) ? raw : raw.replaceAll("\\d+$", ""); + if (!names.containsKey(code)) + { + code = raw; + } + if (!codes.contains(code)) + { + codes.add(code); + } + } + return codes; + } + + private static List countryCodes(String text) + { + List codes = new ArrayList<>(); + if (text == null || text.isBlank()) + { + return codes; + } + String folded = foldMarks(text).replace('\n', ' '); + for (String part : folded.split("[-\\s]+")) + { + String code = part.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + if (code.matches("[A-Z]{2}\\d*")) + { + codes.add(code); + } + } + return codes; + } + + /** 说明里的「德国(DE)」「英国-GB(威尔士…)」对应表头拆出来的代码。 */ + private static Map countryNames(ExcelGrid grid) + { + Map names = new LinkedHashMap<>(); + Pattern wrapped = Pattern.compile("([\\u4e00-\\u9fff][\\u4e00-\\u9fff0-9\\-]{0,24}?)\\s*\\d*(?:\\s*-\\s*\\d+)*\\s*[((]\\s*([A-Z]{2}\\d?)\\s*[))]"); + Pattern ahead = Pattern.compile("([\\u4e00-\\u9fff]{2,12})\\s*\\d*(?:\\s*-\\s*\\d+)*\\s*-\\s*([A-Z]{2})\\s*[((]"); + for (int row = 0; row < grid.rows(); row++) + { + for (int col = 0; col < grid.cols(); col++) + { + String text = foldMarks(grid.text(row, col)); + if (!text.contains("(") && !text.contains("(")) + { + continue; + } + Matcher matcher = wrapped.matcher(text); + while (matcher.find()) + { + names.putIfAbsent(matcher.group(2), matcher.group(1).replaceAll("[-\\s]+$", "")); + } + Matcher codeAhead = ahead.matcher(text); + while (codeAhead.find()) + { + names.putIfAbsent(codeAhead.group(2), codeAhead.group(1)); + } + } + } + return names; + } + + private static String foldMarks(String text) + { + if (text == null) + { + return ""; + } + return text.replace('²', '2').replace('³', '3').replace('⁴', '4').replace('⁵', '5') + .replace('⁶', '6').replace('⁷', '7').replace('⁸', '8').replace('⁹', '9') + .replace('⁻', '-').replace('-', '-'); + } + + private static BigDecimal leadingKg(String label) + { + Matcher matcher = Pattern.compile("(?i)^\\s*(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤)\\s*$").matcher(label == null ? "" : label); + if (!matcher.find()) + { + return null; + } + return new BigDecimal(matcher.group(1)); + } + + private static BigDecimal cellPrice(ExcelGrid grid, int row, int col) + { + BigDecimal price = priceOf(grid, row, col); + if (price != null) + { + return price; + } + String compact = grid.text(row, col).replace(" ", ""); + if (compact.matches("\\d+\\.\\d+")) + { + return new BigDecimal(compact); + } + return null; + } + + private static String serviceCharge(String text) + { + if (text == null || text.isBlank()) + { + return ""; + } + String value = text.trim(); + if (value.equalsIgnoreCase("free") || value.contains("免费")) + { + return "0 EUR"; + } + Matcher euro = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*€").matcher(value); + if (euro.find()) + { + return euro.group(1) + " EUR"; + } + Matcher amount = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*(欧元|英镑|EUR|GBP)").matcher(value); + if (amount.find()) + { + return amount.group(1) + " " + feeCurrency(amount.group(2)); + } + return ""; + } + + /** + * 附加费标题下面不一定是标准表。一句写明金额的说明,或「条件 + 费率」两列,都要收成附加费。 + */ + private int readFeeSection(ExcelGrid grid, int start, LblProduct product) + { + String feeName = ""; + int row = start + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String line = joinRow(grid, row); + String lower = line.toLowerCase(Locale.ROOT); + if (line.contains("禁运") || line.contains("条款") || line.contains("免责") || lower.contains("terms and conditions") + || lower.contains("disclaimer") || isHardHeader(grid, row) || isRegionWeightHeader(grid, row) + || remoteFeeHeader(grid, row) >= 0) + { + break; + } + if (isFeeSectionTitle(grid, row)) + { + feeName = chineseTitle(line); + row++; + continue; + } + String rate = ""; + String desc = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (text.isEmpty()) + { + continue; + } + if (rate.isEmpty() && looksLikeRate(text) && text.length() <= 80) + { + rate = text; + } + else if (text.length() > desc.length()) + { + desc = text; + } + } + if (!rate.isEmpty() && !desc.isEmpty()) + { + addSurcharge(product, "附加费", blankTo(feeName, "附加费"), "", rateLine(rate), desc, ""); + row++; + continue; + } + String amount = feeAmount(line); + if (amount != null && (line.contains("费") || lower.contains("surcharge") || line.contains("标签"))) + { + addSurcharge(product, "附加费", sentenceFeeName(line), "", amount, line, ""); + row++; + continue; + } + break; + } + return row; + } + + /** + * 偏远地区附加费:下一行表头是地区、邮编,价格写在左侧,合并单元格要沿用到下一行。 + */ + private int readRemoteFeeTable(ExcelGrid grid, int header, LblProduct product) + { + int regionCol = -1; + int postcodeCol = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col); + if (regionCol < 0 && text.contains("地区")) + { + regionCol = col; + } + else if (postcodeCol < 0 && text.contains("邮编")) + { + postcodeCol = col; + } + } + String carriedPrice = ""; + String carriedArea = ""; + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isSurchargeHeader(grid, row) && !isPriceHeader(grid, row)) + { + String region = regionCol < 0 ? "" : grid.text(row, regionCol).replace('\n', ' ').trim(); + String postcode = postcodeCol < 0 ? "" : grid.text(row, postcodeCol).replace('\n', ' ').trim(); + if (region.isBlank() && postcode.isBlank()) + { + break; + } + String priceCell = ""; + int end = regionCol < 0 ? grid.cols() : regionCol; + for (int col = 0; col < end; col++) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank()) + { + priceCell = text; + } + } + if (!priceCell.isBlank()) + { + carriedPrice = priceCell; + String area = areaLabel(priceCell); + if (!area.isBlank()) + { + carriedArea = area; + } + } + String desc = region; + if (!postcode.isBlank()) + { + desc = desc.isBlank() ? postcode : desc + ";" + postcode; + } + if (!carriedArea.isBlank()) + { + desc = carriedArea + ";" + desc; + } + addSurcharge(product, "偏远地区附加费", "偏远地区附加费", "", feePriceText(carriedPrice), desc, ""); + row++; + } + return row; + } + + /** + * 德国 UPS:Zone 列仅用于对齐国家与价格;落库只按国家(拆开多国)一票价,不写 zoneName。 + */ + private int readZonePrices(ExcelGrid grid, int header, LblProduct product) + { + int row = header + 1; + while (row < grid.rows() && grid.blankRow(row)) + { + row++; + } + int countryRow = -1; + if (row < grid.rows() && countNumbers(grid, row) == 0 && countText(grid, row) >= 2) + { + countryRow = row; + row++; + } + while (row < grid.rows() && grid.blankRow(row)) + { + row++; + } + if (row >= grid.rows() || countNumbers(grid, row) == 0) + { + return row; + } + String charge = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (!text.isBlank() && grid.number(row, col) == null && !isMoneyText(text)) + { + charge = shortCharge(text); + break; + } + } + for (int col = 0; col < grid.cols(); col++) + { + BigDecimal price = priceOf(grid, row, col); + if (price == null) + { + continue; + } + String country = countryRow < 0 ? "" : grid.text(countryRow, col); + for (String piece : zoneCountryPieces(country)) + { + if (piece == null || piece.isBlank()) + { + continue; + } + LblWeightBand band = new LblWeightBand(); + band.setSectionName("国家报价"); + String code = zoneCodes(piece); + band.setCountryName(zoneCountryName(piece, code, country)); + band.setCountryCode(code.contains(",") ? "" : code); + band.setZoneName(""); + band.setZoneCode(""); + band.setChargeName(charge); + band.setPrice(price); + band.setPriceUnit(unitOf(charge + " EUR")); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + } + return row + 1; + } + + private int readSurchargeTable(ExcelGrid grid, int header, String pending, LblProduct product, StringBuilder note) + { + int nameCol = -1; + int standardCol = -1; + int descCol = -1; + int remarkCol = -1; + int peakCol = -1; + int fuelCol = -1; + String peakHeader = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col); + if (text.contains("收费标准")) + { + standardCol = col; + } + else if (standardCol < 0 && text.contains("收费") && !text.contains("收费项") && !text.contains("收费项目") + && !text.contains("说明") && !text.contains("解释")) + { + standardCol = col; + } + else if (text.contains("高峰")) + { + peakCol = col; + peakHeader = text; + } + else if (text.contains("燃油")) + { + fuelCol = col; + } + else if (text.contains("费用说明") || text.contains("费用解释") || text.contains("收费说明") || text.contains("说明")) + { + descCol = col; + } + else if (text.contains("备注")) + { + remarkCol = col; + } + else if (text.contains("收取") || text.contains("时点")) + { + remarkCol = remarkCol < 0 ? col : remarkCol; + } + else if (nameCol < 0 && isFeeNameHead(text)) + { + nameCol = col; + } + } + List nameCols = new ArrayList<>(); + List zoneCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col).trim(); + if (text.isEmpty()) + { + continue; + } + if (isFeeNameHead(text)) + { + nameCols.add(col); + } + else if (col != standardCol && col != descCol && col != remarkCol && col != peakCol && col != fuelCol) + { + zoneCols.add(col); + } + } + // 其他费用这种表:收费项后面每一列是一个地区,不是单一的收费标准 + if (standardCol < 0 && peakCol < 0 && zoneCols.size() >= 2) + { + return readRegionSurcharges(grid, header, nameCols, zoneCols, descCol, remarkCol, pending, product); + } + int row = header + 1; + // 费用说明里也会出现“处理费、重量”,不能把正文当成新的价格表头而整表跳过 + while (row < grid.rows() && !grid.blankRow(row) && !isWeightColumnHeader(grid, row)) + { + String name = nameCol >= 0 ? firstLine(grid.text(row, nameCol)) : firstLine(firstText(grid, row)); + if (name.startsWith("服务说明") || name.startsWith("报价已含燃油")) + { + appendNote(note, joinRow(grid, row)); + row++; + break; + } + String standard = standardCol >= 0 ? grid.text(row, standardCol) : ""; + String desc = descText(grid, header, row, descCol); + String remark = remarkCol >= 0 ? grid.text(row, remarkCol) : ""; + String fuel = fuelCol >= 0 ? grid.text(row, fuelCol).trim() : ""; + if (!fuel.isEmpty()) + { + remark = (remark + " 是否加收燃油:" + fuel).trim(); + } + String peak = peakCol >= 0 ? grid.text(row, peakCol).trim() : ""; + if (standard.isBlank() && peak.isBlank()) + { + break; + } + if (!name.isBlank()) + { + addSurcharge(product, cleanTitle(pending), name, "", standard, desc, remark); + } + if (!peak.isEmpty()) + { + addSurcharge(product, "高峰附加费", name, "", peak, peakHeader, remark); + } + row++; + } + return row; + } + + /** 表头从左到右是地区,每个地区一列价格。横向合并的价格抄到覆盖的每个地区,纵向合并的费用名沿用到下一行。 */ + private int readRegionSurcharges(ExcelGrid grid, int header, List nameCols, List zoneCols, + int descCol, int remarkCol, String pending, LblProduct product) + { + int firstZone = zoneCols.get(0); + String carried = ""; + String section = cleanTitle(pending); + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isWeightColumnHeader(grid, row) && !isPriceHeader(grid, row)) + { + String original = ""; + String named = ""; + for (int col : nameCols) + { + String text = firstLine(grid.text(row, col)); + if (text.isBlank()) + { + continue; + } + if (original.isBlank()) + { + original = text; + } + named = text; + } + if (named.isBlank()) + { + named = carried; + } + else + { + carried = named; + } + if (named.startsWith("服务说明") || named.contains("报价已含") || named.startsWith("以上报价")) + { + break; + } + boolean priced = false; + for (int col : zoneCols) + { + if (!zonePrice(grid, row, col, firstZone).isBlank()) + { + priced = true; + break; + } + } + if (!priced) + { + break; + } + String desc = descCol < 0 ? "" : grid.text(row, descCol).trim(); + String remark = remarkCol < 0 ? "" : grid.text(row, remarkCol).trim(); + if (!original.isBlank() && !named.isBlank() && !original.equals(named)) + { + desc = desc.isBlank() ? original : original + ";" + desc; + } + String feeName = shortCharge(named); + for (int col : zoneCols) + { + String standard = zonePrice(grid, row, col, firstZone); + if (standard.isBlank()) + { + continue; + } + addSurcharge(product, blankTo(section, "其他费用"), feeName, zoneLabel(grid.text(header, col)), standard, desc, remark); + } + row++; + } + return row; + } + + private static boolean isFeeNameHead(String text) + { + return text.contains("费用类型") || text.contains("收费项") || text.contains("附加费") || text.contains("收费项目") + || text.contains("类目") || text.contains("费用名称"); + } + + /** 地区列被横向合并时,价格只写在左上角,后面的地区沿用这块价格。 */ + private static String zonePrice(ExcelGrid grid, int row, int col, int firstZone) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank()) + { + return text; + } + for (int left = col - 1; left >= firstZone; left--) + { + int span = grid.colSpan(row, left); + if (span > 1 && left + span - 1 >= col) + { + return grid.text(row, left).trim(); + } + } + return ""; + } + + private static String zoneLabel(String text) + { + if (text == null) + { + return ""; + } + return text.replace('\n', ' ').replaceAll("\\s+", " ").trim(); + } + + private int readSurchargeMatrix(ExcelGrid grid, int header, String pending, LblProduct product) + { + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isHardHeader(grid, row)) + { + String name = ""; + for (int col = 0; col < Math.min(3, grid.cols()); col++) + { + String text = grid.text(row, col); + if (!text.isBlank() && !isMoneyText(text)) + { + name = text; + break; + } + } + boolean any = false; + for (int col = 0; col < grid.cols(); col++) + { + if (col < 2 && grid.text(header, col).contains("收费")) + { + continue; + } + BigDecimal price = priceOf(grid, row, col); + if (price == null) + { + continue; + } + any = true; + // 金额横向合并时只记一次,不按每个分区再复制一条 + String zone = grid.colSpan(row, col) > 1 ? "" : grid.text(header, col); + addSurcharge(product, blankTo(cleanTitle(pending), "附加费"), name.isBlank() ? "" : shortCharge(name), + zone, grid.text(row, col), "", ""); + } + if (!any && joinRow(grid, row).length() > 40) + { + break; + } + row++; + } + return row; + } + + private int readLines(ExcelGrid grid, int start, String lineType, LblProduct product) + { + int row = start; + if (!grid.blankRow(row) && (isPostcodeHeader(grid, row) || countNumbers(grid, row) == 0)) + { + row++; + } + int kept = 0; + while (row < grid.rows()) + { + if (grid.blankRow(row)) + { + row++; + if (kept > 0) + { + break; + } + continue; + } + if (kept >= MAX_EXTRA) + { + product.setRemark("邮编或配置超过" + MAX_EXTRA + "行,已保留前" + MAX_EXTRA + "行"); + break; + } + LblExtraLine line = new LblExtraLine(); + line.setLineType(lineType); + line.setLineText(cut(new StringBuilder(joinRow(grid, row)), 1000)); + line.setSortNo(product.getExtraLines().size() + 1); + product.getExtraLines().add(line); + kept++; + row++; + } + return row; + } + + private void addLooseSurcharge(ExcelGrid grid, int row, String pending, LblProduct product) + { + String name = ""; + String standard = ""; + String desc = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (text.isBlank()) + { + continue; + } + if (name.isEmpty() && text.contains("费") && text.length() <= 40) + { + name = text; + } + else if (standard.isEmpty() && (isMoneyText(text) || grid.number(row, col) != null)) + { + standard = text; + } + else if (text.length() > desc.length()) + { + desc = text; + } + } + addSurcharge(product, cleanTitle(pending), blankTo(name, "附加费"), "", standard, desc, ""); + } + + private void addSurcharge(LblProduct product, String section, String name, String zone, String standard, String desc, String remark) + { + String combined = (standard + " " + desc + " " + remark).trim(); + Period period = LblTexts.period(combined, 2026); + Money money = LblTexts.money(blankTo(standard, combined), period); + LblSurcharge fee = new LblSurcharge(); + fee.setSectionName(section); + fee.setFeeName(cut(new StringBuilder(name), 200)); + fee.setZoneName(cut(new StringBuilder(zone), 200)); + fee.setChargeText(cut(new StringBuilder(blankTo(standard, money.amount == null ? "" : money.amount.toPlainString())), 500)); + fee.setAmount(money.amount); + fee.setCurrency(money.currency); + fee.setPriceUnit(cut(new StringBuilder(surchargeUnit(money, blankTo(standard, combined))), 40)); + fee.setFeeDesc(cut(new StringBuilder(joinDesc(desc, remark)), 2000)); + fee.setPeriodText(period.text == null ? "" : cut(new StringBuilder(period.text), 500)); + fee.setEffectiveStart(LblTexts.toDate(period.start)); + fee.setEffectiveEnd(LblTexts.toDate(period.end)); + fee.setChargeTiming(LblTexts.timing(combined, period)); + if (currencyHits(standard) > 1) + { + fee.setAmount(null); + fee.setCurrency(""); + fee.setPriceUnit(""); + } + if (fee.getCurrency() == null || fee.getCurrency().isBlank()) + { + String cardCurrency = product.getCurrency(); + fee.setCurrency(cardCurrency == null || cardCurrency.isBlank() ? "EUR" : cardCurrency.trim().toUpperCase(Locale.ROOT)); + } + fee.setSortNo(product.getSurcharges().size() + 1); + LblSurchargeRules.apply(fee); + product.getSurcharges().add(fee); + } + + /** 附加费币种为空时取价卡币种,价卡也没有则默认 EUR。 */ + private static void fillSurchargeCurrency(LblProduct product, String cardCurrency) + { + if (product == null || product.getSurcharges() == null || product.getSurcharges().isEmpty()) + { + return; + } + String fallback = cardCurrency == null || cardCurrency.isBlank() ? "EUR" : cardCurrency.trim().toUpperCase(Locale.ROOT); + for (LblSurcharge fee : product.getSurcharges()) + { + if (fee == null) + { + continue; + } + if (fee.getCurrency() == null || fee.getCurrency().isBlank()) + { + fee.setCurrency(fallback); + } + } + } + + /** 燃油、去碳列是整张表的包含说明,只记一条,不跟每个重量段重复。 */ + private void readFuelMark(ExcelGrid grid, LblProduct product) + { + if (grid.rows() > 0) + { + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(0, col).trim(); + if (text.isEmpty() || text.length() > 80) + { + continue; + } + String mark = fuelMark(text); + if (!mark.isEmpty()) + { + addSurcharge(product, "报价包含情况", "报价说明", "", mark, text, ""); + break; + } + } + } + int end = Math.min(grid.rows(), 8); + for (int row = 0; row < end; row++) + { + for (int col = 0; col < grid.cols(); col++) + { + String head = grid.text(row, col).replace('\n', ' ').trim(); + if (head.isEmpty() || head.length() > 30 || !head.contains("燃油")) + { + continue; + } + for (int body = row + 1; body < grid.rows(); body++) + { + String text = grid.text(body, col).trim(); + if (text.isEmpty()) + { + continue; + } + String mark = fuelMark(text); + if (mark.isEmpty()) + { + break; + } + addSurcharge(product, "报价包含情况", head, "", mark, text, ""); + return; + } + } + } + } + + private static String fuelMark(String text) + { + StringBuilder mark = new StringBuilder(); + if (text.contains("不含管理费")) + { + mark.append("不含管理费"); + } + if (text.contains("已含燃油")) + { + if (mark.length() > 0) + { + mark.append(';'); + } + mark.append("已含燃油"); + } + else if (text.contains("不含燃油") || text.contains("不包含燃油")) + { + if (mark.length() > 0) + { + mark.append(';'); + } + mark.append("不含燃油"); + } + if (text.contains("去碳") && (text.contains("不包含") || text.contains("不含"))) + { + if (mark.length() > 0) + { + mark.append(';'); + } + mark.append("不含去碳"); + } + return mark.toString(); + } + + private static String firstLine(String text) + { + if (text == null) + { + return ""; + } + int cut = text.indexOf('\n'); + String line = (cut >= 0 ? text.substring(0, cut) : text).trim(); + return line.length() > 80 ? line.substring(0, 80) : line; + } + + private static String zoneCodes(String text) + { + StringBuilder codes = new StringBuilder(); + for (String code : zoneCodeList(text)) + { + if (codes.length() > 0) + { + codes.append(','); + } + codes.append(code); + } + return codes.toString(); + } + + private static List zoneCodeList(String text) + { + List codes = new ArrayList<>(); + Matcher matcher = Pattern.compile("\\b([A-Z]{2})\\b").matcher(text == null ? "" : text); + while (matcher.find()) + { + codes.add(matcher.group(1)); + } + return codes; + } + + /** + * 德国 UPS 这类分区国家格把多个国家写在同一格,用顿号或换行接起来。 + * 纯国家码列表按出现顺序拆开;没有国家码、只用顿号连接的国名同样拆开。 + * 只有一个国家时保留原格,避免把说明文字拆碎。 + */ + private static List zoneCountryPieces(String text) + { + if (text == null || text.isBlank()) + { + return List.of(""); + } + String folded = text.replace('\r', '\n'); + List codes = zoneCodeList(folded); + if (codes.size() > 1 && isCountryCodeList(folded)) + { + return codes; + } + if (!folded.contains("、")) + { + return List.of(text.trim()); + } + List pieces = new ArrayList<>(); + for (String part : folded.split("、")) + { + String token = part.replace('\n', ' ').trim(); + if (token.isEmpty()) + { + continue; + } + List tokenCodes = zoneCodeList(token); + if (tokenCodes.size() > 1 && isCountryCodeList(token)) + { + pieces.addAll(tokenCodes); + } + else + { + pieces.add(token); + } + } + return pieces.size() > 1 ? pieces : List.of(text.trim()); + } + + private static boolean isCountryCodeList(String text) + { + String stripped = text.replaceAll("\\b[A-Z]{2}\\b", ""); + return stripped.replaceAll("[、,,;;\\s]+", "").isEmpty(); + } + + /** 拆开后的一格如果就是国家码,名称也用这个码;没拆开的原格保持原文。 */ + private static String zoneCountryName(String piece, String code, String original) + { + if (piece == null) + { + return ""; + } + if (original != null && piece.equals(original.trim())) + { + return original.trim(); + } + String trimmed = piece.trim(); + if (code == null || code.isBlank() || code.contains(",")) + { + return trimmed; + } + if (trimmed.equalsIgnoreCase(code)) + { + return code; + } + String name = trimmed.replaceAll("(?i)\\b" + Pattern.quote(code) + "\\b", "") + .replaceAll("[()()]", " ") + .trim(); + return name.isEmpty() ? code : name; + } + + private static int currencyHits(String text) + { + if (text == null || text.isBlank()) + { + return 0; + } + Matcher matcher = Pattern.compile("欧元|英镑|EUR|GBP|USD|CNY").matcher(text.toUpperCase(Locale.ROOT)); + int hits = 0; + while (matcher.find()) + { + hits++; + } + return hits; + } + + private List readCatalog(ExcelGrid grid, Workbook workbook, FileMeta meta) + { + int header = -1; + for (int row = 0; row < grid.rows(); row++) + { + String line = joinRow(grid, row); + if (line.contains("产品名称") || line.contains("销售产品") || line.contains("产品代码") || line.contains("产品 Product")) + { + header = row; + break; + } + } + List items = new ArrayList<>(); + if (header < 0) + { + return items; + } + int cols = grid.cols(); + String[] role = new String[cols]; + for (int col = 0; col < cols; col++) + { + role[col] = headerRole(grid.text(header, col)); + } + String carryName = ""; + String carryCountry = ""; + String carryService = ""; + String carryClass = ""; + for (int row = header + 1; row < grid.rows(); row++) + { + if (grid.blankRow(row)) + { + continue; + } + String line = joinRow(grid, row); + if (line.startsWith("特别申明") || line.startsWith("免责") || line.startsWith("备注:") || line.startsWith("备注:")) + { + break; + } + String quoteSheet = ""; + Set related = new LinkedHashSet<>(); + List linked = new ArrayList<>(); + for (int col = 0; col < cols; col++) + { + String sheet = grid.linkSheet(row, col); + if (sheet.isBlank() || sheet.equals(workbook.getSheetAt(0).getSheetName())) + { + continue; + } + linked.add(sheet); + String text = grid.text(row, col).replace('\n', ' '); + boolean zone = "ZONE".equals(role[col]); + boolean quote = !zone && ("QUOTE".equals(role[col]) || "NAME".equals(role[col]) || "进入报价表".equals(text) || "价格表".equals(text)); + if (quote) + { + quoteSheet = sheet; + } + else + { + related.add(sheet); + } + } + if (quoteSheet.isBlank() && linked.size() == 1) + { + quoteSheet = linked.get(0); + related.clear(); + } + String name = valueOf(grid, row, role, "NAME"); + if (name.isBlank()) + { + name = carryName; + } + else + { + carryName = name; + } + String country = valueOf(grid, row, role, "COUNTRY"); + if (country.isBlank()) + { + country = carryCountry; + } + else + { + carryCountry = country; + } + String service = valueOf(grid, row, role, "SERVICE"); + if (service.isBlank()) + { + service = carryService; + } + else + { + carryService = service; + } + String classification = valueOf(grid, row, role, "CLASS"); + if (classification.isBlank()) + { + classification = carryClass; + } + else + { + carryClass = classification; + } + if (quoteSheet.isBlank()) + { + continue; + } + if (name.isBlank()) + { + name = valueOf(grid, row, role, "VERSION"); + } + if (name.isBlank()) + { + name = quoteSheet; + } + CatalogItem item = new CatalogItem(); + item.name = name; + item.country = country; + item.serviceType = service; + item.classification = classification; + item.remark = valueOf(grid, row, role, "REMARK"); + item.updateContent = valueOf(grid, row, role, "UPDATE"); + item.weightLimit = valueOf(grid, row, role, "WEIGHT"); + item.sheetName = quoteSheet; + item.relatedSheet = String.join("、", related); + item.effective = dateOf(valueOf(grid, row, role, "EFFECTIVE")); + if (item.effective == null) + { + item.effective = meta.effective; + } + items.add(item); + } + return items; + } + + private FileMeta fileMeta(String filename) + { + FileMeta meta = new FileMeta(); + meta.supplier = filename.contains("易德") ? "易德" : "威狮"; + meta.gweFile = filename.toUpperCase(Locale.ROOT).contains("GWE"); + Matcher full = FILE_DATE.matcher(filename); + if (full.find()) + { + meta.effective = LocalDate.of(Integer.parseInt(full.group(1)), Integer.parseInt(full.group(2)), Integer.parseInt(full.group(3))); + return meta; + } + Matcher compact = FILE_COMPACT.matcher(filename); + if (compact.find()) + { + meta.effective = LocalDate.of(Integer.parseInt(compact.group(1)), Integer.parseInt(compact.group(2)), Integer.parseInt(compact.group(3))); + return meta; + } + Matcher monthDay = FILE_MONTH_DAY.matcher(filename); + if (monthDay.find()) + { + // 这批价卡文件名只写了月日,年份用价卡所属的 2026 + meta.effective = LocalDate.of(2026, Integer.parseInt(monthDay.group(1)), Integer.parseInt(monthDay.group(2))); + } + return meta; + } + + private static LocalDate dateOf(String text) + { + Period period = LblTexts.period(text, 2026); + return period.start; + } + + private static String headerRole(String text) + { + String flat = text.replace('\n', ' ').replaceAll("\\s+", " ").trim(); + if (flat.contains("产品名称") || flat.contains("销售产品") || flat.equals("产品") || flat.startsWith("产品 ")) + { + return "NAME"; + } + if (text.contains("版本")) + { + return "VERSION"; + } + if (text.contains("国家") || text.contains("地区") || text.contains("口岸")) + { + return "COUNTRY"; + } + if (text.contains("服务类型")) + { + return "SERVICE"; + } + if (text.contains("分类")) + { + return "CLASS"; + } + if (text.contains("更新")) + { + return "UPDATE"; + } + if (text.contains("重量限制")) + { + return "WEIGHT"; + } + if (text.contains("生效")) + { + return "EFFECTIVE"; + } + if (text.contains("报价") || text.contains("价卡")) + { + return "QUOTE"; + } + if (text.contains("分区")) + { + return "ZONE"; + } + if (text.contains("备注")) + { + return "REMARK"; + } + return ""; + } + + private static boolean isWeightColumnHeader(ExcelGrid grid, int row) + { + int hits = 0; + String previous = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (!LblTexts.looksLikeWeight(text)) + { + previous = ""; + continue; + } + // 横向合并会把同一个重量上限复制到相邻格,不能当成两个重量段 + if (text.equals(previous)) + { + continue; + } + previous = text; + hits++; + } + return hits >= 2; + } + + private static boolean isSurchargeHeader(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + // 说明句子里提到「收费标准」不是表头 + if (countText(grid, row) <= 1 && (line.length() > 40 || line.contains("。"))) + { + return false; + } + if (line.contains("收费标准") || line.contains("费用说明") || line.contains("费用解释")) + { + return true; + } + boolean name = line.contains("类目") || line.contains("费用名称") || line.contains("收费项目"); + boolean money = line.contains("收费") || line.contains("金额"); + boolean desc = line.contains("说明") || line.contains("解释"); + return name && money && desc; + } + + /** 标题行写着偏远附加费,紧挨着的表头是地区和邮编。 */ + private static int remoteFeeHeader(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + if (!line.contains("偏远") || !line.contains("费")) + { + return -1; + } + if (line.contains("地区") && line.contains("邮编")) + { + return row; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + if (next >= grid.rows() || next > row + 3) + { + return -1; + } + String header = joinRow(grid, next); + if (header.contains("地区") && header.contains("邮编")) + { + return next; + } + return -1; + } + + /** 地区名称在上一行,本行每个地区下面都写着每件。 */ + private static boolean isRegionWeightHeader(ExcelGrid grid, int row) + { + int perItem = 0; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (text.contains("每件") || text.toLowerCase(Locale.ROOT).contains("per item")) + { + perItem++; + } + } + if (perItem < 2) + { + return false; + } + int prev = row - 1; + while (prev >= 0 && grid.blankRow(prev)) + { + prev--; + } + if (prev < 0) + { + return false; + } + String prevLine = joinRow(grid, prev).toLowerCase(Locale.ROOT); + if (!prevLine.contains("地区") && !prevLine.contains("area")) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + return next < grid.rows() && rowHasWeight(grid, next) && countNumbers(grid, next) >= 2; + } + + /** 一、计费说明这种标题。后面的句子是配置,不是费用行。 */ + private static boolean isNoteTitle(ExcelGrid grid, int row) + { + String line = joinRow(grid, row).trim(); + if (line.isEmpty() || line.length() > 24 || countText(grid, row) > 2 || line.contains("。")) + { + return false; + } + return line.matches("^[一二三四五六七八九十]+、.+"); + } + + private int readNoteBlock(ExcelGrid grid, int row, LblProduct product) + { + String title = joinRow(grid, row).trim(); + StringBuilder body = new StringBuilder(); + row++; + while (row < grid.rows() && !grid.blankRow(row) && !isHardHeader(grid, row) && !isNoteTitle(grid, row) + && !isSurchargeHeader(grid, row)) + { + String line = joinRow(grid, row).replace('\n', ' ').trim(); + if (!line.isEmpty()) + { + if (body.length() > 0) + { + body.append(' '); + } + body.append(line); + } + row++; + } + if (body.length() > 0) + { + addConfig(product, title + " | " + body); + } + return row; + } + + /** 尺寸限制是整段产品的规则,不写进每一档单价。 */ + private void keepLimit(LblProduct product, String head, String text) + { + String body = text.replace('\n', ';').replace(";;", ";").trim(); + if (body.isEmpty()) + { + return; + } + String title = head.contains("尺寸") ? "尺寸限制" : shortCharge(head); + addConfig(product, title + " | " + body); + } + + private void addConfig(LblProduct product, String text) + { + String line = cut(new StringBuilder(text), 1000); + if (line.isEmpty()) + { + return; + } + for (LblExtraLine existing : product.getExtraLines()) + { + if (line.equals(existing.getLineText())) + { + return; + } + } + LblExtraLine extra = new LblExtraLine(); + extra.setLineType("配置"); + extra.setLineText(line); + extra.setSortNo(product.getExtraLines().size() + 1); + product.getExtraLines().add(extra); + } + + /** 单独的「附加费」标题。后面跟着金额说明或条件费率,而不是收费标准表。 */ + private static boolean isFeeSectionTitle(ExcelGrid grid, int row) + { + if (isSurchargeHeader(grid, row)) + { + return false; + } + String line = joinRow(grid, row); + if (line.length() > 80 || countText(grid, row) > 2 || line.contains("。") || feeAmount(line) != null) + { + return false; + } + String lower = line.toLowerCase(Locale.ROOT); + return line.contains("附加费") || lower.contains("surcharge"); + } + + private static String pieceUnit(ExcelGrid grid) + { + for (int row = 0; row < grid.rows(); row++) + { + String line = joinRow(grid, row); + String upper = line.toUpperCase(Locale.ROOT); + if (upper.contains("EUR") || line.contains("欧元")) + { + return "EUR/件"; + } + if (upper.contains("GBP") || line.contains("英镑")) + { + return "GBP/件"; + } + if (upper.contains("USD") || line.contains("美元")) + { + return "USD/件"; + } + } + return "每件"; + } + + private static String sentenceFeeName(String line) + { + if (line.contains("重新贴尾程标签")) + { + return "重新贴尾程标签附加费"; + } + if (line.contains("重新贴")) + { + return "重新贴标签附加费"; + } + String title = chineseTitle(line); + return title.isBlank() ? "附加费" : title; + } + + private static String chineseTitle(String line) + { + Matcher matcher = Pattern.compile("[\\u4e00-\\u9fff]{2,20}").matcher(line == null ? "" : line); + String title = ""; + while (matcher.find()) + { + title = matcher.group(); + } + return title; + } + + private static boolean looksLikeRate(String text) + { + String flat = text.replace('\n', ' '); + return flat.matches("(?i).*(\\d+(?:\\.\\d+)?\\s*%|欧元|英镑|\\beur\\b|\\bgbp\\b|euro).*"); + } + + private static String rateLine(String text) + { + String[] lines = text.split("\\n"); + for (int i = lines.length - 1; i >= 0; i--) + { + String line = lines[i].trim(); + if (line.contains("%") || line.contains("欧元") || line.toUpperCase(Locale.ROOT).contains("EUR")) + { + return line; + } + } + return text.replace('\n', ' ').trim(); + } + + private static String feeAmount(String line) + { + if (line == null || line.isBlank()) + { + return null; + } + Matcher leading = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*(欧元|英镑|EUR|GBP|Euro)").matcher(line); + if (leading.find()) + { + return leading.group(1) + " " + feeCurrency(leading.group(2)); + } + Matcher trailing = Pattern.compile("(?i)(欧元|英镑|EUR|GBP|Euro)\\s*(\\d+(?:\\.\\d+)?)").matcher(line); + if (trailing.find()) + { + return trailing.group(2) + " " + feeCurrency(trailing.group(1)); + } + return null; + } + + private static String feeCurrency(String token) + { + String upper = token.toUpperCase(Locale.ROOT); + if (upper.contains("EUR") || token.contains("欧元") || upper.contains("EURO")) + { + return "EUR"; + } + if (upper.contains("GBP") || token.contains("英镑")) + { + return "GBP"; + } + return token; + } + + /** 重量上下限拆成两列数字,右侧是每票价格。 */ + private static boolean isSplitWeightHeader(ExcelGrid grid, int row) + { + if (isWeightColumnHeader(grid, row) || isSurchargeHeader(grid, row)) + { + return false; + } + boolean weight = false; + boolean price = false; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + String lower = text.toLowerCase(Locale.ROOT); + if (text.contains("公斤") || text.contains("重量") || text.contains("尾程") || lower.contains("kg")) + { + weight = true; + } + if (text.contains("每件") || text.contains("每票") || text.contains("单价")) + { + price = true; + } + } + if (!weight || !price) + { + return false; + } + int perItem = 0; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (text.contains("每件") || text.contains("每票") || text.contains("单价")) + { + perItem++; + } + } + // 每个地区一列「每件」是地区价格表,不是英国 RM 那种两列重量范围 + if (perItem >= 2) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + return next < grid.rows() && countNumbers(grid, next) >= 2; + } + + /** 费用说明表头横向合并时,说明写在合并范围内的每一列,空的那一列不能把下一条说明丢掉。 */ + private static String descText(ExcelGrid grid, int header, int row, int descCol) + { + if (descCol < 0) + { + return ""; + } + int end = descCol + grid.colSpan(header, descCol); + StringBuilder desc = new StringBuilder(); + for (int col = descCol; col < end && col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (text.isEmpty()) + { + continue; + } + if (desc.length() > 0) + { + desc.append('\n'); + } + desc.append(text); + } + return desc.toString(); + } + + /** + * 表头是空的,左侧表头也没有合并过来,副表头同样是空的。 + * 这种列是价卡右边多贴的一份价格,不能再记成重量段。 + */ + private static boolean strayColumn(ExcelGrid grid, int header, int col, int subHeader) + { + if (!grid.text(header, col).isBlank() || headerCovers(grid, header, col)) + { + return false; + } + return subHeader < 0 || grid.text(subHeader, col).isBlank(); + } + + private static boolean headerCovers(ExcelGrid grid, int row, int col) + { + for (int left = col - 1; left >= 0; left--) + { + int span = grid.colSpan(row, left); + if (span > 1 && left + span - 1 >= col) + { + return true; + } + } + return false; + } + + /** 表头是重量段加费用列,但费用本身是附加费,不是派送费。 */ + private static boolean isFeeRateHeader(ExcelGrid grid, int row) + { + if (!isPriceHeader(grid, row)) + { + return false; + } + boolean surcharge = false; + boolean freight = false; + for (int col = 0; col < grid.cols(); col++) + { + String head = grid.text(row, col); + if (isSurchargePriceHead(head)) + { + surcharge = true; + } + if (isBaseFreightHead(head)) + { + freight = true; + } + } + return surcharge && !freight; + } + + private static boolean isRegistrationHead(String head) + { + if (head == null || head.isBlank()) + { + return false; + } + return head.contains("处理费") || head.contains("挂号"); + } + + private static boolean isBaseFreightHead(String head) + { + if (head == null || head.isBlank()) + { + return false; + } + if (head.contains("附加") || head.contains("额外") || head.toLowerCase(Locale.ROOT).contains("surcharge")) + { + return false; + } + // 偏远分行时可用处理费作大陆 FREIGHT底价;价列本身仍走 REGISTRATION + return head.contains("派送费") || head.contains("运费") || isRegistrationHead(head); + } + + /** + * 价表中与派送费并排的偏远列:落重量段 fee_type=REMOTE,不进附加费表。 + */ + private static boolean isRemoteHandleHead(String head) + { + if (head == null || head.isBlank()) + { + return false; + } + return head.contains("偏远") && (head.contains("费") || head.contains("附加")); + } + + private static boolean isFirstOrExtraWeight(String weight) + { + if (weight == null || weight.isBlank()) + { + return false; + } + String lower = weight.toLowerCase(Locale.ROOT); + return weight.contains("首重") || weight.contains("每增加") || weight.contains("续重") + || lower.contains("first") || lower.contains("additional"); + } + + private static boolean isSurchargePriceHead(String head) + { + if (head == null || head.isBlank() || isBaseFreightHead(head) || isRemoteHandleHead(head)) + { + return false; + } + String lower = head.toLowerCase(Locale.ROOT); + return head.contains("附加费") || head.contains("额外") || lower.contains("surcharge") || lower.contains("line-haul") + || lower.contains("line haul"); + } + + /** + * 偏远行按邮编前缀计费:首重整段收取,每增加只收超出部分。没有邮编列表的费用保持人工。 + */ + private static void applyFeeRateRule(LblSurcharge fee, String feeName, String zone, String weight, Map includedKg) + { + boolean remote = feeName != null && feeName.contains("偏远"); + boolean step = weight != null && (weight.contains("每增加") || weight.toLowerCase(Locale.ROOT).contains("additional")); + BigDecimal[] bounds = LblTexts.weightBounds(weight); + String prefixes = LblSurchargeRules.remotePrefixes(zone); + if (!step && bounds[1] != null) + { + includedKg.put(zone, bounds[1]); + } + BigDecimal included = includedKg.get(zone); + if (remote && step && !prefixes.isEmpty()) + { + fee.setCalcRule(LblSurchargeRules.EXTRA_KG); + fee.setCalcParam(included == null ? "1" : included.stripTrailingZeros().toPlainString()); + fee.setPostcodeParam(prefixes); + fee.setWeightMin(null); + fee.setWeightMax(null); + } + else if (remote && !prefixes.isEmpty()) + { + fee.setCalcRule(LblSurchargeRules.POSTCODE); + fee.setCalcParam(prefixes); + fee.setPostcodeParam(prefixes); + fee.setWeightMin(bounds[0]); + fee.setWeightMax(bounds[1]); + } + else + { + fee.setCalcRule(LblSurchargeRules.MANUAL); + fee.setCalcParam(weight); + } + } + + /** 价格表里仍需记入附加费表的并排列(偏远已改走处理费重量段)。 */ + private void addPricedSurcharge(LblProduct product, String section, String head, String zone, String weight, BigDecimal price) + { + String feeName = shortCharge(head); + String standard = price.stripTrailingZeros().toPlainString() + " " + feeUnit(head, weight); + addSurcharge(product, section, feeName, zone, standard.trim(), weight, ""); + LblSurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + BigDecimal[] bounds = LblTexts.weightBounds(weight); + if (bounds[0] != null && bounds[1] != null) + { + fee.setWeightMin(bounds[0]); + fee.setWeightMax(bounds[1]); + fee.setCalcRule(LblSurchargeRules.ZONE); + fee.setCalcParam(null); + } + } + + private static String feeUnit(String head, String weight) + { + String unit = unitOf(head + " " + weight); + String compact = weight == null ? "" : weight.replace(" ", "").toLowerCase(Locale.ROOT); + boolean perKg = compact.contains("每增加") || compact.contains("续重") || compact.contains("additional"); + if (!perKg) + { + return unit; + } + if (unit.contains("ITEM")) + { + return unit.replace("ITEM", "KG"); + } + if (unit.contains("KG") || unit.contains("公斤")) + { + return unit; + } + return unit.isEmpty() ? "KG" : unit + "/KG"; + } + + /** + * PosteItaliane 等两段派送费:第一块 Crono Express / CP 快速保留; + * 第二块 Crono (CE) / 标准派送跳过。 + */ + private static boolean isSecondaryServicePriceHeader(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + if (line == null || line.isBlank()) + { + return false; + } + String lower = line.toLowerCase(Locale.ROOT); + if (lower.contains("crono express") || line.contains("快速派送") + || lower.contains("(cp ") || lower.contains("(cp)")) + { + return false; + } + return lower.contains("crono (ce") || lower.contains("crono(ce") + || line.contains("标准派送"); + } + + /** CTT Extra Line-Haul 暂不入库,整表跳过。 */ + private static boolean isExtraLineHaulHeader(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + if (line == null || line.isBlank()) + { + return false; + } + String lower = line.toLowerCase(Locale.ROOT); + return line.contains("额外线路") || lower.contains("line-haul") || lower.contains("line haul") + || lower.contains("extra line"); + } + + private static String headerCellText(ExcelGrid grid, int row, int col) + { + String text = grid.text(row, col); + if (text == null || text.isBlank()) + { + text = grid.mergedText(row, col); + } + return text == null ? "" : text.replace('\n', ' ').replaceAll("\\s+", " ").trim(); + } + + private static String resolveZoneName(String rawZone, boolean remote) + { + String text = rawZone == null ? "" : rawZone.replace('\n', ' ').replaceAll("\\s+", " ").trim(); + if (text.isEmpty()) + { + return ""; + } + if (remote) + { + return text; + } + String density = LblZonePostcodeAttach.normalizeDensityZone(text); + return density.isEmpty() ? text : density; + } + + private static void applyRemotePostcode(LblWeightBand band, String rawZone) + { + if (band == null) + { + return; + } + String prefixes = LblSurchargeRules.remotePrefixes(rawZone); + if (prefixes == null || prefixes.isEmpty()) + { + prefixes = LblSurchargeRules.remotePrefixes(band.getZoneName()); + } + if (prefixes != null && !prefixes.isEmpty()) + { + band.setPostcodeParam(prefixes); + } + } + + private static String[] countryFromGroup(String group, String fallbackName, String fallbackCode) + { + String g = group == null ? "" : group; + String low = g.toLowerCase(Locale.ROOT); + if (g.contains("西班牙") || low.contains("spain")) + { + return new String[] { "西班牙", "ES" }; + } + if (g.contains("葡萄牙") || low.contains("portugal") || low.contains("mainland") || g.contains("主岛")) + { + return new String[] { "葡萄牙", "PT" }; + } + return new String[] { fallbackName, fallbackCode }; + } + + /** 主派送费表(含派送费,且非偏远/额外线路)。 */ + private static boolean isMainDeliveryPriceHeader(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + if (line == null || line.isBlank()) + { + return false; + } + String lower = line.toLowerCase(Locale.ROOT); + if ((line.contains("偏远") && line.contains("费")) || line.contains("额外") + || lower.contains("line-haul") || lower.contains("line haul") || line.contains("处理费")) + { + return false; + } + return line.contains("派送费") || line.contains("运费"); + } + + /** 跳过一整块价格表(表头+分区行+数据行),不落库。 */ + private int skipPriceTable(ExcelGrid grid, int header) + { + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isHardHeader(grid, row)) + { + if (countNumbers(grid, row) > 0 || rowHasWeight(grid, row)) + { + break; + } + row++; + } + while (row < grid.rows()) + { + if (grid.blankRow(row) || isHardHeader(grid, row) || isPriceHeader(grid, row) || isSurchargeHeader(grid, row)) + { + break; + } + row++; + } + return row; + } + + private static boolean isPriceHeader(ExcelGrid grid, int row) + { + if (isSurchargeHeader(grid, row) || isWeightColumnHeader(grid, row)) + { + return false; + } + String line = joinRow(grid, row); + // 说明句子里提到重量段、运费,不是一张新的价格表 + if (countText(grid, row) <= 1 && (line.length() > 40 || line.contains("。"))) + { + return false; + } + String lower = line.toLowerCase(Locale.ROOT); + // 正文里提到“派送费”不能当成新表头,否则附加费行会被截断 + if (lower.contains("prices in") || line.contains("速递运费") || line.contains("国家简码") || line.contains("重量段")) + { + return true; + } + boolean fee = line.contains("处理费") || line.contains("派送费") || line.contains("运费") + || (line.contains("偏远") && line.contains("费")); + boolean structure = line.contains("国家") || line.contains("产品") || line.contains("重量") || lower.contains("country"); + return fee && structure; + } + + /** 表头从左到右至少两个 Zone,例如德国 UPS 的 Zone 1 到 Zone 5。 */ + private static boolean isZonePriceHeader(ExcelGrid grid, int row) + { + int zones = 0; + for (int col = 0; col < grid.cols(); col++) + { + if (grid.text(row, col).toLowerCase(Locale.ROOT).matches(".*zone\\s*\\d.*")) + { + zones++; + } + } + return zones >= 2; + } + + private static boolean isHardHeader(ExcelGrid grid, int row) + { + return isWeightColumnHeader(grid, row) || isSurchargeHeader(grid, row) || isPriceHeader(grid, row); + } + + private static boolean isSurchargeMatrix(ExcelGrid grid, int row) + { + if (isHardHeader(grid, row) || countText(grid, row) < 2) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + if (next >= grid.rows()) + { + return false; + } + String line = joinRow(grid, next); + return line.contains("费") && countNumbers(grid, next) >= 1; + } + + private static boolean isPostcodeHeader(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + return line.contains("起始邮编") || (line.contains("邮编") && (line.contains("分区") || line.contains("截止"))); + } + + private static boolean isLooseSurcharge(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + if (!line.contains("费") || line.length() > 500) + { + return false; + } + // 一整段说明里顺便提到费用,不是表格行 + if (countText(grid, row) <= 1 && (line.length() > 40 || line.contains("。"))) + { + return false; + } + boolean money = false; + for (int col = 0; col < grid.cols(); col++) + { + if (grid.number(row, col) != null || isMoneyText(grid.text(row, col))) + { + money = true; + } + } + return money && countNumbers(grid, row) <= 2; + } + + private static int columnKind(String head, boolean numericBelow) + { + if (head.contains("产品") && !head.contains("费")) + { + return 0; + } + if (head.contains("备注") || head.contains("尺寸") || head.contains("燃油") || head.contains("说明")) + { + return 0; + } + if (head.contains("简码") || head.toLowerCase(Locale.ROOT).contains("code")) + { + return 3; + } + if (head.contains("英文")) + { + return 0; + } + if (head.contains("重量") || head.toLowerCase(Locale.ROOT).contains("weight")) + { + return 1; + } + if (head.contains("国家") || head.toLowerCase(Locale.ROOT).contains("country")) + { + return 2; + } + if (isVolumeHead(head)) + { + return 5; + } + if (isPriceHead(head) || numericBelow) + { + return 4; + } + return 0; + } + + /** 长x宽x高是这一档的体积上限,不是单价。表头里的 CM 是单边单位,乘积用 cm³。 */ + private static boolean isVolumeHead(String head) + { + if (head == null || head.isBlank()) + { + return false; + } + String value = head.toLowerCase(Locale.ROOT).replace(" ", "").replace("×", "x").replace("*", "x"); + return value.contains("长x宽x高"); + } + + /** 同一行的体积写到这一档的价格上,不另开一条费用。 */ + private static String volumeOnRow(ExcelGrid grid, int row, int[] kind, String[] parent) + { + String volume = ""; + for (int col = 0; col < kind.length; col++) + { + if (kind[col] != 5) + { + continue; + } + String text = volumeText(grid, row, col, parent[col]); + if (!text.isBlank()) + { + volume = text; + } + } + return volume; + } + + private static String volumeText(ExcelGrid grid, int row, int col, String header) + { + Double number = grid.number(row, col); + if (number == null) + { + String text = grid.text(row, col).trim(); + if (!text.matches("\\d+(?:\\.\\d+)?")) + { + return ""; + } + number = Double.valueOf(text); + } + String digits = BigDecimal.valueOf(number).stripTrailingZeros().toPlainString(); + return digits + volumeUnit(header); + } + + private static String volumeUnit(String header) + { + String value = header == null ? "" : header.toLowerCase(Locale.ROOT).replace(" ", ""); + if (value.contains("m³") || value.contains("m3") || value.contains("立方米")) + { + return "m³"; + } + return "cm³"; + } + + private static boolean isPriceHead(String head) + { + if (head == null) + { + return false; + } + String value = head.toLowerCase(Locale.ROOT); + return head.contains("费") || head.contains("价") || head.contains("运费") || value.contains("eur") || value.contains("gbp") + || value.contains("price"); + } + + private static boolean numericBelow(ExcelGrid grid, int col, int from) + { + int end = Math.min(grid.rows(), from + 8); + for (int row = from; row < end; row++) + { + if (priceOf(grid, row, col) != null) + { + return true; + } + } + return false; + } + + private static BigDecimal priceOf(ExcelGrid grid, int row, int col) + { + Double number = grid.number(row, col); + if (number != null && Math.abs(number) < 100000) + { + return BigDecimal.valueOf(number).setScale(4, java.math.RoundingMode.HALF_UP).stripTrailingZeros(); + } + String text = grid.text(row, col); + Matcher matcher = Pattern.compile("^\\s*(\\d+(?:\\.\\d+)?)").matcher(text); + if (matcher.find() && (text.contains("/") || text.toUpperCase(Locale.ROOT).matches(".*([A-Z]{3}|%).*"))) + { + return new BigDecimal(matcher.group(1)); + } + return null; + } + + private static boolean isMoneyText(String text) + { + return text.matches(".*\\d.*") && (text.contains("/") || text.toUpperCase(Locale.ROOT).matches(".*\\d\\s*(EUR|GBP|USD|RMB|CNY|%|欧元|英镑).*")); + } + + /** 这种价卡右边是每票价格。表头写着每件时也按每票记,并带上表里的币种。 */ + private static String perTicketUnit(ExcelGrid grid) + { + String currency = ""; + for (int row = 0; row < grid.rows() && currency.isEmpty(); row++) + { + String line = joinRow(grid, row); + if (line.toUpperCase(Locale.ROOT).contains("GBP") || line.contains("英镑")) + { + currency = "GBP"; + } + else if (line.toUpperCase(Locale.ROOT).contains("EUR") || line.contains("欧元")) + { + currency = "EUR"; + } + else if (line.toUpperCase(Locale.ROOT).contains("USD") || line.contains("美元")) + { + currency = "USD"; + } + } + return currency.isEmpty() ? "每票" : currency + "/票"; + } + + /** 附加费的货币单位跟重量段一样,价格数字单独放 amount,这里只放 EUR/ITEM、RMB/KG、%。 */ + private static String surchargeUnit(LblTexts.Money money, String standard) + { + if (money == null || money.amount == null) + { + return ""; + } + if ("%".equals(money.unit)) + { + return "%"; + } + String fromText = unitOf(standard == null ? "" : standard); + String currency = money.currency == null ? "" : money.currency; + String unit = money.unit == null ? "" : money.unit; + if (fromText.contains("/")) + { + return fromText; + } + if (unit.isEmpty() && ("ITEM".equals(fromText) || "KG".equals(fromText))) + { + unit = fromText; + } + if (!currency.isEmpty() && !unit.isEmpty()) + { + return currency + "/" + unit; + } + if (!fromText.isEmpty() && currency.isEmpty()) + { + return fromText; + } + return currency.isEmpty() ? unit : currency; + } + + private static String unitOf(String header) + { + String value = header.toUpperCase(Locale.ROOT); + String currency = value.contains("GBP") ? "GBP" : value.contains("EUR") ? "EUR" : value.contains("USD") ? "USD" : value.contains("RMB") || value.contains("CNY") ? "CNY" : ""; + String per = value.contains("/ITEM") || header.contains("/票") || header.contains("每件") || header.contains("每票") + || value.contains("/PARCEL") ? "ITEM" : value.contains("/KG") ? "KG" : ""; + if (currency.isEmpty()) + { + return per; + } + return per.isEmpty() ? currency : currency + "/" + per; + } + + /** 去掉表头里的币种括号,留下服务名。两段派送费才能分成 Crono Express 和 Crono。 */ + private static String chargeTitle(String head) + { + if (head == null || head.isBlank()) + { + return ""; + } + String value = head.replace('\n', ' ').replace('\r', ' '); + value = value.replaceAll("(?i)[((]\\s*(?:EUR|GBP|USD|RMB|CNY|欧元|英镑|美元|人民币)\\s*/\\s*[^))]{1,12}[))]", " "); + value = value.replaceAll("\\s+", " ").trim(); + return shortCharge(value); + } + + private static String shortCharge(String text) + { + if (text == null) + { + return ""; + } + String value = text.replace('\n', ' ').trim(); + int cut = value.indexOf('('); + if (cut < 0) + { + cut = value.indexOf('('); + } + if (cut > 0) + { + value = value.substring(0, cut).trim(); + } + return value.length() > 80 ? value.substring(0, 80) : value; + } + + private static String zoneOf(String sub, String parent) + { + if (sub == null || sub.isBlank() || sub.equals(parent)) + { + return ""; + } + return sub; + } + + private static String firstFeeHead(String[] parent) + { + for (String head : parent) + { + if (isPriceHead(head)) + { + return chargeTitle(head); + } + } + return "价格"; + } + + private static String codesIn(String text) + { + Matcher matcher = COUNTRY_CODE.matcher(text == null ? "" : text); + StringBuilder codes = new StringBuilder(); + while (matcher.find()) + { + if (codes.length() > 0) + { + codes.append(','); + } + codes.append(matcher.group(1)); + } + return codes.toString(); + } + + private static String areaLabel(String text) + { + if (text == null) + { + return ""; + } + Matcher matcher = AREA_LABEL.matcher(text); + if (!matcher.find()) + { + return ""; + } + return matcher.group(1).replaceAll("(?i)area\\s*", "Area "); + } + + private static String feePriceText(String text) + { + if (text == null || text.isBlank()) + { + return ""; + } + String flat = text.replace('\n', ' ').replaceAll("\\s+", " ").trim(); + Matcher matcher = FEE_PRICE.matcher(flat); + if (!matcher.find()) + { + return flat.length() > 80 ? flat.substring(0, 80) : flat; + } + StringBuilder price = new StringBuilder(matcher.group(1)); + if (matcher.group(2) != null) + { + price.append(' ').append(matcher.group(2).toUpperCase(Locale.ROOT)); + } + if (matcher.group(3) != null) + { + price.append(matcher.group(3).replace(" ", "")); + } + return price.toString(); + } + + private static String valueOf(ExcelGrid grid, int row, String[] role, String expect) + { + for (int col = 0; col < role.length; col++) + { + if (expect.equals(role[col]) && !grid.text(row, col).isBlank()) + { + return grid.text(row, col); + } + } + return ""; + } + + private static int countText(ExcelGrid grid, int row) + { + int count = 0; + for (int col = 0; col < grid.cols(); col++) + { + if (!grid.text(row, col).isBlank()) + { + count++; + } + } + return count; + } + + private static int countNumbers(ExcelGrid grid, int row) + { + int count = 0; + for (int col = 0; col < grid.cols(); col++) + { + if (grid.number(row, col) != null) + { + count++; + } + } + return count; + } + + private static boolean rowHasWeight(ExcelGrid grid, int row) + { + for (int col = 0; col < grid.cols(); col++) + { + if (LblTexts.looksLikeWeight(grid.text(row, col))) + { + return true; + } + } + return false; + } + + private static String firstText(ExcelGrid grid, int row) + { + for (int col = 0; col < grid.cols(); col++) + { + if (!grid.text(row, col).isBlank()) + { + return grid.text(row, col); + } + } + return ""; + } + + private static String joinRow(ExcelGrid grid, int row) + { + StringBuilder line = new StringBuilder(); + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.isEmpty()) + { + continue; + } + if (line.length() > 0) + { + line.append(" | "); + } + line.append(text); + } + return line.toString(); + } + + private static String joinDesc(String desc, String remark) + { + if (desc == null || desc.isBlank()) + { + return remark == null ? "" : remark; + } + if (remark == null || remark.isBlank() || desc.contains(remark)) + { + return desc; + } + return desc + " " + remark; + } + + private static int postcodeHits(ExcelGrid grid) + { + int hits = 0; + int end = Math.min(grid.rows(), 40); + for (int row = 0; row < end; row++) + { + for (int col = 0; col < grid.cols(); col++) + { + if (POSTCODE.matcher(grid.text(row, col)).find()) + { + hits++; + } + } + } + return hits; + } + + private static String currencyOf(ExcelGrid grid) + { + StringBuilder sample = new StringBuilder(); + int end = Math.min(grid.rows(), 25); + for (int row = 0; row < end; row++) + { + sample.append(joinRow(grid, row)).append(' '); + } + return LblTexts.currencies(sample.toString()); + } + + private static void appendNote(StringBuilder note, String line) + { + if (line == null || line.isBlank()) + { + return; + } + if (note.indexOf(line) >= 0) + { + return; + } + if (note.length() > 0) + { + note.append('\n'); + } + note.append(line.replace('\n', ' ')); + } + + private static String cleanTitle(String title) + { + if (title == null) + { + return ""; + } + String value = title.trim(); + return value.length() > 80 ? "" : value; + } + + private static String blankTo(String value, String fallback) + { + return value == null || value.isBlank() ? fallback : value; + } + + private static String cut(StringBuilder value, int max) + { + if (value == null) + { + return ""; + } + String text = value.toString().trim(); + return text.length() <= max ? text : text.substring(0, max); + } + + private static final class FileMeta + { + private String supplier; + + private boolean gweFile; + + private LocalDate effective; + } + + private static final class CatalogItem + { + private String name; + + private String country; + + private String serviceType; + + private String classification; + + private String remark; + + private String updateContent; + + private String weightLimit; + + private String sheetName; + + private String relatedSheet; + + private LocalDate effective; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/label/LblZonePostcodeAttach.java b/bms-back/bms-system/src/main/java/com/bms/price/label/LblZonePostcodeAttach.java new file mode 100644 index 00000000..dfb6a2f4 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/label/LblZonePostcodeAttach.java @@ -0,0 +1,703 @@ +package com.bms.price.label; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblWeightBand; + +/** + * 打单价卡「邮编分区」表抽取为 zone→postcodes,保存时同步到分区管理 prc_zone。 + * 支持 Corres/双列、Colissimo Densite、CTT Density、PosteItaliane 三列、iMile IT/DE。 + */ +public final class LblZonePostcodeAttach +{ + private static final DataFormatter FMT = new DataFormatter(Locale.ROOT); + private static final Pattern COUNTRY_CODE = Pattern.compile("\\(([A-Z]{2})\\)"); + private static final int MAX_ROWS = 30000; + + private LblZonePostcodeAttach() + { + } + + public static void attach(Workbook workbook, List products) + { + if (workbook == null || products == null || products.isEmpty()) + { + return; + } + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + Sheet sheet = workbook.getSheetAt(i); + if (sheet == null) + { + continue; + } + String sheetName = sheet.getSheetName() == null ? "" : sheet.getSheetName().trim(); + if (!isZonePostcodeSheet(sheetName)) + { + continue; + } + if (sheetName.contains("iMile")) + { + attachImile(sheet, sheetName, products); + continue; + } + Map> zoneMap = parseSheet(sheet); + if (zoneMap.isEmpty()) + { + continue; + } + LblProduct target = matchProduct(products, sheetName); + if (target == null) + { + continue; + } + mergeZonePostcodes(target, zoneMap); + linkSheet(target, sheetName); + } + } + + /** 从产品国家/重量段推导 ISO2,供同步 prc_zone 使用。 */ + public static String countryCodeOf(LblProduct product) + { + if (product == null) + { + return ""; + } + if (product.getWeightBands() != null) + { + for (LblWeightBand band : product.getWeightBands()) + { + if (band == null || band.getCountryCode() == null) + { + continue; + } + String code = band.getCountryCode().trim().toUpperCase(Locale.ROOT); + if (code.matches("[A-Z]{2}")) + { + return code; + } + } + } + String scope = product.getCountryScope() == null ? "" : product.getCountryScope(); + Matcher m = COUNTRY_CODE.matcher(scope.toUpperCase(Locale.ROOT)); + if (m.find()) + { + return m.group(1); + } + return ""; + } + + private static boolean isZonePostcodeSheet(String sheetName) + { + if (sheetName == null || sheetName.isBlank()) + { + return false; + } + return sheetName.contains("邮编") && sheetName.contains("分区"); + } + + private static Map> parseSheet(Sheet sheet) + { + Map> poste = parsePosteItaliane(sheet); + if (!poste.isEmpty()) + { + return poste; + } + Map> ctt = parseCtt(sheet); + if (!ctt.isEmpty()) + { + return ctt; + } + Map> coli = parseColissimo(sheet); + if (!coli.isEmpty()) + { + return coli; + } + return parseTwoCol(sheet); + } + + private static Map> parsePosteItaliane(Sheet sheet) + { + Map> map = new LinkedHashMap>(); + int cityCol = -1; + int provinceCol = -1; + int ruralCol = -1; + int headerRow = -1; + int lastRow = Math.min(sheet.getLastRowNum(), MAX_ROWS); + for (int r = 0; r <= Math.min(lastRow, 10); r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + for (int c = 0; c < row.getLastCellNum(); c++) + { + String val = cellText(row.getCell(c)).toLowerCase(Locale.ROOT); + if (val.contains("city") && cityCol < 0) + { + cityCol = c; + headerRow = r; + } + else if (val.contains("province") && provinceCol < 0) + { + provinceCol = c; + headerRow = r; + } + else if (val.contains("rural") && ruralCol < 0) + { + ruralCol = c; + headerRow = r; + } + } + if (cityCol >= 0 && provinceCol >= 0 && ruralCol >= 0) + { + break; + } + } + if (cityCol < 0 || provinceCol < 0 || ruralCol < 0 || headerRow < 0) + { + return map; + } + int dataStart = headerRow + 1; + Row sub = sheet.getRow(headerRow + 1); + if (sub != null) + { + String subLine = cellText(sub.getCell(cityCol)) + cellText(sub.getCell(provinceCol)) + + cellText(sub.getCell(ruralCol)); + if (subLine.contains("邮编")) + { + dataStart = headerRow + 2; + } + } + for (int r = dataStart; r <= lastRow; r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + addPrefix(map, "City", cellText(row.getCell(cityCol)), 3); + addPrefix(map, "Province", cellText(row.getCell(provinceCol)), 3); + addPrefix(map, "Rural", cellText(row.getCell(ruralCol)), 5); + } + return map; + } + + private static void addPrefix(Map> map, String zone, String raw, int pad) + { + String postcode = normalizePostcode(raw, pad); + if (postcode.isEmpty()) + { + return; + } + List list = map.computeIfAbsent(zone, k -> new ArrayList()); + if (!list.contains(postcode)) + { + list.add(postcode); + } + } + + private static Map> parseCtt(Sheet sheet) + { + Map> map = new LinkedHashMap>(); + int postcodeCol = -1; + int zoneCol = -1; + int headerRow = -1; + int lastRow = Math.min(sheet.getLastRowNum(), MAX_ROWS); + for (int r = 0; r <= Math.min(lastRow, 10); r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + for (int c = 0; c < row.getLastCellNum(); c++) + { + String val = cellText(row.getCell(c)); + String low = val.toLowerCase(Locale.ROOT); + if ((low.contains("codigo postal") || low.contains("código postal") || low.equals("postcode") + || low.equals("postal code")) && postcodeCol < 0) + { + postcodeCol = c; + headerRow = r; + } + if ((low.contains("ctt density") || (low.contains("density") && !low.contains("densité"))) + && zoneCol < 0) + { + zoneCol = c; + headerRow = r; + } + } + if (postcodeCol >= 0 && zoneCol >= 0) + { + break; + } + } + if (postcodeCol < 0 || zoneCol < 0 || headerRow < 0) + { + return map; + } + for (int r = headerRow + 1; r <= lastRow; r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + String postcode = normalizePostcode(cellText(row.getCell(postcodeCol)), 5); + String zone = mapCttZone(cellText(row.getCell(zoneCol))); + if (postcode.isEmpty() || zone.isEmpty()) + { + continue; + } + List list = map.computeIfAbsent(zone, k -> new ArrayList()); + if (!list.contains(postcode)) + { + list.add(postcode); + } + } + return map; + } + + /** 价表密度分区名与邮编表 CTT Density 对齐。 */ + public static String normalizeDensityZone(String raw) + { + return mapCttZone(raw); + } + + private static String mapCttZone(String raw) + { + String zone = normalizeZone(raw); + if (zone.isEmpty()) + { + return ""; + } + String low = zone.toLowerCase(Locale.ROOT); + if ((low.contains("madrid") || low.contains("mad ")) && low.contains("high")) + { + return "Mad High Density"; + } + if (low.equals("high") || low.startsWith("high ")) + { + return "High"; + } + if (low.equals("medium") || low.startsWith("medium ")) + { + return "Medium"; + } + if (low.equals("low") || low.startsWith("low ")) + { + return "Low"; + } + if (low.contains("mainland") || low.contains("主岛")) + { + return "Mainland"; + } + return zone; + } + + private static Map> parseColissimo(Sheet sheet) + { + Map> map = new LinkedHashMap>(); + int postcodeCol = -1; + int zoneCol = -1; + int headerRow = -1; + int lastRow = Math.min(sheet.getLastRowNum(), MAX_ROWS); + for (int r = 0; r <= Math.min(lastRow, 10); r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + for (int c = 0; c < row.getLastCellNum(); c++) + { + String val = cellText(row.getCell(c)); + String low = val.toLowerCase(Locale.ROOT); + if ((val.contains("邮编") || low.contains("postal")) && postcodeCol < 0) + { + postcodeCol = c; + headerRow = r; + } + if ((low.contains("densit") || val.contains("中文名称")) && zoneCol < 0) + { + zoneCol = c; + headerRow = r; + } + } + if (postcodeCol >= 0 && zoneCol >= 0) + { + break; + } + } + if (postcodeCol < 0 || zoneCol < 0 || headerRow < 0) + { + return map; + } + for (int r = headerRow + 1; r <= lastRow; r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + String postcode = normalizePostcode(cellText(row.getCell(postcodeCol)), 5); + String zone = normalizeZone(cellText(row.getCell(zoneCol))); + if (postcode.isEmpty() || zone.isEmpty()) + { + continue; + } + List list = map.computeIfAbsent(zone, k -> new ArrayList()); + if (!list.contains(postcode)) + { + list.add(postcode); + } + } + return map; + } + + private static Map> parseTwoCol(Sheet sheet) + { + Map> map = new LinkedHashMap>(); + int postcodeCol = -1; + int zoneCol = -1; + int headerRow = -1; + int lastRow = Math.min(sheet.getLastRowNum(), MAX_ROWS); + for (int r = 0; r <= Math.min(lastRow, 20); r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + for (int c = 0; c < row.getLastCellNum(); c++) + { + String val = cellText(row.getCell(c)); + if (val.contains("邮编") && postcodeCol < 0) + { + postcodeCol = c; + headerRow = r; + } + if (val.contains("分区") && !val.contains("附加") && zoneCol < 0) + { + zoneCol = c; + headerRow = r; + } + } + if (postcodeCol >= 0 && zoneCol >= 0) + { + break; + } + } + if (postcodeCol < 0 || zoneCol < 0 || headerRow < 0) + { + return map; + } + for (int r = headerRow + 1; r <= lastRow; r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + String postcode = normalizePostcode(cellText(row.getCell(postcodeCol)), 5); + String zone = normalizeZone(cellText(row.getCell(zoneCol))); + if (postcode.isEmpty() || zone.isEmpty()) + { + continue; + } + List list = map.computeIfAbsent(zone, k -> new ArrayList()); + if (!list.contains(postcode)) + { + list.add(postcode); + } + } + return map; + } + + private static void attachImile(Sheet sheet, String sheetName, List products) + { + LblProduct italy = productOf(products, "IT-iMile"); + LblProduct germany = productOf(products, "DE-iMile"); + int lastRow = Math.min(sheet.getLastRowNum(), MAX_ROWS); + int header = -1; + for (int r = 0; r <= Math.min(lastRow, 6); r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + StringBuilder line = new StringBuilder(); + for (int c = 0; c < row.getLastCellNum(); c++) + { + line.append(cellText(row.getCell(c))); + } + String text = line.toString(); + if (text.contains("邮编") || text.toLowerCase(Locale.ROOT).contains("zip")) + { + header = r; + break; + } + } + if (header < 0) + { + return; + } + Row head = sheet.getRow(header); + int postcodeCol = -1; + int zoneCol = -1; + int zipCol = -1; + int statusCol = -1; + for (int c = 0; c < head.getLastCellNum(); c++) + { + String val = cellText(head.getCell(c)); + String low = val.toLowerCase(Locale.ROOT); + if (val.contains("邮编") && postcodeCol < 0) + { + postcodeCol = c; + } + if (("分区".equals(val) || val.startsWith("分区")) && zoneCol < 0) + { + zoneCol = c; + } + if (low.contains("zip") && zipCol < 0) + { + zipCol = c; + } + if (low.contains("status") && statusCol < 0) + { + statusCol = c; + } + } + Map> itMap = new LinkedHashMap>(); + Map> deMap = new LinkedHashMap>(); + for (int r = header + 1; r <= lastRow; r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + if (italy != null && postcodeCol >= 0 && zoneCol >= 0) + { + String postcode = normalizePostcode(cellText(row.getCell(postcodeCol)), 5); + String zone = imileZone(cellText(row.getCell(zoneCol))); + if (!postcode.isEmpty() && !zone.isEmpty()) + { + List list = itMap.computeIfAbsent(zone, k -> new ArrayList()); + if (!list.contains(postcode)) + { + list.add(postcode); + } + } + } + if (germany != null && zipCol >= 0) + { + String postcode = normalizePostcode(cellText(row.getCell(zipCol)), 5); + String status = statusCol >= 0 ? cellText(row.getCell(statusCol)) : "Active"; + if (!postcode.isEmpty() && (statusCol < 0 || "Active".equalsIgnoreCase(status))) + { + List list = deMap.computeIfAbsent("Phase 1", k -> new ArrayList()); + if (!list.contains(postcode)) + { + list.add(postcode); + } + } + } + } + if (italy != null && !itMap.isEmpty()) + { + mergeZonePostcodes(italy, itMap); + linkSheet(italy, sheetName); + } + if (germany != null && !deMap.isEmpty()) + { + mergeZonePostcodes(germany, deMap); + linkSheet(germany, sheetName); + } + } + + private static String imileZone(String text) + { + String value = text == null ? "" : text.trim().toLowerCase(Locale.ROOT); + if (value.startsWith("city")) + { + return "City"; + } + if (value.startsWith("province")) + { + return "Province"; + } + if (value.startsWith("rural")) + { + return "Rural"; + } + return ""; + } + + private static LblProduct productOf(List products, String name) + { + for (LblProduct product : products) + { + if (name.equals(product.getSheetName()) || name.equals(product.getProductName()) + || name.equals(product.getDictValue())) + { + return product; + } + } + return null; + } + + private static LblProduct matchProduct(List products, String sheetName) + { + for (LblProduct product : products) + { + String related = product.getRelatedSheet(); + if (related != null && related.contains(sheetName)) + { + return product; + } + } + String token = sheetName.replace("邮编分区", "").replace("分区", "").trim(); + String tokenNorm = compact(token); + LblProduct fuzzy = null; + int hits = 0; + for (LblProduct product : products) + { + String blob = compact(nz(product.getProductName()) + nz(product.getSheetName()) + nz(product.getDictValue())); + if (!tokenNorm.isEmpty() && blob.contains(tokenNorm)) + { + fuzzy = product; + hits++; + } + } + if (hits == 1) + { + return fuzzy; + } + for (LblProduct product : products) + { + String blob = compact(nz(product.getProductName()) + nz(product.getSheetName()) + nz(product.getDictValue())); + if (tokenNorm.startsWith("ctt") && blob.contains("ctt")) + { + return product; + } + if (tokenNorm.startsWith("colissimo") && blob.contains("colissimo")) + { + return product; + } + if (tokenNorm.startsWith("corres") && blob.contains("corres")) + { + return product; + } + if (tokenNorm.startsWith("posteitaliane") && blob.contains("posteitaliane")) + { + return product; + } + } + if (products.size() == 1) + { + return products.get(0); + } + return null; + } + + private static void mergeZonePostcodes(LblProduct product, Map> incoming) + { + Map> current = product.getZonePostcodes(); + if (current == null) + { + current = new LinkedHashMap>(); + product.setZonePostcodes(current); + } + for (Map.Entry> e : incoming.entrySet()) + { + List list = current.computeIfAbsent(e.getKey(), k -> new ArrayList()); + for (String pc : e.getValue()) + { + if (!list.contains(pc)) + { + list.add(pc); + } + } + } + } + + private static void linkSheet(LblProduct product, String sheetName) + { + String current = product.getRelatedSheet(); + if (current == null || current.isBlank()) + { + product.setRelatedSheet(sheetName); + return; + } + if (!current.contains(sheetName)) + { + product.setRelatedSheet(current + "、" + sheetName); + } + } + + private static String cellText(Cell cell) + { + if (cell == null) + { + return ""; + } + return FMT.formatCellValue(cell).replace('\r', ' ').replace('\n', ' ').replaceAll("\\s+", " ").trim(); + } + + private static String normalizePostcode(String raw, int pad) + { + if (raw == null) + { + return ""; + } + String text = raw.trim().replace(" ", ""); + if (text.isEmpty()) + { + return ""; + } + if (text.matches("\\d+")) + { + if (pad > 0) + { + long value = Long.parseLong(text); + return String.format("%0" + pad + "d", value); + } + return text; + } + return text; + } + + private static String normalizeZone(String raw) + { + if (raw == null) + { + return ""; + } + return raw.replace('\r', ' ').replace('\n', ' ').replaceAll("\\s+", " ").trim(); + } + + private static String compact(String text) + { + return text == null ? "" : text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9\\u4e00-\\u9fa5]", ""); + } + + private static String nz(String text) + { + return text == null ? "" : text; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/LblProductMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/LblProductMapper.java new file mode 100644 index 00000000..ef6dbcd4 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/LblProductMapper.java @@ -0,0 +1,85 @@ +package com.bms.price.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblProductVersion; +import com.bms.price.domain.LblSurcharge; +import com.bms.price.domain.LblWeightBand; +import com.bms.price.domain.LabelPackageVersion; + +/** + * 打单价卡四表 Mapper。 + */ +public interface LblProductMapper +{ + int insertProduct(LblProduct product); + + int updateProduct(LblProduct product); + + LblProduct selectProductById(@Param("productId") Long productId); + + LblProduct selectProductByDict(@Param("dictValue") String dictValue); + + Long selectProductIdBySetAndDict(@Param("priceSetId") Long priceSetId, @Param("dictValue") String dictValue); + + int insertVersion(LblProductVersion version); + + int updateVersion(LblProductVersion version); + + int closePendingBySetProduct(@Param("priceSetId") Long priceSetId, @Param("productId") Long productId, + @Param("endDate") java.util.Date endDate, @Param("operator") String operator); + + List selectVersionIdsBySetAndNo(@Param("priceSetId") Long priceSetId, @Param("versionNo") Integer versionNo); + + int deletePendingVersionsBySetAndNo(@Param("priceSetId") Long priceSetId, @Param("versionNo") Integer versionNo); + + int deleteAllVersionsBySetAndNo(@Param("priceSetId") Long priceSetId, @Param("versionNo") Integer versionNo); + + List selectVersionsBySet(@Param("priceSetId") Long priceSetId); + + List selectVersionsBySetAndNo(@Param("priceSetId") Long priceSetId, + @Param("versionNo") Integer versionNo); + + LblProductVersion selectVersionById(@Param("versionId") Long versionId); + + Integer selectMaxVersionNo(@Param("priceSetId") Long priceSetId); + + List selectLabelVersionGroups(@Param("priceSetId") Long priceSetId); + + int insertWeightBand(LblWeightBand band); + + /** 按 band_id 更新可编辑字段(不动维度/单位) */ + int updateWeightBand(LblWeightBand band); + + LblWeightBand selectWeightBandById(@Param("bandId") Long bandId); + + int deleteWeightBandsByVersion(@Param("versionId") Long versionId); + + List selectWeightBandsByVersion(@Param("versionId") Long versionId); + + int insertSurcharge(LblSurcharge surcharge); + + int updateSurcharge(LblSurcharge surcharge); + + LblSurcharge selectSurchargeById(@Param("surchargeId") Long surchargeId); + + int deleteSurchargesByVersion(@Param("versionId") Long versionId); + + List selectSurchargesByVersion(@Param("versionId") Long versionId); + + List selectVersionIdsBySetAndProducts(@Param("priceSetId") Long priceSetId, @Param("productIds") Long[] productIds); + + int deleteVersionsBySetAndProducts(@Param("priceSetId") Long priceSetId, @Param("productIds") Long[] productIds); + + List selectProductsBySet(@Param("priceSetId") Long priceSetId); + + List selectProductsBySetAndVersion(@Param("priceSetId") Long priceSetId, + @Param("versionNo") Integer versionNo); + + int activateVersionRows(@Param("priceSetId") Long priceSetId, @Param("versionNo") Integer versionNo, + @Param("effectiveStart") java.util.Date effectiveStart, @Param("operator") String operator); + + int expireOtherActive(@Param("priceSetId") Long priceSetId, @Param("versionNo") Integer versionNo, + @Param("endDate") java.util.Date endDate, @Param("operator") String operator); +} \ No newline at end of file diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/LabelArCostCalculator.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/LabelArCostCalculator.java index b58df64d..5d17b918 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/pay/LabelArCostCalculator.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/LabelArCostCalculator.java @@ -122,8 +122,8 @@ public final class LabelArCostCalculator + "),邮编没有对上其中的地区,无法确定运费"); return result; } - PayWeightBand band = matchBand(bands, weight); - if (band == null) + List matched = matchBands(bands, weight); + if (matched.isEmpty()) { PayWeightBand top = highestBand(bands); if (top == null || weight.compareTo(top.getWeightMax()) <= 0 @@ -132,19 +132,43 @@ public final class LabelArCostCalculator result.setMessage(outOfRange(product, bands, weight)); return result; } - band = top; + matched = new ArrayList<>(); + matched.add(top); } + PayWeightBand band = matched.get(0); String currency = currencyOf(product, band); - BigDecimal freight = bandAmount(band, weight); - String freightRemark = band.getWeightLabel() + " " + plain(band.getPrice()) + " " + blank(band.getPriceUnit()); - if (zoneName != null && !zoneName.isBlank()) + BigDecimal freight = BigDecimal.ZERO; + BigDecimal handleTotal = BigDecimal.ZERO; + for (PayWeightBand one : matched) { - freightRemark = freightRemark + ",地区 " + zoneName.replace('\n', ' '); + BigDecimal amount = bandAmount(one, weight); + if (isHandleBand(one)) + { + handleTotal = handleTotal.add(amount); + String handleRemark = blank(one.getChargeName()) + " " + plain(one.getPrice()) + " " + blank(one.getPriceUnit()); + if (zoneName != null && !zoneName.isBlank()) + { + handleRemark = handleRemark + ",地区 " + zoneName.replace('\n', ' '); + } + result.getFees().add(line("HANDLE_FEE", "处理费", 0L, amount, currency, handleRemark)); + } + else + { + freight = freight.add(amount); + String freightRemark = one.getWeightLabel() + " " + plain(one.getPrice()) + " " + blank(one.getPriceUnit()); + if (zoneName != null && !zoneName.isBlank()) + { + freightRemark = freightRemark + ",地区 " + zoneName.replace('\n', ' '); + } + result.getFees().add(line("BASIC_FREIGHT", "基础运费", 0L, amount, currency, freightRemark)); + } + } + if (freight.compareTo(BigDecimal.ZERO) == 0 && handleTotal.compareTo(BigDecimal.ZERO) > 0) + { + freight = handleTotal; } - PayCostLine freightLine = line("BASIC_FREIGHT", "基础运费", 0L, freight, currency, freightRemark); - result.getFees().add(freightLine); result.setCurrency(currency); - addSurcharges(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, freight, currency, + appendSurcharges(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, freight, currency, band.getSectionName(), remoteHits, result); BigDecimal total = BigDecimal.ZERO; for (PayCostLine fee : result.getFees()) @@ -263,7 +287,8 @@ public final class LabelArCostCalculator * @param remoteHits * @param result */ - private static void addSurcharges(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + /** 供新计费复用:在已算完重量段后追加附加费。 */ + public static void appendSurcharges(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus, BigDecimal freight, String currency, String freightSection, List remoteHits, PayOrderCostResult result) { @@ -694,13 +719,25 @@ public final class LabelArCostCalculator private static PayWeightBand matchBand(List bands, BigDecimal weight) { + List matched = matchBands(bands, weight); + return matched.isEmpty() ? null : matched.get(0); + } + + /** 同一重量段可能同时有运费与处理费两行(UNIT_PLUS_HANDLE 拆分后)。 */ + private static List matchBands(List bands, BigDecimal weight) + { + List matched = new ArrayList<>(); for (PayWeightBand band : bands) { if (weight.compareTo(band.getWeightMin()) >= 0 && weight.compareTo(band.getWeightMax()) <= 0) { - return band; + matched.add(band); } } + if (!matched.isEmpty()) + { + return matched; + } for (int i = 0; i < bands.size() - 1; i++) { PayWeightBand current = bands.get(i); @@ -709,10 +746,17 @@ public final class LabelArCostCalculator if (weight.compareTo(current.getWeightMax()) > 0 && weight.compareTo(next.getWeightMin()) < 0 && gap.compareTo(new BigDecimal("0.01")) <= 0) { - return next; + matched.add(next); + return matched; } } - return null; + return matched; + } + + private static boolean isHandleBand(PayWeightBand band) + { + String name = blank(band == null ? null : band.getChargeName()); + return name.contains("处理费") || name.contains("挂号"); } private static String outOfRange(PayProduct product, List bands, BigDecimal weight) diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/ILblProductService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/ILblProductService.java new file mode 100644 index 00000000..2f747ef8 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/ILblProductService.java @@ -0,0 +1,61 @@ +package com.bms.price.service; + +import java.util.Date; +import java.util.List; +import com.bms.price.domain.ChannelProductMap; +import com.bms.price.domain.LabelPackageVersion; +import com.bms.price.domain.LblPreview; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblSurcharge; +import com.bms.price.domain.LblWeightBand; +import com.bms.price.domain.LblSaveCommand; +import com.bms.price.domain.LblSaveTask; +import com.bms.price.domain.PayParseProgress; +import com.bms.price.pay.PayParseProgressListener; + +/** + * 打单价卡:独立解析、落库与版本管理(与应付 IPayProductService 分离)。 + */ +public interface ILblProductService +{ + LblPreview preview(byte[] bytes, String filename, String dictValue, String progressId, Long priceSetId); + + PayParseProgress parseProgress(String progressId); + + String save(LblSaveCommand command); + + String save(LblSaveCommand command, PayParseProgressListener listener); + + /** 提交异步保存,立即返回 taskId */ + String submitSave(LblSaveCommand command); + + LblSaveTask getSaveTask(String taskId); + + List selectVersions(Long priceSetId); + + String activateVersion(Long priceSetId, Integer versionNo, Date effectiveTime); + + int deleteVersion(Long priceSetId, Integer versionNo); + + List selectProducts(Long priceSetId); + + List selectProductsByVersion(Long priceSetId, Integer versionNo); + + LblProduct selectDetailByVersionId(Long versionId); + + int updateDetail(LblProduct product); + + /** 仅更新一条重量段可编辑字段 */ + int updateWeightBand(LblWeightBand band); + + /** 仅更新一条附加费可编辑字段 */ + int updateSurcharge(LblSurcharge surcharge); + + int removeProducts(Long priceSetId, Long[] productIds); + + List listProductChannelBinds(Long productId, String payType); + + String replaceProductChannelBinds(Long productId, String payType, List channelIds); + + LblProduct selectPriceForDate(Long priceSetId, String channelId, Date bizDate); +} \ No newline at end of file diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcZoneService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcZoneService.java index 3f0c62cc..23288735 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcZoneService.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IPrcZoneService.java @@ -1,6 +1,7 @@ package com.bms.price.service; import java.util.List; +import java.util.Map; import org.springframework.web.multipart.MultipartFile; import com.bms.price.domain.PrcZone; import com.bms.price.domain.PrcZoneDetail; @@ -36,4 +37,12 @@ public interface IPrcZoneService public int deletePrcZoneDetailByIds(Long[] detailIds); public String importZoneExcel(MultipartFile file, String operName) throws Exception; + + /** + * 按国家同步邮编分区:仅覆盖本次出现的 zone,不清空该国其他分区。 + * @param countryCode2 ISO2 + * @param zonePostcodes zoneLabel -> postcodes + * @return 摘要文案 + */ + public String syncCountryZones(String countryCode2, Map> zonePostcodes); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/LblProductServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/LblProductServiceImpl.java new file mode 100644 index 00000000..4679073a --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/LblProductServiceImpl.java @@ -0,0 +1,923 @@ +package com.bms.price.service.impl; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.bms.common.enums.PaymentMesEnum; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.DateUtils; +import com.bms.common.utils.SecurityUtils; +import com.bms.common.utils.file.OssStorage; +import com.bms.common.utils.uuid.IdUtils; +import com.bms.price.domain.ChannelProductMap; +import com.bms.price.domain.LabelPackageVersion; +import com.bms.price.domain.LblPreview; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblProductVersion; +import com.bms.price.domain.LblSaveCommand; +import com.bms.price.domain.LblSaveTask; +import com.bms.price.domain.LblSurcharge; +import com.bms.price.domain.LblWeightBand; +import com.bms.price.domain.PayParseProgress; +import com.bms.price.domain.PrcPriceSet; +import com.bms.price.label.LblBandNormalizer; +import com.bms.price.label.LblWorkbookParser; +import com.bms.price.mapper.ChannelProductMapMapper; +import com.bms.price.mapper.LblProductMapper; +import com.bms.price.mapper.PrcPriceSetMapper; +import com.bms.price.pay.PayParseProgressListener; +import com.bms.price.service.ILblProductService; +import com.bms.price.task.LblSaveAsyncRunner; +import com.bms.price.service.IPrcZoneService; +import com.bms.price.label.LblZonePostcodeAttach; +import com.bms.common.core.domain.entity.SysDictData; +import com.bms.system.service.ISysDictTypeService; + +/** + * 打单价卡服务:独立 Excel 解析 + prc_label_* 四表。 + */ +@Service +public class LblProductServiceImpl implements ILblProductService +{ + private static final String DICT_TYPE = "payment_product_type"; + + private static final String TERM_PENDING = "0"; + + private static final String TERM_EFFECT = "1"; + + private static final String TERM_EXPIRED = "2"; + + private static final String PAY_TYPE_AR = "AR"; + + private static final long PARSE_PROGRESS_KEEP_MS = 30L * 60L * 1000L; + + private static final long UPLOAD_KEEP_MS = 60L * 60L * 1000L; + + private final LblWorkbookParser parser = new LblWorkbookParser(); + + private final Map parseProgress = new ConcurrentHashMap(); + + private final Map uploadedSources = new ConcurrentHashMap(); + + @Autowired + private LblProductMapper lblProductMapper; + + @Autowired + private PrcPriceSetMapper priceSetMapper; + + @Autowired + private ChannelProductMapMapper channelProductMapMapper; + + @Autowired + private ISysDictTypeService dictTypeService; + + @Autowired + private OssStorage ossStorage; + + @Autowired + private IPrcZoneService prcZoneService; + + @Autowired + private LblSaveAsyncRunner lblSaveAsyncRunner; + + @Override + public LblPreview preview(byte[] bytes, String filename, String dictValue, String progressId, Long priceSetId) + { + String progressKey = progressKey(progressId); + PayParseProgressListener listener = progressKey == null ? null + : (percent, message) -> writeProgress(progressKey, percent, message, PayParseProgress.RUNNING); + if (progressKey != null) + { + writeProgress(progressKey, 2, "正在准备解析", PayParseProgress.RUNNING); + } + try + { + LblPreview preview = parser.parseMatched(bytes, filename, dictValue, loadDictValues(), listener); + LblBandNormalizer.normalize(preview); + writeProgress(progressKey, 98, "正在核对已有产品", PayParseProgress.RUNNING); + String stored = storeSource(bytes, filename); + rememberSource(preview.getFileMd5(), stored); + preview.setFilePath(stored); + for (LblProduct product : preview.getProducts()) + { + product.setFilePath(stored); + Long existsId = isBlank(product.getDictValue()) ? null + : lblProductMapper.selectProductIdBySetAndDict(priceSetId, product.getDictValue()); + if (existsId == null && !isBlank(product.getDictValue())) + { + LblProduct byDict = lblProductMapper.selectProductByDict(product.getDictValue()); + existsId = byDict == null ? null : byDict.getProductId(); + } + product.setExistsId(existsId); + boolean pendingInSet = priceSetId != null && existsId != null + && hasPending(priceSetId, existsId); + product.setDuplicated(pendingInSet); + } + writeProgress(progressKey, 100, "解析完成", PayParseProgress.SUCCESS); + return preview; + } + catch (RuntimeException ex) + { + String message = ex.getMessage() == null || ex.getMessage().isBlank() ? "价卡解析失败" : ex.getMessage(); + writeProgress(progressKey, 100, message, PayParseProgress.FAIL); + throw ex; + } + } + + @Override + public PayParseProgress parseProgress(String progressId) + { + String key = progressKey(progressId); + if (key == null) + { + return new PayParseProgress(0, "等待解析", PayParseProgress.PENDING); + } + ParseSlot slot = parseProgress.get(key); + if (slot == null || System.currentTimeMillis() - slot.at > PARSE_PROGRESS_KEEP_MS) + { + return new PayParseProgress(0, "等待解析", PayParseProgress.PENDING); + } + return slot.progress; + } + + @Override + public String save(LblSaveCommand command) + { + return save(command, null); + } + + @Override + public String submitSave(LblSaveCommand command) + { + if (command == null || command.getProducts() == null || command.getProducts().isEmpty()) + { + throw PaymentMesEnum.BMS_PAY_PICK_PROD.exception(); + } + String taskId = IdUtils.fastSimpleUUID(); + lblSaveAsyncRunner.put(taskId, LblSaveTask.STATUS_PENDING, 0, "已入队", + command.getPriceSetId(), null); + lblSaveAsyncRunner.run(taskId, command, SecurityContextHolder.getContext().getAuthentication()); + return taskId; + } + + @Override + public LblSaveTask getSaveTask(String taskId) + { + if (isBlank(taskId)) + { + LblSaveTask empty = new LblSaveTask(); + empty.setStatus(LblSaveTask.STATUS_PENDING); + empty.setPercent(0); + empty.setMessage("等待保存"); + return empty; + } + LblSaveTask task = lblSaveAsyncRunner.get(taskId.trim()); + if (task == null) + { + LblSaveTask empty = new LblSaveTask(); + empty.setTaskId(taskId.trim()); + empty.setStatus(LblSaveTask.STATUS_PENDING); + empty.setPercent(0); + empty.setMessage("等待保存"); + return empty; + } + return task; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String save(LblSaveCommand command, PayParseProgressListener listener) + { + if (command == null || command.getProducts() == null || command.getProducts().isEmpty()) + { + throw PaymentMesEnum.BMS_PAY_PICK_PROD.exception(); + } + report(listener, 5, "正在创建/校验套餐"); + Long priceSetId = ensureLabelPriceSet(command); + report(listener, 8, "套餐 " + priceSetId + " 就绪,开始落库产品"); + String operator = SecurityUtils.getUsername(); + Date keepStart = null; + String keepStatus = TERM_PENDING; + int nextVersionNo; + boolean editPendingVersion = command.getVersionNo() != null; + if (editPendingVersion) + { + VersionKeep keep = clearEditableVersion(priceSetId, command.getVersionNo(), operator); + keepStart = keep.effectiveStart; + keepStatus = keep.status == null ? TERM_PENDING : keep.status; + nextVersionNo = command.getVersionNo(); + } + else + { + Integer maxSetVer = lblProductMapper.selectMaxVersionNo(priceSetId); + nextVersionNo = (maxSetVer == null ? 0 : maxSetVer) + 1; + } + int saved = 0; + int replaced = 0; + int skipped = 0; + List products = command.getProducts(); + int total = products.size(); + Map>> zoneSyncByCountry = new LinkedHashMap>>(); + for (int i = 0; i < products.size(); i++) + { + LblProduct product = products.get(i); + if (product == null || isBlank(product.getProductName()) || isBlank(product.getSheetName()) + || isBlank(product.getFileMd5())) + { + throw PaymentMesEnum.BMS_PAY_PROD_FIELD.exception(); + } + String displayName = blankTo(product.getProductName(), product.getSheetName()); + int pct = 10 + (int) Math.floor((i * 75.0) / Math.max(total, 1)); + report(listener, pct, "正在保存「" + displayName + "」(" + (i + 1) + "/" + total + ")"); + LblBandNormalizer.normalizeProduct(product); + String dict = blankTo(product.getDictValue(), product.getProductName()); + product.setDictValue(dict); + Long existsId = lblProductMapper.selectProductIdBySetAndDict(priceSetId, dict); + if (existsId == null) + { + LblProduct byDict = lblProductMapper.selectProductByDict(dict); + existsId = byDict == null ? null : byDict.getProductId(); + } + boolean hasPending = !editPendingVersion && existsId != null && hasPending(priceSetId, existsId); + if (hasPending && !command.isOverwrite()) + { + skipped++; + continue; + } + product.setFilePath(resolveSource(product)); + prepareCounts(product); + product.setCreateBy(operator); + product.setUpdateBy(operator); + product.setCreateTime(new Date()); + if (existsId != null) + { + if (editPendingVersion || command.isOverwrite()) + { + lblProductMapper.closePendingBySetProduct(priceSetId, existsId, yesterday(), operator); + } + product.setProductId(existsId); + lblProductMapper.updateProduct(product); + openVersion(product, priceSetId, nextVersionNo, operator); + replaced++; + } + else + { + product.setProductId(null); + lblProductMapper.insertProduct(product); + openVersion(product, priceSetId, nextVersionNo, operator); + } + saved++; + collectZoneSync(zoneSyncByCountry, product); + } + report(listener, 88, "正在同步邮编分区"); + for (Map.Entry>> e : zoneSyncByCountry.entrySet()) + { + prcZoneService.syncCountryZones(e.getKey(), e.getValue()); + } + if (editPendingVersion && keepStart != null) + { + List rows = lblProductMapper.selectVersionsBySetAndNo(priceSetId, nextVersionNo); + for (LblProductVersion row : rows) + { + row.setEffectiveStart(keepStart); + row.setStatus(keepStatus); + row.setUpdateBy(operator); + lblProductMapper.updateVersion(row); + } + } + report(listener, 98, "保存完成,正在收尾"); + return editPendingVersion + ? ("套餐" + priceSetId + "版本V" + nextVersionNo + "已更新" + saved + "个产品") + : ("套餐" + priceSetId + "已保存" + saved + "个产品,新版本" + replaced + "个,跳过" + skipped + "个"); + } + + private static void report(PayParseProgressListener listener, int percent, String message) + { + if (listener != null) + { + listener.onProgress(percent, message); + } + } + + @Override + public List selectVersions(Long priceSetId) + { + if (priceSetId == null) + { + return Collections.emptyList(); + } + List groups = lblProductMapper.selectLabelVersionGroups(priceSetId); + if (groups == null || groups.isEmpty()) + { + return Collections.emptyList(); + } + Date now = new Date(); + for (LabelPackageVersion ver : groups) + { + ver.setPriceSetId(priceSetId); + ver.setVersionLabel("V" + (ver.getVersionNo() == null ? 1 : ver.getVersionNo())); + Date start = ver.getEffectiveTime(); + Date end = ver.getExpireTime(); + int pending = ver.getPendingCount() == null ? 0 : ver.getPendingCount(); + int active = ver.getActiveCount() == null ? 0 : ver.getActiveCount(); + if (start == null) + { + ver.setStatus(pending > 0 ? TERM_PENDING : (active > 0 ? TERM_EFFECT : TERM_EXPIRED)); + } + else if (start.after(now)) + { + ver.setStatus(TERM_PENDING); + } + else if (end != null && !end.after(now)) + { + ver.setStatus(TERM_EXPIRED); + } + else + { + ver.setStatus(TERM_EFFECT); + } + } + return groups; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String activateVersion(Long priceSetId, Integer versionNo, Date effectiveTime) + { + assertLabelSet(priceSetId); + if (versionNo == null) + { + throw new ServiceException("请指定版本号"); + } + Date start = effectiveTime == null ? new Date() : effectiveTime; + lblProductMapper.expireOtherActive(priceSetId, versionNo, yesterday(), SecurityUtils.getUsername()); + lblProductMapper.activateVersionRows(priceSetId, versionNo, start, SecurityUtils.getUsername()); + PrcPriceSet set = priceSetMapper.selectPrcPriceSetById(priceSetId); + if (set != null && !"E".equalsIgnoreCase(String.valueOf(set.getStatus()))) + { + set.setStatus("E"); + set.setUpdateBy(SecurityUtils.getUsername()); + priceSetMapper.updatePrcPriceSet(set); + } + return "版本V" + versionNo + "已启动"; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteVersion(Long priceSetId, Integer versionNo) + { + assertLabelSet(priceSetId); + List ids = lblProductMapper.selectVersionIdsBySetAndNo(priceSetId, versionNo); + if (ids != null) + { + for (Long versionId : ids) + { + lblProductMapper.deleteWeightBandsByVersion(versionId); + lblProductMapper.deleteSurchargesByVersion(versionId); + } + } + return lblProductMapper.deletePendingVersionsBySetAndNo(priceSetId, versionNo); + } + + @Override + public List selectProducts(Long priceSetId) + { + assertLabelSet(priceSetId); + List list = lblProductMapper.selectProductsBySet(priceSetId); + return list == null ? Collections.emptyList() : list; + } + + @Override + public List selectProductsByVersion(Long priceSetId, Integer versionNo) + { + assertLabelSet(priceSetId); + List list = lblProductMapper.selectProductsBySetAndVersion(priceSetId, versionNo); + return list == null ? Collections.emptyList() : list; + } + + @Override + public LblProduct selectDetailByVersionId(Long versionId) + { + LblProductVersion ver = lblProductMapper.selectVersionById(versionId); + if (ver == null) + { + throw new ServiceException("价卡版本不存在"); + } + LblProduct product = lblProductMapper.selectProductById(ver.getProductId()); + if (product == null) + { + throw new ServiceException("产品不存在"); + } + product.setVersionId(ver.getVersionId()); + product.setVersionNo(ver.getVersionNo()); + product.setVersionStatus(ver.getStatus()); + product.setVersionStart(ver.getEffectiveStart()); + product.setVersionEnd(ver.getEffectiveEnd()); + product.setWeightBands(lblProductMapper.selectWeightBandsByVersion(versionId)); + product.setSurcharges(lblProductMapper.selectSurchargesByVersion(versionId)); + return product; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int updateWeightBand(LblWeightBand band) + { + if (band == null || band.getBandId() == null) + { + throw new ServiceException("缺少重量段ID"); + } + LblWeightBand exists = lblProductMapper.selectWeightBandById(band.getBandId()); + if (exists == null) + { + throw new ServiceException("重量段不存在"); + } + if (band.getChargeMode() == null || band.getChargeMode().isEmpty()) + { + band.setChargeMode(exists.getChargeMode()); + } + if (band.getFeeType() == null || band.getFeeType().isEmpty()) + { + band.setFeeType(exists.getFeeType()); + } + if (band.getPostcodeParam() == null) + { + band.setPostcodeParam(exists.getPostcodeParam() == null ? "" : exists.getPostcodeParam()); + } + return lblProductMapper.updateWeightBand(band); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int updateSurcharge(LblSurcharge surcharge) + { + if (surcharge == null || surcharge.getSurchargeId() == null) + { + throw new ServiceException("缺少附加费ID"); + } + LblSurcharge exists = lblProductMapper.selectSurchargeById(surcharge.getSurchargeId()); + if (exists == null) + { + throw new ServiceException("附加费不存在"); + } + return lblProductMapper.updateSurcharge(surcharge); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int updateDetail(LblProduct product) + { + if (product == null || product.getVersionId() == null) + { + throw new ServiceException("缺少版本ID"); + } + LblBandNormalizer.normalizeProduct(product); + prepareCounts(product); + product.setUpdateBy(SecurityUtils.getUsername()); + lblProductMapper.updateProduct(product); + Long versionId = product.getVersionId(); + lblProductMapper.deleteWeightBandsByVersion(versionId); + lblProductMapper.deleteSurchargesByVersion(versionId); + insertDetails(product, versionId); + return 1; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int removeProducts(Long priceSetId, Long[] productIds) + { + assertLabelSet(priceSetId); + if (productIds == null || productIds.length == 0) + { + return 0; + } + List versionIds = lblProductMapper.selectVersionIdsBySetAndProducts(priceSetId, productIds); + if (versionIds != null) + { + for (Long versionId : versionIds) + { + lblProductMapper.deleteWeightBandsByVersion(versionId); + lblProductMapper.deleteSurchargesByVersion(versionId); + } + } + return lblProductMapper.deleteVersionsBySetAndProducts(priceSetId, productIds); + } + + @Override + public List listProductChannelBinds(Long productId, String payType) + { + String type = isBlank(payType) ? PAY_TYPE_AR : payType.trim(); + List list = channelProductMapMapper.selectByProductIdAndType(productId, type); + return list == null ? new ArrayList() : list; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String replaceProductChannelBinds(Long productId, String payType, List channelIds) + { + if (productId == null) + { + throw new ServiceException("产品不能为空"); + } + String type = isBlank(payType) ? PAY_TYPE_AR : payType.trim(); + List wanted = channelIds == null ? new ArrayList() : channelIds.stream() + .filter(s -> s != null && !s.trim().isEmpty()).map(String::trim).distinct().collect(Collectors.toList()); + List existing = channelProductMapMapper.selectByProductIdAndType(productId, type); + if (existing != null) + { + for (ChannelProductMap row : existing) + { + if (row.getChannelId() != null && !wanted.contains(row.getChannelId())) + { + channelProductMapMapper.deleteByChannelAndType(row.getChannelId(), type); + } + } + } + for (String channelId : wanted) + { + ChannelProductMap row = new ChannelProductMap(); + row.setChannelId(channelId); + row.setProductId(productId); + row.setPayType(type); + channelProductMapMapper.upsert(row); + } + return "已绑定" + wanted.size() + "个渠道"; + } + + @Override + public LblProduct selectPriceForDate(Long priceSetId, String channelId, Date bizDate) + { + if (priceSetId == null || isBlank(channelId) || bizDate == null) + { + throw new ServiceException("打单计费参数不完整"); + } + ChannelProductMap link = channelProductMapMapper.selectByChannelAndType(channelId, PAY_TYPE_AR); + if (link == null || link.getProductId() == null) + { + throw new ServiceException("渠道还没有绑定打单应收产品"); + } + List versions = lblProductMapper.selectVersionsBySet(priceSetId); + LblProductVersion hit = null; + for (LblProductVersion ver : versions) + { + if (!link.getProductId().equals(ver.getProductId())) + { + continue; + } + if (!TERM_EFFECT.equals(ver.getStatus())) + { + continue; + } + if (ver.getEffectiveStart() == null || ver.getEffectiveStart().after(bizDate)) + { + continue; + } + if (ver.getEffectiveEnd() != null && !ver.getEffectiveEnd().after(bizDate)) + { + continue; + } + hit = ver; + break; + } + if (hit == null) + { + throw new ServiceException("订单时间没有落在打单价卡有效期内"); + } + return selectDetailByVersionId(hit.getVersionId()); + } + + private void openVersion(LblProduct product, Long priceSetId, int versionNo, String operator) + { + LblProductVersion version = new LblProductVersion(); + version.setPriceSetId(priceSetId); + version.setProductId(product.getProductId()); + version.setDictValue(product.getDictValue()); + version.setVersionNo(versionNo); + version.setFilePath(product.getFilePath()); + version.setStatus(TERM_PENDING); + version.setCreateBy(operator); + version.setUpdateBy(operator); + lblProductMapper.insertVersion(version); + product.setVersionId(version.getVersionId()); + insertDetails(product, version.getVersionId()); + } + + private void insertDetails(LblProduct product, Long versionId) + { + if (product.getWeightBands() != null) + { + int i = 0; + for (LblWeightBand band : product.getWeightBands()) + { + if (band == null) + { + continue; + } + band.setProductId(product.getProductId()); + band.setVersionId(versionId); + if (band.getCountryCode() == null) + { + band.setCountryCode(""); + } + if (band.getZoneCode() == null) + { + band.setZoneCode(""); + } + if (band.getPostcodeParam() == null) + { + band.setPostcodeParam(""); + } + band.setSortNo(i++); + lblProductMapper.insertWeightBand(band); + } + } + if (product.getSurcharges() != null) + { + int i = 0; + for (LblSurcharge fee : product.getSurcharges()) + { + if (fee == null || isBlank(fee.getFeeName())) + { + continue; + } + fee.setProductId(product.getProductId()); + fee.setVersionId(versionId); + fee.setSortNo(i++); + lblProductMapper.insertSurcharge(fee); + } + } + } + + private VersionKeep clearEditableVersion(Long priceSetId, Integer versionNo, String operator) + { + List rows = lblProductMapper.selectVersionsBySetAndNo(priceSetId, versionNo); + if (rows == null || rows.isEmpty()) + { + throw new ServiceException("版本不存在"); + } + VersionKeep keep = new VersionKeep(); + keep.effectiveStart = rows.get(0).getEffectiveStart(); + keep.status = rows.get(0).getStatus(); + for (LblProductVersion row : rows) + { + if (!TERM_PENDING.equals(row.getStatus()) && !TERM_EFFECT.equals(row.getStatus())) + { + throw new ServiceException("只能编辑待生效或已生效版本"); + } + lblProductMapper.deleteWeightBandsByVersion(row.getVersionId()); + lblProductMapper.deleteSurchargesByVersion(row.getVersionId()); + } + lblProductMapper.deleteAllVersionsBySetAndNo(priceSetId, versionNo); + return keep; + } + + private Long ensureLabelPriceSet(LblSaveCommand command) + { + if (command.getPriceSetId() != null) + { + assertLabelSet(command.getPriceSetId()); + return command.getPriceSetId(); + } + if (isBlank(command.getPriceSetName())) + { + throw new ServiceException("请填写打单套餐名称"); + } + String operator = SecurityUtils.getUsername(); + PrcPriceSet set = new PrcPriceSet(); + set.setPriceSetName(command.getPriceSetName().trim()); + set.setPriceSetNote(command.getPriceSetNote()); + set.setSetType("FIXED"); + set.setSetCategory(PrcPriceSet.CATEGORY_LABEL); + set.setStatus("D"); + set.setCreateBy(operator); + set.setUpdateBy(operator); + priceSetMapper.insertPrcPriceSet(set); + set.setPriceSetCode(String.format("LBL%04d", set.getPriceSetId())); + priceSetMapper.updatePrcPriceSet(set); + command.setPriceSetId(set.getPriceSetId()); + return set.getPriceSetId(); + } + + private void assertLabelSet(Long priceSetId) + { + PrcPriceSet db = priceSetMapper.selectPrcPriceSetById(priceSetId); + if (db == null) + { + throw new ServiceException("打单套餐不存在"); + } + if (!PrcPriceSet.CATEGORY_LABEL.equalsIgnoreCase(String.valueOf(db.getSetCategory()))) + { + throw new ServiceException("只能操作打单套餐"); + } + } + + private boolean hasPending(Long priceSetId, Long productId) + { + List list = lblProductMapper.selectVersionsBySet(priceSetId); + if (list == null) + { + return false; + } + for (LblProductVersion ver : list) + { + if (productId.equals(ver.getProductId()) && TERM_PENDING.equals(ver.getStatus())) + { + return true; + } + } + return false; + } + + private void collectZoneSync(Map>> byCountry, LblProduct product) + { + if (product == null || product.getZonePostcodes() == null || product.getZonePostcodes().isEmpty()) + { + return; + } + String country = LblZonePostcodeAttach.countryCodeOf(product); + if (country == null || country.isBlank()) + { + throw new ServiceException("产品「" + product.getProductName() + "」有邮编分区表,但无法识别国家代码"); + } + Map> bucket = byCountry.computeIfAbsent(country, k -> new LinkedHashMap>()); + for (Map.Entry> e : product.getZonePostcodes().entrySet()) + { + if (e.getKey() == null || e.getValue() == null) + { + continue; + } + List list = bucket.computeIfAbsent(e.getKey(), k -> new ArrayList()); + for (String pc : e.getValue()) + { + if (pc != null && !pc.isBlank() && !list.contains(pc)) + { + list.add(pc); + } + } + } + } + + private void prepareCounts(LblProduct product) + { + int bands = product.getWeightBands() == null ? 0 : product.getWeightBands().size(); + int fees = product.getSurcharges() == null ? 0 : product.getSurcharges().size(); + product.setWeightCount(bands); + product.setSurchargeCount(fees); + } + + private Set loadDictValues() + { + List rows = dictTypeService.selectDictDataByType(DICT_TYPE); + if (rows == null || rows.isEmpty()) + { + throw PaymentMesEnum.BMS_PAY_DICT_EMPTY.exception(); + } + return rows.stream().map(SysDictData::getDictValue).filter(v -> v != null && !v.isBlank()) + .collect(Collectors.toSet()); + } + + private String storeSource(byte[] bytes, String filename) + { + String original = isBlank(filename) ? "import.xlsx" : filename.trim(); + String ext = "xlsx"; + int dot = original.lastIndexOf('.'); + if (dot >= 0 && dot < original.length() - 1) + { + ext = original.substring(dot + 1).toLowerCase(Locale.ROOT); + } + String contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + String objectKey = "price/label/" + DateUtils.datePath() + "/" + IdUtils.fastSimpleUUID() + "." + ext; + try + { + return ossStorage.upload(new ByteArrayInputStream(bytes), bytes.length, objectKey, contentType); + } + catch (IOException ex) + { + throw PaymentMesEnum.BMS_PAY_FILE_UP.exception(); + } + } + + private String resolveSource(LblProduct product) + { + String path = sourcePath(product.getFilePath()); + if (path == null) + { + path = rememberedSource(product.getFileMd5()); + } + if (path == null) + { + throw PaymentMesEnum.BMS_PAY_FILE_WAIT.exception(); + } + return path; + } + + private void rememberSource(String fileMd5, String path) + { + if (isBlank(fileMd5) || sourcePath(path) == null) + { + return; + } + uploadedSources.put(fileMd5, new UploadedSource(path, System.currentTimeMillis())); + long now = System.currentTimeMillis(); + Iterator> it = uploadedSources.entrySet().iterator(); + while (it.hasNext()) + { + Map.Entry e = it.next(); + if (now - e.getValue().at > UPLOAD_KEEP_MS) + { + it.remove(); + } + } + } + + private String rememberedSource(String fileMd5) + { + if (isBlank(fileMd5)) + { + return null; + } + UploadedSource src = uploadedSources.get(fileMd5); + if (src == null || System.currentTimeMillis() - src.at > UPLOAD_KEEP_MS) + { + return null; + } + return src.path; + } + + private static String sourcePath(String path) + { + return isBlank(path) ? null : path.trim(); + } + + private String progressKey(String progressId) + { + return isBlank(progressId) ? null : progressId.trim(); + } + + private void writeProgress(String key, int percent, String message, String status) + { + if (key == null) + { + return; + } + parseProgress.put(key, new ParseSlot(new PayParseProgress(percent, message, status), System.currentTimeMillis())); + } + + private static Date yesterday() + { + return new Date(System.currentTimeMillis() - 24L * 60L * 60L * 1000L); + } + + private static boolean isBlank(String v) + { + return v == null || v.trim().isEmpty(); + } + + private static String blankTo(String v, String fallback) + { + return isBlank(v) ? fallback : v.trim(); + } + + private static final class ParseSlot + { + private final PayParseProgress progress; + private final long at; + + private ParseSlot(PayParseProgress progress, long at) + { + this.progress = progress; + this.at = at; + } + } + + private static final class UploadedSource + { + private final String path; + private final long at; + + private UploadedSource(String path, long at) + { + this.path = path; + this.at = at; + } + } + + private static final class VersionKeep + { + private Date effectiveStart; + private String status; + } +} \ No newline at end of file diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcZoneServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcZoneServiceImpl.java index dd6ca0ec..e2a7ce43 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcZoneServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcZoneServiceImpl.java @@ -276,6 +276,110 @@ public class PrcZoneServiceImpl implements IPrcZoneService return msg; } + + @Override + @Transactional(rollbackFor = Exception.class) + public String syncCountryZones(String countryCode2, Map> zonePostcodes) + { + if (StringUtils.isEmpty(countryCode2)) + { + throw new ServiceException("国家代码不能为空"); + } + if (zonePostcodes == null || zonePostcodes.isEmpty()) + { + return "无邮编分区需同步"; + } + String code = countryCode2.trim().toUpperCase(Locale.ROOT); + BmsRegion region = regionMapper.selectRegionByCode2(code); + if (region == null || region.getRegionId() == null) + { + throw new ServiceException("分区管理未找到国家 " + code); + } + List existing = zoneMapper.selectPrcZoneListByRegionId(region.getRegionId()); + Map byCode = new HashMap(); + if (existing != null) + { + for (PrcZone z : existing) + { + if (z != null && StringUtils.isNotEmpty(z.getZoneCode())) + { + byCode.put(z.getZoneCode().trim().toUpperCase(Locale.ROOT), z); + } + } + } + int zoneUpsert = 0; + int detailInserted = 0; + List>> entries = new ArrayList<>(zonePostcodes.entrySet()); + entries.sort(Comparator.comparingInt(e -> extractZoneSort(e.getKey()))); + for (Map.Entry> entry : entries) + { + String label = StringUtils.trim(entry.getKey()); + if (StringUtils.isEmpty(label)) + { + continue; + } + label = label.replace('\r', ' ').replace('\n', ' ').replaceAll("\\s+", " ").trim(); + String zoneCode = ZoneCodeAbbrevs.toAbbrev(label); + if (StringUtils.isEmpty(zoneCode)) + { + continue; + } + if (zoneCode.length() > 50) + { + zoneCode = zoneCode.substring(0, 50); + } + String key = zoneCode.trim().toUpperCase(Locale.ROOT); + PrcZone zone = byCode.get(key); + if (zone == null) + { + zone = new PrcZone(); + zone.setRegionId(region.getRegionId()); + zone.setCountryCode(code); + zone.setZoneCode(zoneCode); + zone.setZoneName(label.length() > 100 ? label.substring(0, 100) : label); + zone.setZoneSort(extractZoneSort(label)); + zoneMapper.insertPrcZone(zone); + byCode.put(key, zone); + } + else + { + zone.setZoneName(label.length() > 100 ? label.substring(0, 100) : label); + zoneMapper.updatePrcZone(zone); + detailMapper.deletePrcZoneDetailByZoneId(zone.getZoneId()); + } + zoneUpsert++; + Set uniquePostcodes = new LinkedHashSet(); + if (entry.getValue() != null) + { + uniquePostcodes.addAll(entry.getValue()); + } + List batch = new ArrayList<>(BATCH_SIZE); + for (String postcode : uniquePostcodes) + { + if (StringUtils.isEmpty(postcode)) + { + continue; + } + PrcZoneDetail detail = new PrcZoneDetail(); + detail.setZoneId(zone.getZoneId()); + detail.setPostcode(postcode.trim()); + batch.add(detail); + if (batch.size() >= BATCH_SIZE) + { + detailMapper.insertPrcZoneDetailBatch(batch); + detailInserted += batch.size(); + batch = new ArrayList<>(BATCH_SIZE); + } + } + if (!batch.isEmpty()) + { + detailMapper.insertPrcZoneDetailBatch(batch); + detailInserted += batch.size(); + } + } + return "已同步 " + code + " 分区" + zoneUpsert + "个、邮编" + detailInserted + "条"; + } + private int[] replaceRegionZones(BmsRegion region, Map> zonePostcodes) { if (StringUtils.isEmpty(region.getCountryCode2())) 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 8b3780ab..d316bebf 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 @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -42,7 +43,7 @@ import com.bms.price.domain.PrcQuote; import com.bms.price.domain.PrcQuoteFee; import com.bms.price.domain.PrcSurchargeRel; import com.bms.price.domain.PrcZone; -import com.bms.price.pay.LabelArCostCalculator; +import com.bms.price.pay.PayZoneMatcher; import com.bms.price.mapper.PrcZoneDetailMapper; import com.bms.price.domain.vo.TmsPriceBillRequest; import com.bms.price.domain.vo.TmsPriceQuoteFeeLine; @@ -68,14 +69,20 @@ import com.bms.price.mapper.PrcSurchargeRelMapper; import com.bms.price.search.EsBizLogSupport; import com.bms.price.search.EsLogType; import com.bms.price.search.IBillSearchStore; +import com.bms.price.service.ILblProductService; import com.bms.price.service.IPayProductService; +import com.bms.price.label.LblCostCalculator; +import com.bms.price.label.LblProductPayAdapter; +import com.bms.price.domain.LblFeeType; +import com.bms.price.domain.LblProduct; +import com.bms.price.domain.LblWeightBand; import com.bms.price.service.ITmsPriceQuoteService; import com.bms.system.service.IBmsProductCodeMappingService; /** * 共用的试算/计费计算:按 bizDate 取客户绑定和套餐版本(版本 effective_time)。 * 绑定的 pr_begindate/pr_enddate 不作为计费时间门槛。 - * 先按 prc_price_set.set_category 分流:LABEL 走打单 pay_*;PACKAGE/EXCLUSIVE 走销售快照。 + * 先按 prc_price_set.set_category 分流:LABEL 走打单 prc_label_*;PACKAGE/EXCLUSIVE 走销售快照。 * 销售价只来自套餐快照(FIXED 明细 / 按目录底价 DISCOUNT)。 * 无绑定或无生效版本:试算和计费都直接失败(不用 PRODUCT_DEFAULT)。 * 账单同步时,产品/国家/重量段未命中为软失败(billing_fail_status 1/2/3,金额为 0)。 @@ -144,6 +151,9 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService @Autowired private IPayProductService payProductService; + @Autowired + private ILblProductService lblProductService; + @Autowired private PrcOrderBillExtMapper orderBillExtMapper; @@ -209,7 +219,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService return result; } - /** LABEL 费用已由 LabelArCostCalculator 算完,不再叠加销售附加费。 */ + /** LABEL 费用已由 LblCostCalculator 算完,不再叠加销售附加费。 */ private void applySalesSurchargesIfNeeded(TmsPriceQuoteResult result) { if (result == null || SRC_LABEL.equals(result.getMatchSource())) @@ -665,7 +675,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService } /** - * 打单套餐计费:渠道 AR → product_id,套餐+时间 → term,LabelArCostCalculator 算费(与应付独立)。 + * 打单套餐计费:渠道 AR → product_id,套餐+时间 → version,LblCostCalculator 算费。 */ private TmsPriceQuoteResult doLabelQuote(TmsPriceQuoteRequest request, PrcCustomerPriceSet bind, PrcPriceSet set, String productCode, String productName, String countryCode, String countryName, @@ -681,10 +691,10 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService } throw new ServiceException("打单计费需要渠道"); } - PayProduct product; + LblProduct lblProduct; try { - product = payProductService.selectLabelPriceForDate(set.getPriceSetId(), channelId, bizDate); + lblProduct = lblProductService.selectPriceForDate(set.getPriceSetId(), channelId, bizDate); } catch (ServiceException ex) { @@ -695,6 +705,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService } throw ex; } + PayProduct product = LblProductPayAdapter.toPayProduct(lblProduct); BigDecimal length = null; BigDecimal width = null; BigDecimal height = null; @@ -707,8 +718,27 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService height = billReq.getPkgHeight(); orderStatus = billReq.getOrderStatus(); } - PayOrderCostResult cost = LabelArCostCalculator.calculate(product, weight, length, width, height, - request.getDestPostcode(), bizDate, orderStatus); + String freightZone; + try + { + freightZone = resolveLabelFreightZone(lblProduct, countryCode, request.getDestPostcode()); + } + catch (ServiceException ex) + { + if (request instanceof TmsPriceBillRequest) + { + Integer fail = BillBillingFailStatus.POSTCODE_ZONE_NOT_FOUND.getCode(); + if (ex.getMessage() != null && ex.getMessage().contains("目的邮编")) + { + fail = BillBillingFailStatus.POSTCODE_REQUIRED.getCode(); + } + return buildBillingMissResult(request, productCode, productName, countryCode, countryName, + weight, bizDate, fail); + } + throw ex; + } + PayOrderCostResult cost = LblCostCalculator.calculate(lblProduct, weight, length, width, height, + request.getDestPostcode(), freightZone, bizDate, orderStatus); if (cost == null || cost.getFees() == null || cost.getFees().isEmpty()) { if (request instanceof TmsPriceBillRequest) @@ -725,6 +755,128 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService return r; } + /** + * 打单多分区价卡:先按国家收窄运费分区; + * 该国只有 1 个运费分区(如 PT Mainland)直接用,不强依分区管理; + * 多分区(如 ES 密度)再用 prc_zone 按邮编解析。 + */ + private String resolveLabelFreightZone(LblProduct product, String countryCode, String destPostcode) + { + List zoneNames = labelBandZoneNames(product); + if (zoneNames.size() <= 1) + { + return null; + } + if (StringUtils.isEmpty(destPostcode)) + { + throw new ServiceException("请填写目的邮编"); + } + String cc = countryCode == null ? "" : countryCode.trim().toUpperCase(Locale.ROOT); + List countryFreight = labelFreightZonesForCountry(product, cc); + if (countryFreight.size() == 1) + { + return countryFreight.get(0); + } + List candidates = countryFreight.isEmpty() ? zoneNames : countryFreight; + if (StringUtils.isEmpty(cc)) + { + throw new ServiceException("邮编未匹配到分区"); + } + PrcZone resolved = zoneDetailMapper.selectZoneByCountryPostcode(cc, normalizePostcode(destPostcode)); + if (resolved == null) + { + throw new ServiceException("邮编未匹配到分区"); + } + String mapped = PayZoneMatcher.mapRegion(candidates, resolved.getZoneCode()); + if (mapped == null) + { + mapped = PayZoneMatcher.mapRegion(candidates, resolved.getZoneName()); + } + if (mapped == null) + { + throw new ServiceException("邮编分区「" + + (resolved.getZoneName() != null ? resolved.getZoneName() : resolved.getZoneCode()) + + "」对不上价卡地区"); + } + return mapped; + } + + private static List labelBandZoneNames(LblProduct product) + { + LinkedHashSet names = new LinkedHashSet(); + if (product == null || product.getWeightBands() == null) + { + return new ArrayList(); + } + for (LblWeightBand band : product.getWeightBands()) + { + if (band == null) + { + continue; + } + String zone = band.getZoneName(); + if (zone != null && !zone.isBlank()) + { + names.add(zone.trim()); + } + } + return new ArrayList(names); + } + + /** 该国家下运费(FREIGHT)分区列名;偏远行不算入。 */ + private static List labelFreightZonesForCountry(LblProduct product, String countryCode) + { + LinkedHashSet names = new LinkedHashSet(); + if (product == null || product.getWeightBands() == null) + { + return new ArrayList(); + } + for (LblWeightBand band : product.getWeightBands()) + { + if (band == null) + { + continue; + } + String ft = band.getFeeType(); + if (ft != null && !ft.isEmpty() && !LblFeeType.FREIGHT.equals(ft)) + { + continue; + } + String zone = band.getZoneName(); + if (zone == null || zone.isBlank()) + { + continue; + } + if (StringUtils.isNotEmpty(countryCode) && !bandCountryMatches(band.getCountryCode(), countryCode)) + { + continue; + } + names.add(zone.trim()); + } + return new ArrayList(names); + } + + private static boolean bandCountryMatches(String bandCountryCode, String orderCountryCode) + { + if (StringUtils.isEmpty(orderCountryCode)) + { + return true; + } + if (StringUtils.isEmpty(bandCountryCode)) + { + return true; + } + String order = orderCountryCode.trim().toUpperCase(Locale.ROOT); + for (String part : bandCountryCode.split("[,;/\\s]+")) + { + if (part != null && order.equals(part.trim().toUpperCase(Locale.ROOT))) + { + return true; + } + } + return false; + } + /** * 试算用 request.channelId;计费优先 prc_order_bill.product_floor_code, * 没有再取 prc_order_bill_ext.product_floor_code,再解析为渠道ID。 diff --git a/bms-back/bms-system/src/main/java/com/bms/price/task/LblSaveAsyncRunner.java b/bms-back/bms-system/src/main/java/com/bms/price/task/LblSaveAsyncRunner.java new file mode 100644 index 00000000..424d22de --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/task/LblSaveAsyncRunner.java @@ -0,0 +1,124 @@ +package com.bms.price.task; + +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.scheduling.annotation.Async; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import com.bms.common.utils.StringUtils; +import com.bms.price.domain.LblSaveCommand; +import com.bms.price.domain.LblSaveTask; +import com.bms.price.pay.PayParseProgressListener; +import com.bms.price.service.ILblProductService; + +/** + * 打单价卡异步保存:进度存内存(与解析进度一致),前端轮询。 + */ +@Component +public class LblSaveAsyncRunner +{ + private static final Logger log = LoggerFactory.getLogger(LblSaveAsyncRunner.class); + + private static final long KEEP_MS = 60L * 60L * 1000L; + + private final Map tasks = new ConcurrentHashMap(); + + @Autowired + @Lazy + private ILblProductService lblProductService; + + @Async("threadPoolTaskExecutor") + public void run(String taskId, LblSaveCommand command, Authentication authentication) + { + SecurityContext previous = SecurityContextHolder.getContext(); + try + { + if (authentication != null) + { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + } + put(taskId, LblSaveTask.STATUS_RUNNING, 3, "正在准备保存", + command == null ? null : command.getPriceSetId(), null); + PayParseProgressListener listener = (percent, message) -> put(taskId, LblSaveTask.STATUS_RUNNING, + percent, message, command == null ? null : command.getPriceSetId(), null); + String message = lblProductService.save(command, listener); + put(taskId, LblSaveTask.STATUS_SUCCESS, 100, message, + command == null ? null : command.getPriceSetId(), null); + } + catch (Exception e) + { + log.error("async label save failed, taskId={}", taskId, e); + String msg = e.getMessage(); + if (StringUtils.isEmpty(msg)) + { + msg = e.getClass().getSimpleName(); + } + put(taskId, LblSaveTask.STATUS_FAIL, 100, msg, + command == null ? null : command.getPriceSetId(), msg); + } + finally + { + SecurityContextHolder.setContext(previous); + } + } + + public void put(String taskId, String status, int percent, String message, Long priceSetId, String error) + { + LblSaveTask task = new LblSaveTask(); + task.setTaskId(taskId); + task.setStatus(status); + task.setPercent(percent); + task.setMessage(message); + task.setPriceSetId(priceSetId); + task.setError(error); + tasks.put(taskId, new Slot(task, System.currentTimeMillis())); + if (tasks.size() > 200) + { + long now = System.currentTimeMillis(); + Iterator> it = tasks.entrySet().iterator(); + while (it.hasNext()) + { + Map.Entry e = it.next(); + if (now - e.getValue().at > KEEP_MS) + { + it.remove(); + } + } + } + } + + public LblSaveTask get(String taskId) + { + if (taskId == null || taskId.isBlank()) + { + return null; + } + Slot slot = tasks.get(taskId.trim()); + if (slot == null || System.currentTimeMillis() - slot.at > KEEP_MS) + { + return null; + } + return slot.task; + } + + private static final class Slot + { + private final LblSaveTask task; + private final long at; + + private Slot(LblSaveTask task, long at) + { + this.task = task; + this.at = at; + } + } +} diff --git a/bms-back/bms-system/src/main/resources/mapper/price/LblProductMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/LblProductMapper.xml new file mode 100644 index 00000000..c9e21a34 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/price/LblProductMapper.xml @@ -0,0 +1,320 @@ + + + + + + insert into prc_label_product ( + product_name, dict_value, country_scope, charge_type, extra_per_kg, service_type, classification, + catalog_remark, update_content, weight_limit, sheet_name, related_sheet, source_file, file_path, file_md5, + supplier_name, currency, service_note, parse_status, weight_count, surcharge_count, + created_by, create_time, updated_by, update_time, remark + ) values ( + #{productName}, #{dictValue}, #{countryScope}, #{chargeType}, #{extraPerKg}, #{serviceType}, #{classification}, + #{catalogRemark}, #{updateContent}, #{weightLimit}, #{sheetName}, #{relatedSheet}, #{sourceFile}, #{filePath}, #{fileMd5}, + #{supplierName}, #{currency}, #{serviceNote}, ifnull(#{parseStatus}, 'OK'), ifnull(#{weightCount}, 0), ifnull(#{surchargeCount}, 0), + #{createBy}, sysdate(), #{updateBy}, sysdate(), #{remark} + ) + + + + update prc_label_product + set product_name = #{productName}, + dict_value = #{dictValue}, + country_scope = #{countryScope}, + charge_type = #{chargeType}, + extra_per_kg = #{extraPerKg}, + service_type = #{serviceType}, + classification = #{classification}, + catalog_remark = #{catalogRemark}, + update_content = #{updateContent}, + weight_limit = #{weightLimit}, + sheet_name = #{sheetName}, + related_sheet = #{relatedSheet}, + source_file = #{sourceFile}, + file_path = #{filePath}, + file_md5 = #{fileMd5}, + supplier_name = #{supplierName}, + currency = #{currency}, + service_note = #{serviceNote}, + parse_status = ifnull(#{parseStatus}, parse_status), + weight_count = ifnull(#{weightCount}, weight_count), + surcharge_count = ifnull(#{surchargeCount}, surcharge_count), + updated_by = #{updateBy}, + update_time = sysdate(), + remark = #{remark} + where product_id = #{productId} + + + + + + + + + + insert into prc_label_product_version ( + price_set_id, product_id, dict_value, version_no, effective_start, effective_end, file_path, status, + created_by, create_time, updated_by, update_time, remark + ) values ( + #{priceSetId}, #{productId}, #{dictValue}, ifnull(#{versionNo}, 1), #{effectiveStart}, #{effectiveEnd}, #{filePath}, + ifnull(#{status}, '0'), #{createBy}, sysdate(), #{updateBy}, sysdate(), #{remark} + ) + + + + update prc_label_product_version + set effective_start = #{effectiveStart}, + effective_end = #{effectiveEnd}, + file_path = #{filePath}, + status = #{status}, + updated_by = #{updateBy}, + update_time = sysdate(), + remark = #{remark} + where version_id = #{versionId} + + + + update prc_label_product_version + set status = '2', + effective_end = ifnull(effective_end, #{endDate}), + updated_by = #{operator}, + update_time = sysdate() + where price_set_id = #{priceSetId} + and product_id = #{productId} + and status = '0' + + + + + + delete from prc_label_product_version + where price_set_id = #{priceSetId} and version_no = #{versionNo} and status = '0' + + + + delete from prc_label_product_version + where price_set_id = #{priceSetId} and version_no = #{versionNo} + + + + + + + + + + + + + + insert into prc_label_weight_band ( + product_id, version_id, fee_type, country_name, country_code, zone_code, zone_name, postcode_param, + weight_label, weight_min, weight_max, volume_text, charge_name, charge_mode, calc_param, + price, price_unit, extra_price, extra_price_unit, sort_no + ) values ( + #{productId}, #{versionId}, ifnull(#{feeType}, 'FREIGHT'), #{countryName}, ifnull(#{countryCode}, ''), ifnull(#{zoneCode}, ''), #{zoneName}, ifnull(#{postcodeParam}, ''), + #{weightLabel}, #{weightMin}, #{weightMax}, #{volumeText}, #{chargeName}, ifnull(#{chargeMode}, 'ITEM'), #{calcParam}, + #{price}, #{priceUnit}, #{extraPrice}, #{extraPriceUnit}, ifnull(#{sortNo}, 0) + ) + + + + update prc_label_weight_band + set fee_type = ifnull(#{feeType}, fee_type), + charge_mode = ifnull(#{chargeMode}, charge_mode), + calc_param = #{calcParam}, + postcode_param = ifnull(#{postcodeParam}, ''), + price = #{price}, + extra_price = #{extraPrice} + where band_id = #{bandId} + + + + + + delete from prc_label_weight_band where version_id = #{versionId} + + + + + + insert into prc_label_surcharge ( + product_id, version_id, fee_name, sc_id, zone_code, zone_name, charge_text, amount, currency, price_unit, + fee_desc, period_text, effective_start, effective_end, charge_timing, calc_rule, calc_param, postcode_param, + weight_min, weight_max, sort_no + ) values ( + #{productId}, #{versionId}, #{feeName}, #{scId}, #{zoneCode}, #{zoneName}, #{chargeText}, #{amount}, #{currency}, #{priceUnit}, + #{feeDesc}, #{periodText}, #{effectiveStart}, #{effectiveEnd}, #{chargeTiming}, ifnull(#{calcRule}, 'MANUAL'), #{calcParam}, #{postcodeParam}, + #{weightMin}, #{weightMax}, ifnull(#{sortNo}, 0) + ) + + + + update prc_label_surcharge + set fee_name = ifnull(#{feeName}, fee_name), + amount = #{amount}, + currency = #{currency}, + price_unit = #{priceUnit}, + charge_text = #{chargeText}, + fee_desc = #{feeDesc}, + calc_rule = ifnull(#{calcRule}, calc_rule), + calc_param = #{calcParam}, + postcode_param = #{postcodeParam}, + zone_code = #{zoneCode}, + zone_name = #{zoneName} + where surcharge_id = #{surchargeId} + + + + + + delete from prc_label_surcharge where version_id = #{versionId} + + + + + + + + delete from prc_label_product_version + where price_set_id = #{priceSetId} + and product_id in + #{id} + + + + + + + + update prc_label_product_version + set status = '1', + effective_start = #{effectiveStart}, + updated_by = #{operator}, + update_time = sysdate() + where price_set_id = #{priceSetId} and version_no = #{versionNo} + + + + update prc_label_product_version + set status = '2', + effective_end = #{endDate}, + updated_by = #{operator}, + update_time = sysdate() + where price_set_id = #{priceSetId} + and version_no <> #{versionNo} + and status = '1' + + \ No newline at end of file diff --git a/bms-back/sql/price/prc_label_tables.sql b/bms-back/sql/price/prc_label_tables.sql new file mode 100644 index 00000000..23aee8cf --- /dev/null +++ b/bms-back/sql/price/prc_label_tables.sql @@ -0,0 +1,158 @@ +-- 打单价卡独立四表 + 重量段 fee_type 字典(可重复执行) +-- 依据 docs/price/prc_label_schema.md +-- 重量段:fee_type / calc_param;charge_mode 对齐 calc_rule;无 handle_* +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `prc_label_product` ( + `product_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '产品ID', + `product_name` varchar(200) NOT NULL COMMENT '产品名称', + `dict_value` varchar(100) NOT NULL COMMENT '字典 payment_product_type', + `country_scope` varchar(200) DEFAULT NULL COMMENT '国家或口岸', + `charge_type` varchar(50) DEFAULT NULL COMMENT '订单收费类型,字典 order_charge_type', + `extra_per_kg` varchar(40) DEFAULT NULL COMMENT '续重每公斤加价文案', + `service_type` varchar(100) DEFAULT NULL COMMENT '服务类型', + `classification` varchar(100) DEFAULT NULL COMMENT '目录分类', + `catalog_remark` varchar(1000) DEFAULT NULL COMMENT '目录备注', + `update_content` varchar(500) DEFAULT NULL COMMENT '目录更新内容', + `weight_limit` varchar(100) DEFAULT NULL COMMENT '目录重量限制', + `sheet_name` varchar(200) NOT NULL COMMENT '报价工作表名', + `related_sheet` varchar(500) DEFAULT NULL COMMENT '关联分区或附加表名', + `source_file` varchar(500) NOT NULL COMMENT '来源文件名', + `file_path` varchar(500) DEFAULT NULL COMMENT '源文件OSS地址,oss://bucket/key', + `file_md5` char(32) NOT NULL COMMENT '来源文件MD5', + `supplier_name` varchar(100) DEFAULT NULL COMMENT '价卡归属', + `currency` varchar(20) DEFAULT NULL COMMENT '报价币种', + `service_note` varchar(4000) DEFAULT NULL COMMENT '服务说明', + `parse_status` varchar(20) NOT NULL DEFAULT 'OK' COMMENT '识别结果:OK/EMPTY/MISSING', + `weight_count` int(11) NOT NULL DEFAULT 0 COMMENT '重量段行数缓存', + `surcharge_count` int(11) NOT NULL DEFAULT 0 COMMENT '附加费行数缓存', + `created_by` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(64) DEFAULT '' COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`product_id`), + KEY `idx_lbl_prod_dict` (`dict_value`), + KEY `idx_lbl_prod_name` (`product_name`), + KEY `idx_lbl_prod_file` (`file_md5`, `sheet_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='打单价卡产品'; + +CREATE TABLE IF NOT EXISTS `prc_label_product_version` ( + `version_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '版本周期ID', + `price_set_id` bigint(20) NOT NULL COMMENT '打单套餐ID,对应 prc_price_set.price_set_id', + `product_id` bigint(20) NOT NULL COMMENT '产品ID,对应 prc_label_product.product_id', + `dict_value` varchar(100) NOT NULL COMMENT '字典 payment_product_type', + `version_no` int(11) NOT NULL DEFAULT 1 COMMENT '套餐级版本号', + `effective_start` date DEFAULT NULL COMMENT '生效开始', + `effective_end` date DEFAULT NULL COMMENT '生效结束,空表示不限', + `file_path` varchar(500) DEFAULT NULL COMMENT '本版源文件OSS地址', + `status` char(1) NOT NULL DEFAULT '0' COMMENT '0待生效 1生效 2已失效', + `created_by` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(64) DEFAULT '' COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`version_id`), + KEY `idx_lbl_ver_set_prod_time` (`price_set_id`, `product_id`, `effective_start`, `effective_end`), + KEY `idx_lbl_ver_set_ver` (`price_set_id`, `version_no`), + KEY `idx_lbl_ver_prod_set` (`product_id`, `price_set_id`), + KEY `idx_lbl_ver_dict_status` (`dict_value`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='打单价卡产品版本周期'; + +CREATE TABLE IF NOT EXISTS `prc_label_weight_band` ( + `band_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '重量段ID', + `product_id` bigint(20) NOT NULL COMMENT '产品ID', + `version_id` bigint(20) NOT NULL COMMENT '版本周期ID', + `fee_type` varchar(30) NOT NULL COMMENT '费用类型,字典 bms_fee_type(FREIGHT/处理费REGISTRATION/偏远REMOTE)', + `country_name` varchar(200) DEFAULT NULL COMMENT '国家', + `country_code` varchar(32) DEFAULT NULL COMMENT '国家二字码', + `zone_code` varchar(64) DEFAULT NULL COMMENT '分区编码', + `zone_name` varchar(200) DEFAULT NULL COMMENT '分区名称', + `postcode_param` varchar(1000) DEFAULT NULL COMMENT '邮编匹配规则,如07或35,38', + `weight_label` varchar(100) DEFAULT NULL COMMENT '重量段原文', + `weight_min` decimal(10,3) DEFAULT NULL COMMENT '重量下限KG', + `weight_max` decimal(10,3) DEFAULT NULL COMMENT '重量上限KG或首重阈值', + `volume_text` varchar(40) DEFAULT NULL COMMENT '体积或尺寸限制原文', + `charge_name` varchar(200) DEFAULT NULL COMMENT '费用项名', + `charge_mode` varchar(30) NOT NULL DEFAULT 'ITEM' COMMENT '计费条件,字典 lbl_charge_mode:ITEM/按票 PER_KG/单价*公斤 EXTRA_KG/首续重 POSTCODE/邮编', + `calc_param` varchar(1000) DEFAULT NULL COMMENT '条件参数,对齐 surcharge.calc_param', + `price` decimal(12,4) DEFAULT NULL COMMENT '金额:一票价/公斤单价/首重价', + `price_unit` varchar(40) DEFAULT NULL COMMENT '金额单位', + `extra_price` decimal(12,4) DEFAULT NULL COMMENT '续重单价,charge_mode=EXTRA_KG 时使用', + `extra_price_unit` varchar(40) DEFAULT NULL COMMENT '续重单位', + `sort_no` int(11) NOT NULL DEFAULT 0 COMMENT '排序', + PRIMARY KEY (`band_id`), + UNIQUE KEY `uk_lbl_band_dim` (`version_id`, `product_id`, `country_code`, `zone_code`, `fee_type`, `weight_min`, `weight_max`), + KEY `idx_lbl_band_version` (`version_id`), + KEY `idx_lbl_band_prod_ver` (`product_id`, `version_id`), + KEY `idx_lbl_band_fee_type` (`fee_type`), + KEY `idx_lbl_band_dim` (`version_id`, `product_id`, `country_code`, `zone_code`, `fee_type`, `weight_min`, `weight_max`), + KEY `idx_lbl_band_charge_mode` (`charge_mode`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='打单价卡重量段明细'; + +CREATE TABLE IF NOT EXISTS `prc_label_surcharge` ( + `surcharge_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '附加费行ID', + `product_id` bigint(20) NOT NULL COMMENT '产品ID', + `version_id` bigint(20) NOT NULL COMMENT '版本周期ID', + `fee_name` varchar(200) NOT NULL COMMENT '费用名称', + `sc_id` bigint(20) DEFAULT NULL COMMENT '附加费配置ID,对应 prc_surcharge.sc_id', + `zone_code` varchar(64) DEFAULT NULL COMMENT '适用分区编码', + `zone_name` varchar(200) DEFAULT NULL COMMENT '适用分区名称', + `charge_text` varchar(500) DEFAULT NULL COMMENT '收费标准原文', + `amount` decimal(12,4) DEFAULT NULL COMMENT '金额或百分数', + `currency` varchar(20) DEFAULT NULL COMMENT '币种', + `price_unit` varchar(40) DEFAULT NULL COMMENT '货币和计费单位', + `fee_desc` varchar(2000) DEFAULT NULL COMMENT '费用说明', + `period_text` varchar(500) DEFAULT NULL COMMENT '时效原文', + `effective_start` date DEFAULT NULL COMMENT '附加费生效开始', + `effective_end` date DEFAULT NULL COMMENT '附加费生效结束', + `charge_timing` varchar(40) DEFAULT NULL COMMENT '收取时点', + `calc_rule` varchar(20) NOT NULL DEFAULT 'MANUAL' COMMENT '计费条件', + `calc_param` varchar(1000) DEFAULT NULL COMMENT '条件参数', + `postcode_param` varchar(1000) DEFAULT NULL COMMENT '偏远邮编规则', + `weight_min` decimal(12,3) DEFAULT NULL COMMENT '适用重量下限KG', + `weight_max` decimal(12,3) DEFAULT NULL COMMENT '适用重量上限KG', + `sort_no` int(11) NOT NULL DEFAULT 0 COMMENT '排序', + PRIMARY KEY (`surcharge_id`), + KEY `idx_lbl_sc_version` (`version_id`), + KEY `idx_lbl_sc_prod_ver` (`product_id`, `version_id`), + KEY `idx_lbl_sc_scid` (`sc_id`), + KEY `idx_lbl_sc_zone` (`zone_code`), + KEY `idx_lbl_sc_period` (`effective_start`, `effective_end`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='打单价卡附加费明细'; + +-- 字典 bms_fee_type:打单重量段用 FREIGHT / REGISTRATION / REMOTE +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT 10, '运费', 'FREIGHT', 'bms_fee_type', '', 'primary', 'N', '0', 'admin', NOW(), '打单重量段运费(与 BASIC_FREIGHT 并存)' +FROM DUAL +WHERE EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='bms_fee_type') + AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='bms_fee_type' AND dict_value='FREIGHT'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT 20, '处理费', 'REGISTRATION', 'bms_fee_type', '', 'success', 'N', '0', 'admin', NOW(), '打单重量段处理费' +FROM DUAL +WHERE EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='bms_fee_type') + AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='bms_fee_type' AND dict_value='REGISTRATION'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT 40, '偏远费', 'REMOTE', 'bms_fee_type', '', 'warning', 'N', '0', 'admin', NOW(), '打单重量段偏远费' +FROM DUAL +WHERE EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='bms_fee_type') + AND NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='bms_fee_type' AND dict_value='REMOTE'); + +-- 字典 lbl_charge_mode:打单重量段计费条件 +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '打单计费条件', 'lbl_charge_mode', '0', 'admin', NOW(), 'prc_label_weight_band.charge_mode' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'lbl_charge_mode'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT v.dict_sort, v.dict_label, v.dict_value, 'lbl_charge_mode', '', v.list_class, v.is_default, '0', 'admin', NOW(), v.remark +FROM ( + SELECT 1 AS dict_sort, '按票' AS dict_label, 'ITEM' AS dict_value, 'primary' AS list_class, 'Y' AS is_default, '一票固定价' AS remark + UNION ALL SELECT 2, '单价*公斤数', 'PER_KG', 'success', 'N', '单价×计费重' + UNION ALL SELECT 3, '首重+续重', 'EXTRA_KG', 'warning', 'N', 'price+续重单价×超出公斤' + UNION ALL SELECT 4, '邮编', 'POSTCODE', 'info', 'N', 'postcode_param 命中后按票收' +) v +WHERE NOT EXISTS ( + SELECT 1 FROM sys_dict_data d WHERE d.dict_type = 'lbl_charge_mode' AND d.dict_value = v.dict_value +); diff --git a/bms-front/src/api/price/label.js b/bms-front/src/api/price/label.js index 07759485..ee5e5e14 100644 --- a/bms-front/src/api/price/label.js +++ b/bms-front/src/api/price/label.js @@ -69,15 +69,76 @@ export function labelParseProgress(progressId) { }) } +/** 提交异步保存,返回 { taskId } */ export function saveLabelImport(data) { return request({ url: '/price/label/importSave', method: 'post', data, - timeout: 10 * 60 * 1000 + headers: { repeatSubmit: false }, + timeout: 60 * 1000 }) } +export function labelSaveProgress(taskId) { + return request({ + url: '/price/label/importSave/task/' + taskId, + method: 'get', + headers: { repeatSubmit: false } + }) +} + +function pickLabelSaveTaskId(res) { + if (!res) return '' + const d = res.data + if (d && typeof d === 'object' && d.taskId) return String(d.taskId) + if (typeof d === 'string' && d.trim()) return d.trim() + if (res.taskId) return String(res.taskId) + if (typeof res.msg === 'string') { + const m = res.msg.trim() + if (/^[a-f0-9]{32}$/i.test(m)) return m + if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(m)) { + return m.replace(/-/g, '') + } + } + return '' +} + +/** 提交并轮询直到成功/失败;onProgress({ percent, message, status }) */ +export async function saveLabelImportAndWait(data, onProgress) { + const res = await saveLabelImport(data) + const taskId = pickLabelSaveTaskId(res) + if (!taskId) { + throw new Error('未返回保存任务编号') + } + const started = Date.now() + const timeoutMs = 10 * 60 * 1000 + while (Date.now() - started < timeoutMs) { + const prog = await labelSaveProgress(taskId) + const task = (prog && prog.data) || prog || {} + if (typeof onProgress === 'function') { + onProgress({ + percent: Number(task.percent) || 0, + message: task.message || '', + status: task.status || 'PENDING', + priceSetId: task.priceSetId, + error: task.error + }) + } + if (task.status === 'SUCCESS') { + return { + message: task.message || '保存成功', + priceSetId: task.priceSetId + } + } + if (task.status === 'FAIL') { + throw new Error(task.error || task.message || '保存失败') + } + await new Promise(r => setTimeout(r, 400)) + } + throw new Error('保存超时,请稍后在列表中查看') +} + export function listLabelVersions(priceSetId) { return request({ url: '/price/label/' + priceSetId + '/versions', @@ -147,11 +208,11 @@ export function delLabelCustomer(ids) { }) } -export function getLabelProduct(productId, termId) { +export function getLabelProduct(productId, versionId) { return request({ url: '/price/label/product/' + productId, method: 'get', - params: termId ? { termId } : undefined + params: versionId ? { versionId } : undefined }) } @@ -166,6 +227,26 @@ export function updateLabelProduct(data) { }) } +/** 仅保存当前重量段行 */ +export function updateLabelWeightBand(data) { + return request({ + url: '/price/label/product/band', + method: 'put', + data, + headers: { repeatSubmit: false } + }) +} + +/** 仅保存当前附加费行 */ +export function updateLabelSurcharge(data) { + return request({ + url: '/price/label/product/surcharge', + method: 'put', + data, + headers: { repeatSubmit: false } + }) +} + export function removeLabelProducts(priceSetId, productIds) { return request({ url: '/price/label/' + priceSetId + '/products/' + productIds, diff --git a/bms-front/src/views/price/label/components/LabelConfigDetail.vue b/bms-front/src/views/price/label/components/LabelConfigDetail.vue new file mode 100644 index 00000000..394bc74a --- /dev/null +++ b/bms-front/src/views/price/label/components/LabelConfigDetail.vue @@ -0,0 +1,906 @@ + + + + + diff --git a/bms-front/src/views/price/label/components/LabelImportDialog.vue b/bms-front/src/views/price/label/components/LabelImportDialog.vue index bd4e6fa0..53c2da4a 100644 --- a/bms-front/src/views/price/label/components/LabelImportDialog.vue +++ b/bms-front/src/views/price/label/components/LabelImportDialog.vue @@ -13,9 +13,13 @@ :price-set-id="priceSetId" @parsed="onParsed" /> +
+ +
{{ saveMessage }}
+
@@ -38,6 +42,9 @@ const { proxy } = getCurrentInstance() const visible = ref(false) const saving = ref(false) +const savePercent = ref(0) +const saveMessage = ref('') +const saveStatus = ref(undefined) const panelRef = ref() const autoFilled = ref(false) @@ -50,6 +57,9 @@ function open() { function onClosed() { panelRef.value?.reset() saving.value = false + savePercent.value = 0 + saveMessage.value = '' + saveStatus.value = undefined autoFilled.value = false } @@ -76,12 +86,30 @@ async function save() { return } saving.value = true + savePercent.value = 1 + saveStatus.value = undefined + saveMessage.value = '正在提交保存任务…' try { - const result = await panelRef.value.submit({ priceSetId: props.priceSetId }) + const result = await panelRef.value.submit({ + priceSetId: props.priceSetId, + onProgress: prog => { + const pct = Math.min(99, Number(prog.percent) || 0) + if (pct >= savePercent.value) savePercent.value = pct + if (prog.message) saveMessage.value = prog.message + if (prog.status === 'FAIL') saveStatus.value = 'exception' + } + }) + savePercent.value = 100 + saveStatus.value = 'success' + saveMessage.value = result.message || '保存成功' visible.value = false ElMessageBox.close() proxy.$modal.msgSuccess(result.message || '保存成功') emit('saved', { priceSetId: result.priceSetId || props.priceSetId }) + } catch (e) { + saveStatus.value = 'exception' + saveMessage.value = e?.message || '保存失败' + proxy.$modal.msgError(e?.message || '保存失败') } finally { saving.value = false } @@ -89,3 +117,8 @@ async function save() { defineExpose({ open }) + + diff --git a/bms-front/src/views/price/label/components/LabelImportPanel.vue b/bms-front/src/views/price/label/components/LabelImportPanel.vue index 0af0a299..5639ee5a 100644 --- a/bms-front/src/views/price/label/components/LabelImportPanel.vue +++ b/bms-front/src/views/price/label/components/LabelImportPanel.vue @@ -5,11 +5,6 @@ 应收 AR 打单套餐固定应收,不显示建价选项,直接 Excel 导入 - - - - - {{ autoFillOnParse ? '解析成功后自动回填到创建页产品价卡列表;打单保存与应付独立,弹窗内不展示表格。' - : '模板以威狮 B2C 末端派送价卡为准。选一个产品就只读对应工作表。' }} + : '模板以威狮 B2C 末端派送价卡为准;按工作表名称匹配字典产品。' }} @@ -47,7 +42,7 @@ />
- 仅本打单套餐维度;与应付公共价卡互不影响。开启后同产品编号不变,旧价留在历史 term。 + 仅本打单套餐维度;与应付公共价卡互不影响。开启后同产品编号不变,旧价留在历史版本。
- + - - - - - - -