fix: close inbound hold bypass paths

This commit is contained in:
2026-09-07 12:10:11 +08:00
parent 6b479d1a68
commit 41582fe17b
28 changed files with 899 additions and 11 deletions
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.multipart.MultipartFile;
import org.yaml.snakeyaml.Yaml;
import java.lang.reflect.Method;
@@ -22,6 +23,10 @@ class InboundHoldContractTest {
"holdDetail", AuthenticatedUser.class, long.class, long.class);
Method hold = commandMethod("hold");
Method release = commandMethod("releaseHold");
Method upload = PlatformInboundOrderController.class.getDeclaredMethod(
"uploadHoldEvidence", AuthenticatedUser.class, long.class, long.class, MultipartFile.class);
Method download = PlatformInboundOrderController.class.getDeclaredMethod(
"downloadHoldEvidence", AuthenticatedUser.class, long.class, long.class, long.class);
assertThat(detail.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
@@ -29,6 +34,10 @@ class InboundHoldContractTest {
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
assertThat(release.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
assertThat(upload.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
assertThat(download.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
@@ -43,7 +52,9 @@ class InboundHoldContractTest {
Map<String, Object> paths = stringMap(document.get("paths"));
assertThat(paths.keySet()).contains(
"/api/platform/inbounds/{inboundId}/hold",
"/api/platform/inbounds/{inboundId}/hold/release");
"/api/platform/inbounds/{inboundId}/hold/release",
"/api/platform/inbounds/{inboundId}/hold/evidence",
"/api/platform/inbounds/{inboundId}/hold/evidence/{fileId}/download");
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> command = stringMap(schemas.get("InboundHoldCommand"));
@@ -62,6 +73,10 @@ class InboundHoldContractTest {
assertThat(stringMap(stringMap(detail.get("properties")).get("holdFlag")))
.containsEntry("type", "boolean").doesNotContainKey("nullable");
Map<String, Object> evidence = stringMap(schemas.get("InboundHoldEvidenceFile"));
assertThat(stringMap(evidence.get("properties")).keySet())
.containsExactlyInAnyOrder("fileId", "fileName", "contentType", "sizeBytes");
Map<String, Object> history = stringMap(schemas.get("InboundHoldHistoryItem"));
assertThat(stringMap(history.get("properties")).keySet()).containsExactlyInAnyOrder(
"id", "action", "reason", "evidenceFileId", "operatorName", "operatedAt");
@@ -7,6 +7,9 @@ import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.Preloa
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -24,8 +27,27 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LoadingModelBridgeServiceTest {
@Mock LoadingModelBridgeRepository repository;
@Mock InboundHoldGuard inboundHoldGuard;
@InjectMocks LoadingModelBridgeService service;
@Test
void heldInboundOrPlanBlocksEveryPreloadBindingWriteBoundary() {
doThrow(new BizException(ResultCode.CONFLICT, "入库单已扣货"))
.when(inboundHoldGuard).requireInboundNotHeld(10L, 20L);
doThrow(new BizException(ResultCode.CONFLICT, "入库单已扣货"))
.when(inboundHoldGuard).requirePlanNotHeld(40L);
assertThatThrownBy(() -> service.bind(10L, 20L, 40L, 7L, "admin", "bind-held",
new BindRequest(1, 1))).isInstanceOf(BizException.class).hasMessageContaining("扣货");
assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40L, 7L, "admin", "push-held"))
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
assertThatThrownBy(() -> service.bindIncrementalAllocation(
40L, 10L, 20L, 7L, "admin", "incremental-held"))
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
verifyNoInteractions(repository);
}
@Test
void bindsManyToManyModelsWithBothOptimisticLocks() {
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
@@ -194,6 +194,23 @@ class OutboundDispatchServiceTest {
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
}
@Test
void heldInboundBlocksContainerReverseBeforeAnyRowChanges() {
OutboundDispatchService service = serviceWithoutCarrier();
List<OutboundDispatchReadMapper.Binding> held = List.of(
binding(11L, "I-11", true), binding(12L, "I-12", false));
stubContainer(held);
when(repository.lockBindings(22L)).thenReturn(held);
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1));
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1));
when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-held"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-held",
new ActionRequest(1, "复核"))).isInstanceOf(BizException.class).hasMessageContaining("扣货");
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
}
@Test
void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() {
OutboundDispatchService service = serviceWithoutCarrier();
@@ -265,8 +282,12 @@ class OutboundDispatchServiceTest {
}
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) {
return binding(inboundId, inboundCode, false);
}
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode, boolean held) {
return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A",
"LOADED", false, 22L, "CAB-22", "C-22", 44L, "COMPLETED");
"LOADED", held, 22L, "CAB-22", "C-22", 44L, "COMPLETED");
}
private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) {
@@ -0,0 +1,106 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.InboundHoldFileClient;
import com.baiyue.firstleg.dto.InboundHoldDtos.Detail;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InboundHoldEvidenceServiceTest {
private final InboundHoldService holds = mock(InboundHoldService.class);
private final InboundHoldRecordMapper history = mock(InboundHoldRecordMapper.class);
private final InboundHoldFileClient files = mock(InboundHoldFileClient.class);
private InboundHoldEvidenceService service;
@BeforeEach
void setUp() {
service = new InboundHoldEvidenceService(holds, history, files, "internal-token");
}
@Test
void uploadsCustomerOwnedEvidenceForActionableInbound() {
when(holds.detail(7L, 11L)).thenReturn(detail(false, true, false));
var file = new MockMultipartFile("file", "hold.pdf", "application/pdf", new byte[] {1, 2});
when(files.upload("internal-token", 9L, 7L, 11L, file)).thenReturn(ApiResponse.success(
metadata(88L, 7L, 11L, "hold.pdf", "application/pdf", 2L)));
var result = service.upload(7L, 11L, 9L, file);
assertThat(result.fileId()).isEqualTo(88L);
assertThat(result.fileName()).isEqualTo("hold.pdf");
verify(files).upload("internal-token", 9L, 7L, 11L, file);
}
@Test
void rejectsUploadWhenPhysicalWorkBlocksBothActions() {
when(holds.detail(7L, 11L)).thenReturn(detail(false, false, false));
assertThatThrownBy(() -> service.upload(7L, 11L, 9L,
new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[] {1})))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
}
@Test
void rejectsEvidenceFromAnotherInboundOrCustomer() {
when(files.metadata("internal-token", 7L, 11L, 88L)).thenReturn(ApiResponse.success(
metadata(88L, 8L, 12L, "other.pdf", "application/pdf", 2L)));
assertThatThrownBy(() -> service.validateReference(7L, 11L, 88L))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.BAD_REQUEST));
}
@Test
void downloadsOnlyEvidenceReferencedByImmutableHistory() {
when(holds.detail(7L, 11L)).thenReturn(detail(true, false, true));
when(history.selectCount(any(Wrapper.class))).thenReturn(1L);
when(files.metadata("internal-token", 7L, 11L, 88L)).thenReturn(ApiResponse.success(
metadata(88L, 7L, 11L, "hold.pdf", "application/pdf", 2L)));
when(files.download("internal-token", 7L, 11L, 88L))
.thenReturn(ResponseEntity.ok(new byte[] {1, 2}));
var result = service.download(7L, 11L, 88L);
assertThat(result.fileName()).isEqualTo("hold.pdf");
assertThat(result.contentType()).isEqualTo(MediaType.APPLICATION_PDF);
assertThat(result.content()).containsExactly(1, 2);
}
@Test
void refusesUnreferencedEvidenceDownload() {
when(holds.detail(7L, 11L)).thenReturn(detail(false, true, false));
when(history.selectCount(any(Wrapper.class))).thenReturn(0L);
assertThatThrownBy(() -> service.download(7L, 11L, 88L))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND));
}
private static Detail detail(boolean held, boolean canHold, boolean canRelease) {
return new Detail(11L, 7L, held, held ? "reason" : null, 3, canHold, canRelease,
canHold || canRelease ? null : "physical work", List.of());
}
private static InboundHoldFileClient.Metadata metadata(long fileId, long tenantId, long inboundId,
String name, String type, long size) {
return new InboundHoldFileClient.Metadata(fileId, "CUSTOMER", tenantId, "INBOUND_HOLD_EVIDENCE",
Long.toString(inboundId), name, "sha", type, size);
}
}
@@ -42,6 +42,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -67,6 +68,17 @@ class LoadingListServiceTest {
@Resource
private LoadingListService service;
@Test
void heldPlanBlocksDirectPdaFinalizationBeforeAnyPlanMutation() {
doThrow(new BizException(ResultCode.CONFLICT, "入库单已扣货"))
.when(inboundHoldGuard).requirePlanNotHeld(10L);
assertThatThrownBy(() -> service.finishFromPda(10L, 21L, 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
verifyNoInteractions(repository, pickRepository);
}
@TestConfiguration
static class Config {
@Bean
@@ -106,6 +106,30 @@ class LoadingPickServiceTest {
@Resource
private LoadingPickService service;
@Test
void heldPlanBlocksDirectPdaItemSynchronizationBeforeTaskMutation() {
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货"))
.when(inboundHoldGuard).requirePlanNotHeld(10L);
assertThatThrownBy(() -> service.sync(10L, new LoadingPlanItemEntity()))
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
verify(repository, never()).taskByPlan(anyLong());
}
@Test
void heldTaskStopsDurableCompletionWorkerBeforeAnyFulfillmentReadOrWrite() {
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货"))
.when(inboundHoldGuard).requireTaskNotHeld(20L);
assertThatThrownBy(() -> service.processCompletion(
completionCommand(999L, "held-worker"), "worker-held"))
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
verify(repository, never()).task(anyLong());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
}
@BeforeEach
void activeOperationalContainer() {
doAnswer(invocation -> {
@@ -28,9 +28,11 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(PdaPickSettlementService.class)
@@ -41,8 +43,23 @@ class PdaPickSettlementServiceTest {
@MockBean private InboundPalletAllocationRepository allocations;
@MockBean private WarehousePalletLocationClient warehouse;
@MockBean private OrderFulfillmentSplitClient orderSplits;
@MockBean private InboundHoldGuard inboundHoldGuard;
@Resource private PdaPickSettlementService service;
@Test
void heldTaskBlocksDirectSettlementAndOwnerSettlementBeforeResourceMutation() {
doThrow(new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货"))
.when(inboundHoldGuard).requireTaskNotHeld(1L);
assertThatThrownBy(() -> service.settle(1L, 9L, "operator"))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("扣货");
assertThatThrownBy(() -> service.settleOwner(1L, 31L, 9L, "operator"))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("扣货");
verifyNoInteractions(picks, splits, allocations, warehouse, orderSplits);
}
@Test
void createsChildForAllUnloadedPackagesAndTransfersRemainingAllocations() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 8, "PARTIAL");