From fd4afd6ae673606091c03214cdab7241675bbcb5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 18:00:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/resources/openapi/firstleg-v1.yaml | 2 +- ...easurementEvidenceOpenApiContractTest.java | 3 + .../acceptance-finance-full-contract.test.mjs | 7 +- scripts/admin-test-env-acceptance.mjs | 10 +- .../finance-downloads-contract.test.mjs | 53 ++++ .../finance-downloads.mjs | 263 ++++++++++++++++++ .../full-regression-entry-contract.test.mjs | 6 +- 7 files changed, 336 insertions(+), 8 deletions(-) create mode 100644 scripts/admin-test-env-modules/finance-downloads-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/finance-downloads.mjs diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 351209fde..8a73bb6cf 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1034,7 +1034,7 @@ components: currentHoldReason: {type: string, nullable: true} totalPackageCount: {type: integer, nullable: true} locationCodes: {type: string, nullable: true} - actualGrossWeightKg: {type: number, nullable: true} + actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端权威货物总重量 KG;存在已确认称重图片时为全部 CONFIRMED netWeightKg 之和,消费者不得从图片重算} actualNetWeightKg: {type: number, nullable: true} actualVolumeCbm: {type: number, nullable: true} volumeWeightKg: {type: number, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index 549c697f9..58a1d08cc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -35,6 +35,9 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map weightProperties = properties(schemas, "InboundWeightResult"); assertThat(weightProperties).containsKeys( "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt"); + assertThat(stringMap(properties(schemas, "UnifiedInboundOrderSummary") + .get("actualGrossWeightKg")).get("description").toString()) + .contains("服务端权威货物总重量", "CONFIRMED netWeightKg", "不得从图片重算"); } private static Map properties(Map schemas, String schemaName) { diff --git a/scripts/acceptance-finance-full-contract.test.mjs b/scripts/acceptance-finance-full-contract.test.mjs index c89ee67d8..3f77ae4ac 100644 --- a/scripts/acceptance-finance-full-contract.test.mjs +++ b/scripts/acceptance-finance-full-contract.test.mjs @@ -9,11 +9,12 @@ import { AcceptanceRun } from './admin-test-env-acceptance.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'admin-test-env-acceptance.mjs'), 'utf8') -test('默认全量按 fixture、reconciliation、settlement 顺序接入且不重复旧 finance', () => { - const match = source.match(/const DEFAULT_MODULES = \[(.*?)\]/s) +test('默认全量按 fixture、reconciliation、settlement、downloads 顺序接入且不重复旧 finance', () => { + const match = source.match(/const CONFIGURED_MODULES = \[(.*?)\]/s) assert.ok(match) const modules = [...match[1].matchAll(/'([^']+)'/g)].map((item) => item[1]) - assert.deepEqual(modules.slice(-3), ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']) + const financeStart = modules.indexOf('finance-full-fixtures') + assert.deepEqual(modules.slice(financeStart, financeStart + 4), ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads']) assert.equal(modules.filter((item) => item === 'finance').length, 1) assert.ok(modules.indexOf('finance') < modules.indexOf('finance-full-fixtures')) assert.match(source, /fullFinanceChain/) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 4120fe7cf..be0587874 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -12,7 +12,7 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -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', '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-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', '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', '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-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] export function normalizeModuleOrder(modules) { const normalized = [...modules] @@ -62,6 +62,14 @@ export function normalizeModuleOrder(modules) { const missing = prerequisites.filter((name) => !normalized.includes(name)) if (missing.length > 0) normalized.splice(firstFinanceIndex, 0, ...missing) } + const financeDownloadsIndex = normalized.indexOf('finance-downloads') + if (financeDownloadsIndex >= 0 && financeChain.some((name) => normalized.includes(name))) { + for (const prerequisite of financeChain) { + if (!normalized.includes(prerequisite)) normalized.splice(normalized.indexOf('finance-downloads'), 0, prerequisite) + } + normalized.splice(normalized.indexOf('finance-downloads'), 1) + normalized.splice(normalized.indexOf('finance-settlement-full') + 1, 0, 'finance-downloads') + } if (normalized.includes('fee-items')) { const prerequisites = ['merchant-qualification-fixture', 'orders'] const feeItemsIndex = normalized.indexOf('fee-items') diff --git a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs new file mode 100644 index 000000000..889ff8803 --- /dev/null +++ b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { EXPORT_SPECS, run } from './finance-downloads.mjs' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'finance-downloads.mjs'), 'utf8') + +test('八类 Finance XLSX 契约均有正式表头并在完整链末尾执行', () => { + assert.deepEqual(Object.keys(EXPORT_SPECS), [ + 'FINANCE_CUSTOMER_ACCOUNT_LIST', 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', 'FINANCE_FEE_SOURCE_LIST', + 'FINANCE_RECONCILIATION_STATEMENT_LIST', 'FINANCE_RECEIVABLE_SETTLEMENT_LIST', + 'FINANCE_RECEIVABLE_SETTLEMENT_DETAIL', 'FINANCE_PAYABLE_SETTLEMENT_LIST', + 'FINANCE_PAYABLE_SETTLEMENT_DETAIL', + ]) + assert.ok(Object.values(EXPORT_SPECS).every((headers) => headers.length >= 9)) + const order = normalizeModuleOrder(['finance-downloads', 'finance-settlement-full']) + assert.deepEqual(order.slice(-4), [ + 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', + ]) +}) + +test('验收只使用本轮 marker fixture,解析真实 XLSX 并覆盖门禁和失败信息', () => { + assert.match(source, /fixture\.marker !== context\.marker/) + assert.match(source, /parseXlsx\(download\.bytes\)/) + assert.match(source, /processedRows/) + assert.match(source, /60 秒同参请求合并/) + assert.match(source, /同用户并发导出门禁/) + assert.match(source, /无 Finance export 权限拒绝创建/) + assert.match(source, /Provider 失败信息可见/) + assert.match(source, /9223372036854775806/) + assert.doesNotMatch(source, /exportType:\s*['"]CUSTOMER_LIST/) +}) + +test('未部署时能力探测后记录部署前置,不借历史业务数据', async () => { + const steps = []; const gaps = [] + const context = { + marker: 'ADMIN-API-PROBE', createdResources: {}, + apiSuccess(module, name) { steps.push({ module, name, passed: false }); return null }, + assert(module, name) { steps.push({ module, name, passed: false }) }, + addStep(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, + addCoverageGap(...args) { gaps.push(args) }, + async request() { return { http: 200, body: { code: 'A0403', message: 'not deployed' } } }, + } + await run(context) + assert.equal(context.createdResources.financeExportTasks.marker, context.marker) + assert.equal(gaps.length, 1) + assert.match(gaps[0][3], /部署后重跑完整链/) + assert.equal(steps.some((step) => step.name === '读取客户账户筛选事实'), false) +}) diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs new file mode 100644 index 000000000..cc6c0f743 --- /dev/null +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -0,0 +1,263 @@ +import { inflateRawSync } from 'node:zlib' + +const MODULE = 'finance-downloads' +const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'EXPIRED']) +const OPTIONS = { scenario: 'finance-download-center', deploymentBlocker: true } + +export const EXPORT_SPECS = Object.freeze({ + FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '待付(CNY)', '客户状态', '最近变动'], + FINANCE_CUSTOMER_ACCOUNT_LEDGER: ['流水号', '发生时间', '业务类型', '关联单据', '应收变动(CNY)', '应付变动(CNY)', '变动后未收(CNY)', '操作人', '备注'], + FINANCE_FEE_SOURCE_LIST: ['客户', '收付方向', '来源类型', '来源单据ID', '来源版本', '入仓号', '柜次号', '业务发生日', '费用项目', '计费量', '单价(CNY)', '金额(CNY)', '审核状态', '参与结算', '来源状态', '已生成对账单'], + FINANCE_RECONCILIATION_STATEMENT_LIST: ['对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额(CNY)', '已收金额(CNY)', '未收金额(CNY)', '是否结清', '审核状态', '备注', '创建人', '创建时间', '更新人', '更新时间'], + FINANCE_RECEIVABLE_SETTLEMENT_LIST: ['结算单号', '客户', '供应商', '货币', '总金额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '业务期间', '生成日期', '到期日期', '状态'], + FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: ['结算单号', '客户', '供应商', '费用项目', '入仓号', '柜次号', '业务发生日', '明细金额(CNY)', '结算总额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '状态'], + FINANCE_PAYABLE_SETTLEMENT_LIST: ['结算单号', '客户', '供应商', '货币', '总金额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '业务期间', '生成日期', '到期日期', '状态'], + FINANCE_PAYABLE_SETTLEMENT_DETAIL: ['结算单号', '客户', '供应商', '费用项目', '入仓号', '柜次号', '业务发生日', '明细金额(CNY)', '结算总额(CNY)', '已收/付金额(CNY)', '未收/付金额(CNY)', '状态'], +}) + +function id(value) { return value == null ? '' : String(value) } +function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +function values(value) { + if (Array.isArray(value)) return value + for (const key of ['records', 'data', 'items', 'list']) if (Array.isArray(value?.[key])) return value[key] + return [] +} +function xml(value) { + return String(value ?? '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + .replace(/'/g, "'").replace(/&/g, '&') +} +function columnIndex(reference) { + const letters = reference.match(/[A-Z]+/i)?.[0]?.toUpperCase() || 'A' + let result = 0 + for (const letter of letters) result = result * 26 + letter.charCodeAt(0) - 64 + return result - 1 +} + +function zipEntries(bytes) { + const buffer = Buffer.from(bytes) + let end = -1 + for (let index = buffer.length - 22; index >= Math.max(0, buffer.length - 65_557); index -= 1) { + if (buffer.readUInt32LE(index) === 0x06054b50) { end = index; break } + } + if (end < 0) throw new Error('XLSX ZIP central directory is missing') + const count = buffer.readUInt16LE(end + 10) + let offset = buffer.readUInt32LE(end + 16) + const entries = new Map() + for (let index = 0; index < count; index += 1) { + if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error('XLSX ZIP entry is invalid') + const method = buffer.readUInt16LE(offset + 10) + const compressedSize = buffer.readUInt32LE(offset + 20) + const nameLength = buffer.readUInt16LE(offset + 28) + const extraLength = buffer.readUInt16LE(offset + 30) + const commentLength = buffer.readUInt16LE(offset + 32) + const localOffset = buffer.readUInt32LE(offset + 42) + const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8') + const localNameLength = buffer.readUInt16LE(localOffset + 26) + const localExtraLength = buffer.readUInt16LE(localOffset + 28) + const start = localOffset + 30 + localNameLength + localExtraLength + const compressed = buffer.subarray(start, start + compressedSize) + entries.set(name, method === 8 ? inflateRawSync(compressed) : Buffer.from(compressed)) + offset += 46 + nameLength + extraLength + commentLength + } + return entries +} + +export function parseXlsx(bytes) { + const entries = zipEntries(bytes) + const shared = [] + const sharedXml = entries.get('xl/sharedStrings.xml')?.toString('utf8') || '' + for (const match of sharedXml.matchAll(/]*>([\s\S]*?)<\/si>/g)) { + shared.push(xml([...match[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => part[1]).join(''))) + } + const sheet = entries.get('xl/worksheets/sheet1.xml')?.toString('utf8') + if (!sheet) throw new Error('XLSX sheet1 is missing') + const rows = [] + for (const rowMatch of sheet.matchAll(/]*>([\s\S]*?)<\/row>/g)) { + const row = [] + for (const cell of rowMatch[1].matchAll(/]*)>([\s\S]*?)<\/c>/g)) { + const reference = cell[1].match(/\br="([^"]+)"/)?.[1] || 'A1' + const type = cell[1].match(/\bt="([^"]+)"/)?.[1] + const raw = cell[2].match(/([\s\S]*?)<\/v>/)?.[1] + const inline = [...cell[2].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => part[1]).join('') + row[columnIndex(reference)] = type === 's' ? shared[Number(raw)] : xml(inline || raw || '') + } + rows.push(row.map((value) => value ?? '')) + } + return { headers: rows[0] || [], rows: rows.slice(1) } +} + +async function poll(context, taskId, expected = 'SUCCEEDED', attempts = 45) { + let response = null; let task = null; const statuses = [] + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (attempt > 0) await wait(2_000) + response = await context.request('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}`) + if (response.http === 200 && String(response.body?.code) === '200') { + task = response.body.data + if (task?.status && statuses.at(-1) !== task.status) statuses.push(task.status) + if (TERMINAL.has(task?.status)) break + } + } + context.addStep(MODULE, `任务 ${taskId} 到达 ${expected}`, response, task?.status === expected, + `status=${task?.status ?? 'missing'}, transitions=${statuses.join('>') || 'missing'}, failure=${task?.failureMessage || 'none'}`, OPTIONS) + return { response, task, statuses } +} + +async function createTask(context, type, parameters, suffix, expectedHeaders, expectedTokens = []) { + const body = { exportType: type, format: 'XLSX', taskName: `${context.marker}-${suffix}`, parameters } + const createdResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body) + const created = context.apiSuccess(MODULE, `${type} 创建导出任务`, createdResponse, + `type=${type}`, OPTIONS) + const taskId = id(created?.id) + context.assert(MODULE, `${type} 创建态与长 ID 参数正确`, createdResponse, + Boolean(taskId) && created?.exportType === type && created?.status === 'PENDING' + && Object.values(parameters).every((value) => typeof value !== 'number' || Number.isSafeInteger(value)), + `taskId=${taskId || 'missing'}, status=${created?.status ?? 'missing'}`, OPTIONS) + if (!taskId) return null + + const duplicateResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body) + const duplicate = context.apiSuccess(MODULE, `${type} 重复请求`, duplicateResponse, '', OPTIONS) + context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse, id(duplicate?.id) === taskId, + `original=${taskId}, duplicate=${id(duplicate?.id) || 'missing'}`, OPTIONS) + + const listResponse = await context.request('GET', `/api/platform/v1/files/export-tasks?page=1&size=100&exportType=${type}`) + const page = context.apiSuccess(MODULE, `${type} 下载中心列表可见`, listResponse, '', OPTIONS) + context.assert(MODULE, `${type} 列表包含本轮任务`, listResponse, + values(page).some((item) => id(item.id) === taskId && item.taskName === body.taskName), `taskId=${taskId}`, OPTIONS) + + const final = await poll(context, taskId) + if (final.task?.status !== 'SUCCEEDED') return { taskId, fileId: null, status: final.task?.status } + const task = final.task + context.assert(MODULE, `${type} 文件元数据完整`, final.response, + task.fileName?.endsWith('.xlsx') && task.contentType?.includes('spreadsheetml') && Number(task.sizeBytes) > 0 + && Number.isFinite(Number(task.processedRows)), + `fileName=${task.fileName || 'missing'}, bytes=${task.sizeBytes ?? 'missing'}, rows=${task.processedRows ?? 'missing'}`, OPTIONS) + const download = await context.requestBinary('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}/download`) + let workbook = null + try { workbook = download.http === 200 ? parseXlsx(download.bytes) : null } catch (error) { + context.addStep(MODULE, `${type} 解析 XLSX`, download, false, error instanceof Error ? error.message : String(error), OPTIONS) + } + context.addStep(MODULE, `${type} 真实文件下载`, download, + download.http === 200 && download.bytes?.[0] === 0x50 && download.bytes?.[1] === 0x4b, + `bytes=${download.bytes?.length ?? 0}, contentType=${download.contentType || 'missing'}`, OPTIONS) + if (workbook) { + context.assert(MODULE, `${type} XLSX 表头`, download, + JSON.stringify(workbook.headers) === JSON.stringify(expectedHeaders), `headers=${workbook.headers.join('|')}`, OPTIONS) + const flattened = workbook.rows.flat().map(String) + context.assert(MODULE, `${type} XLSX 行数`, download, + workbook.rows.length === Number(task.processedRows), `sheetRows=${workbook.rows.length}, processedRows=${task.processedRows}`, OPTIONS) + context.assert(MODULE, `${type} 业务键与筛选结果`, download, + expectedTokens.every((token) => flattened.includes(String(token))), + `expected=${expectedTokens.join('|') || 'empty-result'}, rows=${workbook.rows.length}`, OPTIONS) + } + return { taskId, fileId: null, status: task.status, fileName: task.fileName, + downloadPath: `/api/platform/v1/files/export-tasks/${taskId}/download`, processedRows: Number(task.processedRows) } +} + +async function official(context, path, name) { + const response = await context.request('GET', path) + return { response, data: context.apiSuccess(MODULE, name, response, '', OPTIONS) } +} + +async function noPermission(context) { + const username = process.env.MERCHANT_TEST_USERNAME + const password = process.env.MERCHANT_TEST_PASSWORD + if (!username || !password) { + context.addCoverageGap(MODULE, 'finance-download-center', '无导出权限拒绝', '本轮商户验收账号前置未生成', 'prerequisite') + return + } + const login = await context.request('POST', '/api/auth/login', { username, password }, '') + const token = login.http === 200 && String(login.body?.code) === '200' ? login.body.data?.accessToken : '' + if (!token) { + context.addStep(MODULE, '无权限账号登录', login, false, '商户 Token 未生成', OPTIONS); return + } + const denied = await context.request('POST', '/api/platform/v1/files/export-tasks', { + exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST', format: 'XLSX', taskName: `${context.marker}-DENIED`, parameters: {}, + }, token) + context.addStep(MODULE, '无 Finance export 权限拒绝创建', denied, + denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code)), + `HTTP=${denied.http}, code=${denied.body?.code ?? 'missing'}`, OPTIONS) +} + +export async function run(context) { + context.createdResources.financeExportTasks = { marker: context.marker, tasks: {} } + const longMissingCustomerId = '9223372036854775806' + const probe = await createTask(context, 'FINANCE_CUSTOMER_ACCOUNT_LIST', + { customerId: longMissingCustomerId }, 'CAPABILITY-PROBE', EXPORT_SPECS.FINANCE_CUSTOMER_ACCOUNT_LIST) + if (!probe || probe.status !== 'SUCCEEDED') { + context.addCoverageGap(MODULE, 'finance-download-center', '八类 Finance 下载中心真实链', + 'Finance/File/权限/配置尚未完整部署,能力探测未成功;部署后重跑完整链', 'deployment-prerequisite') + return + } + context.createdResources.financeExportTasks.probe = probe + context.assert(MODULE, '长 ID 字符串与空结果', null, + probe.processedRows === 0, `customerIdType=string, customerId=${longMissingCustomerId}, rows=${probe.processedRows}`, OPTIONS) + await noPermission(context) + + const fixture = context.createdResources.financeFullFixtures + if (!fixture || fixture.marker !== context.marker) { + context.addStep(MODULE, 'Finance 本轮 fixture 前置', null, false, + `expected marker=${context.marker}, actual=${fixture?.marker || 'missing'};禁止借历史数据`, OPTIONS) + context.addCoverageGap(MODULE, 'finance-download-center', '八类 Finance 导出业务数据校验', + '缺少同 marker 的 financeFullFixtures;请使用完整 Finance 链运行', 'prerequisite') + return + } + const customerId = id(fixture.customerId) + const statementId = id(fixture.statements?.find((item) => item.statementId)?.statementId) + const arId = id(fixture.receivableSettlements?.find((item) => item.settlementId)?.settlementId) + const apId = id(fixture.payableSettlements?.find((item) => item.settlementId)?.settlementId) + if (!customerId || !statementId || !arId || !apId) throw new Error('Finance download fixtures are incomplete') + + const account = await official(context, `/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`, '读取客户账户筛选事实') + const accountRow = values(account.data)[0] + const ledger = await official(context, `/api/platform/finance/v1/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`, '读取账户流水事实') + const statement = await official(context, `/api/platform/finance/v1/reconciliation-statements/${encodeURIComponent(statementId)}`, '读取对账单导出事实') + const ar = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(arId)}`, '读取应收结算导出事实') + const ap = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(apId)}`, '读取应付结算导出事实') + const fee = await official(context, `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`, '读取费用来源导出事实') + const feeRow = values(fee.data)[0] + const statementNo = statement.data?.statementNo + const arSummary = ar.data?.summary || ar.data + const apSummary = ap.data?.summary || ap.data + const ledgerKey = values(ledger.data)[0]?.ledgerId + + const runs = [ + ['FINANCE_CUSTOMER_ACCOUNT_LIST', { customerId, ...(accountRow?.status ? { status: accountRow.status } : {}) }, [accountRow?.customerName]], + ['FINANCE_CUSTOMER_ACCOUNT_LEDGER', { customerId }, [ledgerKey]], + ['FINANCE_FEE_SOURCE_LIST', { customerId, ...(feeRow?.direction ? { direction: feeRow.direction } : {}) }, [feeRow?.sourceId]], + ['FINANCE_RECONCILIATION_STATEMENT_LIST', { customerId, status: statement.data?.status, billNo: statementNo }, [statementNo]], + ['FINANCE_RECEIVABLE_SETTLEMENT_LIST', { customerId, status: arSummary?.status, settlementNo: arSummary?.settlementNo }, [arSummary?.settlementNo]], + ['FINANCE_RECEIVABLE_SETTLEMENT_DETAIL', { settlementId: arId }, [arSummary?.settlementNo]], + ['FINANCE_PAYABLE_SETTLEMENT_LIST', { customerId, status: apSummary?.status, settlementNo: apSummary?.settlementNo, + ...(apSummary?.supplierName ? { supplierName: apSummary.supplierName } : {}) }, [apSummary?.settlementNo]], + ['FINANCE_PAYABLE_SETTLEMENT_DETAIL', { settlementId: apId }, [apSummary?.settlementNo]], + ] + for (const [type, parameters, tokens] of runs) { + const cleanTokens = tokens.filter((value) => value != null && String(value) !== '') + context.createdResources.financeExportTasks.tasks[type] = await createTask(context, type, parameters, + type.replace('FINANCE_', ''), EXPORT_SPECS[type], cleanTokens) + } + + const concurrentResponses = await Promise.all(Array.from({ length: 4 }, (_, index) => context.request('POST', + '/api/platform/v1/files/export-tasks', { + exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST', format: 'XLSX', + taskName: `${context.marker}-CONCURRENCY-${index + 1}`, + parameters: { customerId, unsupportedAcceptanceKey: `${context.marker}-${index + 1}` }, + }))) + const concurrentTasks = concurrentResponses + .filter((response) => response.http === 200 && String(response.body?.code) === '200') + .map((response) => response.body.data) + const limited = concurrentResponses.some((response) => response.http === 429 + || ['429', 'A0429'].includes(String(response.body?.code))) + context.addStep(MODULE, '同用户并发导出门禁', concurrentResponses.find((response) => response.http === 429) || concurrentResponses.at(-1), + concurrentTasks.length <= 3 && limited, + `created=${concurrentTasks.length}, limited=${limited}, codes=${concurrentResponses.map((response) => `${response.http}/${response.body?.code ?? 'missing'}`).join(',')}`, OPTIONS) + const failureTask = concurrentTasks[0] + if (failureTask?.id) { + const failed = await poll(context, id(failureTask.id), 'FAILED') + context.assert(MODULE, 'Provider 失败信息可见', failed.response, + Boolean(failed.task?.failureMessage), `failureMessage=${failed.task?.failureMessage || 'missing'}`, OPTIONS) + context.createdResources.financeExportTasks.providerFailure = { + taskId: id(failureTask.id), fileId: null, status: failed.task?.status, failureMessage: failed.task?.failureMessage, + } + } +} diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index c6ac566ee..403d7e08f 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -142,10 +142,10 @@ test('default Admin HTTP entry carries evidence chain through settlement-aware P assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) }) -test('Finance-only HTTP entry expands the completed-container producer prerequisites', () => { - const focused = normalizeModuleOrder(['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']) +test('Finance-only HTTP entry expands producer prerequisites and runs downloads last', () => { + const focused = normalizeModuleOrder(['finance-downloads', 'finance-settlement-full']) assert.deepEqual(focused.slice(0, 4), [ 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', ]) - assert.deepEqual(focused.slice(-3), ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']) + assert.deepEqual(focused.slice(-4), ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads']) })