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

553 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:list',
'platform:finance:settlement-ap:create',
'platform:finance:settlement-ap:list',
]
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) }
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)
}
function isUnboundContainerCost(raw) {
return raw?.sourceType === 'MANUAL' && raw?.billingBasis === 'CONTAINER_SHARED'
&& !sourceWaybill(raw)
}
function lineSourceIds(value) { return list(value?.lines).map(sourceId).filter(Boolean) }
function rejectedWith(response, code) {
return response?.http === 200 && String(response?.body?.code) === code
}
function accessDenied(response) { return rejectedWith(response, 'A0403') }
function financeSubjectMissing(response) {
return rejectedWith(response, 'FIN_409')
&& String(response?.body?.message ?? '').includes('平台财务主体未配置')
}
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])
}
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',
`${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
}
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,
})
}
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 }
}
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,
{ marker: context.marker, ...diagnostic(response) })
}
async function normalizeFixture(context) {
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)
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))
}
const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item]))
const attemptedSources = root.feeSources.map((raw) => {
const financeSource = poolBySource.get(sourceId(raw))
|| pool.find((item) => String(item?.direction || '').toUpperCase() === 'PAYABLE'
&& [raw.containerBookingId, raw.loadingPlanId].map(id).includes(id(item?.containerId)))
return {
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId),
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
businessDate: sourceDate(raw), waybillId: sourceWaybill(raw), marker: raw.marker,
supplierId: id(raw.supplierId ?? raw.partnerId ?? root.supplierId ?? root.partnerId),
supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null,
}
})
const hasContainerAllocations = list(root.allocations).length > 0
const uncreatedSources = attemptedSources.filter((source) => !source.feeSourceId
|| (isUnboundContainerCost(source.raw) && !hasContainerAllocations))
return {
root, customerId,
sources: attemptedSources.filter((source) => source.feeSourceId
&& (!isUnboundContainerCost(source.raw) || hasContainerAllocations)),
uncreatedSources,
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 })),
}
}
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,
['RECONCILIATION_REJECT', 'RECONCILIATION_REOPEN', 'RECONCILIATION_ADJUST',
'RECONCILIATION_VOID', 'RECONCILIATION_ISSUE'].every((action) => actions.has(action)),
`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)
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)
return settlement
}
export async function run(context) {
const start = context.steps.length
const fixture = await normalizeFixture(context)
if (!fixture) {
blocker(context, '取得本轮财务全量 Fixture',
'financeFullFixtures 必须提供匹配 marker 的 customerId/orders/feeSources;禁止猜测旧字段或历史来源',
'missing-marker-owned-finance-fixture')
finish(context, start)
return
}
fixture.root.reconciliationPrerequisite = null
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)) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: [STATEMENT_LIST_PERMISSION], legacy: [LEGACY_STATEMENT_VIEW_PERMISSION] }
blocker(context, '客户对账单旧 view 权限兼容阻断',
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据;需由 IAM 角色初始化为验收账号授予 ${STATEMENT_LIST_PERMISSION} 并移除旧资源,Finance 脚本不兼容旧权限`,
'legacy-customer-statement-view-permission', permissionResponse)
finish(context, start)
return
}
const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission))
if (missingPermissions.length > 0) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: missingPermissions, legacy: [] }
blocker(context, 'Finance 写权限门禁',
`缺少正式权限资源=${missingPermissions.join(',')}; 需由 IAM 角色初始化授予验收账号;对账接口将返回 A0403,禁止继续写入`,
'missing-finance-write-permissions', permissionResponse)
finish(context, start)
return
}
if (fixture.uncreatedSources.length > 0) {
const evidence = fixture.uncreatedSources.map((source) => {
const raw = source.raw
return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, feeSourceId=${source.feeSourceId || 'missing'}, containerBookingId=${id(raw.containerBookingId) || 'missing'}`
}).join('; ')
context.addCoverageGap(MODULE, SCENARIO, '忽略不完整的柜次成本尝试',
`Finance 未返回 feeSourceId 或逐订单/运单 allocation 关联,不得作为费用来源消费:${evidence}`,
'incomplete-container-cost-attempt', { marker: context.marker, count: fixture.uncreatedSources.length })
}
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|| !source.businessDate || !source.waybillId || source.marker !== context.marker)
if (invalid.length > 0) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
invalidSourceCount: invalid.length }
blocker(context, 'Fixture 来源契约完整',
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`,
'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) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
receivableCount: receivables.length, businessDateCount: businessDates.size }
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')
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'),
`code=${currencyResponse.body?.code ?? 'missing'}`, OPTIONS)
const cutoffDate = [...businessDates].sort()[0]
const cutoffSources = receivables.filter((source) => source.businessDate === cutoffDate)
if (cutoffSources.length < 2) {
blocker(context, '日期筛选多行来源门禁',
`最早真实 businessDate=${cutoffDate} 仅有 ${cutoffSources.length} 条来源,无法同时验证日期排除和多行调整`,
'insufficient-same-date-receivable-sources')
finish(context, start)
return
}
// 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)]
})()]
const issuedStatements = []
for (let index = 0; index < groups.length; index += 1) {
const created = await createStatement(context, groups[index], `selected-${index + 1}`,
`创建第 ${index + 1} 张独立 SELECTED 对账单`)
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
}
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')
}
}
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
}
await verifyIssuedStatementReadback(context, fixture, issued, `第 ${index + 1} 张已出账对账单`)
issuedStatements.push(issued)
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)
}
}
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')
payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 partnerId/supplierId 生成应付结算单',
response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS)
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)
}
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 门禁',
`需要至少一条同客户 CNY PAYABLE,且全部提供相同 partnerId/supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`,
'insufficient-payable-supplier-fixture')
}
const statementRows = [
{ kind: 'SELECTED_DATE', statementId: statementId(issuedStatements[0]), status: issuedStatements[0].status,
version: issuedStatements[0].version, feeSourceIds: cutoffSources.map((source) => source.feeSourceId),
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,
supplierId: id(payableSettlement.supplierId), partnerId: id(payableSettlement.partnerId ?? payableSettlement.supplierId),
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
await verifyCustomerAccountAndLedger(context, fixture)
finish(context, start)
}