196 lines
15 KiB
JavaScript
196 lines
15 KiB
JavaScript
const MODULE = 'phase3-loading'
|
||
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
|
||
|
||
function id(value) { return value == null ? '' : String(value) }
|
||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||
function failure(response) {
|
||
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|
||
|| (response?.http === 200 && codeOf(response) !== '200')
|
||
}
|
||
|
||
async function request(context, method, path, body, headers = {}) {
|
||
try {
|
||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||
method,
|
||
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
|
||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
||
})
|
||
const raw = await response.text(); let payload = null
|
||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
|
||
requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
||
}
|
||
|
||
async function uploadEvidence(context, tenantId, inboundId) {
|
||
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=SEAL`
|
||
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`)
|
||
try {
|
||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
|
||
body: form, 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: 'POST', 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: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||
}
|
||
}
|
||
|
||
function fixture(context) {
|
||
return context.createdResources?.orchestrationFixtures?.inboundMeasurement
|
||
|| context.createdResources?.inboundMeasurement
|
||
|| context.createdResources?.orders
|
||
}
|
||
|
||
export async function run(context) {
|
||
const source = fixture(context)
|
||
const tenantId = id(source?.tenantId)
|
||
const inboundId = id(source?.inboundId)
|
||
if (!tenantId || !inboundId) {
|
||
context.addStep(MODULE, '取得本轮 WAIT_SPLIT 隔离入库单', null, false,
|
||
'未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单', OPTIONS)
|
||
return
|
||
}
|
||
|
||
const detailPath = `/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`
|
||
const initialResponse = await context.request('GET', detailPath)
|
||
const initial = context.apiSuccess(MODULE, '读取三期隔离入库单状态与版本', initialResponse, `tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
|
||
const initialStatus = initial?.summary?.processStatus
|
||
const initialVersion = Number(initial?.summary?.version)
|
||
context.assert(MODULE, '隔离入库单处于智能分柜门禁状态', initialResponse,
|
||
['INBOUNDED', 'WAIT_SPLIT'].includes(initialStatus) && Number.isInteger(initialVersion),
|
||
`status=${initialStatus || 'missing'}, version=${initial?.summary?.version ?? 'missing'}`, OPTIONS)
|
||
if (!['INBOUNDED', 'WAIT_SPLIT'].includes(initialStatus) || !Number.isInteger(initialVersion)) return
|
||
|
||
const base = `/api/platform/v1/inbounds/${inboundId}`
|
||
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
||
const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
|
||
expectedVersion: 0,
|
||
items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }],
|
||
remark: context.marker,
|
||
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
|
||
const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS)
|
||
const planId = id(smartData?.preloadPlan?.id)
|
||
context.assert(MODULE, '智能分柜预装计划 PENDING、版本和明细正确', smart,
|
||
smartData?.mainStatus === 'INBOUNDED' && Boolean(planId) && smartData?.preloadPlan?.status === 'PENDING'
|
||
&& Number(smartData?.preloadPlan?.version) === 0 && smartData?.preloadPlan?.items?.length === 1,
|
||
`mainStatus=${smartData?.mainStatus || 'missing'}, planId=${planId || 'missing'}, status=${smartData?.preloadPlan?.status || 'missing'}`, OPTIONS)
|
||
if (!planId) return
|
||
|
||
const replay = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
|
||
expectedVersion: 0, items: [], remark: 'replay must not replace',
|
||
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
|
||
context.assert(MODULE, '智能分柜幂等重放返回同一预装计划', replay,
|
||
codeOf(replay) === '200' && id(replay.body?.data?.preloadPlan?.id) === planId
|
||
&& replay.body?.data?.preloadPlan?.items?.length === 1,
|
||
`HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS)
|
||
|
||
const confirming = await request(context, 'POST', `${base}/preload/confirming?tenantId=${encodeURIComponent(tenantId)}`,
|
||
{ expectedVersion: Number(smartData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
|
||
const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
|
||
context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming,
|
||
confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 1,
|
||
`status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
||
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
|
||
|
||
const confirm = await request(context, 'POST', `${base}/preload/confirm?tenantId=${encodeURIComponent(tenantId)}`,
|
||
{ expectedVersion: Number(confirmingData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirm`.slice(0, 128) })
|
||
const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS)
|
||
context.assert(MODULE, '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
|
||
confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED'
|
||
&& Number(confirmed?.preloadPlan?.version) === 2,
|
||
`mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
||
if (confirmed?.mainStatus !== 'LOADING') return
|
||
|
||
const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
|
||
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
|
||
context.assert(MODULE, '预装清单归属、明细和 marker 持久化', preload,
|
||
id(preloadData?.id) === planId && id(preloadData?.inboundId) === inboundId && id(preloadData?.tenantId) === tenantId
|
||
&& preloadData?.items?.[0]?.containerCode === `P3-${suffix}` && preloadData?.remark === context.marker,
|
||
`planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS)
|
||
|
||
const loadingResponse = await context.request('GET', detailPath)
|
||
const loading = context.apiSuccess(MODULE, '读取装柜中入库单版本', loadingResponse, '', OPTIONS)
|
||
const loadingVersion = Number(loading?.summary?.version)
|
||
context.assert(MODULE, '入库单已进入 LOADING 且版本有效', loadingResponse,
|
||
loading?.summary?.processStatus === 'LOADING' && Number.isInteger(loadingVersion),
|
||
`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 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 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 forged = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
|
||
expectedVersion: Number(evidenceItem.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}`),
|
||
`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)),
|
||
`mainStatus=${completed?.mainStatus || 'missing'}, evidences=${completed?.evidences?.evidences?.length ?? 'n/a'}`, OPTIONS)
|
||
|
||
const finalResponse = await context.request('GET', detailPath)
|
||
const finalData = context.apiSuccess(MODULE, '回查三期装柜完成终态', finalResponse, '', OPTIONS)
|
||
context.assert(MODULE, '入库单终态 LOADED、版本递增且审计历史存在', finalResponse,
|
||
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === 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.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')
|
||
}
|