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

673 lines
42 KiB
JavaScript
Raw Normal View History

2026-08-12 10:59:39 +08:00
const MODULE = 'finance-reconciliation-full'
const SCENARIO = 'finance-full-reconciliation'
const BASE = '/api/platform/finance/v1'
2026-08-20 17:00:05 +08:00
const CLIENT_BASE = '/api/client/finance/v1'
2026-08-12 10:59:39 +08:00
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
2026-08-13 14:03:54 +08:00
const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'
const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
2026-08-12 13:24:17 +08:00
const REQUIRED_PERMISSIONS = [
2026-08-13 14:03:54 +08:00
'platform:finance:customer-account:list',
2026-08-20 16:13:51 +08:00
'platform:finance:customer-arap:list',
'platform:finance:fee-source:edit',
2026-08-20 16:13:51 +08:00
'platform:finance:fee-source:review',
2026-08-13 14:03:54 +08:00
STATEMENT_LIST_PERMISSION,
2026-08-12 13:24:17 +08:00
'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',
2026-08-13 14:03:54 +08:00
'platform:finance:settlement-ar:list',
2026-08-12 13:24:17 +08:00
'platform:finance:settlement-ap:create',
2026-08-13 14:03:54 +08:00
'platform:finance:settlement-ap:list',
2026-08-12 13:24:17 +08:00
]
2026-08-12 10:59:39 +08:00
function id(value) { return value == null ? '' : String(value) }
function list(value) { return Array.isArray(value) ? value : [] }
function rows(value) { return Array.isArray(value) ? value : list(value?.records) }
function statementId(value) { return id(value?.statementId ?? value?.id ?? value?.billId) }
function settlementId(value) { return id(value?.settlementId ?? value?.id) }
function sourceId(value) { return id(value?.feeSourceId ?? value?.financeFeeSourceId ?? value?.id) }
function sourceDate(value) { return value?.businessDate ?? value?.billingDate ?? value?.feeDate ?? '' }
function sourceWaybill(value) { return id(value?.waybillId ?? value?.shipmentId ?? value?.orderNo ?? value?.sourceNo) }
2026-08-12 14:21:25 +08:00
function financePoolId(value) {
const poolItemId = id(value?.poolItemId)
if (poolItemId) return poolItemId.includes(':') ? poolItemId.slice(poolItemId.indexOf(':') + 1) : poolItemId
return id(value?.feeSourceId ?? value?.financeFeeSourceId ?? value?.id)
}
2026-08-12 13:24:17 +08:00
function isUnboundContainerCost(raw) {
return raw?.sourceType === 'MANUAL' && raw?.billingBasis === 'CONTAINER_SHARED'
&& !sourceWaybill(raw)
}
2026-08-12 10:59:39 +08:00
function lineSourceIds(value) { return list(value?.lines).map(sourceId).filter(Boolean) }
2026-08-12 13:24:17 +08:00
function rejectedWith(response, code) {
return response?.http === 200 && String(response?.body?.code) === code
}
function accessDenied(response) { return rejectedWith(response, 'A0403') }
2026-08-12 13:55:16 +08:00
function financeSubjectMissing(response) {
return rejectedWith(response, 'FIN_409')
&& String(response?.body?.message ?? '').includes('平台财务主体未配置')
}
2026-08-12 10:59:39 +08:00
function sameIds(actual, expected) {
const left = [...new Set(actual)].sort()
const right = [...new Set(expected)].sort()
return left.length === right.length && left.every((value, index) => value === right[index])
}
2026-08-13 09:52:59 +08:00
2026-08-13 14:03:54 +08:00
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)
}
2026-08-13 09:52:59 +08:00
async function findSettlementInOfficialList(context, direction, customerId, targetId, label) {
const segment = direction === 'RECEIVABLE' ? 'receivable-settlements' : 'payable-settlements'
const response = await context.request('GET',
`${BASE}/${segment}?customerId=${encodeURIComponent(customerId)}`)
const summaries = context.apiSuccess(MODULE, `从正式${label}列表查回结算单`, response,
`settlementId=${targetId}, customerId=${customerId}`, OPTIONS)
const found = rows(summaries).find((item) => settlementId(item) === targetId)
context.assert(MODULE, `正式${label}列表包含同一 settlementId`, response,
found?.direction === direction && id(found?.customerId) === id(customerId),
`expected=${targetId}, actual=${settlementId(found) || 'missing'}, direction=${found?.direction ?? 'missing'}`, OPTIONS)
return found ?? null
}
2026-08-12 10:59:39 +08:00
function dates(sources) {
const values = sources.map((source) => source.businessDate).sort()
return { start: values[0], end: values.at(-1) }
}
function key(context, action) {
return `${context.marker}-finance-reconciliation-${action}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
}
function write(context, method, path, body, action) {
return context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, action) })
}
function finish(context, start) {
const steps = context.steps.slice(start)
context.scenarios.push({
name: 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,
})
}
2026-08-20 16:13:51 +08:00
2026-08-20 17:00:05 +08:00
async function confirmIssuedStatementAsClient(context, fixture, statement) {
const username = process.env.MERCHANT_TEST_USERNAME
const password = process.env.MERCHANT_TEST_PASSWORD
const targetId = statementId(statement)
if (!username || !password || !targetId) {
context.assert(MODULE, '客户确认对账单凭据与单据门禁', null, false,
`username=${username ? 'present' : 'missing'}, password=${password ? 'present' : 'missing'}, statementId=${targetId || 'missing'}`, OPTIONS)
return null
}
const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
2026-08-20 17:00:05 +08:00
const login = context.apiSuccess(MODULE, 'Client 客户登录用于确认对账单', loginResponse,
`username=${username}`, OPTIONS)
const clientToken = login?.accessToken
if (!clientToken) return null
const listResponse = await context.request('GET', `${CLIENT_BASE}/statements`, undefined, clientToken)
const statements = context.apiSuccess(MODULE, 'Client 查询本企业对账单', listResponse,
`customerId=${fixture.customerId}, statementId=${targetId}`, OPTIONS)
const before = rows(statements).find((item) => statementId(item) === targetId)
context.assert(MODULE, 'Client 仅取得本企业待确认已出账对账单', listResponse,
id(before?.customerId) === fixture.customerId && before?.status === 'ISSUED'
&& before?.customerConfirmationStatus === 'PENDING_CONFIRMATION',
`statementId=${statementId(before) || 'missing'}, status=${before?.status ?? 'missing'}, confirmation=${before?.customerConfirmationStatus ?? 'missing'}`, OPTIONS)
if (!before) return null
const headers = { 'Idempotency-Key': key(context, `client-confirm-${targetId}`) }
const body = { expectedVersion: Number(before.version) }
const confirmResponse = await context.request('POST',
`${CLIENT_BASE}/statements/${encodeURIComponent(targetId)}/confirm`, body, clientToken, headers)
const confirmed = context.apiSuccess(MODULE, 'Client 确认本人已出账对账单', confirmResponse,
`statementId=${targetId}`, OPTIONS)
context.assert(MODULE, '客户确认状态、确认人和版本已落地', confirmResponse,
statementId(confirmed) === targetId && confirmed?.confirmationStatus === 'CONFIRMED'
&& present(confirmed?.confirmedBy) && present(confirmed?.confirmedByName)
&& present(confirmed?.confirmedAt) && Number(confirmed?.version) === Number(before.version) + 1,
`statementId=${statementId(confirmed) || 'missing'}, confirmation=${confirmed?.confirmationStatus ?? 'missing'}, version=${confirmed?.version ?? 'missing'}`, OPTIONS)
const replayResponse = await context.request('POST',
`${CLIENT_BASE}/statements/${encodeURIComponent(targetId)}/confirm`, body, clientToken, headers)
const replay = context.apiSuccess(MODULE, 'Client 客户确认幂等重放', replayResponse,
`statementId=${targetId}`, OPTIONS)
context.assert(MODULE, '客户确认幂等不重复递增版本', replayResponse,
statementId(replay) === targetId && Number(replay?.version) === Number(confirmed?.version),
`firstVersion=${confirmed?.version ?? 'missing'}, replayVersion=${replay?.version ?? 'missing'}`, OPTIONS)
fixture.root.clientConfirmation = confirmed
return confirmed
}
2026-08-20 16:13:51 +08:00
async function approveFixtureSources(context, fixture) {
const approved = []
for (const source of fixture.sources) {
const detailResponse = await context.request('GET', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}`)
let detail = context.apiSuccess(MODULE, '回读本轮 Finance 费用来源审核状态', detailResponse,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
if (!detail) return false
if (detail.reviewStatus === 'DRAFT' || detail.reviewStatus === 'REJECTED') {
const submitResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/submit`, {
reason: context.marker, expectedVersion: Number(detail.operationVersion),
}, `fee-source-submit-${source.feeSourceId}`)
detail = context.apiSuccess(MODULE, '提交本轮 Finance 费用来源审核', submitResponse,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
context.assert(MODULE, '费用来源提交后进入待审核', submitResponse,
detail?.reviewStatus === 'PENDING' && detail?.sourceStatus === 'AVAILABLE',
`feeSourceId=${source.feeSourceId}, reviewStatus=${detail?.reviewStatus ?? 'missing'}, sourceStatus=${detail?.sourceStatus ?? 'missing'}`, OPTIONS)
}
2026-08-20 16:13:51 +08:00
if (detail.reviewStatus === 'PENDING') {
const reviewResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/review`, {
action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion),
}, `fee-source-review-${source.feeSourceId}`)
detail = context.apiSuccess(MODULE, '审核本轮 Finance 费用来源', reviewResponse,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
context.assert(MODULE, '费用来源审核后成为结算候选', reviewResponse,
detail?.reviewStatus === 'APPROVED' && detail?.sourceStatus === 'AVAILABLE'
&& detail?.statementGenerated === false,
`feeSourceId=${source.feeSourceId}, reviewStatus=${detail?.reviewStatus ?? 'missing'}, sourceStatus=${detail?.sourceStatus ?? 'missing'}`, OPTIONS)
}
if (detail?.reviewStatus !== 'APPROVED') return false
approved.push(source.feeSourceId)
}
fixture.root.approvedFinanceFeeSourceIds = approved
return approved.length === fixture.sources.length
}
2026-08-12 10:59:39 +08:00
function diagnostic(response) {
return { http: response?.http ?? null,
code: response?.body?.code == null ? null : String(response.body.code),
requestId: response?.requestId ?? null, traceId: response?.traceId ?? null }
}
2026-08-13 14:03:54 +08:00
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))
}
2026-08-17 15:41:42 +08:00
function accountInformationComplete(value) {
return ['customerCode', 'customerName', 'contactName', 'contactPhone', 'status', 'lastChangedAt']
.every((field) => present(value?.[field]))
&& ['outstandingReceivable', 'pendingBilling', 'receivedInPeriod', 'outstandingPayable']
.every((field) => Number.isFinite(Number(value?.[field])))
}
function ledgerInformationComplete(value) {
return ['ledgerId', 'occurredAt', 'businessType', 'documentType', 'documentId', 'documentNo', 'operatorName']
.every((field) => present(value?.[field]))
&& ['receivableChange', 'payableChange', 'outstandingReceivableAfter']
.every((field) => Number.isFinite(Number(value?.[field])))
}
2026-08-13 14:03:54 +08:00
async function verifyCustomerAccountAndLedger(context, fixture) {
const customerId = id(fixture.customerId)
const accountResponse = await context.request('GET',
`${BASE}/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`)
2026-08-13 14:03:54 +08:00
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)
2026-08-17 15:41:42 +08:00
context.assert(MODULE, '客户账户基本信息、四项金额和最近变动时间完整', accountResponse,
accountInformationComplete(account),
`customerId=${customerId}, customerCode=${account?.customerCode ?? 'missing'}, contact=${account?.contactName ?? 'missing'}/${account?.contactPhone ?? 'missing'}, lastChangedAt=${account?.lastChangedAt ?? 'missing'}`, OPTIONS)
2026-08-13 14:03:54 +08:00
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)
2026-08-17 15:41:42 +08:00
const incompleteLedger = rowsForCustomer.filter((item) => !ledgerInformationComplete(item))
context.assert(MODULE, '客户账户流水时间、业务类型、关联单据、金额和操作人完整', ledgerResponse,
rowsForCustomer.length > 0 && incompleteLedger.length === 0,
`customerId=${customerId}, ledgerCount=${rowsForCustomer.length}, incomplete=${incompleteLedger.length}`, OPTIONS)
2026-08-13 14:03:54 +08:00
fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse),
ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) }
}
2026-08-12 10:59:39 +08:00
function blocker(context, name, evidence, category, response = null) {
context.addStep(MODULE, name, response, false, evidence, OPTIONS)
context.addCoverageGap(MODULE, SCENARIO, name, evidence, category,
{ marker: context.marker, ...diagnostic(response) })
}
2026-08-12 14:21:25 +08:00
async function normalizeFixture(context) {
2026-08-12 10:59:39 +08:00
const root = context.createdResources?.financeFullFixtures
if (!root || root.marker !== context.marker || !root.customerId
|| !Array.isArray(root.orders) || !Array.isArray(root.feeSources)) return null
const customerId = id(root.customerId)
2026-08-12 14:56:50 +08:00
const expectedSourceIds = new Set(root.feeSources.map(sourceId).filter(Boolean))
let pool = []
for (let attempt = 0; attempt < 20; attempt += 1) {
const poolResponse = await context.request('GET',
`${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`)
const poolData = poolResponse?.body?.data
pool = rows(poolData).concat(rows(poolData?.items))
const visible = new Set(pool.map((item) => id(item?.sourceId)))
if ([...expectedSourceIds].every((source) => visible.has(source))) break
await new Promise((resolve) => setTimeout(resolve, 500))
}
2026-08-12 14:21:25 +08:00
const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item]))
2026-08-12 12:47:06 +08:00
const attemptedSources = root.feeSources.map((raw) => {
2026-08-12 14:21:25 +08:00
const financeSource = poolBySource.get(sourceId(raw))
2026-08-12 10:59:39 +08:00
return {
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId),
2026-08-12 10:59:39 +08:00
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
businessDate: sourceDate(raw), waybillId: sourceWaybill(raw), marker: raw.marker,
2026-08-19 09:42:07 +08:00
supplierId: id(raw.supplierId ?? root.supplierId),
supplierName: raw.supplierName ?? root.supplierName ?? null,
2026-08-12 10:59:39 +08:00
}
})
const hasContainerAllocations = list(root.allocations).length > 0
const uncreatedSources = attemptedSources.filter((source) => !source.feeSourceId
|| (isUnboundContainerCost(source.raw) && !hasContainerAllocations))
2026-08-12 12:47:06 +08:00
return {
root, customerId,
2026-08-15 12:12:34 +08:00
sources: attemptedSources.filter((source) => source.feeSourceId
&& (!isUnboundContainerCost(source.raw) || hasContainerAllocations)),
2026-08-12 13:24:17 +08:00
uncreatedSources,
2026-08-15 12:12:34 +08:00
sourceMappings: attemptedSources.filter((source) => source.feeSourceId
&& (!isUnboundContainerCost(source.raw) || hasContainerAllocations))
.map((source) => ({ sourceFeeId: source.sourceFeeId, feeSourceId: source.feeSourceId,
sourceType: source.raw?.sourceType ?? null, direction: source.direction })),
2026-08-12 12:47:06 +08:00
}
2026-08-12 10:59:39 +08:00
}
async function createStatement(context, sources, action, name, generationMode = 'SELECTED') {
const period = dates(sources)
const body = {
customerId: sources[0].customerId, currency: 'CNY', generationMode,
feeSourceIds: generationMode === 'SELECTED' ? sources.map((source) => source.feeSourceId) : [],
periodStart: period.start, periodEnd: period.end, remark: `${context.marker}-${action}`,
}
const response = await write(context, 'POST', `${BASE}/statements`, body, action)
const statement = context.apiSuccess(MODULE, name, response,
`mode=${generationMode}, sources=${sources.length}`, OPTIONS)
if (!statement) return { body, response, statement: null }
const replayResponse = await write(context, 'POST', `${BASE}/statements`, body, action)
const replay = context.apiSuccess(MODULE, `${name}幂等重放`, replayResponse, '', OPTIONS)
context.assert(MODULE, `${name}幂等保持同一资源和版本`, replayResponse,
statementId(replay) === statementId(statement) && Number(replay?.version) === Number(statement.version),
`first=${statementId(statement)}, replay=${statementId(replay)}, version=${replay?.version ?? 'missing'}`, OPTIONS)
return { body, response, statement }
}
async function act(context, statement, action, suffix, reason, adjustmentLines) {
if (!statement) return null
const body = { action, expectedVersion: Number(statement.version) }
if (reason) body.reason = reason
if (adjustmentLines) body.adjustmentLines = adjustmentLines
const response = await write(context, 'POST',
`${BASE}/reconciliation-statements/${statementId(statement)}/actions`, body, suffix)
const next = context.apiSuccess(MODULE, `对账单执行 ${action}`, response,
`statementId=${statementId(statement)}, version=${statement.version}`, OPTIONS)
const expected = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED',
ISSUE: 'ISSUED', ADJUST: 'ISSUED', VOID: 'VOIDED', REOPEN: 'DRAFT' }[action]
context.assert(MODULE, `${action} 状态与版本符合服务端状态机`, response,
next?.status === expected && Number(next?.version) === Number(statement.version) + 1,
`expected=${expected}, actual=${next?.status ?? 'missing'}, version=${next?.version ?? 'missing'}`, OPTIONS)
return next
}
async function issueSimple(context, statement, suffix) {
statement = await act(context, statement, 'SUBMIT', `${suffix}-submit`)
statement = await act(context, statement, 'APPROVE', `${suffix}-approve`)
return act(context, statement, 'ISSUE', `${suffix}-issue`)
}
async function issueCompleteChain(context, statement) {
statement = await act(context, statement, 'SUBMIT', 'chain-submit-1')
if (!statement) return null
const replayBody = { action: 'SUBMIT', expectedVersion: Number(statement.version) - 1 }
const replayResponse = await write(context, 'POST',
`${BASE}/reconciliation-statements/${statementId(statement)}/actions`, replayBody, 'chain-submit-1')
const replay = context.apiSuccess(MODULE, '状态动作幂等重放原结果', replayResponse, '', OPTIONS)
context.assert(MODULE, '动作幂等重放不重复递增版本', replayResponse,
statementId(replay) === statementId(statement) && Number(replay?.version) === Number(statement.version),
`version=${replay?.version ?? 'missing'}`, OPTIONS)
statement = await act(context, statement, 'REJECT', 'chain-reject', `${context.marker}-reject`)
statement = await act(context, statement, 'REOPEN', 'chain-reopen-rejected', `${context.marker}-reopen-rejected`)
statement = await act(context, statement, 'SUBMIT', 'chain-submit-2')
statement = await act(context, statement, 'APPROVE', 'chain-approve-1')
statement = await act(context, statement, 'ISSUE', 'chain-issue-1')
if (!statement) return null
const adjustable = list(statement.lines).slice(0, 2)
if (adjustable.length < 2) return null
const beforeRevisions = list(statement.revisions).length
const adjustments = adjustable.map((line, index) => ({
statementLineId: id(line.statementLineId ?? line.id),
adjustedAmount: Number(line.statementAmount ?? line.amount) + (index + 1) / 100,
reason: `${context.marker}-adjust-${index + 1}`,
}))
statement = await act(context, statement, 'ADJUST', 'chain-adjust', `${context.marker}-multi-line`, adjustments)
context.assert(MODULE, '多行调整写入行值、原因并新增 revision', null,
statement && adjustments.every((expected) => list(statement.lines).some((line) =>
id(line.statementLineId) === expected.statementLineId
&& Number(line.statementAmount) === expected.adjustedAmount
&& line.adjustmentReason === expected.reason))
&& list(statement?.revisions).length > beforeRevisions,
`lines=${adjustments.length}, revisions=${list(statement?.revisions).length}`, OPTIONS)
statement = await act(context, statement, 'VOID', 'chain-void', `${context.marker}-void`)
statement = await act(context, statement, 'REOPEN', 'chain-reopen-voided', `${context.marker}-reopen-voided`)
statement = await act(context, statement, 'SUBMIT', 'chain-submit-final')
statement = await act(context, statement, 'APPROVE', 'chain-approve-final')
statement = await act(context, statement, 'ISSUE', 'chain-issue-final')
const actions = new Set(list(statement?.revisions).map((revision) => revision.action))
context.assert(MODULE, '完整链保留驳回、重开、调整、作废和再次出账 revision', null,
2026-08-20 16:13:51 +08:00
['REJECT', 'REOPEN', 'ADJUST', 'VOID', 'ISSUE'].every((action) => actions.has(action)),
2026-08-12 10:59:39 +08:00
`actions=${[...actions].join(',')}`, OPTIONS)
return statement
}
async function createReceivable(context, statement, index) {
const body = { reconciliationStatementId: statementId(statement), currency: 'CNY' }
const response = await write(context, 'POST', `${BASE}/receivable-settlements`, body, `ar-${index}`)
const settlement = context.apiSuccess(MODULE, `生成第 ${index} 张应收结算单`, response,
`statementId=${statementId(statement)}`, OPTIONS)
if (!settlement) return null
const replayResponse = await write(context, 'POST', `${BASE}/receivable-settlements`, body, `ar-${index}`)
const replay = context.apiSuccess(MODULE, `第 ${index} 张应收结算单幂等重放`, replayResponse, '', OPTIONS)
context.assert(MODULE, `第 ${index} 张 AR 幂等保持同一资源`, replayResponse,
settlementId(replay) === settlementId(settlement), `settlementId=${settlementId(settlement)}`, OPTIONS)
2026-08-13 09:52:59 +08:00
const listed = await findSettlementInOfficialList(context, 'RECEIVABLE', settlement.customerId,
settlementId(settlement), `应收第 ${index} 张`)
context.assert(MODULE, `第 ${index} 张 AR 正式列表金额与创建结果一致`, null,
listed && Number(listed.totalAmount) === Number(settlement.totalAmount)
&& Number(listed.outstandingAmount) === Number(settlement.outstandingAmount),
`createdTotal=${settlement.totalAmount ?? 'missing'}, listedTotal=${listed?.totalAmount ?? 'missing'}`, OPTIONS)
2026-08-12 10:59:39 +08:00
return settlement
}
export async function run(context) {
const start = context.steps.length
2026-08-12 14:21:25 +08:00
const fixture = await normalizeFixture(context)
2026-08-12 10:59:39 +08:00
if (!fixture) {
blocker(context, '取得本轮财务全量 Fixture',
'financeFullFixtures 必须提供匹配 marker 的 customerId/orders/feeSources;禁止猜测旧字段或历史来源',
'missing-marker-owned-finance-fixture')
finish(context, start)
return
}
2026-08-13 14:37:51 +08:00
fixture.root.reconciliationPrerequisite = null
2026-08-12 13:24:17 +08:00
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))
2026-08-13 14:03:54 +08:00
if (granted.has(LEGACY_STATEMENT_VIEW_PERMISSION)) {
2026-08-13 14:37:51 +08:00
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: [STATEMENT_LIST_PERMISSION], legacy: [LEGACY_STATEMENT_VIEW_PERMISSION] }
2026-08-13 14:03:54 +08:00
blocker(context, '客户对账单旧 view 权限兼容阻断',
2026-08-13 14:37:51 +08:00
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据;需由 IAM 角色初始化为验收账号授予 ${STATEMENT_LIST_PERMISSION} 并移除旧资源,Finance 脚本不兼容旧权限`,
2026-08-13 14:03:54 +08:00
'legacy-customer-statement-view-permission', permissionResponse)
finish(context, start)
return
}
2026-08-12 13:24:17 +08:00
const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission))
if (missingPermissions.length > 0) {
2026-08-13 14:37:51 +08:00
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: missingPermissions, legacy: [] }
2026-08-12 13:24:17 +08:00
blocker(context, 'Finance 写权限门禁',
2026-08-13 14:37:51 +08:00
`缺少正式权限资源=${missingPermissions.join(',')}; 需由 IAM 角色初始化授予验收账号;对账接口将返回 A0403,禁止继续写入`,
2026-08-12 13:24:17 +08:00
'missing-finance-write-permissions', permissionResponse)
finish(context, start)
return
}
const incompleteContainerSources = fixture.uncreatedSources.filter((source) => isUnboundContainerCost(source.raw))
if (incompleteContainerSources.length > 0) {
const evidence = incompleteContainerSources.map((source) => {
2026-08-12 12:47:06 +08:00
const raw = source.raw
2026-08-12 13:24:17 +08:00
return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, feeSourceId=${source.feeSourceId || 'missing'}, containerBookingId=${id(raw.containerBookingId) || 'missing'}`
2026-08-12 12:47:06 +08:00
}).join('; ')
2026-08-12 13:24:17 +08:00
context.addCoverageGap(MODULE, SCENARIO, '忽略不完整的柜次成本尝试',
`Finance 未返回 feeSourceId 或逐订单/运单 allocation 关联,不得作为费用来源消费:${evidence}`,
'incomplete-container-cost-attempt', { marker: context.marker, count: incompleteContainerSources.length })
}
const unpublishedSources = fixture.uncreatedSources.filter((source) => !isUnboundContainerCost(source.raw))
if (unpublishedSources.length > 0) {
blocker(context, '本轮费用来源进入 Finance 池',
`等待 Finance fee-pool 后仍有 ${unpublishedSources.length} 条 marker-owned 来源不可见;禁止忽略来源或改用 MANUAL_COST ID`,
'unpublished-marker-owned-finance-source')
finish(context, start)
return
2026-08-12 12:47:06 +08:00
}
2026-08-12 10:59:39 +08:00
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|| !source.businessDate || !source.waybillId || source.marker !== context.marker
|| (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName)))
2026-08-12 10:59:39 +08:00
if (invalid.length > 0) {
2026-08-13 14:37:51 +08:00
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
invalidSourceCount: invalid.length }
2026-08-12 10:59:39 +08:00
blocker(context, 'Fixture 来源契约完整',
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker/应付供应商快照`,
2026-08-12 10:59:39 +08:00
'invalid-finance-fixture-contract')
finish(context, start)
return
}
const receivables = fixture.sources.filter((source) => source.customerId === fixture.customerId
&& source.direction === 'RECEIVABLE' && source.currency === 'CNY')
const businessDates = new Set(receivables.map((source) => source.businessDate))
if (receivables.length < 4 || businessDates.size < 2) {
2026-08-13 14:37:51 +08:00
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
receivableCount: receivables.length, businessDateCount: businessDates.size }
2026-08-12 10:59:39 +08:00
blocker(context, '三张 AR 与日期范围 Fixture 门禁',
`至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`,
'insufficient-receivable-date-fixture')
finish(context, start)
return
}
const allPeriod = dates(receivables)
const currencyResponse = await write(context, 'POST', `${BASE}/statements`, {
customerId: fixture.customerId, currency: 'USD', generationMode: 'SELECTED',
feeSourceIds: receivables.map((source) => source.feeSourceId),
periodStart: allPeriod.start, periodEnd: allPeriod.end, remark: `${context.marker}-usd`,
}, 'currency-rejected')
2026-08-12 13:24:17 +08:00
if (accessDenied(currencyResponse)) {
blocker(context, 'Finance 对账写权限与当前令牌不一致',
`POST ${BASE}/statements 返回 A0403 Access denied;customerId=${fixture.customerId}, currency=USD, requestId=${currencyResponse.requestId ?? 'null'}, traceId=${currencyResponse.traceId ?? 'null'};有效权限查询与 Finance JWT authority 不一致,禁止继续写入`,
'stale-finance-token-authorities', currencyResponse)
finish(context, start)
return
}
context.assert(MODULE, '对账单拒绝非 CNY 币种', currencyResponse, rejectedWith(currencyResponse, 'FIN_400'),
2026-08-12 10:59:39 +08:00
`code=${currencyResponse.body?.code ?? 'missing'}`, OPTIONS)
const cutoffDate = [...businessDates].sort()[0]
const cutoffSources = receivables.filter((source) => source.businessDate === cutoffDate)
2026-08-12 12:35:29 +08:00
if (cutoffSources.length < 2) {
blocker(context, '日期筛选多行来源门禁',
`最早真实 businessDate=${cutoffDate} 仅有 ${cutoffSources.length} 条来源,无法同时验证日期排除和多行调整`,
'insufficient-same-date-receivable-sources')
2026-08-12 10:59:39 +08:00
finish(context, start)
return
}
2026-08-12 12:35:29 +08:00
// Date coverage intentionally uses SELECTED. BY_CUTOFF queries all eligible customer history.
const remainingSources = receivables.filter((source) => source.businessDate !== cutoffDate)
if (remainingSources.length < 2) {
blocker(context, '跨日期三组来源门禁',
`最早 businessDate=${cutoffDate} 之外仅有 ${remainingSources.length} 条本轮来源,无法生成三张非空独立对账单`,
'insufficient-cross-date-receivable-sources')
finish(context, start)
return
}
const groups = [cutoffSources, ...(() => {
return [remainingSources.slice(0, 1), remainingSources.slice(1)]
})()]
2026-08-20 16:13:51 +08:00
if (!await approveFixtureSources(context, fixture)) {
blocker(context, '本轮 Finance 费用来源全部审核通过',
'费用来源未完成 PENDING -> APPROVED,禁止跳过审核直接生成对账单',
'finance-fee-source-review-failed')
finish(context, start)
return
}
2026-08-12 10:59:39 +08:00
const issuedStatements = []
for (let index = 0; index < groups.length; index += 1) {
const created = await createStatement(context, groups[index], `selected-${index + 1}`,
`创建第 ${index + 1} 张独立 SELECTED 对账单`)
2026-08-12 13:24:17 +08:00
if (accessDenied(created.response)) {
blocker(context, `第 ${index + 1} 张 SELECTED 写权限`,
`POST ${BASE}/statements 返回 A0403 Access denied;customerId=${created.body.customerId}, currency=${created.body.currency}, sourceIds=${created.body.feeSourceIds.join(',')}, requestId=${created.response.requestId ?? 'null'}, traceId=${created.response.traceId ?? 'null'};请求符合 DTO,但当前令牌缺少 platform:finance:reconciliation:create authority`,
'finance-reconciliation-create-forbidden', created.response)
finish(context, start)
return
}
2026-08-12 13:55:16 +08:00
if (financeSubjectMissing(created.response)) {
blocker(context, '平台财务主体 Fixture 门禁',
`POST ${BASE}/statements 返回 FIN_409 平台财务主体未配置;sourceIds=${created.body.feeSourceIds.join(',')}, requestId=${created.response.requestId ?? 'null'}, traceId=${created.response.traceId ?? 'null'};需在测试库 fin_subject 中提供 ACTIVE 主体`,
'missing-active-finance-subject', created.response)
finish(context, start)
return
}
if (index === 0) {
const customerResponse = await context.request('GET', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE')
const otherCustomer = rows(customerResponse?.body?.data)
.find((item) => id(item?.id ?? item?.tenantId) !== fixture.customerId)
if (otherCustomer) {
const otherCustomerId = id(otherCustomer.id ?? otherCustomer.tenantId)
const isolationResponse = await write(context, 'POST', `${BASE}/statements`, {
customerId: otherCustomerId, currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [groups[index][0].feeSourceId], periodStart: groups[index][0].businessDate,
periodEnd: groups[index][0].businessDate, remark: `${context.marker}-foreign`,
}, 'cross-customer-rejected')
context.assert(MODULE, 'SELECTED 拒绝归入其他客户的本轮来源', isolationResponse,
rejectedWith(isolationResponse, 'FIN_404'),
`sourceCustomerId=${fixture.customerId}, requestedCustomerId=${otherCustomerId}, code=${isolationResponse.body?.code ?? 'missing'}`, OPTIONS)
} else {
context.addCoverageGap(MODULE, SCENARIO, 'cross-customer-isolation',
'未找到另一个 ACTIVE 客户,无法用本轮来源执行跨客户归属负向请求', 'missing-cross-customer-fixture')
}
}
2026-08-12 10:59:39 +08:00
context.assert(MODULE, `第 ${index + 1} 张仅使用分组来源`, created.response,
created.statement && sameIds(lineSourceIds(created.statement), groups[index].map((source) => source.feeSourceId)),
`expected=${groups[index].length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS)
const issued = index === 0
? await issueCompleteChain(context, created.statement)
: await issueSimple(context, created.statement, `selected-${index + 1}`)
if (!issued) {
blocker(context, `第 ${index + 1} 张对账单出账`, '状态流转或多行调整未返回有效结果', 'statement-issue-failed')
finish(context, start)
return
}
2026-08-13 14:03:54 +08:00
await verifyIssuedStatementReadback(context, fixture, issued, `第 ${index + 1} 张已出账对账单`)
2026-08-12 10:59:39 +08:00
issuedStatements.push(issued)
2026-08-12 12:35:29 +08:00
if (index === 0) {
context.assert(MODULE, 'SELECTED 日期范围只包含指定真实日期来源', created.response,
created.body.periodStart === cutoffDate && created.body.periodEnd === cutoffDate
&& sameIds(lineSourceIds(created.statement), cutoffSources.map((source) => source.feeSourceId))
&& lineSourceIds(created.statement).every((source) => !receivables
.filter((item) => item.businessDate !== cutoffDate)
.some((item) => item.feeSourceId === source)),
`date=${cutoffDate}, expected=${cutoffSources.length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS)
}
2026-08-12 10:59:39 +08:00
}
2026-08-20 17:00:05 +08:00
await confirmIssuedStatementAsClient(context, fixture, issuedStatements[0])
2026-08-12 10:59:39 +08:00
const receivableSettlements = []
for (let index = 0; index < issuedStatements.length; index += 1) {
const settlement = await createReceivable(context, issuedStatements[index], index + 1)
if (settlement) receivableSettlements.push(settlement)
}
if (receivableSettlements.length !== 3) {
blocker(context, '生成三张独立 AR', `expected=3, actual=${receivableSettlements.length}`, 'three-ar-creation-failed')
}
const payables = fixture.sources.filter((source) => source.customerId === fixture.customerId
&& source.direction === 'PAYABLE' && source.currency === 'CNY')
const payableSupplierIds = new Set(payables.map((source) => source.supplierId).filter(Boolean))
let payableSettlement = null
if (payables.length > 0 && payableSupplierIds.size === 1 && payables.every((source) => source.supplierId)) {
const dueDate = [...new Set(payables.map((source) => source.businessDate))].sort().at(-1)
const supplierId = [...payableSupplierIds][0]
const supplierName = payables.find((source) => source.supplierId === supplierId)?.supplierName ?? null
const body = { customerId: fixture.customerId, supplierId,
feeSourceIds: payables.map((source) => source.feeSourceId), dueDate, currency: 'CNY' }
const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, 'payable-settlement')
2026-08-19 09:42:07 +08:00
payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 supplierId 生成应付结算单',
2026-08-12 10:59:39 +08:00
response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS)
2026-08-13 09:52:59 +08:00
if (payableSettlement) {
const listed = await findSettlementInOfficialList(context, 'PAYABLE', fixture.customerId,
settlementId(payableSettlement), '应付')
context.assert(MODULE, 'AP 正式列表供应商与金额和创建结果一致', null,
listed && id(listed.supplierId) === id(payableSettlement.supplierId)
&& Number(listed.totalAmount) === Number(payableSettlement.totalAmount),
`createdSupplier=${id(payableSettlement.supplierId)}, listedSupplier=${id(listed?.supplierId) || 'missing'}`, OPTIONS)
}
2026-08-12 10:59:39 +08:00
if (!payableSettlement) {
blocker(context, 'PAYABLE 真实接口创建失败',
`HTTP=${response.http}, code=${response.body?.code ?? 'missing'}, requestId=${response.requestId ?? 'null'}, traceId=${response.traceId ?? 'null'}`,
'payable-settlement-api-failure', response)
}
} else {
blocker(context, 'PAYABLE 结算 Fixture 门禁',
2026-08-19 09:42:07 +08:00
`需要至少一条同客户 CNY PAYABLE,且全部提供相同 supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`,
2026-08-12 10:59:39 +08:00
'insufficient-payable-supplier-fixture')
}
const statementRows = [
2026-08-12 12:35:29 +08:00
{ kind: 'SELECTED_DATE', statementId: statementId(issuedStatements[0]), status: issuedStatements[0].status,
version: issuedStatements[0].version, feeSourceIds: cutoffSources.map((source) => source.feeSourceId),
2026-08-12 10:59:39 +08:00
periodStart: cutoffDate, periodEnd: cutoffDate },
...issuedStatements.map((statement, index) => ({ kind: index === 0 ? 'SELECTED_FULL_CHAIN' : 'SELECTED',
statementId: statementId(statement), status: statement.status, version: statement.version,
feeSourceIds: groups[index].map((source) => source.feeSourceId),
periodStart: dates(groups[index]).start, periodEnd: dates(groups[index]).end })),
]
const arRows = receivableSettlements.map((settlement, index) => ({
settlementId: settlementId(settlement), statementId: statementId(issuedStatements[index]),
direction: settlement.direction, status: settlement.status, paymentStatus: settlement.paymentStatus,
version: settlement.version,
}))
fixture.root.statements = statementRows
fixture.root.statementMap = { byCutoff: statementRows[0], fullChain: statementRows[1],
selected: statementRows.slice(1) }
fixture.root.receivableSettlements = arRows
fixture.root.receivableSettlementMap = { byStatementId: Object.fromEntries(arRows.map((row) => [row.statementId, row])) }
fixture.root.payableSettlements = payableSettlement ? [{
settlementId: settlementId(payableSettlement), direction: payableSettlement.direction,
2026-08-19 09:42:07 +08:00
supplierId: id(payableSettlement.supplierId),
2026-08-12 10:59:39 +08:00
supplierName: payableSettlement.supplierName ?? payables[0]?.supplierName ?? null,
status: payableSettlement.status,
paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version,
feeSourceIds: payables.map((source) => source.feeSourceId),
}] : []
fixture.root.sourceMappings = fixture.sourceMappings
2026-08-13 14:03:54 +08:00
await verifyCustomerAccountAndLedger(context, fixture)
2026-08-12 10:59:39 +08:00
finish(context, start)
}