import { readFile } from 'node:fs/promises' import { resolve } from 'node:path' const MODULE = 'manual-pda-batch-verify' const OPTIONS = { scenario: 'manual-pda-batch-readonly-verification', deploymentBlocker: true } const PDA = '/api/platform/v1/first-leg/pda-loading' function id(value) { return value == null ? '' : String(value) } function rows(value) { return Array.isArray(value) ? value : [] } async function loadFixture(context) { const report = JSON.parse(await readFile(resolve(context.options.manualPdaBatchSourceReport), 'utf8')) const fixture = report?.createdResources?.manualPdaBatchFixture const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 && fixture?.stage === 'MANUAL_PDA_CLAIM_AND_BATCH_READY' && fixture?.pdaTaskId && Array.isArray(fixture?.taskItems) && fixture.taskItems.length > 0 context.assert(MODULE, '读取同环境 PDA 人工作业准备报告', null, valid, `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`, OPTIONS) return valid ? fixture : null } function stableItem(value) { return { id: id(value.id), loadingPlanItemId: id(value.loadingPlanItemId), tenantId: id(value.tenantId), inboundId: id(value.inboundId), inboundDetailId: id(value.inboundDetailId), boxCodes: rows(value.boxCodes).map(String), plannedPackageCount: Number(value.plannedPackageCount) } } export async function run(context) { const fixture = await loadFixture(context) if (!fixture) return const taskId = id(fixture.pdaTaskId) const taskResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) const task = context.apiSuccess(MODULE, '读取人工领取和批量提交后的 PDA 任务', taskResponse, '', OPTIONS) const afterItems = rows(task?.items).map(stableItem) .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) const beforeStable = fixture.taskItems.map(stableItem) const allPicked = rows(task?.items).length > 0 && rows(task?.items).every((item) => item.itemStatus === 'PICKED' && Number(item.pickedPackageCount) === Number(item.plannedPackageCount)) context.assert(MODULE, '领取后任务绑定作业员并以一次批量提交进入待复核', taskResponse, id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId) && id(task?.containerBookingId) === id(fixture.containerBookingId) && task?.cabinetNo === fixture.cabinetNo && task?.currentContainerNo === fixture.containerNo && task?.taskStatus === 'LOADING' && task?.workflowStatus === 'AWAITING_REVIEW' && task?.assignment?.assigneeId && task?.assignment?.assigneeName && Number(task?.version) > Number(fixture.beforeTaskVersion) && JSON.stringify(afterItems) === JSON.stringify(beforeStable) && allPicked, `taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, assigneeId=${id(task?.assignment?.assigneeId) || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`, OPTIONS) if (!task || task.workflowStatus !== 'AWAITING_REVIEW') return const pendingResponse = await context.request('GET', `${PDA}/tasks/pending?keyword=${encodeURIComponent(fixture.containerNo)}`) const pending = context.apiSuccess(MODULE, '读取待复核 PDA pending 投影', pendingResponse, '', OPTIONS) const pendingTask = rows(pending).find((item) => id(item.taskId) === taskId) context.assert(MODULE, 'pending 与详情一致显示 AWAITING_REVIEW 且无未处理明细', pendingResponse, Boolean(pendingTask) && pendingTask.workflowStatus === 'AWAITING_REVIEW' && Number(pendingTask.itemCount) === rows(task.items).length && Number(pendingTask.pendingItemCount) === 0, `workflowStatus=${pendingTask?.workflowStatus || 'missing'}, itemCount=${pendingTask?.itemCount ?? 'missing'}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}`, OPTIONS) const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) const operations = context.apiSuccess(MODULE, '读取领取与原子批量提交审计', operationsResponse, '', OPTIONS) const claimOperations = rows(operations).filter((item) => item.actionType === 'CLAIM') const batchOperations = rows(operations).filter((item) => item.actionType === 'BATCH') context.assert(MODULE, '人工主链只保留一次 CLAIM 和一次 BATCH 且尚无复核完成记录', operationsResponse, claimOperations.length === 1 && batchOperations.length === 1 && [...claimOperations, ...batchOperations].every((item) => item.operationId && item.operatorId && item.operatorName && item.createdAt) && !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)), `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, claimCount=${claimOperations.length}, batchCount=${batchOperations.length}`, OPTIONS) const uniqueInbounds = [...new Map(rows(task.items).map((item) => [id(item.inboundId), item])).values()] for (const item of uniqueInbounds) { const projectionResponse = await context.request('GET', `/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/picking?tenantId=${encodeURIComponent(item.tenantId)}`) const projection = context.apiSuccess(MODULE, `回查 ${item.inboundCode || item.inboundId} 入库级拣货投影`, projectionResponse, '', OPTIONS) context.assert(MODULE, `${item.inboundCode || item.inboundId} 服务端汇总数量已拣齐`, projectionResponse, Number(projection?.expectedCount) > 0 && Number(projection?.expectedCount) === Number(projection?.pickedCount), `status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS) const evidenceResponse = await context.request('GET', `/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(item.tenantId)}`) const evidence = context.apiSuccess(MODULE, `${item.inboundCode || item.inboundId} 待复核取证入口可读`, evidenceResponse, '', OPTIONS) context.assert(MODULE, `${item.inboundCode || item.inboundId} AWAITING_REVIEW 可进入取证`, evidenceResponse, Array.isArray(evidence?.evidences), `evidenceCount=${evidence?.evidences?.length ?? 'missing'}`, OPTIONS) } context.createdResources.manualPdaBatchVerification = { sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PDA_AWAITING_REVIEW', pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId), containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo, containerNo: fixture.containerNo, warehouseId: id(fixture.warehouseId), taskVersion: Number(task.version), assigneeId: id(task.assignment?.assigneeId), assigneeName: task.assignment?.assigneeName || null, itemCount: rows(task.items).length, plannedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.plannedPackageCount || 0), 0), pickedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0), claimOperationId: claimOperations[0]?.operationId || null, batchOperationId: batchOperations[0]?.operationId || null, deviceEvidenceRequired: 'HTTP 不记录逐次扫码轨迹;箱码逐一扫描需保留真机人工验收证据。', nextStage: '上传五类 ACTIVE 装柜证据后,再执行 PDA 最终完成与 LOADED 核验。', } }