fix(firstleg): persist pricing conversion metadata

This commit is contained in:
2026-08-29 11:48:19 +08:00
parent 66816d5341
commit 0605c32829
10 changed files with 77 additions and 20 deletions
@@ -69,7 +69,8 @@ public final class FbaPricingDtos {
String businessType, String channelCode, Long carrierId, String carrierName,
String destinationType, Long destinationWarehouseId, String destinationName, String destinationCode,
String taxMode, String pricingTaxMode, String chargeMode,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
@NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale,
@NotBlank String roundingMode,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
@@ -69,7 +69,8 @@ public class ActivePriceBookEstimator {
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
return new Estimate(versionId, channelCode, PriceModel.FBA_WAREHOUSE_RATE, request.pricingTaxMode(),
rate, estimate.quantity(), estimate.unit(), estimate.unitPrice(), estimate.amount(), estimate.currency());
rate, estimate.quantity(), estimate.unit(), estimate.unitPrice(), estimate.amount(), estimate.currency(),
estimate.conversionFactor(), estimate.roundingScale(), estimate.roundingMode());
}
private Estimate estimateRegion(long versionId, String channelCode, EstimateRequest request) {
@@ -91,7 +92,8 @@ public class ActivePriceBookEstimator {
var estimate = regionEstimator.estimate(tiers, request.actualWeightKg(), request.actualVolumeCbm());
RateRow selected = estimate.rate();
return new Estimate(versionId, channelCode, PriceModel.REGION_WEIGHT_TIER, request.pricingTaxMode(),
selected, estimate.quantity(), "KG", estimate.unitPrice(), estimate.amount(), estimate.currency());
selected, estimate.quantity(), "KG", estimate.unitPrice(), estimate.amount(), estimate.currency(),
estimate.conversionFactor(), estimate.roundingScale(), estimate.roundingMode());
}
private PriceRegion resolveWarehouseRegion(Long warehouseId) {
@@ -146,6 +148,9 @@ public class ActivePriceBookEstimator {
String chargeUnit,
BigDecimal unitPrice,
BigDecimal amount,
String currency
String currency,
BigDecimal conversionFactor,
int roundingScale,
String roundingMode
) {}
}
@@ -12,6 +12,8 @@ import java.math.RoundingMode;
public class FbaWarehouseRateEstimator {
private static final BigDecimal KG_PER_CBM = BigDecimal.valueOf(167);
private static final BigDecimal KG_PER_BILLABLE_CBM = BigDecimal.valueOf(363);
private static final int QUANTITY_SCALE = 6;
private static final RoundingMode QUANTITY_ROUNDING_MODE = RoundingMode.HALF_UP;
public Estimate estimate(RateRow rate, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {
BigDecimal weight = nonNegative(actualWeightKg);
@@ -19,14 +21,20 @@ public class FbaWarehouseRateEstimator {
if (weight.signum() == 0 && volume.signum() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积至少填写一项且必须大于0");
}
BigDecimal conversionFactor = switch (rate.getChargeUnit()) {
case "KG" -> KG_PER_CBM;
case "CBM" -> KG_PER_BILLABLE_CBM;
default -> throw new BizException(ResultCode.CONFLICT, "当前价格计费单位不受支持");
};
BigDecimal quantity = switch (rate.getChargeUnit()) {
case "KG" -> weight.max(volume.multiply(KG_PER_CBM));
case "CBM" -> volume.max(weight.divide(KG_PER_BILLABLE_CBM, 12, RoundingMode.HALF_UP));
default -> throw new BizException(ResultCode.CONFLICT, "当前价格计费单位不受支持");
};
quantity = quantity.setScale(6, RoundingMode.HALF_UP);
quantity = quantity.setScale(QUANTITY_SCALE, QUANTITY_ROUNDING_MODE);
return new Estimate(quantity, rate.getChargeUnit(), rate.getUnitPrice(),
quantity.multiply(rate.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), rate.getCurrency());
quantity.multiply(rate.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), rate.getCurrency(),
conversionFactor, QUANTITY_SCALE, QUANTITY_ROUNDING_MODE.name());
}
private static BigDecimal nonNegative(BigDecimal value) {
@@ -36,5 +44,6 @@ public class FbaWarehouseRateEstimator {
}
public record Estimate(BigDecimal quantity, String unit, BigDecimal unitPrice,
BigDecimal amount, String currency) {}
BigDecimal amount, String currency, BigDecimal conversionFactor,
int roundingScale, String roundingMode) {}
}
@@ -13,6 +13,8 @@ import java.util.List;
@Component
public class RegionWeightTierEstimator {
private static final BigDecimal KG_PER_CBM = BigDecimal.valueOf(167);
private static final int QUANTITY_SCALE = 6;
private static final RoundingMode QUANTITY_ROUNDING_MODE = RoundingMode.HALF_UP;
public Estimate estimate(List<RateRow> tiers, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {
BigDecimal weight = nonNegative(actualWeightKg);
@@ -20,7 +22,8 @@ public class RegionWeightTierEstimator {
if (weight.signum() == 0 && volume.signum() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积至少填写一项且必须大于0");
}
BigDecimal quantity = weight.max(volume.multiply(KG_PER_CBM)).setScale(6, RoundingMode.HALF_UP);
BigDecimal quantity = weight.max(volume.multiply(KG_PER_CBM))
.setScale(QUANTITY_SCALE, QUANTITY_ROUNDING_MODE);
RateRow selected = tiers.stream()
.filter(rate -> "KG".equals(rate.getChargeUnit()) && rate.getWeightFromKg() != null)
.filter(rate -> rate.getWeightFromKg().compareTo(quantity) <= 0)
@@ -28,7 +31,8 @@ public class RegionWeightTierEstimator {
.orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED,
"当前计费重量未达到该海派渠道的最低重量阶梯"));
return new Estimate(selected, quantity, selected.getUnitPrice(),
quantity.multiply(selected.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), selected.getCurrency());
quantity.multiply(selected.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), selected.getCurrency(),
KG_PER_CBM, QUANTITY_SCALE, QUANTITY_ROUNDING_MODE.name());
}
private static BigDecimal nonNegative(BigDecimal value) {
@@ -38,5 +42,6 @@ public class RegionWeightTierEstimator {
}
public record Estimate(RateRow rate, BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency) {}
String currency, BigDecimal conversionFactor, int roundingScale,
String roundingMode) {}
}
@@ -318,7 +318,8 @@ public class FbaPricingService {
fee.setRuleCode(null); fee.setCustomerId(inbound.getCustomerId());
fee.setCustomerName(inbound.getCustomerName()); fee.setBusinessType(inbound.getBusinessType());
fee.setTaxMode(inbound.getTaxMode()); fee.setPricingTaxMode(inbound.getPricingTaxMode());
fee.setRoundingScale(null); fee.setRoundingMode(null); fee.setRuleEffectiveFrom(null);
fee.setRoundingScale(estimate.roundingScale()); fee.setRoundingMode(estimate.roundingMode());
fee.setRuleEffectiveFrom(null);
fee.setRuleEffectiveTo(null); fee.setRateCode(rate.getRateCode());
fee.setOriginWarehouseId(rate.getOriginWarehouseId()); fee.setOriginWarehouseName(rate.getOriginWarehouseName());
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(null);
@@ -326,7 +327,7 @@ public class FbaPricingService {
fee.setDestinationWarehouseId(inbound.getDestinationWarehouseId()); fee.setDestinationName(inbound.getDestinationName());
fee.setDestinationCode(inbound.getDestinationCode());
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(null);
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor());
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency());
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
@@ -282,7 +282,7 @@ components:
FeeSnapshotResponse:
type: object
description: fl_inbound_fee 当前版本快照;自动初算为 CALCULATED,人工确认后为 CONFIRMED,只有人工发布后为 SENT。
required: [feeId, feeNo, feeVersion, feeStatus, inboundId, totalAmount, currency]
required: [feeId, feeNo, feeVersion, feeStatus, inboundId, conversionFactor, roundingScale, roundingMode, totalAmount, currency]
properties:
feeId: {type: string, pattern: '^[1-9][0-9]*$'}
feeNo: {type: string}
@@ -312,9 +312,9 @@ components:
taxMode: {type: string, nullable: true}
pricingTaxMode: {type: string, nullable: true}
chargeMode: {type: string, nullable: true}
conversionFactor: {type: number, nullable: true}
roundingScale: {type: integer, nullable: true}
roundingMode: {type: string, nullable: true}
conversionFactor: {type: number, description: 本费用版本实际使用的重量体积换算系数;FBA/区域重量阶梯按KG为167,FBA按CBM为363}
roundingScale: {type: integer, enum: [6], description: 本费用版本最终计费量实际保留的小数位数}
roundingMode: {type: string, enum: [HALF_UP], description: 本费用版本最终计费量实际使用的舍入方式}
actualWeightKg: {type: number, nullable: true}
actualVolumeCbm: {type: number, nullable: true}
convertedQuantity: {type: number, nullable: true}
@@ -85,6 +85,23 @@ class FbaPricingContractTest {
.doesNotContain("format: int64");
}
@Test
@DisplayName("费用快照强制返回实际换算与计费量舍入参数")
void requiresActualConversionAndRoundingMetadata() throws Exception {
String source = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> schemas = map(map(new Yaml().load(source)).get("components"));
Map<String, Object> fee = map(map(schemas.get("schemas")).get("FeeSnapshotResponse"));
assertThat(list(fee.get("required"))).contains(
"conversionFactor", "roundingScale", "roundingMode");
Map<String, Object> properties = map(fee.get("properties"));
assertThat(map(properties.get("conversionFactor")).get("description").toString())
.contains("167", "363");
assertThat(list(map(properties.get("roundingScale")).get("enum"))).containsExactly(6);
assertThat(list(map(properties.get("roundingMode")).get("enum"))).containsExactly("HALF_UP");
}
@Test
@DisplayName("客户规则列表响应锁定数组包络和实体核心字段")
void freezesCustomerRuleVersionListResponse() throws Exception {
@@ -22,15 +22,21 @@ class FbaWarehouseRateEstimatorTest {
assertThat(estimate.unitPrice()).isEqualByComparingTo("12.50");
assertThat(estimate.amount()).isEqualByComparingTo("417.50");
assertThat(estimate.currency()).isEqualTo("CNY");
assertThat(estimate.conversionFactor()).isEqualByComparingTo("167");
assertThat(estimate.roundingScale()).isEqualTo(6);
assertThat(estimate.roundingMode()).isEqualTo("HALF_UP");
}
@Test
void shouldUseGreaterOfVolumeAndWeightConversionForCbmRate() {
var estimate = estimator.estimate(rate("CBM", "880"),
new BigDecimal("363"), new BigDecimal("0.50"));
new BigDecimal("37"), new BigDecimal("0.05"));
assertThat(estimate.quantity()).isEqualByComparingTo("1.000000");
assertThat(estimate.amount()).isEqualByComparingTo("880.00");
assertThat(estimate.quantity()).isEqualByComparingTo("0.101928");
assertThat(estimate.amount()).isEqualByComparingTo("89.70");
assertThat(estimate.conversionFactor()).isEqualByComparingTo("363");
assertThat(estimate.roundingScale()).isEqualTo(6);
assertThat(estimate.roundingMode()).isEqualTo("HALF_UP");
}
@Test
@@ -33,6 +33,9 @@ class RegionWeightTierEstimatorTest {
assertThat(estimate.quantity()).isEqualByComparingTo("101.870000");
assertThat(estimate.unitPrice()).isEqualByComparingTo("11");
assertThat(estimate.amount()).isEqualByComparingTo("1120.57");
assertThat(estimate.conversionFactor()).isEqualByComparingTo("167");
assertThat(estimate.roundingScale()).isEqualTo(6);
assertThat(estimate.roundingMode()).isEqualTo("HALF_UP");
}
@Test
@@ -84,6 +84,9 @@ class FbaPricingServiceTest {
assertThat(response.totalAmount()).isEqualByComparingTo("401.77");
assertThat(response.priceBookVersionId()).isEqualTo(701L);
assertThat(response.pricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(response.conversionFactor()).isEqualByComparingTo("167");
assertThat(response.roundingScale()).isEqualTo(6);
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
@@ -92,6 +95,9 @@ class FbaPricingServiceTest {
assertThat(fee.getValue().getChargeRuleId()).isNull();
assertThat(fee.getValue().getCarrierId()).isNull();
assertThat(fee.getValue().getCustomerId()).isEqualTo(11L);
assertThat(fee.getValue().getConversionFactor()).isEqualByComparingTo("167");
assertThat(fee.getValue().getRoundingScale()).isEqualTo(6);
assertThat(fee.getValue().getRoundingMode()).isEqualTo("HALF_UP");
}
@Test
@@ -125,6 +131,9 @@ class FbaPricingServiceTest {
assertThat(response.convertedQuantity()).isEqualByComparingTo("1.0001");
assertThat(response.chargeableQuantity()).isEqualByComparingTo("1.0001");
assertThat(response.totalAmount()).isEqualByComparingTo("100.01");
assertThat(response.conversionFactor()).isEqualByComparingTo("363");
assertThat(response.roundingScale()).isEqualTo(6);
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
}
@Test
@@ -464,7 +473,8 @@ class FbaPricingServiceTest {
rate.setAdditionalFee(new BigDecimal(additional)); rate.setEffectiveFrom(NOW.minusDays(1));
return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE,
PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), unit,
new BigDecimal(price), new BigDecimal(amount), "USD");
new BigDecimal(price), new BigDecimal(amount), "USD",
"KG".equals(unit) ? new BigDecimal("167") : new BigDecimal("363"), 6, "HALF_UP");
}
private InboundPricingIdempotencyEntity completed(String operation, long feeId) {