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 { receiptBody, run, values } from './finance-settlement-full.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-settlement-full.mjs'), 'utf8') 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]]) 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() 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' }], sourceMappings: [{ sourceFeeId: '9001', feeSourceId: '501', sourceType: 'MANUAL', direction: 'RECEIVABLE' }, { sourceFeeId: '9002', feeSourceId: '601', sourceType: 'MANUAL', direction: 'PAYABLE' }], 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' }, ], payableSettlements: [{ settlementId: '201', direction: 'PAYABLE', supplierId: '301', status: 'ISSUED', feeSourceIds: ['601'] }], 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, financeState: { balances, paidAmounts, statuses, versions }, 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+)/) 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 }) } if (match && path.endsWith('/transactions')) { const current = balances.get(match[1]); if (body.amount > current || current === 0) return response(null, 409, 'FIN_409') 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]) }) } throw new Error(`unexpected ${method} ${path}`) } } } test('values helper supports nested arrays while run consumes frozen top-level arrays', () => { 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 }]) }) 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\)/) }) 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) 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) } }) 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) }) 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) }) 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/) 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) 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/) assert.match(partial.steps[0].evidence, /receivable-settlement-prerequisite/) assert.equal(partial.calls.some((call) => call.method === 'POST'), false) })