Files
baiyue-firstleg/scripts/admin-test-env-modules/phase3-loading-finalize.mjs
T
2026-08-12 12:35:29 +08:00

185 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const MODULE = 'phase3-loading-finalize'
const OPTIONS = { scenario: 'phase3-loading-hard-gate-finalize', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function evidenceSignature(data) {
return JSON.stringify((data?.evidences || []).map((item) => ({
id: id(item?.id), type: item?.evidenceType || null, fileId: id(item?.fileId),
status: item?.status || null, version: Number(item?.version), sealNo: item?.sealNo || null,
voidReason: item?.voidReason || null, voidedBy: id(item?.voidedBy), voidedAt: item?.voidedAt || null,
})))
}
export function buildFinalizeResource(fixture, finalData, completeResponse) {
const observedStatus = finalData?.summary?.processStatus || fixture?.inboundStatus || 'UNKNOWN'
const observedVersion = Number(finalData?.summary?.version)
const loaded = observedStatus === 'LOADED'
const version = Number.isInteger(observedVersion) ? observedVersion : fixture?.inboundVersion
return {
...fixture,
...(Number.isInteger(version) ? { inboundVersion: version } : {}),
inboundStatus: observedStatus,
finalStatus: observedStatus,
finalizeSucceeded: loaded,
notCleanedReason: loaded ? null
: `装柜完成未成功,保留真实状态 ${observedStatus};completeCode=${codeOf(completeResponse)}`,
}
}
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method,
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
...(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 }
}
}
export async function run(context) {
const fixture = context.createdResources?.phase3Loading
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
if (!tenantId || !inboundId || fixture?.marker !== context.marker
|| fixture?.loadingPlanStatus !== 'COMPLETED') {
context.addStep(MODULE, '取得下游门禁待完成装柜 fixture', null, false,
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, marker=${fixture?.marker || 'missing'}, plan=${fixture?.loadingPlanStatus || 'missing'}`, OPTIONS)
return
}
const detailPath = `/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`
const beforeResponse = await context.request('GET', detailPath)
const before = context.apiSuccess(MODULE, '读取下游门禁齐备后的装柜状态', beforeResponse, '', OPTIONS)
const version = Number(before?.summary?.version)
context.assert(MODULE, '最终装柜前保持 LOADING 且版本有效', beforeResponse,
before?.summary?.processStatus === 'LOADING' && Number.isInteger(version),
`status=${before?.summary?.processStatus || 'missing'}, version=${before?.summary?.version ?? 'missing'}`, OPTIONS)
if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return
const base = `/api/platform/v1/inbounds/${inboundId}`
const completeResponse = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: version, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
const completed = context.apiSuccess(MODULE, '订舱拖车报关齐备后完成装柜', completeResponse, '', OPTIONS)
const evidences = completed?.evidences?.evidences || []
context.assert(MODULE, '统一硬门禁通过后推进 LOADED 且五类有效证据仍完整', completeResponse,
completed?.mainStatus === 'LOADED' && fixture.evidenceTypes?.every?.((type) =>
evidences.some((item) => item.evidenceType === type && item.status !== 'VOID')),
`mainStatus=${completed?.mainStatus || 'missing'}, evidences=${evidences.length}`, OPTIONS)
const replayResponse = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: version, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
context.assert(MODULE, 'LOADED 完成请求 Idempotency-Key 重放保持终态', replayResponse,
replayResponse.http === 200 && String(replayResponse.body?.code) === '200'
&& replayResponse.body?.data?.mainStatus === 'LOADED',
`HTTP=${replayResponse.http}, code=${replayResponse.body?.code ?? 'missing'}, status=${replayResponse.body?.data?.mainStatus || 'missing'}`, OPTIONS)
const finalResponse = await context.request('GET', detailPath)
const finalData = context.apiSuccess(MODULE, '回查三期装柜完成终态', finalResponse, '', OPTIONS)
context.assert(MODULE, '入库单终态 LOADED、版本递增且审计历史存在', finalResponse,
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === version + 1
&& finalData?.statusHistory?.some?.((item) => item.action === 'COMPLETE_LOADING'
&& item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'),
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
const evidencePath = `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`
const immutableEvidenceResponse = await context.request('GET', evidencePath)
const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS)
const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(id(fixture.loadingPlanId))}`
const immutablePlanResponse = await context.request('GET', planPath)
const immutablePlan = context.apiSuccess(MODULE, '读取 LOADED 后装柜计划快照', immutablePlanResponse, '', OPTIONS)
const finalVersion = Number(finalData?.summary?.version)
const historySignature = JSON.stringify((finalData?.statusHistory || []).map((item) => ({
action: item?.action || null, from: item?.fromStatus || null, to: item?.toStatus || null,
operatorId: id(item?.operatorId), occurredAt: item?.occurredAt || item?.createdAt || null,
})))
const immutableEvidenceSignature = evidenceSignature(immutableEvidence)
const immutablePlanSignature = JSON.stringify({
version: Number(immutablePlan?.version), status: immutablePlan?.status || null,
containerNo: immutablePlan?.containerNo || null, voyageNo: immutablePlan?.voyageNo || null,
items: (immutablePlan?.items || []).map((item) => ({ id: id(item?.id), frozenAt: item?.frozenAt || null })),
})
const completeNewKey = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: finalVersion, remark: `${context.marker}-loaded-new-key` },
{ 'Idempotency-Key': `${context.marker}-p3-complete-loaded-new-key`.slice(0, 128) })
context.assert(MODULE, 'LOADED 后使用新键重复 complete 幂等保持终态', completeNewKey,
completeNewKey.http === 200 && codeOf(completeNewKey) === '200'
&& completeNewKey.body?.data?.mainStatus === 'LOADED',
`HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.mainStatus || 'missing'}`, OPTIONS)
const completeSameKeyDifferentBody = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: version, remark: `${context.marker}-different-complete-body` },
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
context.addStep(MODULE, 'LOADED complete 同键异参返回冲突', completeSameKeyDifferentBody,
completeSameKeyDifferentBody.http === 200 && codeOf(completeSameKeyDifferentBody) === 'A0409'
&& Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId),
`HTTP=${completeSameKeyDifferentBody.http}, code=${codeOf(completeSameKeyDifferentBody)}, requestId=${completeSameKeyDifferentBody.requestId || 'missing'}, traceId=${completeSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS)
const postLoadedEvidence = await request(context, 'POST', evidencePath, {
expectedVersion: 0, evidenceType: 'PHOTO_STEP_2', fileId: fixture.postLoadedFileId,
fileName: 'client-must-not-override.png', sealNo: null, remark: context.marker,
}, { 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) })
context.addStep(MODULE, 'LOADED 后新增装柜证据被拒绝', postLoadedEvidence,
postLoadedEvidence.http === 200 && codeOf(postLoadedEvidence) === 'A0409'
&& Boolean(postLoadedEvidence.requestId) && Boolean(postLoadedEvidence.traceId),
`HTTP=${postLoadedEvidence.http}, code=${codeOf(postLoadedEvidence)}, requestId=${postLoadedEvidence.requestId || 'missing'}, traceId=${postLoadedEvidence.traceId || 'missing'}`, OPTIONS)
const postLoadedVoid = await request(context, 'POST',
`${base}/loading/evidence/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(fixture.immutableEvidenceVersion), reason: `${context.marker}-void-after-loaded` },
{ 'Idempotency-Key': `${context.marker}-p3-evidence-void-after-loaded`.slice(0, 128) })
context.addStep(MODULE, 'LOADED 后作废装柜证据被拒绝', postLoadedVoid,
postLoadedVoid.http === 200 && codeOf(postLoadedVoid) === 'A0409'
&& Boolean(postLoadedVoid.requestId) && Boolean(postLoadedVoid.traceId),
`HTTP=${postLoadedVoid.http}, code=${codeOf(postLoadedVoid)}, requestId=${postLoadedVoid.requestId || 'missing'}, traceId=${postLoadedVoid.traceId || 'missing'}`, OPTIONS)
const immutableTransport = await request(context, 'PATCH',
`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/transport?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`,
{ containerNo: `MUTATE-${context.marker}`.slice(0, 64), voyageNo: 'MUTATE',
expectedVersion: Number(immutablePlan?.version), operationReason: `${context.marker}-after-loaded` },
{ 'Idempotency-Key': `${context.marker}-p3-transport-after-loaded`.slice(0, 128) })
context.addStep(MODULE, 'LOADED 后运输信息修改被拒绝', immutableTransport,
immutableTransport.http === 200 && codeOf(immutableTransport) === 'A0409'
&& Boolean(immutableTransport.requestId) && Boolean(immutableTransport.traceId),
`HTTP=${immutableTransport.http}, code=${codeOf(immutableTransport)}, requestId=${immutableTransport.requestId || 'missing'}, traceId=${immutableTransport.traceId || 'missing'}`, OPTIONS)
const afterBoundaryResponse = await context.request('GET', detailPath)
const afterBoundary = context.apiSuccess(MODULE, '回查 LOADED 高风险边界后的入库快照', afterBoundaryResponse, '', OPTIONS)
const afterEvidenceResponse = await context.request('GET', evidencePath)
const afterEvidence = context.apiSuccess(MODULE, '回查 LOADED 高风险边界后的证据快照', afterEvidenceResponse, '', OPTIONS)
const afterPlanResponse = await context.request('GET', planPath)
const afterPlan = context.apiSuccess(MODULE, '回查 LOADED 高风险边界后的计划快照', afterPlanResponse, '', OPTIONS)
const afterHistorySignature = JSON.stringify((afterBoundary?.statusHistory || []).map((item) => ({
action: item?.action || null, from: item?.fromStatus || null, to: item?.toStatus || null,
operatorId: id(item?.operatorId), occurredAt: item?.occurredAt || item?.createdAt || null,
})))
const afterPlanSignature = JSON.stringify({
version: Number(afterPlan?.version), status: afterPlan?.status || null,
containerNo: afterPlan?.containerNo || null, voyageNo: afterPlan?.voyageNo || null,
items: (afterPlan?.items || []).map((item) => ({ id: id(item?.id), frozenAt: item?.frozenAt || null })),
})
context.assert(MODULE, 'LOADED 后拒绝与幂等请求保持状态、版本、证据、计划和审计零漂移', afterBoundaryResponse,
afterBoundary?.summary?.processStatus === 'LOADED'
&& Number(afterBoundary?.summary?.version) === finalVersion
&& afterHistorySignature === historySignature
&& evidenceSignature(afterEvidence) === immutableEvidenceSignature
&& afterPlanSignature === immutablePlanSignature,
`status=${afterBoundary?.summary?.processStatus || 'missing'}, version=${afterBoundary?.summary?.version ?? 'missing'}, histories=${afterBoundary?.statusHistory?.length ?? 'n/a'}, evidences=${afterEvidence?.evidences?.length ?? 'n/a'}, planVersion=${afterPlan?.version ?? 'missing'}`, OPTIONS)
context.createdResources.phase3Loading = buildFinalizeResource(fixture, finalData, completeResponse)
}