fix(firstleg): atomically finalize PDA completion

This commit is contained in:
2026-09-01 11:11:43 +08:00
parent de0eddeee8
commit b5bc77b153
10 changed files with 181 additions and 46 deletions
@@ -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:/)
})
@@ -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', () => {
@@ -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', () => {