test(firstleg): use task-scoped loading evidence

This commit is contained in:
2026-09-01 14:46:48 +08:00
parent f0ac0c42c9
commit 76d2da5bf9
5 changed files with 149 additions and 144 deletions
@@ -54,14 +54,18 @@ export async function run(context) {
return return
} }
const path = `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}` const path = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`
const response = await context.request('GET', path) const response = await context.request('GET', path)
const data = context.apiSuccess(MODULE, '复核本轮装柜证据聚合', response, `inboundId=${fixture.inboundId}`, OPTIONS) const data = context.apiSuccess(MODULE, '复核本轮 task 柜级装柜证据聚合', response,
`taskId=${taskId}, containerBookingId=${containerBookingId}`, OPTIONS)
const evidences = Array.isArray(data?.evidences) ? data.evidences : [] const evidences = Array.isArray(data?.evidences) ? data.evidences : []
const required = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const required = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const active = evidences.filter((item) => item.status !== 'VOID') const active = evidences.filter((item) => item.status !== 'VOID')
const voided = evidences.find((item) => id(item.id) === id(fixture.voidedEvidenceId)) const voided = evidences.find((item) => id(item.evidenceId) === id(fixture.voidedEvidenceId))
const missingTypeVoided = evidences.find((item) => id(item.id) === id(fixture.missingTypeVoidedEvidenceId)) const missingTypeVoided = evidences.find((item) => id(item.evidenceId) === id(fixture.missingTypeVoidedEvidenceId))
context.assert(MODULE, 'task 证据聚合归属稳定 PDA 任务和物理柜次', response,
id(data?.taskId) === taskId && id(data?.containerBookingId) === containerBookingId,
`taskId=${id(data?.taskId) || 'missing'}, containerBookingId=${id(data?.containerBookingId) || 'missing'}`, OPTIONS)
context.assert(MODULE, '五类有效证据齐全且同类型多图追加不覆盖历史', response, context.assert(MODULE, '五类有效证据齐全且同类型多图追加不覆盖历史', response,
required.every((type) => active.some((item) => item.evidenceType === type)) required.every((type) => active.some((item) => item.evidenceType === type))
&& evidences.filter((item) => item.evidenceType === 'PHOTO_STEP_1').length >= 2 && evidences.filter((item) => item.evidenceType === 'PHOTO_STEP_1').length >= 2
@@ -69,15 +73,13 @@ export async function run(context) {
&& active.every((item) => String(item.fileName || '').includes(context.marker) && active.every((item) => String(item.fileName || '').includes(context.marker)
&& String(item.remark || '').includes(context.marker)), && String(item.remark || '').includes(context.marker)),
`active=${active.length}, types=${[...new Set(active.map((item) => item.evidenceType))].join(',')}`, OPTIONS) `active=${active.length}, types=${[...new Set(active.map((item) => item.evidenceType))].join(',')}`, OPTIONS)
context.assert(MODULE, '作废证据保留原因、操作者和时间且不计入有效门禁', response, context.assert(MODULE, '作废证据保留稳定记录与更新时间且不计入有效门禁', response,
Boolean(voided) && voided.status === 'VOID' && voided.voidReason === `${context.marker}-wrong-photo` Boolean(voided) && voided.status === 'VOID' && Boolean(voided.updatedAt)
&& Boolean(voided.voidedBy) && Boolean(voided.voidedAt) && !active.some((item) => id(item.evidenceId) === id(fixture.voidedEvidenceId)),
&& !active.some((item) => id(item.id) === id(fixture.voidedEvidenceId)),
`voidedEvidenceId=${id(fixture.voidedEvidenceId) || 'missing'}, status=${voided?.status || 'missing'}`, OPTIONS) `voidedEvidenceId=${id(fixture.voidedEvidenceId) || 'missing'}, status=${voided?.status || 'missing'}`, OPTIONS)
context.assert(MODULE, '缺类作废审计保留且补传证据恢复有效门禁', response, context.assert(MODULE, '缺类作废审计保留且补传证据恢复有效门禁', response,
Boolean(missingTypeVoided) && missingTypeVoided.status === 'VOID' Boolean(missingTypeVoided) && missingTypeVoided.status === 'VOID'
&& missingTypeVoided.voidReason === `${context.marker}-temporary-missing-type` && Boolean(missingTypeVoided.updatedAt)
&& Boolean(missingTypeVoided.voidedBy) && Boolean(missingTypeVoided.voidedAt)
&& active.some((item) => item.evidenceType === 'PHOTO_STEP_4' && active.some((item) => item.evidenceType === 'PHOTO_STEP_4'
&& id(item.fileId) === id(fixture.replacementEvidenceFileId)), && id(item.fileId) === id(fixture.replacementEvidenceFileId)),
`voidedEvidenceId=${id(fixture.missingTypeVoidedEvidenceId) || 'missing'}, replacementFileId=${id(fixture.replacementEvidenceFileId) || 'missing'}`, OPTIONS) `voidedEvidenceId=${id(fixture.missingTypeVoidedEvidenceId) || 'missing'}, replacementFileId=${id(fixture.replacementEvidenceFileId) || 'missing'}`, OPTIONS)
@@ -19,5 +19,7 @@ test('evidence gate requires stable PDA identity and server LOADING before any e
assert.match(source, /AWAITING_REVIEW 不作为证据开放条件/) assert.match(source, /AWAITING_REVIEW 不作为证据开放条件/)
const gate = source.slice(source.indexOf('const taskResponse'), source.indexOf('const path =')) const gate = source.slice(source.indexOf('const taskResponse'), source.indexOf('const path ='))
assert.doesNotMatch(gate, /loading\/evidence/) assert.doesNotMatch(gate, /loading\/evidence/)
assert.match(source, /const path = `\/api\/platform\/v1\/inbounds\/\$\{inboundId\}\/loading\/evidence/) assert.match(source, /const path = `\/api\/platform\/v1\/first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\?warehouseId=/)
assert.doesNotMatch(source, /api\/platform\/v1\/inbounds\/\$\{inboundId\}\/loading\/evidence/)
assert.doesNotMatch(source, /loading\/evidence\?tenantId=/)
}) })
@@ -53,16 +53,17 @@ test('default fixture publishes a second marker-owned inbound for the same-plan
test('HTTP chain refreshes after stale inventory and keeps one stable PDA identity', () => { test('HTTP chain refreshes after stale inventory and keeps one stable PDA identity', () => {
assert.match(loading, assert.match(loading,
/async function uploadEvidence\(context, containerBookingId, evidenceType\)/) /async function uploadEvidence\(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix,/)
assert.match(loading, assert.match(loading,
/files\/loading-evidence\/\$\{containerBookingId\}\?evidenceType=/) /first-leg\/pda-loading\/tasks\/\$\{taskId\}\/evidence\?\$\{query\}/)
assert.match(loading, assert.match(loading,
/files\/loading-evidence\/\$\{containerBookingId\}\/\$\{fileId\}/) /\$\{evidencePath\}\/\$\{encodeURIComponent\(evidenceItem\.id\)\}\/content\?warehouseId=/)
assert.equal((loading.match(/uploadEvidence\(context, containerBookingId,/g) || []).length, 5) assert.match(loading, /\$\{evidencePath\}\/\$\{encodeURIComponent\(id\(voidTarget\.id\)\)\}\/void/)
assert.doesNotMatch(loading, assert.doesNotMatch(loading,
/files\/loading-evidence\/\$\{tenantId\}\/\$\{inboundId\}/) /api\/platform\/v1\/files\/loading-evidence/)
assert.doesNotMatch(loading, assert.doesNotMatch(loading,
/uploadEvidence\(context, tenantId, inboundId,/) /\$\{base\}\/loading\/evidence|api\/platform\/v1\/inbounds\/\$\{inboundId\}\/loading\/evidence/)
assert.doesNotMatch(loading, /uploadEvidence\(context, (tenantId|inboundId|containerBookingId),/)
ordered(loading, [ ordered(loading, [
'创建三期 DRAFT 订舱(不生成真实柜)', '创建三期 DRAFT 订舱(不生成真实柜)',
'三期 DRAFT 订舱返回稳定身份和初始版本', '三期 DRAFT 订舱返回稳定身份和初始版本',
+121 -121
View File
@@ -77,15 +77,20 @@ async function request(context, method, path, body, headers = {}) {
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
} }
async function uploadEvidence(context, containerBookingId, evidenceType) { async function uploadEvidence(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix,
const path = `/api/platform/v1/files/loading-evidence/${containerBookingId}?evidenceType=${encodeURIComponent(evidenceType)}` { sealNo = null, remark = context.marker, fileSuffix = evidenceType } = {}) {
const query = new URLSearchParams({ warehouseId: String(warehouseId), expectedVersion: String(expectedVersion),
evidenceType, remark })
if (sealNo != null) query.set('sealNo', sealNo)
const path = `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/evidence?${query}`
const form = new FormData() const form = new FormData()
const png = 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]) const png = 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])
const fileName = `${context.marker}-${evidenceType.toLowerCase()}.png` const fileName = `${context.marker}-${String(fileSuffix).toLowerCase()}.png`
form.append('file', new Blob([png], { type: 'image/png' }), fileName) form.append('file', new Blob([png], { type: 'image/png' }), fileName)
try { try {
const response = await fetch(`${context.options.gateway}${path}`, { const response = await fetch(`${context.options.gateway}${path}`, {
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
'Idempotency-Key': `${context.marker}-p3-evidence-${keySuffix}`.slice(0, 128) },
body: form, signal: AbortSignal.timeout(60_000), body: form, signal: AbortSignal.timeout(60_000),
}) })
const raw = await response.text(); let payload = null const raw = await response.text(); let payload = null
@@ -99,17 +104,6 @@ async function uploadEvidence(context, containerBookingId, evidenceType) {
} }
} }
async function bindEvidence(context, base, tenantId, evidenceType, fileId, suffix, keySuffix = evidenceType) {
return request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0,
evidenceType,
fileId,
fileName: 'client-must-not-override.png',
sealNo: evidenceType === 'SEAL' ? `SEAL-${suffix}` : null,
remark: context.marker,
}, { 'Idempotency-Key': `${context.marker}-p3-evidence-${keySuffix}`.slice(0, 128) })
}
function fixture(context) { function fixture(context) {
return context.createdResources?.orchestrationFixtures?.phase3Loading return context.createdResources?.orchestrationFixtures?.phase3Loading
|| context.createdResources?.orchestrationFixtures?.inboundMeasurement || context.createdResources?.orchestrationFixtures?.inboundMeasurement
@@ -895,47 +889,45 @@ export async function run(context) {
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const evidenceItems = [] const evidenceItems = []
const evidencePath = `${pdaBase}/tasks/${canonicalTaskId}/evidence`
const normalizedEvidence = (item) => item ? { ...item, id: id(item.evidenceId) } : null
for (const evidenceType of evidenceTypes) { for (const evidenceType of evidenceTypes) {
const upload = await uploadEvidence(context, containerBookingId, evidenceType) const sealNo = evidenceType === 'SEAL' ? `SEAL-${suffix}` : null
const uploaded = context.apiSuccess(MODULE, `上传本轮 ${evidenceType} 真实证据文件`, upload, '', OPTIONS) const upload = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0,
const fileId = id(uploaded?.fileId) evidenceType, { sealNo })
context.assert(MODULE, `${evidenceType} 文件归属类型和媒体元数据正确`, upload, const evidenceData = context.apiSuccess(MODULE, `通过 PDA task 原子上传并绑定 ${evidenceType} 证据`, upload, '', OPTIONS)
Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}` const evidenceItem = normalizedEvidence(evidenceData?.evidences?.find?.((item) =>
&& uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0, item.evidenceType === evidenceType && item.status !== 'VOID'))
`fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}`, OPTIONS) const fileId = id(evidenceItem?.fileId)
if (!fileId) return context.assert(MODULE, `${evidenceType} 证据归属 task、柜次、marker 和版本`, upload,
id(evidenceData?.taskId) === canonicalTaskId
const evidence = await bindEvidence(context, base, tenantId, evidenceType, fileId, suffix) && id(evidenceData?.containerBookingId) === containerBookingId
const evidenceData = context.apiSuccess(MODULE, `绑定本轮 ${evidenceType} 装柜证据`, evidence, '', OPTIONS) && Boolean(evidenceItem?.id) && Boolean(fileId)
const evidenceItem = evidenceData?.evidences?.evidences?.find?.((item) => item.evidenceType === evidenceType)
context.assert(MODULE, `${evidenceType} 证据归属、marker 和版本正确`, evidence,
evidenceData?.mainStatus === 'LOADING' && id(evidenceItem?.fileId) === fileId
&& evidenceItem?.fileName === `${context.marker}-${evidenceType.toLowerCase()}.png` && evidenceItem?.fileName === `${context.marker}-${evidenceType.toLowerCase()}.png`
&& evidenceItem?.remark === context.marker && Number(evidenceItem?.version) === 0 && evidenceItem?.remark === context.marker && Number(evidenceItem?.version) === 0
&& (evidenceType !== 'SEAL' || evidenceItem?.sealNo === `SEAL-${suffix}`), && (evidenceType !== 'SEAL' || evidenceItem?.sealNo === sealNo),
`mainStatus=${evidenceData?.mainStatus || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS) `taskId=${id(evidenceData?.taskId) || 'missing'}, containerBookingId=${id(evidenceData?.containerBookingId) || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS)
if (!evidenceItem?.id) return if (!evidenceItem?.id) return
evidenceItems.push(evidenceItem) evidenceItems.push(evidenceItem)
if (evidenceType === 'PHOTO_STEP_1') { if (evidenceType === 'PHOTO_STEP_1') {
const replay = await bindEvidence(context, base, tenantId, evidenceType, fileId, suffix) const replay = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0,
const replayData = context.apiSuccess(MODULE, '装柜证据绑定同键同参幂等重放', replay, '', OPTIONS) evidenceType)
const replayItems = replayData?.evidences?.evidences?.filter?.((item) => item.evidenceType === evidenceType) || [] const replayData = context.apiSuccess(MODULE, '装柜证据 task 上传同键同载荷重放', replay, '', OPTIONS)
context.assert(MODULE, '证据绑定重放返回同一记录且不重复追加', replay, const replayItems = replayData?.evidences?.filter?.((item) => item.evidenceType === evidenceType) || []
replayItems.length === 1 && id(replayItems[0]?.id) === id(evidenceItem.id), context.assert(MODULE, '证据 task 上传重放返回同一记录且不重复追加', replay,
`evidenceId=${id(replayItems[0]?.id) || 'missing'}, count=${replayItems.length}`, OPTIONS) replayItems.length === 1 && id(replayItems[0]?.evidenceId) === id(evidenceItem.id),
const conflict = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { `evidenceId=${id(replayItems[0]?.evidenceId) || 'missing'}, count=${replayItems.length}`, OPTIONS)
expectedVersion: 0, evidenceType, fileId, const conflict = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0,
fileName: 'client-must-not-override.png', sealNo: null, remark: `${context.marker}-different`, evidenceType, { remark: `${context.marker}-different` })
}, { 'Idempotency-Key': `${context.marker}-p3-evidence-${evidenceType}`.slice(0, 128) }) context.addStep(MODULE, '装柜证据 task 上传同键异载荷返回冲突', conflict,
context.addStep(MODULE, '装柜证据绑定同键异参返回冲突', conflict,
conflict.http === 200 && codeOf(conflict) === 'A0409' conflict.http === 200 && codeOf(conflict) === 'A0409'
&& Boolean(conflict.requestId) && Boolean(conflict.traceId), && Boolean(conflict.requestId) && Boolean(conflict.traceId),
`HTTP=${conflict.http}, code=${codeOf(conflict)}, requestId=${conflict.requestId || 'missing'}, traceId=${conflict.traceId || 'missing'}`, OPTIONS) `HTTP=${conflict.http}, code=${codeOf(conflict)}, requestId=${conflict.requestId || 'missing'}, traceId=${conflict.traceId || 'missing'}`, OPTIONS)
} }
const download = await context.requestBinary('GET', const download = await context.requestBinary('GET',
`/api/platform/v1/files/loading-evidence/${containerBookingId}/${fileId}`) `${evidencePath}/${encodeURIComponent(evidenceItem.id)}/content?warehouseId=${encodeURIComponent(warehouseId)}`)
context.assert(MODULE, `下载本轮 ${evidenceType} 证据文件`, download, context.assert(MODULE, `下载本轮 ${evidenceType} 证据文件`, download,
download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0, download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0,
`bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS) `bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS)
@@ -949,95 +941,100 @@ export async function run(context) {
const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1') const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1')
const sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL') const sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL')
const appendedUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_1') const appended = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_1',
const appendedFile = context.apiSuccess(MODULE, '追加同类型第二张装柜证据文件', appendedUpload, '', OPTIONS) Number(firstPhoto?.version), 'PHOTO_STEP_1_APPEND', { fileSuffix: 'PHOTO_STEP_1_APPEND' })
const appendedFileId = id(appendedFile?.fileId) const appendedData = context.apiSuccess(MODULE, '追加同类型第二张 task 装柜证据', appended, '', OPTIONS)
const appended = appendedFileId const appendedItems = appendedData?.evidences?.filter?.((item) => item.evidenceType === 'PHOTO_STEP_1') || []
? await bindEvidence(context, base, tenantId, 'PHOTO_STEP_1', appendedFileId, suffix, 'PHOTO_STEP_1_APPEND') const appendedActive = normalizedEvidence(appendedItems.find((item) => item.status !== 'VOID'))
: appendedUpload const appendedFileId = id(appendedActive?.fileId)
const appendedData = context.apiSuccess(MODULE, '追加同类型第二张装柜证据', appended, '', OPTIONS) context.assert(MODULE, '同类型装柜证据追加保留历史并唯一激活新图', appended,
const appendedItems = appendedData?.evidences?.evidences?.filter?.((item) => item.evidenceType === 'PHOTO_STEP_1') || []
context.assert(MODULE, '同类型装柜证据支持多图追加且不替换历史', appended,
Boolean(appendedFileId) && appendedItems.length >= 2 Boolean(appendedFileId) && appendedItems.length >= 2
&& appendedItems.some((item) => id(item.fileId) === id(firstPhoto?.fileId)) && appendedItems.some((item) => id(item.fileId) === id(firstPhoto?.fileId) && item.status === 'VOID')
&& appendedItems.some((item) => id(item.fileId) === appendedFileId), && id(appendedActive?.fileId) === appendedFileId,
`photoStep1Count=${appendedItems.length}, original=${id(firstPhoto?.fileId) || 'missing'}, appended=${appendedFileId || 'missing'}`, OPTIONS) `photoStep1Count=${appendedItems.length}, original=${id(firstPhoto?.fileId) || 'missing'}, appended=${appendedFileId || 'missing'}`, OPTIONS)
const wrongType = await bindEvidence(context, base, tenantId, 'PHOTO_STEP_2', id(firstPhoto?.fileId), suffix, 'wrong-type') const wrongType = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_9', 0,
context.addStep(MODULE, '不同证据类型的真实 fileId 不可交叉绑定', wrongType, failure(wrongType), 'wrong-type', { fileSuffix: 'wrong-type' })
context.addStep(MODULE, '不在白名单的证据类型不可上传绑定', wrongType, failure(wrongType),
`HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS) `HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS)
const forged = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { const forged = await context.request('GET',
expectedVersion: Number(sealEvidence?.version), evidenceType: 'SEAL', fileId: '999999999999999999', `${evidencePath}/999999999999999999/content?warehouseId=${encodeURIComponent(warehouseId)}`)
fileName: `${context.marker}-forged.png`, sealNo: `FORGED-${suffix}`, remark: context.marker, context.addStep(MODULE, '伪造装柜 evidenceId 无法读取任务证据内容', forged,
}, { 'Idempotency-Key': `${context.marker}-p3-forged-evidence`.slice(0, 128) }) (forged.http === 200 && codeOf(forged) === 'A0404') || forged.http === 404,
context.addStep(MODULE, '伪造装柜证据 fileId 归属校验拒绝', forged, failure(forged),
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
const missingSealText = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { const beforeMissingSealResponse = await context.request('GET',
expectedVersion: Number(sealEvidence?.version), evidenceType: 'SEAL', fileId: id(sealEvidence?.fileId), `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`)
fileName: `${context.marker}-missing-seal-text.png`, sealNo: ' ', remark: context.marker, const beforeMissingSeal = beforeMissingSealResponse.body?.data?.evidences || []
}, { 'Idempotency-Key': `${context.marker}-p3-missing-seal-text`.slice(0, 128) }) const missingSealText = await uploadEvidence(context, canonicalTaskId, warehouseId, 'SEAL',
Number(sealEvidence?.version), 'missing-seal-text', { sealNo: ' ', fileSuffix: 'missing-seal-text' })
context.addStep(MODULE, '带锁码照片缺少锁码文本时拒绝绑定', missingSealText, failure(missingSealText), context.addStep(MODULE, '带锁码照片缺少锁码文本时拒绝绑定', missingSealText, failure(missingSealText),
`HTTP=${missingSealText.http}, code=${codeOf(missingSealText)}`, OPTIONS) `HTTP=${missingSealText.http}, code=${codeOf(missingSealText)}`, OPTIONS)
const afterMissingSealResponse = await context.request('GET',
`${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`)
context.assert(MODULE, '缺锁码文本失败后 task 证据零漂移', afterMissingSealResponse,
JSON.stringify(afterMissingSealResponse.body?.data?.evidences || []) === JSON.stringify(beforeMissingSeal),
`before=${beforeMissingSeal.length}, after=${afterMissingSealResponse.body?.data?.evidences?.length ?? 'missing'}`, OPTIONS)
const voidTarget = appendedItems.find((item) => id(item.fileId) === appendedFileId) const voidTarget = appendedActive
const voidResponse = voidTarget const voidResponse = voidTarget
? await request(context, 'POST', `${base}/loading/evidence/${encodeURIComponent(id(voidTarget.id))}/void?tenantId=${encodeURIComponent(tenantId)}`, ? await request(context, 'POST', `${evidencePath}/${encodeURIComponent(id(voidTarget.id))}/void`,
{ expectedVersion: Number(voidTarget.version), reason: `${context.marker}-wrong-photo` }, { warehouseId, expectedVersion: Number(voidTarget.version), reason: `${context.marker}-wrong-photo` })
{ 'Idempotency-Key': `${context.marker}-p3-evidence-void`.slice(0, 128) })
: appended : appended
const voidData = context.apiSuccess(MODULE, '作废错误装柜证据并保留审计', voidResponse, '', OPTIONS) const voidData = context.apiSuccess(MODULE, '作废错误装柜证据并保留审计', voidResponse, '', OPTIONS)
context.assert(MODULE, '错图作废后原记录保留且不再计入有效证据', voidResponse, context.assert(MODULE, '错图作废后原记录保留且不再计入有效证据', voidResponse,
Boolean(voidTarget) && voidData?.evidences?.evidences?.some?.((item) => id(item.id) === id(voidTarget.id) Boolean(voidTarget) && voidData?.evidences?.some?.((item) => id(item.evidenceId) === id(voidTarget.id)
&& (item.status === 'VOID' || item.valid === false) && item.voidReason === `${context.marker}-wrong-photo`), && item.status === 'VOID' && Boolean(item.updatedAt)),
`evidenceId=${id(voidTarget?.id) || 'missing'}, status=${voidTarget?.status || 'missing'}`, OPTIONS) `evidenceId=${id(voidTarget?.id) || 'missing'}, status=${voidTarget?.status || 'missing'}`, OPTIONS)
const restoredPhotoResponse = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_1', 0,
'PHOTO_STEP_1_RESTORE', { fileSuffix: 'PHOTO_STEP_1_RESTORE' })
const restoredPhotoData = context.apiSuccess(MODULE, '错图作废后恢复 PHOTO_STEP_1 有效证据', restoredPhotoResponse, '', OPTIONS)
const restoredPhoto = normalizedEvidence(restoredPhotoData?.evidences?.find?.((item) =>
item.evidenceType === 'PHOTO_STEP_1' && item.status !== 'VOID'))
context.assert(MODULE, 'PHOTO_STEP_1 恢复后旧记录保留且新记录唯一有效', restoredPhotoResponse,
Boolean(restoredPhoto?.id) && restoredPhotoData?.evidences?.some?.((item) =>
id(item.evidenceId) === id(voidTarget?.id) && item.status === 'VOID'),
`restoredEvidenceId=${id(restoredPhoto?.id) || 'missing'}`, OPTIONS)
const missingTypeTarget = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_4') const missingTypeTarget = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_4')
const missingTypePath = `${base}/loading/evidence/${encodeURIComponent(id(missingTypeTarget?.id))}/void?tenantId=${encodeURIComponent(tenantId)}` const missingTypePath = `${evidencePath}/${encodeURIComponent(id(missingTypeTarget?.id))}/void`
const staleMissingTypeVoid = missingTypeTarget const staleMissingTypeVoid = missingTypeTarget
? await request(context, 'POST', missingTypePath, ? await request(context, 'POST', missingTypePath,
{ expectedVersion: Number(missingTypeTarget.version) + 1, reason: `${context.marker}-stale-version` }, { warehouseId, expectedVersion: Number(missingTypeTarget.version) + 1, reason: `${context.marker}-stale-version` })
{ 'Idempotency-Key': `${context.marker}-p3-evidence-void-stale`.slice(0, 128) })
: voidResponse : voidResponse
context.addStep(MODULE, '装柜证据作废旧版本返回冲突', staleMissingTypeVoid, context.addStep(MODULE, '装柜证据作废旧版本返回冲突', staleMissingTypeVoid,
Boolean(missingTypeTarget) && staleMissingTypeVoid.http === 200 && codeOf(staleMissingTypeVoid) === 'A0409', Boolean(missingTypeTarget) && staleMissingTypeVoid.http === 200 && codeOf(staleMissingTypeVoid) === 'A0409',
`HTTP=${staleMissingTypeVoid.http}, code=${codeOf(staleMissingTypeVoid)}`, OPTIONS) `HTTP=${staleMissingTypeVoid.http}, code=${codeOf(staleMissingTypeVoid)}`, OPTIONS)
const missingTypeVoidKey = `${context.marker}-p3-evidence-void-missing-type`.slice(0, 128)
const missingTypeVoidBody = { const missingTypeVoidBody = {
expectedVersion: Number(missingTypeTarget?.version), reason: `${context.marker}-temporary-missing-type`, warehouseId, expectedVersion: Number(missingTypeTarget?.version), reason: `${context.marker}-temporary-missing-type`,
} }
const missingTypeVoid = missingTypeTarget const missingTypeVoid = missingTypeTarget
? await request(context, 'POST', missingTypePath, missingTypeVoidBody, ? await request(context, 'POST', missingTypePath, missingTypeVoidBody)
{ 'Idempotency-Key': missingTypeVoidKey })
: voidResponse : voidResponse
const missingTypeVoidData = context.apiSuccess(MODULE, '临时作废唯一 PHOTO_STEP_4 证据', missingTypeVoid, '', OPTIONS) const missingTypeVoidData = context.apiSuccess(MODULE, '临时作废唯一 PHOTO_STEP_4 证据', missingTypeVoid, '', OPTIONS)
context.assert(MODULE, '临时作废后 PHOTO_STEP_4 不再计入有效证据', missingTypeVoid, context.assert(MODULE, '临时作废后 PHOTO_STEP_4 不再计入有效证据', missingTypeVoid,
Boolean(missingTypeTarget) && missingTypeVoidData?.evidences?.evidences?.some?.((item) => Boolean(missingTypeTarget) && missingTypeVoidData?.evidences?.some?.((item) =>
id(item.id) === id(missingTypeTarget.id) && item.status === 'VOID'), id(item.evidenceId) === id(missingTypeTarget.id) && item.status === 'VOID'),
`evidenceId=${id(missingTypeTarget?.id) || 'missing'}`, OPTIONS) `evidenceId=${id(missingTypeTarget?.id) || 'missing'}`, OPTIONS)
const missingTypeVoidReplay = missingTypeTarget const missingTypeVoidReplay = missingTypeTarget
? await request(context, 'POST', missingTypePath, missingTypeVoidBody, ? await request(context, 'POST', missingTypePath, missingTypeVoidBody)
{ 'Idempotency-Key': missingTypeVoidKey })
: voidResponse : voidResponse
context.assert(MODULE, '证据作废同键同参重放保持同一 VOID 结果', missingTypeVoidReplay, context.assert(MODULE, '已作废 task 证据重复作废返回冲突', missingTypeVoidReplay,
missingTypeVoidReplay.http === 200 && codeOf(missingTypeVoidReplay) === '200' missingTypeVoidReplay.http === 200 && codeOf(missingTypeVoidReplay) === 'A0409',
&& missingTypeVoidReplay.body?.data?.evidences?.evidences?.some?.((item) =>
id(item.id) === id(missingTypeTarget?.id) && item.status === 'VOID'),
`HTTP=${missingTypeVoidReplay.http}, code=${codeOf(missingTypeVoidReplay)}`, OPTIONS) `HTTP=${missingTypeVoidReplay.http}, code=${codeOf(missingTypeVoidReplay)}`, OPTIONS)
const missingTypeVoidConflict = missingTypeTarget const missingTypeVoidConflict = missingTypeTarget
? await request(context, 'POST', missingTypePath, ? await request(context, 'POST', missingTypePath,
{ ...missingTypeVoidBody, reason: `${context.marker}-different-void-reason` }, { ...missingTypeVoidBody, reason: `${context.marker}-different-void-reason` })
{ 'Idempotency-Key': missingTypeVoidKey })
: voidResponse : voidResponse
context.addStep(MODULE, '证据作废同键异参返回冲突', missingTypeVoidConflict, context.addStep(MODULE, '已作废 task 证据异参重试仍返回冲突', missingTypeVoidConflict,
missingTypeVoidConflict.http === 200 && codeOf(missingTypeVoidConflict) === 'A0409', missingTypeVoidConflict.http === 200 && codeOf(missingTypeVoidConflict) === 'A0409',
`HTTP=${missingTypeVoidConflict.http}, code=${codeOf(missingTypeVoidConflict)}`, OPTIONS) `HTTP=${missingTypeVoidConflict.http}, code=${codeOf(missingTypeVoidConflict)}`, OPTIONS)
const duplicateVoid = missingTypeTarget const duplicateVoid = missingTypeTarget
? await request(context, 'POST', missingTypePath, missingTypeVoidBody, ? await request(context, 'POST', missingTypePath, missingTypeVoidBody)
{ 'Idempotency-Key': `${context.marker}-p3-evidence-void-duplicate`.slice(0, 128) })
: voidResponse : voidResponse
context.addStep(MODULE, '已作废证据使用新请求重复作废返回冲突', duplicateVoid, context.addStep(MODULE, '已作废证据使用新请求重复作废返回冲突', duplicateVoid,
duplicateVoid.http === 200 && codeOf(duplicateVoid) === 'A0409', duplicateVoid.http === 200 && codeOf(duplicateVoid) === 'A0409',
@@ -1046,51 +1043,53 @@ export async function run(context) {
context.addStep(MODULE, '作废唯一证据类型后不提前占用异步完成命令', null, true, context.addStep(MODULE, '作废唯一证据类型后不提前占用异步完成命令', null, true,
'PHOTO_STEP_4 已真实作废并回读;补传恢复后才允许最终阶段提交唯一完成命令', OPTIONS) 'PHOTO_STEP_4 已真实作废并回读;补传恢复后才允许最终阶段提交唯一完成命令', OPTIONS)
const replacementUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_4') const replacementUpload = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_4', 0,
const replacementFile = context.apiSuccess(MODULE, '补传缺失 PHOTO_STEP_4 真实文件', replacementUpload, '', OPTIONS) 'PHOTO_STEP_4_RESTORE', { fileSuffix: 'PHOTO_STEP_4_RESTORE' })
const replacementFileId = id(replacementFile?.fileId) const replacementData = context.apiSuccess(MODULE, '补传恢复 PHOTO_STEP_4 有效证据', replacementUpload, '', OPTIONS)
const replacementBind = replacementFileId const replacementItem = normalizedEvidence(replacementData?.evidences?.find?.((item) =>
? await bindEvidence(context, base, tenantId, 'PHOTO_STEP_4', replacementFileId, suffix, 'PHOTO_STEP_4_RESTORE') item.evidenceType === 'PHOTO_STEP_4' && item.status !== 'VOID'))
: replacementUpload const replacementFileId = id(replacementItem?.fileId)
const replacementData = context.apiSuccess(MODULE, '补传恢复 PHOTO_STEP_4 有效证据', replacementBind, '', OPTIONS) context.assert(MODULE, '补传后五类有效证据恢复且旧 VOID 记录保留', replacementUpload,
context.assert(MODULE, '补传后五类有效证据恢复且旧 VOID 记录保留', replacementBind,
Boolean(replacementFileId) Boolean(replacementFileId)
&& replacementData?.evidences?.evidences?.some?.((item) => item.evidenceType === 'PHOTO_STEP_4' && replacementData?.evidences?.some?.((item) => item.evidenceType === 'PHOTO_STEP_4'
&& id(item.fileId) === replacementFileId && item.status !== 'VOID') && id(item.fileId) === replacementFileId && item.status !== 'VOID')
&& replacementData?.evidences?.evidences?.some?.((item) => id(item.id) === id(missingTypeTarget?.id) && replacementData?.evidences?.some?.((item) => id(item.evidenceId) === id(missingTypeTarget?.id)
&& item.status === 'VOID'), && item.status === 'VOID'),
`replacementFileId=${replacementFileId || 'missing'}`, OPTIONS) `replacementFileId=${replacementFileId || 'missing'}`, OPTIONS)
const customerEvidence = merchantToken const customerEvidence = merchantToken
? await request(context, 'GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, ? await request(context, 'GET', `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`,
undefined, { Authorization: `Bearer ${merchantToken}` }) undefined, { Authorization: `Bearer ${merchantToken}` })
: merchantLogin : merchantLogin
context.addStep(MODULE, '客户身份无权访问平台装柜证据', customerEvidence, context.addStep(MODULE, '客户身份无权访问平台装柜证据', customerEvidence,
customerEvidence.http === 403 && codeOf(customerEvidence) === 'A0403' customerEvidence.http === 403 && codeOf(customerEvidence) === 'A0403'
&& Boolean(customerEvidence.requestId) && Boolean(customerEvidence.traceId), && Boolean(customerEvidence.requestId) && Boolean(customerEvidence.traceId),
`HTTP=${customerEvidence.http}, code=${codeOf(customerEvidence)}, requestId=${customerEvidence.requestId || 'missing'}, traceId=${customerEvidence.traceId || 'missing'}`, OPTIONS) `HTTP=${customerEvidence.http}, code=${codeOf(customerEvidence)}, requestId=${customerEvidence.requestId || 'missing'}, traceId=${customerEvidence.traceId || 'missing'}`, OPTIONS)
const wrongTenantEvidence = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(Number(tenantId) + 1)}`) const wrongWarehouseEvidence = await context.request('GET',
context.addStep(MODULE, '错误客户作用域不可读取本轮装柜证据', wrongTenantEvidence, `${evidencePath}?warehouseId=${encodeURIComponent(Number(warehouseId) + 1)}`)
wrongTenantEvidence.http === 200 && codeOf(wrongTenantEvidence) === 'A0404', context.addStep(MODULE, '错误仓库作用域不可读取本轮 task 装柜证据', wrongWarehouseEvidence,
`HTTP=${wrongTenantEvidence.http}, code=${codeOf(wrongTenantEvidence)}`, OPTIONS) wrongWarehouseEvidence.http === 403 && codeOf(wrongWarehouseEvidence) === 'A0403',
const wrongInboundEvidence = await context.request('GET', `HTTP=${wrongWarehouseEvidence.http}, code=${codeOf(wrongWarehouseEvidence)}`, OPTIONS)
`/api/platform/v1/inbounds/999999999999999999/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) const wrongTaskEvidence = await context.request('GET',
context.addStep(MODULE, '错误入库单不可读取本轮装柜证据', wrongInboundEvidence, `${pdaBase}/tasks/999999999999999999/evidence?warehouseId=${encodeURIComponent(warehouseId)}`)
wrongInboundEvidence.http === 200 && codeOf(wrongInboundEvidence) === 'A0404', context.addStep(MODULE, '错误 taskId 不可读取本轮装柜证据', wrongTaskEvidence,
`HTTP=${wrongInboundEvidence.http}, code=${codeOf(wrongInboundEvidence)}`, OPTIONS) wrongTaskEvidence.http === 200 && codeOf(wrongTaskEvidence) === 'A0404',
`HTTP=${wrongTaskEvidence.http}, code=${codeOf(wrongTaskEvidence)}`, OPTIONS)
const postLoadedUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_2') const evidenceList = await context.request('GET',
const postLoadedFile = context.apiSuccess(MODULE, '预置 LOADED 后新增证据边界文件', postLoadedUpload, '', OPTIONS) `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`)
const postLoadedFileId = id(postLoadedFile?.fileId)
const evidenceList = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS) const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS)
const activeEvidenceList = evidenceListData?.evidences?.filter?.((item) => item.status !== 'VOID') || []
context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList, context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList,
evidenceListData?.evidences?.length >= evidenceTypes.length id(evidenceListData?.taskId) === canonicalTaskId
&& evidenceTypes.every((type) => evidenceListData.evidences.some((item) => item.evidenceType === type)) && id(evidenceListData?.containerBookingId) === containerBookingId
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`), && evidenceTypes.every((type) => activeEvidenceList.some((item) => item.evidenceType === type))
&& activeEvidenceList.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS) `evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
const immutablePhoto = restoredPhoto
const postLoadedFileId = null
const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental
const incrementalInboundId = id(incrementalFixture?.inboundId) const incrementalInboundId = id(incrementalFixture?.inboundId)
let skipBatchVerified = false let skipBatchVerified = false
@@ -1242,7 +1241,8 @@ export async function run(context) {
const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS) const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS)
const incrementalTaskItem = incrementedTask?.items?.find?.((item) => const incrementalTaskItem = incrementedTask?.items?.find?.((item) =>
id(item?.loadingPlanItemId) === id(incrementalPlanItem?.id)) id(item?.loadingPlanItemId) === id(incrementalPlanItem?.id))
const evidenceAfterIncrementResponse = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) const evidenceAfterIncrementResponse = await context.request('GET',
`${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`)
const evidenceAfterIncrement = context.apiSuccess(MODULE, '增量后回查既有五类证据', evidenceAfterIncrementResponse, '', OPTIONS) const evidenceAfterIncrement = context.apiSuccess(MODULE, '增量后回查既有五类证据', evidenceAfterIncrementResponse, '', OPTIONS)
context.assert(MODULE, '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', incrementResponse, context.assert(MODULE, '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', incrementResponse,
id(incrementedPlan?.id) === loadingPlanId && incrementedPlan?.status === 'LOADING' id(incrementedPlan?.id) === loadingPlanId && incrementedPlan?.status === 'LOADING'
@@ -1418,7 +1418,7 @@ export async function run(context) {
evidenceIds: evidenceItems.map((item) => id(item.id)), evidenceIds: evidenceItems.map((item) => id(item.id)),
voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`, voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`,
missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId, missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId,
immutableEvidenceId: id(firstPhoto?.id), immutableEvidenceVersion: Number(firstPhoto?.version), immutableEvidenceId: id(immutablePhoto?.id), immutableEvidenceVersion: Number(immutablePhoto?.version),
postLoadedFileId, postLoadedFileId,
oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId, oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId,
canonicalPdaTaskId: canonicalTaskId, canonicalPdaTaskId: canonicalTaskId,
@@ -669,18 +669,18 @@ test('customs and finalize consume the same loading inbound identity', () => {
test('loading evidence acceptance covers idempotency, reversible missing-type gate and scope isolation', () => { test('loading evidence acceptance covers idempotency, reversible missing-type gate and scope isolation', () => {
const loading = source('./phase3-loading.mjs') const loading = source('./phase3-loading.mjs')
assertOrder(loading, [ assertOrder(loading, [
'装柜证据绑定同键同参幂等重放', '装柜证据 task 上传同键同载荷重放',
'装柜证据绑定同键异参返回冲突', '装柜证据 task 上传同键异载荷返回冲突',
'装柜证据作废旧版本返回冲突', '装柜证据作废旧版本返回冲突',
'临时作废唯一 PHOTO_STEP_4 证据', '临时作废唯一 PHOTO_STEP_4 证据',
'证据作废同键同参重放保持同一 VOID 结果', '已作废 task 证据重复作废返回冲突',
'证据作废同键异参返回冲突', '已作废 task 证据异参重试仍返回冲突',
'已作废证据使用新请求重复作废返回冲突', '已作废证据使用新请求重复作废返回冲突',
'作废唯一证据类型后不提前占用异步完成命令', '作废唯一证据类型后不提前占用异步完成命令',
'补传恢复 PHOTO_STEP_4 有效证据', '补传恢复 PHOTO_STEP_4 有效证据',
'客户身份无权访问平台装柜证据', '客户身份无权访问平台装柜证据',
'错误客户作用域不可读取本轮装柜证据', '错误仓库作用域不可读取本轮 task 装柜证据',
'错误入库单不可读取本轮装柜证据', '错误 taskId 不可读取本轮装柜证据',
]) ])
assert.match(loading, /missingTypeVoidedEvidenceId/) assert.match(loading, /missingTypeVoidedEvidenceId/)
assert.match(loading, /replacementEvidenceFileId/) assert.match(loading, /replacementEvidenceFileId/)