fix: complete phase5 overseas deployment contract

This commit is contained in:
2026-09-01 20:20:25 +08:00
45 changed files with 810 additions and 638 deletions
@@ -32,17 +32,11 @@ class PlatformSmartLoadingControllerContractTest {
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
}
@Test void openApiPushDoesNotRequireContainerNumberOrCreatePda() throws Exception {
@Test void openApiHasNoManualCreateOrPushStep() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:")
.contains("不要求真实柜号")
.contains("不创建 PDA 作业任务")
.contains("已关联 RELEASED 订舱时才创建待领取 PDA 任务")
.contains("领取后才进入 LOADING")
.doesNotContain("已关联 CONFIRMED/RELEASED 订舱")
.doesNotContain("真实柜号必须已录入");
assertThat(yaml).doesNotContain("/push-preload:")
.doesNotContain("SmartLoadingPushRequest:");
}
@Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
@@ -169,7 +169,7 @@ class LoadingModelBridgeServiceTest {
@Test
void confirmationGateRejectsIncompleteAllocatedLinks() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(
40L, 50L, "C1", "EDITING", 3, LocalDateTime.now()));
40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
when(repository.byLoadingPlan(40)).thenReturn(List.of(
@@ -193,7 +193,7 @@ class LoadingModelBridgeServiceTest {
@Test
void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() {
when(repository.byPair(10, 20, 40)).thenReturn(null);
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 3, LocalDateTime.now()));
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 3));
when(repository.allocatedPackages(10, 20, 40)).thenReturn(2);
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4));
when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1);
@@ -34,9 +34,9 @@ class LoadingListQueryMapperContractTest {
.contains("FROM fl_loading_plan p")
.contains("JOIN fl_container_booking b")
.contains("LEFT JOIN fl_loading_plan_item i")
.contains("p.preload_pushed_at IS NOT NULL")
.doesNotContain("preload_pushed_at")
.contains("p.plan_status IN ('EDITING', 'LOADING')")
.contains("active_item.deleted = 0")
.doesNotContain("active_item.deleted = 0")
.contains("fl_preload_loading_plan_link")
.contains("fl_ocean_booking_container_allocation")
.contains("fl_loading_pick_task")
@@ -88,14 +88,14 @@ class LoadingListQueryMapperContractTest {
assertThat(touch)
.contains("plan_status = 'EDITING'")
.contains("preload_pushed_at IS NOT NULL")
.doesNotContain("preload_pushed_at")
.contains("fl_loading_pick_assignment", "item.item_status<>'PENDING'")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("p.plan_status='EDITING'")
.contains("preload_pushed_at IS NOT NULL")
.doesNotContain("preload_pushed_at")
.doesNotContain("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task", "fl_loading_pick_assignment")
.doesNotContain("DELETE")
@@ -122,7 +122,7 @@ class LoadingListQueryMapperContractTest {
.contains("version = version + 1");
assertThat(confirmPlan)
.contains("plan_status = 'EDITING'")
.contains("preload_pushed_at IS NOT NULL")
.doesNotContain("preload_pushed_at")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
}
@@ -20,9 +20,9 @@ class SmartLoadingSqlContractTest {
private static final Set<String> PHASE3_PERMISSIONS = Set.of(
"platform:firstleg:container:list", "platform:firstleg:container:view",
"platform:firstleg:container:create", "platform:firstleg:container:edit",
"platform:firstleg:smart-loading:list", "platform:firstleg:smart-loading:create",
"platform:firstleg:smart-loading:list",
"platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete",
"platform:firstleg:smart-loading:push", "platform:firstleg:preload:list",
"platform:firstleg:preload:list",
"platform:firstleg:preload:view", "platform:firstleg:preload:edit",
"platform:firstleg:preload:complete", "platform:firstleg:preload:confirm",
"platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view");
@@ -60,7 +60,7 @@ class SmartLoadingSqlContractTest {
}
@Test
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整")
@DisplayName("活动明细唯一锁、乐观锁和操作幂等字段完整")
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
assertContainerLifecycleSchema(migration());
assertContainerLifecycleSchema(unifiedMigration());
@@ -71,7 +71,6 @@ class SmartLoadingSqlContractTest {
.contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)")
.contains("UNIQUE KEY uk_container_booking_no (booking_no)")
.contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)")
.contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)")
.contains("UNIQUE KEY uk_loading_history_operation (operation_id)")
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
}
@@ -156,7 +155,7 @@ class SmartLoadingSqlContractTest {
void shouldProvidePlatformPermissions() throws Exception {
assertThat(migration())
.contains("platform:firstleg:container:view")
.contains("platform:firstleg:smart-loading:push")
.doesNotContain("'platform:firstleg:smart-loading:push', '推送预装'")
.contains("platform:firstleg:preload:confirm")
.contains("platform:firstleg:loading-list:view")
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
@@ -165,7 +164,7 @@ class SmartLoadingSqlContractTest {
}
@Test
@DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权")
@DisplayName("模块脚本和统一脚本均校验14项权限与两个角色28项授权")
void shouldAssertCompletePermissionGrants() throws Exception {
assertPermissionContract(migration());
assertPermissionContract(unifiedMigration());
@@ -192,8 +191,6 @@ class SmartLoadingSqlContractTest {
.doesNotContain("旧版本在推送预装时即写入 LOADING")
.doesNotContain("修复旧版本“推送预装即拣货中”的状态污染")
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
@@ -345,7 +342,7 @@ class SmartLoadingSqlContractTest {
int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message);
String assertion = sql.substring(assertionStart, message);
assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS);
assertThat(assertion).contains("<> 32 THEN");
assertThat(assertion).contains("<> 28 THEN");
}
private static Set<String> permissions(String sql) {
@@ -45,12 +45,17 @@ class ContainerServiceTest {
@MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private SmartLoadingService smartLoadingService;
@Resource private ContainerService service;
@BeforeEach
void setUpWarehouse() {
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001");
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
invocation.getArgument(0, Long.class), null, null, 0, true, true, null,
"NOT_STARTED", null, null, null, List.of()));
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A"))
@@ -77,7 +82,10 @@ class ContainerServiceTest {
assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000");
assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING");
assertThat(saved.getPlanId()).isEqualTo(501L);
assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001");
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
verify(smartLoadingService).createForContainer(saved.getId(), 9L, "admin");
}
@Test
@@ -85,11 +85,9 @@ class LoadingListServiceTest {
@Test
void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
before.setVoyageNo("001E");
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null);
@@ -112,7 +110,6 @@ class LoadingListServiceTest {
@Test
void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
before.setVoyageNo("023E");
when(repository.aggregate(10L)).thenReturn(before);
@@ -132,10 +129,8 @@ class LoadingListServiceTest {
@Test
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1);
@@ -149,18 +144,18 @@ class LoadingListServiceTest {
}
@Test
void editingPlanCannotReceiveTransportBeforePush() {
void editingPlanCanCorrectTransportWithoutPushStep() {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
AggregateRow after = row("EDITING", 4);
after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号")))
.isInstanceOf(BizException.class).hasMessageContaining("已推送");
service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"));
verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(),
org.mockito.ArgumentMatchers.isNull(), eq(9L), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator");
}
@Test
@@ -179,7 +174,6 @@ class LoadingListServiceTest {
@Test
void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
@@ -195,7 +189,6 @@ class LoadingListServiceTest {
@Test
void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
@@ -210,12 +203,9 @@ class LoadingListServiceTest {
@Test
void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() {
AggregateRow loading = row("LOADING", 3);
loading.setPreloadPushedAt(java.time.LocalDateTime.now());
loading.setContainerNo("MSCU6639870");
AggregateRow missing = row("EDITING", 3);
missing.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow afterFill = row("EDITING", 4);
afterFill.setPreloadPushedAt(missing.getPreloadPushedAt());
afterFill.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill);
when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator"))
@@ -236,9 +226,7 @@ class LoadingListServiceTest {
@Test
void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() {
AggregateRow before = row("LOADING", 7);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("LOADING", 8);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
@@ -260,7 +248,6 @@ class LoadingListServiceTest {
@Test
void correctingExistingContainerNumberStillRequiresReason() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
@@ -274,7 +261,6 @@ class LoadingListServiceTest {
@Test
void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true);
@@ -329,10 +315,8 @@ class LoadingListServiceTest {
@Test
void shouldReplaySameTransportRequestWithoutSecondMutation() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null);
@@ -361,7 +345,6 @@ class LoadingListServiceTest {
@Test
void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot("{\"requestHash\":\"different\"}");
@@ -765,9 +748,7 @@ class LoadingListServiceTest {
@Test
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
List<Link> links = List.of(
new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L,
"MSCU-001", "EDITING", 3, 4, null),
@@ -850,7 +831,6 @@ class LoadingListServiceTest {
@Test
void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() {
AggregateRow confirmed = row("EDITING", 4);
confirmed.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(confirmed);
when(loadingPickService.requirePreloadConfirmationReady(10L))
@@ -872,7 +852,6 @@ class LoadingListServiceTest {
@Test
void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before);
when(loadingPickService.requirePreloadConfirmationReady(10L))
@@ -904,7 +883,7 @@ class LoadingListServiceTest {
private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm,
boolean readyForPda, Long taskId) {
return new PreloadReadiness(true, true, 2, 2, 90L, "RELEASED", taskId,
return new PreloadReadiness(true, 2, 2, 90L, "RELEASED", taskId,
taskId == null ? null : "LOADING", confirmationStatus, canConfirm,
readyForPda, List.of());
}
@@ -9,7 +9,6 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
@@ -403,29 +402,6 @@ class SmartLoadingServiceTest {
verify(loadingPickService, never()).remove(anyLong());
}
@Test
void pushPreloadRejectsIncompleteGroupBeforeAnyLifecycleOrPlanWrite() {
LoadingPlanEntity plan = plan("EDITING", 0);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first));
when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first));
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
assertThatThrownBy(() -> service.pushPreload(1L, "push-incomplete-group",
new PushRequest(0), 7L, "u"))
.isInstanceOf(BizException.class).hasMessageContaining("全部在同一柜");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(
anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(
anyLong(), anyLong(), anyString(), anyString());
}
@Test
void removesEveryGroupMemberAndUnlocksOnlyAfterAllRowsAreRemoved() {
LoadingPlanEntity plan = plan("EDITING", 0);
@@ -548,7 +524,7 @@ class SmartLoadingServiceTest {
when(repository.planByBooking(99L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of());
Plan result = service.create(99L, "备注", 7L, "u");
Plan result = service.createForContainer(99L, 7L, "u");
assertThat(result.id()).isEqualTo(1L);
verify(repository, never()).insert(any(LoadingPlanEntity.class));
@@ -565,7 +541,7 @@ class SmartLoadingServiceTest {
}).when(repository).insert(any(LoadingPlanEntity.class));
when(repository.items(1L)).thenReturn(List.of());
Plan result = service.create(99L, "备注", 7L, "u");
Plan result = service.createForContainer(99L, 7L, "u");
assertThat(result.planNo()).isEqualTo("LP-260818-0001");
}
@@ -1076,205 +1052,6 @@ class SmartLoadingServiceTest {
verify(repository).remove(1, 8, 0, 7, "u");
}
@Test
void pushesIdempotently() {
LoadingPlanEntity editing = plan("EDITING", 3);
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
Plan result = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).push(1, "key", 3, 7, "u");
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD");
assertThat(history.getValue().getAfterSnapshot())
.contains(sha256("PUSH_PRELOAD|1|3"));
var ordered = inOrder(splitPreloadService, loadingModelBridge);
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void exactPushReplayIsReadOnly() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
Plan replay = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
assertThat(replay.version()).isEqualTo(4);
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void samePushKeyWithDifferentRequestIsRejectedWithoutWrites() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "key", new PushRequest(4), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(repository, never()).items(anyLong());
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verifyNoPushSideEffects();
}
@Test
void newPushKeyAfterSuccessfulPushIsRejectedWithoutWrites() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("first-key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "new-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("新的幂等键");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verifyNoPushSideEffects();
}
@Test
void pushKeyUsedByAnotherPlanOperationIsRejectedBeforeWrites() {
LoadingPlanEntity editing = plan("EDITING", 3);
when(repository.plan(1)).thenReturn(editing);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("used-key"))
.thenReturn(history(1, 8L, "REMOVE_ITEM", sha256("DELETE|1|8|3|0")));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "used-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(repository, never()).items(anyLong());
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verifyNoPushSideEffects();
}
@Test
void bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback() {
LoadingPlanEntity editing = plan("EDITING", 3);
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
doThrow(new BizException(ResultCode.CONFLICT, "预装计划不存在"))
.when(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getMessage()).contains("预装计划不存在");
verify(repository).push(1, "key", 3, 7, "u");
verify(repository).insert(any(LoadingPlanHistoryEntity.class));
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void concurrentPushKeyClaimReturnsConflictBeforeBridgeAndPda() {
LoadingPlanEntity editing = plan("EDITING", 3);
when(repository.plan(1)).thenReturn(editing);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "race-key", 3, 7, "u")).thenReturn(1);
doThrow(new DuplicateKeyException("duplicate operation_id"))
.when(repository).insert(any(LoadingPlanHistoryEntity.class));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "race-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void rejectsOverweightPlanBeforePush() {
LoadingPlanEntity editing = plan("EDITING", 1);
ContainerBookingEntity booking = booking();
booking.setMaxWeightKg(new BigDecimal("1"));
LoadingPlanItemEntity overweight = item(8, 9, 20, 1);
overweight.setGrossWeightKg(new BigDecimal("2"));
when(repository.plan(1)).thenReturn(editing);
when(repository.booking(99)).thenReturn(booking);
when(repository.items(1)).thenReturn(List.of(overweight));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "weight-key", new PushRequest(1), 7, "u"),
BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(error.getMessage()).contains("重量").contains("110%");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void pushesWithoutContainerNumberAndDoesNotOpenPda() {
LoadingPlanEntity editing = plan("EDITING", 1);
ContainerBookingEntity booking = booking();
booking.setContainerNo(null);
when(repository.plan(1)).thenReturn(editing, plan("EDITING", 2));
when(repository.booking(99)).thenReturn(booking);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.push(1, "no-container-key", 1, 7, "u")).thenReturn(1);
service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u");
verify(repository).push(1, "no-container-key", 1, 7, "u");
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void exactReplayWhenContainerNumberIsStillMissingIsReadOnly() {
LoadingPlanEntity pushed = plan("EDITING", 2);
pushed.setPushIdempotencyKey("replay-key");
ContainerBookingEntity booking = booking();
booking.setContainerNo(" ");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking);
when(repository.operation("replay-key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|2")));
service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
private void verifyNoPushSideEffects() {
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
private void stubAssignment() {
LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));