diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java index 8f1bb0ae2..5223acffc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java @@ -90,6 +90,12 @@ public class LoadingModelBridgeService { throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联"); } for (AllocationScope scope : scopes) { + // Internal push replay is pair-idempotent: an existing association has + // already advanced both versions, so calling public bind again would + // derive a different request hash for the same AUTO key. + if (repository.byPair(scope.tenantId(), scope.inboundId(), loadingPlanId) != null) { + continue; + } var preload = repository.preload(scope.tenantId(), scope.inboundId()); var currentPlan = repository.loadingPlan(loadingPlanId); if (preload == null || currentPlan == null) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java index a2b06c672..9ecc38874 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryController.java @@ -18,6 +18,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; /** 平台端装柜证据和国内装柜完成操作。 */ @@ -58,6 +59,16 @@ public class PlatformLoadingDeliveryController { return ApiResponse.success(service.evidence(tenantId, inboundId)); } + @Operation(summary = "PDA查看装柜证据照片") + @GetMapping("/{inboundId}/loading/evidence/{evidenceId}/content/pda") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ResponseEntity pdaEvidenceContent( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, + @PathVariable long evidenceId) { + FirstlegScopeGuard.platform(user, tenantId); + return service.evidenceContent(tenantId, inboundId, evidenceId); + } + @Operation(summary = "PDA作废错误装柜证据") @PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java index df27a188b..c725b4205 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/dto/LoadingResponses.java @@ -7,7 +7,7 @@ public final class LoadingResponses { private LoadingResponses() {} public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType, - Long fileId, String fileName, String sealNo, String remark, + Long fileId, String fileName, String previewUrl, String sealNo, String remark, Integer version, String status, String voidReason, Long voidedBy, String voidedByName, LocalDateTime voidedAt, LocalDateTime createdAt, LocalDateTime updatedAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 654431214..4e9ad0013 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -24,6 +24,7 @@ import feign.FeignException; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -149,6 +150,22 @@ public class LoadingDeliveryService { return evidenceResponse(tenantId, inboundId); } + public ResponseEntity evidenceContent(long tenantId, long inboundId, long evidenceId) { + requireLoadingStage(tenantId, inboundId); + LoadingEvidenceEntity evidence = repository.listEvidence(tenantId, inboundId).stream() + .filter(item -> Long.valueOf(evidenceId).equals(item.getId()) && isActive(item)) + .findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在")); + try { + ResponseEntity response = files.download(internalToken, tenantId, inboundId, evidence.getFileId()); + if (response == null || response.getBody() == null) { + throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在"); + } + return response; + } catch (FeignException ex) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + @Transactional public LoadingActionResponse voidEvidence(long tenantId, long inboundId, long evidenceId, long operatorId, String operatorName, String key, VoidEvidenceRequest request) { @@ -333,11 +350,16 @@ public class LoadingDeliveryService { private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) { List values = repository.listEvidence(tenantId, inboundId).stream().map(e -> new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(), - e.getFileName(), e.getSealNo(), e.getRemark(), e.getVersion(), activeStatus(e), e.getVoidReason(), + e.getFileName(), evidencePreviewUrl(tenantId, inboundId, e.getId()), e.getSealNo(), e.getRemark(), e.getVersion(), activeStatus(e), e.getVoidReason(), e.getVoidedBy(), e.getVoidedByName(), e.getVoidedAt(), e.getCreatedAt(), e.getUpdatedAt())).toList(); return new EvidenceListResponse(inboundId, values); } + private static String evidencePreviewUrl(long tenantId, long inboundId, long evidenceId) { + return "/api/platform/v1/inbounds/" + inboundId + "/loading/evidence/" + evidenceId + + "/content/pda?tenantId=" + tenantId; + } + private static boolean isActive(LoadingEvidenceEntity evidence) { return evidence != null && (evidence.getStatus() == null || "ACTIVE".equals(evidence.getStatus())); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 96d0c7fd4..dd1d9ab43 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -233,20 +233,18 @@ public class SmartLoadingService { public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) { String key = trim(idempotencyKey); if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key"); + String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion()); LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); if (!StringUtils.hasText(booking.getContainerNo())) { throw validation("录入真实柜号后才能推送预装清单并开启PDA作业"); } if (key.equals(plan.getPushIdempotencyKey())) { - if ("EDITING".equals(plan.getPlanStatus())) { - ensurePendingPreloads(repository.items(planId), operatorId, operatorName); - } - loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key); - loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName); + requirePushReplay(planId, key, requestHash); return view(plan, booking); } if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送"); + if (repository.operation(key) != null) throw conflict("Idempotency-Key已用于不同推送请求"); if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配"); List items = repository.items(planId); ensurePendingPreloads(items, operatorId, operatorName); @@ -254,13 +252,26 @@ public class SmartLoadingService { try { if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改"); } catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); } - history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName); + history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, + json(values("version", request.expectedVersion())), + json(values("requestHash", requestHash)), operatorId, operatorName); LoadingPlanEntity pushed = repository.plan(planId); loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key); loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName); return view(pushed, requireBooking(pushed.getContainerBookingId())); } + private void requirePushReplay(long planId, String key, String requestHash) { + LoadingPlanHistoryEntity history = repository.operation(key); + if (history == null + || !Objects.equals(history.getLoadingPlanId(), planId) + || !Objects.equals(history.getActionType(), "PUSH_PRELOAD") + || history.getAfterSnapshot() == null + || !sameRequestHash(history.getAfterSnapshot(), requestHash)) { + throw conflict("Idempotency-Key已用于不同推送请求"); + } + } + private void ensurePendingPreloads(List items, long operatorId, String operatorName) { if (items.isEmpty()) throw validation("装柜明细不能为空"); if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) { diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 3910a22b8..43b332c36 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -35,6 +35,13 @@ paths: application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} responses: '200': {description: Business envelope; ownership, type and version failures use business error codes} + /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda: + get: + summary: Download one loading evidence photo for the active PDA worker + description: Uses the existing platform:firstleg:pda:work authority and validates customer, inbound and evidence ownership before proxying File service content. + parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/EvidenceId'}, {$ref: '#/components/parameters/TenantId'}] + responses: + '200': {description: Authenticated image bytes} /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void/pda: post: summary: Void an incorrect PDA loading evidence photo @@ -102,6 +109,19 @@ components: EvidenceId: {name: evidenceId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + EvidenceResponse: + type: object + required: [id, inboundId, tenantId, evidenceType, fileId, previewUrl, version, status] + properties: + id: {type: integer, format: int64} + inboundId: {type: integer, format: int64} + tenantId: {type: integer, format: int64} + evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]} + fileId: {type: integer, format: int64} + fileName: {type: string} + previewUrl: {type: string, description: Authenticated PDA image-content path; request with the current bearer token} + version: {type: integer} + status: {type: string} UpsertEvidenceRequest: type: object required: [expectedVersion, evidenceType, fileId] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java index 9a556e3b0..aa90a16b2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -64,6 +64,54 @@ class LoadingModelBridgeServiceTest { verify(repository).touchLoadingPlan(40, 3, 7, "admin"); } + @Test + void editingReplayWithEveryPairExistingIsReadOnly() { + var plan = new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 9); + when(repository.loadingPlan(40)).thenReturn(plan); + when(repository.allocatedScopes(40)).thenReturn(List.of( + new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); + when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity()); + when(repository.byPair(11, 21, 40)).thenReturn(new PreloadLoadingPlanLinkEntity()); + Link first = link("PENDING", 3, "EDITING", 9, 8); + when(repository.byLoadingPlan(40)).thenReturn(List.of(first)); + + assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1")) + .containsExactly(first); + + verify(repository, never()).preload(anyLong(), anyLong()); + verify(repository, never()).insert(any()); + verify(repository, never()).touchPreload(anyLong(), anyLong(), anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString()); + } + + @Test + void editingReplaySkipsExistingPairAndCreatesOnlyMissingPair() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedScopes(40)).thenReturn(List.of( + new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); + when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity()); + when(repository.byPair(11, 21, 40)).thenReturn(null); + when(repository.preload(11, 21)).thenReturn(new PreloadSnapshot(31L, 11L, 21L, "PENDING", 5)); + when(repository.loadingPlan(40)).thenReturn( + new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3), + new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedPackages(11, 21, 40)).thenReturn(4); + when(repository.touchPreload(31, 11, 21, 5, 7, "admin")).thenReturn(1); + when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1); + Link linked = link("PENDING", 6, "EDITING", 4, 4); + when(repository.byInbound(11, 21)).thenReturn(List.of(linked)); + when(repository.byLoadingPlan(40)).thenReturn(List.of(linked)); + + assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1")) + .containsExactly(linked); + + verify(repository, never()).preload(10, 20); + verify(repository, times(2)).preload(11, 21); + verify(repository, times(1)).insert(any()); + verify(repository, times(1)).touchPreload(31, 11, 21, 5, 7, "admin"); + verify(repository, times(1)).touchLoadingPlan(40, 3, 7, "admin"); + } + @Test void refusesPostHocBindingAfterLoadingStarts() { when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4)); @@ -104,6 +152,20 @@ class LoadingModelBridgeServiceTest { verify(repository, never()).byInbound(10, 20); } + @Test + void publicBindSameKeyWithChangedVersionsStillConflicts() { + var prior = new PreloadLoadingPlanLinkEntity(); + prior.setRequestHash("prior-request-hash"); + when(repository.byIdempotency(7, "manual-key")).thenReturn(prior); + + assertThatThrownBy(() -> service.bind(10, 20, 40, 7, "admin", "manual-key", + new BindRequest(3, 4))) + .hasMessageContaining("Idempotency-Key已用于不同关联请求"); + + verify(repository, never()).byPair(anyLong(), anyLong(), anyLong()); + verify(repository, never()).insert(any()); + } + @Test void terminalGateRequiresEveryLinkedContainerPlanCompleted() { when(repository.byInbound(10, 20)).thenReturn(List.of( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java index 721f2ca40..03b0365e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java @@ -16,11 +16,13 @@ class LoadingDeliveryOpenApiContractTest { 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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java index 10e086560..b647e0b0f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/PlatformLoadingDeliveryControllerContractTest.java @@ -30,6 +30,11 @@ class PlatformLoadingDeliveryControllerContractTest { "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()) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 21f02c468..39dd4c83f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -24,6 +24,7 @@ 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 java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -54,6 +55,24 @@ class LoadingDeliveryServiceTest { token.set(service, "token"); } + @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})); + + var listed = service.evidence(10, 20); + var content = service.evidenceContent(10, 20, 99); + + 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); @@ -297,6 +316,7 @@ class LoadingDeliveryServiceTest { 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; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index e205eca36..8b1bd09be 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -391,6 +391,11 @@ class SmartLoadingServiceTest { assertThat(result.status()).isEqualTo("EDITING"); verify(repository).push(1, "key", 3, 7, "u"); + ArgumentCaptor 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, loadingPickService); ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key"); @@ -398,19 +403,77 @@ class SmartLoadingServiceTest { } @Test - void editingPushReplayEnsuresPendingPreloadBeforePda() { + void exactPushReplayIsReadOnly() { LoadingPlanEntity pushed = plan("EDITING", 4); pushed.setPushIdempotencyKey("key"); when(repository.plan(1)).thenReturn(pushed); - when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); + 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()); - service.pushPreload(1, "key", new PushRequest(4), 7, "u"); + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "new-key", new PushRequest(3), 7, "u"), BizException.class); - verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); - verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key"); - verify(loadingPickService).beginIfReady(99, 7, "u"); + 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 @@ -490,6 +553,12 @@ class SmartLoadingServiceTest { 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)); diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 8f0928275..08d6816ab 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -387,16 +387,19 @@ export async function run(context) { const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', OPTIONS) const pushKey = `${context.marker}-container-push`.slice(0, 128) - const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, - { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey }) + const pushRequest = Object.freeze({ + path: `${SMART}/plans/${planId}/push-preload`, + body: Object.freeze({ expectedVersion: Number(assigned.version) }), + headers: Object.freeze({ 'Idempotency-Key': pushKey }), + }) + const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) const pushed = context.apiSuccess(MODULE, '柜号与 RELEASED 订舱就绪后推送预装计划', pushResponse, '', OPTIONS) context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse, pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1, `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS) if (pushed?.status !== 'EDITING') return - const pushReplayResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, - { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey }) + const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS) context.assert(MODULE, '推送重放保持同一 EDITING 计划', pushReplayResponse, id(pushReplay?.id) === planId && pushReplay?.status === 'EDITING' @@ -476,6 +479,15 @@ export async function run(context) { context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse, failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS) + const beforeClaimPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) + const beforeClaimPlan = context.apiSuccess(MODULE, '领取任务前读取装柜计划即时版本', + beforeClaimPlanResponse, '', OPTIONS) + const beforeClaimPlanVersion = Number(beforeClaimPlan?.version) + context.assert(MODULE, '领取任务前计划保持 EDITING 且版本有效', beforeClaimPlanResponse, + beforeClaimPlan?.status === 'EDITING' && Number.isInteger(beforeClaimPlanVersion), + `status=${beforeClaimPlan?.status || 'missing'}, version=${beforeClaimPlan?.version ?? 'missing'}`, OPTIONS) + if (beforeClaimPlan?.status !== 'EDITING' || !Number.isInteger(beforeClaimPlanVersion)) return + const claimResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, { expectedAssignmentVersion: Number(task?.assignment?.version || 0), operationId: `${context.marker}-pda-claim`.slice(0, 128), @@ -489,7 +501,7 @@ export async function run(context) { const claimedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) const claimedPlan = context.apiSuccess(MODULE, '领取后回查装柜计划', claimedPlanResponse, '', OPTIONS) context.assert(MODULE, '领取任务后计划进入 LOADING 且版本递增', claimedPlanResponse, - claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushed?.version) + 1, + claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion, `status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS) const inventoryResponse = await context.request('GET', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index c371337ef..630836c0a 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -211,9 +211,11 @@ export async function run(context) { }, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) }) const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS) const planId = id(smartData?.preloadPlan?.id) + const smartPreloadVersion = Number(smartData?.preloadPlan?.version) context.assert(MODULE, '智能分柜预装计划 PENDING、版本和明细正确', smart, smartData?.mainStatus === 'WAIT_SPLIT' && Boolean(planId) && smartData?.preloadPlan?.status === 'PENDING' - && Number(smartData?.preloadPlan?.version) === 0 && smartData?.preloadPlan?.items?.length === 1, + && Number.isInteger(smartPreloadVersion) && smartPreloadVersion >= 0 + && smartData?.preloadPlan?.items?.length === 1, `mainStatus=${smartData?.mainStatus || 'missing'}, planId=${planId || 'missing'}, status=${smartData?.preloadPlan?.status || 'missing'}`, OPTIONS) if (!planId) return @@ -225,16 +227,19 @@ export async function run(context) { && replay.body?.data?.preloadPlan?.items?.length === 1, `HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS) + const bindVersions = Object.freeze({ + expectedPreloadVersion: smartPreloadVersion, + expectedLoadingPlanVersion: Number(assignedLoadingPlan.version), + }) const bindResponse = await request(context, 'POST', - `${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, { - expectedPreloadVersion: Number(smartData.preloadPlan.version), - expectedLoadingPlanVersion: Number(assignedLoadingPlan.version), - }, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) }) + `${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, + bindVersions, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) }) const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS) context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse, id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1 - && Number(link?.preloadVersion) === 1 && Number(link?.loadingPlanVersion) === 2, + && Number(link?.preloadVersion) > bindVersions.expectedPreloadVersion + && Number(link?.loadingPlanVersion) > bindVersions.expectedLoadingPlanVersion, `linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) if (!link?.id) return @@ -606,7 +611,8 @@ export async function run(context) { { expectedVersion: Number(link.preloadVersion) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) }) const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS) context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming, - confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 2, + confirmingData?.preloadPlan?.status === 'CONFIRMING' + && Number(confirmingData?.preloadPlan?.version) > Number(link.preloadVersion), `status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS) if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return @@ -618,9 +624,9 @@ export async function run(context) { : '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm, directPdaCompletion ? confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMING' - && Number(confirmed?.preloadPlan?.version) === 2 + && Number(confirmed?.preloadPlan?.version) === Number(confirmingData.preloadPlan.version) : confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED' - && Number(confirmed?.preloadPlan?.version) === 3, + && Number(confirmed?.preloadPlan?.version) > Number(confirmingData.preloadPlan.version), `mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS) if (confirmed?.mainStatus !== 'LOADING') return diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 89e18295e..5f246d264 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -105,7 +105,7 @@ test('bridge loading starts through a real PDA batch before preload confirmation assert.doesNotMatch(value, /关联柜次计划进入 LOADING/) assert.match(value, /confirmed\?\.mainStatus === 'LOADING'/) assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/) - assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === 2/) + assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === Number\(confirmingData\.preloadPlan\.version\)/) assert.match(value, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/) assert.match(value, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/) assert.match(value, /version: transportVersion/) @@ -312,6 +312,21 @@ test('container loading enforces channel isolation and stable PDA identity acros assert.doesNotMatch(batch, /containerNo:/) }) +test('preload and loading versions use immediate operation baselines and exact push replay', () => { + const container = source('./phase3-container-loading.mjs') + assert.match(container, /const pushRequest = Object\.freeze\(\{[\s\S]*path:[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) + assert.equal((container.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) + assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/) + assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) + + const loading = source('./phase3-loading.mjs') + assert.match(loading, /Number\.isInteger\(smartPreloadVersion\) && smartPreloadVersion >= 0/) + assert.match(loading, /Number\(link\?\.preloadVersion\) > bindVersions\.expectedPreloadVersion/) + assert.match(loading, /Number\(link\?\.loadingPlanVersion\) > bindVersions\.expectedLoadingPlanVersion/) + assert.match(loading, /Number\(confirmingData\?\.preloadPlan\?\.version\) > Number\(link\.preloadVersion\)/) + assert.doesNotMatch(loading, /Number\((?:smartData|link|confirmingData|confirmed)[^\n]*version\) === [0-9]/) +}) + test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { const container = source('./phase3-container-loading.mjs') const convergence = source('./phase3-pda-convergence.mjs')