feat: add inbound hold lifecycle

This commit is contained in:
2026-09-07 11:20:10 +08:00
parent e52ecb9315
commit 0c8f6fe2b3
48 changed files with 1280 additions and 16 deletions
@@ -0,0 +1,87 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
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.yaml.snakeyaml.Yaml;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class InboundHoldContractTest {
@Test
void controllerUsesIndependentWritePermissionAndRequiredIdempotencyKey() throws Exception {
Method detail = PlatformInboundOrderController.class.getDeclaredMethod(
"holdDetail", AuthenticatedUser.class, long.class, long.class);
Method hold = commandMethod("hold");
Method release = commandMethod("releaseHold");
assertThat(detail.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
assertThat(hold.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
assertThat(release.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
assertThat(release.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
}
@Test
void openApiFreezesHoldPathsFieldsAndValidationWithoutLegacyAliases() throws Exception {
String source = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(source));
Map<String, Object> paths = stringMap(document.get("paths"));
assertThat(paths.keySet()).contains(
"/api/platform/inbounds/{inboundId}/hold",
"/api/platform/inbounds/{inboundId}/hold/release");
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> command = stringMap(schemas.get("InboundHoldCommand"));
assertThat(stringList(command.get("required"))).containsExactly("expectedVersion", "reason");
assertThat(stringMap(command.get("properties")).keySet())
.containsExactlyInAnyOrder("expectedVersion", "reason", "evidenceFileId");
assertThat(stringMap(stringMap(command.get("properties")).get("expectedVersion")))
.containsEntry("minimum", 0);
assertThat(stringMap(stringMap(command.get("properties")).get("reason")))
.containsEntry("minLength", 1).containsEntry("maxLength", 1000);
Map<String, Object> detail = stringMap(schemas.get("InboundHoldDetail"));
assertThat(stringMap(detail.get("properties")).keySet()).containsExactlyInAnyOrder(
"inboundId", "tenantId", "holdFlag", "currentHoldReason", "version",
"canHold", "canRelease", "actionBlockReason", "history");
assertThat(stringMap(stringMap(detail.get("properties")).get("holdFlag")))
.containsEntry("type", "boolean").doesNotContainKey("nullable");
Map<String, Object> history = stringMap(schemas.get("InboundHoldHistoryItem"));
assertThat(stringMap(history.get("properties")).keySet()).containsExactlyInAnyOrder(
"id", "action", "reason", "evidenceFileId", "operatorName", "operatedAt");
assertThat(source).contains("platform:firstleg:inbound:hold")
.doesNotContain("hold" + "Cargo:")
.doesNotContain("hold" + "Reason:");
}
private static Method commandMethod(String name) throws NoSuchMethodException {
return PlatformInboundOrderController.class.getDeclaredMethod(name, AuthenticatedUser.class,
long.class, long.class, String.class, CommandRequest.class);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
}
@SuppressWarnings("unchecked")
private static List<String> stringList(Object value) {
return (List<String>) value;
}
}
@@ -8,6 +8,7 @@ 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.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
@@ -35,6 +36,7 @@ class CabinetLoadingEvidenceServiceTest {
@MockBean LoadingPickRepository loadingPicks;
@MockBean LoadingEvidenceFileClient files;
@MockBean ObjectMapper objectMapper;
@MockBean InboundHoldGuard inboundHoldGuard;
@Resource CabinetLoadingEvidenceService service;
@BeforeEach
@@ -7,6 +7,7 @@ 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;
import org.junit.jupiter.api.Test;
@@ -30,6 +31,7 @@ class LoadingDeliveryServiceTest {
@MockBean CabinetLoadingEvidenceService cabinetEvidence;
@MockBean FbaPricingService pricingService;
@MockBean ObjectMapper objectMapper;
@MockBean InboundHoldGuard inboundHoldGuard;
@Resource LoadingDeliveryService service;
@Test
@@ -266,7 +266,7 @@ class OutboundDispatchServiceTest {
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) {
return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A",
"LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED");
"LOADED", false, 22L, "CAB-22", "C-22", 44L, "COMPLETED");
}
private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) {
@@ -12,6 +12,24 @@ import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("入库审核SQL契约")
class InboundWorkflowSqlContractTest {
@Test
@DisplayName("扣货快照、不可变历史和幂等记录使用冻结字段与长度")
void shouldPersistInboundHoldSnapshotHistoryAndIdempotency() throws Exception {
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(schema)
.contains("hold_flag TINYINT(1) NOT NULL DEFAULT 0")
.contains("current_hold_reason VARCHAR(1000) NULL")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_hold_record")
.contains("hold_action VARCHAR(16) NOT NULL")
.contains("reason VARCHAR(1000) NOT NULL")
.contains("evidence_file_id BIGINT UNSIGNED NULL")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_idempotency")
.contains("idempotency_key VARCHAR(128) NOT NULL")
.contains("UNIQUE KEY uk_inbound_idempotency");
}
@Test
@DisplayName("基础表和增量迁移包含审核历史、幂等、Outbox和放行索引")
void shouldProvideWorkflowPersistenceBoundaries() throws Exception {
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.mapper.InboundHoldGateMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class InboundHoldGuardTest {
private final InboundHoldGateMapper mapper = mock(InboundHoldGateMapper.class);
private final InboundHoldGuard guard = new InboundHoldGuard(mapper);
@Test
void blocksInboundPlanTaskAndContainerScopes() {
when(mapper.countHeldInbound(7L, 11L)).thenReturn(1);
when(mapper.countHeldByPlan(22L)).thenReturn(1);
when(mapper.countHeldByTask(33L)).thenReturn(1);
when(mapper.countHeldByContainer(44L)).thenReturn(1);
for (Runnable action : new Runnable[] {
() -> guard.requireInboundNotHeld(7L, 11L),
() -> guard.requirePlanNotHeld(22L),
() -> guard.requireTaskNotHeld(33L),
() -> guard.requireContainerNotHeld(44L)
}) {
assertThatThrownBy(action::run).isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
}
}
@Test
void treatsMissingPhysicalWorkProjectionAsFailClosed() {
when(mapper.hasPhysicalWork(7L, 11L)).thenReturn(null);
assertThat(guard.hasPhysicalWork(7L, 11L)).isTrue();
}
}
@@ -0,0 +1,135 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
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.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InboundHoldServiceTest {
@Mock InboundOrderMapper inbounds;
@Mock InboundHoldRecordMapper history;
@Mock InboundIdempotencyService idempotency;
@Mock InboundHoldGuard guard;
@InjectMocks InboundHoldService service;
@Test
void holdsOwnedInboundWithOptimisticVersionAndImmutableHistory() {
InboundOrderEntity before = inbound(false, null, 3);
InboundOrderEntity after = inbound(true, "海关抽查", 4);
when(idempotency.begin(7L, 9L, 11L, "INBOUND_HOLD", "hold-key",
"HOLD|7|11|3|海关抽查|88")).thenReturn(new InboundIdempotencyService.Context(7L, 101L, false));
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(before);
when(inbounds.updateManualHold(7L, 11L, 3, false, true, "海关抽查", 9L)).thenReturn(1);
when(inbounds.selectById(11L)).thenReturn(after);
when(history.selectList(any(Wrapper.class))).thenReturn(List.of());
var result = service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, " 海关抽查 ", 88L));
assertThat(result.holdFlag()).isTrue();
assertThat(result.currentHoldReason()).isEqualTo("海关抽查");
assertThat(result.version()).isEqualTo(4);
assertThat(result.canRelease()).isTrue();
ArgumentCaptor<InboundHoldRecordEntity> record = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
verify(history).insert(record.capture());
assertThat(record.getValue().getHoldAction()).isEqualTo("HOLD");
assertThat(record.getValue().getReason()).isEqualTo("海关抽查");
assertThat(record.getValue().getEvidenceFileId()).isEqualTo(88L);
verify(idempotency).complete(new InboundIdempotencyService.Context(7L, 101L, false), 11L);
}
@Test
void replaysSameCompletedCommandWithoutWritingAgain() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), eq("INBOUND_HOLD"), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 101L, true));
when(inbounds.selectById(11L)).thenReturn(inbound(true, "检查", 4));
when(history.selectList(any(Wrapper.class))).thenReturn(List.of());
assertThat(service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, "检查", null)).holdFlag()).isTrue();
verify(inbounds, never()).updateManualHold(anyLong(), anyLong(), anyInt(), anyBoolean(),
anyBoolean(), any(), anyLong());
verify(history, never()).insert(any(InboundHoldRecordEntity.class));
}
@Test
void rejectsCrossCustomerInboundAsNotFound() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 101L, false));
InboundOrderEntity other = inbound(false, null, 3);
other.setTenantId(8L);
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(other);
assertThatThrownBy(() -> service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, "检查", null)))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND));
}
@Test
void rejectsManualActionAfterPhysicalPicking() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 101L, false));
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(inbound(false, null, 3));
when(guard.hasPhysicalWork(7L, 11L)).thenReturn(true);
assertThatThrownBy(() -> service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, "检查", null)))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
verify(inbounds, never()).updateManualHold(anyLong(), anyLong(), anyInt(), anyBoolean(),
anyBoolean(), any(), anyLong());
}
@Test
void releasesAndClearsCurrentReasonButKeepsReleaseHistoryReason() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), eq("INBOUND_RELEASE"), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 102L, false));
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(inbound(true, "旧原因", 4));
when(inbounds.updateManualHold(7L, 11L, 4, true, false, null, 9L)).thenReturn(1);
when(inbounds.selectById(11L)).thenReturn(inbound(false, null, 5));
when(history.selectList(any(Wrapper.class))).thenReturn(List.of());
var result = service.release(7L, 11L, 9L, "平台人员", "release-key",
new CommandRequest(4, "复核通过", null));
assertThat(result.holdFlag()).isFalse();
assertThat(result.currentHoldReason()).isNull();
ArgumentCaptor<InboundHoldRecordEntity> record = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
verify(history).insert(record.capture());
assertThat(record.getValue().getHoldAction()).isEqualTo("RELEASE");
assertThat(record.getValue().getReason()).isEqualTo("复核通过");
}
private static InboundOrderEntity inbound(boolean held, String reason, int version) {
InboundOrderEntity value = new InboundOrderEntity();
value.setId(11L);
value.setTenantId(7L);
value.setHoldFlag(held);
value.setCurrentHoldReason(reason);
value.setVersion(version);
value.setDeleted(false);
value.setProcessStatus("WAIT_SPLIT");
return value;
}
}
@@ -89,7 +89,7 @@ class InboundOrderCreationServiceTest {
base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(),
base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(),
base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(),
"ONE_CLICK_DROPSHIP");
"ONE_CLICK_DROPSHIP", base.lxCode());
when(orders.selectOne(any(Wrapper.class))).thenReturn(null);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
@@ -62,6 +62,8 @@ class LoadingListServiceTest {
private LoadingDocumentFileClient documentFiles;
@MockBean
private LoadingCompletedOutboxService loadingCompletedOutboxService;
@MockBean
private InboundHoldGuard inboundHoldGuard;
@Resource
private LoadingListService service;
@@ -101,6 +101,8 @@ class LoadingPickServiceTest {
private PdaPickSettlementService settlementService;
@MockBean
private InboundPalletAllocationRepository palletAllocations;
@MockBean
private InboundHoldGuard inboundHoldGuard;
@Resource
private LoadingPickService service;
@@ -26,6 +26,7 @@ import static org.mockito.Mockito.when;
class LoadingWorkPalletServiceTest {
@MockBean private LoadingWorkPalletMapper pallets;
@MockBean private LoadingWorkPalletCartonMapper cartons;
@MockBean private InboundHoldGuard inboundHoldGuard;
@Resource private LoadingWorkPalletService service;
@Test
@@ -42,6 +42,7 @@ class SmartLoadingProvisionalAdjustmentTest {
@Mock private PendingReviewOrderClient pendingReviewOrders;
@Mock private InboundOrderCreationService inboundOrderCreationService;
@Mock private LoadingPickService loadingPickService;
@Mock private InboundHoldGuard inboundHoldGuard;
@Mock private LoadingModelBridgeService loadingModelBridge;
@Mock private SplitPreloadService splitPreloadService;
@Mock private ColoadGroupAssignmentPort coloadGroups;
@@ -89,6 +89,7 @@ class SmartLoadingServiceTest {
@MockBean private PendingReviewOrderClient pendingReviewOrders;
@MockBean private InboundOrderCreationService inboundOrderCreationService;
@MockBean private LoadingPickService loadingPickService;
@MockBean private InboundHoldGuard inboundHoldGuard;
@MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService;
@MockBean private ColoadGroupAssignmentPort coloadGroups;