Files
baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
T

1130 lines
65 KiB
Java

package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
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.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.mapper.ContainerTierPricingMapper;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.annotations.Select;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.concurrent.atomic.AtomicReference;
import java.util.List;
import java.util.Map;
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.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;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("FBA费用状态机")
@SpringJUnitConfig(classes = {FbaPricingService.class, FbaPricingServiceTest.TestConfig.class})
class FbaPricingServiceTest {
@MockBean private FinanceFeeSourceOutboxService financeOutbox;
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);
@MockBean private FbaPricingRepository repository;
@MockBean private com.baiyue.firstleg.repository.InboundMeasurementRepository measurementRepository;
@MockBean private InboundFeeDetailService feeDetails;
@MockBean private ActivePriceBookEstimator priceBookEstimator;
@MockBean private ContainerTierAmountAllocator containerTierAllocator;
@Resource private FbaPricingService service;
@Test
@DisplayName("客户B不能读取客户A的费用快照")
void rejectsCrossCustomerFeeLookup() {
assertThatThrownBy(() -> service.latest(10002L, INBOUND))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
verify(repository).findLatestFee(10002L, INBOUND);
verify(repository, never()).findLatestFee(TENANT, INBOUND);
}
@Test
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0001");
inbound.setCarrierId(null);
inbound.setCarrierName(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("KG", "167.017", "2.345678", "391.77", "10.00"));
FeeSnapshotResponse response = service.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.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());
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(fee.getValue().getChargeRuleId()).isNull();
assertThat(fee.getValue().getCarrierId()).isNull();
assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT);
assertThat(fee.getValue().getConversionFactor()).isEqualByComparingTo("167");
assertThat(fee.getValue().getRoundingScale()).isEqualTo(6);
assertThat(fee.getValue().getRoundingMode()).isEqualTo("HALF_UP");
}
@Test
@DisplayName("外箱规计费保存测量批次和尺寸来源快照")
void bindsSavedOuterMeasurementWhenCalculatingCustomerFee() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "1.0019");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(301L);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setOuterLengthCm(new BigDecimal("34"));
item.setOuterWidthCm(new BigDecimal("24"));
item.setOuterHeightCm(new BigDecimal("64"));
item.setCalculationRuleVersion("CUSTOMER_DIMENSION_SMALL_V3");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(item));
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("CBM", "1.0019", "100", "100.19", "0"));
service.calculate(TENANT, OPERATOR, INBOUND, NOW,
new BigDecimal("62.500"), new BigDecimal("1.0019"),
"OUTER", "OUTER", "calc-outer");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getMeasurementId()).isEqualTo(301L);
assertThat(fee.getValue().getMeasurementSource()).isEqualTo("OUTER");
assertThat(fee.getValue().getActualVolumeCbm()).isEqualByComparingTo("1.0019");
}
@Test
@DisplayName("指定实际测量来源时必须存在测量批次和明细")
void requiresActualMeasurementDetailsForActualSource() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "0.7880");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW,
new BigDecimal("100.000"), new BigDecimal("0.7880"),
"ACTUAL", "ACTUAL", "calc-actual-missing"))
.isInstanceOf(BizException.class)
.hasMessageContaining("实际测量数据不存在");
verify(priceBookEstimator, never()).estimate(any());
verify(repository, never()).insertFee(any());
}
@Test
@DisplayName("订单客户字段为空时使用租户归属生成对客费用")
void usesTenantOwnershipWhenOrderCustomerIsMissing() {
readyRepository();
InboundPricingEntity inbound = inbound("200.000", "0.2000");
inbound.setCustomerId(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("KG", "200.000", "2.000000", "400.00", "0.00"));
assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer"))
.isNotNull();
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT);
}
@Test
@DisplayName("不包税CBM使用生效价格表返回的计费量")
void calculatesTaxExcludedCbmFromActivePriceBook() {
readyRepository();
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(anyLong(), any())).thenReturn(
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
FeeSnapshotResponse response = service.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");
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
@DisplayName("待分柜边界可独立计费且不回退主流程状态")
void calculatesAtWaitSplitWithoutRollingBackProcessStatus() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0000");
inbound.setProcessStatus("WAIT_SPLIT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
new BigDecimal("100.000"), new BigDecimal("1.0000"), new BigDecimal("167.000"));
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 501L, OPERATOR, NOW);
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
/** 待组托、待分柜确认费用均不推进物流主流程。 */
@ParameterizedTest
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
@DisplayName("待组托或待分柜确认费用后仍保持主流程状态")
void confirmsWithoutRollingBackProcessStatus(String processStatus) {
InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus(processStatus);
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-wait-split"))
.thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
.when(repository).insertIdempotency(any());
when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 501L)).thenReturn(fee);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
FeeSnapshotResponse response = service.confirm(
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
assertThat(response.nextProcessStatus()).isEqualTo(processStatus);
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@Test
@DisplayName("装柜完成但客户未确认时仍允许确认新费用版本")
void confirmsNewFeeVersionAfterLoadingCompleted() {
InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-loaded"))
.thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(899L); return null; })
.when(repository).insertIdempotency(any());
when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 501L)).thenReturn(fee);
FeeSnapshotResponse response = service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded");
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@Test
@DisplayName("审核未通过的测量快照也允许计费")
void allowsUnapprovedMeasurementSnapshot() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
inbound.setMeasureReviewStatus("REJECTED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("CBM", "1.0000", "10", "10", "0"));
assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")).isNotNull();
verify(priceBookEstimator).estimate(eq(TENANT), any());
}
@Test
@DisplayName("一件代发待合托状态允许初算且不改变作业状态")
void calculatesDropshipFeeWhileWaitingForPalletization() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_PALLETIZE");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("CBM", "1.0000", "10", "10", "0"));
FeeSnapshotResponse response = service.calculate(
TENANT, OPERATOR, INBOUND, NOW, "dropship-initial-pricing");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLETIZE");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLETIZE", "CBM",
new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000"));
}
@Test
@DisplayName("客户未确认时装柜完成订单仍可重新计算并替换已发送费用")
void recalculatesSentFeeBeforeCustomerConfirmation() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("SENT");
InboundFeeEntity sent = savedFee("SENT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1);
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("CBM", "1.0000", "10", "10", "0"));
FeeSnapshotResponse response = service.calculate(
TENANT, OPERATOR, INBOUND, NOW, "resend-before-confirm");
assertThat(response.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "LOADED", "CBM",
new BigDecimal("10.000"), new BigDecimal("1.0000"), new BigDecimal("1.0000"));
}
@Test
@DisplayName("客户已确认且装柜完成时拒绝重新计算已发送费用")
void rejectsRecalculationAfterCustomerConfirmation() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("SENT");
InboundFeeEntity sent = savedFee("SENT");
sent.setCustomerConfirmedAt(NOW);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
assertThatThrownBy(() -> service.calculate(
TENANT, OPERATOR, INBOUND, NOW, "resend-after-confirm"))
.isInstanceOf(BizException.class)
.hasMessageContaining("客户已确认");
verify(priceBookEstimator, never()).estimate(any());
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
verify(repository, never()).insertFee(any());
}
@Test
@DisplayName("当前生效价格表无匹配价格时明确拒绝")
void rejectsMissingActivePriceBookRate() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(anyLong(), any())).thenThrow(
new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格"));
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
.isInstanceOf(BizException.class).hasMessageContaining("没有匹配价格");
verify(repository, never()).insertFee(any());
}
@Test
@DisplayName("FBA价格未命中时按人工单价生成零附加费头程费用")
void createsManualQuoteWhenFbaRateIsMissing() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(301L);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(item));
when(priceBookEstimator.estimate(anyLong(), any())).thenThrow(
new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格"));
FeeSnapshotResponse result = service.manualQuote(TENANT, OPERATOR, INBOUND,
new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"),
new BigDecimal("12.2000"), "FBA价格未维护"), "manual-1");
assertThat(result.rateCode()).isEqualTo("MANUAL_QUOTE");
assertThat(result.unitPrice()).isEqualByComparingTo("12.2000");
assertThat(result.chargeableQuantity()).isEqualByComparingTo("64.128000");
assertThat(result.additionalAmount()).isEqualByComparingTo("0.00");
assertThat(result.totalAmount()).isEqualByComparingTo("782.36");
assertThat(result.carrierRateId()).isNull();
ArgumentCaptor<InboundFeeItemEntity> feeItem = ArgumentCaptor.forClass(InboundFeeItemEntity.class);
verify(repository).insertItem(feeItem.capture());
assertThat(feeItem.getValue().getAutomaticFlag()).isFalse();
assertThat(feeItem.getValue().getRemark()).isEqualTo("FBA价格未维护");
}
@Test
@DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价")
void rejectsManualQuoteWhenRateExists() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(301L);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(new InboundMeasurementItemEntity()));
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("KG", "64.128", "12.2", "782.36", "0"));
assertThatThrownBy(() -> service.manualQuote(TENANT, OPERATOR, INBOUND,
new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"),
new BigDecimal("12.2000"), "人工价格"), "manual-existing"))
.isInstanceOf(BizException.class).hasMessageContaining("已有匹配价格");
verify(repository, never()).insertFee(any());
}
@Test
@DisplayName("同一幂等键重复试算直接返回原费用快照")
void returnsOriginalSnapshotForRepeatedCalculation() {
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
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);
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
when(repository.findBaseItem(TENANT, 501L)).thenReturn(item);
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
assertThat(response.feeId()).isEqualTo(501L);
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository, never()).matchRules(any(), any());
verify(repository, never()).insertFee(any());
}
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
@ParameterizedTest
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus(processStatus);
inbound.setFeeStatus("CONFIRMED");
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.markInboundFeeSent(TENANT, INBOUND, processStatus)).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());
FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once");
FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once");
assertThat(first.nextProcessStatus()).isEqualTo(expectedStatus);
assertThat(repeated.nextProcessStatus()).isEqualTo(expectedStatus);
verify(financeOutbox, times(1)).publishInbound(
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
eq(501L), eq(1), eq(NOW));
verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW);
verify(feeDetails, times(2)).publishFreight(TENANT, INBOUND, 501L, OPERATOR, null, NOW);
}
@Test
@DisplayName("已入库费用发送只发布费用且不回退主流程状态")
void sendsFeeAfterInboundWithoutRollingBackProcessStatus() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("CONFIRMED");
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-inbounded")).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
.when(repository).insertIdempotency(any());
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
when(repository.markInboundFeeSent(TENANT, INBOUND, "WAIT_SPLIT")).thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
FeeSnapshotResponse response = service.send(TENANT, OPERATOR, INBOUND, "send-inbounded");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundFeeSent(TENANT, INBOUND, "WAIT_SPLIT");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
}
@Test
@DisplayName("同一幂等键用于不同试算时点会冲突")
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
completed.setRequestHash("different");
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same"))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
}
@Test
@DisplayName("已发布费用可生成新版本且不回退已入库状态")
void adjustsPublishedFeeWithoutRollingBackInbound() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
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.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("SENT"),
any(), any(), eq("KG"), any()))
.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.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
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("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("160.0000");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
}
@Test
@DisplayName("客户确认已发送费用后记录确认人并锁定版本")
void customerConfirmsSentFeeAndLocksVersion() {
InboundFeeEntity current = savedFee("SENT");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
when(repository.findIdempotency(TENANT, OPERATOR, "CUSTOMER_FEE_CONFIRM", "customer-confirm-1"))
.thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(910L); return null; })
.when(repository).insertIdempotency(any());
when(repository.confirmFeeByCustomer(TENANT, 501L, 1, OPERATOR, "客户成员", NOW))
.thenAnswer(invocation -> {
current.setCustomerConfirmedBy(OPERATOR);
current.setCustomerConfirmedByName("客户成员");
current.setCustomerConfirmedAt(NOW);
return 1;
});
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 501L)).thenReturn(current);
FeeSnapshotResponse response = service.confirmByCustomer(
TENANT, OPERATOR, "客户成员", INBOUND, 1, "customer-confirm-1");
assertThat(response.customerConfirmationStatus()).isEqualTo("CONFIRMED");
assertThat(response.customerConfirmedBy()).isEqualTo(OPERATOR);
assertThat(response.customerConfirmedByName()).isEqualTo("客户成员");
assertThat(response.customerConfirmedAt()).isEqualTo(NOW);
}
@Test
@DisplayName("客户确认后即拒绝平台再次修改")
void rejectsAdjustmentAfterCustomerConfirmation() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
BigDecimal.ZERO, "客服修正", true), "locked-adjust"))
.isInstanceOf(BizException.class)
.hasMessageContaining("客户已确认");
verify(repository, never()).insertIdempotency(any());
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
}
@Test
@DisplayName("初算费用调整后保持初算状态且不发布财务来源")
void adjustsCalculatedFeeWithoutPublishingIt() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity current = savedFee("CALCULATED");
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-calculated")).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(902L); 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("WAIT_SPLIT"), eq("CALCULATED"),
any(), any(), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
fee.setId(503L);
inserted.set(fee);
return null;
}).when(repository).insertFee(any());
when(repository.completeIdempotency(any(), eq(503L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 503L)).thenAnswer(invocation -> inserted.get());
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("160"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正", true), "adjust-calculated");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
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);
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
@DisplayName("费用版本已变化时拒绝覆盖当前版本")
void rejectsAdjustmentWithStaleFeeVersion() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
InboundFeeEntity current = savedFee("CALCULATED");
current.setFeeVersion(2);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
BigDecimal.ZERO, "客服修正", true), "stale-adjust"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).insertIdempotency(any());
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("WAIT_SPLIT"), eq("CALCULATED"),
any(), any(), 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());
}
@Test
@DisplayName("同客户同柜区域阶梯按总计费重命中临界档并按票分摊")
void appliesContainerCustomerTierAndAllocatedAmount() {
readyRepository();
InboundPricingEntity inbound = inbound("100", "0.1");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1"),
memberRow(901L, 30002L, "100", "0.1")
));
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(
regionEstimate("100", "10", "1000"), regionEstimate("200", "8", "1600"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
new ContainerTierAmountAllocator.Allocation(new BigDecimal("200"), new BigDecimal("1600"),
Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("800.00"))));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "container-tier");
assertThat(response.chargeableQuantity()).isEqualByComparingTo("100");
assertThat(response.unitPrice()).isEqualByComparingTo("8");
assertThat(response.subtotalAmount()).isEqualByComparingTo("800.00");
assertThat(response.totalAmount()).isEqualByComparingTo("800.00");
ArgumentCaptor<com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest> requests =
ArgumentCaptor.forClass(com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest.class);
verify(priceBookEstimator, times(2)).estimate(eq(TENANT), requests.capture());
assertThat(requests.getAllValues().get(1).actualWeightKg()).isEqualByComparingTo("200");
assertThat(requests.getAllValues().get(1).actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("单票柜次保持原区域阶梯结果,不进行无意义分摊")
void keepsIndividualResultForSingleInboundContainer() {
readyRepository();
InboundPricingEntity inbound = inbound("100", "0.1");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1")
));
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "single-container-tier");
assertThat(response.totalAmount()).isEqualByComparingTo("1000");
verify(containerTierAllocator, never()).allocate(any(), any());
verify(priceBookEstimator).estimate(eq(TENANT), any());
}
@Test
@DisplayName("无柜次关系不改变单票区域阶梯计价")
void keepsIndividualResultWithoutContainerRelation() {
readyRepository();
InboundPricingEntity inbound = inbound("100", "0.1");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of());
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000"));
assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "no-container-tier").totalAmount())
.isEqualByComparingTo("1000");
verify(containerTierAllocator, never()).allocate(any(), any());
}
@Test
@DisplayName("柜次成员查询只按客户和同一柜次隔离")
void scopesContainerTierMembersByCustomerAndContainer() throws NoSuchMethodException {
String sql = ContainerTierPricingMapper.class.getMethod("members", long.class, long.class)
.getAnnotation(Select.class).value()[0];
String lockedSql = ContainerTierPricingMapper.class.getMethod("membersForUpdate", long.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(sql).contains("plan.container_booking_id=current_plan.container_booking_id")
.contains("grouped_item.account_id=#{customerId}")
.contains("grouped_inbound.tenant_id=#{customerId}")
.doesNotContain("grouped_inbound.destination_code")
.doesNotContain("grouped_inbound.destination_warehouse_id");
assertThat(lockedSql).contains("plan.container_booking_id=#{containerBookingId}")
.contains("item.account_id=#{customerId}").contains("FOR UPDATE");
}
@Test
@DisplayName("柜内客户费用重算复用冻结选量和来源,并按统一阶梯分摊")
void recalculatesUnlockedContainerFeesFromFrozenSelections() {
readyRepository();
InboundPricingEntity firstInbound = inbound("999", "9");
InboundPricingEntity secondInbound = inbound("888", "8");
secondInbound.setId(30002L); secondInbound.setInboundCode("RK-002");
InboundFeeEntity first = savedFee("CALCULATED");
first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000"));
first.setMeasurementId(301L); first.setMeasurementSource("OUTER");
InboundFeeEntity second = savedFee("CALCULATED");
second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000"));
second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L);
second.setMeasurementSource("ACTUAL");
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "999", "9"), memberRow(901L, 30002L, "888", "8")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(firstInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(secondInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(first);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(second);
when(repository.findRate(201L)).thenReturn(storedRate(201L, PriceModel.REGION_WEIGHT_TIER));
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(),
any(), any(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(
regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
new ContainerTierAmountAllocator.Allocation(new BigDecimal("200"), new BigDecimal("1600"),
Map.of(INBOUND, new BigDecimal("720.00"), 30002L, new BigDecimal("880.00"))));
service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-1");
ArgumentCaptor<InboundFeeEntity> fees = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository, times(2)).insertFee(fees.capture());
assertThat(fees.getAllValues().get(0).getActualWeightKg()).isEqualByComparingTo("90.000");
assertThat(fees.getAllValues().get(0).getActualVolumeCbm()).isEqualByComparingTo("0.1000");
assertThat(fees.getAllValues().get(0).getMeasurementId()).isEqualTo(301L);
assertThat(fees.getAllValues().get(0).getMeasurementSource()).isEqualTo("OUTER");
assertThat(fees.getAllValues().get(0).getSubtotalAmount()).isEqualByComparingTo("720.00");
assertThat(fees.getAllValues().get(1).getActualWeightKg()).isEqualByComparingTo("110.000");
assertThat(fees.getAllValues().get(1).getMeasurementId()).isEqualTo(302L);
assertThat(fees.getAllValues().get(1).getMeasurementSource()).isEqualTo("ACTUAL");
ArgumentCaptor<com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest> requests =
ArgumentCaptor.forClass(com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest.class);
verify(priceBookEstimator, times(2)).estimate(eq(TENANT), requests.capture());
assertThat(requests.getAllValues()).allSatisfy(request -> {
assertThat(request.actualWeightKg()).isEqualByComparingTo("200");
assertThat(request.actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO);
});
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
@DisplayName("柜内已发送费用保持锁定且不阻断其他未锁定费用重算")
void keepsImmutableContainerFeeAndRecalculatesOnlyUnlockedMembers() {
readyRepository();
InboundPricingEntity lockedInbound = inbound("100", "0.1");
InboundPricingEntity unlockedInbound = inbound("80", "0.1");
unlockedInbound.setId(30002L); unlockedInbound.setInboundCode("RK-002");
InboundFeeEntity sent = savedFee("SENT");
sent.setActualWeightKg(new BigDecimal("100")); sent.setActualVolumeCbm(new BigDecimal("0.1"));
InboundFeeEntity calculated = savedFee("CALCULATED");
calculated.setId(502L); calculated.setInboundId(30002L);
calculated.setActualWeightKg(new BigDecimal("80")); calculated.setActualVolumeCbm(new BigDecimal("0.1"));
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(lockedInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(unlockedInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(calculated);
when(repository.findRate(201L)).thenReturn(storedRate(201L, PriceModel.REGION_WEIGHT_TIER));
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(),
any(), any(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
new ContainerTierAmountAllocator.Allocation(new BigDecimal("180"), new BigDecimal("1440"),
Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("640.00"))));
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED);
assertThat(result.recalculatedCount()).isEqualTo(1);
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getInboundId()).isEqualTo(30002L);
assertThat(fee.getValue().getSubtotalAmount()).isEqualByComparingTo("640.00");
verify(repository, never()).supersedeCurrent(TENANT, 501L, "SENT");
verify(priceBookEstimator).estimate(eq(TENANT), any());
}
@Test
@DisplayName("同柜混合固定仓价和区域阶梯时仅重算区域阶梯成员")
void recalculatesOnlyRegionalMembersWhenContainerUsesMixedPriceModels() {
readyRepository();
InboundPricingEntity regionalInbound = inbound("90", "0.1");
InboundPricingEntity fixedInbound = inbound("110", "0.1");
fixedInbound.setId(30002L); fixedInbound.setInboundCode("RK-FIXED");
InboundFeeEntity regional = savedFee("CALCULATED");
regional.setActualWeightKg(new BigDecimal("90")); regional.setActualVolumeCbm(new BigDecimal("0.1"));
InboundFeeEntity fixed = savedFee("CALCULATED");
fixed.setId(502L); fixed.setInboundId(30002L); fixed.setCarrierRateId(203L);
fixed.setActualWeightKg(new BigDecimal("110")); fixed.setActualVolumeCbm(new BigDecimal("0.1"));
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "90", "0.1"), memberRow(901L, 30002L, "110", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(regionalInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(fixedInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(regional);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(fixed);
when(repository.findRate(201L)).thenReturn(storedRate(201L, PriceModel.REGION_WEIGHT_TIER));
when(repository.findRate(203L)).thenReturn(storedRate(203L, PriceModel.FBA_WAREHOUSE_RATE));
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(INBOUND), anyString(),
any(), any(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("90", "8", "720"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
new ContainerTierAmountAllocator.Allocation(new BigDecimal("90"), new BigDecimal("720"),
Map.of(INBOUND, new BigDecimal("720.00"))));
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "mixed-models");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED);
assertThat(result.recalculatedCount()).isEqualTo(1);
verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED");
verify(repository, never()).supersedeCurrent(TENANT, 502L, "CALCULATED");
ArgumentCaptor<List<ContainerTierAmountAllocator.Member>> members = ArgumentCaptor.forClass(List.class);
verify(containerTierAllocator).allocate(members.capture(), eq(new BigDecimal("8")));
assertThat(members.getValue()).extracting(ContainerTierAmountAllocator.Member::inboundId)
.containsExactly(INBOUND);
}
@Test
@DisplayName("柜内费用全部锁定时关系变更无需重算且不报错")
void skipsContainerRecalculationWhenEveryFeeIsImmutable() {
InboundPricingEntity inbound = inbound("100", "0.1");
InboundFeeEntity confirmed = savedFee("CONFIRMED");
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(confirmed);
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-locked");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_APPLICABLE);
assertThat(result.recalculatedCount()).isZero();
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString());
verify(repository, never()).insertFee(any());
verify(priceBookEstimator, never()).estimate(anyLong(), any());
}
@Test
@DisplayName("待审核或待测量成员无初算费用时保持占位并返回未就绪")
void keepsContainerRelationshipWhenAnyMemberFeeIsNotReady() {
readyRepository();
InboundPricingEntity pricedInbound = inbound("100", "0.1");
InboundPricingEntity pendingInbound = inbound("80", "0.1");
pendingInbound.setId(30002L); pendingInbound.setInboundCode("RK-PENDING");
InboundFeeEntity priced = savedFee("CALCULATED");
priced.setActualWeightKg(new BigDecimal("100")); priced.setActualVolumeCbm(new BigDecimal("0.1"));
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(pricedInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(pendingInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(priced);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(null);
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "pending-member");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY);
assertThat(result.recalculatedCount()).isZero();
verify(repository, never()).insertFee(any());
verify(priceBookEstimator, never()).estimate(anyLong(), any());
}
@Test
@DisplayName("相同柜次重算触发标识重放不重复创建费用版本")
void replaysCompletedContainerRecalculationTrigger() {
InboundPricingIdempotencyEntity completed = completedContainerRecalculation(901L, "same-trigger");
when(repository.findIdempotency(TENANT, 0L, "CONTAINER_FEE_RECALCULATE", "CONTAINER:901:same-trigger"))
.thenReturn(completed);
service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "same-trigger");
verify(repository, never()).containerTierMembersForUpdate(anyLong(), anyLong());
verify(repository, never()).insertFee(any());
}
private void readyRepository() {
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(), any(), any(), any()))
.thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
}
@Configuration
static class TestConfig {
@Bean
Clock clock() {
return 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("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED");
value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
value.setActualGrossWeightKg(new BigDecimal(weight).add(new BigDecimal("8.000")));
value.setActualNetWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
return value;
}
private ActivePriceBookEstimator.Estimate estimate(String unit, String quantity, String price,
String amount, String additional) {
RateRow rate = new RateRow();
rate.setId(201L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-7-1"); rate.setRateVersion(1);
rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK");
rate.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); rate.setDestinationType("FBA");
rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit(unit);
rate.setUnitPrice(new BigDecimal(price)); rate.setCurrency("USD");
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",
"KG".equals(unit) ? new BigDecimal("167") : new BigDecimal("363"), 6, "HALF_UP");
}
private ContainerTierPricingMapper.MemberRow memberRow(long containerId, long inboundId,
String weight, String volume) {
return new ContainerTierPricingMapper.MemberRow(containerId, inboundId,
new BigDecimal(weight), new BigDecimal(volume));
}
private ActivePriceBookEstimator.Estimate regionEstimate(String quantity, String unitPrice, String amount) {
RateRow rate = new RateRow();
rate.setId(202L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-TIER-1"); rate.setRateVersion(1);
rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK");
rate.setPriceModel(PriceModel.REGION_WEIGHT_TIER.name()); rate.setDestinationType("FBA");
rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit("KG");
rate.setUnitPrice(new BigDecimal(unitPrice)); rate.setCurrency("USD"); rate.setAdditionalFee(BigDecimal.ZERO);
rate.setEffectiveFrom(NOW.minusDays(1));
return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.REGION_WEIGHT_TIER,
PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), "KG", new BigDecimal(unitPrice),
new BigDecimal(amount), "USD", new BigDecimal("167"), 6, "HALF_UP");
}
private CarrierFbaRateEntity storedRate(long id, PriceModel model) {
CarrierFbaRateEntity rate = new CarrierFbaRateEntity();
rate.setId(id); rate.setPriceModel(model.name());
return rate;
}
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"
: "1b874a4f469151f236aa6086835f3cac22cca5cb4a1b90444f08b27b76e8a3c4");
return value;
}
private InboundPricingIdempotencyEntity completedContainerRecalculation(long containerBookingId, String trigger) {
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
value.setId(900L); value.setOperation("CONTAINER_FEE_RECALCULATE"); value.setStatus("COMPLETED");
value.setResultResourceId(containerBookingId);
try {
value.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(("CONTAINER_FEE_RECALCULATE:" + containerBookingId + ':' + trigger)
.getBytes(java.nio.charset.StandardCharsets.UTF_8))));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new AssertionError(exception);
}
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;
}
}