Files
baiyue-firstleg/scripts/admin-test-env-modules/finance-reconciliation-full.mjs
T
2026-08-12 10:59:39 +08:00

329 lines
20 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 }
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?.orderId ?? value?.inboundId ?? value?.sourceNo) }
function lineSourceIds(value) { return list(value?.lines).map(sourceId).filter(Boolean) }
function apiRejected(response) { return response?.http === 200 && response?.body && String(response.body.code) !== '200' }
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 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 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) })
}
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 orders = new Map(root.orders.map((order) => [id(order.orderId ?? order.id), order]))
const sources = root.feeSources.map((raw) => {
const order = orders.get(id(raw.orderId))
return {
raw, feeSourceId: sourceId(raw), customerId: id(raw.customerId ?? customerId),
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
businessDate: sourceDate(raw), waybillId: sourceWaybill(raw) || sourceWaybill(order),
marker: raw.marker ?? root.marker,
supplierId: id(raw.supplierId ?? raw.partnerId ?? root.supplierId ?? root.partnerId),
supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null,
}
})
return { root, customerId, sources }
}
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)
return settlement
}
export async function run(context) {
const start = context.steps.length
const fixture = normalizeFixture(context)
if (!fixture) {
blocker(context, '取得本轮财务全量 Fixture',
'financeFullFixtures 必须提供匹配 marker 的 customerId/orders/feeSources;禁止猜测旧字段或历史来源',
'missing-marker-owned-finance-fixture')
finish(context, start)
return
}
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|| !source.businessDate || !source.waybillId || source.marker !== context.marker)
if (invalid.length > 0) {
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) {
blocker(context, '三张 AR 与日期范围 Fixture 门禁',
`至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`,
'insufficient-receivable-date-fixture')
finish(context, start)
return
}
const foreign = fixture.sources.find((source) => source.customerId !== fixture.customerId)
if (foreign) {
const response = await write(context, 'POST', `${BASE}/statements`, {
customerId: fixture.customerId, currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [foreign.feeSourceId], periodStart: foreign.businessDate,
periodEnd: foreign.businessDate, remark: `${context.marker}-foreign`,
}, 'cross-customer-rejected')
context.assert(MODULE, 'SELECTED 拒绝混入其他客户来源', response, apiRejected(response),
`code=${response.body?.code ?? 'missing'}`, OPTIONS)
} else {
context.addCoverageGap(MODULE, SCENARIO, 'cross-customer-isolation',
'Fixture 未提供其他客户 marker 来源,无法执行跨客户负向请求', 'missing-cross-customer-fixture')
}
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')
context.assert(MODULE, '对账单拒绝非 CNY 币种', currencyResponse, apiRejected(currencyResponse),
`code=${currencyResponse.body?.code ?? 'missing'}`, OPTIONS)
const cutoffDate = [...businessDates].sort()[0]
const cutoffSources = receivables.filter((source) => source.businessDate === cutoffDate)
const poolResponse = await context.request('GET',
`${BASE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=RECEIVABLE`)
const pool = context.apiSuccess(MODULE, 'BY_CUTOFF 前核对客户应收费用池', poolResponse, '', OPTIONS)
const eligible = rows(pool).filter((source) => sourceDate(source) === cutoffDate
&& String(source.currency ?? source.currencyCode) === 'CNY' && source.reviewStatus === 'APPROVED'
&& source.settlementIncluded === true && source.sourceStatus === 'AVAILABLE').map(sourceId)
if (!sameIds(eligible, cutoffSources.map((source) => source.feeSourceId))) {
blocker(context, 'BY_CUTOFF 历史来源隔离门禁',
'指定日期全部可结算来源必须与本轮 fixture 精确相等,禁止锁定历史来源', 'unsafe-cutoff-source-set', poolResponse)
finish(context, start)
return
}
const cutoff = await createStatement(context, cutoffSources, 'cutoff', '按真实业务日期生成对账单', 'BY_CUTOFF')
let cutoffStatement = cutoff.statement
context.assert(MODULE, 'BY_CUTOFF 仅纳入真实日期范围内本轮来源', cutoff.response,
cutoffStatement && cutoff.body.periodStart === cutoffDate && cutoff.body.periodEnd === cutoffDate
&& sameIds(lineSourceIds(cutoffStatement), cutoffSources.map((source) => source.feeSourceId)),
`date=${cutoffDate}, expected=${cutoffSources.length}, actual=${lineSourceIds(cutoffStatement).length}`, OPTIONS)
// ReconciliationStatementService explicitly permits VOID from DRAFT and releases LOCKED sources.
cutoffStatement = await act(context, cutoffStatement, 'VOID', 'cutoff-draft-void', `${context.marker}-release-cutoff`)
if (!cutoffStatement) { finish(context, start); return }
const groups = [[receivables[0], receivables[1]], [receivables[2]], receivables.slice(3)]
const issuedStatements = []
for (let index = 0; index < groups.length; index += 1) {
const created = await createStatement(context, groups[index], `selected-${index + 1}`,
`创建第 ${index + 1} 张独立 SELECTED 对账单`)
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
}
issuedStatements.push(issued)
}
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) {
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: 'BY_CUTOFF', statementId: statementId(cutoffStatement), status: cutoffStatement.status,
version: cutoffStatement.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),
}] : []
finish(context, start)
}