feat(firstleg): enforce pricing adjustment bounds

This commit is contained in:
2026-09-08 17:13:43 +08:00
parent 78a73a1b0e
commit 2e60f2cd77
11 changed files with 236 additions and 19 deletions
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
@@ -80,7 +81,8 @@ public final class FbaPricingDtos {
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity,
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
@NotBlank @Size(max = 500) String reason
@NotBlank @Size(max = 500) String reason,
@NotNull @AssertTrue Boolean toleranceConfirmed
) {}
public record FeeSnapshotResponse(
@@ -96,7 +98,9 @@ public final class FbaPricingDtos {
@NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale,
@NotBlank String roundingMode,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal convertedQuantity, BigDecimal baselineChargeableQuantity,
BigDecimal lowerChargeableQuantity, BigDecimal upperChargeableQuantity,
BigDecimal tolerancePercent, BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
BigDecimal totalAmount, String currency,
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
@@ -119,9 +119,20 @@ public final class PriceBookDtos {
@Size(max = 128) String destinationName,
PriceRegion destinationRegion,
PricingTaxMode pricingTaxMode,
@Pattern(regexp = "KG|CBM") String chargeMode,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm
) {}
) {
public EstimateRequest(Long originWarehouseId, String channelCode, String destinationCode,
PricingDestinationType destinationType, Long destinationWarehouseId,
String destinationName, PriceRegion destinationRegion,
PricingTaxMode pricingTaxMode, BigDecimal actualWeightKg,
BigDecimal actualVolumeCbm) {
this(originWarehouseId, channelCode, destinationCode, destinationType,
destinationWarehouseId, destinationName, destinationRegion, pricingTaxMode,
null, actualWeightKg, actualVolumeCbm);
}
}
public record EstimateResponse(
Long priceBookVersionId,
@@ -54,6 +54,10 @@ public class InboundFeeEntity {
private BigDecimal actualWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal convertedQuantity;
/** 系统按权威测量与生效报价计算的原始计费量;人工调整不得改变。 */
private BigDecimal baselineChargeableQuantity;
/** 人工计费量相对系统基准允许的最大偏差百分比。 */
private BigDecimal tolerancePercent;
private BigDecimal chargeableQuantity;
private String chargeUnit;
private String currency;
@@ -65,6 +65,8 @@ public class ActivePriceBookEstimator {
} else {
throw validation("私人地址暂不支持参考估价");
}
String chargeMode = requiredChargeMode(request);
rates = rates.stream().filter(value -> chargeMode.equals(value.getChargeUnit())).toList();
RateRow rate = uniqueRate(rates);
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
@@ -81,6 +83,9 @@ public class ActivePriceBookEstimator {
if (request.pricingTaxMode() == null) {
throw validation("请选择包税或不包税报价");
}
if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) {
throw validation("区域重量阶梯仅支持KG计费");
}
PriceRegion region = request.destinationRegion() == null
? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion();
List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode,
@@ -113,6 +118,19 @@ public class ActivePriceBookEstimator {
return rates.get(0);
}
private static String requiredChargeMode(EstimateRequest request) {
if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED) {
if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) {
throw validation("包税报价必须按KG计费");
}
return "KG";
}
if (!StringUtils.hasText(request.chargeMode())) {
throw validation("不包税报价请选择客户计费方式");
}
return request.chargeMode();
}
private static void requireModel(RateRow rate, PriceModel expected) {
if (rate == null || !expected.name().equals(rate.getPriceModel())) {
throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致");
@@ -171,7 +171,7 @@ public class FbaPricingRepository {
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode())
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getPricingTaxMode())
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.le(CustomerChargeRuleEntity::getEffectiveFrom, at)
.and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo)
@@ -35,6 +35,8 @@ import java.util.List;
@Service
public class FbaPricingService {
private static final int MONEY_SCALE = 2;
private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00");
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
@Resource
@@ -114,10 +116,12 @@ public class FbaPricingService {
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
pricingTaxMode(inbound), policy.chargeMode(),
selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
RateRow rate = estimate.rate();
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
@@ -133,6 +137,13 @@ public class FbaPricingService {
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version,
weight, volume, converted, chargeable, subtotal, additional, total, now);
if (policy.rule() != null) {
fee.setChargeRuleId(policy.rule().getId());
fee.setChargeRuleVersion(policy.rule().getRuleVersion());
fee.setRuleCode(policy.rule().getRuleCode());
fee.setRuleEffectiveFrom(policy.rule().getEffectiveFrom());
fee.setRuleEffectiveTo(policy.rule().getEffectiveTo());
}
fee.setMeasurementId(measurement == null ? null : measurement.getId());
fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource);
if (published != null) {
@@ -283,7 +294,8 @@ public class FbaPricingService {
}
String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":"
+ request.chargeableQuantity() + ":" + request.unitPrice() + ":"
+ request.additionalAmount() + ":" + request.reason().trim();
+ request.additionalAmount() + ":" + request.reason().trim() + ":"
+ request.toleranceConfirmed();
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
if ("COMPLETED".equals(token.getStatus())) {
@@ -297,6 +309,17 @@ public class FbaPricingService {
adjusted.setId(null);
adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion);
adjusted.setFeeVersion(nextVersion);
BigDecimal baseline = baseline(current);
ToleranceRange range = toleranceRange(baseline);
if (request.chargeableQuantity().compareTo(range.lower()) < 0
|| request.chargeableQuantity().compareTo(range.upper()) > 0) {
throw conflict("调整计费量必须在系统原计费量±10%范围内(含边界)");
}
if (!Boolean.TRUE.equals(request.toleranceConfirmed())) {
throw conflict("请二次确认计费量调整范围后再提交");
}
adjusted.setBaselineChargeableQuantity(baseline);
adjusted.setTolerancePercent(ADJUST_TOLERANCE_PERCENT);
adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP));
adjusted.setConvertedQuantity(adjusted.getChargeableQuantity());
adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice())
@@ -401,6 +424,27 @@ public class FbaPricingService {
}
}
private PricingPolicy pricingPolicy(InboundPricingEntity inbound, LocalDateTime at) {
PricingTaxMode taxMode = pricingTaxMode(inbound);
if (taxMode == PricingTaxMode.TAX_INCLUDED) return new PricingPolicy("KG", null);
List<com.baiyue.firstleg.entity.CustomerChargeRuleEntity> rules = repository.matchRules(inbound, at);
if (rules == null || rules.isEmpty()) {
throw conflict("客户未配置当前不包税报价的计费方式");
}
List<com.baiyue.firstleg.entity.CustomerChargeRuleEntity> defaults = rules.stream()
.filter(rule -> Boolean.TRUE.equals(rule.getDefaultRule())).toList();
com.baiyue.firstleg.entity.CustomerChargeRuleEntity selected = defaults.size() == 1
? defaults.get(0) : rules.size() == 1 ? rules.get(0) : null;
if (selected == null) throw conflict("客户当前不包税计费规则匹配不唯一");
if (!List.of("KG", "CBM").contains(selected.getChargeMode())) {
throw conflict("客户计费方式配置无效");
}
return new PricingPolicy(selected.getChargeMode(), selected);
}
private record PricingPolicy(String chargeMode,
com.baiyue.firstleg.entity.CustomerChargeRuleEntity rule) { }
private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
long inboundId, String operation,
String key, String hash) {
@@ -456,6 +500,8 @@ public class FbaPricingService {
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor());
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
fee.setBaselineChargeableQuantity(chargeable);
fee.setTolerancePercent(ADJUST_TOLERANCE_PERCENT);
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency());
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
@@ -497,6 +543,8 @@ public class FbaPricingService {
private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee,
BigDecimal unitPrice, String nextStatus) {
BigDecimal baseline = baseline(fee);
ToleranceRange range = toleranceRange(baseline);
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
fee.getMeasurementId(), fee.getMeasurementSource(),
@@ -508,7 +556,9 @@ public class FbaPricingService {
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
fee.getConversionFactor(),
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
fee.getConvertedQuantity(), baseline, range.lower(), range.upper(),
fee.getTolerancePercent() == null ? ADJUST_TOLERANCE_PERCENT : fee.getTolerancePercent(),
fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(),
fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(),
fee.getSentAt(), nextStatus,
@@ -516,6 +566,22 @@ public class FbaPricingService {
fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt());
}
private static BigDecimal baseline(InboundFeeEntity fee) {
BigDecimal value = fee.getBaselineChargeableQuantity();
if (value == null) value = fee.getChargeableQuantity();
if (value == null || value.signum() <= 0) throw conflict("系统原计费量必须大于零");
return value.setScale(6, RoundingMode.HALF_UP);
}
private static ToleranceRange toleranceRange(BigDecimal baseline) {
BigDecimal delta = baseline.multiply(ADJUST_TOLERANCE_PERCENT)
.divide(ONE_HUNDRED, 6, RoundingMode.HALF_UP);
return new ToleranceRange(baseline.subtract(delta).setScale(6, RoundingMode.HALF_UP),
baseline.add(delta).setScale(6, RoundingMode.HALF_UP));
}
private record ToleranceRange(BigDecimal lower, BigDecimal upper) { }
private static String hash(String operation, long inboundId, String discriminator) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
@@ -42,6 +42,10 @@ public class MerchantPriceBookService {
public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) {
requireMerchant(merchantTenantId);
if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED
&& request.chargeMode() != null && !"KG".equals(request.chargeMode())) {
throw validation("包税报价必须按KG计费");
}
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),