重构打单套餐相关逻辑,更新服务接口,优化保存和查询功能

This commit is contained in:
wuweihua
2026-11-13 11:27:58 +08:00
parent 6675808dfc
commit a024b45940
37 changed files with 11647 additions and 160 deletions
@@ -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";
}
@@ -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;
}
@@ -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";
}
@@ -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<LblProduct> products = new ArrayList<>();
private List<String> skippedSheets = new ArrayList<>();
}
@@ -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<LblWeightBand> weightBands;
private List<LblSurcharge> surcharges;
/** 兼容旧前端 termId,等同 versionId */
public Long getTermId()
{
return versionId;
}
public void setTermId(Long termId)
{
this.versionId = termId;
}
private List<LblExtraLine> extraLines;
/** 邮编→分区(保存时同步到 prc_zone,不落产品子表) */
private Map<String, List<String>> zonePostcodes;
}
@@ -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;
}
@@ -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<LblProduct> products = new ArrayList<>();
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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<LblWeightBand> 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<LblWeightBand> splitByFeeType(List<LblWeightBand> source)
{
List<LblWeightBand> out = new ArrayList<LblWeightBand>();
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<LblWeightBand> mergeFirstExtra(List<LblWeightBand> source)
{
Map<String, LblWeightBand> firsts = new LinkedHashMap<String, LblWeightBand>();
List<LblWeightBand> extras = new ArrayList<LblWeightBand>();
List<LblWeightBand> rest = new ArrayList<LblWeightBand>();
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<LblWeightBand> out = new ArrayList<LblWeightBand>();
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<LblWeightBand> 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();
}
}
@@ -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<Integer> 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<PayCostLine>());
result.setSkipped(new ArrayList<String>());
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<LblWeightBand> all = product.getWeightBands() == null ? List.of() : product.getWeightBands();
Set<String> 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<LblWeightBand> all, String feeType,
BigDecimal weight, BigDecimal length, BigDecimal width, BigDecimal height, String postcode, String zoneName,
Integer orderStatus, String currency, boolean required)
{
List<LblWeightBand> 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<LblWeightBand> filterCandidates(List<LblWeightBand> all, String feeType, BigDecimal weight,
String zoneName)
{
List<LblWeightBand> out = new ArrayList<LblWeightBand>();
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<LblWeightBand> candidates, BigDecimal weight, String postcode,
String zoneName, BigDecimal length, BigDecimal width, BigDecimal height, Integer orderStatus)
{
List<LblWeightBand> ok = new ArrayList<LblWeightBand>();
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<String> zoneNames(List<LblWeightBand> bands)
{
Set<String> zones = new LinkedHashSet<String>();
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<LblWeightBand> bands, BigDecimal weight, String zoneName)
{
String name = nz(product.getProductName());
List<LblWeightBand> 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();
}
}
@@ -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<PayWeightBand> bands = new ArrayList<PayWeightBand>();
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<PayWeightBand>());
}
return p;
}
private static List<PaySurcharge> toSurcharges(LblProduct src)
{
List<PaySurcharge> fees = new ArrayList<PaySurcharge>();
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();
}
}
@@ -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<String> 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<String, Object> 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<Boolean> 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<java.util.Map<String, Object>> details, String feeName, java.util.Date bizDate)
{
if (details == null || details.isEmpty())
{
return true;
}
String name = feeName == null ? "" : feeName.trim();
List<java.util.Map<String, Object>> named = new ArrayList<>();
List<java.util.Map<String, Object>> shared = new ArrayList<>();
for (java.util.Map<String, Object> 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<java.util.Map<String, Object>> 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<String, Object> detail : mine)
{
if (!hasPeriod(detail))
{
return true;
}
if (day != null && inPeriod(day, detail))
{
return true;
}
}
return false;
}
private static void addBound(List<Boolean> 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<String, Object> detail)
{
return text(detail.get("periodStart")) != null || text(detail.get("periodEnd")) != null;
}
private static boolean inPeriod(java.time.LocalDate day, java.util.Map<String, Object> 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<String> 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<String> 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;
}
}
}
@@ -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;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<LblProduct> 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<String, List<String>> 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<String, List<String>> parseSheet(Sheet sheet)
{
Map<String, List<String>> poste = parsePosteItaliane(sheet);
if (!poste.isEmpty())
{
return poste;
}
Map<String, List<String>> ctt = parseCtt(sheet);
if (!ctt.isEmpty())
{
return ctt;
}
Map<String, List<String>> coli = parseColissimo(sheet);
if (!coli.isEmpty())
{
return coli;
}
return parseTwoCol(sheet);
}
private static Map<String, List<String>> parsePosteItaliane(Sheet sheet)
{
Map<String, List<String>> map = new LinkedHashMap<String, List<String>>();
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<String, List<String>> map, String zone, String raw, int pad)
{
String postcode = normalizePostcode(raw, pad);
if (postcode.isEmpty())
{
return;
}
List<String> list = map.computeIfAbsent(zone, k -> new ArrayList<String>());
if (!list.contains(postcode))
{
list.add(postcode);
}
}
private static Map<String, List<String>> parseCtt(Sheet sheet)
{
Map<String, List<String>> map = new LinkedHashMap<String, List<String>>();
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<String> list = map.computeIfAbsent(zone, k -> new ArrayList<String>());
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<String, List<String>> parseColissimo(Sheet sheet)
{
Map<String, List<String>> map = new LinkedHashMap<String, List<String>>();
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<String> list = map.computeIfAbsent(zone, k -> new ArrayList<String>());
if (!list.contains(postcode))
{
list.add(postcode);
}
}
return map;
}
private static Map<String, List<String>> parseTwoCol(Sheet sheet)
{
Map<String, List<String>> map = new LinkedHashMap<String, List<String>>();
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<String> list = map.computeIfAbsent(zone, k -> new ArrayList<String>());
if (!list.contains(postcode))
{
list.add(postcode);
}
}
return map;
}
private static void attachImile(Sheet sheet, String sheetName, List<LblProduct> 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<String, List<String>> itMap = new LinkedHashMap<String, List<String>>();
Map<String, List<String>> deMap = new LinkedHashMap<String, List<String>>();
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<String> list = itMap.computeIfAbsent(zone, k -> new ArrayList<String>());
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<String> list = deMap.computeIfAbsent("Phase 1", k -> new ArrayList<String>());
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<LblProduct> 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<LblProduct> 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<String, List<String>> incoming)
{
Map<String, List<String>> current = product.getZonePostcodes();
if (current == null)
{
current = new LinkedHashMap<String, List<String>>();
product.setZonePostcodes(current);
}
for (Map.Entry<String, List<String>> e : incoming.entrySet())
{
List<String> list = current.computeIfAbsent(e.getKey(), k -> new ArrayList<String>());
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;
}
}
@@ -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<Long> 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<LblProductVersion> selectVersionsBySet(@Param("priceSetId") Long priceSetId);
List<LblProductVersion> selectVersionsBySetAndNo(@Param("priceSetId") Long priceSetId,
@Param("versionNo") Integer versionNo);
LblProductVersion selectVersionById(@Param("versionId") Long versionId);
Integer selectMaxVersionNo(@Param("priceSetId") Long priceSetId);
List<LabelPackageVersion> 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<LblWeightBand> 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<LblSurcharge> selectSurchargesByVersion(@Param("versionId") Long versionId);
List<Long> selectVersionIdsBySetAndProducts(@Param("priceSetId") Long priceSetId, @Param("productIds") Long[] productIds);
int deleteVersionsBySetAndProducts(@Param("priceSetId") Long priceSetId, @Param("productIds") Long[] productIds);
List<LblProduct> selectProductsBySet(@Param("priceSetId") Long priceSetId);
List<LblProduct> 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);
}
@@ -122,8 +122,8 @@ public final class LabelArCostCalculator
+ "),邮编没有对上其中的地区,无法确定运费");
return result;
}
PayWeightBand band = matchBand(bands, weight);
if (band == null)
List<PayWeightBand> 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<PayRemoteBook.Hit> remoteHits, PayOrderCostResult result)
{
@@ -694,13 +719,25 @@ public final class LabelArCostCalculator
private static PayWeightBand matchBand(List<PayWeightBand> bands, BigDecimal weight)
{
List<PayWeightBand> matched = matchBands(bands, weight);
return matched.isEmpty() ? null : matched.get(0);
}
/** 同一重量段可能同时有运费与处理费两行(UNIT_PLUS_HANDLE 拆分后)。 */
private static List<PayWeightBand> matchBands(List<PayWeightBand> bands, BigDecimal weight)
{
List<PayWeightBand> 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<PayWeightBand> bands, BigDecimal weight)
@@ -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<LabelPackageVersion> selectVersions(Long priceSetId);
String activateVersion(Long priceSetId, Integer versionNo, Date effectiveTime);
int deleteVersion(Long priceSetId, Integer versionNo);
List<LblProduct> selectProducts(Long priceSetId);
List<LblProduct> 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<ChannelProductMap> listProductChannelBinds(Long productId, String payType);
String replaceProductChannelBinds(Long productId, String payType, List<String> channelIds);
LblProduct selectPriceForDate(Long priceSetId, String channelId, Date bizDate);
}
@@ -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<String, List<String>> zonePostcodes);
}
@@ -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<String, ParseSlot> parseProgress = new ConcurrentHashMap<String, ParseSlot>();
private final Map<String, UploadedSource> uploadedSources = new ConcurrentHashMap<String, UploadedSource>();
@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<LblProduct> products = command.getProducts();
int total = products.size();
Map<String, Map<String, List<String>>> zoneSyncByCountry = new LinkedHashMap<String, Map<String, List<String>>>();
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<String, Map<String, List<String>>> e : zoneSyncByCountry.entrySet())
{
prcZoneService.syncCountryZones(e.getKey(), e.getValue());
}
if (editPendingVersion && keepStart != null)
{
List<LblProductVersion> 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<LabelPackageVersion> selectVersions(Long priceSetId)
{
if (priceSetId == null)
{
return Collections.emptyList();
}
List<LabelPackageVersion> 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<Long> 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<LblProduct> selectProducts(Long priceSetId)
{
assertLabelSet(priceSetId);
List<LblProduct> list = lblProductMapper.selectProductsBySet(priceSetId);
return list == null ? Collections.emptyList() : list;
}
@Override
public List<LblProduct> selectProductsByVersion(Long priceSetId, Integer versionNo)
{
assertLabelSet(priceSetId);
List<LblProduct> 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<Long> 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<ChannelProductMap> listProductChannelBinds(Long productId, String payType)
{
String type = isBlank(payType) ? PAY_TYPE_AR : payType.trim();
List<ChannelProductMap> list = channelProductMapMapper.selectByProductIdAndType(productId, type);
return list == null ? new ArrayList<ChannelProductMap>() : list;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String replaceProductChannelBinds(Long productId, String payType, List<String> channelIds)
{
if (productId == null)
{
throw new ServiceException("产品不能为空");
}
String type = isBlank(payType) ? PAY_TYPE_AR : payType.trim();
List<String> wanted = channelIds == null ? new ArrayList<String>() : channelIds.stream()
.filter(s -> s != null && !s.trim().isEmpty()).map(String::trim).distinct().collect(Collectors.toList());
List<ChannelProductMap> 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<LblProductVersion> 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<LblProductVersion> 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<LblProductVersion> 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<String, Map<String, List<String>>> 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<String, List<String>> bucket = byCountry.computeIfAbsent(country, k -> new LinkedHashMap<String, List<String>>());
for (Map.Entry<String, List<String>> e : product.getZonePostcodes().entrySet())
{
if (e.getKey() == null || e.getValue() == null)
{
continue;
}
List<String> list = bucket.computeIfAbsent(e.getKey(), k -> new ArrayList<String>());
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<String> loadDictValues()
{
List<SysDictData> 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<Map.Entry<String, UploadedSource>> it = uploadedSources.entrySet().iterator();
while (it.hasNext())
{
Map.Entry<String, UploadedSource> 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;
}
}
@@ -276,6 +276,110 @@ public class PrcZoneServiceImpl implements IPrcZoneService
return msg;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String syncCountryZones(String countryCode2, Map<String, List<String>> 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<PrcZone> existing = zoneMapper.selectPrcZoneListByRegionId(region.getRegionId());
Map<String, PrcZone> byCode = new HashMap<String, PrcZone>();
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<Map.Entry<String, List<String>>> entries = new ArrayList<>(zonePostcodes.entrySet());
entries.sort(Comparator.comparingInt(e -> extractZoneSort(e.getKey())));
for (Map.Entry<String, List<String>> 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<String> uniquePostcodes = new LinkedHashSet<String>();
if (entry.getValue() != null)
{
uniquePostcodes.addAll(entry.getValue());
}
List<PrcZoneDetail> 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<String, List<String>> zonePostcodes)
{
if (StringUtils.isEmpty(region.getCountryCode2()))
@@ -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<String> 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<String> countryFreight = labelFreightZonesForCountry(product, cc);
if (countryFreight.size() == 1)
{
return countryFreight.get(0);
}
List<String> 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<String> labelBandZoneNames(LblProduct product)
{
LinkedHashSet<String> names = new LinkedHashSet<String>();
if (product == null || product.getWeightBands() == null)
{
return new ArrayList<String>();
}
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<String>(names);
}
/** 该国家下运费(FREIGHT)分区列名;偏远行不算入。 */
private static List<String> labelFreightZonesForCountry(LblProduct product, String countryCode)
{
LinkedHashSet<String> names = new LinkedHashSet<String>();
if (product == null || product.getWeightBands() == null)
{
return new ArrayList<String>();
}
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<String>(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。
@@ -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<String, Slot> tasks = new ConcurrentHashMap<String, Slot>();
@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<Map.Entry<String, Slot>> it = tasks.entrySet().iterator();
while (it.hasNext())
{
Map.Entry<String, Slot> 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;
}
}
}
@@ -0,0 +1,320 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bms.price.mapper.LblProductMapper">
<insert id="insertProduct" useGeneratedKeys="true" keyProperty="productId">
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}
)
</insert>
<update id="updateProduct">
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}
</update>
<select id="selectProductById" resultType="com.bms.price.domain.LblProduct">
select product_id as productId, product_name as productName, dict_value as dictValue, country_scope as countryScope,
charge_type as chargeType, extra_per_kg as extraPerKg, service_type as serviceType, classification,
catalog_remark as catalogRemark, update_content as updateContent, weight_limit as weightLimit,
sheet_name as sheetName, related_sheet as relatedSheet, source_file as sourceFile, file_path as filePath,
file_md5 as fileMd5, supplier_name as supplierName, currency, service_note as serviceNote,
parse_status as parseStatus, weight_count as weightCount, surcharge_count as surchargeCount,
created_by as createBy, create_time as createTime, updated_by as updateBy, update_time as updateTime, remark
from prc_label_product where product_id = #{productId}
</select>
<select id="selectProductByDict" resultType="com.bms.price.domain.LblProduct">
select product_id as productId, product_name as productName, dict_value as dictValue
from prc_label_product where dict_value = #{dictValue} order by product_id desc limit 1
</select>
<select id="selectProductIdBySetAndDict" resultType="long">
select v.product_id
from prc_label_product_version v
where v.price_set_id = #{priceSetId} and v.dict_value = #{dictValue}
order by v.version_id desc limit 1
</select>
<insert id="insertVersion" useGeneratedKeys="true" keyProperty="versionId">
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}
)
</insert>
<update id="updateVersion">
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>
<update id="closePendingBySetProduct">
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'
</update>
<select id="selectVersionIdsBySetAndNo" resultType="long">
select version_id from prc_label_product_version
where price_set_id = #{priceSetId} and version_no = #{versionNo}
</select>
<delete id="deletePendingVersionsBySetAndNo">
delete from prc_label_product_version
where price_set_id = #{priceSetId} and version_no = #{versionNo} and status = '0'
</delete>
<delete id="deleteAllVersionsBySetAndNo">
delete from prc_label_product_version
where price_set_id = #{priceSetId} and version_no = #{versionNo}
</delete>
<select id="selectVersionsBySet" resultType="com.bms.price.domain.LblProductVersion">
select v.version_id as versionId, v.price_set_id as priceSetId, v.product_id as productId, v.dict_value as dictValue,
v.version_no as versionNo, v.effective_start as effectiveStart, v.effective_end as effectiveEnd, v.file_path as filePath,
v.status, p.product_name as productName, p.source_file as sourceFile, p.weight_count as weightCount,
p.surcharge_count as surchargeCount, p.currency
from prc_label_product_version v
join prc_label_product p on p.product_id = v.product_id
where v.price_set_id = #{priceSetId} and v.status in ('0', '1')
order by p.product_name, v.version_id desc
</select>
<select id="selectVersionsBySetAndNo" resultType="com.bms.price.domain.LblProductVersion">
select v.version_id as versionId, v.price_set_id as priceSetId, v.product_id as productId, v.dict_value as dictValue,
v.version_no as versionNo, v.effective_start as effectiveStart, v.effective_end as effectiveEnd, v.file_path as filePath,
v.status, p.product_name as productName, p.source_file as sourceFile, p.weight_count as weightCount,
p.surcharge_count as surchargeCount, p.currency
from prc_label_product_version v
join prc_label_product p on p.product_id = v.product_id
where v.price_set_id = #{priceSetId} and v.version_no = #{versionNo}
order by p.product_name, v.version_id desc
</select>
<select id="selectVersionById" resultType="com.bms.price.domain.LblProductVersion">
select version_id as versionId, price_set_id as priceSetId, product_id as productId, dict_value as dictValue,
version_no as versionNo, effective_start as effectiveStart, effective_end as effectiveEnd, file_path as filePath, status
from prc_label_product_version where version_id = #{versionId}
</select>
<select id="selectMaxVersionNo" resultType="int">
select ifnull(max(version_no), 0) from prc_label_product_version where price_set_id = #{priceSetId}
</select>
<select id="selectLabelVersionGroups" resultType="com.bms.price.domain.LabelPackageVersion">
select price_set_id as priceSetId,
version_no as versionNo,
min(effective_start) as effectiveTime,
max(effective_end) as expireTime,
count(1) as productCount,
sum(case when status = '1' then 1 else 0 end) as activeCount,
sum(case when status = '0' then 1 else 0 end) as pendingCount,
min(create_time) as createTime
from prc_label_product_version
where price_set_id = #{priceSetId}
group by price_set_id, version_no
order by version_no desc
</select>
<insert id="insertWeightBand" useGeneratedKeys="true" keyProperty="bandId">
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)
)
</insert>
<update id="updateWeightBand">
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}
</update>
<select id="selectWeightBandById" resultType="com.bms.price.domain.LblWeightBand">
select band_id as bandId, product_id as productId, version_id as versionId, fee_type as feeType,
country_name as countryName, country_code as countryCode, zone_code as zoneCode, zone_name as zoneName,
postcode_param as postcodeParam, weight_label as weightLabel, weight_min as weightMin, weight_max as weightMax,
volume_text as volumeText, charge_name as chargeName, charge_mode as chargeMode, calc_param as calcParam,
price, price_unit as priceUnit, extra_price as extraPrice, extra_price_unit as extraPriceUnit, sort_no as sortNo
from prc_label_weight_band where band_id = #{bandId}
</select>
<delete id="deleteWeightBandsByVersion">
delete from prc_label_weight_band where version_id = #{versionId}
</delete>
<select id="selectWeightBandsByVersion" resultType="com.bms.price.domain.LblWeightBand">
select band_id as bandId, product_id as productId, version_id as versionId, fee_type as feeType,
country_name as countryName, country_code as countryCode, zone_code as zoneCode, zone_name as zoneName,
postcode_param as postcodeParam, weight_label as weightLabel, weight_min as weightMin, weight_max as weightMax,
volume_text as volumeText, charge_name as chargeName, charge_mode as chargeMode, calc_param as calcParam,
price, price_unit as priceUnit, extra_price as extraPrice, extra_price_unit as extraPriceUnit, sort_no as sortNo
from prc_label_weight_band where version_id = #{versionId} order by sort_no, band_id
</select>
<insert id="insertSurcharge" useGeneratedKeys="true" keyProperty="surchargeId">
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)
)
</insert>
<update id="updateSurcharge">
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}
</update>
<select id="selectSurchargeById" resultType="com.bms.price.domain.LblSurcharge">
select surcharge_id as surchargeId, product_id as productId, version_id as versionId, fee_name as feeName,
sc_id as scId, zone_code as zoneCode, zone_name as zoneName, charge_text as chargeText, amount, currency,
price_unit as priceUnit, fee_desc as feeDesc, period_text as periodText,
effective_start as effectiveStart, effective_end as effectiveEnd, charge_timing as chargeTiming,
calc_rule as calcRule, calc_param as calcParam, postcode_param as postcodeParam,
weight_min as weightMin, weight_max as weightMax, sort_no as sortNo
from prc_label_surcharge where surcharge_id = #{surchargeId}
</select>
<delete id="deleteSurchargesByVersion">
delete from prc_label_surcharge where version_id = #{versionId}
</delete>
<select id="selectSurchargesByVersion" resultType="com.bms.price.domain.LblSurcharge">
select surcharge_id as surchargeId, product_id as productId, version_id as versionId, fee_name as feeName, sc_id as scId,
zone_code as zoneCode, zone_name as zoneName, charge_text as chargeText, amount, currency, price_unit as priceUnit,
fee_desc as feeDesc, period_text as periodText, effective_start as effectiveStart, effective_end as effectiveEnd,
charge_timing as chargeTiming, calc_rule as calcRule, calc_param as calcParam, postcode_param as postcodeParam,
weight_min as weightMin, weight_max as weightMax, sort_no as sortNo
from prc_label_surcharge where version_id = #{versionId} order by sort_no, surcharge_id
</select>
<select id="selectVersionIdsBySetAndProducts" resultType="long">
select version_id from prc_label_product_version
where price_set_id = #{priceSetId}
and product_id in
<foreach collection="productIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
<delete id="deleteVersionsBySetAndProducts">
delete from prc_label_product_version
where price_set_id = #{priceSetId}
and product_id in
<foreach collection="productIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</delete>
<select id="selectProductsBySet" resultType="com.bms.price.domain.LblProduct">
select p.product_id as productId, p.product_name as productName, p.dict_value as dictValue,
v.version_id as versionId, v.version_no as versionNo, v.status as versionStatus,
v.effective_start as versionStart, v.effective_end as versionEnd,
p.country_scope as countryScope, p.charge_type as chargeType, p.currency, p.sheet_name as sheetName,
p.source_file as sourceFile, p.file_path as filePath, p.weight_count as weightCount, p.surcharge_count as surchargeCount
from prc_label_product_version v
join prc_label_product p on p.product_id = v.product_id
where v.price_set_id = #{priceSetId} and v.status in ('0', '1')
order by p.product_name, v.version_id desc
</select>
<select id="selectProductsBySetAndVersion" resultType="com.bms.price.domain.LblProduct">
select p.product_id as productId, p.product_name as productName, p.dict_value as dictValue,
v.version_id as versionId, v.version_no as versionNo, v.status as versionStatus,
v.effective_start as versionStart, v.effective_end as versionEnd,
p.country_scope as countryScope, p.charge_type as chargeType, p.currency, p.sheet_name as sheetName,
p.source_file as sourceFile, p.file_path as filePath, p.weight_count as weightCount, p.surcharge_count as surchargeCount
from prc_label_product_version v
join prc_label_product p on p.product_id = v.product_id
where v.price_set_id = #{priceSetId} and v.version_no = #{versionNo}
order by p.product_name, v.version_id desc
</select>
<update id="activateVersionRows">
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>
<update id="expireOtherActive">
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 &lt;&gt; #{versionNo}
and status = '1'
</update>
</mapper>