feat: add inbound hold lifecycle
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user