test(acceptance): align retained state fixtures

This commit is contained in:
2026-09-02 13:59:05 +08:00
parent f4c139815c
commit 5d098c9762
4 changed files with 92 additions and 75 deletions
@@ -37,7 +37,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(source, /linked\?\.cabinetNo !== simulation\.cabinetNo/)
assert.match(source, /iso6346ContainerNumber/)
assert.match(source, /shippingCompanyId: id\(agent\.id\), shippingCompanyName: agent\.name/)
assert.match(source, /shippingCompanyId: id\(carrier\.id\), shippingCompanyName: carrier\.name/)
assert.match(source, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/)
assert.match(source, /billOfLadingNo: `BL-\$\{value\}`/)
const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete'))
@@ -57,7 +57,7 @@ test('fails closed when invoked outside the manual matrix wrapper', () => {
assert.match(source, /missing=manualStateMatrix\.pages/)
})
test('covers simplified clearance and customs actions through public multipart upload and complete APIs', () => {
test('covers clearance through its public API and keeps customs on the dedicated customs-v1 flow', () => {
assert.match(source, /new FormData\(\)/)
assert.match(source, /form\.append\('file'/)
assert.match(source, /form\.append\('expectedVersion'/)
@@ -65,10 +65,9 @@ test('covers simplified clearance and customs actions through public multipart u
assert.match(source, /'Idempotency-Key'/)
assert.match(source, /clearancePath\}\/document\/upload/)
assert.match(source, /clearancePath\}\/complete/)
assert.match(source, /customsPath\}\/document\/upload\?\$\{warehouse\}/)
assert.match(source, /customsPath\}\/complete\?\$\{warehouse\}/)
assert.match(source, /matrix\.pages\.containerLoading\.actions/)
assert.match(source, /matrix\.pages\.loadingLists\.actions/)
assert.doesNotMatch(source, /CUSTOMS_DECLARATION|loading-customs|customsPath/)
assert.match(source, /tasks\/\$\{taskId\}\/evidence/)
assert.doesNotMatch(source, /internal\/file|business-files/)
assert.doesNotMatch(source, /\{\s*fileId\s*:/)
})
@@ -49,31 +49,31 @@ async function uploadDocument(context, path, expectedVersion, operationReason, l
}
async function ensureCompletionEvidence(context, chain, label) {
const tenantId = chain.source.tenantId
const inboundId = chain.source.inboundId
const taskId = id(chain.task?.id)
const warehouseId = id(chain.source?.warehouseId)
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])
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
const uploadPath = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}`
const query = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType,
remark: context.marker })
if (evidenceType === 'SEAL') query.set('sealNo', `SEAL-${suffix(context, label)}`.slice(0, 64))
const uploadPath = `${PDA}/tasks/${taskId}/evidence?${query}`
const form = new FormData()
form.append('file', new Blob([png], { type: 'image/png' }), `${label}-${evidenceType}.png`)
const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, {
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
'Idempotency-Key': operationId(context, `${label}-evidence-${evidenceType}`) },
body: form, signal: AbortSignal.timeout(60_000),
})
const uploadBody = await rawUpload.json()
const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody,
requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'),
traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') }
const file = context.apiSuccess(MODULE, `${label} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS)
const fileId = id(file?.id || file?.fileId)
if (!fileId) throw new Error(`${label} ${evidenceType} 未返回文件ID`)
const bind = await post(context,
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0, evidenceType, fileId,
sealNo: evidenceType === 'SEAL' ? `SEAL-${suffix(context, label)}`.slice(0, 64) : null,
remark: context.marker,
}, `${label}-evidence-${evidenceType}`)
context.apiSuccess(MODULE, `${label} 绑定 ${evidenceType} 装柜证据`, bind, '', OPTIONS)
const evidence = context.apiSuccess(MODULE, `${label} 上传并绑定 ${evidenceType} 装柜证据`, upload, '', OPTIONS)
const active = evidence?.evidences?.find?.((item) => item.evidenceType === evidenceType
&& item.status !== 'VOID')
if (!active?.evidenceId || id(evidence?.taskId) !== taskId) {
throw new Error(`${label} ${evidenceType} 未返回任务证据ID`)
}
}
}
@@ -133,11 +133,14 @@ async function createConfirmedContainer(context, source, label) {
const agentPage = context.apiSuccess(MODULE, `${label} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS)
const agent = agentPage?.records?.find?.((item) => item?.id && item?.name)
if (!agent) throw new Error(`${label} 未取得 ACTIVE 订舱代理`)
const { ensureMatsonCarrierPartner } = await import('./carrier-partner-fixture.mjs')
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
if (!carrier?.id) throw new Error(`${label} 未取得 ACTIVE 船公司`)
const draft = {
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
channelCode: summary.channelCode, warehouseId: source.warehouseId,
billOfLadingNo: `BL-${value}`.slice(0, 128),
shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, carrierCode: 'OTHER',
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: carrier.carrierCode,
bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
@@ -161,8 +164,20 @@ async function createConfirmedContainer(context, source, label) {
{ expectedVersion: 0, data: draft }, `${label}-booking-create`)
const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS)
const bookingId = id(created?.oceanBooking?.id)
if (!bookingId) throw new Error(`${label} 创建订舱未返回订舱ID`)
const documentForm = new FormData()
documentForm.append('expectedVersion', String(Number(created?.oceanBooking?.version)))
documentForm.append('documentType', 'BOOKING_CONFIRMATION')
documentForm.append('file', new Blob([`${context.marker}-${label}-BOOKING_CONFIRMATION`],
{ type: 'text/plain' }), `booking-confirmation-${value}.txt`)
const documentResponse = await context.requestMultipart('POST',
`/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, documentForm, context.token,
{ 'Idempotency-Key': operationId(context, `${label}-booking-document`) })
const document = context.apiSuccess(MODULE, `${label} 上传订舱确认文件`, documentResponse, '', OPTIONS)
const releaseVersion = Number(document?.bookingVersion)
if (!Number.isInteger(releaseVersion)) throw new Error(`${label} 订舱确认文件未返回有效版本`)
const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker,
expectedVersion: releaseVersion, reason: context.marker,
}, `${label}-booking-release`)
const confirmed = context.apiSuccess(MODULE, `${label} 放舱订舱`, releasedResponse, '', OPTIONS)
context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse,
@@ -179,12 +194,13 @@ async function createConfirmedContainer(context, source, label) {
throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`)
}
context.apiSuccess(MODULE, `${label} 放舱后关联真实柜号`, linkedResponse, '', OPTIONS)
return containerId
return { containerId, containerNo }
}
async function createPlanTask(context, label) {
const source = await readInbound(context, await prepareInbound(context, label))
const containerId = await createConfirmedContainer(context, source, label)
const container = await createConfirmedContainer(context, source, label)
const { containerId, containerNo } = container
const candidateResponse = await context.request('GET',
`${SMART}/candidates?bookingId=${containerId}&warehouseId=${source.warehouseId}`)
const candidates = context.apiSuccess(MODULE, `${label} 查询同渠道候选货物`, candidateResponse, '', OPTIONS)
@@ -288,6 +304,15 @@ async function complete(context, chain, label) {
remark: `${context.marker} 人工完成状态样本`,
})
chain.task = context.apiSuccess(MODULE, `${label} 人工确认装柜完成`, response, '', OPTIONS)
for (let attempt = 0; attempt < 90 && chain.task?.taskStatus !== 'COMPLETED'
&& chain.task?.taskStatus !== 'COMPLETION_FAILED'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000))
const detailResponse = await context.request('GET',
`${PDA}/tasks/${chain.task.id}?warehouseId=${encodeURIComponent(chain.source.warehouseId)}`)
if (detailResponse.http === 200 && String(detailResponse?.body?.code) === '200') {
chain.task = detailResponse.body?.data
}
}
return chain
}
@@ -351,39 +376,11 @@ async function completeSimpleDocuments(context, chain, matrix) {
&& Number(clearanceCompleted?.version) === Number(clearanceDocument?.version) + 1,
`documentId=${id(clearanceCompleted?.id) || 'missing'}, completedAt=${clearanceCompleted?.completedAt || 'missing'}`, OPTIONS)
const customsPath = `${LOADING}/loading-lists/${chain.plan.id}/customs`
const warehouse = `warehouseId=${encodeURIComponent(chain.source.warehouseId)}`
const customsUpload = await uploadDocument(context, `${customsPath}/document/upload?${warehouse}`, 0,
reason, 'loading-customs')
const customsDocument = context.apiSuccess(MODULE, '装柜清单公开接口上传出口报关文档',
customsUpload, '', OPTIONS)
context.assert(MODULE, '装柜清单报关上传返回真实文件事实', customsUpload,
Boolean(customsDocument?.id) && Boolean(customsDocument?.fileId)
&& customsDocument?.documentType === 'CUSTOMS_DECLARATION'
&& /^[0-9a-f]{64}$/i.test(String(customsDocument?.fileHash || ''))
&& !customsDocument?.completedAt,
`documentId=${id(customsDocument?.id) || 'missing'}, version=${customsDocument?.version ?? 'missing'}`, OPTIONS)
if (!customsDocument?.id || !customsDocument?.fileId || !Number.isInteger(Number(customsDocument?.version))) return
const customsCompleteResponse = await post(context, `${customsPath}/complete?${warehouse}`, {
expectedVersion: Number(customsDocument?.version), operationReason: reason,
}, 'loading-customs-complete')
const customsCompleted = context.apiSuccess(MODULE, '装柜清单点击完成出口报关',
customsCompleteResponse, '', OPTIONS)
context.assert(MODULE, '装柜清单报关完成事实可回读', customsCompleteResponse,
id(customsCompleted?.id) === id(customsDocument?.id) && Boolean(customsCompleted?.completedAt)
&& Number(customsCompleted?.version) === Number(customsDocument?.version) + 1,
`documentId=${id(customsCompleted?.id) || 'missing'}, completedAt=${customsCompleted?.completedAt || 'missing'}`, OPTIONS)
matrix.pages.containerLoading.actions = { clearance: fixture(chain.containerId, 'COMPLETED',
`/container-loading/${chain.containerId}/smart-loading`, {
documentId: id(clearanceCompleted?.id), documentVersion: Number(clearanceCompleted?.version),
completedAt: clearanceCompleted?.completedAt || null,
}) }
matrix.pages.loadingLists.actions = { customsDeclaration: fixture(chain.plan.id, 'COMPLETED',
`/loading-lists?planId=${chain.plan.id}&warehouseId=${chain.source.warehouseId}`, {
warehouseId: chain.source.warehouseId, documentId: id(customsCompleted?.id),
documentVersion: Number(customsCompleted?.version), completedAt: customsCompleted?.completedAt || null,
}) }
}
export async function run(context, matrix = context.createdResources.manualStateMatrix) {
@@ -396,13 +393,13 @@ export async function run(context, matrix = context.createdResources.manualState
requiredActions: ['clearance'], fixtures: {}, actions: {} }
matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} }
matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'],
requiredActions: ['customsDeclaration'], fixtures: {}, actions: {} }
requiredActions: [], fixtures: {}, actions: {} }
matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} }
const waitingSource = await readInbound(context, await prepareInbound(context, 'WAIT'))
const waitingContainerId = await createConfirmedContainer(context, waitingSource, 'WAIT')
matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainerId, 'WAIT_LOADING',
`/container-loading?containerId=${waitingContainerId}`)
const waitingContainer = await createConfirmedContainer(context, waitingSource, 'WAIT')
matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainer.containerId, 'WAIT_LOADING',
`/container-loading?containerId=${waitingContainer.containerId}`)
const editing = await createPlanTask(context, 'EDIT')
const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS')
@@ -39,9 +39,11 @@ async function raw(context, method, path, body, token, headers = {}) {
async function loginMerchant(context) {
const account = context.context?.merchantQualificationFixture
if (!account?.username || !account?.password) throw new Error('缺少本轮客户登录凭据')
const username = account?.username || process.env.MERCHANT_TEST_USERNAME || ''
const password = account?.password || process.env.MERCHANT_TEST_PASSWORD || ''
if (!username || !password) throw new Error('缺少客户登录凭据')
const response = await context.request('POST', '/api/auth/login', {
username: account.username, password: account.password,
username, password,
}, '')
const token = success(response) ? response.body?.data?.accessToken : null
if (!token) throw new Error(`本轮客户登录失败: requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`)
@@ -69,31 +69,28 @@ async function request(context, method, path, body, headers = {}, token = contex
}
}
async function ensureCompletionEvidence(context, tenantId, inboundId, keyBase) {
async function ensureCompletionEvidence(context, taskId, warehouseId, keyBase) {
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])
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
const uploadPath = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}`
const query = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType,
remark: context.marker })
if (evidenceType === 'SEAL') query.set('sealNo', `SEAL-${keyBase}`.slice(0, 64))
const uploadPath = `${PDA}/tasks/${taskId}/evidence?${query}`
const form = new FormData()
form.append('file', new Blob([png], { type: 'image/png' }), `${keyBase}-${evidenceType}.png`)
const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, {
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) },
body: form, signal: AbortSignal.timeout(60_000),
})
const uploadBody = await rawUpload.json()
const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody,
requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'),
traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') }
const file = context.apiSuccess(MODULE, `${keyBase} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS)
const fileId = id(file?.id || file?.fileId)
if (!fileId) return false
const bind = await request(context, 'POST',
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0, evidenceType, fileId,
sealNo: evidenceType === 'SEAL' ? `SEAL-${keyBase}`.slice(0, 64) : null,
remark: context.marker,
}, { 'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) })
const bound = context.apiSuccess(MODULE, `${keyBase} 绑定 ${evidenceType} 装柜证据`, bind, '', OPTIONS)
if (!bound) return false
const evidence = context.apiSuccess(MODULE, `${keyBase} 上传并绑定 ${evidenceType} 装柜证据`, upload, '', OPTIONS)
const active = evidence?.evidences?.find?.((item) => item.evidenceType === evidenceType
&& item.status !== 'VOID')
if (!active?.evidenceId || id(evidence?.taskId) !== taskId) return false
}
return true
}
@@ -201,8 +198,20 @@ async function loadOneFragment(context, state) {
const oceanBookingId = id(bookingCreated?.oceanBooking?.id)
if (!oceanBookingId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId)
const bookingDocumentForm = new FormData()
bookingDocumentForm.append('expectedVersion', String(Number(bookingCreated?.oceanBooking?.version)))
bookingDocumentForm.append('documentType', 'BOOKING_CONFIRMATION')
bookingDocumentForm.append('file', new Blob([`${context.marker}-${sourceOrderNo}-BOOKING_CONFIRMATION`],
{ type: 'text/plain' }), `booking-confirmation-${operationBase.replace(/[^A-Za-z0-9-]/g, '')}.txt`)
const bookingDocumentResponse = await context.requestMultipart('POST',
`/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`, bookingDocumentForm, context.token,
{ 'Idempotency-Key': `${operationBase}-booking-document`.slice(0, 128) })
const bookingDocument = context.apiSuccess(MODULE, `上传 ${sourceOrderNo} 的订舱确认文件`,
bookingDocumentResponse, '', OPTIONS)
const releaseVersion = Number(bookingDocument?.bookingVersion)
if (!Number.isInteger(releaseVersion)) return null
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, {
expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker,
expectedVersion: releaseVersion, reason: context.marker,
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
const bookingConfirmed = context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的订舱`, releaseResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 订舱为 RELEASED 且不预生成真实柜`, releaseResponse,
@@ -419,7 +428,7 @@ async function loadOneFragment(context, state) {
`HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS)
}
if (!await ensureCompletionEvidence(context, tenantId, id(candidate.inboundId), operationBase)) return null
if (!await ensureCompletionEvidence(context, taskId, warehouseId, operationBase)) return null
const completePath = `${PDA}/tasks/${taskId}/complete`
const completePayload = {
@@ -427,8 +436,18 @@ async function loadOneFragment(context, state) {
remark: context.marker,
}
const complete = await request(context, 'POST', completePath, completePayload)
const completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, complete,
let completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS)
let completedResponse = complete
for (let attempt = 0; attempt < 90 && completed?.taskStatus !== 'COMPLETED'
&& completed?.taskStatus !== 'COMPLETION_FAILED'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000))
completedResponse = await context.request('GET',
`${PDA}/tasks/${taskId}?warehouseId=${encodeURIComponent(warehouseId)}`)
if (completedResponse.http === 200 && codeOf(completedResponse) === '200') {
completed = completedResponse.body?.data
}
}
context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, completedResponse,
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
&& completed?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),