二期内容提交
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPricingEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("FBA费用状态机")
|
||||
class FbaPricingServiceTest {
|
||||
private static final long TENANT = 10001L;
|
||||
private static final long OPERATOR = 20001L;
|
||||
private static final long INBOUND = 30001L;
|
||||
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0);
|
||||
|
||||
@Test
|
||||
@DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入")
|
||||
void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() {
|
||||
FbaPricingRepository repository = readyRepository();
|
||||
InboundPricingEntity inbound = inbound("100.000", "1.0001");
|
||||
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
|
||||
CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.00");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
|
||||
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
|
||||
|
||||
FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1");
|
||||
|
||||
assertThat(response.convertedQuantity()).isEqualByComparingTo("167.017");
|
||||
assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017");
|
||||
assertThat(response.subtotalAmount()).isEqualByComparingTo("391.77");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("401.77");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不包税CBM按配置系数折算且精度边界向上取整")
|
||||
void calculatesTaxExcludedCbmAtRoundingBoundary() {
|
||||
FbaPricingRepository repository = readyRepository();
|
||||
InboundPricingEntity inbound = inbound("363.001", "0.5000");
|
||||
CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING");
|
||||
CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
|
||||
when(repository.matchRates(inbound, "CBM", NOW)).thenReturn(List.of(rate));
|
||||
|
||||
FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2");
|
||||
|
||||
assertThat(response.convertedQuantity()).isEqualByComparingTo("1.0001");
|
||||
assertThat(response.chargeableQuantity()).isEqualByComparingTo("1.0001");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("100.01");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核未通过的测量快照禁止计费")
|
||||
void rejectsUnapprovedMeasurementSnapshot() {
|
||||
FbaPricingRepository repository = readyRepository();
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setMeasureReviewStatus("REJECTED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("审核通过");
|
||||
verify(repository, never()).matchRules(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无客户规则和多客户规则都明确拒绝")
|
||||
void rejectsMissingAndAmbiguousRules() {
|
||||
FbaPricingRepository missing = readyRepository();
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
when(missing.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(missing.matchRules(inbound, NOW)).thenReturn(List.of());
|
||||
assertThatThrownBy(() -> service(missing).calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则");
|
||||
|
||||
FbaPricingRepository ambiguous = readyRepository();
|
||||
when(ambiguous.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(ambiguous.matchRules(inbound, NOW)).thenReturn(List.of(
|
||||
rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING")));
|
||||
assertThatThrownBy(() -> service(ambiguous).calculate(TENANT, OPERATOR, INBOUND, NOW, "many"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一幂等键重复试算直接返回原费用快照")
|
||||
void returnsOriginalSnapshotForRepeatedCalculation() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED"));
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("10", "1"));
|
||||
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
||||
when(repository.findBaseItem(TENANT, 501L)).thenReturn(item);
|
||||
|
||||
FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
|
||||
|
||||
assertThat(response.feeId()).isEqualTo(501L);
|
||||
verify(repository, never()).matchRules(any(), any());
|
||||
verify(repository, never()).insertFee(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
InboundFeeEntity confirmed = savedFee("CONFIRMED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(confirmed);
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once"))
|
||||
.thenReturn(null, completed("FEE_SEND", 501L));
|
||||
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
||||
when(repository.markInboundWaitingPallet(TENANT, INBOUND)).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
|
||||
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
||||
when(repository.findBaseItem(TENANT, 501L)).thenReturn(item);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
|
||||
.when(repository).insertIdempotency(any());
|
||||
|
||||
FbaPricingService service = service(repository);
|
||||
service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
|
||||
verify(repository, times(1)).insertOutbox(any());
|
||||
verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一幂等键用于不同试算时点会冲突")
|
||||
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
|
||||
completed.setRequestHash("different");
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
|
||||
|
||||
assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已发布费用可生成新版本且不回退已入库状态")
|
||||
void adjustsPublishedFeeWithoutRollingBackInbound() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setProcessStatus("INBOUNDED");
|
||||
inbound.setFeeStatus("SENT");
|
||||
InboundFeeEntity current = savedFee("SENT");
|
||||
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "adjust-1")).thenReturn(null);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; })
|
||||
.when(repository).insertIdempotency(any());
|
||||
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
|
||||
when(repository.supersedeSent(TENANT, 501L)).thenReturn(1);
|
||||
doAnswer(invocation -> {
|
||||
InboundFeeEntity fee = invocation.getArgument(0);
|
||||
fee.setId(502L);
|
||||
inserted.set(fee);
|
||||
return null;
|
||||
}).when(repository).insertFee(any());
|
||||
when(repository.completeIdempotency(any(), eq(502L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 502L)).thenAnswer(invocation -> inserted.get());
|
||||
|
||||
FeeSnapshotResponse response = service(repository).adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||
new BigDecimal("100.00"), new BigDecimal("20.00"), "客服修正"), "adjust-1");
|
||||
|
||||
assertThat(response.feeVersion()).isEqualTo(2);
|
||||
assertThat(response.feeStatus()).isEqualTo("SENT");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
|
||||
assertThat(inbound.getProcessStatus()).isEqualTo("INBOUNDED");
|
||||
verify(repository).supersedeSent(TENANT, 501L);
|
||||
verify(repository).insertOutbox(any());
|
||||
}
|
||||
|
||||
private FbaPricingRepository readyRepository() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
|
||||
.when(repository).insertIdempotency(any());
|
||||
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(501L); return null; })
|
||||
.when(repository).insertFee(any());
|
||||
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1);
|
||||
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any())).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
return repository;
|
||||
}
|
||||
|
||||
private FbaPricingService service(FbaPricingRepository repository) {
|
||||
return new FbaPricingService(repository,
|
||||
Clock.fixed(Instant.parse("2026-08-03T02:00:00Z"), ZoneId.of("Asia/Shanghai")));
|
||||
}
|
||||
|
||||
private InboundPricingEntity inbound(String weight, String volume) {
|
||||
InboundPricingEntity value = new InboundPricingEntity();
|
||||
value.setId(INBOUND); value.setTenantId(TENANT); value.setInboundCode("RK-001");
|
||||
value.setProcessStatus("WAIT_PRICING"); value.setMeasureReviewStatus("APPROVED");
|
||||
value.setFeeStatus("UNPRICED"); value.setCustomerId(11L); value.setCustomerName("客户A");
|
||||
value.setWarehouseId(21L); value.setWarehouseName("义乌仓"); value.setBusinessType("FBA_SEA");
|
||||
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
|
||||
value.setTaxMode("TAX_INCLUDED"); value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
|
||||
value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
|
||||
return value;
|
||||
}
|
||||
|
||||
private CustomerChargeRuleEntity rule(String mode, String factor, int scale, String rounding) {
|
||||
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
|
||||
value.setId(101L); value.setRuleCode("CR-001"); value.setRuleVersion(1);
|
||||
value.setChargeMode(mode); value.setConversionFactor(new BigDecimal(factor));
|
||||
value.setRoundingScale(scale); value.setRoundingMode(rounding); value.setCurrency("USD");
|
||||
return value;
|
||||
}
|
||||
|
||||
private CarrierFbaRateEntity rate(String unit, String price, String additional) {
|
||||
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
|
||||
value.setId(201L); value.setRateCode("RT-001"); value.setRateVersion(1);
|
||||
value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓");
|
||||
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
|
||||
value.setDestinationCode("ONT8"); value.setChargeUnit(unit); value.setUnitPrice(new BigDecimal(price));
|
||||
value.setCurrency("USD"); value.setAdditionalFee(new BigDecimal(additional));
|
||||
value.setEffectiveFrom(NOW.minusDays(1)); value.setEffectiveTo(NOW.plusDays(1));
|
||||
return value;
|
||||
}
|
||||
|
||||
private InboundPricingIdempotencyEntity completed(String operation, long feeId) {
|
||||
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
|
||||
value.setId(900L); value.setOperation(operation); value.setStatus("COMPLETED");
|
||||
value.setResultResourceId(feeId);
|
||||
// 试算重复用例的摘要由服务算法固定;其余用例在首次调用后才返回该记录。
|
||||
value.setRequestHash("FEE_SEND".equals(operation)
|
||||
? "3be2051b9bc4c06a163cb3099fd7b3f828b51307cc54d3cfc232c11353c61046"
|
||||
: "8aa8a9861d4cac0c6001bb902754c152c6211ba57e1fad9160b1caef651e3d65");
|
||||
return value;
|
||||
}
|
||||
|
||||
private InboundFeeEntity savedFee(String status) {
|
||||
InboundFeeEntity value = new InboundFeeEntity();
|
||||
value.setId(501L); value.setTenantId(TENANT); value.setInboundId(INBOUND); value.setFeeNo("FBAF-30001-1");
|
||||
value.setFeeVersion(1); value.setFeeStatus(status); value.setChargeRuleId(101L);
|
||||
value.setRuleCode("CR-001"); value.setChargeRuleVersion(1); value.setCarrierRateId(201L);
|
||||
value.setRateCode("RT-001"); value.setCarrierRateVersion(1); value.setCustomerId(11L);
|
||||
value.setCustomerName("客户A"); value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓");
|
||||
value.setBusinessType("FBA_SEA"); value.setChannelCode("SEA-TAX"); value.setCarrierId(31L);
|
||||
value.setCarrierName("船司A"); value.setDestinationCode("ONT8"); value.setTaxMode("TAX_INCLUDED");
|
||||
value.setChargeMode("KG"); value.setConversionFactor(new BigDecimal("167")); value.setRoundingScale(3);
|
||||
value.setRoundingMode("CEILING"); value.setActualWeightKg(new BigDecimal("10"));
|
||||
value.setActualVolumeCbm(BigDecimal.ONE); value.setConvertedQuantity(new BigDecimal("167"));
|
||||
value.setChargeableQuantity(new BigDecimal("167")); value.setChargeUnit("KG");
|
||||
value.setSubtotalAmount(new BigDecimal("334")); value.setAdditionalAmount(BigDecimal.ZERO);
|
||||
value.setTotalAmount(new BigDecimal("334")); value.setCurrency("USD"); value.setCalculatedAt(NOW);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user