Files
baiyue-firstleg/scripts/admin-test-env-modules/phase3-loading-finalize.mjs
T

482 lines
34 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)}`,
}
}
export function collectPhase4InboundProducerFixture(previous, producer) {
const existing = previous?.marker === producer?.marker && Array.isArray(previous?.containers)
? previous.containers : []
const containers = [...existing.filter((item) => item.eventKey !== producer.eventKey), producer]
const byDestinationType = Object.fromEntries(['FBA', 'OVERSEAS'].map((destinationType) => [
destinationType,
containers.filter((item) => item.destinationType === destinationType),
]))
const dropshipContainers = containers.filter((item) => item.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const distinctContainerCount = new Set(containers.map((item) => item.containerNo).filter(Boolean)).size
return {
marker: producer.marker,
containers,
byDestinationType,
readiness: {
fbaContainerCount: byDestinationType.FBA.length,
overseasContainerCount: byDestinationType.OVERSEAS.length,
dropshipContainerCount: dropshipContainers.length,
distinctContainerCount,
readyForPhase4Pda: byDestinationType.FBA.length >= 2
&& byDestinationType.OVERSEAS.length >= 1
&& dropshipContainers.length >= 1
&& distinctContainerCount >= 4,
},
requiredEntryOrchestration: [
'DROPSHIP-FBA: public order fulfillmentType -> inbound-measurement -> phase3-loading -> phase3-loading-finalize -> overseas PDA',
'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
],
}
}
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 }
}
}
async function waitForCompletion(context, taskDetailPath, operationsPath, attempts = 90) {
let taskResponse = null
let task = null
let operationsResponse = null
let operations = null
for (let attempt = 1; attempt <= attempts; attempt += 1) {
taskResponse = await context.request('GET', taskDetailPath)
task = taskResponse?.http === 200 && String(taskResponse?.body?.code) === '200'
? taskResponse.body.data : null
if (task?.taskStatus === 'COMPLETED' || task?.taskStatus === 'COMPLETION_FAILED') {
operationsResponse = await context.request('GET', operationsPath)
operations = operationsResponse?.http === 200 && String(operationsResponse?.body?.code) === '200'
? operationsResponse.body.data : null
return { taskResponse, task, operationsResponse, operations, attempts: attempt }
}
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { taskResponse, task, operationsResponse, operations, attempts }
}
async function waitForEvidenceSnapshot(context, evidencePath, expectedTypes, attempts = 30) {
let response = null
for (let attempt = 1; attempt <= attempts; attempt += 1) {
response = await context.request('GET', evidencePath)
const evidences = response?.http === 200 && String(response?.body?.code) === '200'
? response.body.data?.evidences : null
if (Array.isArray(evidences) && expectedTypes.every((type) =>
evidences.some((item) => item?.evidenceType === type && item?.status !== 'VOID'))) {
return { response, attempts: attempt }
}
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { response, attempts }
}
export async function run(context) {
const fixture = context.createdResources?.phase3Loading
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
const orderId = id(fixture?.orderId)
const taskId = id(fixture?.canonicalPdaTaskId); const loadingPlanId = id(fixture?.loadingPlanId)
const containerBookingId = id(fixture?.containerBookingId)
if (!tenantId || !orderId || !inboundId || fixture?.marker !== context.marker
|| !taskId || !loadingPlanId || !containerBookingId
|| fixture?.loadingPlanStatus !== 'LOADING' || fixture?.finalStatus !== 'AWAITING_REVIEW') {
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 taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}`
const taskDetailPath = `${taskPath}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`
const taskBeforeResponse = await context.request('GET', taskDetailPath)
const taskBefore = context.apiSuccess(MODULE, '读取最终完成前 PDA 任务即时版本', taskBeforeResponse, '', OPTIONS)
context.assert(MODULE, '最终完成前四元身份稳定且任务等待人工确认', taskBeforeResponse,
id(taskBefore?.id) === taskId && id(taskBefore?.inboundId) === inboundId
&& id(taskBefore?.loadingPlanId) === loadingPlanId
&& id(taskBefore?.containerBookingId) === containerBookingId
&& taskBefore?.taskStatus === 'LOADING' && taskBefore?.workflowStatus === 'AWAITING_REVIEW',
`taskId=${id(taskBefore?.id) || 'missing'}, workflow=${taskBefore?.workflowStatus || 'missing'}, version=${taskBefore?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(Number(taskBefore?.version))) return
const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}`
const planBeforeCompleteResponse = await context.request('GET', planPath)
const planBeforeComplete = context.apiSuccess(MODULE, '读取结算前计划即时版本', planBeforeCompleteResponse, '', OPTIONS)
const operationsBeforeResponse = await context.request('GET', `${taskPath}/operations`)
const operationsBefore = context.apiSuccess(MODULE, '读取结算前 PDA 操作历史基线', operationsBeforeResponse, '', OPTIONS)
const taskBeforeSignature = JSON.stringify({
version: Number(taskBefore?.version), taskStatus: taskBefore?.taskStatus, workflowStatus: taskBefore?.workflowStatus,
items: (taskBefore?.items || []).map((item) => ({ id: id(item?.id), status: item?.itemStatus,
picked: Number(item?.pickedPackageCount), version: Number(item?.version) })),
})
const planBeforeSignature = JSON.stringify({
version: Number(planBeforeComplete?.version), status: planBeforeComplete?.status,
items: (planBeforeComplete?.items || []).map((item) => ({ id: id(item?.id), version: Number(item?.version),
packageCount: Number(item?.packageCount), picked: Number(item?.pickedPackageCount) })),
})
const operationsBeforeSignature = JSON.stringify(operationsBefore || [])
const completeAttempts = [1, 2].map((index) => ({
body: {
expectedTaskVersion: Number(taskBefore.version),
operationId: `${context.marker}-pda-final-complete-concurrent-${index}`.slice(0, 128),
remark: context.marker,
},
key: `${context.marker}-p3-complete-concurrent-key-${index}`.slice(0, 128),
}))
const concurrentCompleteResponses = await Promise.all(completeAttempts.map((attempt) =>
request(context, 'POST', `${taskPath}/complete`, attempt.body,
{ 'Idempotency-Key': attempt.key })))
const successfulCompleteIndexes = concurrentCompleteResponses.flatMap((response, index) =>
response.http === 200 && codeOf(response) === '200' ? [index] : [])
const conflictedCompleteIndexes = concurrentCompleteResponses.flatMap((response, index) =>
response.http === 200 && codeOf(response) === 'A0409' ? [index] : [])
const conflictedCompleteResponse = conflictedCompleteIndexes.length === 1
? concurrentCompleteResponses[conflictedCompleteIndexes[0]] : null
const winningCompleteIndex = successfulCompleteIndexes[0] ?? -1
const acceptedCompleteResponse = winningCompleteIndex >= 0
? concurrentCompleteResponses[winningCompleteIndex] : concurrentCompleteResponses[0]
const completeBody = winningCompleteIndex >= 0
? completeAttempts[winningCompleteIndex].body : completeAttempts[0].body
const completeKey = winningCompleteIndex >= 0
? completeAttempts[winningCompleteIndex].key : completeAttempts[0].key
const completion = winningCompleteIndex >= 0
? await waitForCompletion(context, taskDetailPath, `${taskPath}/operations`)
: { taskResponse: acceptedCompleteResponse, task: null, operationsResponse: null, operations: null, attempts: 0 }
context.assert(MODULE, '异步完成命令收敛到终态', completion.taskResponse,
completion.task?.taskStatus === 'COMPLETED',
`taskStatus=${completion.task?.taskStatus || 'missing'}, workflow=${completion.task?.workflowStatus || 'missing'}, attempts=${completion.attempts}`, OPTIONS)
const operationsAfterConcurrentResponse = completion.operationsResponse
const operationsAfterConcurrent = completion.operations
const baselineCompleteOperationIds = new Set((operationsBefore || [])
.filter((item) => item?.actionType === 'COMPLETE_LOADING').map((item) => String(item?.operationId || '')))
const concurrentCompleteOperations = (operationsAfterConcurrent || []).filter((item) =>
item?.actionType === 'COMPLETE_LOADING'
&& completeAttempts.some((attempt) => attempt.body.operationId === item?.operationId)
&& !baselineCompleteOperationIds.has(String(item?.operationId || '')))
const concurrentCompleteVerified = successfulCompleteIndexes.length === 1
&& conflictedCompleteIndexes.length === 1
&& Boolean(conflictedCompleteResponse?.requestId) && Boolean(conflictedCompleteResponse?.traceId)
&& Array.isArray(operationsAfterConcurrent) && Array.isArray(operationsBefore)
&& operationsAfterConcurrent.length === operationsBefore.length + 1
&& concurrentCompleteOperations.length === 1
&& concurrentCompleteOperations[0]?.operationId === completeBody.operationId
&& id(concurrentCompleteOperations[0]?.operatorId) && Boolean(concurrentCompleteOperations[0]?.createdAt)
&& completion.task?.taskStatus === 'COMPLETED' && completion.task?.workflowStatus === 'COMPLETED'
const completeResponse = completion.task?.taskStatus === 'COMPLETED'
? await request(context, 'POST', `${taskPath}/complete`, completeBody,
{ 'Idempotency-Key': completeKey })
: acceptedCompleteResponse
context.assert(MODULE, '两个不同幂等键并发 complete 仅一成功且只写一条完成审计',
completeResponse, concurrentCompleteVerified,
`results=${concurrentCompleteResponses.map((response) => `${response.http}/${codeOf(response)}`).join(',')}, success=${successfulCompleteIndexes.length}, conflict=${conflictedCompleteIndexes.length}, completeAudits=${concurrentCompleteOperations.length}, winner=${completeBody.operationId}`, OPTIONS)
if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200'
|| completion.task?.taskStatus !== 'COMPLETED') {
context.addStep(MODULE, '结算递增 inboundVersion 后 final complete 必须使用最新版本', completeResponse, false,
`expected=success after settlement refresh; HTTP=${completeResponse.http}, code=${codeOf(completeResponse)}, message=${completeResponse.body?.message || 'missing'}, taskId=${taskId}, inboundId=${inboundId}, taskVersion=${taskBefore?.version ?? 'missing'}, inboundVersion=${version}, requestId=${completeResponse.requestId || 'missing'}, traceId=${completeResponse.traceId || 'missing'}`, OPTIONS)
const taskAfterFailureResponse = await context.request('GET', taskDetailPath)
const taskAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查 PDA 任务', taskAfterFailureResponse, '', OPTIONS)
const planAfterFailureResponse = await context.request('GET', planPath)
const planAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查计划', planAfterFailureResponse, '', OPTIONS)
const inboundAfterFailureResponse = await context.request('GET', detailPath)
const inboundAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查入库单', inboundAfterFailureResponse, '', OPTIONS)
const operationsAfterFailureResponse = await context.request('GET', `${taskPath}/operations`)
const operationsAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查操作历史', operationsAfterFailureResponse, '', OPTIONS)
const taskAfterSignature = JSON.stringify({
version: Number(taskAfterFailure?.version), taskStatus: taskAfterFailure?.taskStatus,
workflowStatus: taskAfterFailure?.workflowStatus,
items: (taskAfterFailure?.items || []).map((item) => ({ id: id(item?.id), status: item?.itemStatus,
picked: Number(item?.pickedPackageCount), version: Number(item?.version) })),
})
const planAfterSignature = JSON.stringify({
version: Number(planAfterFailure?.version), status: planAfterFailure?.status,
items: (planAfterFailure?.items || []).map((item) => ({ id: id(item?.id), version: Number(item?.version),
packageCount: Number(item?.packageCount), picked: Number(item?.pickedPackageCount) })),
})
context.assert(MODULE, '结算或版本冲突失败后 task/plan/inbound/operation 全部零写入', inboundAfterFailureResponse,
taskAfterSignature === taskBeforeSignature && planAfterSignature === planBeforeSignature
&& inboundAfterFailure?.summary?.processStatus === 'LOADING'
&& Number(inboundAfterFailure?.summary?.version) === version
&& JSON.stringify(operationsAfterFailure || []) === operationsBeforeSignature,
`taskUnchanged=${taskAfterSignature === taskBeforeSignature}, planUnchanged=${planAfterSignature === planBeforeSignature}, inboundStatus=${inboundAfterFailure?.summary?.processStatus || 'missing'}, inboundVersion=${inboundAfterFailure?.summary?.version ?? 'missing'}, operationsUnchanged=${JSON.stringify(operationsAfterFailure || []) === operationsBeforeSignature}`, OPTIONS)
return
}
if (!concurrentCompleteVerified) return
const completed = context.apiSuccess(MODULE, '订舱与五类证据等保留门禁齐备后完成装柜', completeResponse, '', OPTIONS)
context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse,
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
&& id(completed?.id) === taskId && id(completed?.loadingPlanId) === loadingPlanId
&& id(completed?.containerBookingId) === containerBookingId && id(completed?.inboundId) === inboundId,
`taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}`, OPTIONS)
const replayResponse = await request(context, 'POST',
`${taskPath}/complete`, completeBody, { 'Idempotency-Key': completeKey })
context.assert(MODULE, 'LOADED 完成请求 Idempotency-Key 重放保持终态', replayResponse,
replayResponse.http === 200 && String(replayResponse.body?.code) === '200'
&& replayResponse.body?.data?.taskStatus === 'COMPLETED'
&& id(replayResponse.body?.data?.id) === taskId,
`HTTP=${replayResponse.http}, code=${replayResponse.body?.code ?? 'missing'}, status=${replayResponse.body?.data?.taskStatus || '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
&& 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 slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`
const slicesResponse = await context.request('GET', slicesPath)
const slices = context.apiSuccess(MODULE, '结算后回查柜次实际履约分片', slicesResponse, '', OPTIONS)
const loadedSlices = Array.isArray(slices)
? slices.filter((item) => id(item?.tenantId) === tenantId
&& id(item?.containerBookingId) === containerBookingId
&& id(item?.loadingPlanId) === loadingPlanId
&& item?.splitRole === 'LOADED')
: []
const loadedSlice = loadedSlices.find((item) => id(item?.sourceOrderId) === orderId
|| id(item?.childOrderId) === orderId) || loadedSlices[0]
const remainderSlice = Array.isArray(slices) && loadedSlice
? slices.find((item) => item?.splitRole === 'REMAINDER'
&& id(item?.sourceOrderId) === id(loadedSlice?.sourceOrderId))
: null
const settlementMode = remainderSlice ? 'PARTIAL' : 'FULL'
const loadedSourceIds = loadedSlices.map((item) => id(item?.sourceOrderId)).filter(Boolean)
context.assert(MODULE, '结算后每个实装来源分片唯一且柜次身份稳定', slicesResponse,
loadedSlices.length > 0 && Boolean(loadedSlice)
&& new Set(loadedSourceIds).size === loadedSlices.length
&& loadedSlices.every((item) => id(item?.tenantId) === tenantId
&& id(item?.containerBookingId) === containerBookingId
&& id(item?.loadingPlanId) === loadingPlanId
&& Number.isInteger(Number(item?.packageCount)) && Number(item.packageCount) > 0),
`mode=${settlementMode}, loadedSlices=${loadedSlices.length}, sourceOrderId=${id(loadedSlice?.sourceOrderId) || 'missing'}, childOrderId=${id(loadedSlice?.childOrderId) || 'missing'}, packageCount=${loadedSlice?.packageCount ?? 'missing'}`, OPTIONS)
const unifiedDetailPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
const unifiedDetailResponse = await context.request('GET', unifiedDetailPath)
const unifiedDetail = context.apiSuccess(MODULE, 'LOADED 后回查同一统一订单详情', unifiedDetailResponse, '', OPTIONS)
const orderNo = String(unifiedDetail?.summary?.orderNo || '').trim()
context.assert(MODULE, '统一订单详情投影同一订单为 LOADED', unifiedDetailResponse,
id(unifiedDetail?.summary?.orderId) === orderId && unifiedDetail?.summary?.mainStatus === 'LOADED'
&& Boolean(orderNo),
`orderId=${id(unifiedDetail?.summary?.orderId) || 'missing'}, mainStatus=${unifiedDetail?.summary?.mainStatus || 'missing'}, orderNo=${orderNo || 'missing'}`, OPTIONS)
if (id(unifiedDetail?.summary?.orderId) !== orderId || unifiedDetail?.summary?.mainStatus !== 'LOADED' || !orderNo) return
const unifiedListPath = `/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}`
const unifiedListResponse = await context.request('GET', unifiedListPath)
const unifiedPage = context.apiSuccess(MODULE, 'LOADED 后回查同一统一订单列表', unifiedListResponse, '', OPTIONS)
const unifiedRow = unifiedPage?.records?.find?.((row) => id(row?.orderId) === orderId)
context.assert(MODULE, '统一订单列表投影同一订单为 LOADED', unifiedListResponse,
Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED',
`orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS)
const evidenceBase = `${taskPath}/evidence`
const evidencePath = `${evidenceBase}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`
const immutableEvidenceResult = await waitForEvidenceSnapshot(
context, evidencePath, fixture.evidenceTypes || [])
const immutableEvidenceResponse = immutableEvidenceResult.response
const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS)
context.assert(MODULE, '统一硬门禁通过后五类有效证据仍完整', immutableEvidenceResponse,
fixture.evidenceTypes?.every?.((type) => immutableEvidence?.evidences?.some?.((item) =>
item.evidenceType === type && item.status !== 'VOID')),
`evidences=${immutableEvidence?.evidences?.length ?? 'missing'}, attempts=${immutableEvidenceResult.attempts}`, OPTIONS)
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',
`${taskPath}/complete`,
{ expectedTaskVersion: Number(completed?.version),
operationId: `${context.marker}-pda-final-complete-new-key`.slice(0, 128), 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) === 'A0409'
&& Boolean(completeNewKey.requestId) && Boolean(completeNewKey.traceId),
`HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.taskStatus || 'missing'}`, OPTIONS)
const completeSameKeyDifferentBody = await request(context, 'POST',
`${taskPath}/complete`,
{ ...completeBody, remark: `${context.marker}-different-complete-body` },
{ 'Idempotency-Key': completeKey })
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 postLoadedEvidenceForm = new FormData()
postLoadedEvidenceForm.append('file', new Blob([Uint8Array.from([
137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,
0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130,
])], { type: 'image/png' }), `${context.marker}-after-loaded.png`)
const postLoadedEvidenceQuery = new URLSearchParams({
warehouseId: id(fixture.warehouseId), expectedVersion: String(fixture.immutableEvidenceVersion),
evidenceType: 'PHOTO_STEP_1', remark: `${context.marker}-after-loaded`,
})
const postLoadedEvidence = await context.requestMultipart('POST',
`${evidenceBase}?${postLoadedEvidenceQuery}`, postLoadedEvidenceForm, context.token,
{ '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',
`${evidenceBase}/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void`,
{ warehouseId: id(fixture.warehouseId), expectedVersion: Number(fixture.immutableEvidenceVersion),
reason: `${context.marker}-void-after-loaded` })
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: immutablePlan?.containerNo, 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 completedPlanItem = immutablePlan?.items?.[0]
const assignmentAfterCompleted = await request(context, 'POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/assignments`, {
inboundId: fixture.incrementalInboundId || inboundId,
inboundDetailId: id(completedPlanItem?.inboundDetailId), packageCount: 1,
expectedVersion: Number(immutablePlan?.version),
operationId: `${context.marker}-assign-after-completed`.slice(0, 128),
})
context.addStep(MODULE, 'COMPLETED 后新增计划明细被拒绝', assignmentAfterCompleted,
assignmentAfterCompleted.http === 200 && codeOf(assignmentAfterCompleted) === 'A0409',
`HTTP=${assignmentAfterCompleted.http}, code=${codeOf(assignmentAfterCompleted)}`, OPTIONS)
const adjustAfterCompleted = await request(context, 'PATCH',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(completedPlanItem?.id))}`, {
packageCount: Number(completedPlanItem?.packageCount || 1), expectedPlanVersion: Number(immutablePlan?.version),
expectedItemVersion: Number(completedPlanItem?.version), operationId: `${context.marker}-adjust-after-completed`.slice(0, 128),
})
context.addStep(MODULE, 'COMPLETED 后调整计划明细被拒绝', adjustAfterCompleted,
adjustAfterCompleted.http === 200 && codeOf(adjustAfterCompleted) === 'A0409',
`HTTP=${adjustAfterCompleted.http}, code=${codeOf(adjustAfterCompleted)}`, OPTIONS)
const deleteAfterCompleted = await request(context, 'DELETE',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(completedPlanItem?.id))}`, {
expectedPlanVersion: Number(immutablePlan?.version), expectedItemVersion: Number(completedPlanItem?.version),
operationId: `${context.marker}-delete-after-completed`.slice(0, 128),
})
context.addStep(MODULE, 'COMPLETED 后删除计划明细被拒绝', deleteAfterCompleted,
deleteAfterCompleted.http === 200 && codeOf(deleteAfterCompleted) === 'A0409',
`HTTP=${deleteAfterCompleted.http}, code=${codeOf(deleteAfterCompleted)}`, 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),
settlementMode,
settledOrderId: id(loadedSlice?.childOrderId) || orderId,
}
const producer = {
marker: context.context?.phase4ProducerMatrixMarker || context.marker,
eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED',
eventVersion: 2,
eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:2`,
resourceKey: `${context.marker}:${fixture.destinationType || 'UNKNOWN'}:${fixture.containerNo || containerBookingId}`,
tenantId,
aggregateType: 'LOADING_PLAN',
aggregateId: loadingPlanId,
loadingPlanId,
containerBookingId,
sourceOrderId: orderId,
sourceInboundId: inboundId,
cartonMark: fixture.cartonMark || null,
cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [],
skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [],
skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [],
fulfillmentType: fixture.fulfillmentType || 'STANDARD',
destinationType: fixture.destinationType || null,
destinationWarehouseId: id(fixture.destinationWarehouseId),
destinationWarehouseCode: fixture.destinationWarehouseCode || null,
containerNo: fixture.containerNo || null,
sourceStatus: 'LOADED',
verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP',
}
context.createdResources.phase3LoadingCompletedProducer = producer
context.createdResources.phase4InboundProducerFixtures = collectPhase4InboundProducerFixture(
context.createdResources.phase4InboundProducerFixtures, producer)
}