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 663cc8c0e..eb203f7dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -54,6 +54,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.slf4j.Logger; @@ -86,6 +87,8 @@ public class LoadingPickService { @Resource private LoadingCompletionCommandRepository completionCommands; @Resource + private TransactionTemplate completionTransaction; + @Resource private SmartLoadingRepository smartLoadingRepository; @Resource private OceanBookingRepository oceanBookingRepository; @@ -565,6 +568,14 @@ public class LoadingPickService { "INBOUND_COMPLETED"); } } + completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups)); + completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName()); + } + + /** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */ + private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker, + LoadingPickTaskEntity task, List taskGroups) { + long taskId = task.getId(); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数")); } @@ -594,7 +605,6 @@ public class LoadingPickService { if (!completionCommands.complete(command.getId(), worker)) { throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化")); } - completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName()); } private void requireColoadConservation(LoadingPickTaskEntity task, 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 113d1256e..ff9e7681c 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 @@ -14,8 +14,10 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringJUnitConfig(LoadingDeliveryService.class) class LoadingDeliveryServiceTest { @@ -45,4 +47,76 @@ class LoadingDeliveryServiceTest { verify(cabinetEvidence).requireComplete(99L); } + + @Test + void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() throws Exception { + Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8); + when(repository.completionTarget(10L, 20L)).thenReturn(null); + when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed); + when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"), + eq("pda-full"), anyString())).thenReturn(true); + when(repository.findInbound(10L, 20L)).thenReturn(refreshed); + when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress()); + when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L); + when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(1); + when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result"); + + service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-full", "done"); + + verify(repository).completeLoading(10L, 20L, 9L, 8); + } + + @Test + void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() throws Exception { + var target = new com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget( + 20L, 21L, 22L, "LOADING", 6, "LOADING", 9); + Snapshot source = new Snapshot(20L, 10L, "LOADING", 6); + Snapshot loadedSlice = new Snapshot(21L, 10L, "LOADING", 9); + when(repository.completionTarget(10L, 20L)).thenReturn(target); + when(repository.findInboundForUpdate(10L, 21L)).thenReturn(loadedSlice); + when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"), + eq("pda-partial"), anyString())).thenReturn(true); + when(repository.findInbound(10L, 20L)).thenReturn(source); + when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress()); + when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L); + when(repository.completeLoadedChild(10L, 20L, 9L, 9)).thenReturn(1); + when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result"); + + service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-partial", "done"); + + verify(repository).findInboundForUpdate(10L, 21L); + verify(repository).completeLoadedChild(10L, 20L, 9L, 9); + } + + @Test + void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() { + Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8); + when(repository.completionTarget(10L, 20L)).thenReturn(null); + when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed); + when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"), + eq("pda-conflict"), anyString())).thenReturn(true); + when(repository.findInbound(10L, 20L)).thenReturn(refreshed); + when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress()); + when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L); + when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(0); + try { + when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request"); + } catch (com.fasterxml.jackson.core.JsonProcessingException exception) { + throw new AssertionError(exception); + } + + assertThatThrownBy(() -> service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", + "pda-conflict", "done")).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + + verify(repository).completeLoading(10L, 20L, 9L, 8); + verify(histories, never()).insert(org.mockito.ArgumentMatchers + .any(com.baiyue.firstleg.entity.InboundStatusHistoryEntity.class)); + } + + private static InboundProgress resolvedProgress() { + InboundProgress progress = new InboundProgress(); + progress.setExpectedCount(2); progress.setPickedCount(2); progress.setTaskCount(1); + progress.setCompletedTaskCount(0); progress.setUnresolvedCount(0); + return progress; + } } 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 196529b95..86557fd2e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -44,10 +44,13 @@ import org.mockito.InOrder; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.time.LocalDateTime; +import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -74,6 +77,8 @@ class LoadingPickServiceTest { @MockBean private LoadingCompletionCommandRepository completionCommands; @MockBean + private TransactionTemplate completionTransaction; + @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private OceanBookingRepository oceanBookingRepository; @@ -100,6 +105,12 @@ class LoadingPickServiceTest { @BeforeEach void activeOperationalContainer() { + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer action = invocation.getArgument(0); + action.accept(mock(TransactionStatus.class)); + return null; + }).when(completionTransaction).executeWithoutResult(any()); ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(99L); booking.setContainerNo("CSLU1234567"); diff --git a/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs index 789e4ada4..d66c0e20f 100644 --- a/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs +++ b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs @@ -42,7 +42,11 @@ test('backend never appends a generic STATUS_UPDATE and push replay is read-only assert.doesNotMatch(serviceSources, /(?:history|appendHistory)\([^;]{0,500}"STATUS_UPDATE"/s) assert.match(smartLoading, /if \(key\.equals\(plan\.getPushIdempotencyKey\(\)\)\)[\s\S]{0,300}return view\(plan, booking\)/) assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/) - assert.match(smartLoadingTest, /exactPushReplayIsReadOnly[\s\S]{0,1000}never\(\)\.insert/) + const replayStart = smartLoadingTest.indexOf('void exactPushReplayIsReadOnly()') + const replayEnd = smartLoadingTest.indexOf('@Test', replayStart + 10) + const replayBlock = smartLoadingTest.slice(replayStart, replayEnd) + assert.notEqual(replayStart, -1) + assert.match(replayBlock, /never\(\)\)\.insert/) assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/) }) @@ -50,7 +54,7 @@ test('operationId is projected end-to-end for identity-based frontend deduplicat assert.match(loadingDtos, /record History\([\s\S]{0,180}String operationId/) assert.match(loadingRepository, /new History\([\s\S]{0,180}getOperationId\(\)/) assert.match(openapi, /OperationHistory:[\s\S]{0,500}operationId:/) - assert.match(adminApi, /interface LoadingHistory[\s\S]{0,260}operationId:\s*string/) + assert.match(adminApi, /interface LoadingHistory[\s\S]*?operationId:\s*string/) assert.match(adminApi, /normalizeHistory[\s\S]{0,260}operationId:/) }) diff --git a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs index 620f1dcd0..da2d5aa12 100644 --- a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs @@ -8,6 +8,7 @@ const loadingPickService = read('../../firstleg/src/main/java/com/baiyue/firstle const completionCommand = read('../../firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingCompletionCommandEntity.java') const completionWorker = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java') const loadingPickTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java') +const completionWorkerTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java') const settlementTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java') const unifiedSql = read('../../deploy/sql/头程数据库统一增量-MySQL8.sql') const finalizeHttp = read('phase3-loading-finalize.mjs') @@ -15,8 +16,6 @@ const pdaPage = read('../../../apps/pda-uniapp/pages/picking/picking.vue') const pdaCompletionState = read('../../../apps/pda-uniapp/utils/loading-completion-state.js') const pdaCompletionTests = read('../../../apps/pda-uniapp/tests/loading-completion-state.test.mjs') -const RECOVERY_TASK_ID = '2092817774453780481' - function retryDecision({ operationStatus, sameRequest, finalTaskStatus }) { if (!sameRequest) return 'CONFLICT' if (operationStatus === 'PROCESSING') return 'PROCESSING' @@ -74,7 +73,8 @@ test('durable worker processes inbound settlements incrementally with retry back assert.match(completionWorker, /15L <<|nextRetryAt|retryAt/) assert.match(completionWorker, /"FAILED"\s*:\s*"RETRYABLE"/) assert.match(loadingPickService, /checkpointAtLeast[\s\S]{0,1800}SETTLED/) - assert.match(loadingPickTests, /(?:transient|瞬时)[\s\S]{0,1500}(?:backoff|retry|退避)/i) + assert.match(completionWorkerTests, /transientFailureIsReleasedForBackoffRetry/) + assert.match(completionWorkerTests, /"RETRYABLE"/) assert.match(loadingPickTests, /FAILED[\s\S]{0,1500}(?:same operationId|同operationId|resume|恢复)/i) }) @@ -89,27 +89,30 @@ test('a different operationId is rejected while the completion command is proces }) test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => { - assert.match(settlementTests, /15|fifteen/i) - assert.match(settlementTests, /(?:partial success|部分成功|local rollback|本地回滚)/i) - assert.match(settlementTests, /(?:settlementTarget|COMPLETED|replaySettlement)/) - assert.match(loadingPickTests, /(?:resume|recover|reconcile|恢复|核平)[\s\S]{0,2000}COMPLETE_LOADING/i) + assert.match(loadingPickTests, /workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce/) + assert.match(loadingPickTests, /IntStream\.range\(0, 15\)/) + assert.match(settlementTests, /reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback/) + assert.match(settlementTests, /warehouse\.settlementTarget\("internal-token", 31L, "PDA-SETTLE-1-31"\)/) + assert.match(settlementTests, /targetBusinessId\(\) == 999L/) + assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/) }) -test('recovery fixture preserves the observed fifteen-settlement partial success', () => { - assert.equal(RECOVERY_TASK_ID, '2092817774453780481') - assert.match(finalizeHttp, new RegExp(RECOVERY_TASK_ID)) - assert.match(finalizeHttp, /15[\s\S]{0,600}(?:Warehouse|settlement)/i) - assert.match(finalizeHttp, /(?:LOADING|v1)[\s\S]{0,600}(?:恢复|recovery|resume)/i) +test('recovery contract is fixture-independent and uses durable checkpoints', () => { + assert.doesNotMatch(finalizeHttp, /2092817774453780481/) + assert.match(loadingPickService, /checkpointAtLeast\(checkpoint, "SETTLED"\)/) + assert.match(loadingPickService, /checkpointAtLeast\(checkpoint, "INBOUND_COMPLETED"\)/) + assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/) }) -test('final acceptance reconciles task allocation reservation occupancy and one completion audit', () => { - for (const fact of ['task', 'allocation', 'reservation', 'occupancy']) { +test('final acceptance reconciles terminal task and one completion audit after owner checkpoints', () => { + for (const fact of ['task', 'plan', 'inbound', 'operation']) { assert.match(finalizeHttp, new RegExp(fact, 'i'), `missing final ${fact} reconciliation`) } assert.match(finalizeHttp, /COMPLETE_LOADING/) - assert.match(finalizeHttp, /(?:length === 1|恰有一次|只写一条)/) - assert.match(finalizeHttp, /(?:PROCESSING|处理中)/) - assert.match(finalizeHttp, /(?:elapsed|latency|耗时|毫秒|ms)/i) + assert.match(finalizeHttp, /concurrentCompleteOperations\.length === 1/) + assert.match(finalizeHttp, /异步完成命令收敛到终态/) + assert.match(loadingPickService, /completionTransaction\.executeWithoutResult/) + assert.match(loadingPickService, /private void finalizeCompletion/) }) test('PDA treats accepted completion as processing, returns to list, and never claims completion early', () => { diff --git a/scripts/admin-test-env-modules/pda-complete-location-release-convergence-contract.test.mjs b/scripts/admin-test-env-modules/pda-complete-location-release-convergence-contract.test.mjs index 5c7b012e8..20a6cc759 100644 --- a/scripts/admin-test-env-modules/pda-complete-location-release-convergence-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-complete-location-release-convergence-contract.test.mjs @@ -11,6 +11,7 @@ const locationTests = read('../../warehouse/src/test/java/com/baiyue/warehouse/s const settlementService = read('../../warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java') const settlementTests = read('../../warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java') const finalizeHttp = read('phase3-loading-finalize.mjs') +const loadingPickService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java') function releaseDecision({ sharedOverflow, locationStatus, activeOccupanciesAfterRelease }) { if (sharedOverflow) return 'SHARED_STATUS_UNCHANGED' @@ -63,10 +64,16 @@ test('same settlement key replays without a second release or transfer', () => { assert.match(settlementTests, /verify\(locations,\s*never\(\)\)\.transferToChildByReservation/) }) -test('HTTP failure contract proves task plan allocation location history outbox operation zero drift', () => { - for (const fact of ['task', 'plan', 'inbound', 'allocation', 'location', 'history', 'outbox', 'operation']) { +test('acceptance failure is zero-write before worker and final local convergence is transactional', () => { + for (const fact of ['task', 'plan', 'inbound', 'operation']) { assert.match(finalizeHttp, new RegExp(fact, 'i'), `missing ${fact} failure baseline`) } assert.match(finalizeHttp, /A0409/) assert.match(finalizeHttp, /零写入|零漂移/) + assert.match(loadingPickService, /completionTransaction\.executeWithoutResult/) + const finalizer = loadingPickService.slice(loadingPickService.indexOf('private void finalizeCompletion'), + loadingPickService.indexOf('private void requireColoadConservation')) + for (const fact of ['COMPLETE_LOADING', 'finishFromPda', 'publishCompletedEvent', 'completeAccepted']) { + assert.match(finalizer, new RegExp(fact), `missing transactional ${fact}`) + } }) 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 1d99d7a3f..01225936e 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 @@ -107,19 +107,19 @@ test('downstream finalizer uses the frozen truck-coupled complete contract', () assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/) }) -test('PDA complete OpenAPI freezes optional truck coupling and atomic rollback', () => { +test('PDA complete OpenAPI freezes reliable async acceptance and checkpoint recovery', () => { assert.match(pdaOpenApi, /\/api\/platform\/v1\/first-leg\/pda-loading\/tasks\/\{taskId\}\/complete:/) - assert.match(pdaOpenApi, /拖车状态不是前置门禁/) - assert.match(pdaOpenApi, /没有有效拖车则直接继续/) - assert.match(pdaOpenApi, /恰有一条有效拖车时由服务端加锁推进为COMPLETED并写历史/) - assert.match(pdaOpenApi, /多条有效拖车或并发修改返回A0409且全部Firstleg本地状态回滚/) - assert.match(pdaOpenApi, /同operationId重放返回冻结结果,不重复推进拖车或写历史/) + assert.match(pdaOpenApi, /持久化可靠完成命令并将任务置为COMPLETING后立即返回PROCESSING事实/) + assert.match(pdaOpenApi, /后台worker按稳定入库单顺序以checkpoint恢复/) + assert.match(pdaOpenApi, /请求事务内不调用任何远程服务/) + assert.match(pdaOpenApi, /同operationId重放立即返回相同PROCESSING或最终结果/) + assert.match(pdaOpenApi, /不同operationId在处理期间返回A0409/) }) test('PDA final completion refreshes post-settlement versions for full and partial split', () => { ordered(loadingPickService, [ - 'settlementService.settle(taskId, operatorId, operatorName)', - 'loadingDeliveryService.completeAfterPdaSettlement', + 'settlementService.settleOwner(', + 'loadingDeliveryService.completeAfterPdaSettlement(', ]) assert.doesNotMatch(loadingPickService, /new LoadingRequests\.CompleteLoadingRequest\(entry\.getValue\(\)\.version\(\), remark\)/) @@ -133,7 +133,8 @@ test('PDA final completion refreshes post-settlement versions for full and parti 'pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement', 'pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange', ]) assert.match(loadingDeliveryServiceTest, new RegExp(regression)) - assert.match(loadingDeliveryServiceTest, /verify\(repository, never\(\)\)\.completeLoadedChild\(10, 20, 7, 6\)/) + assert.match(loadingDeliveryServiceTest, /verify\(repository\)\.findInboundForUpdate\(10L, 21L\)/) + assert.match(loadingDeliveryServiceTest, /verify\(repository\)\.completeLoadedChild\(10L, 20L, 9L, 9\)/) assert.match(loadingDeliveryServiceTest, /verify\(histories, never\(\)\)\.insert/) }) @@ -159,7 +160,8 @@ test('HTTP finalizer races distinct completion keys and persists exactly one ter '读取结算前 PDA 操作历史基线', 'const completeAttempts = [1, 2].map', 'const concurrentCompleteResponses = await Promise.all', - '并发完成后回查 PDA 操作审计', + '异步完成命令收敛到终态', + 'const operationsAfterConcurrent = completion.operations', '两个不同幂等键并发 complete 仅一成功且只写一条完成审计', '订舱与五类证据等保留门禁齐备后完成装柜', 'LOADED 完成请求 Idempotency-Key 重放保持终态', @@ -176,3 +178,17 @@ test('HTTP finalizer races distinct completion keys and persists exactly one ter assert.match(finalize, /concurrentCompleteOperations\.length === 1/) assert.match(finalize, /concurrentCompleteOperations\[0\]\?\.operationId === completeBody\.operationId/) }) + +test('worker final Firstleg convergence is one short transaction after remote owner settlement', () => { + ordered(loadingPickService, [ + 'settlementService.settleOwner(', + 'loadingDeliveryService.completeAfterPdaSettlement(', + 'completionTransaction.executeWithoutResult', + 'completeTruckAfterCommit(', + ]) + const finalizer = loadingPickService.slice(loadingPickService.indexOf('private void finalizeCompletion'), + loadingPickService.indexOf('private void requireColoadConservation')) + for (const token of ['COMPLETE_LOADING', 'finishFromPda', 'syncInboundProjections', + 'publishCompletedEvent', 'completeAccepted', 'completionCommands.complete']) assert.match(finalizer, new RegExp(token)) + assert.doesNotMatch(finalizer, /settlementService|completeTruckAfterCommit/) +}) diff --git a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs index 96bb8c319..caaea1192 100644 --- a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs @@ -37,7 +37,7 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat test('pending backend contract derives workflow from task items instead of assignment alone', () => { assert.match(openApi, - /PendingTask:[\s\S]*workflowStatus: \{type: string, enum: \[UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW\][^\r\n]*}/) + /PendingTask:[\s\S]*workflowStatus: \{type: string, enum: \[UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED\][^\r\n]*}/) assert.match(mapper, /WHEN SUM\(CASE WHEN pick_item\.item_status IN \('PENDING', 'PICKING'\)[\s\S]*= 0[\s\S]*THEN 'AWAITING_REVIEW'/) assert.doesNotMatch(mapper, diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index df0841e3e..b1907a4d3 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -10,10 +10,11 @@ import { collectPhase4InboundProducerFixture } from './phase3-loading-finalize.m const here = dirname(fileURLToPath(import.meta.url)) const read = (name) => readFileSync(resolve(here, name), 'utf8') -test('order fixture defaults to FBA and accepts only the explicit OVERSEAS variant', () => { +test('order fixture defaults to FBA and preserves explicit OVERSEAS or PRIVATE routes', () => { assert.equal(requestedOrderDestinationType({ context: {} }), 'FBA') assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'overseas' } }), 'OVERSEAS') - assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'PRIVATE' } }), 'FBA') + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'PRIVATE' } }), 'PRIVATE') + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'invalid' } }), 'FBA') const orders = read('orders.mjs') assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/) assert.match(orders, /destinationType: master\.destinationType/) @@ -88,7 +89,8 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(entry, /skuLines: Array\.isArray\(fixtureOrder\.skuLines\) \? fixtureOrder\.skuLines : \[\]/) const pda = read('phase4-overseas-pda.mjs') assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) - assert.match(pda, /skuFixtures\(context, producer\)/) + assert.match(pda, /resolveCartonFixtures\(context, token, current\.data\.inbounds, producerCartonMarks\)/) + assert.match(pda, /FBA与OVERSEAS箱码resolve均为纯查询且零状态写入/) }) test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => { 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 c36677567..4ac14ceee 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 @@ -377,7 +377,10 @@ test('container loading enforces channel isolation and stable PDA identity acros assert.match(value, /bookingAgentId: id\(bookingAgent\.id\), bookingAgentName: bookingAgent\.name/) assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/) assert.match(value, /if \(!frozenTransportMatches\) return/) - assert.match(value, /expectedVersion: Number\(bookingCreatedDraft\?\.oceanBooking\?\.version\), reason: context\.marker/) + assert.match(value, /const releaseVersion = Number\(bookingDocument\?\.bookingVersion\)/) + assert.match(value, /expectedVersion: releaseVersion, reason: context\.marker/) + assertOrder(value, ['上传有效订舱确认文件', '订舱确认文件已关联且版本递增', + '订舱放舱为 RELEASED 且不预生成真实货柜']) assert.doesNotMatch(value, /const sailingAt = new Date\(Date\.now\(\)/) const batch = value.slice(value.indexOf('const staleBatchResponse'), value.indexOf('const completePath')) assert.doesNotMatch(batch, /containerNo:/) @@ -412,16 +415,18 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au const convergence = source('./phase3-pda-convergence.mjs') assertOrder(container, [ '最后一箱拣货后 PDA 任务等待人工确认装柜完成', - '上传柜次 ${evidenceType} 真实证据文件', - '绑定柜次 ${evidenceType} 装柜证据', + '上传并绑定柜次 ${evidenceType} 真实证据文件', + '${evidenceType} 有效证据归属稳定且锁码门禁完整', 'PDA 完成前回查五类有效装柜证据与锁码', - 'PDA 人工确认装柜完成', + 'PDA 提交人工确认装柜完成', + '等待 PDA 装柜完成异步收敛', ]) assert.match(container, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/) - assert.match(container, /businessType === `LOADING_\$\{evidenceType\}`/) + assert.match(container, /form\.append\('evidenceType', evidenceType\)/) + assert.match(container, /new Blob\(\[png\], \{ type: 'image\/png' \}\)/) assert.match(container, /item\.evidenceType === 'SEAL' && item\.sealNo === sealNo/) assert.match(container, /if \(!evidenceTypes\.every[\s\S]*\) return/) - assert.match(container, /requestBinary\('GET',[\s\S]*files\/loading-evidence/) + assert.match(container, /requestBinary\('GET',[\s\S]*\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content/) assert.match(container, /回查完成后的 PDA 操作审计/) assert.match(container, /item\.actionType === 'BATCH'/) assert.match(container, /item\.actionType === 'COMPLETE_LOADING'/) @@ -502,7 +507,7 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.match(loading, /Number\(exceptionOverview\?\.pda\?\.unresolvedItemCount\) === 0/) assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/) assert.match(entry, /this\.context\.phase3LoadingIncrementalDetailCount = 4/) - assert.match(measurement, /增量 fixture 形成四条真实独立货物测量明细/) + assert.match(measurement, /增量 fixture 形成动态真实独立货物测量明细/) assert.match(measurement, /detailBoxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-\$\{cartonIndex\}`/) assert.match(measurement, /items: draftItems/) assert.match(measurement, /expectedPackages === originalPackageCount/) @@ -560,9 +565,11 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/) assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) assert.match(serviceCenter, /if \(!allocationMatches\) return/) - assert.match(serviceCenter, /const customerName = String\(oceanDetail\?\.oceanBooking\?\.customerName \|\| ''\)\.trim\(\)/) assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/) - assert.match(serviceCenter, /customerId, customerName: null, carrierId: id\(carrier\.id\), carrierName: carrier\.name/) + assert.match(serviceCenter, /oceanBookingId: ocean\.id/) + assert.match(serviceCenter, /containerBookingId: linkContainer \? id\(containerFlow\.containerBookingId\) : null/) + assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/) + assert.doesNotMatch(serviceCenter, /customerId:|customerName:/) assert.doesNotMatch(serviceCenter, /链路承运商-/) assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/) const schedules = source('./phase3-port-schedules.mjs') @@ -596,7 +603,8 @@ test('container overview always follows phase3 loading in default and custom mod const entry = source('../admin-test-env-acceptance.mjs') assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/) - assert.match(entry, /modules: normalizeModuleOrder\(/) + assert.match(entry, /const modules = normalizeModuleOrder\(/) + assert.match(entry, /return \{[\s\S]{0,500}modules,/) }) test('overview does not fall back to a container fixture when loading evidence was requested', () => {