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

173 lines
8.9 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { run } from './finance-reconciliation-full.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'finance-reconciliation-full.mjs'), 'utf8')
function ok(data) {
return { http: 200, body: { code: '200', data }, requestId: 'request-test', traceId: 'trace-test' }
}
function fakeContext(withFixture = true) {
const marker = 'FIN-TEST-MARKER'
const receivables = [
{ feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' },
{ feeSourceId: '102', orderId: '202', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-11' },
{ feeSourceId: '103', orderId: '203', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-11' },
{ feeSourceId: '104', orderId: '204', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-11' },
]
const feeSources = [...receivables,
{ feeSourceId: '105', orderId: '205', direction: 'PAYABLE', currency: 'CNY',
businessDate: '2026-08-11', partnerId: '501', partnerName: 'Fixture Partner' }]
const fixture = { marker, customerId: '11',
orders: feeSources.map((item) => ({ orderId: item.orderId, shipmentId: `SHIP-${item.orderId}` })),
feeSources: feeSources.map((item) => ({ ...item, marker })) }
const statements = new Map()
const settlements = new Map()
const idempotency = new Map()
let statementSequence = 900
let settlementSequence = 1200
const calls = []
const steps = []
const coverageGaps = []
function responseForStatement(statement) { return structuredClone(statement) }
function applyAction(statement, body) {
const status = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED',
ISSUE: 'ISSUED', ADJUST: 'ISSUED', VOID: 'VOIDED', REOPEN: 'DRAFT' }[body.action]
statement.revisions.push({ action: `RECONCILIATION_${body.action}` })
statement.status = status
statement.version += 1
if (body.action === 'ADJUST') {
for (const adjustment of body.adjustmentLines) {
const line = statement.lines.find((item) => item.statementLineId === adjustment.statementLineId)
line.statementAmount = adjustment.adjustedAmount
line.adjustmentReason = adjustment.reason
}
}
return responseForStatement(statement)
}
const context = {
marker, token: 'token', steps, scenarios: [], coverageGaps, calls,
createdResources: withFixture ? { financeFullFixtures: fixture } : {},
async request(method, requestPath, body, token, headers = {}) {
calls.push({ method, path: requestPath, body, headers })
const idem = headers['Idempotency-Key']
if (idem && idempotency.has(idem)) return ok(structuredClone(idempotency.get(idem)))
if (method === 'GET' && requestPath.includes('/fee-pool')) {
return ok(receivables.map((item) => ({ ...item, reviewStatus: 'APPROVED',
settlementIncluded: true, sourceStatus: 'AVAILABLE' })))
}
if (method === 'POST' && requestPath.endsWith('/statements')) {
if (body.currency !== 'CNY') return { http: 200, body: { code: 'FIN_400' } }
const selected = body.generationMode === 'BY_CUTOFF'
? receivables.filter((item) => item.businessDate >= body.periodStart && item.businessDate <= body.periodEnd)
: feeSources.filter((item) => body.feeSourceIds.includes(item.feeSourceId))
if (selected.some((item) => item.direction !== 'RECEIVABLE')) return { http: 200, body: { code: 'FIN_409' } }
const statementId = String(++statementSequence)
const statement = { statementId, status: 'DRAFT', version: 0, revisions: [],
lines: selected.map((item, index) => ({ statementLineId: `${statementId}${index + 1}`,
feeSourceId: item.feeSourceId, statementAmount: 100 + index, adjustmentReason: null })) }
statements.set(statementId, statement)
if (idem) idempotency.set(idem, responseForStatement(statement))
return ok(responseForStatement(statement))
}
const actionMatch = requestPath.match(/reconciliation-statements\/(\d+)\/actions$/)
if (method === 'POST' && actionMatch) {
const result = applyAction(statements.get(actionMatch[1]), body)
if (idem) idempotency.set(idem, result)
return ok(result)
}
if (method === 'POST' && requestPath.endsWith('/receivable-settlements')) {
const statement = statements.get(String(body.reconciliationStatementId))
assert.equal(statement.status, 'ISSUED')
const settlement = { settlementId: String(++settlementSequence), direction: 'RECEIVABLE',
reconciliationStatementId: body.reconciliationStatementId, status: 'OPEN',
paymentStatus: 'UNPAID', version: 0 }
settlements.set(settlement.settlementId, settlement)
if (idem) idempotency.set(idem, settlement)
return ok(structuredClone(settlement))
}
if (method === 'POST' && requestPath.endsWith('/payable-settlements')) {
const settlement = { settlementId: String(++settlementSequence), direction: 'PAYABLE',
supplierId: String(body.supplierId), status: 'OPEN', paymentStatus: 'UNPAID', version: 0 }
if (idem) idempotency.set(idem, settlement)
return ok(structuredClone(settlement))
}
throw new Error(`Unexpected request: ${method} ${requestPath}`)
},
addStep(module, name, response, passed, evidence = '', options = {}) {
steps.push({ module, name, passed: Boolean(passed), deploymentBlocker: Boolean(options.deploymentBlocker && !passed), evidence })
return Boolean(passed)
},
addCoverageGap(module, scenario, name, reason, category) {
coverageGaps.push({ module, scenario, name, reason, category })
},
apiSuccess(module, name, response, evidence = '', options = {}) {
const passed = response.http === 200 && String(response.body?.code) === '200'
this.addStep(module, name, response, passed, evidence, options)
return passed ? response.body.data : null
},
assert(module, name, response, condition, evidence = '', options = {}) {
return this.addStep(module, name, response, condition, evidence, options)
},
}
return context
}
test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输出', () => {
assert.match(source, /generationMode === 'SELECTED'/)
assert.match(source, /businessDates\.size < 2/)
assert.match(source, /receivables\.length < 4/)
assert.match(source, /const groups = \[\[receivables\[0\], receivables\[1\]\]/)
assert.match(source, /\/payable-settlements/)
assert.match(source, /fixture\.root\.statements = statementRows/)
assert.match(source, /fixture\.root\.receivableSettlements = arRows/)
assert.match(source, /http: response\?\.http \?\? null/)
assert.match(source, /requestId: response\?\.requestId \?\? null/)
assert.match(source, /traceId: response\?\.traceId \?\? null/)
for (const action of ['SUBMIT', 'APPROVE', 'REJECT', 'ISSUE', 'ADJUST', 'VOID', 'REOPEN']) {
assert.match(source, new RegExp(`['"]${action}['"]`))
}
})
test('run 发布一条日期链、三张独立已出账单、三张 AR 和一张 AP', async () => {
const context = fakeContext()
await run(context)
const fixture = context.createdResources.financeFullFixtures
assert.equal(fixture.statements.length, 4)
assert.equal(fixture.statements[0].kind, 'BY_CUTOFF')
assert.equal(fixture.statements[0].status, 'VOIDED')
assert.deepEqual(fixture.statements.slice(1).map((item) => item.status), ['ISSUED', 'ISSUED', 'ISSUED'])
assert.equal(fixture.receivableSettlements.length, 3)
assert.equal(new Set(fixture.receivableSettlements.map((item) => item.statementId)).size, 3)
assert.equal(fixture.payableSettlements.length, 1)
assert.equal(fixture.payableSettlements[0].supplierId, '501')
assert.equal(fixture.payableSettlements[0].partnerId, '501')
assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Partner')
assert.equal(context.steps.filter((step) => !step.passed).length, 0)
})
test('run 在 fixture 缺失时记录 blocker 且不发送 HTTP 请求', async () => {
const context = fakeContext(false)
await run(context)
assert.equal(context.calls.length, 0)
assert.equal(context.steps.length, 1)
assert.equal(context.steps[0].passed, false)
assert.equal(context.steps[0].deploymentBlocker, true)
assert.equal(context.coverageGaps[0].category, 'missing-marker-owned-finance-fixture')
})
test('PAYABLE 接受 partnerId 及名称快照,不依赖 SUPPLIER 类型', async () => {
const context = fakeContext()
await run(context)
const payableRequest = context.calls.find((call) => call.path.endsWith('/payable-settlements'))
assert.equal(payableRequest.body.supplierId, '501')
assert.doesNotMatch(source, /partnerType|types\?\.includes|SUPPLIER.*阻断/)
})