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
|
|
|
|
|
2026-08-24 19:58:35 +08:00
|
|
|
|
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),
|
|
|
|
|
|
]))
|
2026-08-25 08:40:27 +08:00
|
|
|
|
const dropshipContainers = containers.filter((item) => item.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
2026-08-24 19:58:35 +08:00
|
|
|
|
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,
|
2026-08-25 08:40:27 +08:00
|
|
|
|
dropshipContainerCount: dropshipContainers.length,
|
2026-08-24 19:58:35 +08:00
|
|
|
|
distinctContainerCount,
|
2026-08-25 08:40:27 +08:00
|
|
|
|
readyForPhase4Pda: byDestinationType.FBA.length >= 2
|
2026-08-24 19:58:35 +08:00
|
|
|
|
&& byDestinationType.OVERSEAS.length >= 1
|
2026-08-25 08:40:27 +08:00
|
|
|
|
&& dropshipContainers.length >= 1
|
|
|
|
|
|
&& distinctContainerCount >= 4,
|
2026-08-24 19:58:35 +08:00
|
|
|
|
},
|
|
|
|
|
|
requiredEntryOrchestration: [
|
2026-08-25 08:40:27 +08:00
|
|
|
|
'DROPSHIP-FBA: public order fulfillmentType -> inbound-measurement -> phase3-loading -> phase3-loading-finalize -> overseas PDA',
|
2026-08-24 19:58:35 +08:00
|
|
|
|
'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',
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-09 19:14:47 +08:00
|
|
|
|
|
|
|
|
|
|
async function requestPdf(context, path, body) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { Accept: 'application/pdf', Authorization: `Bearer ${context.token}`,
|
|
|
|
|
|
'Content-Type': 'application/json; charset=utf-8' },
|
|
|
|
|
|
body: JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
|
|
|
|
|
})
|
|
|
|
|
|
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
|
|
|
|
return { method: 'POST', path, http: response.status, body: null, raw: null,
|
|
|
|
|
|
contentType: response.headers.get('content-type'), byteLength: bytes.length,
|
|
|
|
|
|
pdfMagic: String.fromCharCode(...bytes.slice(0, 4)),
|
|
|
|
|
|
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return { method: 'POST', path, http: 0, body: null, raw: String(error), contentType: null,
|
|
|
|
|
|
byteLength: 0, pdfMagic: null, requestId: null, traceId: null }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function verifyPalletLabels(context, fixture, loadingPlanId) {
|
|
|
|
|
|
if (fixture.destinationType !== 'FBA' || fixture.fulfillmentType === 'ONE_CLICK_DROPSHIP') return null
|
|
|
|
|
|
const warehouseId = id(fixture.warehouseId)
|
|
|
|
|
|
const base = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(loadingPlanId)}/pallet-labels`
|
|
|
|
|
|
const path = `${base}/prepare?warehouseId=${encodeURIComponent(warehouseId)}`
|
|
|
|
|
|
const preparedResponse = await context.request('POST', path, {})
|
|
|
|
|
|
const manifest = context.apiSuccess(MODULE, '生成并读取按 FBA 仓分组的散货托盘码清单', preparedResponse, '', OPTIONS)
|
|
|
|
|
|
const labels = (manifest?.groups || []).flatMap((group) => group?.labels || [])
|
|
|
|
|
|
const codesValid = labels.length > 0 && labels.every((label) => {
|
|
|
|
|
|
const index = Number(label?.palletIndex)
|
|
|
|
|
|
return id(label?.containerNo) === id(fixture.containerNo)
|
|
|
|
|
|
&& id(label?.destinationWarehouseInternalCode)
|
|
|
|
|
|
&& id(label?.palletCode) === `${fixture.containerNo}-${label.destinationWarehouseInternalCode}-${String(index).padStart(3, '0')}`
|
|
|
|
|
|
})
|
|
|
|
|
|
const indexesValid = (manifest?.groups || []).every((group) =>
|
|
|
|
|
|
(group?.labels || []).every((label, index) => Number(label?.palletIndex) === index + 1))
|
|
|
|
|
|
context.assert(MODULE, '散货托盘码使用柜号-FBA内部编号-仓内连续下标', preparedResponse,
|
|
|
|
|
|
id(manifest?.containerNo) === id(fixture.containerNo)
|
|
|
|
|
|
&& Number(manifest?.totalCount) === labels.length && codesValid && indexesValid,
|
|
|
|
|
|
`containerNo=${manifest?.containerNo || 'missing'}, groups=${manifest?.groups?.length ?? 0}, labels=${labels.length}`,
|
|
|
|
|
|
OPTIONS)
|
|
|
|
|
|
|
|
|
|
|
|
const replayResponse = await context.request('POST', path, {})
|
|
|
|
|
|
const replay = context.apiSuccess(MODULE, '重复生成散货托盘码返回稳定清单', replayResponse, '', OPTIONS)
|
|
|
|
|
|
context.assert(MODULE, '散货托盘码重复生成零漂移', replayResponse,
|
|
|
|
|
|
JSON.stringify(replay) === JSON.stringify(manifest), `labels=${labels.length}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
|
|
const printPath = `${base}/print?warehouseId=${encodeURIComponent(warehouseId)}`
|
|
|
|
|
|
const allPdf = await requestPdf(context, printPath, { destinationWarehouseCode: null, count: null })
|
|
|
|
|
|
context.addStep(MODULE, '打印全部 FBA 仓散货托盘码 PDF', allPdf,
|
|
|
|
|
|
allPdf.http === 200 && allPdf.contentType?.toLowerCase().includes('application/pdf')
|
|
|
|
|
|
&& allPdf.pdfMagic === '%PDF' && allPdf.byteLength > 0,
|
|
|
|
|
|
`HTTP=${allPdf.http}, type=${allPdf.contentType || 'missing'}, bytes=${allPdf.byteLength}`, OPTIONS)
|
|
|
|
|
|
const group = manifest?.groups?.[0]
|
|
|
|
|
|
const selectedPdf = await requestPdf(context, printPath,
|
|
|
|
|
|
{ destinationWarehouseCode: group?.destinationWarehouseCode, count: 1 })
|
|
|
|
|
|
context.addStep(MODULE, '按指定 FBA 仓补打 1 张托盘码 PDF', selectedPdf,
|
|
|
|
|
|
Boolean(group?.destinationWarehouseCode) && selectedPdf.http === 200
|
|
|
|
|
|
&& selectedPdf.contentType?.toLowerCase().includes('application/pdf')
|
|
|
|
|
|
&& selectedPdf.pdfMagic === '%PDF' && selectedPdf.byteLength > 0,
|
|
|
|
|
|
`warehouse=${group?.destinationWarehouseCode || 'missing'}, HTTP=${selectedPdf.http}, bytes=${selectedPdf.byteLength}`,
|
|
|
|
|
|
OPTIONS)
|
|
|
|
|
|
return manifest
|
|
|
|
|
|
}
|
2026-09-04 13:48:03 +08:00
|
|
|
|
function blockResourceIdentity(resource) {
|
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
|
blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
|
|
|
|
|
|
reservationId: id(resource?.reservationId), locationId: id(resource?.locationId),
|
|
|
|
|
|
sharedOverflow: resource?.sharedOverflow === true,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-08-10 12:23:43 +08:00
|
|
|
|
|
2026-09-14 18:34:37 +08:00
|
|
|
|
export async function waitForCompletion(context, taskDetailPath, operationsPath, attempts = 90) {
|
2026-08-28 09:19:18 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 12:23:43 +08:00
|
|
|
|
export async function run(context) {
|
|
|
|
|
|
const fixture = context.createdResources?.phase3Loading
|
|
|
|
|
|
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
|
2026-08-15 11:05:58 +08:00
|
|
|
|
const orderId = id(fixture?.orderId)
|
2026-08-17 15:41:42 +08:00
|
|
|
|
const taskId = id(fixture?.canonicalPdaTaskId); const loadingPlanId = id(fixture?.loadingPlanId)
|
|
|
|
|
|
const containerBookingId = id(fixture?.containerBookingId)
|
2026-08-15 11:05:58 +08:00
|
|
|
|
if (!tenantId || !orderId || !inboundId || fixture?.marker !== context.marker
|
2026-08-17 15:41:42 +08:00
|
|
|
|
|| !taskId || !loadingPlanId || !containerBookingId
|
|
|
|
|
|
|| fixture?.loadingPlanStatus !== 'LOADING' || fixture?.finalStatus !== 'AWAITING_REVIEW') {
|
2026-08-10 12:23:43 +08:00
|
|
|
|
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}`
|
2026-08-17 15:41:42 +08:00
|
|
|
|
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}`
|
2026-08-24 12:52:23 +08:00
|
|
|
|
const taskDetailPath = `${taskPath}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`
|
|
|
|
|
|
const taskBeforeResponse = await context.request('GET', taskDetailPath)
|
2026-08-17 15:41:42 +08:00
|
|
|
|
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)}`
|
2026-08-17 16:07:20 +08:00
|
|
|
|
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 || [])
|
2026-08-25 09:58:59 +08:00
|
|
|
|
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
|
2026-08-28 09:19:18 +08:00
|
|
|
|
const acceptedCompleteResponse = winningCompleteIndex >= 0
|
2026-08-25 09:58:59 +08:00
|
|
|
|
? concurrentCompleteResponses[winningCompleteIndex] : concurrentCompleteResponses[0]
|
|
|
|
|
|
const completeBody = winningCompleteIndex >= 0
|
|
|
|
|
|
? completeAttempts[winningCompleteIndex].body : completeAttempts[0].body
|
|
|
|
|
|
const completeKey = winningCompleteIndex >= 0
|
|
|
|
|
|
? completeAttempts[winningCompleteIndex].key : completeAttempts[0].key
|
2026-08-28 09:19:18 +08:00
|
|
|
|
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
|
2026-08-25 09:58:59 +08:00
|
|
|
|
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)
|
2026-08-28 09:19:18 +08:00
|
|
|
|
&& 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
|
2026-08-25 09:58:59 +08:00
|
|
|
|
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)
|
2026-08-28 09:19:18 +08:00
|
|
|
|
if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200'
|
|
|
|
|
|
|| completion.task?.taskStatus !== 'COMPLETED') {
|
2026-08-17 16:07:20 +08:00
|
|
|
|
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)
|
2026-08-24 12:52:23 +08:00
|
|
|
|
const taskAfterFailureResponse = await context.request('GET', taskDetailPath)
|
2026-08-17 16:07:20 +08:00
|
|
|
|
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
|
2026-08-17 15:41:42 +08:00
|
|
|
|
}
|
2026-08-25 09:58:59 +08:00
|
|
|
|
if (!concurrentCompleteVerified) return
|
2026-08-18 09:09:28 +08:00
|
|
|
|
const completed = context.apiSuccess(MODULE, '订舱与五类证据等保留门禁齐备后完成装柜', completeResponse, '', OPTIONS)
|
2026-08-17 15:41:42 +08:00
|
|
|
|
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)
|
2026-08-10 12:23:43 +08:00
|
|
|
|
|
|
|
|
|
|
const replayResponse = await request(context, 'POST',
|
2026-08-17 15:41:42 +08:00
|
|
|
|
`${taskPath}/complete`, completeBody, { 'Idempotency-Key': completeKey })
|
2026-08-10 12:23:43 +08:00
|
|
|
|
context.assert(MODULE, 'LOADED 完成请求 Idempotency-Key 重放保持终态', replayResponse,
|
|
|
|
|
|
replayResponse.http === 200 && String(replayResponse.body?.code) === '200'
|
2026-08-17 15:41:42 +08:00
|
|
|
|
&& 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)
|
2026-08-10 12:23:43 +08:00
|
|
|
|
|
|
|
|
|
|
const finalResponse = await context.request('GET', detailPath)
|
|
|
|
|
|
const finalData = context.apiSuccess(MODULE, '回查三期装柜完成终态', finalResponse, '', OPTIONS)
|
|
|
|
|
|
context.assert(MODULE, '入库单终态 LOADED、版本递增且审计历史存在', finalResponse,
|
2026-08-17 15:41:42 +08:00
|
|
|
|
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) > version
|
2026-08-10 12:23:43 +08:00
|
|
|
|
&& 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-09-04 13:48:03 +08:00
|
|
|
|
const expectedBlocks = [...(fixture.inventoryBlockSnapshots || []), fixture.partialBlockSnapshot]
|
|
|
|
|
|
.filter((item) => item?.inboundId && Array.isArray(item?.resources) && item.resources.length > 0)
|
|
|
|
|
|
.filter((item, index, all) => all.findIndex((candidate) => id(candidate.inboundId) === id(item.inboundId)) === index)
|
|
|
|
|
|
const releasedOrTransferredBlocks = []
|
|
|
|
|
|
for (const expected of expectedBlocks) {
|
|
|
|
|
|
const sourceBlockResponse = await context.request('GET',
|
|
|
|
|
|
`/api/platform/inbounds/${encodeURIComponent(id(expected.inboundId))}?tenantId=${encodeURIComponent(tenantId)}`)
|
|
|
|
|
|
const sourceBlock = context.apiSuccess(MODULE, '完成后回查来源入仓单整票块状态', sourceBlockResponse, '', OPTIONS)
|
|
|
|
|
|
const expectedIdentity = expected.resources.map(blockResourceIdentity).sort()
|
|
|
|
|
|
const sourceLocations = sourceBlock?.palletLocations || []
|
|
|
|
|
|
const sourceIdentity = sourceLocations.map(blockResourceIdentity).sort()
|
|
|
|
|
|
const statuses = new Set(sourceLocations.map((item) => item?.allocationStatus))
|
|
|
|
|
|
const transferred = statuses.size === 1 && statuses.has('TRANSFERRED')
|
|
|
|
|
|
const released = statuses.size === 1 && statuses.has('RELEASED')
|
|
|
|
|
|
let inheritedByRemainder = false
|
|
|
|
|
|
if (transferred) {
|
|
|
|
|
|
const sourceCode = String(sourceBlock?.summary?.inboundCode || expected.inboundCode || '').trim()
|
|
|
|
|
|
const remainderPageResponse = await context.request('GET',
|
|
|
|
|
|
`/api/platform/inbounds?tenantId=${encodeURIComponent(tenantId)}&inboundCode=${encodeURIComponent(`${sourceCode}-`)}&page=1&size=200`)
|
|
|
|
|
|
const remainderPage = context.apiSuccess(MODULE, '查询部分拣货生成的余量入仓单', remainderPageResponse, '', OPTIONS)
|
|
|
|
|
|
for (const row of remainderPage?.records || []) {
|
|
|
|
|
|
if (id(row?.id) === id(expected.inboundId)) continue
|
|
|
|
|
|
const remainderResponse = await context.request('GET',
|
|
|
|
|
|
`/api/platform/inbounds/${encodeURIComponent(id(row?.id))}?tenantId=${encodeURIComponent(tenantId)}`)
|
|
|
|
|
|
const remainder = remainderResponse?.http === 200 && codeOf(remainderResponse) === '200'
|
|
|
|
|
|
? remainderResponse.body?.data : null
|
|
|
|
|
|
const locations = remainder?.palletLocations || []
|
|
|
|
|
|
if (locations.length > 0
|
|
|
|
|
|
&& locations.every((item) => item?.allocationStatus === 'CONFIRMED')
|
|
|
|
|
|
&& JSON.stringify(locations.map(blockResourceIdentity).sort()) === JSON.stringify(expectedIdentity)) {
|
|
|
|
|
|
inheritedByRemainder = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const identityStable = JSON.stringify(sourceIdentity) === JSON.stringify(expectedIdentity)
|
|
|
|
|
|
const verified = identityStable && (released || (transferred && inheritedByRemainder))
|
|
|
|
|
|
context.assert(MODULE, '整票块释放或原身份转移给余量入仓单', sourceBlockResponse, verified,
|
|
|
|
|
|
`inboundId=${id(expected.inboundId)}, blockId=${expected.resources[0]?.blockId || 'missing'}, statuses=${[...statuses].join(',') || 'missing'}, identityStable=${identityStable}, inheritedByRemainder=${inheritedByRemainder}`, OPTIONS)
|
|
|
|
|
|
releasedOrTransferredBlocks.push({ inboundId: id(expected.inboundId), released, transferred, verified })
|
|
|
|
|
|
}
|
|
|
|
|
|
const completedInventoryResponse = await context.request('GET',
|
|
|
|
|
|
`${taskPath}/inventory-snapshot?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`)
|
|
|
|
|
|
const completedInventory = context.apiSuccess(MODULE, '完成后回查 PDA 实时库存快照', completedInventoryResponse, '', OPTIONS)
|
|
|
|
|
|
const completedInventoryReleased = expectedBlocks.length > 0
|
|
|
|
|
|
&& expectedBlocks.every((expected) => {
|
|
|
|
|
|
const current = completedInventory?.inbounds?.find?.((item) => id(item?.inboundId) === id(expected.inboundId))
|
|
|
|
|
|
return Number(current?.availablePackageCount || 0) === 0 && (current?.resources || []).length === 0
|
|
|
|
|
|
})
|
|
|
|
|
|
context.assert(MODULE, '完成后的来源整票块不再作为 PDA 可用资源', completedInventoryResponse,
|
|
|
|
|
|
completedInventoryReleased,
|
|
|
|
|
|
`expectedBlocks=${expectedBlocks.length}, available=${(completedInventory?.inbounds || []).reduce((sum, item) => sum + Number(item?.availablePackageCount || 0), 0)}`, OPTIONS)
|
|
|
|
|
|
|
2026-08-17 16:07:20 +08:00
|
|
|
|
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'
|
2026-08-24 13:41:59 +08:00
|
|
|
|
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),
|
2026-08-17 16:07:20 +08:00
|
|
|
|
`mode=${settlementMode}, loadedSlices=${loadedSlices.length}, sourceOrderId=${id(loadedSlice?.sourceOrderId) || 'missing'}, childOrderId=${id(loadedSlice?.childOrderId) || 'missing'}, packageCount=${loadedSlice?.packageCount ?? 'missing'}`, OPTIONS)
|
|
|
|
|
|
|
2026-08-15 11:05:58 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-09-02 13:26:24 +08:00
|
|
|
|
const evidencePath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(id(fixture.canonicalPdaTaskId))}/evidence?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`
|
2026-08-12 12:35:29 +08:00
|
|
|
|
const immutableEvidenceResponse = await context.request('GET', evidencePath)
|
|
|
|
|
|
const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS)
|
2026-08-17 15:41:42 +08:00
|
|
|
|
context.assert(MODULE, '统一硬门禁通过后五类有效证据仍完整', immutableEvidenceResponse,
|
|
|
|
|
|
fixture.evidenceTypes?.every?.((type) => immutableEvidence?.evidences?.some?.((item) =>
|
|
|
|
|
|
item.evidenceType === type && item.status !== 'VOID')),
|
|
|
|
|
|
`evidences=${immutableEvidence?.evidences?.length ?? 'missing'}`, OPTIONS)
|
2026-08-12 12:35:29 +08:00
|
|
|
|
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',
|
2026-08-17 15:41:42 +08:00
|
|
|
|
`${taskPath}/complete`,
|
|
|
|
|
|
{ expectedTaskVersion: Number(completed?.version),
|
|
|
|
|
|
operationId: `${context.marker}-pda-final-complete-new-key`.slice(0, 128), remark: `${context.marker}-loaded-new-key` },
|
2026-08-12 12:35:29 +08:00
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-p3-complete-loaded-new-key`.slice(0, 128) })
|
2026-08-24 13:41:59 +08:00
|
|
|
|
context.assert(MODULE, 'LOADED 后使用新键重复 complete 返回冲突', completeNewKey,
|
|
|
|
|
|
completeNewKey.http === 200 && codeOf(completeNewKey) === 'A0409'
|
|
|
|
|
|
&& Boolean(completeNewKey.requestId) && Boolean(completeNewKey.traceId),
|
2026-08-17 15:41:42 +08:00
|
|
|
|
`HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.taskStatus || 'missing'}`, OPTIONS)
|
2026-08-12 12:35:29 +08:00
|
|
|
|
|
|
|
|
|
|
const completeSameKeyDifferentBody = await request(context, 'POST',
|
2026-08-17 15:41:42 +08:00
|
|
|
|
`${taskPath}/complete`,
|
|
|
|
|
|
{ ...completeBody, remark: `${context.marker}-different-complete-body` },
|
|
|
|
|
|
{ 'Idempotency-Key': completeKey })
|
2026-08-12 12:35:29 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-09-02 13:26:24 +08:00
|
|
|
|
const postLoadedForm = new FormData()
|
|
|
|
|
|
postLoadedForm.append('file', new Blob([`${context.marker}-after-loaded`], { type: 'text/plain' }), 'after-loaded.txt')
|
|
|
|
|
|
const postLoadedEvidence = await context.requestMultipart('POST',
|
|
|
|
|
|
`${evidencePath}&expectedVersion=0&evidenceType=PHOTO_STEP_2&remark=${encodeURIComponent(context.marker)}`,
|
|
|
|
|
|
postLoadedForm, context.token,
|
|
|
|
|
|
{ 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) })
|
2026-08-12 12:35:29 +08:00
|
|
|
|
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',
|
2026-09-02 13:26:24 +08:00
|
|
|
|
`/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(id(fixture.canonicalPdaTaskId))}/evidence/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void`,
|
|
|
|
|
|
{ warehouseId: id(fixture.warehouseId), expectedVersion: Number(fixture.immutableEvidenceVersion),
|
|
|
|
|
|
reason: `${context.marker}-void-after-loaded` },
|
2026-08-12 12:35:29 +08:00
|
|
|
|
{ '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))}`,
|
2026-09-02 13:26:24 +08:00
|
|
|
|
{ containerNo: fixture.containerNo, voyageNo: 'MUTATE',
|
2026-08-12 12:35:29 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-17 15:41:42 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-12 12:35:29 +08:00
|
|
|
|
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-17 16:07:20 +08:00
|
|
|
|
context.createdResources.phase3Loading = {
|
|
|
|
|
|
...buildFinalizeResource(fixture, finalData, completeResponse),
|
|
|
|
|
|
settlementMode,
|
|
|
|
|
|
settledOrderId: id(loadedSlice?.childOrderId) || orderId,
|
2026-09-04 13:48:03 +08:00
|
|
|
|
inventoryBlockSettlementVerified: expectedBlocks.length > 0
|
|
|
|
|
|
&& releasedOrTransferredBlocks.every((item) => item.verified) && completedInventoryReleased,
|
2026-08-17 16:07:20 +08:00
|
|
|
|
}
|
2026-09-09 19:14:47 +08:00
|
|
|
|
const palletLabelManifest = await verifyPalletLabels(context, fixture, loadingPlanId)
|
2026-08-24 19:58:35 +08:00
|
|
|
|
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,
|
2026-08-24 22:14:41 +08:00
|
|
|
|
cartonMark: fixture.cartonMark || null,
|
2026-09-09 13:20:11 +08:00
|
|
|
|
inboundCode: fixture.inboundCode || null,
|
|
|
|
|
|
sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [],
|
2026-09-09 18:33:17 +08:00
|
|
|
|
sealedSourcePalletNos: Array.isArray(fixture.sealedSourcePalletNos) ? fixture.sealedSourcePalletNos : [],
|
2026-08-24 23:28:24 +08:00
|
|
|
|
cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [],
|
2026-08-25 09:23:28 +08:00
|
|
|
|
skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [],
|
|
|
|
|
|
skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [],
|
2026-08-25 08:40:27 +08:00
|
|
|
|
fulfillmentType: fixture.fulfillmentType || 'STANDARD',
|
2026-08-24 19:58:35 +08:00
|
|
|
|
destinationType: fixture.destinationType || null,
|
|
|
|
|
|
destinationWarehouseId: id(fixture.destinationWarehouseId),
|
|
|
|
|
|
destinationWarehouseCode: fixture.destinationWarehouseCode || null,
|
2026-09-09 19:14:47 +08:00
|
|
|
|
palletLabelManifest,
|
2026-08-24 19:58:35 +08:00
|
|
|
|
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)
|
2026-08-10 12:23:43 +08:00
|
|
|
|
}
|