fix(firstleg): atomically finalize PDA completion
This commit is contained in:
@@ -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<GroupSnapshot> 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,
|
||||
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TransactionStatus> 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");
|
||||
|
||||
@@ -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:/)
|
||||
})
|
||||
|
||||
|
||||
+20
-17
@@ -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', () => {
|
||||
|
||||
+9
-2
@@ -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}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user