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

347 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 attemptedSources = 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: attemptedSources.filter((source) => source.feeSourceId),
uncreatedSources: attemptedSources.filter((source) => !source.feeSourceId),
}
}
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
}
if (fixture.uncreatedSources.length > 0) {
const evidence = fixture.uncreatedSources.map((source) => {
const raw = source.raw
return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, containerBookingId=${id(raw.containerBookingId) || 'missing'}`
}).join('; ')
context.addCoverageGap(MODULE, SCENARIO, '忽略未创建的费用来源尝试',
`Finance 未返回 feeSourceId,不得作为费用来源消费:${evidence}`,
'uncreated-fee-source-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) {
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)
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 对账单`)
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)
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) {
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),
}] : []
finish(context, start)
}