Files
baiyue-firstleg/scripts/admin-test-env-modules/manual-loading-complete-verify.mjs
T
2026-08-26 16:50:22 +08:00

164 lines
11 KiB
JavaScript

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;后续报关、国内出仓和海外接柜属于后续节点。',
}
}