140 lines
11 KiB
JavaScript
140 lines
11 KiB
JavaScript
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)
|
|
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)
|
|
}
|
|
if (detail?.reviewStatus === 'APPROVED' && detail?.settlementIncluded !== true) {
|
|
const inclusionBody = { included: true, reason: context.marker, expectedVersion: Number(detail.operationVersion ?? detail.version) }
|
|
detailResponse = await write(context, 'PUT', `${BASE}/fee-sources/${encodeURIComponent(feeSourceId)}/settlement-inclusion`, inclusionBody, 'fee-include')
|
|
detail = context.apiSuccess(MODULE, '将本轮费用来源纳入结算', detailResponse, '', OPTIONS)
|
|
}
|
|
context.assert(MODULE, '本轮来源满足对账单生成条件', detailResponse,
|
|
detail?.reviewStatus === 'APPROVED' && detail?.settlementIncluded === true && detail?.sourceStatus === 'AVAILABLE' && detail?.currency === 'CNY',
|
|
`review=${detail?.reviewStatus}, included=${detail?.settlementIncluded}, status=${detail?.sourceStatus}`, OPTIONS)
|
|
|
|
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
|
|
|
|
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,
|
|
grantedPermissions.has('platform:finance:bill:view') || grantedPermissions.has('platform:finance:customer-statement:list'),
|
|
`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)
|
|
|
|
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 })
|
|
}
|