2026-08-10 12:23:43 +08:00
|
|
|
|
const MODULE = 'phase3-loading-finalize'
|
|
|
|
|
|
const OPTIONS = { scenario: 'phase3-loading-hard-gate-finalize', deploymentBlocker: true }
|
|
|
|
|
|
|
|
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
2026-08-11 14:07:16 +08:00
|
|
|
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
2026-08-12 12:35:29 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})))
|
|
|
|
|
|
}
|
2026-08-11 14:07:16 +08:00
|
|
|
|
|
|
|
|
|
|
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)}`,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-10 12:23:43 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-12 10:59:39 +08:00
|
|
|
|
if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return
|
2026-08-10 12:23:43 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-12 12:35:29 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-11 14:07:16 +08:00
|
|
|
|
context.createdResources.phase3Loading = buildFinalizeResource(fixture, finalData, completeResponse)
|
2026-08-10 12:23:43 +08:00
|
|
|
|
}
|