2026-08-09 11:21:38 +08:00
|
|
|
const MODULE = 'phase3-pda-convergence'
|
|
|
|
|
const OPTIONS = { scenario: 'pda-picking-convergence', deploymentBlocker: true }
|
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
|
|
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
|
|
|
|
function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') }
|
2026-08-17 16:17:20 +08:00
|
|
|
export function buildPendingActions(task) {
|
|
|
|
|
if (task?.workflowStatus === 'AWAITING_REVIEW' || task?.workflowStatus === 'COMPLETED') return []
|
|
|
|
|
return (task?.items || []).filter((item) => ['PENDING', 'PICKING'].includes(item?.itemStatus))
|
|
|
|
|
.map((item) => ({ itemId: id(item?.id), action: 'PICK' }))
|
|
|
|
|
}
|
2026-08-09 11:21:38 +08:00
|
|
|
async function request(context, method, path, body, headers = {}) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${context.options.gateway}${path}`, { method,
|
2026-09-23 19:31:50 +08:00
|
|
|
headers: { Accept: 'application/json', Authorization: `Bearer ${context.pdaToken && /\/pda-loading\/tasks\/\d+/.test(path) ? context.pdaToken : context.token}`,
|
2026-08-09 11:21:38 +08:00
|
|
|
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
|
|
|
|
|
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000) })
|
|
|
|
|
const raw = await response.text(); let payload = null
|
|
|
|
|
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
|
|
|
|
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
|
|
|
|
|
requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
|
|
|
|
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
|
|
|
|
}
|
2026-08-08 22:42:49 +08:00
|
|
|
export async function run(context) {
|
2026-08-09 11:21:38 +08:00
|
|
|
const fixture = context.createdResources?.phase3Loading
|
|
|
|
|
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
|
|
|
|
|
const taskId = id(fixture?.canonicalPdaTaskId); const containerNo = fixture?.containerNo
|
2026-08-11 11:11:57 +08:00
|
|
|
const warehouseId = id(fixture?.warehouseId); const loadingPlanId = id(fixture?.loadingPlanId)
|
|
|
|
|
if (!tenantId || !inboundId || !taskId || !containerNo || !warehouseId || !loadingPlanId
|
|
|
|
|
|| fixture?.marker !== context.marker) {
|
2026-08-24 19:58:35 +08:00
|
|
|
context.addStep(MODULE, '取得同 marker 的 PDA 收敛前置', null, false,
|
|
|
|
|
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, taskId=${taskId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
|
|
|
|
|
OPTIONS)
|
2026-08-09 11:21:38 +08:00
|
|
|
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛',
|
|
|
|
|
'本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture')
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 13:48:16 +08:00
|
|
|
const loadingPlanResponse = await request(context, 'GET',
|
|
|
|
|
`/api/platform/v1/first-leg/smart-loading/plans/${loadingPlanId}`)
|
|
|
|
|
const loadingPlan = context.apiSuccess(MODULE, '回查柜次 PDA 待最终完成的运行中计划', loadingPlanResponse, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, '运行中计划保持 LOADING 且尚未最终完成', loadingPlanResponse,
|
|
|
|
|
id(loadingPlan?.id) === loadingPlanId && loadingPlan?.status === 'LOADING'
|
|
|
|
|
&& loadingPlan?.finalCompletionStatus === 'IN_PROGRESS',
|
|
|
|
|
`planId=${id(loadingPlan?.id) || 'missing'}, status=${loadingPlan?.status || 'missing'}, final=${loadingPlan?.finalCompletionStatus || 'missing'}`, OPTIONS)
|
2026-08-09 11:21:38 +08:00
|
|
|
const projectionResponse = await request(context, 'GET',
|
|
|
|
|
`/api/platform/v1/inbounds/${inboundId}/loading/picking?tenantId=${encodeURIComponent(tenantId)}`)
|
|
|
|
|
const projection = context.apiSuccess(MODULE, '查询入库级 PDA 服务端聚合投影', projectionResponse, '', OPTIONS)
|
2026-08-17 15:41:42 +08:00
|
|
|
context.assert(MODULE, '入库投影数量已拣齐但仍等待最终门禁', projectionResponse,
|
2026-08-24 13:48:16 +08:00
|
|
|
['IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED'].includes(projection?.status) && Number(projection?.expectedCount) > 0
|
2026-08-09 11:21:38 +08:00
|
|
|
&& Number(projection?.expectedCount) === Number(projection?.pickedCount),
|
|
|
|
|
`status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS)
|
2026-08-17 16:17:20 +08:00
|
|
|
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}`
|
|
|
|
|
const taskDetailResponse = await request(context, 'GET',
|
|
|
|
|
`${taskPath}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
|
|
|
const taskDetail = context.apiSuccess(MODULE, '按稳定 taskId 读取 PDA 任务详情', taskDetailResponse, '', OPTIONS)
|
|
|
|
|
const pendingResponse = await request(context, 'GET',
|
2026-09-11 12:36:12 +08:00
|
|
|
`/api/platform/v1/first-leg/pda-loading/tasks/pending?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}`)
|
2026-08-17 16:17:20 +08:00
|
|
|
const pendingTasks = context.apiSuccess(MODULE, '读取当前操作员 PDA pending 列表', pendingResponse, '', OPTIONS)
|
2026-09-23 19:33:53 +08:00
|
|
|
const pendingRecords = Array.isArray(pendingTasks) ? pendingTasks
|
|
|
|
|
: pendingTasks?.records || pendingTasks?.items || []
|
2026-09-23 19:36:45 +08:00
|
|
|
let pendingTask = pendingRecords.find((item) => id(item?.taskId || item?.id) === taskId)
|
2026-09-23 19:39:31 +08:00
|
|
|
if (!pendingTask) {
|
|
|
|
|
const currentKeyword = id(taskDetail?.currentContainerNo)
|
|
|
|
|
const currentPendingResponse = currentKeyword ? await request(context, 'GET',
|
|
|
|
|
`/api/platform/v1/first-leg/pda-loading/tasks/pending?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(currentKeyword)}`) : null
|
|
|
|
|
const currentPending = currentPendingResponse
|
|
|
|
|
? context.apiSuccess(MODULE, '按任务当前真实柜号回查 PDA pending 列表', currentPendingResponse, '', OPTIONS) : []
|
|
|
|
|
const currentRecords = Array.isArray(currentPending) ? currentPending : currentPending?.records || currentPending?.items || []
|
|
|
|
|
pendingTask = currentRecords.find((item) => id(item?.taskId || item?.id) === taskId)
|
|
|
|
|
}
|
2026-09-23 19:36:45 +08:00
|
|
|
if (!pendingTask) {
|
|
|
|
|
const broadPendingResponse = await request(context, 'GET',
|
|
|
|
|
`/api/platform/v1/first-leg/pda-loading/tasks/pending?warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
|
|
|
const broadPending = context.apiSuccess(MODULE, '无关键词回查当前操作员 PDA pending 列表', broadPendingResponse, '', OPTIONS)
|
|
|
|
|
const broadRecords = Array.isArray(broadPending) ? broadPending : broadPending?.records || broadPending?.items || []
|
|
|
|
|
pendingTask = broadRecords.find((item) => id(item?.taskId || item?.id) === taskId)
|
|
|
|
|
}
|
2026-08-17 16:17:20 +08:00
|
|
|
const unresolvedItems = (taskDetail?.items || []).filter((item) =>
|
|
|
|
|
['PENDING', 'PICKING'].includes(item?.itemStatus))
|
|
|
|
|
context.assert(MODULE, 'pending 与 detail 使用同一 workflowStatus 和未完成数量事实', pendingResponse,
|
|
|
|
|
Boolean(pendingTask) && pendingTask?.workflowStatus === taskDetail?.workflowStatus
|
|
|
|
|
&& Number(pendingTask?.pendingItemCount) === unresolvedItems.length
|
|
|
|
|
&& Number(pendingTask?.itemCount) === Number(taskDetail?.items?.length),
|
|
|
|
|
`taskId=${taskId}, pendingWorkflow=${pendingTask?.workflowStatus || 'missing'}, detailWorkflow=${taskDetail?.workflowStatus || 'missing'}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}, detailUnresolved=${unresolvedItems.length}`, OPTIONS)
|
|
|
|
|
const pendingActions = buildPendingActions(taskDetail)
|
|
|
|
|
context.assert(MODULE, 'AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch', taskDetailResponse,
|
|
|
|
|
taskDetail?.workflowStatus === 'AWAITING_REVIEW' && unresolvedItems.length === 0
|
|
|
|
|
&& pendingActions.length === 0,
|
|
|
|
|
`workflow=${taskDetail?.workflowStatus || 'missing'}, unresolved=${unresolvedItems.length}, actions=${pendingActions.length}`, OPTIONS)
|
|
|
|
|
const evidenceResponse = await request(context, 'GET',
|
2026-09-01 14:48:51 +08:00
|
|
|
`${taskPath}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`)
|
2026-08-17 16:17:20 +08:00
|
|
|
const evidence = context.apiSuccess(MODULE, 'AWAITING_REVIEW 可进入装柜取证只读入口', evidenceResponse, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, 'AWAITING_REVIEW 取证 GET 开放且不依赖重复 batch', evidenceResponse,
|
2026-09-01 14:48:51 +08:00
|
|
|
taskDetail?.workflowStatus === 'AWAITING_REVIEW' && id(evidence?.taskId) === taskId
|
|
|
|
|
&& id(evidence?.containerBookingId) === id(fixture?.containerBookingId)
|
|
|
|
|
&& Array.isArray(evidence?.evidences),
|
2026-08-17 16:17:20 +08:00
|
|
|
`workflow=${taskDetail?.workflowStatus || 'missing'}, evidences=${evidence?.evidences?.length ?? 'missing'}, actions=${pendingActions.length}`, OPTIONS)
|
2026-08-09 11:21:38 +08:00
|
|
|
const operationsResponse = await request(context, 'GET',
|
2026-08-17 16:17:20 +08:00
|
|
|
`${taskPath}/operations`)
|
2026-08-09 11:21:38 +08:00
|
|
|
const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS)
|
2026-08-24 19:58:35 +08:00
|
|
|
const batchOperations = Array.isArray(operations)
|
|
|
|
|
? operations.filter((item) => item.actionType === 'BATCH'
|
|
|
|
|
&& Boolean(item.operatorId) && Boolean(item.createdAt)) : []
|
2026-08-25 13:59:58 +08:00
|
|
|
const expectedBatchOperationIds = Array.isArray(fixture?.expectedBatchOperationIds)
|
|
|
|
|
? fixture.expectedBatchOperationIds.map(id).filter(Boolean) : []
|
|
|
|
|
const actualBatchOperationIds = batchOperations.map((item) => id(item.operationId)).filter(Boolean)
|
2026-09-02 03:32:35 +08:00
|
|
|
const expectedBatchCount = fixture?.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? 1 : 4
|
2026-08-17 15:41:42 +08:00
|
|
|
context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse,
|
2026-08-11 11:11:57 +08:00
|
|
|
Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM'
|
|
|
|
|
&& Boolean(item.operatorId) && Boolean(item.createdAt))
|
2026-08-17 15:41:42 +08:00
|
|
|
&& !operations.some((item) => item.actionType === 'REVIEW')
|
|
|
|
|
&& !operations.some((item) => item.actionType === 'COMPLETE_LOADING'),
|
2026-08-09 11:21:38 +08:00
|
|
|
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
2026-08-12 10:59:39 +08:00
|
|
|
|
|
|
|
|
context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse,
|
2026-09-02 03:32:35 +08:00
|
|
|
expectedBatchOperationIds.length === expectedBatchCount
|
|
|
|
|
&& new Set(expectedBatchOperationIds).size === expectedBatchCount
|
|
|
|
|
&& actualBatchOperationIds.length === expectedBatchCount
|
|
|
|
|
&& new Set(actualBatchOperationIds).size === expectedBatchCount
|
2026-08-25 13:59:58 +08:00
|
|
|
&& expectedBatchOperationIds.every((operationId) => actualBatchOperationIds.includes(operationId))
|
|
|
|
|
&& actualBatchOperationIds.every((operationId) => expectedBatchOperationIds.includes(operationId)),
|
|
|
|
|
`expectedBatchCount=${expectedBatchOperationIds.length}, actualBatchCount=${actualBatchOperationIds.length}, expectedIds=${expectedBatchOperationIds.join(',') || 'missing'}, actualIds=${actualBatchOperationIds.join(',') || 'missing'}`, OPTIONS)
|
2026-08-09 11:21:38 +08:00
|
|
|
const forged = await request(context, 'POST',
|
|
|
|
|
`/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
|
|
|
|
{ expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' },
|
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-pda-forged-projection`.slice(0, 128) })
|
|
|
|
|
context.addStep(MODULE, '入库兼容入口拒绝客户端伪造汇总数量', forged, failure(forged),
|
|
|
|
|
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
|
2026-08-10 12:23:43 +08:00
|
|
|
|
2026-08-11 11:44:34 +08:00
|
|
|
if (fixture?.atomicBatchVerified !== true) {
|
2026-08-24 19:58:35 +08:00
|
|
|
context.addStep(MODULE, 'PDA 原子批次成功且同键重放零新增', null, false,
|
|
|
|
|
'phase3-loading 未发布 atomicBatchVerified=true;不能以 coverageGap 代替真实批次与幂等断言', OPTIONS)
|
2026-08-11 11:44:34 +08:00
|
|
|
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 原子批量成功与批次幂等闭环',
|
|
|
|
|
'原子 batch 端点已部署,但本轮合法整批提交或同键重放未通过真实 HTTP 断言;保留真实失败证据',
|
|
|
|
|
'deployment-blocker')
|
|
|
|
|
}
|
2026-08-11 20:10:24 +08:00
|
|
|
if (fixture?.inventorySnapshotVerified !== true) {
|
2026-08-24 19:58:35 +08:00
|
|
|
context.addStep(MODULE, 'PDA 实时库存快照身份与合法 token 提交', null, false,
|
|
|
|
|
'phase3-loading 未发布 inventorySnapshotVerified=true;不能以 coverageGap 代替真实库存快照断言', OPTIONS)
|
2026-08-11 20:10:24 +08:00
|
|
|
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 实时库存快照身份、稳定性与合法 token 提交',
|
|
|
|
|
'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证',
|
|
|
|
|
'deployment-blocker')
|
|
|
|
|
}
|
2026-09-04 13:48:03 +08:00
|
|
|
if (fixture?.inventoryBlockIdentityVerified !== true
|
|
|
|
|
|| !Array.isArray(fixture?.inventoryBlockSnapshots) || fixture.inventoryBlockSnapshots.length === 0) {
|
|
|
|
|
context.addStep(MODULE, 'PDA 整票库位块身份已由正式库存快照验证', null, false,
|
|
|
|
|
'phase3-loading 未发布唯一 blockId 及 slotNo/reservationId/locationId/sharedOverflow 身份快照', OPTIONS)
|
2026-08-11 20:10:24 +08:00
|
|
|
}
|
2026-08-24 19:58:35 +08:00
|
|
|
if (fixture?.dynamicIncrementVerified !== true) {
|
|
|
|
|
context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false,
|
|
|
|
|
'phase3-loading 未发布 dynamicIncrementVerified=true;不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护', OPTIONS)
|
|
|
|
|
}
|
2026-08-25 13:59:58 +08:00
|
|
|
if (fixture?.skipBatchVerified !== true || fixture?.partialBatchVerified !== true) {
|
|
|
|
|
context.addStep(MODULE, 'SKIP 与 PARTIAL 独立原子批次均完成', null, false,
|
|
|
|
|
`skipBatchVerified=${fixture?.skipBatchVerified === true}, partialBatchVerified=${fixture?.partialBatchVerified === true};不能按普通 PICK 批次代替异常处理审计`, OPTIONS)
|
|
|
|
|
}
|
2026-08-11 11:41:41 +08:00
|
|
|
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环',
|
|
|
|
|
'原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收',
|
|
|
|
|
'device')
|
2026-08-08 22:42:49 +08:00
|
|
|
}
|