diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index cae31a46b..221f62e3d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -25,6 +25,10 @@ public class LoadingRepository { return inbounds.find(tenantId, inboundId); } + public LoadingInboundMapper.Snapshot findInboundForUpdate(long tenantId, long inboundId) { + return inbounds.findForUpdate(tenantId, inboundId); + } + public int completeLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) { return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion); } 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 7198f715c..e2703301b 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 @@ -121,6 +121,24 @@ public class LoadingDeliveryService { new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId))); } + /** + * PDA settlement may increment the source version and may create a loaded fulfillment slice. + * Resolve and lock the canonical completion row after settlement, then retain optimistic locking + * with that current server version instead of reusing the pre-settlement task snapshot. + */ + public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, + long operatorId, String operatorName, + String key, String remark) { + CompletionTarget target = repository.completionTarget(tenantId, inboundId); + long completionInboundId = target == null ? inboundId : target.completionInboundId(); + Snapshot current = repository.findInboundForUpdate(tenantId, completionInboundId); + if (current == null || current.version() == null) { + throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试"); + } + return completeLoading(tenantId, inboundId, operatorId, operatorName, key, + new CompleteLoadingRequest(current.version(), remark)); + } + /** * Read-only preflight used before PDA settlement. The current plan may still be LOADING here; * every external/business hard gate must pass before the PDA task is mutated. diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index d75ba7f67..61fed730e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -33,7 +33,6 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baiyue.firstleg.loading.service.LoadingDeliveryService; -import com.baiyue.firstleg.loading.dto.LoadingRequests; import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; @@ -59,7 +58,6 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.Map; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -453,12 +451,10 @@ public class LoadingPickService { .thenComparing(LoadingPickItemMapper.InboundOwner::inboundId)) .toList(); if (owners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); - Map inboundSnapshots = new LinkedHashMap<>(); for (LoadingPickItemMapper.InboundOwner owner : owners) { LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId()); if (snapshot == null) throw conflict("PDA任务关联入库单不存在"); loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId()); - inboundSnapshots.put(owner, snapshot); } truckAppointmentService.completeLoadingFromPda(task.getContainerBookingId(), taskId, operatorId, operatorName); @@ -472,12 +468,9 @@ public class LoadingPickService { insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName); loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); repository.syncInboundProjections(taskId, operatorId, operatorName); - for (Map.Entry entry - : inboundSnapshots.entrySet()) { - LoadingPickItemMapper.InboundOwner owner = entry.getKey(); - loadingDeliveryService.completeLoading(owner.tenantId(), owner.inboundId(), operatorId, - operatorName, "PDA-" + operationId + '-' + owner.inboundId(), - new LoadingRequests.CompleteLoadingRequest(entry.getValue().version(), remark)); + for (LoadingPickItemMapper.InboundOwner owner : owners) { + loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId, + operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark); } return view(requiredTask(taskId), null); } 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 df015021b..7fdc885e3 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 @@ -178,6 +178,62 @@ class LoadingDeliveryServiceTest { verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); } + @Test + void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() { + readyForPdaFinalize(); + CompletionTarget settled = new CompletionTarget(20L, null, 20L, + "LOADING", 7, "LOADING", 7); + when(repository.completionTarget(10, 20)).thenReturn(settled); + when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1); + + LoadingActionResponse result = service.completeAfterPdaSettlement( + 10, 20, 7, "admin", "pda-full", "完成"); + + assertThat(result.mainStatus()).isEqualTo("LOADED"); + verify(repository).findInboundForUpdate(10, 20); + verify(repository).completeLoadedChild(10, 20, 7, 7); + verify(repository, never()).completeLoadedChild(10, 20, 7, 6); + } + + @Test + void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() { + readyForPdaFinalize(); + CompletionTarget settled = new CompletionTarget(20L, 21L, 20L, + "LOADING", 7, "LOADING", 2); + when(repository.completionTarget(10, 20)).thenReturn(settled); + when(repository.findInboundForUpdate(10, 21)).thenReturn(new Snapshot(21L, 10L, "LOADING", 2)); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.completeLoadedChild(10, 20, 7, 2)).thenReturn(1); + + LoadingActionResponse result = service.completeAfterPdaSettlement( + 10, 20, 7, "admin", "pda-partial", "完成"); + + assertThat(result.mainStatus()).isEqualTo("LOADED"); + verify(repository).findInboundForUpdate(10, 21); + verify(repository).completeLoadedChild(10, 20, 7, 2); + } + + @Test + void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() { + readyForPdaFinalize(); + CompletionTarget settled = new CompletionTarget(20L, null, 20L, + "LOADING", 7, "LOADING", 7); + when(repository.completionTarget(10, 20)).thenReturn(settled); + when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(0); + + assertThatThrownBy(() -> service.completeAfterPdaSettlement( + 10, 20, 7, "admin", "pda-concurrent", "完成")) + .hasMessageContaining("数据已被其他人修改"); + + verify(histories, never()).insert(any(InboundStatusHistoryEntity.class)); + verify(repository, never()).completeOperation(anyLong(), anyLong(), anyLong(), + anyString(), anyString(), anyString()); + } + @Test void completesSingleRemainderSourceOnceAndReplaysSameKeyWithoutRepeatingSideEffects() { AtomicInteger claims = new AtomicInteger(); @@ -359,6 +415,28 @@ class LoadingDeliveryServiceTest { return value; } + private void readyForPdaFinalize() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(true); + LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); + when(progress.getExpectedCount()).thenReturn(5); + when(progress.getPickedCount()).thenReturn(5); + when(progress.getTaskCount()).thenReturn(1); + when(progress.getCompletedTaskCount()).thenReturn(1); + when(progress.getUnresolvedCount()).thenReturn(0); + when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); + when(repository.listEvidence(10, 20)).thenReturn(List.of( + evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), + evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), + evidence("SEAL", 5L, "LOCK-001"))); + when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { + long fileId = invocation.getArgument(3); + String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; + return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( + fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); + }); + } + private static LoadingOperationEntity successfulOperation(String requestHash, String resultJson) { LoadingOperationEntity value = new LoadingOperationEntity(); value.setRequestHash(requestHash); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 7a67d1948..717d657fd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -486,12 +486,18 @@ class LoadingPickServiceTest { } @Test - void manualCompletionSettlesResolvedItemsAndCompletesPlan() { + void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() { LoadingPickTaskEntity task = task(); LoadingPickItemEntity item = item(); + AtomicReference operation = new AtomicReference<>(); item.setPlannedPackageCount(5); item.setPickedPackageCount(3); item.setItemStatus("PARTIAL"); + when(repository.operation("complete-1")).thenAnswer(invocation -> operation.get()); + doAnswer(invocation -> { + operation.set(invocation.getArgument(0)); + return null; + }).when(repository).insert(any(LoadingPickOperationEntity.class)); when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.task(20L)).thenReturn(task); when(repository.items(20L)).thenReturn(List.of(item)); @@ -505,6 +511,7 @@ class LoadingPickServiceTest { new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); + service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); verify(repository).insert(any(LoadingPickOperationEntity.class)); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); @@ -512,9 +519,8 @@ class LoadingPickServiceTest { verify(repository).syncInboundProjections(20L, 9L, "operator"); verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); - verify(loadingDeliveryService).completeLoading(eq(101L), eq(50L), eq(9L), eq("operator"), - eq("PDA-complete-1-50"), argThat(value -> value.expectedVersion() == 7 - && "checked".equals(value.remark()))); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-1-50", "checked"); } @Test @@ -538,7 +544,7 @@ class LoadingPickServiceTest { verify(settlementService, never()).settle(anyLong(), anyLong(), anyString()); verify(repository, never()).approve(anyLong(), anyLong(), anyString(), any()); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); - verify(loadingDeliveryService, never()).completeLoading(anyLong(), anyLong(), anyLong(), + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), any()); } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 7b55496b2..d42cc3186 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -66,7 +66,7 @@ export async function run(context) { 其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。 -计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车缺失或未完成不得阻断,若稳定柜次有关联有效拖车,则该业务动作负责将其推进到“已完成装柜”。当前后端 OpenAPI 仍写“预先 COMPLETED 拖车”,验收 finalize 已 `BLOCKED_CONTRACT` 主动停止,待新响应/历史/回滚字段冻结后接线。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 +计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态不是前置门禁,无有效拖车时继续,恰有一条有效拖车时同一事务推进为 `COMPLETED`,多条或并发冲突返回 `A0409` 且本地状态回滚。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 部署后需要定向验证该完整链时,模块顺序固定为: diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 94e627f67..d7d54bf16 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -13,6 +13,9 @@ const loading = read('./phase3-loading.mjs') const convergence = read('./phase3-pda-convergence.mjs') const customs = read('./phase3-customs.mjs') const finalize = read('./phase3-loading-finalize.mjs') +const loadingPickService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java') +const loadingDeliveryService = read('../../firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java') +const loadingDeliveryServiceTest = read('../../firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java') function ordered(source, fragments) { let cursor = -1 @@ -73,7 +76,7 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/) }) -test('downstream modules stop before HTTP until the new truck-coupled complete contract freezes', () => { +test('downstream finalizer uses the frozen truck-coupled complete contract', () => { assert.match(convergence, /summary\?\.status === 'LOADING'/) assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/) assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/) @@ -81,17 +84,58 @@ test('downstream modules stop before HTTP until the new truck-coupled complete c assert.doesNotMatch(customs, /truck\?\.finalStatus !== 'COMPLETED'/) ordered(finalize, [ '最终装柜前保持 LOADING 且版本有效', - 'PDA complete 联动拖车并原子推进 LOADED', - 'return', 'const taskPath =', + '结算递增 inboundVersion 后 final complete 必须使用最新版本', + '入库单终态 LOADED、版本递增且审计历史存在', ]) - assert.match(finalize, /BLOCKED_CONTRACT:用户已确认拖车缺失或未完成不再阻断/) + assert.doesNotMatch(finalize, /BLOCKED_CONTRACT/) assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/) }) -test('current PDA complete OpenAPI remains a blocker because it still requires pre-completed truck', () => { +test('PDA complete OpenAPI freezes optional truck coupling and atomic rollback', () => { assert.match(pdaOpenApi, /\/api\/platform\/v1\/first-leg\/pda-loading\/tasks\/\{taskId\}\/complete:/) - assert.match(pdaOpenApi, /先无写入校验全部关联入库单的RELEASED订舱、COMPLETED拖车和五类ACTIVE证据/) - assert.match(finalize, /BLOCKED_CONTRACT/) + assert.match(pdaOpenApi, /拖车状态不是前置门禁/) + assert.match(pdaOpenApi, /没有有效拖车则直接继续/) + assert.match(pdaOpenApi, /恰有一条有效拖车时由服务端加锁推进为COMPLETED并写历史/) + assert.match(pdaOpenApi, /多条有效拖车或并发修改返回A0409且全部Firstleg本地状态回滚/) + assert.match(pdaOpenApi, /同operationId重放返回冻结结果,不重复推进拖车或写历史/) +}) + +test('PDA final completion refreshes post-settlement versions for full and partial split', () => { + ordered(loadingPickService, [ + 'settlementService.settle(taskId, operatorId, operatorName)', + 'loadingDeliveryService.completeAfterPdaSettlement', + ]) + assert.doesNotMatch(loadingPickService, + /new LoadingRequests\.CompleteLoadingRequest\(entry\.getValue\(\)\.version\(\), remark\)/) + ordered(loadingDeliveryService, [ + 'CompletionTarget target = repository.completionTarget(tenantId, inboundId)', + 'Snapshot current = repository.findInboundForUpdate(tenantId, completionInboundId)', + 'new CompleteLoadingRequest(current.version(), remark)', + ]) + for (const regression of [ + 'pdaFinalizeUsesSourceVersionIncrementedByFullSettlement', + 'pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement', + 'pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange', + ]) assert.match(loadingDeliveryServiceTest, new RegExp(regression)) + assert.match(loadingDeliveryServiceTest, /verify\(repository, never\(\)\)\.completeLoadedChild\(10, 20, 7, 6\)/) + assert.match(loadingDeliveryServiceTest, /verify\(histories, never\(\)\)\.insert/) +}) + +test('HTTP finalizer preserves failure evidence and verifies the settled slice', () => { + ordered(finalize, [ + '读取结算前计划即时版本', + '读取结算前 PDA 操作历史基线', + '结算递增 inboundVersion 后 final complete 必须使用最新版本', + 'final complete 失败后回查 PDA 任务', + 'final complete 失败后回查计划', + 'final complete 失败后回查入库单', + '结算或版本冲突失败后 task/plan/inbound/operation 全部零写入', + '结算后回查柜次实际履约分片', + '结算递增版本后实际完成分片唯一且柜次身份稳定', + ]) + assert.match(finalize, /requestId=\$\{completeResponse\.requestId \|\| 'missing'\}/) + assert.match(finalize, /traceId=\$\{completeResponse\.traceId \|\| 'missing'\}/) + assert.match(finalize, /settlementMode = remainderSlice \? 'PARTIAL' : 'FULL'/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 403ee9052..6c58c6cac 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -69,11 +69,6 @@ export async function run(context) { if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return const base = `/api/platform/v1/inbounds/${inboundId}` - context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA complete 联动拖车并原子推进 LOADED', - 'BLOCKED_CONTRACT:用户已确认拖车缺失或未完成不再阻断;等待后端冻结 complete 响应中的拖车投影、LOADING→COMPLETED 历史动作、错柜隔离、重放与失败回滚字段后再执行写 HTTP。当前脚本主动停止,禁止沿用“拖车必须预先 COMPLETED”的旧门禁。', - 'contract') - return - const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}` const taskBeforeResponse = await context.request('GET', taskPath) const taskBefore = context.apiSuccess(MODULE, '读取最终完成前 PDA 任务即时版本', taskBeforeResponse, '', OPTIONS) @@ -85,54 +80,57 @@ export async function run(context) { `taskId=${id(taskBefore?.id) || 'missing'}, workflow=${taskBefore?.workflowStatus || 'missing'}, version=${taskBefore?.version ?? 'missing'}`, OPTIONS) if (!Number.isInteger(Number(taskBefore?.version))) return const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}` - const planBeforeRaceResponse = await context.request('GET', planPath) - const planBeforeRace = context.apiSuccess(MODULE, '读取最终完成并发前计划即时版本', planBeforeRaceResponse, '', OPTIONS) - const raceItem = planBeforeRace?.items?.find?.((item) => Number(item?.pickedPackageCount) > 0) - context.assert(MODULE, '最终完成并发使用同一已拣计划明细与即时版本', planBeforeRaceResponse, - Boolean(raceItem?.id) && Number.isInteger(Number(planBeforeRace?.version)) - && Number.isInteger(Number(raceItem?.version)), - `planVersion=${planBeforeRace?.version ?? 'missing'}, itemId=${id(raceItem?.id) || 'missing'}, itemVersion=${raceItem?.version ?? 'missing'}`, OPTIONS) - if (!raceItem?.id || !Number.isInteger(Number(planBeforeRace?.version))) return - let completeBody = { + const planBeforeCompleteResponse = await context.request('GET', planPath) + const planBeforeComplete = context.apiSuccess(MODULE, '读取结算前计划即时版本', planBeforeCompleteResponse, '', OPTIONS) + const operationsBeforeResponse = await context.request('GET', `${taskPath}/operations`) + const operationsBefore = context.apiSuccess(MODULE, '读取结算前 PDA 操作历史基线', operationsBeforeResponse, '', OPTIONS) + const taskBeforeSignature = JSON.stringify({ + version: Number(taskBefore?.version), taskStatus: taskBefore?.taskStatus, workflowStatus: taskBefore?.workflowStatus, + items: (taskBefore?.items || []).map((item) => ({ id: id(item?.id), status: item?.itemStatus, + picked: Number(item?.pickedPackageCount), version: Number(item?.version) })), + }) + const planBeforeSignature = JSON.stringify({ + version: Number(planBeforeComplete?.version), status: planBeforeComplete?.status, + items: (planBeforeComplete?.items || []).map((item) => ({ id: id(item?.id), version: Number(item?.version), + packageCount: Number(item?.packageCount), picked: Number(item?.pickedPackageCount) })), + }) + const operationsBeforeSignature = JSON.stringify(operationsBefore || []) + const completeBody = { expectedTaskVersion: Number(taskBefore.version), operationId: `${context.marker}-pda-final-complete`.slice(0, 128), remark: context.marker, } - let completeKey = `${context.marker}-p3-complete-after-downstream`.slice(0, 128) - const concurrentAdjustBody = { - packageCount: Number(raceItem.packageCount), expectedPlanVersion: Number(planBeforeRace.version), - expectedItemVersion: Number(raceItem.version), operationId: `${context.marker}-race-adjust`.slice(0, 128), - } - const [initialCompleteResponse, concurrentAdjustResponse] = await Promise.all([ - request(context, 'POST', `${taskPath}/complete`, completeBody, { 'Idempotency-Key': completeKey }), - request(context, 'PATCH', - `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(raceItem.id))}`, - concurrentAdjustBody), - ]) - const completeWon = initialCompleteResponse.http === 200 && codeOf(initialCompleteResponse) === '200' - const adjustWon = concurrentAdjustResponse.http === 200 && codeOf(concurrentAdjustResponse) === '200' - context.assert(MODULE, 'Admin 增量与 PDA final complete 并发恰好一个事务成功', initialCompleteResponse, - completeWon !== adjustWon - && (completeWon ? codeOf(concurrentAdjustResponse) === 'A0409' : codeOf(initialCompleteResponse) === 'A0409'), - `completeCode=${codeOf(initialCompleteResponse)}, adjustCode=${codeOf(concurrentAdjustResponse)}, completeRequestId=${initialCompleteResponse.requestId || 'missing'}, adjustRequestId=${concurrentAdjustResponse.requestId || 'missing'}`, OPTIONS) - - let completeResponse = initialCompleteResponse - if (adjustWon) { - const taskAfterRaceResponse = await context.request('GET', taskPath) - const taskAfterRace = context.apiSuccess(MODULE, '增量赢得并发后刷新 PDA 任务版本', taskAfterRaceResponse, '', OPTIONS) - const planAfterRaceResponse = await context.request('GET', planPath) - const planAfterRace = context.apiSuccess(MODULE, '增量赢得并发后回查唯一计划事实', planAfterRaceResponse, '', OPTIONS) - context.assert(MODULE, 'final complete 失败方零写入且增量版本成为唯一事实', taskAfterRaceResponse, - taskAfterRace?.taskStatus === 'LOADING' && taskAfterRace?.workflowStatus === 'AWAITING_REVIEW' - && Number(taskAfterRace?.version) > Number(taskBefore?.version) - && Number(planAfterRace?.version) > Number(planBeforeRace?.version), - `taskVersion=${taskAfterRace?.version ?? 'missing'}, planVersion=${planAfterRace?.version ?? 'missing'}`, OPTIONS) - completeBody = { - expectedTaskVersion: Number(taskAfterRace?.version), - operationId: `${context.marker}-pda-final-complete-retry`.slice(0, 128), remark: context.marker, - } - completeKey = `${context.marker}-p3-complete-after-race-refresh`.slice(0, 128) - completeResponse = await request(context, 'POST', `${taskPath}/complete`, completeBody, - { 'Idempotency-Key': completeKey }) + const completeKey = `${context.marker}-p3-complete-after-downstream`.slice(0, 128) + const completeResponse = await request(context, 'POST', `${taskPath}/complete`, completeBody, + { 'Idempotency-Key': completeKey }) + if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200') { + context.addStep(MODULE, '结算递增 inboundVersion 后 final complete 必须使用最新版本', completeResponse, false, + `expected=success after settlement refresh; HTTP=${completeResponse.http}, code=${codeOf(completeResponse)}, message=${completeResponse.body?.message || 'missing'}, taskId=${taskId}, inboundId=${inboundId}, taskVersion=${taskBefore?.version ?? 'missing'}, inboundVersion=${version}, requestId=${completeResponse.requestId || 'missing'}, traceId=${completeResponse.traceId || 'missing'}`, OPTIONS) + const taskAfterFailureResponse = await context.request('GET', taskPath) + const taskAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查 PDA 任务', taskAfterFailureResponse, '', OPTIONS) + const planAfterFailureResponse = await context.request('GET', planPath) + const planAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查计划', planAfterFailureResponse, '', OPTIONS) + const inboundAfterFailureResponse = await context.request('GET', detailPath) + const inboundAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查入库单', inboundAfterFailureResponse, '', OPTIONS) + const operationsAfterFailureResponse = await context.request('GET', `${taskPath}/operations`) + const operationsAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查操作历史', operationsAfterFailureResponse, '', OPTIONS) + const taskAfterSignature = JSON.stringify({ + version: Number(taskAfterFailure?.version), taskStatus: taskAfterFailure?.taskStatus, + workflowStatus: taskAfterFailure?.workflowStatus, + items: (taskAfterFailure?.items || []).map((item) => ({ id: id(item?.id), status: item?.itemStatus, + picked: Number(item?.pickedPackageCount), version: Number(item?.version) })), + }) + const planAfterSignature = JSON.stringify({ + version: Number(planAfterFailure?.version), status: planAfterFailure?.status, + items: (planAfterFailure?.items || []).map((item) => ({ id: id(item?.id), version: Number(item?.version), + packageCount: Number(item?.packageCount), picked: Number(item?.pickedPackageCount) })), + }) + context.assert(MODULE, '结算或版本冲突失败后 task/plan/inbound/operation 全部零写入', inboundAfterFailureResponse, + taskAfterSignature === taskBeforeSignature && planAfterSignature === planBeforeSignature + && inboundAfterFailure?.summary?.processStatus === 'LOADING' + && Number(inboundAfterFailure?.summary?.version) === version + && JSON.stringify(operationsAfterFailure || []) === operationsBeforeSignature, + `taskUnchanged=${taskAfterSignature === taskBeforeSignature}, planUnchanged=${planAfterSignature === planBeforeSignature}, inboundStatus=${inboundAfterFailure?.summary?.processStatus || 'missing'}, inboundVersion=${inboundAfterFailure?.summary?.version ?? 'missing'}, operationsUnchanged=${JSON.stringify(operationsAfterFailure || []) === operationsBeforeSignature}`, OPTIONS) + return } const completed = context.apiSuccess(MODULE, '订舱拖车报关齐备后完成装柜', completeResponse, '', OPTIONS) context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse, @@ -157,6 +155,30 @@ export async function run(context) { && item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'), `status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS) + const slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}` + const slicesResponse = await context.request('GET', slicesPath) + const slices = context.apiSuccess(MODULE, '结算后回查柜次实际履约分片', slicesResponse, '', OPTIONS) + const loadedSlices = Array.isArray(slices) + ? slices.filter((item) => id(item?.tenantId) === tenantId + && id(item?.containerBookingId) === containerBookingId + && id(item?.loadingPlanId) === loadingPlanId + && item?.splitRole === 'LOADED') + : [] + const loadedSlice = loadedSlices.find((item) => id(item?.sourceOrderId) === orderId + || id(item?.childOrderId) === orderId) || loadedSlices[0] + const remainderSlice = Array.isArray(slices) && loadedSlice + ? slices.find((item) => item?.splitRole === 'REMAINDER' + && id(item?.sourceOrderId) === id(loadedSlice?.sourceOrderId)) + : null + const settlementMode = remainderSlice ? 'PARTIAL' : 'FULL' + context.assert(MODULE, '结算递增版本后实际完成分片唯一且柜次身份稳定', slicesResponse, + loadedSlices.length === 1 && Boolean(loadedSlice) + && id(loadedSlice?.tenantId) === tenantId + && id(loadedSlice?.containerBookingId) === containerBookingId + && id(loadedSlice?.loadingPlanId) === loadingPlanId + && Number.isInteger(Number(loadedSlice?.packageCount)) && Number(loadedSlice.packageCount) > 0, + `mode=${settlementMode}, loadedSlices=${loadedSlices.length}, sourceOrderId=${id(loadedSlice?.sourceOrderId) || 'missing'}, childOrderId=${id(loadedSlice?.childOrderId) || 'missing'}, packageCount=${loadedSlice?.packageCount ?? 'missing'}`, OPTIONS) + const unifiedDetailPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}` const unifiedDetailResponse = await context.request('GET', unifiedDetailPath) const unifiedDetail = context.apiSuccess(MODULE, 'LOADED 后回查同一统一订单详情', unifiedDetailResponse, '', OPTIONS) @@ -293,5 +315,9 @@ export async function run(context) { && afterPlanSignature === immutablePlanSignature, `status=${afterBoundary?.summary?.processStatus || 'missing'}, version=${afterBoundary?.summary?.version ?? 'missing'}, histories=${afterBoundary?.statusHistory?.length ?? 'n/a'}, evidences=${afterEvidence?.evidences?.length ?? 'n/a'}, planVersion=${afterPlan?.version ?? 'missing'}`, OPTIONS) - context.createdResources.phase3Loading = buildFinalizeResource(fixture, finalData, completeResponse) + context.createdResources.phase3Loading = { + ...buildFinalizeResource(fixture, finalData, completeResponse), + settlementMode, + settledOrderId: id(loadedSlice?.childOrderId) || orderId, + } } diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index b42a99ade..fe8d9660f 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -102,7 +102,7 @@ test('test-environment loading chains push without a number and open PDA only af assert.doesNotMatch(containerLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) }) -test('default Admin HTTP entry carries evidence chain but fail-closes before obsolete truck completion semantics', () => { +test('default Admin HTTP entry carries evidence chain through settlement-aware PDA completion', () => { const focused = normalizeModuleOrder([ 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', @@ -130,9 +130,11 @@ test('default Admin HTTP entry carries evidence chain but fail-closes before obs assert.match(evidenceGate, /taskId[\s\S]*inboundId[\s\S]*loadingPlanId[\s\S]*containerBookingId/) assert.match(customs, /createdResources\?\.phase3Loading[\s\S]*container\?\.finalStatus/) assert.match(finalize, /fixture\?\.marker !== context\.marker/) - assert.match(finalize, /BLOCKED_CONTRACT:用户已确认拖车缺失或未完成不再阻断/) - assert.ok(finalize.indexOf('PDA complete 联动拖车并原子推进 LOADED') - < finalize.indexOf('const taskPath =')) + assert.doesNotMatch(finalize, /BLOCKED_CONTRACT/) + assert.ok(finalize.indexOf('const taskPath =') + < finalize.indexOf('结算递增 inboundVersion 后 final complete 必须使用最新版本')) + assert.match(finalize, /结算或版本冲突失败后 task\/plan\/inbound\/operation 全部零写入/) + assert.match(finalize, /结算递增版本后实际完成分片唯一且柜次身份稳定/) assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) })