test: strengthen customs and fulfillment acceptance boundaries

This commit is contained in:
2026-09-30 12:58:15 +08:00
parent 125ca34ba4
commit 80632ba409
13 changed files with 1616 additions and 40 deletions
@@ -33,6 +33,11 @@ function resolvePreparation(context) {
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 }
}
@@ -260,6 +265,168 @@ async function runRefund(context, routes, fixture) {
}
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), batchNo: `${context.marker}-DECL`.slice(-64), 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
@@ -278,7 +445,7 @@ async function runMixedConcurrency(context, routes, fixture) {
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
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)
@@ -303,7 +470,7 @@ async function runMixedConcurrency(context, routes, fixture) {
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
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)
@@ -314,6 +481,21 @@ async function runMixedConcurrency(context, routes, fixture) {
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'
@@ -372,22 +554,12 @@ async function runMixedConcurrency(context, routes, fixture) {
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 receiptVersion = Number(receipt.body?.data?.receipt?.version || 0)
const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm'))
const receiptConfirmReplay = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm'))
context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm,
success(receiptConfirm) && success(receiptConfirmReplay)
&& receiptConfirm.body?.data?.status === 'DECLARING'
&& receiptConfirmReplay.body?.data?.status === 'DECLARING',
[receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), 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`,
@@ -408,6 +580,19 @@ async function runMixedConcurrency(context, routes, 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)
@@ -451,6 +636,19 @@ async function runMixedConcurrency(context, routes, fixture) {
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) {