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