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 REQUIRED_PERMISSIONS = Object.freeze([ 'platform:download:list', 'platform:finance:customer-account:export', 'platform:finance:customer-arap:export', 'platform:finance:customer-statement:export', 'platform:finance:settlement-ar:export', 'platform:finance:settlement-ap:export', ]) export const EXPORT_SPECS = Object.freeze({ FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收客户款(CNY)', '待开账(CNY)', '本期已收(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 idempotencyKey = `${context.marker}-export-${type}-${suffix}`.slice(0, 128) const createdResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body, context.token, { 'Idempotency-Key': idempotencyKey }) 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, context.token, { 'Idempotency-Key': idempotencyKey }) 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', { loginId: 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 permissionsResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions') const permissionData = context.apiSuccess(MODULE, 'fresh login 权限快照', permissionsResponse, '', OPTIONS) const permissions = new Set(Array.isArray(permissionData) ? permissionData : []) const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !permissions.has(permission)) context.assert(MODULE, '下载中心与五项 Finance export 权限齐全', permissionsResponse, missingPermissions.length === 0, `missing=${missingPermissions.join(',') || 'none'}`, OPTIONS) if (missingPermissions.length > 0) { context.addCoverageGap(MODULE, 'finance-download-center', '八类 Finance 下载中心真实链', `fresh login 权限快照缺失:${missingPermissions.join(',')}`, 'deployment-prerequisite') return } const longMissingCustomerId = '9223372036854775806' const probe = await createTask(context, 'FINANCE_CUSTOMER_ACCOUNT_LIST', { customerId: longMissingCustomerId }, 'CAPABILITY-PROBE', EXPORT_SPECS.FINANCE_CUSTOMER_ACCOUNT_LIST) context.createdResources.financeExportTasks.probe = probe if (!probe || probe.status !== 'SUCCEEDED') { context.addCoverageGap(MODULE, 'finance-download-center', '八类 Finance 下载中心真实链', 'Finance/File/权限/配置尚未完整部署,能力探测未成功;部署后重跑完整链', 'deployment-prerequisite') return } 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`, '读取费用来源导出事实') // PAYABLE rows are removed from the payable pool once an AP settlement owns // them. Use a receivable row so the export filter still has an authoritative // post-settlement match instead of asserting against an intentionally consumed row. const feeRow = values(fee.data).find((item) => item?.direction === 'RECEIVABLE') || 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, } } }