286 lines
19 KiB
JavaScript
286 lines
19 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(/</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(/<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`, '读取费用来源导出事实')
|
|
// 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,
|
|
}
|
|
}
|
|
}
|