2026-08-12 10:59:39 +08:00
|
|
|
import test from 'node:test'
|
|
|
|
|
import assert from 'node:assert/strict'
|
2026-08-14 10:09:46 +08:00
|
|
|
import fs from 'node:fs'
|
|
|
|
|
import path from 'node:path'
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
|
import { receiptBody, run, values } from './finance-settlement-full.mjs'
|
2026-08-12 10:59:39 +08:00
|
|
|
|
2026-08-14 10:09:46 +08:00
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
|
|
|
const source = fs.readFileSync(path.join(here, 'finance-settlement-full.mjs'), 'utf8')
|
2026-08-12 10:59:39 +08:00
|
|
|
const response = (data, http = 200, code = '200') => ({ http, body: { code, data } })
|
|
|
|
|
|
|
|
|
|
function fakeContext(overrides = {}) {
|
|
|
|
|
const balances = new Map([['101', 100], ['102', 100], ['103', 100], ['201', 100]])
|
2026-08-13 14:03:54 +08:00
|
|
|
const paidAmounts = new Map([['101', 0], ['102', 0], ['103', 0], ['201', 0]])
|
|
|
|
|
const statuses = new Map([['101', 'ISSUED'], ['102', 'ISSUED'], ['103', 'ISSUED'], ['201', 'ISSUED']])
|
|
|
|
|
const versions = new Map([['101', 1], ['102', 1], ['103', 1], ['201', 1]])
|
|
|
|
|
const actionIdempotency = new Map()
|
2026-08-12 10:59:39 +08:00
|
|
|
const settlementToBill = new Map([['101', '11'], ['102', '12'], ['103', '13']])
|
|
|
|
|
const billToSettlement = new Map([...settlementToBill].map(([s, b]) => [b, s]))
|
|
|
|
|
const fixture = { marker: 'T1', customerId: '9', voucherFileId: '77',
|
|
|
|
|
feeSources: [{ feeSourceId: '501', sourceType: 'MANUAL' }],
|
2026-08-12 15:53:47 +08:00
|
|
|
sourceMappings: [{ sourceFeeId: '9001', feeSourceId: '501', sourceType: 'MANUAL', direction: 'RECEIVABLE' },
|
|
|
|
|
{ sourceFeeId: '9002', feeSourceId: '601', sourceType: 'MANUAL', direction: 'PAYABLE' }],
|
2026-08-12 10:59:39 +08:00
|
|
|
statements: [
|
|
|
|
|
{ kind: 'BY_CUTOFF', statementId: '10', status: 'VOIDED', feeSourceIds: [] },
|
|
|
|
|
{ kind: 'SELECTED_FULL_CHAIN', statementId: '11', status: 'ISSUED', feeSourceIds: ['501'] },
|
|
|
|
|
{ kind: 'SELECTED', statementId: '12', status: 'ISSUED', feeSourceIds: [] },
|
|
|
|
|
{ kind: 'SELECTED', statementId: '13', status: 'ISSUED', feeSourceIds: [] },
|
|
|
|
|
],
|
|
|
|
|
receivableSettlements: [
|
|
|
|
|
{ settlementId: '101', statementId: '11', direction: 'RECEIVABLE', status: 'ISSUED' },
|
|
|
|
|
{ settlementId: '102', statementId: '12', direction: 'RECEIVABLE', status: 'ISSUED' },
|
|
|
|
|
{ settlementId: '103', statementId: '13', direction: 'RECEIVABLE', status: 'ISSUED' },
|
|
|
|
|
],
|
2026-08-12 15:53:47 +08:00
|
|
|
payableSettlements: [{ settlementId: '201', direction: 'PAYABLE', supplierId: '301', status: 'ISSUED', feeSourceIds: ['601'] }],
|
2026-08-12 10:59:39 +08:00
|
|
|
receivablePartialAmount: 40, payablePartialAmount: 40, multiBillAllocationAmount: 10,
|
|
|
|
|
...overrides }
|
|
|
|
|
const calls = []; const assertions = []; const gaps = []; const steps = []
|
|
|
|
|
return { marker: 'T1', token: 'token', createdResources: { financeFullFixtures: fixture }, calls, assertions, gaps, steps,
|
2026-09-04 16:33:33 +08:00
|
|
|
financeState: { balances, paidAmounts, statuses, versions },
|
2026-08-12 10:59:39 +08:00
|
|
|
addStep(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) },
|
|
|
|
|
assert(module, name, res, condition) { assertions.push({ name, condition }); assert.equal(condition, true, name) },
|
|
|
|
|
async request(method, path, body, token, headers) {
|
|
|
|
|
calls.push({ method, path, body, headers })
|
|
|
|
|
const match = path.match(/\/settlements\/(\d+)/)
|
2026-08-13 14:03:54 +08:00
|
|
|
if (method === 'GET' && match) return response({ settlementId: match[1], outstandingAmount: balances.get(match[1]), paidAmount: paidAmounts.get(match[1]), status: statuses.get(match[1]), version: versions.get(match[1]) })
|
|
|
|
|
if (match && path.endsWith('/actions')) {
|
|
|
|
|
const targetId = match[1]; const idem = headers['Idempotency-Key']; const fingerprint = JSON.stringify(body)
|
|
|
|
|
const prior = actionIdempotency.get(idem)
|
|
|
|
|
if (prior) return prior.fingerprint === fingerprint ? prior.response : response(null, 409, 'FIN_IDEMPOTENCY_CONFLICT')
|
|
|
|
|
if (body.action === 'VOID' && paidAmounts.get(targetId) > 0) return response(null, 409, 'FIN_SETTLEMENT_ALREADY_PAID')
|
|
|
|
|
if (body.expectedVersion !== versions.get(targetId)) return response(null, 409, 'FIN_VERSION_CONFLICT')
|
|
|
|
|
const nextStatus = body.action === 'VOID' ? 'VOIDED' : 'ISSUED'; statuses.set(targetId, nextStatus); versions.set(targetId, versions.get(targetId) + 1)
|
|
|
|
|
const result = response({ settlementId: targetId, outstandingAmount: balances.get(targetId), paidAmount: paidAmounts.get(targetId), status: nextStatus, version: versions.get(targetId) })
|
|
|
|
|
actionIdempotency.set(idem, { fingerprint, response: result }); return result
|
|
|
|
|
}
|
|
|
|
|
if (path.endsWith('/receipts')) { for (const allocation of body.allocations) { const targetId = billToSettlement.get(String(allocation.billId)); balances.set(targetId, balances.get(targetId) - allocation.amount); paidAmounts.set(targetId, paidAmounts.get(targetId) + allocation.amount); statuses.set(targetId, balances.get(targetId) === 0 ? 'PAID' : 'PARTIALLY_PAID'); versions.set(targetId, versions.get(targetId) + 1) } return response({ receiptId: calls.length, allocations: body.allocations }) }
|
2026-08-12 10:59:39 +08:00
|
|
|
if (match && path.endsWith('/transactions')) {
|
|
|
|
|
const current = balances.get(match[1]); if (body.amount > current || current === 0) return response(null, 409, 'FIN_409')
|
2026-08-13 14:03:54 +08:00
|
|
|
balances.set(match[1], current - body.amount); paidAmounts.set(match[1], paidAmounts.get(match[1]) + body.amount); statuses.set(match[1], current - body.amount === 0 ? 'PAID' : 'PARTIALLY_PAID'); versions.set(match[1], versions.get(match[1]) + 1); return response({ settlementId: match[1], outstandingAmount: current - body.amount, paidAmount: paidAmounts.get(match[1]), status: statuses.get(match[1]), version: versions.get(match[1]) })
|
2026-08-12 10:59:39 +08:00
|
|
|
}
|
|
|
|
|
throw new Error(`unexpected ${method} ${path}`)
|
|
|
|
|
} }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 12:18:59 +08:00
|
|
|
test('values helper supports nested arrays while run consumes frozen top-level arrays', () => {
|
2026-08-12 10:59:39 +08:00
|
|
|
assert.deepEqual(values([{ id: 1 }]), [{ id: 1 }])
|
|
|
|
|
assert.deepEqual(values({ records: [{ id: 2 }] }), [{ id: 2 }])
|
|
|
|
|
assert.deepEqual(values({ marker: 'x', selected: { id: 3 } }), [{ id: 3 }])
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-14 10:09:46 +08:00
|
|
|
test('receipt body preserves 19-digit customer and voucher IDs through JSON', () => {
|
|
|
|
|
const body = receiptBody({ marker: 'RUN-1' }, '2088073097986560001',
|
|
|
|
|
'2088073285653299201', [{ billId: 13, amount: 1 }])
|
|
|
|
|
const roundTrip = JSON.parse(JSON.stringify(body))
|
|
|
|
|
assert.equal(roundTrip.customerId, '2088073097986560001')
|
|
|
|
|
assert.deepEqual(roundTrip.voucherFileIds, ['2088073285653299201'])
|
|
|
|
|
assert.match(source, /customerId: String\(customerId\)/)
|
|
|
|
|
assert.doesNotMatch(source, /customerId: Number\(customerId\)/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-12 10:59:39 +08:00
|
|
|
test('run 使用 receipts 完成应收部分、全额及多单分配,使用 transactions 完成 AP', async () => {
|
|
|
|
|
const context = fakeContext()
|
|
|
|
|
await run(context)
|
|
|
|
|
const receiptCalls = context.calls.filter((call) => call.path.endsWith('/receipts'))
|
|
|
|
|
assert.equal(receiptCalls.length, 3)
|
|
|
|
|
assert.deepEqual(Object.keys(receiptCalls[0].body.allocations[0]).sort(), ['amount', 'billId'])
|
|
|
|
|
assert.equal(receiptCalls.find((call) => call.body.allocations.length === 3).body.allocations.length, 3)
|
|
|
|
|
const payments = context.calls.filter((call) => call.path.includes('/settlements/201/transactions'))
|
|
|
|
|
assert.equal(payments.length, 3)
|
|
|
|
|
assert.ok(payments.every((call) => call.body.method === 'BANK_TRANSFER' && call.body.transactionDate && call.body.expectedVersion != null))
|
|
|
|
|
assert.ok(context.calls.filter((call) => call.method === 'POST').every((call) => call.headers['Idempotency-Key'].startsWith('T1-')))
|
|
|
|
|
assert.ok(context.createdResources.financeFullFixtures.receipts.final)
|
|
|
|
|
assert.ok(context.createdResources.financeFullFixtures.payments.final)
|
|
|
|
|
assert.equal(context.createdResources.financeFullFixtures.settlementFinalStates.receivable.outstandingAmount, 0)
|
|
|
|
|
assert.equal(context.createdResources.financeFullFixtures.settlementFinalStates.payable.outstandingAmount, 0)
|
2026-08-13 14:03:54 +08:00
|
|
|
const actions = context.calls.filter((call) => call.path.endsWith('/actions'))
|
|
|
|
|
assert.deepEqual(actions.slice(0, 4).map((call) => call.body.action), ['VOID', 'VOID', 'REOPEN', 'REOPEN'])
|
|
|
|
|
assert.equal(actions[0].headers['Idempotency-Key'], actions[1].headers['Idempotency-Key'])
|
|
|
|
|
assert.equal(actions[0].headers['Idempotency-Key'], actions[2].headers['Idempotency-Key'])
|
|
|
|
|
assert.notEqual(actions[2].body.reason, actions[0].body.reason)
|
|
|
|
|
assert.notEqual(actions[3].headers['Idempotency-Key'], actions[0].headers['Idempotency-Key'])
|
|
|
|
|
assert.ok(context.assertions.find((item) => item.name === '已收付款结算单拒绝作废且状态金额不变')?.condition)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('正式结算动作仅使用本轮 fixture,覆盖 VOID/REOPEN、重放、异参冲突和已付款拒绝', async () => {
|
|
|
|
|
const context = fakeContext()
|
|
|
|
|
await run(context)
|
|
|
|
|
const actions = context.calls.filter((call) => call.path.match(/\/settlements\/(101|102|103|201)\/actions$/))
|
|
|
|
|
assert.equal(actions.length, 5)
|
|
|
|
|
assert.ok(actions.every((call) => ['101', '102', '103', '201'].includes(call.path.match(/\/settlements\/(\d+)\/actions$/)[1])))
|
|
|
|
|
for (const name of ['未收付款结算单作废', '结算单作废同键同参幂等重放', '结算单动作同键异参冲突', '已作废结算单重开', '已收付款结算单拒绝作废且状态金额不变']) {
|
|
|
|
|
assert.ok(context.assertions.find((item) => item.name === name)?.condition, name)
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
})
|
|
|
|
|
|
2026-09-04 16:33:33 +08:00
|
|
|
test('结算动作按实时详情跳过已被前置幂等场景部分收款的陈旧 fixture', async () => {
|
|
|
|
|
const context = fakeContext()
|
|
|
|
|
context.financeState.balances.set('101', 99.99)
|
|
|
|
|
context.financeState.paidAmounts.set('101', 0.01)
|
|
|
|
|
context.financeState.statuses.set('101', 'PARTIALLY_PAID')
|
|
|
|
|
await run(context)
|
|
|
|
|
const firstAction = context.calls.find((call) => call.path.endsWith('/actions'))
|
|
|
|
|
assert.match(firstAction.path, /\/settlements\/102\/actions$/)
|
|
|
|
|
assert.equal(context.gaps.length, 0)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-12 10:59:39 +08:00
|
|
|
test('冻结共享输入使用 4 条 statements、3 条 AR 和 payableSettlements 数组', async () => {
|
|
|
|
|
const context = fakeContext()
|
|
|
|
|
assert.equal(context.createdResources.financeFullFixtures.statements.length, 4)
|
|
|
|
|
assert.equal(context.createdResources.financeFullFixtures.receivableSettlements.length, 3)
|
|
|
|
|
assert.equal(context.createdResources.financeFullFixtures.payableSettlements.length, 1)
|
|
|
|
|
await run(context)
|
|
|
|
|
assert.equal(context.gaps.length, 0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('缺 fixture 或真实 voucher 时记录 blocker 且不写接口', async () => {
|
|
|
|
|
const missing = fakeContext(); delete missing.createdResources.financeFullFixtures
|
|
|
|
|
await run(missing); assert.equal(missing.gaps.length, 1); assert.equal(missing.calls.length, 0)
|
|
|
|
|
assert.equal(missing.steps.length, 1); assert.equal(missing.steps[0].passed, false)
|
|
|
|
|
assert.equal(missing.steps[0].options.deploymentBlocker, true)
|
|
|
|
|
const noVoucher = fakeContext({ voucherFileId: null })
|
|
|
|
|
await run(noVoucher); assert.equal(noVoucher.gaps.length, 1); assert.equal(noVoucher.calls.length, 0)
|
|
|
|
|
assert.equal(noVoucher.steps.length, 1); assert.equal(noVoucher.steps[0].passed, false)
|
|
|
|
|
assert.equal(noVoucher.steps[0].options.deploymentBlocker, true)
|
|
|
|
|
})
|
2026-08-12 12:18:59 +08:00
|
|
|
|
|
|
|
|
test('AR/AP 数组为空或不完整时原子阻断且不查询、不写入', async () => {
|
|
|
|
|
const empty = fakeContext({ statements: [], receivableSettlements: [], payableSettlements: [] })
|
|
|
|
|
await run(empty)
|
|
|
|
|
assert.equal(empty.calls.length, 0)
|
|
|
|
|
assert.equal(empty.gaps.length, 1)
|
|
|
|
|
assert.equal(empty.steps[0].passed, false)
|
|
|
|
|
assert.match(empty.steps[0].evidence, /actual AR=0, AP=0/)
|
2026-08-13 14:37:51 +08:00
|
|
|
assert.match(empty.steps[0].evidence, /上游 Finance fixture 前置条件未满足,非结算服务故障/)
|
|
|
|
|
assert.match(empty.gaps[0][4], /prerequisite/)
|
|
|
|
|
assert.equal(empty.calls.some((call) => call.method === 'POST'), false)
|
2026-08-12 12:18:59 +08:00
|
|
|
|
|
|
|
|
const partial = fakeContext({ receivableSettlements: [
|
|
|
|
|
{ settlementId: '101', statementId: '11', direction: 'RECEIVABLE', status: 'ISSUED' },
|
|
|
|
|
] })
|
|
|
|
|
await run(partial)
|
|
|
|
|
assert.equal(partial.calls.length, 0)
|
|
|
|
|
assert.equal(partial.gaps.length, 1)
|
|
|
|
|
assert.match(partial.steps[0].evidence, /actual AR=1, AP=1/)
|
2026-08-13 14:37:51 +08:00
|
|
|
assert.match(partial.steps[0].evidence, /receivable-settlement-prerequisite/)
|
|
|
|
|
assert.equal(partial.calls.some((call) => call.method === 'POST'), false)
|
2026-08-12 12:18:59 +08:00
|
|
|
})
|