This commit is contained in:
2026-08-13 14:03:54 +08:00
parent 18cebd3fdd
commit bc62650ca9
11 changed files with 349 additions and 18 deletions
@@ -2,16 +2,20 @@ const MODULE = 'finance-reconciliation-full'
const SCENARIO = 'finance-full-reconciliation'
const BASE = '/api/platform/finance/v1'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'
const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
const REQUIRED_PERMISSIONS = [
'platform:finance:customer-account:list',
STATEMENT_LIST_PERMISSION,
'platform:finance:reconciliation:create',
'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue',
'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void',
'platform:finance:settlement-ar:create',
'platform:finance:settlement-ar:view',
'platform:finance:settlement-ar:list',
'platform:finance:settlement-ap:create',
'platform:finance:settlement-ap:view',
'platform:finance:settlement-ap:list',
]
function id(value) { return value == null ? '' : String(value) }
@@ -46,6 +50,41 @@ function sameIds(actual, expected) {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function present(value) { return value !== undefined && value !== null && String(value).trim() !== '' }
async function verifyIssuedStatementReadback(context, fixture, statement, label) {
const customerId = id(fixture.customerId)
const targetId = statementId(statement)
if (!customerId || !targetId) {
context.addCoverageGap(MODULE, SCENARIO, `${label} 对账单列表/详情查回`,
`必须使用本轮 fixture.customerId 和刚创建且已出账的 statementId;customerId=${customerId || 'missing'}, statementId=${targetId || 'missing'}`,
'missing-issued-statement-readback-fixture', { marker: context.marker, customerId, statementId: targetId })
return
}
const listResponse = await context.request('GET',
`${BASE}/statements?customerId=${encodeURIComponent(customerId)}`)
const summaries = context.apiSuccess(MODULE, `${label} 从正式对账单列表查回`, listResponse,
`customerId=${customerId}, statementId=${targetId}, permission=${STATEMENT_LIST_PERMISSION}`, OPTIONS)
const statementRows = rows(summaries)
const found = statementRows.find((item) => statementId(item) === targetId)
context.assert(MODULE, `${label} 正式对账单列表仅返回本轮客户且包含同一 statementId`, listResponse,
statementRows.length > 0 && statementRows.every((item) => id(item?.customerId) === customerId) && Boolean(found),
`customerId=${customerId}, statementId=${targetId}, rows=${statementRows.length}, found=${statementId(found) || 'missing'}`, OPTIONS)
const detailResponse = await context.request('GET',
`${BASE}/reconciliation-statements/${encodeURIComponent(targetId)}`)
const detail = context.apiSuccess(MODULE, `${label} 从正式对账单详情查回`, detailResponse,
`customerId=${customerId}, statementId=${targetId}, permission=${STATEMENT_LIST_PERMISSION}`, OPTIONS)
const auditShape = ['createdBy', 'createdAt', 'updatedBy', 'updatedAt']
.every((field) => Object.hasOwn(detail ?? {}, field) && present(detail?.[field]))
context.assert(MODULE, `${label} 对账单详情归属、明细、revision 与审计结构完整`, detailResponse,
id(detail?.customerId) === customerId && statementId(detail) === targetId
&& Array.isArray(detail?.lines) && detail.lines.length > 0
&& Array.isArray(detail?.revisions) && detail.revisions.length > 0
&& auditShape,
`customerId=${id(detail?.customerId) || 'missing'}, statementId=${statementId(detail) || 'missing'}, lines=${list(detail?.lines).length}, revisions=${list(detail?.revisions).length}, auditShape=${auditShape}`, OPTIONS)
}
async function findSettlementInOfficialList(context, direction, customerId, targetId, label) {
const segment = direction === 'RECEIVABLE' ? 'receivable-settlements' : 'payable-settlements'
const response = await context.request('GET',
@@ -82,6 +121,42 @@ function diagnostic(response) {
code: response?.body?.code == null ? null : String(response.body.code),
requestId: response?.requestId ?? null, traceId: response?.traceId ?? null }
}
function accountRows(value) {
const data = value?.body?.data
return rows(data).concat(rows(data?.items))
}
function accountCustomerId(value) { return id(value?.customerId ?? value?.tenantId ?? value?.accountId ?? value?.id) }
function hasWalletField(value) {
return value && typeof value === 'object' && ['balance', 'recharge', 'prepayment'].some((field) => Object.hasOwn(value, field))
}
async function verifyCustomerAccountAndLedger(context, fixture) {
const customerId = id(fixture.customerId)
const accountResponse = await context.request('GET', `${BASE}/accounts?page=1&pageSize=20`)
const accounts = accountRows(accountResponse)
const account = accounts.find((item) => accountCustomerId(item) === customerId)
context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse,
Boolean(account) && !accounts.some(hasWalletField),
`customerId=${customerId}, accountCount=${accounts.length}, requestId=${accountResponse.requestId ?? 'null'}, traceId=${accountResponse.traceId ?? 'null'}`, OPTIONS)
const ledgerResponse = await context.request('GET', `${BASE}/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`)
const ledger = accountRows(ledgerResponse)
const rowsForCustomer = ledger.filter((item) => {
const itemCustomer = accountCustomerId(item)
return !itemCustomer || itemCustomer === customerId
})
const expectedIds = new Set([
...(fixture.statements || []).map((item) => statementId(item)),
...(fixture.receivableSettlements || []).map((item) => settlementId(item)),
...(fixture.payableSettlements || []).map((item) => settlementId(item)),
].filter(Boolean))
const containsRoundTrip = rowsForCustomer.some((item) => expectedIds.has(statementId(item) || settlementId(item)
|| id(item?.documentId ?? item?.businessId ?? item?.sourceId ?? item?.referenceId))
|| String(item?.remark ?? '').includes(context.marker))
context.assert(MODULE, '客户账户流水仅属于本轮客户并包含账单/收款或结算流水', ledgerResponse,
rowsForCustomer.length === ledger.length && containsRoundTrip,
`customerId=${customerId}, ledgerCount=${ledger.length}, requestId=${ledgerResponse.requestId ?? 'null'}, traceId=${ledgerResponse.traceId ?? 'null'}`, OPTIONS)
fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse),
ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) }
}
function blocker(context, name, evidence, category, response = null) {
context.addStep(MODULE, name, response, false, evidence, OPTIONS)
context.addCoverageGap(MODULE, SCENARIO, name, evidence, category,
@@ -251,6 +326,13 @@ export async function run(context) {
const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions')
const permissions = context.apiSuccess(MODULE, '查询当前账号 Finance 有效权限', permissionResponse, '', OPTIONS)
const granted = new Set(list(permissions).map(String))
if (granted.has(LEGACY_STATEMENT_VIEW_PERMISSION)) {
blocker(context, '客户对账单旧 view 权限兼容阻断',
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据`,
'legacy-customer-statement-view-permission', permissionResponse)
finish(context, start)
return
}
const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission))
if (missingPermissions.length > 0) {
blocker(context, 'Finance 写权限门禁',
@@ -374,6 +456,7 @@ export async function run(context) {
finish(context, start)
return
}
await verifyIssuedStatementReadback(context, fixture, issued, `第 ${index + 1} 张已出账对账单`)
issuedStatements.push(issued)
if (index === 0) {
context.assert(MODULE, 'SELECTED 日期范围只包含指定真实日期来源', created.response,
@@ -455,5 +538,6 @@ export async function run(context) {
feeSourceIds: payables.map((source) => source.feeSourceId),
}] : []
fixture.root.sourceMappings = fixture.sourceMappings
await verifyCustomerAccountAndLedger(context, fixture)
finish(context, start)
}