This commit is contained in:
2026-08-15 12:33:23 +08:00
parent 3a9558bd1c
commit 0ddffeddc8
14 changed files with 290 additions and 29 deletions
@@ -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) {
@@ -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<byte[]> 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')")
@@ -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) {}
@@ -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<byte[]> 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<byte[]> 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<EvidenceResponse> 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()));
}
@@ -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<LoadingPlanItemEntity> 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<LoadingPlanItemEntity> items, long operatorId, String operatorName) {
if (items.isEmpty()) throw validation("装柜明细不能为空");
if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) {
@@ -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]
@@ -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(
@@ -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")
@@ -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())
@@ -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;
}
@@ -391,6 +391,11 @@ class SmartLoadingServiceTest {
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, 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));
@@ -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',
@@ -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
@@ -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')