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