const MODULE = 'phase3-container-documents' const OPTIONS = { scenario: 'phase3-container-operational-documents', deploymentBlocker: true } const CONTAINERS = '/api/platform/v1/containers' export const DOCUMENT_TYPES = Object.freeze([ 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', ]) function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function success(response) { return response?.http === 200 && codeOf(response) === '200' } function conflict(response) { return response?.http === 200 && codeOf(response) === 'A0409' } function traced(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) } function key(context, label) { return `${context.marker}-operational-documents-${label}`.slice(0, 128) } function validDocument(document, type, completed) { return Boolean(document?.id) && Boolean(document?.fileId) && document?.documentType === type && /^[0-9a-f]{64}$/i.test(String(document?.fileHash || '')) && Number.isInteger(Number(document?.version)) && (completed ? Boolean(document?.completedAt) : document?.completedAt == null) } function documentForm(context, type, expectedVersion, operationReason, altered = false) { const form = new FormData() const suffix = altered ? '-altered' : '' form.append('file', new Blob([`${context.marker}-${type}${suffix}`], { type: 'text/plain' }), `${type.toLowerCase()}${suffix}.txt`) form.append('expectedVersion', String(expectedVersion)) form.append('operationReason', operationReason) return form } async function upload(context, path, type, expectedVersion, operationReason, idempotencyKey, altered = false) { return context.requestMultipart('POST', path, documentForm(context, type, expectedVersion, operationReason, altered), context.token, { 'Idempotency-Key': idempotencyKey }) } async function uploadAndVerify(context, { type, uploadPath, readPath, completePath = null }) { const operationReason = `${context.marker} 货柜作业资料全链验收` const uploadKey = key(context, `${type}-upload`) const response = await upload(context, uploadPath, type, 0, operationReason, uploadKey) let document = context.apiSuccess(MODULE, `上传${type}`, response, `documentType=${type}`, OPTIONS) context.assert(MODULE, `${type}上传返回归属、文件哈希和草稿版本`, response, validDocument(document, type, false) && traced(response), `documentId=${id(document?.id) || 'missing'}, fileId=${id(document?.fileId) || 'missing'}, version=${document?.version ?? 'missing'}`, OPTIONS) if (!validDocument(document, type, false)) return null const replay = await upload(context, uploadPath, type, 0, operationReason, uploadKey) const replayed = context.apiSuccess(MODULE, `${type}同键同载荷重放`, replay, '', OPTIONS) context.assert(MODULE, `${type}上传重放不新增资料或递增版本`, replay, id(replayed?.id) === id(document.id) && id(replayed?.fileId) === id(document.fileId) && Number(replayed?.version) === Number(document.version), `documentId=${id(replayed?.id) || 'missing'}, version=${replayed?.version ?? 'missing'}`, OPTIONS) const hashConflict = await upload(context, uploadPath, type, 0, operationReason, uploadKey, true) context.addStep(MODULE, `${type}同键异文件返回A0409`, hashConflict, conflict(hashConflict) && traced(hashConflict), `HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}, requestId=${hashConflict.requestId || 'missing'}, traceId=${hashConflict.traceId || 'missing'}`, OPTIONS) if (completePath) { const completeKey = key(context, `${type}-complete`) const completeBody = { expectedVersion: Number(document.version), operationReason } const completeResponse = await context.request('POST', completePath, completeBody, context.token, { 'Idempotency-Key': completeKey }) document = context.apiSuccess(MODULE, `完成${type}`, completeResponse, '', OPTIONS) context.assert(MODULE, `${type}完成后锁定且版本递增`, completeResponse, validDocument(document, type, true) && Number(document.version) === Number(completeBody.expectedVersion) + 1, `completedAt=${document?.completedAt || 'missing'}, version=${document?.version ?? 'missing'}`, OPTIONS) const completeReplay = await context.request('POST', completePath, completeBody, context.token, { 'Idempotency-Key': completeKey }) const replayedComplete = context.apiSuccess(MODULE, `${type}完成同键重放`, completeReplay, '', OPTIONS) context.assert(MODULE, `${type}完成重放保持同一终态`, completeReplay, id(replayedComplete?.id) === id(document.id) && Number(replayedComplete?.version) === Number(document.version) && replayedComplete?.completedAt === document.completedAt, `documentId=${id(replayedComplete?.id) || 'missing'}, version=${replayedComplete?.version ?? 'missing'}`, OPTIONS) const completeConflict = await context.request('POST', completePath, { ...completeBody, operationReason: `${operationReason}-altered` }, context.token, { 'Idempotency-Key': completeKey }) context.addStep(MODULE, `${type}完成同键异载荷返回A0409`, completeConflict, conflict(completeConflict) && traced(completeConflict), `HTTP=${completeConflict.http}, code=${codeOf(completeConflict)}`, OPTIONS) } const readResponse = await context.request('GET', readPath) const stored = context.apiSuccess(MODULE, `回读${type}`, readResponse, '', OPTIONS) const expectedCompleted = Boolean(completePath) context.assert(MODULE, `${type}负向请求后持久事实零漂移`, readResponse, validDocument(stored, type, expectedCompleted) && id(stored.id) === id(document.id) && id(stored.fileId) === id(document.fileId) && Number(stored.version) === Number(document.version), `documentId=${id(stored?.id) || 'missing'}, version=${stored?.version ?? 'missing'}`, OPTIONS) return stored } export async function run(context) { const fixture = context.createdResources?.phase3Loading const outbound = context.createdResources?.phase3OutboundDispatch const containerId = id(fixture?.containerBookingId) const planId = id(fixture?.loadingPlanId) const warehouseId = id(fixture?.warehouseId) if (fixture?.marker !== context.marker || fixture?.finalizeSucceeded !== true || fixture?.finalStatus !== 'LOADED' || !containerId || !planId || !warehouseId) { context.addStep(MODULE, '取得本轮LOADED柜次作业资料前置', null, false, `marker=${fixture?.marker || 'missing'}, status=${fixture?.finalStatus || 'missing'}, containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS) return } if (outbound?.marker !== context.marker || id(outbound?.containerBookingId) !== containerId || outbound?.finalStatus !== 'WAITING_FOR_LOADING' || outbound?.finalInboundStatus !== 'LOADED') { context.addStep(MODULE, '取得本轮已确认国内离仓货柜', null, false, `marker=${outbound?.marker || 'missing'}, status=${outbound?.finalStatus || 'missing'}, inboundStatus=${outbound?.finalInboundStatus || 'missing'}, containerBookingId=${id(outbound?.containerBookingId) || 'missing'}`, OPTIONS) return } const specs = [ { type: 'DESTINATION_CLEARANCE', uploadPath: `${CONTAINERS}/${containerId}/clearance/document/upload`, readPath: `${CONTAINERS}/${containerId}/clearance`, completePath: `${CONTAINERS}/${containerId}/clearance/complete`, }, ...['DELIVERY_ORDER'].map((type) => ({ type, uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`, readPath: `${CONTAINERS}/${containerId}/documents/${type}`, })), ] const documents = [] for (const spec of specs) documents.push(await uploadAndVerify(context, spec)) const overviewResponse = await context.request('GET', `${CONTAINERS}/${containerId}/overview`) const overview = context.apiSuccess(MODULE, '回查货柜作业资料聚合', overviewResponse, '', OPTIONS) const storedTypes = new Set((overview?.documents || []).map((item) => item?.documentType)) context.assert(MODULE, '同一LOADED柜次聚合返回作业资料且身份未漂移', overviewResponse, id(overview?.container?.id) === containerId && documents.every(Boolean) && DOCUMENT_TYPES.every((type) => storedTypes.has(type)), `containerBookingId=${containerId}, documentTypes=${[...storedTypes].join('|') || 'none'}`, OPTIONS) if (documents.every(Boolean) && DOCUMENT_TYPES.every((type) => storedTypes.has(type))) { context.createdResources.phase3ContainerDocuments = { marker: context.marker, containerBookingId: containerId, loadingPlanId: planId, warehouseId, finalStatus: 'COMPLETED', documentTypes: [...DOCUMENT_TYPES], documentIds: Object.fromEntries(documents.map((item) => [item.documentType, id(item.id)])), } } }