test(firstleg): add mixed container customs acceptance
This commit is contained in:
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
||||
const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
||||
'manual-preload-push-fixture', 'manual-preload-push-verify',
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
@@ -31,6 +31,7 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']],
|
||||
['phase3-container-documents', ['phase3-loading-finalize']],
|
||||
['phase3-customs', ['phase3-loading-finalize']],
|
||||
['phase3-container-customs', ['merchant-qualification-fixture']],
|
||||
['phase3-port-schedules', ['ocean-bookings']],
|
||||
['phase3-outbound-dispatch', ['phase3-container-documents', 'phase3-customs', 'phase3-port-schedules', 'service-center-flow']],
|
||||
['phase3-outbound-aggregate', ['phase3-outbound-dispatch']],
|
||||
@@ -1206,13 +1207,13 @@ export class AcceptanceRun {
|
||||
}
|
||||
}
|
||||
|
||||
async preparePhase3LoadingFixture(suffix = '', requireSharedOverflow = true) {
|
||||
async preparePhase3LoadingFixture(suffix = '', requireSharedOverflow = true, taxModes = null) {
|
||||
const originalMarker = this.marker
|
||||
const fixtureMarker = `${originalMarker}-LOAD${suffix}`
|
||||
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
|
||||
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound',
|
||||
'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount',
|
||||
'phase3LoadingIncrementalDetailCount']
|
||||
'phase3LoadingIncrementalDetailCount', 'orderProfile']
|
||||
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
|
||||
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
|
||||
}]))
|
||||
@@ -1221,6 +1222,11 @@ export class AcceptanceRun {
|
||||
}]))
|
||||
|
||||
this.marker = fixtureMarker
|
||||
if (taxModes?.primary) {
|
||||
this.context.orderProfile = {
|
||||
...(this.context.orderProfile || {}), taxMode: taxModes.primary,
|
||||
}
|
||||
}
|
||||
const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url))
|
||||
await this.executeModule('orders-phase3-loading-fixture', () => orders.run(this))
|
||||
const fixtureInbound = this.createdResources.orders
|
||||
@@ -1254,12 +1260,18 @@ export class AcceptanceRun {
|
||||
customerRemark: fixtureOrder.customerRemark,
|
||||
internalRemark: fixtureOrder.internalRemark,
|
||||
cargoRemark: fixtureOrder.cargoRemark,
|
||||
taxMode: fixtureOrder.taxMode || null,
|
||||
} : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true }
|
||||
const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC`
|
||||
this.marker = incrementalMarker
|
||||
this.context.phase3LoadingRequireSharedOverflow = false
|
||||
this.context.phase3LoadingIncrementalPackageCount = 2
|
||||
this.context.phase3LoadingIncrementalDetailCount = 4
|
||||
if (taxModes?.incremental) {
|
||||
this.context.orderProfile = {
|
||||
...(this.context.orderProfile || {}), taxMode: taxModes.incremental,
|
||||
}
|
||||
}
|
||||
await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this))
|
||||
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
||||
await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this))
|
||||
@@ -1272,6 +1284,7 @@ export class AcceptanceRun {
|
||||
inboundId: incrementalSource.inboundId,
|
||||
boxCode: incrementalSource.boxCode,
|
||||
fulfillmentType: this.createdResources.orders?.fulfillmentType || 'STANDARD',
|
||||
taxMode: this.createdResources.orders?.taxMode || null,
|
||||
skuCodes: Array.isArray(this.createdResources.orders?.skuCodes)
|
||||
? this.createdResources.orders.skuCodes : [],
|
||||
skuLines: Array.isArray(this.createdResources.orders?.skuLines)
|
||||
@@ -1546,6 +1559,16 @@ export class AcceptanceRun {
|
||||
{ scenario: 'phase3-loading-resource-isolation' })
|
||||
}
|
||||
}
|
||||
if (module === 'phase3-container-customs') {
|
||||
try {
|
||||
const fixture = await import(new URL('./admin-test-env-modules/phase3-container-customs-fixture.mjs', import.meta.url))
|
||||
restoreFixture = await fixture.prepare(this)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.addStep('phase3-container-customs-fixture', '创建独立BUY+TAX混合整柜', null, false,
|
||||
reason, { scenario: 'phase3-container-customs-mixed-fixture', deploymentBlocker: true })
|
||||
}
|
||||
}
|
||||
let handler = handlers[module]
|
||||
if (!handler) {
|
||||
try {
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function run(context) {
|
||||
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
|
||||
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 头程整柜报关(待最终HTTP契约对齐) | `phase3-container-customs.mjs`(全买单、纯退税、混合三柜;最终 Controller/OpenAPI 未齐时 fail-closed,不进入默认入口) | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` |
|
||||
| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` |
|
||||
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
||||
| 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` |
|
||||
| 人工推送预装前置与推送后只读核验 | `manual-preload-push-fixture.mjs`、`manual-preload-push-verify.mjs`(可选,不进入默认全量) | `manual-preload-push-contract.test.mjs` |
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from './container-customs-acceptance-matrix.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase3-container-customs.mjs', import.meta.url), 'utf8')
|
||||
const fixtureSource = readFileSync(new URL('./phase3-container-customs-fixture.mjs', import.meta.url), 'utf8')
|
||||
const entrySource = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('matrix covers three isolated LOADED container kinds', () => {
|
||||
assert.deepEqual(CONTAINER_CUSTOMS_FIXTURE_KINDS,
|
||||
@@ -26,9 +28,24 @@ test('matrix covers three isolated LOADED container kinds', () => {
|
||||
taxMode: kind === 'ALL_BUY_DECLARATION' ? 'BUY_DECLARATION' : 'TAX_REFUND' }],
|
||||
})) }
|
||||
assert.equal(validateContainerCustomsFixtures(fixtures, 'M'), true)
|
||||
assert.equal(validateContainerCustomsFixtures({ marker: 'M', containers: [fixtures.containers[2]] },
|
||||
'M', ['MIXED']), true)
|
||||
assert.equal(validateContainerCustomsFixtures({ ...fixtures, marker: 'OLD' }, 'M'), false)
|
||||
})
|
||||
|
||||
test('mixed fixture producer is registered and uses only public HTTP composition', () => {
|
||||
assert.match(entrySource, /CONFIGURED_MODULES[^\n]*'phase3-container-customs'/)
|
||||
assert.match(entrySource, /\['phase3-container-customs', \['merchant-qualification-fixture'\]\]/)
|
||||
assert.match(entrySource, /phase3-container-customs-fixture\.mjs/)
|
||||
assert.match(fixtureSource, /primary: 'BUY_DECLARATION', incremental: 'TAX_REFUND'/)
|
||||
assert.match(fixtureSource, /phase3-loading\.mjs/)
|
||||
assert.match(fixtureSource, /phase3-loading-finalize\.mjs/)
|
||||
assert.match(fixtureSource, /LOADING_LIST_BUY_DECLARATION_ITEMS/)
|
||||
assert.match(fixtureSource, /merchantQualificationFixture/)
|
||||
assert.doesNotMatch(fixtureSource, /customerToken:/)
|
||||
assert.doesNotMatch(fixtureSource, /\b(?:INSERT\s+INTO|DELETE\s+FROM|UPDATE\s+(?:fl_|ll_|wh_|ord_))/i)
|
||||
})
|
||||
|
||||
test('coverage matrix freezes every requested product boundary', () => {
|
||||
const text = CONTAINER_CUSTOMS_MATRIX.map((item) => `${item.scenario}:${item.expected}`).join('\n')
|
||||
for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '逐份完成文件',
|
||||
@@ -51,6 +68,10 @@ test('HTTP scenario contains file, state, idempotency, concurrency and zero-drif
|
||||
'缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移',
|
||||
"['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移',
|
||||
'最后一份完成文件前禁止整柜完成', '最后一份完成文件后纯退税柜完成',
|
||||
'混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票',
|
||||
'买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED',
|
||||
'自动离仓只写一次SYSTEM历史', '自动离仓只创建一个OUTBOUND_CONFIRM长ID任务',
|
||||
'已停止后续完成写入并保留现场',
|
||||
'自动离仓失败不回滚报关完成', '自动或待条件重试后整柜单次离仓',
|
||||
'同幂等键异载荷A0409', 'Promise.all', 'requestId=', 'traceId=', 'xlsxMagic']) {
|
||||
assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
|
||||
@@ -41,10 +41,11 @@ export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([
|
||||
{ priority: 'P1', fixture: 'MIXED', scenario: 'Admin与Client权限', expected: '列表/详情/写权限分离,客户下载仅限自身可见文件' },
|
||||
])
|
||||
|
||||
export function validateContainerCustomsFixtures(value, marker) {
|
||||
export function validateContainerCustomsFixtures(value, marker,
|
||||
requestedKinds = CONTAINER_CUSTOMS_FIXTURE_KINDS) {
|
||||
if (value?.marker !== marker || !Array.isArray(value?.containers)) return false
|
||||
const byKind = new Map(value.containers.map((item) => [item?.kind, item]))
|
||||
return CONTAINER_CUSTOMS_FIXTURE_KINDS.every((kind) => {
|
||||
return requestedKinds.every((kind) => {
|
||||
const fixture = byKind.get(kind)
|
||||
return fixture?.marker === marker && fixture?.status === 'LOADED'
|
||||
&& /^\d+$/.test(String(fixture?.loadingPlanId ?? ''))
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
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
|
||||
if (!account?.username || !account?.password) throw new Error('缺少本轮客户登录凭据')
|
||||
const response = await context.request('POST', '/api/auth/login', {
|
||||
username: account.username, password: account.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) {
|
||||
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),
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,14 @@ function blocker(context, name, reason) {
|
||||
function resolvePreparation(context) {
|
||||
const fixtures = context.createdResources?.phase3ContainerCustomsFixtures
|
||||
const routes = context.context?.phase3ContainerCustomsRoutes
|
||||
if (!validateContainerCustomsFixtures(fixtures, context.marker)) {
|
||||
const requestedKinds = fixtures?.requestedKinds || CONTAINER_CUSTOMS_FIXTURE_KINDS
|
||||
if (!validateContainerCustomsFixtures(fixtures, context.marker, requestedKinds)) {
|
||||
blocker(context, '三类LOADED整柜fixture', '缺少本轮全买单、纯退税、混合三柜正式链路数据')
|
||||
}
|
||||
if (!validateRouteContract(routes)) {
|
||||
blocker(context, '整柜报关最终HTTP契约', '后端Controller/OpenAPI与Admin/Client最终路径尚未对齐,禁止猜测URL发请求')
|
||||
}
|
||||
return { fixtures, routes }
|
||||
return { fixtures, routes, requestedKinds }
|
||||
}
|
||||
|
||||
async function platformDetail(context, routes, fixture) {
|
||||
@@ -224,32 +225,167 @@ async function runRefund(context, routes, fixture) {
|
||||
}
|
||||
|
||||
async function runMixedConcurrency(context, routes, fixture) {
|
||||
const before = await platformDetail(context, routes, fixture)
|
||||
const body = { expectedVersion: Number(before.body?.data?.version), remark: context.marker }
|
||||
const initial = await platformDetail(context, routes, fixture)
|
||||
const initialData = initial.body?.data
|
||||
const buy = (initialData?.inbounds || []).filter((item) => item.taxMode === 'BUY_DECLARATION')
|
||||
const refund = (initialData?.inbounds || []).filter((item) => item.taxMode === 'TAX_REFUND')
|
||||
context.assert(MODULE, '混合柜平台详情精确包含BUY与TAX长ID票', initial,
|
||||
success(initial) && buy.length > 0 && refund.length > 0
|
||||
&& initialData.inbounds.every((item) => /^\d{16,}$/.test(id(item.inboundId))),
|
||||
evidence(initial, `buy=${buy.length}, tax=${refund.length}`), OPTIONS)
|
||||
context.assert(MODULE, '退税资料未覆盖前状态精确阻断开始', initial,
|
||||
initialData?.status === 'WAITING_CUSTOMER_DOCUMENTS'
|
||||
&& refund.every((item) => item.covered === false),
|
||||
evidence(initial, `status=${initialData?.status || 'missing'}`), OPTIONS)
|
||||
|
||||
const customer = fixture.customers?.[0]
|
||||
if (!customer) blocker(context, '混合柜客户fixture', '缺少本轮退税客户与正式登录凭据')
|
||||
const merchant = await routes.merchantDetail(context, fixture, customer, customer.customerToken)
|
||||
context.assert(MODULE, 'Client只见本客户TAX票且完全隐藏BUY票', merchant,
|
||||
success(merchant) && merchant.body?.data?.inbounds?.length === refund.length
|
||||
&& merchant.body.data.inbounds.every((item) => item.taxMode === 'TAX_REFUND')
|
||||
&& !merchant.body.data.inbounds.some((item) => fixture.buyInboundIds.includes(id(item.inboundId))),
|
||||
evidence(merchant), OPTIONS)
|
||||
const merchantPlatform = await context.request('GET',
|
||||
`/api/platform/v1/customs/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}`,
|
||||
undefined, customer.customerToken)
|
||||
context.assert(MODULE, 'Client身份禁止读取平台整柜报关入口', merchantPlatform,
|
||||
merchantPlatform?.http === 403 || (merchantPlatform?.http === 200
|
||||
&& ['A0401', 'A0403', 'S0503'].includes(codeOf(merchantPlatform))),
|
||||
evidence(merchantPlatform), OPTIONS)
|
||||
|
||||
const exportResult = await routes.buyDeclarationXlsx(context, { loadingPlanId: id(fixture.loadingPlanId) })
|
||||
context.assert(MODULE, '买单导出只处理实际装柜BUY行', exportResult,
|
||||
exportResult.http === 200 && xlsxMagic(exportResult.bytes)
|
||||
&& Number(exportResult.task?.processedRows) === Number(fixture.expectedBuyRows)
|
||||
&& Number(fixture.expectedBuyRows) > 0,
|
||||
`processedRows=${exportResult.task?.processedRows ?? 'missing'}, expectedBuyRows=${fixture.expectedBuyRows}, bytes=${exportResult.bytes?.length ?? 0}`, OPTIONS)
|
||||
|
||||
const premature = await routes.platformStart(context, fixture, {
|
||||
expectedVersion: Number(initialData?.version), confirm: true, remark: context.marker,
|
||||
}, key(context, 'mixed-premature-start'))
|
||||
await assertZeroDrift(context, '退税资料缺失时开始精确A0409且零漂移', routes, fixture, initial, premature)
|
||||
|
||||
const uploadKey = key(context, 'mixed-tax-upload')
|
||||
const uploaded = await routes.merchantUploadDocument(context, fixture, customer, customer.document, uploadKey)
|
||||
context.assert(MODULE, 'Client上传资料只覆盖本柜TAX票', uploaded,
|
||||
success(uploaded) && uploaded.body?.data?.inboundIds?.length === refund.length
|
||||
&& uploaded.body.data.inboundIds.every((value) => customer.inboundIds.includes(id(value)))
|
||||
&& !uploaded.body.data.inboundIds.some((value) => fixture.buyInboundIds.includes(id(value))),
|
||||
evidence(uploaded), OPTIONS)
|
||||
const uploadReplay = await routes.merchantUploadDocument(context, fixture, customer, customer.document, uploadKey)
|
||||
context.assert(MODULE, '退税资料上传同键重放返回同一长ID文件', uploadReplay,
|
||||
success(uploadReplay) && id(uploadReplay.body?.data?.documentId) === id(uploaded.body?.data?.documentId),
|
||||
evidence(uploadReplay), OPTIONS)
|
||||
customer.documentId = id(uploaded.body?.data?.documentId)
|
||||
customer.documentVersion = Number(uploaded.body?.data?.source?.version)
|
||||
|
||||
const ready = await platformDetail(context, routes, fixture)
|
||||
context.assert(MODULE, 'TAX票全部覆盖后平台准确进入READY_TO_START', ready,
|
||||
success(ready) && ready.body?.data?.status === 'READY_TO_START'
|
||||
&& ready.body.data.taxRefundCount === ready.body.data.coveredTaxRefundCount,
|
||||
evidence(ready), OPTIONS)
|
||||
const body = { expectedVersion: Number(ready.body?.data?.version), confirm: true, remark: context.marker }
|
||||
const concurrentKey = key(context, 'mixed-start-concurrent')
|
||||
const responses = await Promise.all([
|
||||
routes.platformStart(context, fixture, body, concurrentKey),
|
||||
routes.platformStart(context, fixture, body, concurrentKey),
|
||||
])
|
||||
const startsConverged = responses.every(success)
|
||||
&& zeroDrift(responses[0].body?.data, responses[1].body?.data)
|
||||
context.assert(MODULE, '开始并发双击收敛同一结果', responses[1],
|
||||
responses.every(success) && zeroDrift(responses[0].body?.data, responses[1].body?.data),
|
||||
startsConverged,
|
||||
responses.map((response) => evidence(response)).join('; '), OPTIONS)
|
||||
if (!startsConverged) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '同键并发start幂等收敛',
|
||||
'同一Idempotency-Key与相同载荷未收敛,已停止后续完成写入并保留现场',
|
||||
'backend-idempotency', { deploymentBlocker: true })
|
||||
return
|
||||
}
|
||||
|
||||
const different = await routes.platformStart(context, fixture, { ...body, remark: 'different' }, concurrentKey)
|
||||
context.assert(MODULE, '同幂等键异载荷A0409', different, a0409(different), evidence(different), OPTIONS)
|
||||
const frozen = responses[0].body?.data
|
||||
const customer = fixture.customers?.[0]
|
||||
if (!customer?.documentId) blocker(context, '混合柜冻结文件fixture', '混合柜缺少已上传退税客户资料')
|
||||
const frozen = responses.find(success)?.body?.data
|
||||
const locked = await routes.merchantUpdateScope(context, fixture, customer, {
|
||||
documentId: customer.documentId, inboundIds: customer.inboundIds.map(id), expectedVersion: customer.documentVersion,
|
||||
}, key(context, 'locked-scope'))
|
||||
await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked)
|
||||
|
||||
const completionRequest = {
|
||||
documentId: customer.documentId, expectedVersion: customer.documentVersion,
|
||||
finalConfirmation: true, fileName: `${context.marker}-mixed-result.done`,
|
||||
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x52, 0x45, 0x53]),
|
||||
}
|
||||
const completionKey = key(context, 'mixed-completion')
|
||||
const completions = await Promise.all([
|
||||
routes.platformUploadCompletion(context, fixture, completionRequest, completionKey),
|
||||
routes.platformUploadCompletion(context, fixture, completionRequest, completionKey),
|
||||
])
|
||||
context.assert(MODULE, '最后一份TAX完成文件并发同键收敛同一结果', completions[1],
|
||||
completions.every(success)
|
||||
&& id(completions[0].body?.data?.documentId) === id(completions[1].body?.data?.documentId)
|
||||
&& id(completions[0].body?.data?.completion?.fileId) === id(completions[1].body?.data?.completion?.fileId),
|
||||
completions.map((response) => evidence(response)).join('; '), OPTIONS)
|
||||
const completionDifferent = await routes.platformUploadCompletion(context, fixture,
|
||||
{ ...completionRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, completionKey)
|
||||
context.assert(MODULE, '完成文件同键异载荷A0409', completionDifferent,
|
||||
a0409(completionDifferent), evidence(completionDifferent), OPTIONS)
|
||||
|
||||
let completed = await platformDetail(context, routes, fixture)
|
||||
context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed,
|
||||
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
|
||||
fixture.customsStatus = 'COMPLETED'
|
||||
let outbound = await routes.outboundState(context, fixture)
|
||||
for (let attempt = 0; attempt < 12 && outbound.body?.data?.departed !== true; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
outbound = await routes.outboundState(context, fixture)
|
||||
}
|
||||
context.assert(MODULE, '整柜完成后自动离仓只写一次SYSTEM历史', outbound,
|
||||
success(outbound) && outbound.body?.data?.departed === true
|
||||
&& outbound.body?.data?.departureHistoryCount === 1
|
||||
&& outbound.body?.data?.timeline?.filter((item) => item.module === 'OUTBOUND_DISPATCH'
|
||||
&& item.action === 'CONFIRM_DISPATCH' && id(item.operatorId) === '0'
|
||||
&& item.operatorName === 'SYSTEM').length === 1,
|
||||
evidence(outbound, `history=${outbound.body?.data?.departureHistoryCount ?? 'missing'}`), OPTIONS)
|
||||
|
||||
const queryPage = await context.request('GET', `/api/platform/v1/carrier-schedule-queries?${new URLSearchParams({
|
||||
containerBookingId: id(fixture.containerBookingId), page: '1', size: '20',
|
||||
})}`)
|
||||
const queries = queryPage.body?.data?.records || []
|
||||
const matching = queries.filter((item) => item?.query?.triggerSource === 'OUTBOUND_CONFIRM'
|
||||
&& id(item?.query?.containerBookingId) === id(fixture.containerBookingId))
|
||||
context.assert(MODULE, '自动离仓只创建一个OUTBOUND_CONFIRM长ID任务', queryPage,
|
||||
success(queryPage) && matching.length === 1 && /^\d{16,}$/.test(id(matching[0]?.query?.id)),
|
||||
evidence(queryPage, `matches=${matching.length}`), OPTIONS)
|
||||
if (matching.length === 1) {
|
||||
const queryDetail = await context.request('GET', `/api/platform/v1/carrier-schedule-queries/${encodeURIComponent(id(matching[0].query.id))}`)
|
||||
context.assert(MODULE, '船节点事实与SYSTEM自动创建历史均唯一', queryDetail,
|
||||
success(queryDetail)
|
||||
&& queryDetail.body.data.nodes.filter((item) => item.standardNode === 'DEPARTED_WAREHOUSE'
|
||||
&& item.providerCode === 'FIRSTLEG' && item.providerEventId === 'OUTBOUND_CONFIRM').length === 1
|
||||
&& queryDetail.body.data.histories.filter((item) => item.action === 'AUTO_CREATE'
|
||||
&& id(item.operatorId) === '0' && item.operatorName === 'SYSTEM').length === 1,
|
||||
evidence(queryDetail), OPTIONS)
|
||||
}
|
||||
|
||||
const completionDownload = await routes.merchantDownload(context, fixture, customer, {
|
||||
documentId: customer.documentId, completion: true,
|
||||
})
|
||||
context.assert(MODULE, '整柜完成后Client可下载自身TAX完成文件', completionDownload,
|
||||
completionDownload.http === 200 && completionDownload.bytes?.length > 0,
|
||||
`HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const { fixtures, routes } = resolvePreparation(context)
|
||||
const { fixtures, routes, requestedKinds } = resolvePreparation(context)
|
||||
const byKind = new Map(fixtures.containers.map((item) => [item.kind, item]))
|
||||
await runAllBuy(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[0]))
|
||||
await runRefund(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[1]))
|
||||
await runMixedConcurrency(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[2]))
|
||||
if (requestedKinds.includes(CONTAINER_CUSTOMS_FIXTURE_KINDS[0])) {
|
||||
await runAllBuy(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[0]))
|
||||
}
|
||||
if (requestedKinds.includes(CONTAINER_CUSTOMS_FIXTURE_KINDS[1])) {
|
||||
await runRefund(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[1]))
|
||||
}
|
||||
if (requestedKinds.includes(CONTAINER_CUSTOMS_FIXTURE_KINDS[2])) {
|
||||
await runMixedConcurrency(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[2]))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user