fix(firstleg): allow fee revision until final fulfillment lock

This commit is contained in:
2026-09-10 11:02:45 +08:00
parent a926a23b87
commit 8731efcbea
16 changed files with 294 additions and 108 deletions
@@ -61,6 +61,13 @@ class InboundMeasurementEvidenceOpenApiContractTest {
assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys(
"inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm",
"widthCm", "heightCm", "intelligentImageFileId", "remark");
String strategyPath = "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/calculation-strategy";
assertThat(paths).containsKey(strategyPath);
Map<String, Object> strategyOperation = stringMap(stringMap(paths.get(strategyPath)).get("put"));
assertThat(strategyOperation.get("description").toString())
.contains("不覆盖整批测量数据", "不改变测量提交或审核状态", "客户已确认费用且该票已装柜完成");
assertThat(properties(schemas, "InboundMeasurementCalculationStrategyRequest"))
.containsKeys("measurementId", "version", "coefficientMode", "chargeRuleId");
Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys(
@@ -6,7 +6,6 @@ import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -29,7 +28,6 @@ class LoadingDeliveryServiceTest {
@MockBean LoadingCompletionGateService completionGate;
@MockBean InboundStatusHistoryMapper histories;
@MockBean CabinetLoadingEvidenceService cabinetEvidence;
@MockBean FbaPricingService pricingService;
@MockBean ObjectMapper objectMapper;
@MockBean InboundHoldGuard inboundHoldGuard;
@Resource LoadingDeliveryService service;
@@ -50,7 +48,6 @@ class LoadingDeliveryServiceTest {
new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done"));
verify(cabinetEvidence).requireComplete(99L);
verify(pricingService).autoConfirmAfterLoading(10L, 20L);
}
@Test
@@ -91,7 +88,6 @@ class LoadingDeliveryServiceTest {
verify(repository).findInboundForUpdate(10L, 21L);
verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
verify(pricingService).autoConfirmAfterLoading(10L, 21L);
}
@Test
@@ -77,48 +77,6 @@ class FbaPricingServiceTest {
verify(repository, never()).findLatestFee(TENANT, INBOUND);
}
@Test
@DisplayName("装柜完成后系统自动确认客户未确认的已发送费用")
void automaticallyConfirmsSentFeeAfterLoading() {
InboundFeeEntity sent = savedFee("SENT");
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.confirmFeeByCustomer(TENANT, 501L, 1, 0L,
"装柜完成自动确认", NOW)).thenReturn(1);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository).confirmFeeByCustomer(TENANT, 501L, 1, 0L,
"装柜完成自动确认", NOW);
}
@Test
@DisplayName("客户已经确认时装柜完成不覆盖原确认信息")
void keepsExistingCustomerConfirmationAfterLoading() {
InboundFeeEntity sent = savedFee("SENT");
sent.setCustomerConfirmedBy(88L);
sent.setCustomerConfirmedByName("客户成员");
sent.setCustomerConfirmedAt(NOW.minusHours(1));
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
anyLong(), anyString(), any());
}
@Test
@DisplayName("没有已发送费用时装柜完成不受影响")
void ignoresMissingOrUnsentFeeAfterLoading() {
when(repository.findLatestFeeForUpdate(TENANT, INBOUND))
.thenReturn(null, savedFee("CONFIRMED"));
service.autoConfirmAfterLoading(TENANT, INBOUND);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
anyLong(), anyString(), any());
}
@Test
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
@@ -289,17 +247,27 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("装柜完成后禁止确认费用")
void rejectsConfirmAfterLoadingCompleted() {
@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);
assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded"))
.isInstanceOf(BizException.class)
.hasMessageContaining("装柜流程");
verify(repository, never()).markInboundConfirmed(TENANT, INBOUND);
FeeSnapshotResponse response = service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded");
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@Test
@@ -335,11 +303,11 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("客户未确认时待装柜订单可重新计算并替换已发送费用")
@DisplayName("客户未确认时装柜完成订单仍可重新计算并替换已发送费用")
void recalculatesSentFeeBeforeCustomerConfirmation() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_PALLET");
inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("SENT");
InboundFeeEntity sent = savedFee("SENT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -354,18 +322,18 @@ class FbaPricingServiceTest {
assertThat(response.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLET");
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLET", "CBM",
verify(repository).markInboundCalculated(TENANT, INBOUND, "LOADED", "CBM",
new BigDecimal("1.0000"));
}
@Test
@DisplayName("客户已确认时拒绝重新计算已发送费用")
@DisplayName("客户已确认且装柜完成时拒绝重新计算已发送费用")
void rejectsRecalculationAfterCustomerConfirmation() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_PALLET");
inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("SENT");
InboundFeeEntity sent = savedFee("SENT");
sent.setCustomerConfirmedAt(NOW);
@@ -493,6 +461,7 @@ class FbaPricingServiceTest {
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("SENT");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW.minusHours(1));
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
@@ -501,7 +470,7 @@ class FbaPricingServiceTest {
.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("SENT"), eq("KG"), any()))
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("SENT"), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
@@ -561,10 +530,10 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("客户已确认费用时拒绝平台再次修改")
@DisplayName("客户已确认且装柜完成时拒绝平台再次修改")
void rejectsAdjustmentAfterCustomerConfirmation() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setProcessStatus("LOADED");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -596,7 +565,7 @@ class FbaPricingServiceTest {
.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()))
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
@@ -655,7 +624,7 @@ class FbaPricingServiceTest {
.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()))
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; })
.when(repository).insertFee(any());
@@ -11,12 +11,14 @@ import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -57,6 +59,7 @@ import static org.mockito.Mockito.when;
class InboundMeasurementServiceTest {
@MockBean private InboundMeasurementRepository repository;
@MockBean private MeasurementBatchService batchService;
@MockBean private FbaPricingRepository pricingRepository;
@MockBean private FbaMasterDataService fbaMasterDataService;
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient;
@@ -90,6 +93,52 @@ class InboundMeasurementServiceTest {
verify(repository, never()).listWeights(anyLong(), anyLong());
}
@Test
void updatesSubmittedCalculationStrategyWithoutReplacingMeasurementBatch() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setTenantId(1L);
measurement.setInboundId(10L);
measurement.setStatus("SUBMITTED");
measurement.setVersion(5);
measurement.setTotalVolumeCbm(new BigDecimal("2.5000"));
measurement.setVolumeWeightKg(new BigDecimal("417.500"));
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(31L);
item.setTenantId(1L);
item.setMeasurementId(30L);
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
when(repository.lockMeasurement(1L, 10L, 30L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.updateMeasurement(measurement)).thenReturn(true);
when(repository.updateInbound(inbound)).thenReturn(true);
var response = service.updateCalculationStrategy(1L, 10L,
new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ADD", null));
assertThat(response.status()).isEqualTo("SUBMITTED");
verify(batchService).recalculateStrategy(1L, measurement, List.of(item), "ADD", null);
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.5000");
}
@Test
void locksCalculationStrategyOnlyAfterCustomerConfirmationAndLoadingCompletion() {
InboundOrderEntity inbound = inbound("LOADED");
InboundFeeEntity fee = new InboundFeeEntity();
fee.setCustomerConfirmedAt(LocalDateTime.now());
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
when(pricingRepository.findLatestFeeForUpdate(1L, 10L)).thenReturn(fee);
assertThatThrownBy(() -> service.updateCalculationStrategy(1L, 10L,
new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ACTUAL", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("客户已确认且该票已装柜完成");
verify(repository, never()).lockMeasurement(anyLong(), anyLong(), anyLong());
}
@Test
void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -26,6 +26,7 @@ import java.util.List;
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.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -97,6 +98,41 @@ class MeasurementBatchServiceTest {
assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L);
}
@Test
void recalculatesSubmittedItemsInPlaceWithoutChangingTheirIdentityOrStatus() {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(100L);
measurement.setTenantId(1L);
measurement.setInboundId(10L);
measurement.setSourceType("MANUAL");
measurement.setStatus("SUBMITTED");
measurement.setVersion(4);
measurement.setCreatedBy(9L);
InboundMeasurementItemEntity existing = new InboundMeasurementItemEntity();
existing.setId(200L);
existing.setTenantId(1L);
existing.setMeasurementId(100L);
existing.setInboundDetailId(80001L);
existing.setBoxCode("RK-001#1");
existing.setItemNo("001");
existing.setBoxSpecification("40x30x20cm");
existing.setPackageCount(2);
existing.setLengthCm(new BigDecimal("40"));
existing.setWidthCm(new BigDecimal("30"));
existing.setHeightCm(new BigDecimal("20"));
when(repository.updateItem(existing)).thenReturn(true);
service.recalculateStrategy(1L, measurement, List.of(existing), "ACTUAL", null);
assertThat(existing.getId()).isEqualTo(200L);
assertThat(existing.getBoxCode()).isEqualTo("RK-001#1");
assertThat(existing.getOuterLengthCm()).isEqualByComparingTo("40.000");
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("0.0480");
verify(repository).updateItem(existing);
verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
}
@Test
void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() {
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();