feat(firstleg): implement container customs workflow
This commit is contained in:
@@ -62,6 +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-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` |
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
CONTAINER_CUSTOMS_FIXTURE_KINDS,
|
||||
CONTAINER_CUSTOMS_MATRIX,
|
||||
EXACT_50_MB,
|
||||
READ_ONLY_DB_EVIDENCE,
|
||||
REQUIRED_ROUTE_OPERATIONS,
|
||||
validateContainerCustomsFixtures,
|
||||
validateRouteContract,
|
||||
xlsxMagic,
|
||||
zeroDrift,
|
||||
} from './container-customs-acceptance-matrix.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase3-container-customs.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('matrix covers three isolated LOADED container kinds', () => {
|
||||
assert.deepEqual(CONTAINER_CUSTOMS_FIXTURE_KINDS,
|
||||
['ALL_BUY_DECLARATION', 'ALL_TAX_REFUND', 'MIXED'])
|
||||
const fixtures = { marker: 'M', containers: CONTAINER_CUSTOMS_FIXTURE_KINDS.map((kind, index) => ({
|
||||
marker: 'M', kind, status: 'LOADED', loadingPlanId: String(100 + index),
|
||||
containerBookingId: String(200 + index),
|
||||
inbounds: [{ inboundId: String(300 + index), customerId: String(400 + index),
|
||||
taxMode: kind === 'ALL_BUY_DECLARATION' ? 'BUY_DECLARATION' : 'TAX_REFUND' }],
|
||||
})) }
|
||||
assert.equal(validateContainerCustomsFixtures(fixtures, 'M'), true)
|
||||
assert.equal(validateContainerCustomsFixtures({ ...fixtures, marker: 'OLD' }, 'M'), false)
|
||||
})
|
||||
|
||||
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', '多对多', '覆盖', '改关联', '删除', '冻结', '逐份完成文件',
|
||||
'最后一份', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式',
|
||||
'幂等', '并发', 'A0409', '零漂移', '自动离仓', 'Outbox', '历史', 'Admin与Client权限']) {
|
||||
assert.match(text, new RegExp(token))
|
||||
}
|
||||
})
|
||||
|
||||
test('route adapter fails closed until final backend and frontend contracts align', () => {
|
||||
assert.equal(validateRouteContract({}), false)
|
||||
const routes = { version: 'final', ...Object.fromEntries(REQUIRED_ROUTE_OPERATIONS.map((name) => [name, () => null])) }
|
||||
assert.equal(validateRouteContract(routes), true)
|
||||
assert.match(source, /后端Controller\/OpenAPI与Admin\/Client最终路径尚未对齐,禁止猜测URL发请求/)
|
||||
assert.doesNotMatch(source, /https?:\/\//)
|
||||
})
|
||||
|
||||
test('HTTP scenario contains file, state, idempotency, concurrency and zero-drift probes', () => {
|
||||
for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream',
|
||||
'缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移',
|
||||
"['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移',
|
||||
'最后一份完成文件前禁止整柜完成', '最后一份完成文件后纯退税柜完成',
|
||||
'自动离仓失败不回滚报关完成', '自动或待条件重试后整柜单次离仓',
|
||||
'同幂等键异载荷A0409', 'Promise.all', 'requestId=', 'traceId=', 'xlsxMagic']) {
|
||||
assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
}
|
||||
assert.equal(EXACT_50_MB, 52_428_800)
|
||||
assert.equal(zeroDrift({ a: 1 }, { a: 1 }), true)
|
||||
assert.equal(xlsxMagic(Uint8Array.from([0x50, 0x4b, 0x03, 0x04])), true)
|
||||
})
|
||||
|
||||
test('database evidence list covers atomicity, history, dispatch and outbox without embedding SQL writes', () => {
|
||||
for (const table of ['fl_customs_container', 'fl_customs_customer_document_inbound',
|
||||
'fl_customs_completion_document', 'fl_customs_command', 'fl_customs_container_history',
|
||||
'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table)
|
||||
assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i)
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
export const CONTAINER_CUSTOMS_MODULE = 'phase3-container-customs'
|
||||
export const EXACT_50_MB = 50 * 1024 * 1024
|
||||
|
||||
export const CONTAINER_CUSTOMS_FIXTURE_KINDS = Object.freeze([
|
||||
'ALL_BUY_DECLARATION',
|
||||
'ALL_TAX_REFUND',
|
||||
'MIXED',
|
||||
])
|
||||
|
||||
export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([
|
||||
'platformDetail',
|
||||
'merchantDetail',
|
||||
'buyDeclarationXlsx',
|
||||
'merchantUploadDocument',
|
||||
'merchantReplaceDocument',
|
||||
'merchantUpdateScope',
|
||||
'merchantDeleteDocument',
|
||||
'platformStart',
|
||||
'platformUploadCompletion',
|
||||
'platformComplete',
|
||||
'platformDownload',
|
||||
'merchantDownload',
|
||||
'outboundState',
|
||||
'satisfyOutboundPrerequisites',
|
||||
])
|
||||
|
||||
export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([
|
||||
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' },
|
||||
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' },
|
||||
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' },
|
||||
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份完成文件后才允许整柜完成' },
|
||||
{ priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' },
|
||||
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' },
|
||||
{ priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' },
|
||||
{ priority: 'P1', fixture: 'ALL_TAX_REFUND', scenario: '开始后锁定', expected: '上传、覆盖、改关联、删除全部A0409零漂移' },
|
||||
{ priority: 'P1', fixture: 'ALL_TAX_REFUND', scenario: '完成文件继承范围', expected: '完成文件只继承冻结关联,不接收客户端范围' },
|
||||
{ priority: 'P1', fixture: 'MIXED', scenario: '客户隔离与买单隐藏', expected: '客户只见本企业退税资料;买单XLSX及其他客户文件不可见' },
|
||||
{ priority: 'P1', fixture: 'ALL_TAX_REFUND', scenario: '文件边界', expected: '任意格式允许;50MiB成功;50MiB+1拒绝且零写入' },
|
||||
{ priority: 'P1', fixture: 'MIXED', scenario: '幂等与并发', expected: '同键同载荷同结果;异载荷/并发冲突A0409且零漂移' },
|
||||
{ priority: 'P1', fixture: 'MIXED', scenario: '历史与Outbox', expected: '每个真实动作单条历史;自动离仓意图/结果可追踪且无重复' },
|
||||
{ priority: 'P1', fixture: 'MIXED', scenario: 'Admin与Client权限', expected: '列表/详情/写权限分离,客户下载仅限自身可见文件' },
|
||||
])
|
||||
|
||||
export function validateContainerCustomsFixtures(value, marker) {
|
||||
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) => {
|
||||
const fixture = byKind.get(kind)
|
||||
return fixture?.marker === marker && fixture?.status === 'LOADED'
|
||||
&& /^\d+$/.test(String(fixture?.loadingPlanId ?? ''))
|
||||
&& /^\d+$/.test(String(fixture?.containerBookingId ?? ''))
|
||||
&& Array.isArray(fixture?.inbounds) && fixture.inbounds.length > 0
|
||||
&& fixture.inbounds.every((inbound) => /^\d+$/.test(String(inbound?.inboundId ?? ''))
|
||||
&& /^\d+$/.test(String(inbound?.customerId ?? ''))
|
||||
&& ['BUY_DECLARATION', 'TAX_REFUND'].includes(inbound?.taxMode))
|
||||
})
|
||||
}
|
||||
|
||||
export function validateRouteContract(routes) {
|
||||
return routes?.version != null
|
||||
&& REQUIRED_ROUTE_OPERATIONS.every((name) => typeof routes?.[name] === 'function')
|
||||
}
|
||||
|
||||
export function stableSnapshot(value) {
|
||||
const normalize = (current) => {
|
||||
if (Array.isArray(current)) return current.map(normalize)
|
||||
if (current != null && typeof current === 'object') {
|
||||
return Object.fromEntries(Object.keys(current).sort().map((key) => [key, normalize(current[key])]))
|
||||
}
|
||||
return current
|
||||
}
|
||||
return JSON.stringify(normalize(value))
|
||||
}
|
||||
|
||||
export function zeroDrift(before, after) {
|
||||
return stableSnapshot(before) === stableSnapshot(after)
|
||||
}
|
||||
|
||||
export function xlsxMagic(bytes) {
|
||||
return bytes instanceof Uint8Array && bytes.length >= 4
|
||||
&& bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04
|
||||
}
|
||||
|
||||
export const READ_ONLY_DB_EVIDENCE = Object.freeze([
|
||||
'fl_customs_container',
|
||||
'fl_customs_customer_document',
|
||||
'fl_customs_customer_document_inbound',
|
||||
'fl_customs_customer_document_version',
|
||||
'fl_customs_frozen_document_inbound',
|
||||
'fl_customs_completion_document',
|
||||
'fl_customs_completion_document_version',
|
||||
'fl_customs_command',
|
||||
'fl_customs_container_history',
|
||||
'fl_outbound_dispatch',
|
||||
'fl_outbound_dispatch_history',
|
||||
'fl_event_outbox',
|
||||
])
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
CONTAINER_CUSTOMS_FIXTURE_KINDS,
|
||||
CONTAINER_CUSTOMS_MODULE,
|
||||
EXACT_50_MB,
|
||||
validateContainerCustomsFixtures,
|
||||
validateRouteContract,
|
||||
xlsxMagic,
|
||||
zeroDrift,
|
||||
} from './container-customs-acceptance-matrix.mjs'
|
||||
|
||||
const MODULE = CONTAINER_CUSTOMS_MODULE
|
||||
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
||||
const id = (value) => value == null ? '' : String(value)
|
||||
const code = (response) => String(response?.body?.code ?? '')
|
||||
const success = (response) => response?.http === 200 && code(response) === '200'
|
||||
const a0409 = (response) => response?.http === 200 && code(response) === 'A0409'
|
||||
const evidence = (response, extra = '') => `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
const key = (context, value) => `${context.marker}-container-customs-${value}`.slice(0, 128)
|
||||
|
||||
function blocker(context, name, reason) {
|
||||
context.addCoverageGap(MODULE, MODULE, name, reason, 'contract-or-fixture', { deploymentBlocker: true })
|
||||
context.addStep(MODULE, name, null, false, reason, OPTIONS)
|
||||
throw new Error(`blocking coverage gap: ${name}: ${reason}`)
|
||||
}
|
||||
|
||||
function resolvePreparation(context) {
|
||||
const fixtures = context.createdResources?.phase3ContainerCustomsFixtures
|
||||
const routes = context.context?.phase3ContainerCustomsRoutes
|
||||
if (!validateContainerCustomsFixtures(fixtures, context.marker)) {
|
||||
blocker(context, '三类LOADED整柜fixture', '缺少本轮全买单、纯退税、混合三柜正式链路数据')
|
||||
}
|
||||
if (!validateRouteContract(routes)) {
|
||||
blocker(context, '整柜报关最终HTTP契约', '后端Controller/OpenAPI与Admin/Client最终路径尚未对齐,禁止猜测URL发请求')
|
||||
}
|
||||
return { fixtures, routes }
|
||||
}
|
||||
|
||||
async function platformDetail(context, routes, fixture) {
|
||||
return routes.platformDetail(context, { loadingPlanId: id(fixture.loadingPlanId) })
|
||||
}
|
||||
|
||||
async function assertZeroDrift(context, name, routes, fixture, before, response, acceptedCodes = ['A0409']) {
|
||||
const after = await platformDetail(context, routes, fixture)
|
||||
context.assert(MODULE, name, response,
|
||||
response?.http === 200 && acceptedCodes.includes(code(response))
|
||||
&& success(after) && zeroDrift(before.body?.data, after.body?.data),
|
||||
evidence(response, `afterRequestId=${after.requestId || 'missing'}, afterTraceId=${after.traceId || 'missing'}`), OPTIONS)
|
||||
}
|
||||
|
||||
async function runAllBuy(context, routes, fixture) {
|
||||
const detail = await platformDetail(context, routes, fixture)
|
||||
context.assert(MODULE, '全买单柜LOADED详情', detail, success(detail)
|
||||
&& detail.body?.data?.inbounds?.every?.((item) => item.taxMode === 'BUY_DECLARATION'), evidence(detail), OPTIONS)
|
||||
|
||||
const xlsx = await routes.buyDeclarationXlsx(context, { loadingPlanId: id(fixture.loadingPlanId) })
|
||||
context.assert(MODULE, '买单XLSX下载窗口返回真实工作簿', xlsx,
|
||||
xlsx.http === 200 && xlsxMagic(xlsx.bytes),
|
||||
`HTTP=${xlsx.http}, bytes=${xlsx.bytes?.length ?? 0}, contentType=${xlsx.contentType || 'missing'}`, OPTIONS)
|
||||
|
||||
const startBody = { expectedVersion: Number(detail.body?.data?.version), remark: context.marker }
|
||||
const started = await routes.platformStart(context, fixture, startBody, key(context, 'all-buy-start'))
|
||||
context.assert(MODULE, '全买单柜开始报关', started,
|
||||
success(started) && started.body?.data?.status === 'DECLARING', evidence(started), OPTIONS)
|
||||
|
||||
const completeBody = { expectedVersion: Number(started.body?.data?.version), remark: context.marker }
|
||||
const completed = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete'))
|
||||
context.assert(MODULE, '全买单柜完成并触发自动离仓意图', completed,
|
||||
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
|
||||
const replay = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete'))
|
||||
context.assert(MODULE, '全买单柜完成同键重放', replay,
|
||||
success(replay) && id(replay.body?.data?.aggregateId) === id(completed.body?.data?.aggregateId), evidence(replay), OPTIONS)
|
||||
|
||||
let outbound = await routes.outboundState(context, fixture)
|
||||
if (fixture.autoDispatchMode === 'WAIT_CONDITION') {
|
||||
context.assert(MODULE, '自动离仓失败不回滚报关完成', outbound,
|
||||
success(outbound) && outbound.body?.data?.customsStatus === 'COMPLETED'
|
||||
&& outbound.body?.data?.departed !== true && Boolean(outbound.body?.data?.retryable),
|
||||
evidence(outbound), OPTIONS)
|
||||
await routes.satisfyOutboundPrerequisites(context, fixture)
|
||||
for (let attempt = 0; attempt < 12 && outbound.body?.data?.departed !== true; attempt += 1) {
|
||||
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
outbound = await routes.outboundState(context, fixture)
|
||||
}
|
||||
}
|
||||
context.assert(MODULE, '自动或待条件重试后整柜单次离仓', outbound,
|
||||
success(outbound) && outbound.body?.data?.departed === true
|
||||
&& Number(outbound.body?.data?.departureHistoryCount) === 1,
|
||||
evidence(outbound), OPTIONS)
|
||||
}
|
||||
|
||||
async function uploadBoundary(context, routes, fixture, customer, size, suffix) {
|
||||
const content = new Uint8Array(size)
|
||||
content[0] = 0x43; content[content.length - 1] = 0x58
|
||||
return routes.merchantUploadDocument(context, fixture, customer, {
|
||||
fileName: `${context.marker}-${suffix}.customblob`, contentType: 'application/octet-stream', content,
|
||||
inboundIds: customer.inboundIds.map(id), expectedVersion: customer.expectedVersion,
|
||||
}, key(context, suffix))
|
||||
}
|
||||
|
||||
async function runRefund(context, routes, fixture) {
|
||||
const customers = fixture.customers || []
|
||||
if (customers.length < 2) blocker(context, '纯退税多客户fixture', '至少需要两个客户和可交叉验证的多对多入仓范围')
|
||||
const first = customers[0]
|
||||
const exact = await uploadBoundary(context, routes, fixture, first, EXACT_50_MB, 'exact-50m')
|
||||
context.assert(MODULE, '任意格式50MiB边界成功', exact, success(exact), evidence(exact), OPTIONS)
|
||||
const tooLargeBefore = await platformDetail(context, routes, fixture)
|
||||
const tooLarge = await uploadBoundary(context, routes, fixture, first, EXACT_50_MB + 1, 'over-50m')
|
||||
await assertZeroDrift(context, '50MiB+1拒绝且零漂移', routes, fixture, tooLargeBefore, tooLarge,
|
||||
['A0400', 'A0409', 'A0422'])
|
||||
|
||||
const crossCustomer = await routes.merchantDetail(context, fixture, customers[1], first.customerToken)
|
||||
context.assert(MODULE, '客户隔离阻止查看其他客户资料', crossCustomer,
|
||||
!success(crossCustomer), evidence(crossCustomer), OPTIONS)
|
||||
const hiddenBuy = await routes.buyDeclarationXlsx(context, { loadingPlanId: id(fixture.loadingPlanId), token: first.customerToken })
|
||||
context.assert(MODULE, '客户侧隐藏买单XLSX', hiddenBuy, hiddenBuy.http !== 200 || !xlsxMagic(hiddenBuy.bytes),
|
||||
`HTTP=${hiddenBuy.http}, bytes=${hiddenBuy.bytes?.length ?? 0}`, OPTIONS)
|
||||
|
||||
for (const customer of customers) {
|
||||
const document = await routes.merchantUploadDocument(context, fixture, customer, customer.document,
|
||||
key(context, `upload-${customer.customerId}`))
|
||||
context.assert(MODULE, `客户${customer.customerId}上传多对多资料`, document, success(document), evidence(document), OPTIONS)
|
||||
customer.documentId = id(document.body?.data?.documentId)
|
||||
customer.documentVersion = Number(document.body?.data?.source?.version)
|
||||
}
|
||||
|
||||
const mutable = customers[0]
|
||||
const replaced = await routes.merchantReplaceDocument(context, fixture, mutable, {
|
||||
...mutable.replacement, documentId: mutable.documentId, expectedVersion: mutable.documentVersion,
|
||||
}, key(context, 'replace'))
|
||||
context.assert(MODULE, '客户覆盖资料生成新版本', replaced, success(replaced), evidence(replaced), OPTIONS)
|
||||
const scoped = await routes.merchantUpdateScope(context, fixture, mutable, {
|
||||
documentId: mutable.documentId, inboundIds: mutable.updatedInboundIds.map(id),
|
||||
expectedVersion: Number(replaced.body?.data?.source?.version),
|
||||
}, key(context, 'scope'))
|
||||
context.assert(MODULE, '客户修改多对多关联', scoped, success(scoped), evidence(scoped), OPTIONS)
|
||||
const deleted = await routes.merchantDeleteDocument(context, fixture, mutable, {
|
||||
documentId: mutable.documentId, expectedVersion: Number(scoped.body?.data?.source?.version),
|
||||
}, key(context, 'delete'))
|
||||
context.assert(MODULE, '客户删除未冻结资料', deleted, success(deleted), evidence(deleted), OPTIONS)
|
||||
|
||||
const beforeMissingStart = await platformDetail(context, routes, fixture)
|
||||
const missingStart = await routes.platformStart(context, fixture, {
|
||||
expectedVersion: Number(beforeMissingStart.body?.data?.version), remark: 'missing-document-negative',
|
||||
}, key(context, 'missing-start'))
|
||||
await assertZeroDrift(context, '缺退税资料禁止开始且零漂移', routes, fixture, beforeMissingStart, missingStart)
|
||||
|
||||
const restored = await routes.merchantUploadDocument(context, fixture, mutable, mutable.document,
|
||||
key(context, `restore-${mutable.customerId}`))
|
||||
context.assert(MODULE, '删除后重新上传恢复完整退税覆盖', restored, success(restored), evidence(restored), OPTIONS)
|
||||
mutable.documentId = id(restored.body?.data?.documentId)
|
||||
mutable.documentVersion = Number(restored.body?.data?.source?.version)
|
||||
|
||||
const ready = await platformDetail(context, routes, fixture)
|
||||
const started = await routes.platformStart(context, fixture, {
|
||||
expectedVersion: Number(ready.body?.data?.version), remark: context.marker,
|
||||
}, key(context, 'refund-start'))
|
||||
context.assert(MODULE, '纯退税柜资料齐全后开始并冻结', started,
|
||||
success(started) && started.body?.data?.status === 'DECLARING'
|
||||
&& started.body?.data?.documents?.every?.((item) => item.frozen === true),
|
||||
evidence(started), OPTIONS)
|
||||
|
||||
const frozenSnapshot = started.body?.data
|
||||
const lockedOperations = [
|
||||
['上传', () => routes.merchantUploadDocument(context, fixture, mutable, {
|
||||
...mutable.document, fileName: `${context.marker}-locked.customblob`, content: Uint8Array.from([1]),
|
||||
}, key(context, 'locked-upload'))],
|
||||
['覆盖', () => routes.merchantReplaceDocument(context, fixture, mutable, {
|
||||
...mutable.replacement, documentId: mutable.documentId, expectedVersion: mutable.documentVersion,
|
||||
}, key(context, 'locked-replace'))],
|
||||
['改关联', () => routes.merchantUpdateScope(context, fixture, mutable, {
|
||||
documentId: mutable.documentId, inboundIds: mutable.inboundIds.map(id), expectedVersion: mutable.documentVersion,
|
||||
}, key(context, 'locked-scope'))],
|
||||
['删除', () => routes.merchantDeleteDocument(context, fixture, mutable, {
|
||||
documentId: mutable.documentId, expectedVersion: mutable.documentVersion,
|
||||
}, key(context, 'locked-delete'))],
|
||||
]
|
||||
for (const [label, request] of lockedOperations) {
|
||||
const response = await request()
|
||||
await assertZeroDrift(context, `开始后${label}锁定A0409零漂移`, routes, fixture,
|
||||
{ body: { data: frozenSnapshot } }, response)
|
||||
}
|
||||
|
||||
const documents = frozenSnapshot?.documents || []
|
||||
for (const [index, document] of documents.entries()) {
|
||||
const completion = await routes.platformUploadCompletion(context, fixture, {
|
||||
documentId: id(document.documentId), expectedVersion: Number(document.source?.version),
|
||||
fileName: `${context.marker}-completion-${index + 1}.done`,
|
||||
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]),
|
||||
}, key(context, `completion-${index + 1}`))
|
||||
context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion,
|
||||
success(completion)
|
||||
&& zeroDrift(completion.body?.data?.inboundIds, document.inboundIds),
|
||||
evidence(completion, `sourceDocumentId=${id(document.documentId)}`), OPTIONS)
|
||||
if (index + 1 < documents.length) {
|
||||
const beforeEarlyComplete = await platformDetail(context, routes, fixture)
|
||||
const early = await routes.platformComplete(context, fixture, {
|
||||
expectedVersion: Number(beforeEarlyComplete.body?.data?.version), remark: 'not-last-completion',
|
||||
}, key(context, `early-complete-${index + 1}`))
|
||||
await assertZeroDrift(context, '最后一份完成文件前禁止整柜完成', routes, fixture, beforeEarlyComplete, early)
|
||||
}
|
||||
}
|
||||
const completedReady = await platformDetail(context, routes, fixture)
|
||||
const completed = await routes.platformComplete(context, fixture, {
|
||||
expectedVersion: Number(completedReady.body?.data?.version), remark: context.marker,
|
||||
}, key(context, 'refund-complete'))
|
||||
context.assert(MODULE, '最后一份完成文件后纯退税柜完成', completed,
|
||||
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
|
||||
|
||||
const firstDocument = documents[0]
|
||||
if (firstDocument) {
|
||||
const platformDownload = await routes.platformDownload(context, fixture, {
|
||||
documentId: id(firstDocument.documentId), completion: true,
|
||||
})
|
||||
context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload,
|
||||
platformDownload.http === 200 && platformDownload.bytes?.length > 0,
|
||||
`HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS)
|
||||
const merchantDownload = await routes.merchantDownload(context, fixture, mutable, {
|
||||
documentId: id(firstDocument.documentId), completion: true,
|
||||
})
|
||||
context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload,
|
||||
merchantDownload.http === 200 && merchantDownload.bytes?.length > 0,
|
||||
`HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
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 concurrentKey = key(context, 'mixed-start-concurrent')
|
||||
const responses = await Promise.all([
|
||||
routes.platformStart(context, fixture, body, concurrentKey),
|
||||
routes.platformStart(context, fixture, body, concurrentKey),
|
||||
])
|
||||
context.assert(MODULE, '开始并发双击收敛同一结果', responses[1],
|
||||
responses.every(success) && zeroDrift(responses[0].body?.data, responses[1].body?.data),
|
||||
responses.map((response) => evidence(response)).join('; '), OPTIONS)
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const { fixtures, routes } = 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]))
|
||||
}
|
||||
Reference in New Issue
Block a user