Files
2026-10-21 07:53:31 +08:00

667 lines
44 KiB
JavaScript

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
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发请求')
}
if (requestedKinds.includes('MIXED') && ['platformDeclarationBatches', 'platformCreateDeclaration',
'platformSubmitDeclaration', 'platformClearanceResults', 'platformRecordClearance',
'platformResolveClearance'].some((name) => typeof routes[name] !== 'function')) {
blocker(context, '正式申报与人工海关结果HTTP契约', '缺少已核对Controller的正式申报或海关结果适配器')
}
return { fixtures, routes, requestedKinds }
}
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), confirm: true, 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), confirm: true, 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), confirm: true, 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 initialSent = await routes.platformSendInitialToBroker(context, fixture, {
expectedVersion: Number(started.body?.data?.version), confirm: true, remark: context.marker,
}, key(context, 'refund-send-initial-to-broker'))
context.assert(MODULE, '冻结资料首次发送报关行并生成独立批次', initialSent,
success(initialSent) && initialSent.body?.data?.status === 'DECLARING'
&& initialSent.body?.data?.brokerSubmission?.status === 'SENT'
&& typeof initialSent.body?.data?.brokerSubmission?.batchNo === 'string'
&& initialSent.body?.data?.brokerSubmission?.batchNo.length > 0,
evidence(initialSent), OPTIONS)
const frozenSnapshot = initialSent.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 receipt = await routes.platformUploadReceipt(context, fixture, {
documentId: id(document.documentId), expectedVersion: 0,
fileName: `${context.marker}-broker-receipt-${index + 1}.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x52, index + 1]),
}, key(context, `receipt-${index + 1}`))
context.assert(MODULE, `逐份上传报关行回执${index + 1}`, receipt,
success(receipt) && zeroDrift(receipt.body?.data?.inboundIds, document.inboundIds),
evidence(receipt, `sourceDocumentId=${id(document.documentId)}`), OPTIONS)
}
const beforeConfirm = await platformDetail(context, routes, fixture)
const earlyFinal = await routes.platformUploadFinal(context, fixture, {
documentId: id(documents[0]?.documentId), expectedVersion: 0,
fileName: `${context.marker}-early-final.pdf`, contentType: 'application/pdf', content: Uint8Array.from([0x45]),
}, key(context, 'early-final'))
await assertZeroDrift(context, '回执未确认且未发送执行时禁止上传完税文件', routes, fixture, beforeConfirm, earlyFinal)
const confirmed = []
for (const customer of customers) {
const document = documents.find((item) => (item.inboundIds || []).some((inboundId) =>
customer.inboundIds.map(id).includes(id(inboundId))))
if (!document) continue
const current = await platformDetail(context, routes, fixture)
const live = current.body?.data?.documents?.find((item) => id(item.documentId) === id(document.documentId))
const version = Number(live?.receipt?.version || 0)
const response = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: id(document.documentId), expectedVersion: version, confirm: true, remark: context.marker,
}, key(context, `refund-receipt-confirm-${customer.customerId}`))
confirmed.push(response)
}
const afterConfirm = await platformDetail(context, routes, fixture)
context.assert(MODULE, '客户确认整份回执后仍处于报关中', afterConfirm,
success(afterConfirm) && afterConfirm.body?.data?.status === 'DECLARING' && confirmed.every(success),
evidence(afterConfirm), OPTIONS)
const submitted = await routes.platformSubmitToBroker(context, fixture,
{ expectedVersion: Number(afterConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'submit-to-broker'))
context.assert(MODULE, '平台发送客户确认回执给报关行执行', submitted,
success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS)
const submitReplay = await routes.platformSubmitToBroker(context, fixture,
{ expectedVersion: Number(afterConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'submit-to-broker'))
context.assert(MODULE, '发送报关行执行同键重放', submitReplay, success(submitReplay), evidence(submitReplay), OPTIONS)
for (const [index, document] of documents.entries()) {
const finalDocument = await routes.platformUploadFinal(context, fixture, {
documentId: id(document.documentId), expectedVersion: 0,
fileName: `${context.marker}-customs-tax-final-${index + 1}.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x46, index + 1]),
}, key(context, `final-${index + 1}`))
context.assert(MODULE, `逐份上传真实报关完税文件${index + 1}`, finalDocument,
success(finalDocument) && zeroDrift(finalDocument.body?.data?.inboundIds, document.inboundIds),
evidence(finalDocument), OPTIONS)
}
const completed = await platformDetail(context, routes, fixture)
context.assert(MODULE, '真实报关完税文件齐全后纯退税柜完成', completed,
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
const firstDocument = documents[0]
if (firstDocument) {
const platformReceipt = await routes.platformDownloadReceipt(context, fixture, { documentId: id(firstDocument.documentId) })
context.assert(MODULE, 'Admin下载报关行回执', platformReceipt,
platformReceipt.http === 200 && platformReceipt.bytes?.length > 0, evidence(platformReceipt), OPTIONS)
const platformFinal = await routes.platformDownloadFinal(context, fixture, { documentId: id(firstDocument.documentId) })
context.assert(MODULE, 'Admin下载真实报关完税文件', platformFinal,
platformFinal.http === 200 && platformFinal.bytes?.length > 0, evidence(platformFinal), OPTIONS)
const merchantFinal = await routes.merchantDownloadFinal(context, fixture, mutable, { documentId: id(firstDocument.documentId) })
context.assert(MODULE, 'Client仅下载本客户真实报关完税文件', merchantFinal,
merchantFinal.http === 200 && merchantFinal.bytes?.length > 0, evidence(merchantFinal), OPTIONS)
}
}
export function customsDocumentFacts(container) {
return { loadingPlanId: id(container?.loadingPlanId), containerBookingId: id(container?.containerBookingId),
containerNo: container?.containerNo, status: container?.status,
documents: container?.documents || [], inbounds: container?.inbounds || [],
brokerSubmittedAt: container?.brokerSubmittedAt, brokerSubmission: container?.brokerSubmission }
}
export async function verifyMixedReceiptIsolation(context, routes, fixture, firstReceipt) {
const [first, other] = fixture.customers
const receiptVersion = Number(firstReceipt.body?.data?.receipt?.version)
const firstConfirmed = await routes.merchantConfirmReceipt(context, fixture, first, {
documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm'))
const firstReplay = await routes.merchantConfirmReceipt(context, fixture, first, {
documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm'))
context.assert(MODULE, '客户确认回执后仍未完成整柜', firstReplay,
success(firstConfirmed) && success(firstReplay) && firstReplay.body?.data?.status === 'DECLARING',
evidence(firstReplay), OPTIONS)
const beforeOther = await platformDetail(context, routes, fixture)
const confirmedFirst = beforeOther.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)
for (const customer of fixture.customers.slice(1)) {
const uploaded = await routes.platformUploadReceipt(context, fixture, {
documentId: customer.documentId, expectedVersion: 0,
fileName: `${context.marker}-${customer.customerId}-receipt.customblob`,
contentType: 'application/octet-stream', content: Uint8Array.from([0x52, 0x32]),
}, key(context, `receipt-${customer.customerId}`))
context.assert(MODULE, '第二客户报关回执独立上传', uploaded, success(uploaded), evidence(uploaded), OPTIONS)
}
const foreignConfirm = await routes.merchantConfirmReceipt(context, fixture, other, {
documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'foreign-receipt-confirm'))
const foreignBefore = await platformDetail(context, routes, fixture)
context.assert(MODULE, '第二客户不能确认其他客户回执且原确认保持', foreignConfirm,
foreignConfirm.http === 200 && ['A0403', 'A0404'].includes(code(foreignConfirm))
&& zeroDrift(confirmedFirst, foreignBefore.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)),
evidence(foreignConfirm), OPTIONS)
const otherDocument = foreignBefore.body?.data?.documents?.find((item) => id(item.documentId) === other.documentId)
const rejected = await routes.merchantConfirmReceipt(context, fixture, other, {
documentId: other.documentId, expectedVersion: Number(otherDocument?.receipt?.version),
confirm: false, remark: `${context.marker}-回执资料需修改`,
}, key(context, 'mixed-other-reject'))
const afterReject = await platformDetail(context, routes, fixture)
const rejectedDocument = afterReject.body?.data?.documents?.find((item) => id(item.documentId) === other.documentId)
context.assert(MODULE, '第二客户打回不撤销首个客户整份确认', rejected,
success(rejected) && rejectedDocument?.receiptStatus === 'REJECTED'
&& zeroDrift(confirmedFirst, afterReject.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)),
evidence(rejected), OPTIONS)
const earlyExecution = await routes.platformSubmitToBroker(context, fixture, {
expectedVersion: Number(afterReject.body?.data?.version), confirm: true, remark: context.marker,
}, key(context, 'mixed-unconfirmed-execution'))
await assertZeroDrift(context, '未全部客户确认禁止发送报关行执行', routes, fixture, afterReject, earlyExecution)
const earlyDeclaration = await routes.platformCreateDeclaration(context, fixture, {
expectedVersion: Number(afterReject.body?.data?.version), remark: context.marker,
}, key(context, 'mixed-unconfirmed-declaration'))
await assertZeroDrift(context, '未全部确认禁止创建正式申报批次', routes, fixture, afterReject, earlyDeclaration)
const replacement = await routes.platformUploadReceipt(context, fixture, {
documentId: other.documentId, expectedVersion: Number(rejectedDocument?.receipt?.version),
fileName: `${context.marker}-${other.customerId}-receipt-v2.customblob`,
contentType: 'application/octet-stream', content: Uint8Array.from([0x52, 0x33]),
}, key(context, 'mixed-other-receipt-replacement'))
const afterReplacement = await platformDetail(context, routes, fixture)
context.assert(MODULE, '替换打回客户回执只重置该客户待确认且保留他人确认', replacement,
success(replacement) && replacement.body?.data?.receiptStatus === 'PENDING'
&& zeroDrift(confirmedFirst, afterReplacement.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)),
evidence(replacement), OPTIONS)
for (const customer of fixture.customers.slice(1)) {
const current = await platformDetail(context, routes, fixture)
const document = current.body?.data?.documents?.find((item) => id(item.documentId) === customer.documentId)
const confirmation = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: customer.documentId, expectedVersion: Number(document?.receipt?.version),
confirm: true, remark: context.marker,
}, key(context, `mixed-other-confirm-${customer.customerId}`))
context.assert(MODULE, '剩余客户确认自己当前整份回执', confirmation, success(confirmation), evidence(confirmation), OPTIONS)
}
const confirmed = await platformDetail(context, routes, fixture)
context.assert(MODULE, '同柜全部客户确认齐全仍等待平台发送执行', confirmed,
success(confirmed) && confirmed.body?.data?.documents?.every((item) => item.receiptStatus === 'CONFIRMED')
&& confirmed.body?.data?.brokerSubmittedAt == null && confirmed.body?.data?.status === 'DECLARING',
evidence(confirmed), OPTIONS)
return confirmed
}
export async function verifyFormalDeclarationAndExceptions(context, routes, fixture) {
const before = await platformDetail(context, routes, fixture)
const baseline = customsDocumentFacts(before.body?.data)
const created = await routes.platformCreateDeclaration(context, fixture, {
expectedVersion: Number(before.body?.data?.version), remark: context.marker,
}, key(context, 'formal-create'))
context.assert(MODULE, '平台人工创建正式申报批次', created,
success(created) && created.body?.data?.status === 'READY', evidence(created), OPTIONS)
if (!success(created)) blocker(context, '正式申报批次', '创建正式申报批次失败,保留资料并停止结果写入')
const batchId = id(created.body.data.id)
const batches = () => routes.platformDeclarationBatches(context, fixture)
const batch = async () => {
const response = await batches()
if (!success(response)) blocker(context, '正式申报批次回查', evidence(response))
const value = response.body.data.find((item) => id(item.id) === batchId)
if (!value) blocker(context, '正式申报批次回查', '本轮批次不在当前柜列表')
return value
}
const submitted = await routes.platformSubmitDeclaration(context, fixture, batchId, {
expectedVersion: Number(created.body.data.version), customsEntryNo: `${context.marker}-ENTRY`.slice(-64), remark: context.marker,
}, key(context, 'formal-submit'))
context.assert(MODULE, '人工回填报关单号提交正式申报', submitted,
success(submitted) && submitted.body?.data?.status === 'SUBMITTED'
&& Boolean(submitted.body?.data?.customsEntryNo), evidence(submitted), OPTIONS)
if (!success(submitted)) blocker(context, '正式申报提交', '正式提交失败,禁止继续人工录入海关结果')
const local = new Date()
const occurredAt = `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}T${String(local.getHours()).padStart(2, '0')}:${String(local.getMinutes()).padStart(2, '0')}:${String(local.getSeconds()).padStart(2, '0')}`
const missingRemarkBatch = await batch()
const invalidOther = await routes.platformRecordClearance(context, fixture, batchId, {
expectedVersion: Number(missingRemarkBatch.version), resultType: 'OTHER', occurredAt, resolution: '', resolved: false,
}, key(context, 'other-missing-remark'))
const afterInvalid = await batch()
context.assert(MODULE, '海关其他异常必须填写备注且批次结果零漂移', invalidOther,
invalidOther.http === 200 && code(invalidOther) === 'A0400' && zeroDrift(missingRemarkBatch, afterInvalid),
evidence(invalidOther), OPTIONS)
const opened = []
for (const resultType of ['INSPECTION', 'SUPPLEMENT_REQUIRED', 'DETAINED', 'OTHER']) {
const current = await batch()
const recorded = await routes.platformRecordClearance(context, fixture, batchId, {
expectedVersion: Number(current.version), resultType, occurredAt,
resolution: `${context.marker}-${resultType}-人工记录`, resolved: false,
}, key(context, `clearance-${resultType}`))
context.assert(MODULE, `平台人工标记海关异常${resultType}`, recorded,
success(recorded) && recorded.body?.data?.resultType === resultType && recorded.body?.data?.resultStatus === 'OPEN',
evidence(recorded), OPTIONS)
if (!success(recorded)) blocker(context, '人工海关异常', `${resultType}保存失败`)
opened.push(id(recorded.body.data.id))
}
const pending = await batch()
const released = await routes.platformRecordClearance(context, fixture, batchId, {
expectedVersion: Number(pending.version), resultType: 'RELEASED', occurredAt,
resolution: `${context.marker}-人工录入放行短信`, resolved: true,
}, key(context, 'clearance-released-with-open'))
const stillPending = await batch()
context.assert(MODULE, '已有未解除异常时新增已处理结果不能提前关闭申报批次', released,
success(released) && stillPending.status === 'RESULT_PENDING'
&& stillPending.results.filter((item) => item.resultStatus === 'OPEN').length === opened.length,
evidence(released), OPTIONS)
for (const [index, resultId] of opened.entries()) {
const current = await batch()
const resolved = await routes.platformResolveClearance(context, fixture, resultId, {
expectedVersion: Number(current.version), resolution: `${context.marker}-平台人工确认解除`,
}, key(context, `clearance-resolve-${resultId}`))
const after = await batch()
context.assert(MODULE, `人工解除海关异常并保留剩余门禁${index + 1}`, resolved,
success(resolved) && resolved.body?.data?.resultStatus === 'RESOLVED'
&& after.status === (index === opened.length - 1 ? 'CLOSED' : 'RESULT_PENDING')
&& after.results.filter((item) => item.resultStatus === 'OPEN').length === opened.length - index - 1,
evidence(resolved), OPTIONS)
}
const results = await routes.platformClearanceResults(context, fixture, batchId)
const after = await platformDetail(context, routes, fixture)
context.assert(MODULE, '海关人工处理保留客户资料回执确认原柜与报关四态', after,
success(results) && results.body?.data?.length === 5 && results.body.data.every((item) => item.resultStatus === 'RESOLVED')
&& success(after) && zeroDrift(baseline, customsDocumentFacts(after.body?.data)), evidence(after), OPTIONS)
fixture.declarationBatchId = batchId
fixture.clearanceResultIds = [...opened, id(released.body?.data?.id)]
}
async function runMixedConcurrency(context, routes, fixture) {
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 === customer.inboundIds.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 routes.merchantPlatformDetail(context, fixture)
context.assert(MODULE, 'Client身份禁止读取平台整柜报关入口', merchantPlatform,
merchantPlatform?.http === 403 || (merchantPlatform?.http === 200
&& ['A0401', 'A0403', 'S0503'].includes(code(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 === customer.inboundIds.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)
if (fixture.customers.length < 2) blocker(context, '同柜多退税客户', '必须由本轮正式装柜形成两个独立退税客户')
for (const other of fixture.customers.slice(1)) {
const isolated = await routes.merchantDetail(context, fixture, other)
context.assert(MODULE, '第二客户只见自身退税入仓票', isolated,
success(isolated) && isolated.body?.data?.inbounds?.length === other.inboundIds.length
&& isolated.body.data.inbounds.every((item) => other.inboundIds.includes(id(item.inboundId))),
evidence(isolated), OPTIONS)
const otherUpload = await routes.merchantUploadDocument(context, fixture, other, other.document,
key(context, `mixed-tax-upload-${other.customerId}`))
context.assert(MODULE, '第二退税客户独立上传资料', otherUpload,
success(otherUpload) && zeroDrift(otherUpload.body?.data?.inboundIds, other.inboundIds), evidence(otherUpload), OPTIONS)
if (!success(otherUpload)) blocker(context, '第二客户退税资料', '本轮第二客户资料上传失败,禁止继续开始')
other.documentId = id(otherUpload.body.data.documentId)
}
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],
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.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 initialSent = await routes.platformSendInitialToBroker(context, fixture, {
expectedVersion: Number(frozen?.version), confirm: true, remark: context.marker,
}, key(context, 'mixed-send-initial-to-broker'))
context.assert(MODULE, '混合柜冻结资料首次发送报关行并生成独立批次', initialSent,
success(initialSent) && initialSent.body?.data?.status === 'DECLARING'
&& initialSent.body?.data?.brokerSubmission?.status === 'SENT'
&& typeof initialSent.body?.data?.brokerSubmission?.batchNo === 'string'
&& initialSent.body?.data?.brokerSubmission?.batchNo.length > 0,
evidence(initialSent), OPTIONS)
const receiptRequest = {
documentId: customer.documentId, expectedVersion: 0,
fileName: `${context.marker}-mixed-broker-receipt.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x52, 0x45, 0x43]),
}
const receiptKey = key(context, 'mixed-receipt-upload')
const receipt = await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey)
const receiptReplay = success(receipt)
? await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) : null
const receiptConverged = success(receipt) && success(receiptReplay)
&& id(receipt.body?.data?.documentId) === id(receiptReplay.body?.data?.documentId)
&& id(receipt.body?.data?.receipt?.fileId) === id(receiptReplay.body?.data?.receipt?.fileId)
context.assert(MODULE, '报关行回执同键重放收敛', receiptReplay || receipt, receiptConverged,
[receipt, receiptReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
const receiptDifferent = await routes.platformUploadReceipt(context, fixture,
{ ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey)
context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS)
const receiptConfirm = await verifyMixedReceiptIsolation(context, routes, fixture, receipt)
const submitted = await routes.platformSubmitToBroker(context, fixture,
{ expectedVersion: Number(receiptConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'mixed-submit-to-broker'))
context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted,
success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS)
await verifyFormalDeclarationAndExceptions(context, routes, fixture)
const finalRequest = {
documentId: customer.documentId, expectedVersion: 0,
fileName: `${context.marker}-mixed-customs-tax-final.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x46, 0x49, 0x4e]),
}
const finalKey = key(context, 'mixed-final')
const finalDocument = await routes.platformUploadFinal(context, fixture, finalRequest, finalKey)
const finalReplay = success(finalDocument)
? await routes.platformUploadFinal(context, fixture, finalRequest, finalKey) : null
const finalConverged = success(finalDocument) && success(finalReplay)
&& id(finalDocument.body?.data?.finalDocument?.fileId) === id(finalReplay.body?.data?.finalDocument?.fileId)
context.assert(MODULE, '真实报关完税文件同键重放收敛', finalReplay || finalDocument, finalConverged,
[finalDocument, finalReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
if (!finalConverged) {
blocker(context, '完税文件幂等收敛', '完成文件未成功或同键重放未收敛,已停止后续自动离仓写入')
}
const finalDifferent = await routes.platformUploadFinal(context, fixture,
{ ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey)
context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS)
const partiallyCompleted = await platformDetail(context, routes, fixture)
context.assert(MODULE, '首个客户完成文件不能提前完成多客户整柜', partiallyCompleted,
success(partiallyCompleted) && partiallyCompleted.body?.data?.status === 'DECLARING', evidence(partiallyCompleted), OPTIONS)
for (const other of fixture.customers.slice(1)) {
const otherFinal = await routes.platformUploadFinal(context, fixture, {
documentId: other.documentId, expectedVersion: 0,
fileName: `${context.marker}-${other.customerId}-final.customblob`,
contentType: 'application/octet-stream', content: Uint8Array.from([0x46, 0x32]),
}, key(context, `mixed-final-${other.customerId}`))
context.assert(MODULE, '其他客户真实完成文件独立回传', otherFinal,
success(otherFinal) && zeroDrift(otherFinal.body?.data?.inboundIds, other.inboundIds), evidence(otherFinal), 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.merchantDownloadFinal(context, fixture, customer, {
documentId: customer.documentId,
})
context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload,
completionDownload.http === 200 && completionDownload.bytes?.length > 0,
`HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS)
for (const other of fixture.customers.slice(1)) {
const ownDetail = await routes.merchantDetail(context, fixture, other)
context.assert(MODULE, '整柜完成后第二客户详情只包含自身资料与完成文件', ownDetail,
success(ownDetail) && ownDetail.body?.data?.documents?.length === 1
&& ownDetail.body.data.documents.every((item) => id(item.tenantId) === other.customerId
&& id(item.documentId) === other.documentId && item.finalDocument?.fileId),
evidence(ownDetail), OPTIONS)
const foreignDownload = await routes.merchantDownloadFinal(context, fixture, other, {
documentId: customer.documentId,
})
context.assert(MODULE, '第二客户不能下载其他客户完成文件', foreignDownload,
foreignDownload.http === 200 && code(foreignDownload) === 'A0404', evidence(foreignDownload), OPTIONS)
}
}
export async function run(context) {
const { fixtures, routes, requestedKinds } = resolvePreparation(context)
const byKind = new Map(fixtures.containers.map((item) => [item.kind, item]))
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]))
}
}