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
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
@@ -37,6 +38,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
@@ -217,6 +219,10 @@ class FbaPricingServiceTest {
InboundPricingEntity inbound = inbound("363.001", "0.5000");
inbound.setPricingTaxMode("TAX_EXCLUDED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1);
rule.setChargeMode("CBM"); rule.setDefaultRule(true);
when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
@@ -228,6 +234,8 @@ class FbaPricingServiceTest {
assertThat(response.conversionFactor()).isEqualByComparingTo("363");
assertThat(response.roundingScale()).isEqualTo(6);
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
assertThat(response.chargeRuleId()).isEqualTo(601L);
assertThat(response.ruleCode()).isEqualTo("CBM-RULE");
}
@Test
@@ -483,15 +491,19 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正"), "adjust-1");
1, new BigDecimal("160"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正", true), "adjust-1");
assertThat(response.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("SENT");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.totalAmount()).isEqualByComparingTo("1300.00");
assertThat(response.baselineChargeableQuantity()).isEqualByComparingTo("167.000000");
assertThat(response.lowerChargeableQuantity()).isEqualByComparingTo("150.300000");
assertThat(response.upperChargeableQuantity()).isEqualByComparingTo("183.700000");
assertThat(response.tolerancePercent()).isEqualByComparingTo("10.00");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000");
assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("160.0000");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
@@ -538,7 +550,7 @@ class FbaPricingServiceTest {
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
BigDecimal.ZERO, "客服修正"), "locked-adjust"))
BigDecimal.ZERO, "客服修正", true), "locked-adjust"))
.isInstanceOf(BizException.class)
.hasMessageContaining("客户已确认");
@@ -574,11 +586,11 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正"), "adjust-calculated");
1, new BigDecimal("160"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正", true), "adjust-calculated");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.totalAmount()).isEqualByComparingTo("1300.00");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED");
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW);
@@ -598,7 +610,7 @@ class FbaPricingServiceTest {
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
BigDecimal.ZERO, "客服修正"), "stale-adjust"))
BigDecimal.ZERO, "客服修正", true), "stale-adjust"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
@@ -607,6 +619,64 @@ class FbaPricingServiceTest {
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
}
@Test
@DisplayName("人工计费量允许上下10%含边界")
void acceptsInclusiveToleranceBoundaries() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
InboundFeeEntity current = savedFee("CALCULATED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "lower-bound")).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(903L); return null; })
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; })
.when(repository).insertFee(any());
when(repository.completeIdempotency(any(), eq(504L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 504L)).thenAnswer(invocation -> current);
service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("150.3"), new BigDecimal("2"), BigDecimal.ZERO,
"下界确认", true), "lower-bound");
verify(repository).insertFee(argThat(fee ->
fee.getChargeableQuantity().compareTo(new BigDecimal("150.3000")) == 0));
}
@Test
@DisplayName("人工计费量越过10%或未二次确认返回冲突")
void rejectsOutOfToleranceOrUnconfirmedAdjustment() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(savedFee("CALCULATED"));
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(904L); return null; })
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("183.7001"), new BigDecimal("2"), BigDecimal.ZERO,
"越界", true), "outside"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("167"), new BigDecimal("2"), BigDecimal.ZERO,
"未确认", false), "unconfirmed"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
}
private void readyRepository() {
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })