import { readFile } from 'node:fs/promises' import { resolve } from 'node:path' const MODULE = 'manual-loading-complete-verify' const OPTIONS = { scenario: 'manual-loading-complete-readonly-verification', deploymentBlocker: true } const PDA = '/api/platform/v1/first-leg/pda-loading' const SMART = '/api/platform/v1/first-leg/smart-loading' const CONTAINERS = '/api/platform/v1/containers' 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.manualLoadingCompleteSourceReport), 'utf8')) const fixture = report?.createdResources?.manualLoadingCompleteFixture const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 && fixture?.stage === 'MANUAL_EVIDENCE_AND_COMPLETE_READY' && fixture?.pdaTaskId && Array.isArray(fixture?.requiredEvidenceTypes) && fixture.requiredEvidenceTypes.length === 5 && Array.isArray(fixture?.sourceInbounds) && fixture.sourceInbounds.length > 0 && fixture?.evidenceTenantId && fixture?.evidenceInboundId context.assert(MODULE, '读取同环境装柜最终完成准备报告', null, valid, `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`, OPTIONS) return valid ? fixture : null } function itemIdentity(value) { return { id: id(value.id), tenantId: id(value.tenantId), inboundId: id(value.inboundId), inboundDetailId: id(value.inboundDetailId), plannedPackageCount: Number(value.plannedPackageCount), pickedPackageCount: Number(value.pickedPackageCount), itemStatus: value.itemStatus } } function controlledImage(download) { const type = String(download?.contentType || '').toLowerCase() return download?.http === 200 && download?.bytes?.length > 0 && type.startsWith('image/') && !type.includes('json') && !type.includes('html') } 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(itemIdentity) .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) const beforeItems = fixture.beforeItems.map(itemIdentity) context.assert(MODULE, '最终完成后任务与 workflow 同为 COMPLETED 且明细身份不漂移', taskResponse, id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId) && id(task?.containerBookingId) === id(fixture.containerBookingId) && task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED' && Number(task?.version) > Number(fixture.beforeTaskVersion) && JSON.stringify(afterItems) === JSON.stringify(beforeItems), `taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`, OPTIONS) const evidencePath = `/api/platform/v1/inbounds/${encodeURIComponent(fixture.evidenceInboundId)}/loading/evidence?tenantId=${encodeURIComponent(fixture.evidenceTenantId)}` const evidenceResponse = await context.request('GET', evidencePath) const evidence = context.apiSuccess(MODULE, '读取 LOADED 后五类不可变证据', evidenceResponse, '', OPTIONS) const activeEvidence = rows(evidence?.evidences).filter((item) => item.status !== 'VOID') const evidenceComplete = fixture.requiredEvidenceTypes.every((type) => activeEvidence.filter((item) => item.evidenceType === type).length === 1) && activeEvidence.length === fixture.requiredEvidenceTypes.length && activeEvidence.some((item) => item.evidenceType === 'SEAL' && String(item.sealNo || '').trim()) context.assert(MODULE, '五类 ACTIVE 证据各一份且 SEAL 锁码有效', evidenceResponse, evidenceComplete, `types=${activeEvidence.map((item) => item.evidenceType).join(',') || 'none'}, sealNo=${activeEvidence.find((item) => item.evidenceType === 'SEAL')?.sealNo || 'missing'}`, OPTIONS) for (const item of activeEvidence) { const path = String(item.previewUrl || '') const controlled = path.startsWith('/api/') && !path.startsWith('//') && !path.includes('://') const download = controlled ? await context.requestBinary('GET', path) : null context.assert(MODULE, `${item.evidenceType} 证据可鉴权下载真实图片`, download, controlled && controlledImage(download), `evidenceId=${id(item.id)}, path=${path || 'missing'}, bytes=${download?.bytes?.length ?? 0}, contentType=${download?.contentType || 'missing'}`, OPTIONS) } const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) const operations = context.apiSuccess(MODULE, '读取最终完成不可变操作审计', operationsResponse, '', OPTIONS) const completeOperations = rows(operations).filter((item) => item.actionType === 'COMPLETE_LOADING') context.assert(MODULE, '最终主链恰有一次 COMPLETE_LOADING 且保留原 CLAIM/BATCH', operationsResponse, rows(operations).filter((item) => item.actionType === 'CLAIM').length === 1 && rows(operations).filter((item) => item.actionType === 'BATCH').length === 1 && completeOperations.length === 1 && completeOperations[0]?.operationId && completeOperations[0]?.operatorId && completeOperations[0]?.operatorName && completeOperations[0]?.createdAt, `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, completeCount=${completeOperations.length}`, OPTIONS) const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(fixture.loadingPlanId)}`) const plan = context.apiSuccess(MODULE, '读取最终完成装柜计划', planResponse, '', OPTIONS) context.assert(MODULE, '原装柜计划进入 COMPLETED 且版本递增', planResponse, id(plan?.id) === id(fixture.loadingPlanId) && plan?.status === 'COMPLETED' && Number(plan?.version) > Number(fixture.beforePlanVersion), `status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}`, OPTIONS) const orderFacts = [] for (const source of fixture.sourceInbounds || []) { const inboundResponse = await context.request('GET', `/api/platform/inbounds/${encodeURIComponent(source.inboundId)}?tenantId=${encodeURIComponent(source.tenantId)}`) const inbound = context.apiSuccess(MODULE, `${source.inboundId} 回查 LOADED 入库终态`, inboundResponse, '', OPTIONS) const orderId = id(inbound?.summary?.orderId) context.assert(MODULE, `${source.inboundId} 入库状态和完成历史正确`, inboundResponse, inbound?.summary?.processStatus === 'LOADED' && orderId && rows(inbound?.statusHistory).some((item) => item.action === 'COMPLETE_LOADING' && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED' && item.operatorName), `status=${inbound?.summary?.processStatus || 'missing'}, orderId=${orderId || 'missing'}, histories=${rows(inbound?.statusHistory).length}`, OPTIONS) if (!orderId) continue const orderResponse = await context.request('GET', `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(source.tenantId)}`) const order = context.apiSuccess(MODULE, `${orderId} 回查统一订单终态`, orderResponse, '', OPTIONS) context.assert(MODULE, `${orderId} 统一订单详情投影为 LOADED`, orderResponse, id(order?.summary?.orderId) === orderId && order?.summary?.mainStatus === 'LOADED', `mainStatus=${order?.summary?.mainStatus || 'missing'}, orderNo=${order?.summary?.orderNo || 'missing'}`, OPTIONS) orderFacts.push({ tenantId: id(source.tenantId), inboundId: id(source.inboundId), orderId, orderNo: order?.summary?.orderNo || null }) } const tenantId = id(fixture.sourceInbounds?.[0]?.tenantId) const slicesResponse = await context.request('GET', `/api/platform/v1/orders/container-bookings/${encodeURIComponent(fixture.containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`) const slices = context.apiSuccess(MODULE, '回查柜次最终实装分片', slicesResponse, '', OPTIONS) const loadedSlices = rows(slices).filter((item) => item.splitRole === 'LOADED' && id(item.containerBookingId) === id(fixture.containerBookingId) && id(item.loadingPlanId) === id(fixture.loadingPlanId)) const pickedPackages = fixture.sourceInbounds.reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0) context.assert(MODULE, '最终实装分片唯一且箱数等于 PDA 已拣总数', slicesResponse, loadedSlices.length > 0 && new Set(loadedSlices.map((item) => id(item.sourceOrderId))).size === loadedSlices.length && loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0) === pickedPackages, `loadedSlices=${loadedSlices.length}, loadedPackages=${loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0)}, pickedPackages=${pickedPackages}`, OPTIONS) const overviewResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(fixture.containerBookingId)}/overview`) const overview = context.apiSuccess(MODULE, '回查货柜最终总览', overviewResponse, '', OPTIONS) const photoCounts = new Map(rows(overview?.pda?.photoCounts).map((item) => [item.evidenceType, Number(item.count)])) context.assert(MODULE, '货柜总览汇总 COMPLETED PDA 和五类 ACTIVE 证据', overviewResponse, id(overview?.loadingPlanId) === id(fixture.loadingPlanId) && id(overview?.pda?.taskId) === taskId && overview?.pda?.taskStatus === 'COMPLETED' && overview?.pda?.workflowStatus === 'COMPLETED' && fixture.requiredEvidenceTypes.every((type) => Number(photoCounts.get(type)) >= 1) && rows(overview?.pda?.photos).filter((item) => item.status === 'ACTIVE').length === 5, `taskStatus=${overview?.pda?.taskStatus || 'missing'}, workflowStatus=${overview?.pda?.workflowStatus || 'missing'}, photoCounts=${JSON.stringify(Object.fromEntries(photoCounts))}`, OPTIONS) context.createdResources.manualLoadingCompleteVerification = { sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'LOADED', pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId), containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo, containerNo: fixture.containerNo, completeOperationId: completeOperations[0]?.operationId || null, evidenceInboundId: id(fixture.evidenceInboundId), evidenceIds: activeEvidence.map((item) => id(item.id)), loadedSliceCount: loadedSlices.length, loadedPackageCount: pickedPackages, orders: orderFacts, terminalNote: '国内装柜人工验收链已到 LOADED;后续报关、国内出仓和海外接柜属于后续节点。', } }