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') const financePermissions = [ 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', 'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create', 'platform:finance:settlement-ap:create', ] function ok(data) { return { http: 200, body: { code: '200', data }, requestId: 'request-test', traceId: 'trace-test' } } function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false, missingSubject = false) { 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-10' }, { 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' }, { feeSourceId: null, sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY', businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', partnerId: '501', containerBookingId: 'CB-FAILED' }] feeSources.push({ feeSourceId: '106', sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY', businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', partnerId: '501', containerBookingId: 'CB-COST-RESPONSE' }) const fixture = { marker, customerId: '11', orders: feeSources.filter((item) => item.orderId) .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 === '/api/platform/v1/iam/authorization/permissions') { return ok(permissions) } if (denyFinanceWrites && method === 'POST' && requestPath.endsWith('/statements')) { return { http: 200, body: { code: 'A0403', message: 'Access denied' }, requestId: 'request-denied', traceId: 'trace-denied' } } if (method === 'GET' && requestPath.includes('/fee-pool')) { return ok(feeSources.filter((item) => item.feeSourceId).map((item, index) => { const { feeSourceId, ...rest } = item return { ...rest, poolItemId: `${item.direction}:${1001 + index}`, sourceId: feeSourceId, reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE' } })) } if (method === 'POST' && requestPath.endsWith('/statements')) { if (body.currency !== 'CNY') return { http: 200, body: { code: 'FIN_400' } } if (missingSubject) return { http: 200, body: { code: 'FIN_409', message: '平台财务主体未配置' }, requestId: 'request-subject', traceId: 'trace-subject' } const selected = body.generationMode === 'BY_CUTOFF' ? receivables.filter((item) => item.businessDate >= body.periodStart && item.businessDate <= body.periodEnd) : feeSources.filter((item, index) => body.feeSourceIds.includes(String(1001 + index))) 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: String(1001 + feeSources.indexOf(item)), 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, /platform:finance:reconciliation:create/) assert.match(source, /\/api\/platform\/v1\/iam\/authorization\/permissions/) assert.match(source, /rejectedWith\(currencyResponse, 'FIN_400'\)/) assert.match(source, /stale-finance-token-authorities/) assert.match(source, /missing-active-finance-subject/) assert.match(source, /businessDates\.size < 2/) assert.match(source, /receivables\.length < 4/) assert.match(source, /const groups = \[cutoffSources/) assert.match(source, /\/payable-settlements/) assert.match(source, /fixture\.root\.statements = statementRows/) assert.match(source, /fixture\.root\.receivableSettlements = arRows/) assert.doesNotMatch(source, /generationMode:\s*['"]BY_CUTOFF['"]/) assert.match(source, /\/fee-pool/) 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, 'SELECTED_DATE') assert.equal(fixture.statements[0].status, 'ISSUED') 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.coverageGaps.some((gap) => gap.category === 'incomplete-container-cost-attempt' && gap.reason.includes('containerBookingId=CB-FAILED') && gap.reason.includes('containerBookingId=CB-COST-RESPONSE')), true) assert.equal(context.calls.some((call) => call.body?.feeSourceIds?.includes(null)), false) assert.equal(context.calls.some((call) => call.body?.feeSourceIds?.includes('106')), false) assert.equal(context.calls.some((call) => call.path.includes('/fee-pool')), true) assert.equal(context.calls.some((call) => call.body?.generationMode === 'BY_CUTOFF'), false) const failedSteps = context.steps.filter((step) => !step.passed) assert.equal(failedSteps.length, 0, JSON.stringify(failedSteps, null, 2)) }) 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.*阻断/) }) test('缺少 Finance 创建权限时 fail-closed 且不发送财务写请求', async () => { const context = fakeContext(true, financePermissions.filter((item) => item !== 'platform:finance:reconciliation:create')) await run(context) assert.equal(context.calls.filter((call) => call.method !== 'GET').length, 0) assert.equal(context.steps.some((step) => !step.passed && step.deploymentBlocker && step.evidence.includes('platform:finance:reconciliation:create')), true) assert.equal(context.coverageGaps.some((gap) => gap.category === 'missing-finance-write-permissions'), true) }) test('权限快照已授权但 Finance 返回 A0403 时立即停止并保留诊断', async () => { const context = fakeContext(true, financePermissions, true) await run(context) const writes = context.calls.filter((call) => call.method === 'POST' && call.path.endsWith('/statements')) assert.equal(writes.length, 1) assert.equal(writes[0].body.customerId, '11') assert.equal(writes[0].body.currency, 'USD') assert.equal(context.coverageGaps.some((gap) => gap.category === 'stale-finance-token-authorities' && gap.reason.includes('requestId=request-denied') && gap.reason.includes('traceId=trace-denied')), true) assert.equal(context.calls.some((call) => call.body?.generationMode === 'SELECTED' && call.body?.currency === 'CNY'), false) }) test('平台财务主体缺失时报明确 blocker 且不继续状态流转', async () => { const context = fakeContext(true, financePermissions, false, true) await run(context) assert.equal(context.coverageGaps.some((gap) => gap.category === 'missing-active-finance-subject' && gap.reason.includes('requestId=request-subject') && gap.reason.includes('traceId=trace-subject')), true) assert.equal(context.calls.filter((call) => call.method === 'POST' && call.path.endsWith('/statements')).length, 2) assert.equal(context.calls.some((call) => call.path.includes('/actions') || call.path.endsWith('/receivable-settlements') || call.path.endsWith('/payable-settlements')), false) assert.equal(context.steps.some((step) => step.name === '第 1 张对账单出账'), false) })