新增附加费计费标识,优化附加费管理逻辑

This commit is contained in:
wuweihua
2026-11-12 14:43:42 +08:00
parent cf336f7a67
commit 90147ed4e3
10 changed files with 1339 additions and 39 deletions
@@ -73,4 +73,10 @@ public class PaySurcharge
private BigDecimal weightMax;
private Integer sortNo;
/**
* 是否计费并落库:false 不写入 pay_surcharge;null/缺省视为要计费(兼容应付全量保存)。
* 仅作为前端传参,不落库字段。
*/
private Boolean needBill;
}
@@ -0,0 +1,830 @@
package com.bms.price.pay;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
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.PayCostLine;
import com.bms.price.domain.PayOrderCostResult;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PaySurcharge;
import com.bms.price.domain.PayWeightBand;
/**
* 打单套餐应收计费(与应付 {@link PayCostCalculator} 独立)。
* 按打单 AR 价卡的重量段、附加费计算;后续仅改本类,不影响应付计费。
* 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。
*/
public final class LabelArCostCalculator
{
private static final ZoneId SHANGHAI = ZoneId.of("GMT+8");
private static final Set<Integer> REFUND_STATUS_SET = Set.of(9, 10);
private LabelArCostCalculator()
{
}
/**
* 按重量、尺寸和邮编计算一票打单应收。不指定地区,也不传入偏远命中结果。
*
* @param product 当前价卡版本,含重量段和附加费
* @param weight 计费重,公斤
* @param length 长,厘米
* @param width 宽,厘米
* @param height 高,厘米
* @param postcode 收件邮编
* @param bizDate 取价日,用来判断附加费是否在有效期内
* @param orderStatus 订单状态,退件时才收退件类附加费
* @return 费用明细和算不了的附加费说明
*/
public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width,
BigDecimal height, String postcode, Date bizDate, Integer orderStatus)
{
return calculate(product, weight, length, width, height, postcode, null, bizDate, orderStatus);
}
/**
* 在已经选定价卡地区时计算打单应收。偏远仍按价卡上的邮编条件判断。
*
* @param product 当前价卡版本
* @param weight 计费重,公斤
* @param length 长,厘米
* @param width 宽,厘米
* @param height 高,厘米
* @param postcode 收件邮编
* @param zoneName 邮编对上的价卡地区,空表示不按地区过滤
* @param bizDate 取价日
* @param orderStatus 订单状态
* @return 费用明细和算不了的附加费说明
*/
public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width,
BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus)
{
return calculate(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, null);
}
/**
* 计算一票打单应收。remoteHits 为空列表表示产品有偏远邮编但这次没命中;为 null 表示没有这份配置,仍按价卡上的邮编条件。
*
* @param product 当前价卡版本
* @param weight 计费重,公斤
* @param length 长,厘米
* @param width 宽,厘米
* @param height 高,厘米
* @param postcode 收件邮编
* @param zoneName 价卡地区
* @param bizDate 取价日
* @param orderStatus 订单状态
* @param remoteHits 偏远邮编命中结果
* @return 费用明细和算不了的附加费说明
*/
public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width,
BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus, List<PayRemoteBook.Hit> remoteHits)
{
PayOrderCostResult result = new PayOrderCostResult();
result.setFees(new ArrayList<>());
result.setSkipped(new ArrayList<>());
if (product == null)
{
result.setMessage(PaymentMesEnum.BMS_PAY_NO_PRODUCT.getCnMes());
return result;
}
result.setProductId(product.getProductId());
result.setProductName(product.getProductName());
if (weight == null || weight.compareTo(BigDecimal.ZERO) <= 0)
{
result.setMessage(PaymentMesEnum.BMS_PAY_NO_WEIGHT.getCnMes());
return result;
}
List<PayWeightBand> usable = usableBands(product.getWeightBands());
List<PayWeightBand> bands = forZone(usable, zoneName);
if (zoneName != null && !zoneName.isBlank() && bands.isEmpty() && hasZoneFee(product, zoneName))
{
bands = deliveryBands(usable, product);
}
if (zoneName != null && !zoneName.isBlank() && bands.isEmpty())
{
result.setMessage("邮编对应的地区「" + zoneName + "」不在价卡重量段里");
return result;
}
Set<String> zones = zones(bands);
if (zones.size() > 1)
{
result.setMessage("产品「" + product.getProductName() + "」有多个地区(" + String.join("、", zones)
+ "),邮编没有对上其中的地区,无法确定运费");
return result;
}
PayWeightBand band = matchBand(bands, weight);
if (band == null)
{
PayWeightBand top = highestBand(bands);
if (top == null || weight.compareTo(top.getWeightMax()) <= 0
|| !hasExtraKg(product, zoneName, top.getWeightMax(), top.getSectionName()))
{
result.setMessage(outOfRange(product, bands, weight));
return result;
}
band = top;
}
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())
{
freightRemark = freightRemark + ",地区 " + zoneName.replace('\n', ' ');
}
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,
band.getSectionName(), remoteHits, result);
BigDecimal total = BigDecimal.ZERO;
for (PayCostLine fee : result.getFees())
{
total = total.add(fee.getAmount());
}
result.setPayTotal(total.setScale(2, RoundingMode.HALF_UP));
result.setMessage(summary(result));
return result;
}
/** 邮编落到偏远分区,而价卡重量段只有大陆派送费时,运费用大陆档。 */
private static boolean hasZoneFee(PayProduct product, String zoneName)
{
if (product.getSurcharges() == null)
{
return false;
}
for (PaySurcharge fee : product.getSurcharges())
{
if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && PayZoneMatcher.sameZone(fee.getZoneName(), zoneName))
{
return true;
}
}
return false;
}
private static List<PayWeightBand> deliveryBands(List<PayWeightBand> bands, PayProduct product)
{
List<String> remoteZones = new ArrayList<>();
if (product.getSurcharges() != null)
{
for (PaySurcharge fee : product.getSurcharges())
{
if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && fee.getZoneName() != null && !fee.getZoneName().isBlank())
{
remoteZones.add(fee.getZoneName());
}
}
}
List<PayWeightBand> kept = new ArrayList<>();
for (PayWeightBand band : bands)
{
boolean remote = false;
for (String zone : remoteZones)
{
if (PayZoneMatcher.sameZone(band.getZoneName(), zone))
{
remote = true;
break;
}
}
if (!remote)
{
kept.add(band);
}
}
return kept;
}
/** 首重对更重的包裹仍然收取,超出部分由续重另计。 */
private static boolean weightAtLeast(PaySurcharge fee, BigDecimal weight)
{
if (fee.getWeightMin() == null || weight == null)
{
return true;
}
return weight.compareTo(fee.getWeightMin()) >= 0;
}
private static boolean weightIn(PaySurcharge fee, BigDecimal weight)
{
if (weight == null || (fee.getWeightMin() == null && fee.getWeightMax() == null))
{
return false;
}
if (fee.getWeightMin() != null && weight.compareTo(fee.getWeightMin()) < 0)
{
return false;
}
return fee.getWeightMax() == null || weight.compareTo(fee.getWeightMax()) <= 0;
}
private static List<PayWeightBand> forZone(List<PayWeightBand> bands, String zoneName)
{
if (zoneName == null || zoneName.isBlank())
{
return bands;
}
List<PayWeightBand> kept = new ArrayList<>();
for (PayWeightBand band : bands)
{
if (PayZoneMatcher.sameZone(band.getZoneName(), zoneName))
{
kept.add(band);
}
}
return kept;
}
/**
* 添加相关附加费,根据计费规则去判断
* @param product
* @param weight
* @param length
* @param width
* @param height
* @param postcode
* @param zoneName
* @param bizDate
* @param orderStatus
* @param freight
* @param currency
* @param freightSection
* @param remoteHits
* @param result
*/
private static void addSurcharges(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)
{
if (product.getSurcharges() == null)
{
return;
}
List<PayCostLine> sized = new ArrayList<>();
for (PaySurcharge fee : product.getSurcharges())
{
if (fee == null || !inForce(fee, bizDate))
{
continue;
}
String rule = fee.getCalcRule() == null || fee.getCalcRule().isBlank() ? PaySurchargeRules.MANUAL : fee.getCalcRule();
if (remoteHits != null && remoteFee(fee))
{
String name = blank(fee.getFeeName());
if (postcode == null || postcode.isBlank())
{
result.getSkipped().add(name + ":订单没有邮编");
}
else if (bookApplies(remoteHits, fee, rule))
{
String remark = "邮编 " + postcode.trim();
if (PaySurchargeRules.EXTRA_KG.equals(rule))
{
addExtraKg(result.getFees(), fee, weight, currency);
}
else if (perKg(fee))
{
addPerKg(result.getFees(), fee, weight, currency, remark);
}
else
{
addFlat(result.getFees(), fee, currency, remark);
}
}
continue;
}
boolean postcodeDriven = PaySurchargeRules.POSTCODE.equals(rule) || !blank(fee.getPostcodeParam()).isEmpty();
if (!postcodeDriven && zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank()
&& !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName))
{
continue;
}
String name = blank(fee.getFeeName());
if (PaySurchargeRules.POSTCODE.equals(rule))
{
String param = blank(fee.getPostcodeParam()).isEmpty() ? fee.getCalcParam() : fee.getPostcodeParam();
if (postcode == null || postcode.isBlank())
{
result.getSkipped().add(name + ":订单没有邮编");
}
else if (PaySurchargeRules.matchesPostcode(param, postcode) && weightAtLeast(fee, weight))
{
addFlat(result.getFees(), fee, currency, "邮编 " + postcode.trim());
}
}
else if (PaySurchargeRules.ZONE.equals(rule))
{
boolean already = result.getFees().stream().anyMatch(line -> name.equals(line.getFeeName()));
if (!already && zoneName != null && !zoneName.isBlank() && PayZoneMatcher.sameZone(fee.getZoneName(), zoneName) && weightIn(fee, weight))
{
addFlat(result.getFees(), fee, currency, "地区 " + zoneName.replace('\n', ' '));
}
}
else if (PaySurchargeRules.WEIGHT.equals(rule))
{
if (PaySurchargeRules.matchesWeight(fee.getCalcParam(), weight))
{
addFlat(result.getFees(), fee, currency, "计费重超过 " + fee.getCalcParam() + "kg");
}
}
// 超出尺寸费用
else if (PaySurchargeRules.SIZE.equals(rule))
{
if (length == null || width == null || height == null)
{
result.getSkipped().add(name + ":订单没有长宽高");
}
else if (PaySurchargeRules.matchesSize(fee.getCalcParam(), length, width, height))
{
PayCostLine matched = matchedFee(fee, freight, currency, "尺寸命中 " + fee.getCalcParam());
if (matched != null)
{
sized.add(matched);
}
}
}
// 退件费用
else if (PaySurchargeRules.RETURN.equals(rule))
{
if (orderStatus != null && REFUND_STATUS_SET.contains(orderStatus))
{
addFlat(result.getFees(), fee, currency, "订单已退件");
}
}
// 超重费
else if (PaySurchargeRules.EXTRA_KG.equals(rule))
{
if (postcodeDriven)
{
if (postcode == null || postcode.isBlank())
{
result.getSkipped().add(name + ":订单没有邮编");
}
else if (PaySurchargeRules.matchesPostcode(fee.getPostcodeParam(), postcode))
{
addExtraKg(result.getFees(), fee, weight, currency);
}
}
else if (sameSection(fee.getSectionName(), freightSection))
{
addExtraKg(result.getFees(), fee, weight, currency);
}
}
else if (PaySurchargeRules.ALWAYS.equals(rule))
{
PayCostLine matched = matchedFee(fee, freight, currency, blank(fee.getChargeText()));
if (matched != null)
{
result.getFees().add(matched);
}
}
else if (!name.isEmpty() && !name.contains("报价说明"))
{
result.getSkipped().add(name + ":价卡没有可计算的条件,暂不计费");
}
}
result.getFees().addAll(keepHighestByName(sized));
}
/** 同一费用名的尺寸档同时命中时,只收较高的一档,例如 70% 覆盖 35%。 */
private static List<PayCostLine> keepHighestByName(List<PayCostLine> lines)
{
List<PayCostLine> kept = new ArrayList<>();
for (PayCostLine line : lines)
{
PayCostLine same = null;
for (PayCostLine exists : kept)
{
if (exists.getFeeName().equals(line.getFeeName()))
{
same = exists;
break;
}
}
if (same == null)
{
kept.add(line);
}
else if (line.getAmount().compareTo(same.getAmount()) > 0)
{
kept.set(kept.indexOf(same), line);
}
}
return kept;
}
private static void addExtraKg(List<PayCostLine> fees, PaySurcharge fee, BigDecimal weight, String currency)
{
if (fee.getAmount() == null || weight == null)
{
return;
}
BigDecimal threshold;
try
{
threshold = new BigDecimal(fee.getCalcParam() == null ? "" : fee.getCalcParam().trim());
}
catch (NumberFormatException ex)
{
return;
}
if (weight.compareTo(threshold) <= 0)
{
return;
}
String feeCurrency = normalize(fee.getCurrency());
if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency))
{
return;
}
BigDecimal over = weight.subtract(threshold);
BigDecimal amount = fee.getAmount().multiply(over).setScale(2, RoundingMode.HALF_UP);
long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId();
String remark = "超出 " + plain(over) + "kg × " + plain(fee.getAmount()) + " " + blank(fee.getPriceUnit());
fees.add(line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark));
}
/** 同一地区有多张服务表时,续重只跟本次首重所在的那一张表。 */
private static boolean sameSection(String feeSection, String freightSection)
{
if (feeSection == null || feeSection.isBlank() || freightSection == null || freightSection.isBlank())
{
return true;
}
return feeSection.trim().equals(freightSection.trim());
}
/** 重量超过最高档,且该地区、该服务表有每公斤续重时,基础运费先用最高档。 */
private static boolean hasExtraKg(PayProduct product, String zoneName, BigDecimal threshold, String section)
{
if (product.getSurcharges() == null || threshold == null)
{
return false;
}
for (PaySurcharge fee : product.getSurcharges())
{
if (fee == null || !PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule()) || fee.getAmount() == null
|| !blank(fee.getPostcodeParam()).isEmpty())
{
continue;
}
if (zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank()
&& !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName))
{
continue;
}
if (!sameSection(fee.getSectionName(), section))
{
continue;
}
try
{
if (fee.getCalcParam() != null && new BigDecimal(fee.getCalcParam().trim()).compareTo(threshold) == 0)
{
return true;
}
}
catch (NumberFormatException ex)
{
continue;
}
}
return false;
}
private static PayWeightBand highestBand(List<PayWeightBand> bands)
{
PayWeightBand top = null;
for (PayWeightBand band : bands)
{
if (top == null || band.getWeightMax().compareTo(top.getWeightMax()) > 0)
{
top = band;
}
}
return top;
}
private static boolean remoteFee(PaySurcharge fee)
{
String name = blank(fee.getFeeName()).toLowerCase(Locale.ROOT);
return name.contains("偏远") || name.contains("岛屿") || name.contains("remote") || name.contains("island");
}
/** 邮编配置命中后,金额相同的那一档才收;分区写明时还要对上地区。 */
private static boolean bookApplies(List<PayRemoteBook.Hit> hits, PaySurcharge fee, String rule)
{
boolean extra = PaySurchargeRules.EXTRA_KG.equals(rule);
boolean zoned = !blank(fee.getZoneName()).isEmpty();
for (PayRemoteBook.Hit hit : hits)
{
if (hit == null || !sameFeeName(hit.getFeeName(), fee.getFeeName()))
{
continue;
}
boolean amountKnown = hit.getAmount() != null && fee.getAmount() != null;
if (amountKnown && hit.getAmount().compareTo(fee.getAmount()) != 0 && !extra)
{
continue;
}
boolean areaKnown = !blank(hit.getZoneName()).isEmpty() || !blank(hit.getRegionName()).isEmpty();
if (zoned && areaKnown && !sameArea(hit, fee))
{
continue;
}
if (extra && zoned && !sameArea(hit, fee))
{
continue;
}
if (extra && amountKnown && hit.getAmount().compareTo(fee.getAmount()) != 0 && !sameArea(hit, fee))
{
continue;
}
return true;
}
return false;
}
private static boolean sameFeeName(String bookName, String feeName)
{
String book = feeKey(bookName);
String fee = feeKey(feeName);
if (book.isEmpty())
{
return fee.contains("偏远") || fee.contains("岛屿") || fee.contains("remote") || fee.contains("island");
}
return fee.contains(book) || book.contains(fee);
}
private static String feeKey(String name)
{
return blank(name).toLowerCase(Locale.ROOT).replace("地区", "").replace(" ", "");
}
private static boolean sameArea(PayRemoteBook.Hit hit, PaySurcharge fee)
{
return PayZoneMatcher.sameZone(fee.getZoneName(), hit.getZoneName())
|| PayZoneMatcher.sameZone(fee.getZoneName(), hit.getRegionName());
}
private static boolean perKg(PaySurcharge fee)
{
String unit = blank(fee.getPriceUnit()).toUpperCase(Locale.ROOT);
return unit.contains("KG") || unit.contains("公斤");
}
private static void addPerKg(List<PayCostLine> fees, PaySurcharge fee, BigDecimal weight, String currency, String remark)
{
if (fee.getAmount() == null || weight == null)
{
return;
}
String feeCurrency = normalize(fee.getCurrency());
if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency))
{
return;
}
BigDecimal amount = fee.getAmount().multiply(weight).setScale(2, RoundingMode.HALF_UP);
long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId();
fees.add(line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark + " × " + plain(weight) + "kg"));
}
private static void addFlat(List<PayCostLine> fees, PaySurcharge fee, String currency, String remark)
{
PayCostLine line = matchedFee(fee, null, currency, remark);
if (line != null && !percent(fee))
{
fees.add(line);
}
}
private static PayCostLine matchedFee(PaySurcharge fee, BigDecimal freight, String currency, String remark)
{
if (fee.getAmount() == null)
{
return null;
}
BigDecimal amount = fee.getAmount();
if (percent(fee))
{
if (freight == null)
{
return null;
}
amount = freight.multiply(fee.getAmount()).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
else
{
amount = amount.setScale(2, RoundingMode.HALF_UP);
String feeCurrency = normalize(fee.getCurrency());
if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency))
{
return null;
}
}
long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId();
return line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark);
}
private static boolean percent(PaySurcharge fee)
{
String charge = fee.getChargeText() == null ? "" : fee.getChargeText();
return charge.contains("%") || "%".equals(fee.getCurrency());
}
private static boolean inForce(PaySurcharge fee, Date bizDate)
{
if (bizDate == null || (fee.getEffectiveStart() == null && fee.getEffectiveEnd() == null))
{
return true;
}
LocalDate day = toLocal(bizDate);
if (fee.getEffectiveStart() != null && day.isBefore(toLocal(fee.getEffectiveStart())))
{
return false;
}
if (fee.getEffectiveEnd() != null && day.isAfter(toLocal(fee.getEffectiveEnd())))
{
return false;
}
return true;
}
private static List<PayWeightBand> usableBands(List<PayWeightBand> bands)
{
List<PayWeightBand> usable = new ArrayList<>();
if (bands == null)
{
return usable;
}
for (PayWeightBand band : bands)
{
if (band != null && band.getPrice() != null && band.getWeightMin() != null && band.getWeightMax() != null)
{
usable.add(band);
}
}
usable.sort((left, right) -> left.getWeightMin().compareTo(right.getWeightMin()));
return usable;
}
private static Set<String> zones(List<PayWeightBand> bands)
{
Set<String> zones = new LinkedHashSet<>();
for (PayWeightBand band : bands)
{
if (band.getZoneName() != null && !band.getZoneName().isBlank())
{
zones.add(band.getZoneName().trim());
}
}
return zones;
}
private static PayWeightBand matchBand(List<PayWeightBand> bands, BigDecimal weight)
{
for (PayWeightBand band : bands)
{
if (weight.compareTo(band.getWeightMin()) >= 0 && weight.compareTo(band.getWeightMax()) <= 0)
{
return band;
}
}
for (int i = 0; i < bands.size() - 1; i++)
{
PayWeightBand current = bands.get(i);
PayWeightBand next = bands.get(i + 1);
BigDecimal gap = next.getWeightMin().subtract(current.getWeightMax());
if (weight.compareTo(current.getWeightMax()) > 0 && weight.compareTo(next.getWeightMin()) < 0
&& gap.compareTo(new BigDecimal("0.01")) <= 0)
{
return next;
}
}
return null;
}
private static String outOfRange(PayProduct product, List<PayWeightBand> bands, BigDecimal weight)
{
String name = blank(product.getProductName());
if (bands.isEmpty())
{
return "产品「" + name + "」没有可匹配的重量段";
}
BigDecimal max = bands.get(bands.size() - 1).getWeightMax();
if (weight.compareTo(max) > 0)
{
return "计费重 " + plain(weight) + "kg 超出「" + name + "」的重量段,最大 " + plain(max) + "kg";
}
return "计费重 " + plain(weight) + "kg 没有落在「" + name + "」的重量段里";
}
private static BigDecimal bandAmount(PayWeightBand band, BigDecimal weight)
{
BigDecimal price = band.getPrice();
if (perKilogram(band))
{
return price.multiply(weight).setScale(2, RoundingMode.HALF_UP);
}
return price.setScale(2, RoundingMode.HALF_UP);
}
private static boolean perKilogram(PayWeightBand band)
{
String blob = (blank(band.getPriceUnit()) + " " + blank(band.getChargeName())).toLowerCase(Locale.ROOT);
return blob.contains("/kg") || blob.contains("每公斤") || blob.contains("每千克") || blob.contains("/公斤");
}
private static String currencyOf(PayProduct product, PayWeightBand band)
{
String unit = blank(band.getPriceUnit()).toUpperCase(Locale.ROOT);
if (unit.contains("GBP") || unit.contains("英镑"))
{
return "GBP";
}
if (unit.contains("EUR") || unit.contains("欧元"))
{
return "EUR";
}
if (unit.contains("USD") || unit.contains("美元"))
{
return "USD";
}
if (unit.contains("CNY") || unit.contains("RMB") || unit.contains("人民币"))
{
return "CNY";
}
String currency = blank(product.getCurrency());
return currency.isEmpty() ? "CNY" : currency;
}
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.setScale(2, RoundingMode.HALF_UP));
line.setCurrency(currency);
line.setRemark(remark == null ? "" : remark.trim());
return line;
}
private static String summary(PayOrderCostResult result)
{
String text = "打单应收 " + plain(result.getPayTotal()) + " " + result.getCurrency();
if (result.getSkipped() != null && !result.getSkipped().isEmpty())
{
text = text + ",另有 " + result.getSkipped().size() + " 项附加费未自动计算";
}
return text;
}
private static LocalDate toLocal(Date date)
{
if (date instanceof java.sql.Date sql)
{
return sql.toLocalDate();
}
return date.toInstant().atZone(SHANGHAI).toLocalDate();
}
private static String plain(BigDecimal value)
{
return value == null ? "" : value.stripTrailingZeros().toPlainString();
}
private static String blank(String value)
{
return value == null ? "" : value.trim();
}
private static String normalize(String currency)
{
if (currency == null || currency.isBlank() || "%".equals(currency.trim()))
{
return "";
}
String value = currency.trim().toUpperCase(Locale.ROOT);
if ("英镑".equals(currency))
{
return "GBP";
}
if ("欧元".equals(currency))
{
return "EUR";
}
return value;
}
}
@@ -18,7 +18,8 @@ import com.bms.price.domain.PaySurcharge;
import com.bms.price.domain.PayWeightBand;
/**
* 用渠道上的应付产品,按订单重量、尺寸、邮编和是否退件计算应付。
* 应付(AP)计费:用渠道应付产品按重量、尺寸、邮编和是否退件计算。
* 打单应收请用 {@link LabelArCostCalculator},两者独立,互不影响。
* 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。
*/
public final class PayCostCalculator
@@ -461,6 +461,8 @@ public class PayProductServiceImpl implements IPayProductService
{
product.setExtraLines(new ArrayList<>());
}
// 计数前先按「是否计费」过滤,与落库行数一致
retainBillableSurcharges(product);
product.setWeightCount(product.getWeightBands().size());
product.setSurchargeCount(product.getSurcharges().size());
product.setExtraCount(product.getExtraLines().size());
@@ -1529,6 +1531,7 @@ public class PayProductServiceImpl implements IPayProductService
private void insertSurcharges(PayProduct product)
{
retainBillableSurcharges(product);
List<PaySurcharge> rows = product.getSurcharges();
if (rows == null || rows.isEmpty())
{
@@ -1547,6 +1550,27 @@ public class PayProductServiceImpl implements IPayProductService
}
}
/**
* 仅保留需要计费的附加费:needBill=false 不入库;null 视为要计费。
*/
private void retainBillableSurcharges(PayProduct product)
{
List<PaySurcharge> rows = product.getSurcharges();
if (rows == null || rows.isEmpty())
{
return;
}
List<PaySurcharge> kept = new ArrayList<PaySurcharge>();
for (PaySurcharge fee : rows)
{
if (fee != null && !Boolean.FALSE.equals(fee.getNeedBill()))
{
kept.add(fee);
}
}
product.setSurcharges(kept);
}
/**
* 按附加费名称映射 prc_surcharge.sc_id;币种不能为空:附加费 -> 价卡 -> EUR。
*/
@@ -42,7 +42,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.PayCostCalculator;
import com.bms.price.pay.LabelArCostCalculator;
import com.bms.price.mapper.PrcZoneDetailMapper;
import com.bms.price.domain.vo.TmsPriceBillRequest;
import com.bms.price.domain.vo.TmsPriceQuoteFeeLine;
@@ -209,7 +209,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
return result;
}
/** LABEL 费用已由 PayCostCalculator 算完,不再叠加销售附加费。 */
/** LABEL 费用已由 LabelArCostCalculator 算完,不再叠加销售附加费。 */
private void applySalesSurchargesIfNeeded(TmsPriceQuoteResult result)
{
if (result == null || SRC_LABEL.equals(result.getMatchSource()))
@@ -665,7 +665,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
}
/**
* 打单套餐计费:渠道 AR → product_id,套餐+时间 → term,PayCostCalculator 算费。
* 打单套餐计费:渠道 AR → product_id,套餐+时间 → term,LabelArCostCalculator 算费(与应付独立)。
*/
private TmsPriceQuoteResult doLabelQuote(TmsPriceQuoteRequest request, PrcCustomerPriceSet bind,
PrcPriceSet set, String productCode, String productName, String countryCode, String countryName,
@@ -707,7 +707,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
height = billReq.getPkgHeight();
orderStatus = billReq.getOrderStatus();
}
PayOrderCostResult cost = PayCostCalculator.calculate(product, weight, length, width, height,
PayOrderCostResult cost = LabelArCostCalculator.calculate(product, weight, length, width, height,
request.getDestPostcode(), bizDate, orderStatus);
if (cost == null || cost.getFees() == null || cost.getFees().isEmpty())
{