import test from 'node:test' import assert from 'node:assert/strict' import { run, values } from './finance-settlement-full.mjs' 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 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, 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]), status: balances.get(match[1]) === 0 ? 'PAID' : 'PARTIALLY_PAID', version: calls.length }) if (path.endsWith('/receipts')) { for (const allocation of body.allocations) balances.set(billToSettlement.get(String(allocation.billId)), balances.get(billToSettlement.get(String(allocation.billId))) - allocation.amount); 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); return response({ settlementId: match[1], outstandingAmount: current - body.amount, status: current - body.amount === 0 ? 'PAID' : 'PARTIALLY_PAID', version: calls.length }) } 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('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) }) 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/) 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/) })