fix(firstleg): scope loading evidence to cabinet tasks

This commit is contained in:
2026-08-31 18:14:36 +08:00
parent 4795ab958c
commit df0fbc1c7f
31 changed files with 695 additions and 1314 deletions
@@ -9,28 +9,18 @@ import static org.assertj.core.api.Assertions.assertThat;
class LoadingDeliveryOpenApiContractTest {
@Test
void publishesRealFileFiveEvidenceAndPickingGate() throws Exception {
void removesInboundEvidenceProtocolAndKeepsPickingGate() throws Exception {
String yaml = new ClassPathResource("openapi/phase3-loading-delivery-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/pda:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:")
.contains("required: [expectedVersion, evidenceType, fileId]")
.contains("previewUrl:")
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
.contains("同类型可多次追加")
.contains("Required when evidenceType is SEAL")
.contains("compatibility projection")
.contains("associated inbound process status to be LOADING")
.contains("workflowStatus AWAITING_REVIEW does not bypass this gate")
.contains("must exactly match the server-derived container-task aggregate")
.contains("cannot complete canonical picking");
assertThat(yaml).doesNotContain("/delivery/complete").doesNotContain("DELIVERED");
assertThat(yaml).doesNotContain("/loading/evidence").doesNotContain("EvidenceResponse:")
.doesNotContain("/delivery/complete");
}
}
@@ -1,17 +1,7 @@
package com.baiyue.firstleg.loading.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import java.util.Arrays;
@@ -20,72 +10,11 @@ import static org.assertj.core.api.Assertions.assertThat;
class PlatformLoadingDeliveryControllerContractTest {
@Test
void keepsInboundScopedPathsAndPermissions() throws Exception {
void removesInboundEvidenceProtocolAndKeepsLoadingActions() {
assertThat(PlatformLoadingDeliveryController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/inbounds");
post("evidence", "/{inboundId}/loading/evidence",
"platform:firstleg:inbound:loading:edit", UpsertEvidenceRequest.class);
post("pdaEvidence", "/{inboundId}/loading/evidence/pda",
"platform:firstleg:pda:work", UpsertEvidenceRequest.class);
get("pdaEvidence", "/{inboundId}/loading/evidence/pda",
"platform:firstleg:pda:work");
Method pdaContent = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaEvidenceContent",
AuthenticatedUser.class, long.class, long.class, long.class);
assertThat(pdaContent.getAnnotation(GetMapping.class).value())
.containsExactly("/{inboundId}/loading/evidence/{evidenceId}/content/pda");
assertAuthority(pdaContent, "platform:firstleg:pda:work");
Method pdaVoid = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaVoidEvidence",
AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class);
assertThat(pdaVoid.getAnnotation(PostMapping.class).value())
.containsExactly("/{inboundId}/loading/evidence/{evidenceId}/void/pda");
assertAuthority(pdaVoid, "platform:firstleg:pda:work");
get("evidence", "/{inboundId}/loading/evidence",
"platform:firstleg:inbound:loading:view");
postVoidEvidence();
post("completeLoading", "/{inboundId}/loading/complete",
"platform:firstleg:inbound:loading:complete", CompleteLoadingRequest.class);
post("startPicking", "/{inboundId}/loading/picking/start",
"platform:firstleg:inbound:picking:edit", PickingRequest.class);
post("completePicking", "/{inboundId}/loading/picking/complete",
"platform:firstleg:inbound:picking:edit", PickingRequest.class);
get("picking", "/{inboundId}/loading/picking",
"platform:firstleg:inbound:picking:view");
assertThat(Arrays.stream(PlatformLoadingDeliveryController.class.getDeclaredMethods())
.map(Method::getName)).doesNotContain("completeDelivery");
}
private static void postVoidEvidence() throws Exception {
Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod("voidEvidence",
AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class);
assertThat(method.getAnnotation(PostMapping.class).value())
.containsExactly("/{inboundId}/loading/evidence/{evidenceId}/void");
assertAuthority(method, "platform:firstleg:inbound:loading:edit");
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[4].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
}
private static void post(String name, String path, String authority, Class<?> requestType) throws Exception {
Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod(name,
AuthenticatedUser.class, long.class, long.class, String.class, requestType);
assertThat(method.getAnnotation(PostMapping.class).value()).containsExactly(path);
assertAuthority(method, authority);
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
}
private static void get(String name, String path, String authority) throws Exception {
Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod(name,
AuthenticatedUser.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly(path);
assertAuthority(method, authority);
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class)).isNotNull();
}
private static void assertAuthority(Method method, String authority) {
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
.map(Method::getName)).contains("completeLoading", "startPicking", "completePicking", "picking")
.doesNotContain("evidence", "pdaEvidence", "pdaEvidenceContent", "voidEvidence", "pdaVoidEvidence");
}
}
@@ -11,43 +11,26 @@ import static org.assertj.core.api.Assertions.assertThat;
class LoadingEvidenceSqlContractTest {
@Test
void schemaSupportsAppendOnlyEvidenceAndVoidAudit() throws Exception {
void schemaOwnsEvidenceByCabinetWithOneActiveFactPerType() throws Exception {
String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("file_id BIGINT UNSIGNED NOT NULL")
.contains("KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status)")
.contains("status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'")
.contains("void_reason VARCHAR(500)")
.contains("voided_by BIGINT UNSIGNED")
.contains("voided_at DATETIME(3)")
.contains("DROP INDEX uk_loading_evidence_type")
.contains("DROP INDEX uk_loading_evidence_seal")
.contains("idx_loading_evidence_seal (tenant_id, seal_no, status)")
.contains("fl_inbound_loading_pick_task");
.contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
.contains("source_task_id BIGINT UNSIGNED NOT NULL")
.contains("UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)")
.contains("UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key)")
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)")
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_evidence");
}
@Test
void unifiedEntryMigratesLegacySingleSlotEvidenceWithoutRecreatingUniqueIndexes() throws Exception {
void unifiedEntryContainsCabinetEvidenceTable() throws Exception {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path unified = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String sql = Files.readString(unified, StandardCharsets.UTF_8);
assertThat(sql)
.contains("file_id BIGINT UNSIGNED NOT NULL")
.contains("status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'")
.contains("phase3_loading_evidence_add_column('status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''ACTIVE/VOID''')")
.contains("phase3_loading_evidence_add_column('void_reason'")
.contains("phase3_loading_evidence_add_column('voided_by'")
.contains("phase3_loading_evidence_add_column('voided_by_name'")
.contains("phase3_loading_evidence_add_column('voided_at'")
.contains("DROP INDEX uk_loading_evidence_type")
.contains("DROP INDEX uk_loading_evidence_seal")
.contains("ADD KEY idx_loading_evidence_type (tenant_id,inbound_id,evidence_type,status)")
.contains("ADD KEY idx_loading_evidence_seal (tenant_id,seal_no,status)")
.doesNotContain("phase3_loading_evidence_add_column('status', \"VARCHAR(16)")
.doesNotContain("UNIQUE KEY uk_loading_evidence_type")
.doesNotContain("UNIQUE KEY uk_loading_evidence_seal");
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence")
.contains("uk_cabinet_evidence_active")
.contains("uk_cabinet_evidence_operation");
}
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.loading.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CabinetLoadingEvidenceService.class)
class CabinetLoadingEvidenceServiceTest {
@MockBean CabinetLoadingEvidenceMapper mapper;
@MockBean LoadingPickRepository loadingPicks;
@MockBean LoadingEvidenceFileClient files;
@MockBean ObjectMapper objectMapper;
@Resource CabinetLoadingEvidenceService service;
@BeforeEach
void taskScope() {
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
task.setId(20L); task.setContainerBookingId(99L); task.setTaskStatus("LOADING");
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(99L); booking.setWarehouseId(8L); booking.setRecordStatus("ACTIVE");
LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity();
assignment.setPickTaskId(20L); assignment.setAssigneeId(9L);
when(loadingPicks.task(20L)).thenReturn(task);
when(loadingPicks.activeBookingByTask(20L)).thenReturn(booking);
when(loadingPicks.assignment(20L)).thenReturn(assignment);
}
@Test
void rejectsCrossWarehouseBeforeReadingEvidence() {
assertThatThrownBy(() -> service.list(20L, 7L, 9L)).isInstanceOf(BizException.class);
verify(mapper, never()).byContainer(99L);
}
@Test
void rejectsOperatorWhoDidNotClaimTaskBeforeReadingEvidence() {
assertThatThrownBy(() -> service.list(20L, 8L, 10L)).isInstanceOf(BizException.class);
verify(mapper, never()).byContainer(99L);
}
@Test
void rejectsCrossContainerEvidenceBeforeCallingFile() {
when(mapper.byContainer(99L)).thenReturn(List.of());
assertThatThrownBy(() -> service.content(20L, 8L, 777L, 9L)).isInstanceOf(BizException.class);
verify(files, never()).download(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyLong());
}
@Test
void rejectsTaskWhoseActiveBookingIdentityChanged() {
ContainerBookingEntity another = new ContainerBookingEntity();
another.setId(100L); another.setWarehouseId(8L);
when(loadingPicks.activeBookingByTask(20L)).thenReturn(another);
assertThatThrownBy(() -> service.list(20L, 8L, 9L)).isInstanceOf(BizException.class);
verify(mapper, never()).byContainer(99L);
}
@Test
void cabinetGateAcceptsFiveFactsRegardlessOfOwnerOrder() {
when(mapper.byContainer(99L)).thenReturn(List.of(evidence("SEAL"), evidence("PHOTO_STEP_4"),
evidence("PHOTO_STEP_2"), evidence("PHOTO_STEP_1"), evidence("PHOTO_STEP_3")));
service.requireComplete(99L);
verify(mapper).byContainer(99L);
}
private static CabinetLoadingEvidenceEntity evidence(String type) {
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setEvidenceType(type); value.setStatus("ACTIVE"); value.setContainerBookingId(99L);
return value;
}
}
@@ -1,601 +1,48 @@
package com.baiyue.firstleg.loading.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
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.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@SpringJUnitConfig(LoadingDeliveryService.class)
class LoadingDeliveryServiceTest {
@Mock LoadingRepository repository;
@Mock LoadingModelBridgeService loadingModelBridge;
@Mock LoadingCompletionGateService completionGate;
@Mock LoadingPickRepository loadingPicks;
@Mock InboundStatusHistoryMapper histories;
@Mock LoadingEvidenceFileClient files;
@InjectMocks LoadingDeliveryService service;
@BeforeEach
void injectObjectMapper() throws Exception {
var field = LoadingDeliveryService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(service, new ObjectMapper());
var token = LoadingDeliveryService.class.getDeclaredField("internalToken");
token.setAccessible(true);
token.set(service, "token");
}
@MockBean LoadingRepository repository;
@MockBean LoadingPickRepository loadingPicks;
@MockBean LoadingModelBridgeService loadingModelBridge;
@MockBean LoadingCompletionGateService completionGate;
@MockBean InboundStatusHistoryMapper histories;
@MockBean CabinetLoadingEvidenceService cabinetEvidence;
@MockBean ObjectMapper objectMapper;
@Resource LoadingDeliveryService service;
@Test
void returnsAuthenticatedPreviewUrlAndProxiesOwnedEvidenceContent() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity target = evidence("PHOTO_STEP_1", 11L, null);
target.setId(99L); target.setInboundId(20L); target.setTenantId(10L); target.setStatus("ACTIVE");
when(repository.listEvidence(10, 20)).thenReturn(List.of(target));
when(files.download("token", 10, 20, 11)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
void manualCompletionUsesCabinetEvidenceResolvedFromInbound() throws Exception {
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request");
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"), eq("op-1"), anyString())).thenReturn(true);
when(repository.findInbound(10L, 20L)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
InboundProgress progress = new InboundProgress();
progress.setExpectedCount(2); progress.setPickedCount(2); progress.setTaskCount(1);
progress.setCompletedTaskCount(1); progress.setUnresolvedCount(0);
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(progress);
when(repository.completeLoading(10L, 20L, 9L, 3)).thenReturn(1);
var listed = service.evidence(10, 20);
var content = service.evidenceContent(10, 20, 99);
service.completeLoading(10L, 20L, 9L, "operator", "op-1",
new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done"));
assertThat(listed.evidences()).singleElement().satisfies(item ->
assertThat(item.previewUrl()).isEqualTo(
"/api/platform/v1/inbounds/20/loading/evidence/99/content/pda?tenantId=10"));
assertThat(content.getBody()).containsExactly(1, 2, 3);
verify(files).download("token", 10, 20, 11);
}
@Test
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1);
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireReleasedBooking(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void pdaCompletionPreflightChecksStartedPlanTransportAndEvidenceWithoutWriting() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
service.requirePdaCompletionReady(10, 20);
verify(loadingModelBridge).requireStarted(10, 20);
verify(completionGate).requireReleasedBooking(10, 20);
verify(loadingModelBridge, never()).requireCompleted(anyLong(), anyLong());
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
verify(repository, never()).claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString());
}
@Test
void pdaCompletionPreflightReportsBookingBlockerBeforeEvidenceOrWrites() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
doThrow(new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT,
"LOADED门禁未通过:订舱尚未完成(必须为 RELEASED)"))
.when(completionGate).requireReleasedBooking(10, 20);
assertThatThrownBy(() -> service.requirePdaCompletionReady(10, 20))
.hasMessageContaining("订舱尚未完成");
verify(repository, never()).listEvidence(anyLong(), anyLong());
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void completesLoadedFulfillmentSliceBeforeAggregatingRoot() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "WAIT_SPLIT", 3));
when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget(
20L, null, 20L, "LOADING", 3, "LOADING", 3));
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
when(repository.completeLoadedChild(10, 20, 7, 3)).thenReturn(1);
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "slice-1",
new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireReleasedBooking(10, 20);
verify(repository).completeLoadedChild(10, 20, 7, 3);
verify(repository).aggregateRootLoaded(10, 20, 7);
verify(histories).insert(argThat((InboundStatusHistoryEntity history) ->
Long.valueOf(20L).equals(history.getInboundId())));
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() {
readyForPdaFinalize();
CompletionTarget settled = new CompletionTarget(20L, null, 20L,
"LOADING", 7, "LOADING", 7);
when(repository.completionTarget(10, 20)).thenReturn(settled);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1);
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-full", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).findInboundForUpdate(10, 20);
verify(repository).completeLoadedChild(10, 20, 7, 7);
verify(repository, never()).completeLoadedChild(10, 20, 7, 6);
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
}
@Test
void pdaFinalizeResumesSameProcessingClaimLeftByAnEarlierWorkerFailure() {
readyForPdaFinalizeFacts();
CompletionTarget settled = new CompletionTarget(20L, null, 20L,
"LOADING", 7, "LOADING", 7);
when(repository.completionTarget(10, 20)).thenReturn(settled);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1);
AtomicReference<String> hash = new AtomicReference<>();
when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"),
eq("pda-resume"), anyString())).thenAnswer(invocation -> {
hash.set(invocation.getArgument(5));
return false;
});
when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "pda-resume"))
.thenAnswer(invocation -> processingOperation(hash.get()));
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-resume", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).completeLoadedChild(10, 20, 7, 7);
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
eq("COMPLETE_LOADING"), eq("pda-resume"), anyString());
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
}
@Test
void pdaFinalizeConvergesAnAlreadyLoadedInboundBeforeThePlanIsCompleted() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(true);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
when(repository.listEvidence(10, 20)).thenReturn(List.of());
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-already-loaded", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
eq("COMPLETE_LOADING"), eq("pda-already-loaded"), anyString());
}
@Test
void pdaFinalizeEntryPointsAreTransactional() throws Exception {
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
long.class, long.class, long.class, String.class, String.class, String.class)
.getAnnotation(Transactional.class)).isNotNull();
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
long.class, long.class, long.class, long.class, long.class,
String.class, String.class, String.class)
.getAnnotation(Transactional.class)).isNotNull();
}
@Test
void pdaPreflightUsesCabinetEvidenceOwnerWithoutCrossCustomerLookup() {
when(repository.findInbound(20, 200)).thenReturn(new Snapshot(200L, 20L, "LOADING", 3));
when(repository.listEvidence(10, 100)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(100L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
service.requirePdaCompletionReady(20, 200, 10, 100);
verify(loadingModelBridge).requireStarted(20, 200);
verify(completionGate).requireReleasedBooking(20, 200);
verify(repository).listEvidence(10, 100);
verify(repository, never()).listEvidence(20, 100);
verify(files, times(5)).validate(eq("token"), eq(10L), eq(100L), anyLong());
}
@Test
void pdaFinalizeKeepsOperationOnBusinessOwnerWhileUsingCabinetEvidenceOwner() {
when(repository.claimOperation(eq(20L), eq(200L), eq(7L), eq("COMPLETE_LOADING"),
eq("pda-multi-customer"), anyString())).thenReturn(true);
when(repository.findInboundForUpdate(20, 200)).thenReturn(new Snapshot(200L, 20L, "LOADING", 7));
when(repository.findInbound(20, 200)).thenReturn(new Snapshot(200L, 20L, "LOADING", 7));
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(1);
when(progress.getPickedCount()).thenReturn(1);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(0);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(20, 200)).thenReturn(progress);
when(repository.listEvidence(10, 100)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(100L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
when(repository.completeLoading(20, 200, 7, 7)).thenReturn(1);
LoadingActionResponse result = service.completeAfterPdaSettlement(
20, 200, 10, 100, 7, "admin", "pda-multi-customer", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).completeLoading(20, 200, 7, 7);
verify(repository).completeOperation(eq(20L), eq(200L), eq(7L),
eq("COMPLETE_LOADING"), eq("pda-multi-customer"), anyString());
verify(repository, never()).completeOperation(eq(20L), eq(100L), anyLong(),
anyString(), anyString(), anyString());
verify(repository).listEvidence(10, 100);
verify(repository, never()).listEvidence(20, 100);
}
@Test
void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() {
readyForPdaFinalize();
CompletionTarget settled = new CompletionTarget(20L, 21L, 20L,
"LOADING", 7, "LOADING", 2);
when(repository.completionTarget(10, 20)).thenReturn(settled);
when(repository.findInboundForUpdate(10, 21)).thenReturn(new Snapshot(21L, 10L, "LOADING", 2));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.completeLoadedChild(10, 20, 7, 2)).thenReturn(1);
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-partial", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).findInboundForUpdate(10, 21);
verify(repository).completeLoadedChild(10, 20, 7, 2);
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
}
@Test
void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() {
readyForPdaFinalize();
CompletionTarget settled = new CompletionTarget(20L, null, 20L,
"LOADING", 7, "LOADING", 7);
when(repository.completionTarget(10, 20)).thenReturn(settled);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(0);
assertThatThrownBy(() -> service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-concurrent", "完成"))
.hasMessageContaining("数据已被其他人修改");
verify(histories, never()).insert(any(InboundStatusHistoryEntity.class));
verify(repository, never()).completeOperation(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), anyString());
}
@Test
void completesSingleRemainderSourceOnceAndReplaysSameKeyWithoutRepeatingSideEffects() {
AtomicInteger claims = new AtomicInteger();
AtomicReference<String> requestHash = new AtomicReference<>();
AtomicReference<String> resultJson = new AtomicReference<>();
when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"),
eq("slice-replay"), anyString())).thenAnswer(invocation -> {
requestHash.set(invocation.getArgument(5));
return claims.getAndIncrement() == 0;
});
doAnswer(invocation -> {
resultJson.set(invocation.getArgument(5));
return null;
}).when(repository).completeOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"),
eq("slice-replay"), anyString());
when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay"))
.thenAnswer(invocation -> successfulOperation(requestHash.get(), resultJson.get()));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 5));
when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget(
20L, null, 10L, "LOADING", 5, "LOADING", 5));
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
when(repository.completeLoadedChild(10, 20, 7, 5)).thenReturn(1);
LoadingActionResponse first = service.completeLoading(10, 20, 7, "admin", "slice-replay",
new CompleteLoadingRequest(5, "完成"));
LoadingActionResponse replay = service.completeLoading(10, 20, 7, "admin", "slice-replay",
new CompleteLoadingRequest(5, "完成"));
assertThat(first.mainStatus()).isEqualTo("LOADED");
assertThat(replay).isEqualTo(first);
verify(repository, times(2)).claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"),
eq("slice-replay"), anyString());
verify(repository).completeLoadedChild(10, 20, 7, 5);
verify(repository).aggregateRootLoaded(10, 10, 7);
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireReleasedBooking(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class));
verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get());
}
@Test
void keepsHistoricalLoadedChildAsCompletionTarget() {
CompletionTarget historical = new CompletionTarget(
20L, 21L, 20L, "WAIT_SPLIT", 3, "LOADING", 1);
assertThat(historical.completionInboundId()).isEqualTo(21L);
}
@Test
void rejectsCompletionWhenEvidenceIsIncomplete() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(evidence("SEAL", 5L, "LOCK-001")));
assertThatThrownBy(() -> service.completeLoading(10, 20, 7, "admin", "k-missing",
new CompleteLoadingRequest(3, null))).hasMessageContaining("必须齐全");
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void evidenceCanBeEditedAndDoesNotChangeMainStatus() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.listEvidence(10, 20)).thenReturn(List.of());
when(files.validate("token", 10, 20, 1)).thenReturn(ApiResponse.success(
new LoadingEvidenceFileClient.FileMetadata(1L, "photo.jpg", "LOADING_PHOTO_STEP_1", "image/jpeg")));
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "k2",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 1L, "ignored.jpg", null, "updated"));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(repository).createEvidence(any(LoadingEvidenceEntity.class));
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void appendsMultiplePhotosOfTheSameType() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.listEvidence(10, 20)).thenReturn(List.of());
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long id = invocation.getArgument(3);
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(id, "photo-" + id + ".jpg",
"LOADING_PHOTO_STEP_1", "image/jpeg"));
});
service.upsertEvidence(10, 20, 7, "admin", "append-1",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 11L, null, null, null));
service.upsertEvidence(10, 20, 7, "admin", "append-2",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 12L, null, null, null));
verify(repository, times(2)).createEvidence(argThat(value ->
"PHOTO_STEP_1".equals(value.getEvidenceType()) && "ACTIVE".equals(value.getStatus())));
}
@Test
void replaysTheSameActiveEvidenceFileWithoutAppendingAnotherRecord() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity existing = evidence("SEAL", 11L, "LOCK-001");
existing.setId(99L); existing.setInboundId(20L); existing.setTenantId(10L); existing.setStatus("ACTIVE");
when(repository.findEvidenceBySealNo(10, "LOCK-001")).thenReturn(existing);
when(repository.findActiveEvidenceByFile(10, 20, "SEAL", 11L)).thenReturn(existing);
when(repository.listEvidence(10, 20)).thenReturn(List.of(existing));
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "same-seal-photo",
new UpsertEvidenceRequest(0, "SEAL", 11L, null, "LOCK-001", null));
assertThat(result.evidences().evidences()).hasSize(1);
verify(repository, never()).createEvidence(any());
verify(files, never()).validate(anyString(), anyLong(), anyLong(), anyLong());
}
@Test
void voidsPhotoWithReasonAndKeepsTheRecord() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity target = evidence("PHOTO_STEP_1", 11L, null);
target.setId(99L); target.setStatus("ACTIVE"); target.setVersion(2);
when(repository.listEvidence(10, 20)).thenReturn(List.of(target));
when(repository.voidEvidence(10, 20, 99L, "模糊", 7L, "admin", 2)).thenReturn(1);
LoadingActionResponse result = service.voidEvidence(10, 20, 99L, 7L, "admin", "void-1",
new VoidEvidenceRequest(2, "模糊"));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(repository).voidEvidence(10, 20, 99L, "模糊", 7L, "admin", 2);
}
@Test
void rejectsDuplicateSealUsedByAnotherInbound() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity duplicate = evidence("SEAL", 9L, "LOCK-DUP");
duplicate.setInboundId(21L);
when(repository.findEvidenceBySealNo(10, "LOCK-DUP")).thenReturn(duplicate);
assertThatThrownBy(() -> service.upsertEvidence(10, 20, 7, "admin", "k-duplicate-seal",
new UpsertEvidenceRequest(0, "SEAL", 5L, null, "LOCK-DUP", null)))
.hasMessageContaining("锁码已用于其他装柜任务");
verify(repository, never()).createEvidence(any());
}
@Test
void rejectsEvidenceAppendAndVoidAfterLoadingCompleted() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 4));
assertThatThrownBy(() -> service.upsertEvidence(10, 20, 7, "admin", "loaded-append",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 11L, null, null, null)))
.hasMessageContaining("装柜完成后不允许在线修改证据");
assertThatThrownBy(() -> service.voidEvidence(10, 20, 99L, 7L, "admin", "loaded-void",
new VoidEvidenceRequest(0, "拍错")))
.hasMessageContaining("装柜完成后不允许在线修改证据");
verify(repository, never()).createEvidence(any());
verify(repository, never()).voidEvidence(anyLong(), anyLong(), anyLong(), anyString(),
anyLong(), anyString(), anyInt());
}
@Test
void rejectsCompletingPickingWithoutCanonicalContainerTask() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
assertThatThrownBy(() -> service.completePicking(10, 20, 7, "admin", "k-picking",
new PickingRequest(0, 1, 1, null))).hasMessageContaining("真实柜次");
verify(repository, never()).createPickTask(any());
}
private static LoadingEvidenceEntity evidence(String type, Long fileId, String sealNo) {
LoadingEvidenceEntity value = new LoadingEvidenceEntity();
value.setId(fileId); value.setInboundId(20L); value.setTenantId(10L);
value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo);
return value;
}
private void readyForPdaFinalize() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(true);
readyForPdaFinalizeFacts();
}
private void readyForPdaFinalizeFacts() {
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
// completion worker runs before task finalization; accepted COMPLETING is not COMPLETED yet.
when(progress.getCompletedTaskCount()).thenReturn(0);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
}
private static LoadingOperationEntity successfulOperation(String requestHash, String resultJson) {
LoadingOperationEntity value = new LoadingOperationEntity();
value.setRequestHash(requestHash);
value.setStatus("SUCCESS");
value.setResultJson(resultJson);
return value;
}
private static LoadingOperationEntity processingOperation(String requestHash) {
LoadingOperationEntity value = new LoadingOperationEntity();
value.setRequestHash(requestHash);
value.setStatus("PROCESSING");
return value;
verify(cabinetEvidence).requireComplete(99L);
}
}
@@ -108,7 +108,7 @@ class ContainerOverviewServiceTest {
61L, 52L, 41L, "LOADING", 7));
when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow(
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now())));
when(evidenceFiles.download("internal-token", 81L, 91L, 101L)).thenReturn(ResponseEntity.ok()
when(evidenceFiles.download("internal-token", 41L, "LOADING_DOOR_OPEN", 101L)).thenReturn(ResponseEntity.ok()
.contentType(MediaType.IMAGE_JPEG).body(new byte[]{1, 2, 3}));
var download = service.downloadPhoto(41L, 101L);
@@ -23,6 +23,7 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
import com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
@@ -83,6 +84,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingDeliveryService loadingDeliveryService;
@MockBean
private CabinetLoadingEvidenceService cabinetEvidenceService;
@MockBean
private ColoadGroupAssignmentPort coloadGroups;
@MockBean
private TruckAppointmentService truckAppointmentService;
@@ -574,17 +577,17 @@ class LoadingPickServiceTest {
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L);
verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator",
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 101L, 50L, 9L, "operator",
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 9L, "operator",
"PDA-complete-1-60", "checked");
verify(loadingListService).publishCompletedEvent(10L);
InOrder finalization = inOrder(loadingListService, loadingDeliveryService);
finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator",
finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
finalization.verify(loadingListService).publishCompletedEvent(10L);
}
@@ -615,7 +618,7 @@ class LoadingPickServiceTest {
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).approve(20L, 9L, "operator", "checked");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator",
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-not-blocked-50", "checked");
verify(loadingListService).publishCompletedEvent(10L);
}
@@ -634,7 +637,7 @@ class LoadingPickServiceTest {
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"入库单并发修改"))
.when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator",
.when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-finalize-fails-50", "checked");
assertThatThrownBy(() -> service.complete(20L,
@@ -694,7 +697,7 @@ class LoadingPickServiceTest {
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).publishCompletedEvent(anyLong());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyLong(), anyLong(), anyString(), anyString(), anyString());
anyString(), anyString(), anyString());
}
@Test
@@ -869,7 +872,7 @@ class LoadingPickServiceTest {
}
@Test
void workerUsesOneStableCabinetEvidenceOwnerAcrossTwoCustomers() {
void workerUsesCabinetEvidenceOnceWithReversedTwoCustomerOwnerOrder() {
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
@@ -889,12 +892,13 @@ class LoadingPickServiceTest {
service.processCompletion(completionCommand(903L, "async-multi"), "worker-multi");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(202L, 70L, 101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(202L, 80L, 101L, 50L);
verify(cabinetEvidenceService, times(1)).requireComplete(99L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L);
verify(loadingDeliveryService).requirePdaCompletionReady(202L, 70L);
verify(loadingDeliveryService).requirePdaCompletionReady(202L, 80L);
verify(loadingDeliveryService).completeAfterPdaSettlement(
202L, 70L, 101L, 50L, 9L, "operator", "PDA-async-multi-70", "checked");
202L, 70L, 9L, "operator", "PDA-async-multi-70", "checked");
verify(settlementService, times(4)).settleOwner(eq(20L), anyLong(), eq(9L), eq("operator"));
verify(repository, times(1)).insert(argThat((LoadingPickOperationEntity operation) ->
"COMPLETE_LOADING".equals(operation.getActionType())
@@ -914,7 +918,7 @@ class LoadingPickServiceTest {
new LoadingPickItemMapper.InboundOwner(101L, 50L),
new LoadingPickItemMapper.InboundOwner(202L, 70L)));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "第二客户门禁未就绪"))
.when(loadingDeliveryService).requirePdaCompletionReady(202L, 70L, 101L, 50L);
.when(loadingDeliveryService).requirePdaCompletionReady(202L, 70L);
assertThatThrownBy(() -> service.processCompletion(
completionCommand(904L, "async-owner-ready-fails"), "worker-failure"))
@@ -924,7 +928,7 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyLong(), anyLong(), anyString(), anyString(), anyString());
anyString(), anyString(), anyString());
verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY");
verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(),
eq("SETTLED"));
@@ -952,8 +956,8 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(),
anyLong(), anyLong(), anyString(), anyString(), anyString());
verify(loadingDeliveryService).completeAfterPdaSettlement(202L, 60L, 101L, 50L, 9L, "operator",
anyString(), anyString(), anyString());
verify(loadingDeliveryService).completeAfterPdaSettlement(202L, 60L, 9L, "operator",
"PDA-async-resume-60", "checked");
verify(loadingListService).publishCompletedEvent(10L);
verify(repository).insert(org.mockito.ArgumentMatchers.<LoadingPickOperationEntity>argThat(