新增附加费计费标识,优化附加费管理逻辑
This commit is contained in:
@@ -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
|
||||
|
||||
+24
@@ -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。
|
||||
*/
|
||||
|
||||
+4
-4
@@ -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())
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user