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

140 lines
11 KiB
JavaScript
Raw Normal View History

2026-08-12 09:25:53 +08:00
const MODULE = 'finance'
const OPTIONS = { scenario: 'finance-phase1-g1-g4', deploymentBlocker: true }
const BASE = '/api/platform/finance/v1'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
function fixture(context) {
const candidates = [context.createdResources?.inboundMeasurement, context.createdResources?.testInbound,
context.createdResources?.orders, context.context?.testInbound, context.context?.createdInbound]
return candidates.find((item) => item?.tenantId && item?.inboundId && item?.marker === context.marker) || null
}
function key(context, action) { return `${context.marker}-${action}`.slice(0, 128) }
function write(context, method, path, body, action) {
return context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, action) })
}
function statementId(detail) { return id(detail?.statementId ?? detail?.id ?? detail?.billId) }
function settlementId(detail) { return id(detail?.summary?.settlementId ?? detail?.summary?.id ?? detail?.settlementId ?? detail?.id) }
export async function run(context) {
const start = context.steps.length
const openapiResponse = await context.request('GET', '/finance-service/v3/api-docs')
const openapi = openapiResponse.body
context.addStep(MODULE, 'Gateway 可访问 Finance OpenAPI', openapiResponse,
openapiResponse.http === 200 && typeof openapi?.openapi === 'string',
'/finance-service/v3/api-docs', OPTIONS)
context.assert(MODULE, 'OpenAPI 暴露 G3/G4 冻结路径', openapiResponse,
Boolean(openapi?.paths?.[`${BASE}/statements`] && openapi?.paths?.[`${BASE}/receivable-settlements`]
&& openapi?.paths?.[`${BASE}/settlements/{settlementId}/transactions`]),
`paths=${Object.keys(openapi?.paths || {}).length}`, OPTIONS)
const accountsResponse = await context.request('GET', `${BASE}/accounts?page=1&pageSize=20`)
const accounts = context.apiSuccess(MODULE, '查询客户账户派生汇总', accountsResponse, '', OPTIONS)
context.assert(MODULE, '客户账户不暴露钱包余额字段', accountsResponse,
rows(accounts).every((item) => !Object.hasOwn(item, 'balance') && !Object.hasOwn(item, 'rechargeAmount') && !Object.hasOwn(item, 'prepayment')),
`records=${rows(accounts).length}`, OPTIONS)
const current = fixture(context)
if (!current) {
context.addCoverageGap(MODULE, 'finance-owned-fixture', 'G2-G4 写操作真实验收',
'本轮未取得同 marker 的客户入库单,禁止借用历史费用来源执行不可逆财务写操作', 'no-marker-owned-fixture')
return
}
const customerId = id(current.tenantId)
const inboundId = id(current.inboundId)
const feePoolResponse = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=RECEIVABLE`)
const feePool = context.apiSuccess(MODULE, '查询本轮客户应收费用来源', feePoolResponse,
`customerId=${customerId}, inboundId=${inboundId}`, OPTIONS)
const source = rows(feePool).find((item) => id(item.inboundId) === inboundId && id(item.feeSourceId || item.id))
context.assert(MODULE, '费用来源仅使用本轮入库单', feePoolResponse, Boolean(source),
source ? `feeSourceId=${id(source.feeSourceId || source.id)}` : '未找到本轮入库单费用来源', OPTIONS)
if (!source) return
const feeSourceId = id(source.feeSourceId || source.id)
let detailResponse = await context.request('GET', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}`)
let detail = context.apiSuccess(MODULE, '读取本轮费用来源详情', detailResponse, '', OPTIONS)
2026-08-19 09:42:07 +08:00
if (detail?.reviewStatus === 'DRAFT' || detail?.reviewStatus === 'REJECTED') {
const submitBody = { reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) }
detailResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/submit`, submitBody, 'fee-submit')
detail = context.apiSuccess(MODULE, '提交本轮费用来源审核', detailResponse, '', OPTIONS)
}
2026-08-12 09:25:53 +08:00
if (detail?.reviewStatus === 'PENDING') {
const reviewBody = { action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) }
detailResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/review`, reviewBody, 'fee-review')
detail = context.apiSuccess(MODULE, '审核本轮费用来源', detailResponse, '', OPTIONS)
}
context.assert(MODULE, '本轮来源满足对账单生成条件', detailResponse,
2026-08-19 09:42:07 +08:00
detail?.reviewStatus === 'APPROVED' && detail?.sourceStatus === 'AVAILABLE' && detail?.currency === 'CNY',
`review=${detail?.reviewStatus}, status=${detail?.sourceStatus}`, OPTIONS)
2026-08-12 09:25:53 +08:00
const businessDate = detail?.businessDate || new Date().toISOString().slice(0, 10)
const createBody = { customerId, currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [feeSourceId], periodStart: businessDate, periodEnd: businessDate, remark: context.marker }
const createResponse = await write(context, 'POST', `${BASE}/statements`, createBody, 'statement-create')
let statement = context.apiSuccess(MODULE, '按本轮来源生成对账单', createResponse, '', OPTIONS)
const createdStatementId = statementId(statement)
const replayResponse = await write(context, 'POST', `${BASE}/statements`, createBody, 'statement-create')
const replay = context.apiSuccess(MODULE, '同键同参生成对账单重放原结果', replayResponse, '', OPTIONS)
context.assert(MODULE, '对账单幂等重放保持同一资源', replayResponse,
Boolean(createdStatementId) && statementId(replay) === createdStatementId,
`first=${createdStatementId || 'missing'}, replay=${statementId(replay) || 'missing'}`, OPTIONS)
if (!createdStatementId) return
2026-08-13 11:57:33 +08:00
const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions')
const permissions = context.apiSuccess(MODULE, '客户详情财务账单查看权限链可用', permissionResponse, '', OPTIONS)
const grantedPermissions = new Set(rows(permissions).map((item) => id(item?.permissionCode ?? item)))
context.assert(MODULE, '财务账单列表查看权限已授予', permissionResponse,
2026-08-13 16:58:17 +08:00
grantedPermissions.has('platform:finance:customer-statement:list'),
2026-08-13 11:57:33 +08:00
`permissions=${[...grantedPermissions].filter((item) => item.includes('finance')).join(',')}`, OPTIONS)
const statementListResponse = await context.request('GET', `${BASE}/statements?customerId=${encodeURIComponent(customerId)}`)
const statementList = context.apiSuccess(MODULE, '客户详情财务账单列表按客户过滤', statementListResponse,
`customerId=${customerId}`, OPTIONS)
const statementRows = rows(statementList)
context.assert(MODULE, '财务账单列表仅返回本轮客户且包含新账单', statementListResponse,
statementRows.length > 0 && statementRows.every((item) => id(item?.customerId) === customerId)
&& statementRows.some((item) => statementId(item) === createdStatementId),
`customerId=${customerId}, statementId=${createdStatementId}, rows=${statementRows.length}`, OPTIONS)
const statementDetailResponse = await context.request('GET', `${BASE}/reconciliation-statements/${encodeURIComponent(createdStatementId)}`)
const statementDetail = context.apiSuccess(MODULE, '读取客户详情财务账单详情', statementDetailResponse,
`statementId=${createdStatementId}`, OPTIONS)
context.assert(MODULE, '财务账单详情客户与明细修订结构完整', statementDetailResponse,
id(statementDetail?.customerId) === customerId && statementId(statementDetail) === createdStatementId
&& Array.isArray(statementDetail?.lines) && Array.isArray(statementDetail?.revisions),
`customerId=${id(statementDetail?.customerId) || 'missing'}, statementId=${statementId(statementDetail) || 'missing'}, lines=${Array.isArray(statementDetail?.lines)}, revisions=${Array.isArray(statementDetail?.revisions)}`, OPTIONS)
2026-08-12 09:25:53 +08:00
for (const action of ['SUBMIT', 'APPROVE', 'ISSUE']) {
const actionResponse = await write(context, 'POST', `${BASE}/reconciliation-statements/${createdStatementId}/actions`,
{ action, expectedVersion: Number(statement.version) }, `statement-${action.toLowerCase()}`)
statement = context.apiSuccess(MODULE, `对账单执行 ${action}`, actionResponse, '', OPTIONS)
}
const adjustmentLines = rows(statement?.lines).map((line) => ({ statementLineId: id(line.statementLineId || line.id), adjustedAmount: Number(line.amount), reason: context.marker }))
const adjustResponse = await write(context, 'POST', `${BASE}/reconciliation-statements/${createdStatementId}/actions`,
{ action: 'ADJUST', reason: context.marker, expectedVersion: Number(statement.version), adjustmentLines }, 'statement-adjust')
statement = context.apiSuccess(MODULE, '对账单多行调整并保留 revision', adjustResponse, '', OPTIONS)
context.assert(MODULE, '调整响应包含修订历史', adjustResponse,
adjustmentLines.length > 0 && rows(statement?.revisions).length > 0,
`lines=${adjustmentLines.length}, revisions=${rows(statement?.revisions).length}`, OPTIONS)
const arBody = { reconciliationStatementId: createdStatementId, currency: 'CNY' }
const arResponse = await write(context, 'POST', `${BASE}/receivable-settlements`, arBody, 'settlement-ar-create')
const ar = context.apiSuccess(MODULE, '从已出账对账单生成应收结算单', arResponse, '', OPTIONS)
const arId = settlementId(ar)
const arDetailResponse = arId ? await context.request('GET', `${BASE}/settlements/${arId}`) : arResponse
const arDetail = arId ? context.apiSuccess(MODULE, '查询应收结算单详情', arDetailResponse, '', OPTIONS) : null
context.assert(MODULE, '应收结算单方向和金额正确', arDetailResponse,
Boolean(arId) && arDetail?.summary?.direction === 'RECEIVABLE' && Number(arDetail?.summary?.totalAmount) === Number(statement.totalAmount),
`settlementId=${arId || 'missing'}, direction=${arDetail?.summary?.direction || 'missing'}`, OPTIONS)
const apListResponse = await context.request('GET', `${BASE}/payable-settlements?customerId=${encodeURIComponent(customerId)}`)
context.apiSuccess(MODULE, '查询本轮客户应付结算单列表', apListResponse, '', OPTIONS)
context.addCoverageGap(MODULE, 'payable-payment', '应付生成、部分付款与付清',
'本轮上游 fixture 未承诺生成同客户同供应商 PAYABLE 来源;禁止借用历史应付单登记付款', 'no-marker-owned-payable-source')
context.addCoverageGap(MODULE, 'receipt-three-settlements', '300 元收款分配 3 张 100 元应收单',
'当前模块只有一条本轮独占应收来源,无法安全构造三张可清理应收单', 'insufficient-marker-owned-receivable-sources')
context.createdResources.finance = { marker: context.marker, customerId, inboundId, feeSourceId, statementId: createdStatementId, receivableSettlementId: arId || null }
const steps = context.steps.slice(start)
context.scenarios.push({ name: OPTIONS.scenario, module: MODULE, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
}