test(firstleg): cover phase3 outbound contracts

This commit is contained in:
2026-08-08 22:42:49 +08:00
parent 66d1aa0d3d
commit 9b88fb4b9e
12 changed files with 810 additions and 50 deletions
@@ -22,11 +22,12 @@ 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, tenantId, inboundId) {
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=SEAL`
async function uploadEvidence(context, tenantId, inboundId, evidenceType) {
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}`
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])
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-seal.png`)
const fileName = `${context.marker}-${evidenceType.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}` },
@@ -43,6 +44,17 @@ async function uploadEvidence(context, tenantId, inboundId) {
}
}
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?.inboundMeasurement
|| context.createdResources?.inboundMeasurement
@@ -124,50 +136,100 @@ export async function run(context) {
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(loadingVersion)) return
const upload = await uploadEvidence(context, tenantId, inboundId)
const uploaded = context.apiSuccess(MODULE, '上传本轮装柜铅封证据文件', upload, `fileName=${context.marker}-seal.png`, OPTIONS)
const fileId = id(uploaded?.fileId)
context.assert(MODULE, '装柜证据文件归属类型和媒体元数据正确', upload,
Boolean(fileId) && uploaded?.businessType === 'LOADING_SEAL' && uploaded?.contentType === 'image/png'
&& Number(uploaded?.sizeBytes) > 0,
`fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}`, OPTIONS)
if (!fileId) return
const blockedBeforePicking = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: loadingVersion, remark: 'must require picking' },
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-picking`.slice(0, 128) })
context.addStep(MODULE, '未完成 PDA 拣货时拒绝完成装柜', blockedBeforePicking, failure(blockedBeforePicking),
`HTTP=${blockedBeforePicking.http}, code=${codeOf(blockedBeforePicking)}`, OPTIONS)
const evidence = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0, evidenceType: 'SEAL', fileId, fileName: 'client-must-not-override.png', sealNo: `SEAL-${suffix}`, remark: context.marker,
}, { 'Idempotency-Key': `${context.marker}-p3-evidence`.slice(0, 128) })
const evidenceData = context.apiSuccess(MODULE, '写入本轮装柜铅封证据', evidence, '', OPTIONS)
const evidenceItem = evidenceData?.evidences?.evidences?.find?.((item) => item.evidenceType === 'SEAL')
context.assert(MODULE, '装柜证据类型、铅封号、marker 和版本正确', evidence,
evidenceData?.mainStatus === 'LOADING' && evidenceItem?.sealNo === `SEAL-${suffix}`
&& id(evidenceItem?.fileId) === fileId && evidenceItem?.fileName === `${context.marker}-seal.png`
&& evidenceItem?.remark === context.marker && Number(evidenceItem?.version) === 0,
`mainStatus=${evidenceData?.mainStatus || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS)
if (!evidenceItem?.id) return
const pickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-picking-start`.slice(0, 128) })
const pickingStarted = context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS)
context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart,
pickingStarted?.status === 'IN_PROGRESS' && Number(pickingStarted?.version) === 0
&& Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 0,
`status=${pickingStarted?.status || 'missing'}, version=${pickingStarted?.version ?? 'missing'}`, OPTIONS)
const download = await context.requestBinary('GET', `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`)
context.assert(MODULE, '下载本轮装柜证据文件', download,
download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0,
`bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS)
const pickingMismatch = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-picking-mismatch`.slice(0, 128) })
context.addStep(MODULE, 'PDA 拣货数量不一致时拒绝完成', pickingMismatch, failure(pickingMismatch),
`HTTP=${pickingMismatch.http}, code=${codeOf(pickingMismatch)}`, OPTIONS)
const pickingComplete = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-picking-complete`.slice(0, 128) })
const pickingCompleted = context.apiSuccess(MODULE, '完成本轮 PDA 拣货复核', pickingComplete, '', OPTIONS)
context.assert(MODULE, 'PDA 拣货任务 COMPLETED 且数量一致', pickingComplete,
pickingCompleted?.status === 'COMPLETED' && Number(pickingCompleted?.version) === 1
&& Number(pickingCompleted?.expectedCount) === 1 && Number(pickingCompleted?.pickedCount) === 1,
`status=${pickingCompleted?.status || 'missing'}, version=${pickingCompleted?.version ?? 'missing'}`, OPTIONS)
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const evidenceItems = []
for (const evidenceType of evidenceTypes) {
const upload = await uploadEvidence(context, tenantId, inboundId, 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
&& 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)
if (!evidenceItem?.id) return
evidenceItems.push(evidenceItem)
const download = await context.requestBinary('GET', `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`)
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)
if (evidenceType === 'PHOTO_STEP_4') {
const missingSeal = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: loadingVersion, remark: 'must require seal' },
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-seal`.slice(0, 128) })
context.addStep(MODULE, '缺铅封照片和锁码时拒绝完成装柜', missingSeal, failure(missingSeal),
`HTTP=${missingSeal.http}, code=${codeOf(missingSeal)}`, OPTIONS)
}
}
const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1')
const sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL')
const wrongType = await bindEvidence(context, base, tenantId, 'PHOTO_STEP_2', id(firstPhoto?.fileId), suffix, 'wrong-type')
context.addStep(MODULE, '不同证据类型的真实 fileId 不可交叉绑定', wrongType, failure(wrongType),
`HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS)
const forged = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: Number(evidenceItem.version), evidenceType: 'SEAL', fileId: '999999999999999999',
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),
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
const evidenceList = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
const evidenceListData = context.apiSuccess(MODULE, '回查装柜证据列表', evidenceList, '', OPTIONS)
context.assert(MODULE, '装柜证据列表只读回显本轮铅封证据', evidenceList,
evidenceListData?.evidences?.some?.((item) => id(item.id) === id(evidenceItem.id) && item.sealNo === `SEAL-${suffix}`),
const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS)
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}`),
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
const complete = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: loadingVersion, remark: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-complete`.slice(0, 128) })
const completed = context.apiSuccess(MODULE, '确认本轮入库单装柜完成', complete, '', OPTIONS)
context.assert(MODULE, '装柜完成推进主状态 LOADED 且保留证据', complete,
completed?.mainStatus === 'LOADED' && completed?.evidences?.evidences?.some?.((item) => id(item.id) === id(evidenceItem.id)),
context.assert(MODULE, '装柜完成推进主状态 LOADED 且保留五类证据', complete,
completed?.mainStatus === 'LOADED' && completed?.evidences?.evidences?.length === evidenceTypes.length,
`mainStatus=${completed?.mainStatus || 'missing'}, evidences=${completed?.evidences?.evidences?.length ?? 'n/a'}`, OPTIONS)
const finalResponse = await context.request('GET', detailPath)
@@ -176,20 +238,9 @@ export async function run(context) {
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === loadingVersion + 1
&& finalData?.statusHistory?.some?.((item) => item.action === 'COMPLETE_LOADING' && item.operatorName),
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
context.createdResources.phase3Loading = { tenantId, inboundId, preloadPlanId: planId, evidenceId: id(evidenceItem.id), marker: context.marker, finalStatus: 'LOADED' }
context.createdResources.phase3Loading = {
tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)),
pickingTaskId: id(pickingCompleted?.id), marker: context.marker, finalStatus: 'LOADED',
}
context.addCoverageGap(MODULE, 'outbound-aggregate', '出仓单/柜次主聚合与整体时间线',
'当前公开 API 只有入库单级分柜/预装/装柜状态和独立货柜、订舱、拖车对象;没有出仓单主资源、子流程关联或整体时间线端点', 'contract-gap')
context.addCoverageGap(MODULE, 'customs-collaboration', '报关资料、补件、代理提交与回执闭环',
'当前 Firstleg 无报关清单版本、客户文件版本、审核退回、代理提交批次、回执上传/下载的公开 Controller 契约', 'not-implemented')
context.addCoverageGap(MODULE, 'port-schedule-query', '港务/船期独立查询与时效性',
'订舱和货柜仅保存 ETD/ETA、船名航次等快照字段;无港务或船期数据源、更新时间、过期标识和独立查询端点', 'contract-gap')
context.addCoverageGap(MODULE, 'domestic-dispatch-gate', '国内出仓统一门禁与 WAITING_FOR_LOADING 终态',
'当前公开状态链仅能到 LOADED/DELIVERED 或装柜计划 COMPLETED;无聚合订舱、拖车、拣货复核、柜封证据和报关条件的 check-gate/confirm-dispatch/reverse-dispatch API,也无 WAITING_FOR_LOADING 状态', 'not-implemented')
context.addCoverageGap(MODULE, 'parallel-loading-models', '入库单级预装与柜次级智能装柜未串联',
'fl_preload_plan 按入库单推进 LOADING/LOADED,fl_loading_plan 按柜次推进 EDITING/LOADING/COMPLETED;两套公开 API 不会互相创建、绑定或校验终态', 'contract-gap')
context.addCoverageGap(MODULE, 'pda-picking-convergence', 'PDA 柜次拣货与入库单汇总拣货未收敛',
'柜次级 /first-leg/pda-loading 和入库单级 /inbounds/{id}/loading/picking 是两套独立任务;当前无领取、作业员分配、复核和统一完成门禁', 'contract-gap')
context.addCoverageGap(MODULE, 'loading-evidence-gate', '四步照片与铅封证据完整性门禁',
'本轮真实 HTTP 仅上传并绑定 SEAL 文件即可成功进入 LOADED;服务端未要求 PHOTO_STEP_1..4 齐全,也未将柜次级 PDA 复核结果作为前置', 'contract-gap')
}