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 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', { loginId: username, password, }, '') 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, customerTokens = new Map()) { const tokenFor = (customer) => customerTokens.get(id(customer.customerId)) || 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 = tokenFor(customer)) => ctx.request('GET', `${MERCHANT}/orders/${encodeURIComponent(id(customer.orderId))}`, undefined, token), merchantPlatformDetail: (ctx, fixture) => ctx.request('GET', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}`, undefined, merchantToken), 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, tokenFor(customer), 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, tokenFor(customer), 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), }, tokenFor(customer), { 'Idempotency-Key': idempotencyKey }), merchantDeleteDocument: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('DELETE', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`, undefined, tokenFor(customer), { 'Idempotency-Key': idempotencyKey }), merchantConfirmReceipt: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('POST', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/${request.confirm === false ? 'reject' : 'confirm'}`, { expectedVersion: Number(request.expectedVersion), confirm: request.confirm !== false, remark: request.remark || '' }, tokenFor(customer), { 'Idempotency-Key': idempotencyKey }), platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), platformSendInitialToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/send-to-broker`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart( `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`, {}, request, ctx.token, idempotencyKey), platformSubmitToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/submit-to-broker`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), platformDeclarationBatches: (ctx, fixture) => ctx.request('GET', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/declaration-batches`), platformCreateDeclaration: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/declaration-batches`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), platformSubmitDeclaration: (ctx, fixture, batchId, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/submit`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), platformClearanceResults: (ctx, fixture, batchId) => ctx.request('GET', `${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/results`), platformRecordClearance: (ctx, fixture, batchId, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/results`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), platformResolveClearance: (ctx, fixture, resultId, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/clearance-results/${encodeURIComponent(id(resultId))}/resolve`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), platformUploadFinal: (ctx, fixture, request, idempotencyKey) => multipart( `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/upload?expectedVersion=${Number(request.expectedVersion)}`, {}, request, ctx.token, idempotencyKey), platformDownloadReceipt: (ctx, fixture, request) => binary('GET', `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`), platformDownloadFinal: (ctx, fixture, request) => binary('GET', `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/download`), merchantDownloadReceipt: (ctx, fixture, customer, request) => binary('GET', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`, tokenFor(customer)), merchantDownloadFinal: (ctx, fixture, customer, request) => binary('GET', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/final/download`, tokenFor(customer)), 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, } } async function addSecondRefundCustomer(context) { const source = context.createdResources.phase3Loading const secondary = Object.create(context) secondary.marker = `${context.marker}-CUSTOMER-B` secondary.options = { ...context.options, customerName: '' } secondary.context = { ...context.context, orderProfile: { ...(context.context.orderProfile || {}), taxMode: 'TAX_REFUND', fulfillmentType: 'STANDARD', }, orderFulfillmentType: 'STANDARD' } secondary.createdResources = {} const qualification = await import('./merchant-qualification-fixture.mjs') await qualification.run(secondary, { publishEnvironment: false }) const account = secondary.context.merchantQualificationFixture // orders.mjs captures its credentials at import time. A distinct module instance captures // this customer's credentials without changing the primary customer's cached producer. const saved = ['MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD'].map((name) => [name, Object.hasOwn(process.env, name), process.env[name]]) let orders try { process.env.MERCHANT_TEST_USERNAME = account.username process.env.MERCHANT_TEST_PASSWORD = account.password orders = await import(`./orders.mjs?customsCustomer=${encodeURIComponent(account.tenantId)}`) } finally { for (const [name, present, value] of saved) { if (present) process.env[name] = value else delete process.env[name] } } await secondary.executeModule(`${MODULE}-customer-b-orders`, () => orders.run(secondary)) const measurement = await import('./inbound-measurement.mjs') await secondary.executeModule(`${MODULE}-customer-b-measurement`, () => measurement.run(secondary)) const inbound = secondary.createdResources.inboundMeasurement if (!inbound?.inboundId || id(inbound.tenantId) !== id(account.tenantId) || id(inbound.tenantId) === id(source.tenantId)) throw new Error('第二退税客户未生成独立入库单') const login = await secondary.request('POST', '/api/auth/login', { loginId: account.username, password: account.password, }, '') if (!success(login) || !login.body?.data?.accessToken) throw new Error('第二退税客户登录失败') const smart = '/api/platform/v1/first-leg/smart-loading' const candidatesResponse = await context.request('GET', `${smart}/candidates?${new URLSearchParams({ bookingId: id(source.containerBookingId), warehouseId: id(source.warehouseId), })}`) const candidates = context.apiSuccess(MODULE, '第二退税客户读取原柜正式可装候选', candidatesResponse, '', OPTIONS) const candidate = (Array.isArray(candidates) ? candidates : candidates?.records || []).find((item) => id(item.inboundId) === id(inbound.inboundId) && id(item.accountId) === id(account.tenantId) && item.inboundDetailId && Number(item.availablePackageCount) > 0) if (!candidate) throw new Error('第二退税客户在原柜没有正式可装候选,禁止借历史票') const planResponse = await context.request('GET', `${smart}/plans/${id(source.loadingPlanId)}`) const plan = context.apiSuccess(MODULE, '加入第二客户前回查原柜计划', planResponse, '', OPTIONS) const assignedResponse = await context.request('POST', `${smart}/plans/${id(source.loadingPlanId)}/assignments`, { inboundId: id(inbound.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, expectedVersion: Number(plan.version), operationId: key(context, 'customer-b-assign'), }) const assigned = context.apiSuccess(MODULE, '第二退税客户正式分配到同柜', assignedResponse, '', OPTIONS) const item = assigned?.items?.find((value) => id(value.inboundId) === id(inbound.inboundId)) const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${id(source.canonicalPdaTaskId)}` const taskResponse = await context.request('GET', `${taskPath}?warehouseId=${id(source.warehouseId)}`) const task = context.apiSuccess(MODULE, '同柜新增第二客户后回查原PDA任务', taskResponse, '', OPTIONS) const taskItem = task?.items?.find((value) => id(value.loadingPlanItemId) === id(item?.id)) const inventoryResponse = await context.request('GET', `${taskPath}/inventory-snapshot?warehouseId=${id(source.warehouseId)}`) const inventory = context.apiSuccess(MODULE, '第二客户刷新正式拣货库存快照', inventoryResponse, '', OPTIONS) if (!taskItem || !inventory?.snapshotToken) throw new Error('第二客户缺少正式PDA明细或库存快照') const pickedResponse = await context.request('POST', `${taskPath}/batch`, { warehouseId: id(source.warehouseId), loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId), expectedVersion: Number(task.version), inventorySnapshotToken: inventory.snapshotToken, items: [{ itemId: id(taskItem.id), action: 'PICK', pickedPackageCount: 1, version: Number(taskItem.version) }], }, context.pdaToken || context.token, { 'Idempotency-Key': key(context, 'customer-b-pick') }) const picked = context.apiSuccess(MODULE, '同柜第二客户完成正式PDA拣货', pickedResponse, '', OPTIONS) context.assert(MODULE, '第二客户加入后原柜原任务保持且恢复待完成', pickedResponse, id(picked?.id) === id(source.canonicalPdaTaskId) && picked?.workflowStatus === 'AWAITING_REVIEW' && id(picked?.loadingPlanId) === id(source.loadingPlanId) && id(picked?.containerBookingId) === id(source.containerBookingId), `workflow=${picked?.workflowStatus || 'missing'}`, OPTIONS) source.pdaTaskVersion = Number(picked?.version) const { stableLoadedCartonMarks } = await import('./phase3-loading.mjs') source.cartonMarks = stableLoadedCartonMarks(picked?.items) return { customerId: id(account.tenantId), sessionToken: login.body.data.accessToken } } export async function withPlatformBookingScope(context, runLoading) { const name = 'phase3LoadingBookingScope' const present = Object.hasOwn(context.context, name) const previous = context.context[name] context.context[name] = 'PLATFORM' try { return await runLoading() } finally { if (present) context.context[name] = previous else delete context.context[name] } } export async function prepare(context) { if (context.options?.fixedCustomerId && !context.options.fixedSecondaryCustomer) throw new Error('混合报关柜需要第二个固定客户,停止订单及装柜造数') if (context.options?.fixedCustomerId) { const { requireFixedSecondaryCustomer } = await import('./merchant-qualification-fixture.mjs') requireFixedSecondaryCustomer(context.options) } const outerMarker = context.marker const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'] const isolatedResourceSnapshot = new Map(isolatedResourceKeys.map((name) => [name, { present: Object.hasOwn(context.createdResources, name), value: context.createdResources[name], }])) const restoreIsolatedResources = () => { for (const [name, snapshot] of isolatedResourceSnapshot) { if (snapshot.present) context.createdResources[name] = snapshot.value else delete context.createdResources[name] } } const restore = await context.preparePhase3LoadingFixture('-CUSTOMS-MIXED', false, { primary: 'BUY_DECLARATION', incremental: 'TAX_REFUND', }) try { const executionMarker = `${outerMarker}-CUSTOMS-MIXED` context.marker = executionMarker if (context.createdResources?.orchestrationFixtures?.phase3Loading) { context.createdResources.orchestrationFixtures.phase3Loading.marker = executionMarker } if (context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental) { context.createdResources.orchestrationFixtures.phase3LoadingIncremental.marker = executionMarker } const loading = await import('./phase3-loading.mjs') await withPlatformBookingScope(context, () => context.executeModule(`${MODULE}-loading`, () => loading.run(context))) const secondCustomer = await addSecondRefundCustomer(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') { context.marker = outerMarker restore() restoreIsolatedResources() 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) { context.marker = outerMarker restore() restoreIsolatedResources() throw new Error(`正式装柜API未形成混合柜: buy=${buy.length}, tax=${refund.length}`) } const merchantToken = await loginMerchant(context) const customers = [...new Set(refund.map((item) => id(item.tenantId)))].map((customerId) => { const own = refund.filter((item) => id(item.tenantId) === customerId) return { customerId, orderId: id(own[0].orderId), inboundIds: own.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: own.map((item) => id(item.inboundId)), } }) if (customers.length < 2) throw new Error('同柜未形成两个实际退税客户') 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.marker = outerMarker context.createdResources.phase3ContainerCustomsFixtures = { marker: outerMarker, requestedKinds: ['MIXED'], containers: [{ kind: 'MIXED', marker: outerMarker, 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, expectedBuyRows, buyInboundIds: [...buyInboundIds], customsStatus: customs.status, }], } context.context.phase3ContainerCustomsRoutes = routes(context, merchantToken, new Map([[secondCustomer.customerId, secondCustomer.sessionToken]])) return () => { delete context.context.phase3ContainerCustomsRoutes context.marker = outerMarker restore() restoreIsolatedResources() } } catch (error) { delete context.context.phase3ContainerCustomsRoutes context.marker = outerMarker restore() restoreIsolatedResources() throw error } }