feat(firstleg): enforce pricing adjustment bounds
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user