2026-08-28 22:39:18 +08:00
|
|
|
const MODULE = 'phase3-container-customs-fixture'
|
|
|
|
|
const OPTIONS = { scenario: 'phase3-container-customs-mixed-fixture', deploymentBlocker: true }
|
|
|
|
|
const PLATFORM = '/api/platform/v1/customs/loading-plans'
|
|
|
|
|
const MERCHANT = '/api/merchant/v1/customs'
|
|
|
|
|
|
|
|
|
|
const id = (value) => value == null ? '' : String(value)
|
|
|
|
|
const success = (response) => response?.http === 200 && String(response?.body?.code) === '200'
|
|
|
|
|
const key = (context, value) => `${context.marker}-mixed-customs-${value}`.slice(0, 128)
|
|
|
|
|
|
|
|
|
|
async function raw(context, method, path, body, token, headers = {}) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
|
|
|
method,
|
|
|
|
|
headers: {
|
|
|
|
|
Accept: 'application/json', Authorization: token ? `Bearer ${token}` : '',
|
|
|
|
|
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json; charset=utf-8' }),
|
|
|
|
|
...headers,
|
|
|
|
|
},
|
|
|
|
|
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
|
|
|
|
signal: AbortSignal.timeout(120_000),
|
|
|
|
|
})
|
|
|
|
|
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
|
|
|
const contentType = response.headers.get('content-type') || ''
|
|
|
|
|
let payload = null
|
|
|
|
|
if (contentType.includes('json')) {
|
|
|
|
|
try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* bounded binary evidence */ }
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
method, path, http: response.status, body: payload,
|
|
|
|
|
raw: payload == null && contentType.includes('text') ? new TextDecoder().decode(bytes).slice(0, 500) : null,
|
|
|
|
|
bytes, contentType,
|
|
|
|
|
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, bytes: new Uint8Array(), raw: String(error) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loginMerchant(context) {
|
|
|
|
|
const account = context.context?.merchantQualificationFixture
|
2026-09-02 13:59:05 +08:00
|
|
|
const username = account?.username || process.env.MERCHANT_TEST_USERNAME || ''
|
|
|
|
|
const password = account?.password || process.env.MERCHANT_TEST_PASSWORD || ''
|
|
|
|
|
if (!username || !password) throw new Error('缺少客户登录凭据')
|
2026-08-28 22:39:18 +08:00
|
|
|
const response = await context.request('POST', '/api/auth/login', {
|
2026-09-02 13:59:05 +08:00
|
|
|
username, password,
|
2026-08-28 22:39:18 +08:00
|
|
|
}, '')
|
|
|
|
|
const token = success(response) ? response.body?.data?.accessToken : null
|
|
|
|
|
if (!token) throw new Error(`本轮客户登录失败: requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`)
|
|
|
|
|
return token
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function routes(context, merchantToken) {
|
|
|
|
|
const binary = (method, path, token = context.token) => raw(context, method, path, undefined, token)
|
|
|
|
|
const multipart = (path, fields, file, token, idempotencyKey) => {
|
|
|
|
|
const form = new FormData()
|
|
|
|
|
for (const [name, value] of Object.entries(fields || {})) {
|
|
|
|
|
const values = Array.isArray(value) ? value : [value]
|
|
|
|
|
for (const item of values) if (item != null) form.append(name, String(item))
|
|
|
|
|
}
|
|
|
|
|
form.append('file', new Blob([file.content], { type: file.contentType }), file.fileName)
|
|
|
|
|
return raw(context, 'POST', path, form, token, { 'Idempotency-Key': idempotencyKey })
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
version: 1,
|
|
|
|
|
platformDetail: (ctx, value) => ctx.request('GET', `${PLATFORM}/${encodeURIComponent(id(value.loadingPlanId))}`),
|
|
|
|
|
merchantDetail: (ctx, fixture, customer, token = merchantToken) => ctx.request('GET',
|
|
|
|
|
`${MERCHANT}/orders/${encodeURIComponent(id(customer.orderId))}`, undefined, token),
|
2026-08-28 22:54:23 +08:00
|
|
|
merchantPlatformDetail: (ctx, fixture) => ctx.request('GET',
|
|
|
|
|
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}`, undefined, merchantToken),
|
2026-08-28 22:39:18 +08:00
|
|
|
merchantUploadDocument: (ctx, fixture, customer, file, idempotencyKey) => multipart(
|
|
|
|
|
`${MERCHANT}/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/documents/upload?${customer.inboundIds.map((value) => `inboundIds=${encodeURIComponent(id(value))}`).join('&')}`,
|
|
|
|
|
{}, file, customer.customerToken || merchantToken, idempotencyKey),
|
|
|
|
|
merchantReplaceDocument: (ctx, fixture, customer, file, idempotencyKey) => multipart(
|
|
|
|
|
`${MERCHANT}/documents/${encodeURIComponent(id(file.documentId))}/upload?expectedVersion=${Number(file.expectedVersion)}&${customer.inboundIds.map((value) => `inboundIds=${encodeURIComponent(id(value))}`).join('&')}`,
|
|
|
|
|
{}, file, customer.customerToken || merchantToken, idempotencyKey),
|
|
|
|
|
merchantUpdateScope: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('PUT',
|
|
|
|
|
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/inbounds`, {
|
|
|
|
|
inboundIds: request.inboundIds.map(id), expectedVersion: Number(request.expectedVersion),
|
|
|
|
|
}, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }),
|
|
|
|
|
merchantDeleteDocument: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('DELETE',
|
|
|
|
|
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`,
|
|
|
|
|
undefined, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }),
|
|
|
|
|
platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
|
|
|
|
|
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token,
|
|
|
|
|
{ 'Idempotency-Key': idempotencyKey }),
|
|
|
|
|
platformUploadCompletion: (ctx, fixture, request, idempotencyKey) => multipart(
|
|
|
|
|
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/completion/upload?expectedVersion=${Number(request.expectedVersion)}&finalConfirmation=${request.finalConfirmation === true}`,
|
|
|
|
|
{}, request, ctx.token, idempotencyKey),
|
|
|
|
|
platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
|
|
|
|
|
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token,
|
|
|
|
|
{ 'Idempotency-Key': idempotencyKey }),
|
|
|
|
|
platformDownload: (ctx, fixture, request) => binary('GET',
|
|
|
|
|
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`),
|
|
|
|
|
merchantDownload: (ctx, fixture, customer, request) => binary('GET',
|
|
|
|
|
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`,
|
|
|
|
|
customer.customerToken || merchantToken),
|
|
|
|
|
buyDeclarationXlsx: async (ctx, request) => {
|
|
|
|
|
const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', {
|
|
|
|
|
exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX',
|
|
|
|
|
taskName: `${ctx.marker}-mixed-buy-declaration`,
|
|
|
|
|
parameters: { loadingPlanId: id(request.loadingPlanId) },
|
|
|
|
|
})
|
|
|
|
|
let task = success(created) ? created.body?.data : null
|
|
|
|
|
const taskId = id(task?.id)
|
|
|
|
|
for (let attempt = 0; taskId && attempt < 30 && !['SUCCEEDED', 'FAILED', 'EXPIRED'].includes(task?.status); attempt += 1) {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
|
|
|
|
const detail = await ctx.request('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}`)
|
|
|
|
|
if (success(detail)) task = detail.body.data
|
|
|
|
|
}
|
|
|
|
|
if (!taskId || task?.status !== 'SUCCEEDED') return { ...created, task, bytes: new Uint8Array() }
|
|
|
|
|
const download = await ctx.requestBinary('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}/download`)
|
|
|
|
|
return { ...download, task, taskId, createResponse: created }
|
|
|
|
|
},
|
|
|
|
|
outboundState: async (ctx, fixture) => {
|
|
|
|
|
const detail = await ctx.request('GET', `/api/platform/v1/first-leg/outbound-dispatches/${encodeURIComponent(id(fixture.containerBookingId))}`)
|
|
|
|
|
const aggregate = success(detail) ? detail.body?.data?.aggregate : null
|
|
|
|
|
const history = success(detail) ? detail.body?.data?.timeline || [] : []
|
|
|
|
|
return {
|
|
|
|
|
...detail,
|
|
|
|
|
body: { ...detail.body, data: {
|
|
|
|
|
customsStatus: fixture.customsStatus,
|
|
|
|
|
departed: aggregate?.dispatchStatus === 'WAITING_FOR_LOADING',
|
|
|
|
|
retryable: aggregate?.dispatchStatus === 'READY_FOR_DISPATCH',
|
|
|
|
|
departureHistoryCount: history.filter((item) => item.module === 'OUTBOUND_DISPATCH'
|
|
|
|
|
&& item.action === 'CONFIRM_DISPATCH').length,
|
|
|
|
|
aggregate, timeline: history,
|
|
|
|
|
} },
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
satisfyOutboundPrerequisites: async () => undefined,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function prepare(context) {
|
|
|
|
|
const restore = await context.preparePhase3LoadingFixture('-CUSTOMS-MIXED', false, {
|
|
|
|
|
primary: 'BUY_DECLARATION', incremental: 'TAX_REFUND',
|
|
|
|
|
})
|
|
|
|
|
const loading = await import('./phase3-loading.mjs')
|
|
|
|
|
await context.executeModule(`${MODULE}-loading`, () => loading.run(context))
|
|
|
|
|
const finalize = await import('./phase3-loading-finalize.mjs')
|
|
|
|
|
await context.executeModule(`${MODULE}-finalize`, () => finalize.run(context))
|
|
|
|
|
|
|
|
|
|
const source = context.createdResources?.phase3Loading
|
|
|
|
|
if (!source?.loadingPlanId || !source?.containerBookingId || source?.finalStatus !== 'LOADED') {
|
|
|
|
|
restore()
|
|
|
|
|
throw new Error(`混合柜未进入LOADED: planId=${id(source?.loadingPlanId) || 'missing'}, status=${source?.finalStatus || 'missing'}`)
|
|
|
|
|
}
|
|
|
|
|
const platform = await context.request('GET', `${PLATFORM}/${encodeURIComponent(id(source.loadingPlanId))}`)
|
|
|
|
|
const customs = success(platform) ? platform.body.data : null
|
|
|
|
|
const plan = await context.request('GET', `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(id(source.loadingPlanId))}`)
|
|
|
|
|
const planItems = success(plan) && Array.isArray(plan.body?.data?.items) ? plan.body.data.items : []
|
|
|
|
|
const inbounds = Array.isArray(customs?.inbounds) ? customs.inbounds : []
|
|
|
|
|
const buy = inbounds.filter((item) => item.taxMode === 'BUY_DECLARATION')
|
|
|
|
|
const refund = inbounds.filter((item) => item.taxMode === 'TAX_REFUND')
|
|
|
|
|
context.assert(MODULE, '正式LOADED计划同时包含BUY与TAX实际入仓票', platform,
|
|
|
|
|
buy.length > 0 && refund.length > 0 && inbounds.every((item) => /^\d{16,}$/.test(id(item.inboundId))),
|
|
|
|
|
`buy=${buy.length}, tax=${refund.length}, inbounds=${inbounds.length}`, OPTIONS)
|
|
|
|
|
if (!buy.length || !refund.length) {
|
|
|
|
|
restore()
|
|
|
|
|
throw new Error(`正式装柜API未形成混合柜: buy=${buy.length}, tax=${refund.length}`)
|
|
|
|
|
}
|
|
|
|
|
const merchantToken = await loginMerchant(context)
|
|
|
|
|
const customerId = id(refund[0].tenantId)
|
|
|
|
|
const customer = {
|
|
|
|
|
customerId, orderId: id(refund[0].orderId),
|
|
|
|
|
inboundIds: refund.map((item) => id(item.inboundId)),
|
|
|
|
|
document: {
|
|
|
|
|
fileName: `${context.marker}-tax-refund.customblob`, contentType: 'application/octet-stream',
|
|
|
|
|
content: Uint8Array.from([0x54, 0x41, 0x58]),
|
|
|
|
|
},
|
|
|
|
|
replacement: {
|
|
|
|
|
fileName: `${context.marker}-tax-refund-v2.customblob`, contentType: 'application/octet-stream',
|
|
|
|
|
content: Uint8Array.from([0x54, 0x41, 0x58, 0x32]),
|
|
|
|
|
},
|
|
|
|
|
updatedInboundIds: refund.map((item) => id(item.inboundId)),
|
|
|
|
|
}
|
|
|
|
|
const buyInboundIds = new Set(buy.map((item) => id(item.inboundId)))
|
|
|
|
|
const planBuyRows = planItems.filter((item) => buyInboundIds.has(id(item.inboundId))).length
|
|
|
|
|
const expectedBuyRows = planBuyRows > 0 ? planBuyRows : buy.length
|
|
|
|
|
context.createdResources.phase3ContainerCustomsFixtures = {
|
|
|
|
|
marker: context.marker, requestedKinds: ['MIXED'],
|
|
|
|
|
containers: [{
|
|
|
|
|
kind: 'MIXED', marker: context.marker, status: 'LOADED', autoDispatchMode: 'IMMEDIATE',
|
|
|
|
|
loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId),
|
|
|
|
|
oceanBookingId: id(source.oceanBookingId), containerNo: source.containerNo,
|
|
|
|
|
inbounds: inbounds.map((item) => ({
|
|
|
|
|
inboundId: id(item.inboundId), orderId: id(item.orderId), customerId: id(item.tenantId),
|
|
|
|
|
inboundCode: item.inboundCode, taxMode: item.taxMode,
|
|
|
|
|
})),
|
|
|
|
|
customers: [customer], expectedBuyRows, buyInboundIds: [...buyInboundIds], customsStatus: customs.status,
|
|
|
|
|
}],
|
|
|
|
|
}
|
|
|
|
|
context.context.phase3ContainerCustomsRoutes = routes(context, merchantToken)
|
|
|
|
|
return () => {
|
|
|
|
|
delete context.context.phase3ContainerCustomsRoutes
|
|
|
|
|
restore()
|
|
|
|
|
}
|
|
|
|
|
}
|