149 lines
8.8 KiB
JavaScript
149 lines
8.8 KiB
JavaScript
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}-four-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)])),
|
|
}
|
|
}
|
|
}
|