From 76d2da5bf9ce41b5ee3d0c676278e6395cc019cd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:46:48 +0800 Subject: [PATCH] test(firstleg): use task-scoped loading evidence --- .../phase3-evidence-gate.mjs | 22 +- .../phase3-evidence-stage-contract.test.mjs | 4 +- ...e3-loading-dynamic-final-contract.test.mjs | 13 +- .../admin-test-env-modules/phase3-loading.mjs | 242 +++++++++--------- .../phase3-producer-chain-contract.test.mjs | 12 +- 5 files changed, 149 insertions(+), 144 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs index 9639671a9..f8c6f6da4 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs @@ -54,14 +54,18 @@ export async function run(context) { 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 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 required = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const active = evidences.filter((item) => item.status !== 'VOID') - const voided = evidences.find((item) => id(item.id) === id(fixture.voidedEvidenceId)) - const missingTypeVoided = evidences.find((item) => id(item.id) === id(fixture.missingTypeVoidedEvidenceId)) + const voided = evidences.find((item) => id(item.evidenceId) === id(fixture.voidedEvidenceId)) + 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, required.every((type) => active.some((item) => item.evidenceType === type)) && 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) && String(item.remark || '').includes(context.marker)), `active=${active.length}, types=${[...new Set(active.map((item) => item.evidenceType))].join(',')}`, OPTIONS) - context.assert(MODULE, '作废证据保留原因、操作者和时间且不计入有效门禁', response, - Boolean(voided) && voided.status === 'VOID' && voided.voidReason === `${context.marker}-wrong-photo` - && Boolean(voided.voidedBy) && Boolean(voided.voidedAt) - && !active.some((item) => id(item.id) === id(fixture.voidedEvidenceId)), + context.assert(MODULE, '作废证据保留稳定记录与更新时间且不计入有效门禁', response, + Boolean(voided) && voided.status === 'VOID' && Boolean(voided.updatedAt) + && !active.some((item) => id(item.evidenceId) === id(fixture.voidedEvidenceId)), `voidedEvidenceId=${id(fixture.voidedEvidenceId) || 'missing'}, status=${voided?.status || 'missing'}`, OPTIONS) context.assert(MODULE, '缺类作废审计保留且补传证据恢复有效门禁', response, Boolean(missingTypeVoided) && missingTypeVoided.status === 'VOID' - && missingTypeVoided.voidReason === `${context.marker}-temporary-missing-type` - && Boolean(missingTypeVoided.voidedBy) && Boolean(missingTypeVoided.voidedAt) + && Boolean(missingTypeVoided.updatedAt) && active.some((item) => item.evidenceType === 'PHOTO_STEP_4' && id(item.fileId) === id(fixture.replacementEvidenceFileId)), `voidedEvidenceId=${id(fixture.missingTypeVoidedEvidenceId) || 'missing'}, replacementFileId=${id(fixture.replacementEvidenceFileId) || 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs index aaf0d0a81..a7dbf7da9 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs @@ -19,5 +19,7 @@ test('evidence gate requires stable PDA identity and server LOADING before any e assert.match(source, /AWAITING_REVIEW 不作为证据开放条件/) const gate = source.slice(source.indexOf('const taskResponse'), source.indexOf('const path =')) 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=/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 156049cef..e71b8e220 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -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', () => { assert.match(loading, - /async function uploadEvidence\(context, containerBookingId, evidenceType\)/) + /async function uploadEvidence\(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix,/) assert.match(loading, - /files\/loading-evidence\/\$\{containerBookingId\}\?evidenceType=/) + /first-leg\/pda-loading\/tasks\/\$\{taskId\}\/evidence\?\$\{query\}/) assert.match(loading, - /files\/loading-evidence\/\$\{containerBookingId\}\/\$\{fileId\}/) - assert.equal((loading.match(/uploadEvidence\(context, containerBookingId,/g) || []).length, 5) + /\$\{evidencePath\}\/\$\{encodeURIComponent\(evidenceItem\.id\)\}\/content\?warehouseId=/) + assert.match(loading, /\$\{evidencePath\}\/\$\{encodeURIComponent\(id\(voidTarget\.id\)\)\}\/void/) assert.doesNotMatch(loading, - /files\/loading-evidence\/\$\{tenantId\}\/\$\{inboundId\}/) + /api\/platform\/v1\/files\/loading-evidence/) 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, [ '创建三期 DRAFT 订舱(不生成真实柜)', '三期 DRAFT 订舱返回稳定身份和初始版本', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 578277af8..4db161c7f 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -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 } } } -async function uploadEvidence(context, containerBookingId, evidenceType) { - const path = `/api/platform/v1/files/loading-evidence/${containerBookingId}?evidenceType=${encodeURIComponent(evidenceType)}` +async function uploadEvidence(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix, + { 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 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) try { 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), }) 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) { return context.createdResources?.orchestrationFixtures?.phase3Loading || 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 evidenceItems = [] + const evidencePath = `${pdaBase}/tasks/${canonicalTaskId}/evidence` + const normalizedEvidence = (item) => item ? { ...item, id: id(item.evidenceId) } : null for (const evidenceType of evidenceTypes) { - const upload = await uploadEvidence(context, containerBookingId, evidenceType) - const uploaded = context.apiSuccess(MODULE, `上传本轮 ${evidenceType} 真实证据文件`, upload, '', OPTIONS) - const fileId = id(uploaded?.fileId) - context.assert(MODULE, `${evidenceType} 文件归属类型和媒体元数据正确`, upload, - Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}` - && uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0, - `fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}`, OPTIONS) - if (!fileId) return - - const evidence = await bindEvidence(context, base, tenantId, evidenceType, fileId, suffix) - const evidenceData = context.apiSuccess(MODULE, `绑定本轮 ${evidenceType} 装柜证据`, evidence, '', OPTIONS) - const evidenceItem = evidenceData?.evidences?.evidences?.find?.((item) => item.evidenceType === evidenceType) - context.assert(MODULE, `${evidenceType} 证据归属、marker 和版本正确`, evidence, - evidenceData?.mainStatus === 'LOADING' && id(evidenceItem?.fileId) === fileId + const sealNo = evidenceType === 'SEAL' ? `SEAL-${suffix}` : null + const upload = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0, + evidenceType, { sealNo }) + const evidenceData = context.apiSuccess(MODULE, `通过 PDA task 原子上传并绑定 ${evidenceType} 证据`, upload, '', OPTIONS) + const evidenceItem = normalizedEvidence(evidenceData?.evidences?.find?.((item) => + item.evidenceType === evidenceType && item.status !== 'VOID')) + const fileId = id(evidenceItem?.fileId) + context.assert(MODULE, `${evidenceType} 证据归属 task、柜次、marker 和版本`, upload, + id(evidenceData?.taskId) === canonicalTaskId + && id(evidenceData?.containerBookingId) === containerBookingId + && Boolean(evidenceItem?.id) && Boolean(fileId) && evidenceItem?.fileName === `${context.marker}-${evidenceType.toLowerCase()}.png` && evidenceItem?.remark === context.marker && Number(evidenceItem?.version) === 0 - && (evidenceType !== 'SEAL' || evidenceItem?.sealNo === `SEAL-${suffix}`), - `mainStatus=${evidenceData?.mainStatus || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS) + && (evidenceType !== 'SEAL' || evidenceItem?.sealNo === sealNo), + `taskId=${id(evidenceData?.taskId) || 'missing'}, containerBookingId=${id(evidenceData?.containerBookingId) || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS) if (!evidenceItem?.id) return evidenceItems.push(evidenceItem) if (evidenceType === 'PHOTO_STEP_1') { - const replay = await bindEvidence(context, base, tenantId, evidenceType, fileId, suffix) - const replayData = context.apiSuccess(MODULE, '装柜证据绑定同键同参幂等重放', replay, '', OPTIONS) - const replayItems = replayData?.evidences?.evidences?.filter?.((item) => item.evidenceType === evidenceType) || [] - context.assert(MODULE, '证据绑定重放返回同一记录且不重复追加', replay, - replayItems.length === 1 && id(replayItems[0]?.id) === id(evidenceItem.id), - `evidenceId=${id(replayItems[0]?.id) || 'missing'}, count=${replayItems.length}`, OPTIONS) - const conflict = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: 0, evidenceType, fileId, - fileName: 'client-must-not-override.png', sealNo: null, remark: `${context.marker}-different`, - }, { 'Idempotency-Key': `${context.marker}-p3-evidence-${evidenceType}`.slice(0, 128) }) - context.addStep(MODULE, '装柜证据绑定同键异参返回冲突', conflict, + const replay = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0, + evidenceType) + const replayData = context.apiSuccess(MODULE, '装柜证据 task 上传同键同载荷重放', replay, '', OPTIONS) + const replayItems = replayData?.evidences?.filter?.((item) => item.evidenceType === evidenceType) || [] + context.assert(MODULE, '证据 task 上传重放返回同一记录且不重复追加', replay, + replayItems.length === 1 && id(replayItems[0]?.evidenceId) === id(evidenceItem.id), + `evidenceId=${id(replayItems[0]?.evidenceId) || 'missing'}, count=${replayItems.length}`, OPTIONS) + const conflict = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0, + evidenceType, { remark: `${context.marker}-different` }) + context.addStep(MODULE, '装柜证据 task 上传同键异载荷返回冲突', conflict, conflict.http === 200 && codeOf(conflict) === 'A0409' && Boolean(conflict.requestId) && Boolean(conflict.traceId), `HTTP=${conflict.http}, code=${codeOf(conflict)}, requestId=${conflict.requestId || 'missing'}, traceId=${conflict.traceId || 'missing'}`, OPTIONS) } 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, download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0, `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 sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL') - const appendedUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_1') - const appendedFile = context.apiSuccess(MODULE, '追加同类型第二张装柜证据文件', appendedUpload, '', OPTIONS) - const appendedFileId = id(appendedFile?.fileId) - const appended = appendedFileId - ? await bindEvidence(context, base, tenantId, 'PHOTO_STEP_1', appendedFileId, suffix, 'PHOTO_STEP_1_APPEND') - : appendedUpload - const appendedData = context.apiSuccess(MODULE, '追加同类型第二张装柜证据', appended, '', OPTIONS) - const appendedItems = appendedData?.evidences?.evidences?.filter?.((item) => item.evidenceType === 'PHOTO_STEP_1') || [] - context.assert(MODULE, '同类型装柜证据支持多图追加且不替换历史', appended, + const appended = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_1', + Number(firstPhoto?.version), 'PHOTO_STEP_1_APPEND', { fileSuffix: 'PHOTO_STEP_1_APPEND' }) + const appendedData = context.apiSuccess(MODULE, '追加同类型第二张 task 装柜证据', appended, '', OPTIONS) + const appendedItems = appendedData?.evidences?.filter?.((item) => item.evidenceType === 'PHOTO_STEP_1') || [] + const appendedActive = normalizedEvidence(appendedItems.find((item) => item.status !== 'VOID')) + const appendedFileId = id(appendedActive?.fileId) + context.assert(MODULE, '同类型装柜证据追加保留历史并唯一激活新图', appended, Boolean(appendedFileId) && appendedItems.length >= 2 - && appendedItems.some((item) => id(item.fileId) === id(firstPhoto?.fileId)) - && appendedItems.some((item) => id(item.fileId) === appendedFileId), + && appendedItems.some((item) => id(item.fileId) === id(firstPhoto?.fileId) && item.status === 'VOID') + && id(appendedActive?.fileId) === appendedFileId, `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') - context.addStep(MODULE, '不同证据类型的真实 fileId 不可交叉绑定', wrongType, failure(wrongType), + const wrongType = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_9', 0, + 'wrong-type', { fileSuffix: 'wrong-type' }) + context.addStep(MODULE, '不在白名单的证据类型不可上传绑定', wrongType, failure(wrongType), `HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS) - const forged = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: Number(sealEvidence?.version), evidenceType: 'SEAL', fileId: '999999999999999999', - fileName: `${context.marker}-forged.png`, sealNo: `FORGED-${suffix}`, remark: context.marker, - }, { 'Idempotency-Key': `${context.marker}-p3-forged-evidence`.slice(0, 128) }) - context.addStep(MODULE, '伪造装柜证据 fileId 归属校验拒绝', forged, failure(forged), + const forged = await context.request('GET', + `${evidencePath}/999999999999999999/content?warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '伪造装柜 evidenceId 无法读取任务证据内容', forged, + (forged.http === 200 && codeOf(forged) === 'A0404') || forged.http === 404, `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) - const missingSealText = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: Number(sealEvidence?.version), evidenceType: 'SEAL', fileId: id(sealEvidence?.fileId), - fileName: `${context.marker}-missing-seal-text.png`, sealNo: ' ', remark: context.marker, - }, { 'Idempotency-Key': `${context.marker}-p3-missing-seal-text`.slice(0, 128) }) + const beforeMissingSealResponse = await context.request('GET', + `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`) + const beforeMissingSeal = beforeMissingSealResponse.body?.data?.evidences || [] + 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), `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 - ? await request(context, 'POST', `${base}/loading/evidence/${encodeURIComponent(id(voidTarget.id))}/void?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: Number(voidTarget.version), reason: `${context.marker}-wrong-photo` }, - { 'Idempotency-Key': `${context.marker}-p3-evidence-void`.slice(0, 128) }) + ? await request(context, 'POST', `${evidencePath}/${encodeURIComponent(id(voidTarget.id))}/void`, + { warehouseId, expectedVersion: Number(voidTarget.version), reason: `${context.marker}-wrong-photo` }) : appended const voidData = context.apiSuccess(MODULE, '作废错误装柜证据并保留审计', voidResponse, '', OPTIONS) context.assert(MODULE, '错图作废后原记录保留且不再计入有效证据', voidResponse, - Boolean(voidTarget) && voidData?.evidences?.evidences?.some?.((item) => id(item.id) === id(voidTarget.id) - && (item.status === 'VOID' || item.valid === false) && item.voidReason === `${context.marker}-wrong-photo`), + Boolean(voidTarget) && voidData?.evidences?.some?.((item) => id(item.evidenceId) === id(voidTarget.id) + && item.status === 'VOID' && Boolean(item.updatedAt)), `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 missingTypePath = `${base}/loading/evidence/${encodeURIComponent(id(missingTypeTarget?.id))}/void?tenantId=${encodeURIComponent(tenantId)}` + const missingTypePath = `${evidencePath}/${encodeURIComponent(id(missingTypeTarget?.id))}/void` const staleMissingTypeVoid = missingTypeTarget ? await request(context, 'POST', missingTypePath, - { expectedVersion: Number(missingTypeTarget.version) + 1, reason: `${context.marker}-stale-version` }, - { 'Idempotency-Key': `${context.marker}-p3-evidence-void-stale`.slice(0, 128) }) + { warehouseId, expectedVersion: Number(missingTypeTarget.version) + 1, reason: `${context.marker}-stale-version` }) : voidResponse context.addStep(MODULE, '装柜证据作废旧版本返回冲突', staleMissingTypeVoid, Boolean(missingTypeTarget) && staleMissingTypeVoid.http === 200 && codeOf(staleMissingTypeVoid) === 'A0409', `HTTP=${staleMissingTypeVoid.http}, code=${codeOf(staleMissingTypeVoid)}`, OPTIONS) - const missingTypeVoidKey = `${context.marker}-p3-evidence-void-missing-type`.slice(0, 128) 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 - ? await request(context, 'POST', missingTypePath, missingTypeVoidBody, - { 'Idempotency-Key': missingTypeVoidKey }) + ? await request(context, 'POST', missingTypePath, missingTypeVoidBody) : voidResponse const missingTypeVoidData = context.apiSuccess(MODULE, '临时作废唯一 PHOTO_STEP_4 证据', missingTypeVoid, '', OPTIONS) context.assert(MODULE, '临时作废后 PHOTO_STEP_4 不再计入有效证据', missingTypeVoid, - Boolean(missingTypeTarget) && missingTypeVoidData?.evidences?.evidences?.some?.((item) => - id(item.id) === id(missingTypeTarget.id) && item.status === 'VOID'), + Boolean(missingTypeTarget) && missingTypeVoidData?.evidences?.some?.((item) => + id(item.evidenceId) === id(missingTypeTarget.id) && item.status === 'VOID'), `evidenceId=${id(missingTypeTarget?.id) || 'missing'}`, OPTIONS) const missingTypeVoidReplay = missingTypeTarget - ? await request(context, 'POST', missingTypePath, missingTypeVoidBody, - { 'Idempotency-Key': missingTypeVoidKey }) + ? await request(context, 'POST', missingTypePath, missingTypeVoidBody) : voidResponse - context.assert(MODULE, '证据作废同键同参重放保持同一 VOID 结果', missingTypeVoidReplay, - missingTypeVoidReplay.http === 200 && codeOf(missingTypeVoidReplay) === '200' - && missingTypeVoidReplay.body?.data?.evidences?.evidences?.some?.((item) => - id(item.id) === id(missingTypeTarget?.id) && item.status === 'VOID'), + context.assert(MODULE, '已作废 task 证据重复作废返回冲突', missingTypeVoidReplay, + missingTypeVoidReplay.http === 200 && codeOf(missingTypeVoidReplay) === 'A0409', `HTTP=${missingTypeVoidReplay.http}, code=${codeOf(missingTypeVoidReplay)}`, OPTIONS) const missingTypeVoidConflict = missingTypeTarget ? await request(context, 'POST', missingTypePath, - { ...missingTypeVoidBody, reason: `${context.marker}-different-void-reason` }, - { 'Idempotency-Key': missingTypeVoidKey }) + { ...missingTypeVoidBody, reason: `${context.marker}-different-void-reason` }) : voidResponse - context.addStep(MODULE, '证据作废同键异参返回冲突', missingTypeVoidConflict, + context.addStep(MODULE, '已作废 task 证据异参重试仍返回冲突', missingTypeVoidConflict, missingTypeVoidConflict.http === 200 && codeOf(missingTypeVoidConflict) === 'A0409', `HTTP=${missingTypeVoidConflict.http}, code=${codeOf(missingTypeVoidConflict)}`, OPTIONS) const duplicateVoid = missingTypeTarget - ? await request(context, 'POST', missingTypePath, missingTypeVoidBody, - { 'Idempotency-Key': `${context.marker}-p3-evidence-void-duplicate`.slice(0, 128) }) + ? await request(context, 'POST', missingTypePath, missingTypeVoidBody) : voidResponse context.addStep(MODULE, '已作废证据使用新请求重复作废返回冲突', duplicateVoid, duplicateVoid.http === 200 && codeOf(duplicateVoid) === 'A0409', @@ -1046,51 +1043,53 @@ export async function run(context) { context.addStep(MODULE, '作废唯一证据类型后不提前占用异步完成命令', null, true, 'PHOTO_STEP_4 已真实作废并回读;补传恢复后才允许最终阶段提交唯一完成命令', OPTIONS) - const replacementUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_4') - const replacementFile = context.apiSuccess(MODULE, '补传缺失 PHOTO_STEP_4 真实文件', replacementUpload, '', OPTIONS) - const replacementFileId = id(replacementFile?.fileId) - const replacementBind = replacementFileId - ? await bindEvidence(context, base, tenantId, 'PHOTO_STEP_4', replacementFileId, suffix, 'PHOTO_STEP_4_RESTORE') - : replacementUpload - const replacementData = context.apiSuccess(MODULE, '补传恢复 PHOTO_STEP_4 有效证据', replacementBind, '', OPTIONS) - context.assert(MODULE, '补传后五类有效证据恢复且旧 VOID 记录保留', replacementBind, + const replacementUpload = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_4', 0, + 'PHOTO_STEP_4_RESTORE', { fileSuffix: 'PHOTO_STEP_4_RESTORE' }) + const replacementData = context.apiSuccess(MODULE, '补传恢复 PHOTO_STEP_4 有效证据', replacementUpload, '', OPTIONS) + const replacementItem = normalizedEvidence(replacementData?.evidences?.find?.((item) => + item.evidenceType === 'PHOTO_STEP_4' && item.status !== 'VOID')) + const replacementFileId = id(replacementItem?.fileId) + context.assert(MODULE, '补传后五类有效证据恢复且旧 VOID 记录保留', replacementUpload, 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') - && replacementData?.evidences?.evidences?.some?.((item) => id(item.id) === id(missingTypeTarget?.id) + && replacementData?.evidences?.some?.((item) => id(item.evidenceId) === id(missingTypeTarget?.id) && item.status === 'VOID'), `replacementFileId=${replacementFileId || 'missing'}`, OPTIONS) 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}` }) : merchantLogin context.addStep(MODULE, '客户身份无权访问平台装柜证据', customerEvidence, customerEvidence.http === 403 && codeOf(customerEvidence) === 'A0403' && Boolean(customerEvidence.requestId) && Boolean(customerEvidence.traceId), `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)}`) - context.addStep(MODULE, '错误客户作用域不可读取本轮装柜证据', wrongTenantEvidence, - wrongTenantEvidence.http === 200 && codeOf(wrongTenantEvidence) === 'A0404', - `HTTP=${wrongTenantEvidence.http}, code=${codeOf(wrongTenantEvidence)}`, OPTIONS) - const wrongInboundEvidence = await context.request('GET', - `/api/platform/v1/inbounds/999999999999999999/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) - context.addStep(MODULE, '错误入库单不可读取本轮装柜证据', wrongInboundEvidence, - wrongInboundEvidence.http === 200 && codeOf(wrongInboundEvidence) === 'A0404', - `HTTP=${wrongInboundEvidence.http}, code=${codeOf(wrongInboundEvidence)}`, OPTIONS) + const wrongWarehouseEvidence = await context.request('GET', + `${evidencePath}?warehouseId=${encodeURIComponent(Number(warehouseId) + 1)}`) + context.addStep(MODULE, '错误仓库作用域不可读取本轮 task 装柜证据', wrongWarehouseEvidence, + wrongWarehouseEvidence.http === 403 && codeOf(wrongWarehouseEvidence) === 'A0403', + `HTTP=${wrongWarehouseEvidence.http}, code=${codeOf(wrongWarehouseEvidence)}`, OPTIONS) + const wrongTaskEvidence = await context.request('GET', + `${pdaBase}/tasks/999999999999999999/evidence?warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '错误 taskId 不可读取本轮装柜证据', wrongTaskEvidence, + wrongTaskEvidence.http === 200 && codeOf(wrongTaskEvidence) === 'A0404', + `HTTP=${wrongTaskEvidence.http}, code=${codeOf(wrongTaskEvidence)}`, OPTIONS) - const postLoadedUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_2') - const postLoadedFile = context.apiSuccess(MODULE, '预置 LOADED 后新增证据边界文件', postLoadedUpload, '', OPTIONS) - const postLoadedFileId = id(postLoadedFile?.fileId) - - const evidenceList = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) + const evidenceList = await context.request('GET', + `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`) const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS) + const activeEvidenceList = evidenceListData?.evidences?.filter?.((item) => item.status !== 'VOID') || [] context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList, - evidenceListData?.evidences?.length >= evidenceTypes.length - && evidenceTypes.every((type) => evidenceListData.evidences.some((item) => item.evidenceType === type)) - && evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`), + id(evidenceListData?.taskId) === canonicalTaskId + && id(evidenceListData?.containerBookingId) === containerBookingId + && 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) + const immutablePhoto = restoredPhoto + const postLoadedFileId = null + const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental const incrementalInboundId = id(incrementalFixture?.inboundId) let skipBatchVerified = false @@ -1242,7 +1241,8 @@ export async function run(context) { const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS) const incrementalTaskItem = incrementedTask?.items?.find?.((item) => 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) context.assert(MODULE, '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', incrementResponse, id(incrementedPlan?.id) === loadingPlanId && incrementedPlan?.status === 'LOADING' @@ -1418,7 +1418,7 @@ export async function run(context) { evidenceIds: evidenceItems.map((item) => id(item.id)), voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`, missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId, - immutableEvidenceId: id(firstPhoto?.id), immutableEvidenceVersion: Number(firstPhoto?.version), + immutableEvidenceId: id(immutablePhoto?.id), immutableEvidenceVersion: Number(immutablePhoto?.version), postLoadedFileId, oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 4ac14ceee..8573d0854 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -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', () => { const loading = source('./phase3-loading.mjs') assertOrder(loading, [ - '装柜证据绑定同键同参幂等重放', - '装柜证据绑定同键异参返回冲突', + '装柜证据 task 上传同键同载荷重放', + '装柜证据 task 上传同键异载荷返回冲突', '装柜证据作废旧版本返回冲突', '临时作废唯一 PHOTO_STEP_4 证据', - '证据作废同键同参重放保持同一 VOID 结果', - '证据作废同键异参返回冲突', + '已作废 task 证据重复作废返回冲突', + '已作废 task 证据异参重试仍返回冲突', '已作废证据使用新请求重复作废返回冲突', '作废唯一证据类型后不提前占用异步完成命令', '补传恢复 PHOTO_STEP_4 有效证据', '客户身份无权访问平台装柜证据', - '错误客户作用域不可读取本轮装柜证据', - '错误入库单不可读取本轮装柜证据', + '错误仓库作用域不可读取本轮 task 装柜证据', + '错误 taskId 不可读取本轮装柜证据', ]) assert.match(loading, /missingTypeVoidedEvidenceId/) assert.match(loading, /replacementEvidenceFileId/)