Files
baiyue-firstleg/scripts/admin-test-env-modules/finance-downloads.mjs
T

283 lines
18 KiB
JavaScript

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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
.replace(/&apos;/g, "'").replace(/&amp;/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(/<si\b[^>]*>([\s\S]*?)<\/si>/g)) {
shared.push(xml([...match[1].matchAll(/<t\b[^>]*>([\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(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
const row = []
for (const cell of rowMatch[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const reference = cell[1].match(/\br="([^"]+)"/)?.[1] || 'A1'
const type = cell[1].match(/\bt="([^"]+)"/)?.[1]
const raw = cell[2].match(/<v>([\s\S]*?)<\/v>/)?.[1]
const inline = [...cell[2].matchAll(/<t\b[^>]*>([\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 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`, '读取费用来源导出事实')
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,
}
}
}