2026-08-12 10:59:39 +08:00
|
|
|
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')
|
2026-08-15 12:12:34 +08:00
|
|
|
|
2026-08-15 17:52:46 +08:00
|
|
|
test('Finance AP consumes only published fin_fee_source rows and never substitutes MANUAL_COST IDs', () => {
|
2026-08-15 12:12:34 +08:00
|
|
|
assert.doesNotMatch(source, /sourceType \?\? ''\) !== 'MANUAL_COST'/)
|
2026-08-15 17:52:46 +08:00
|
|
|
assert.doesNotMatch(source, /\[raw\.containerBookingId, raw\.loadingPlanId\]\.map\(id\)\.includes\(id\(item\?\.containerId\)\)/)
|
|
|
|
|
assert.match(source, /const financeSource = poolBySource\.get\(sourceId\(raw\)\)/)
|
2026-08-15 12:12:34 +08:00
|
|
|
assert.match(source, /!isUnboundContainerCost\(source\.raw\) \|\| hasContainerAllocations/)
|
|
|
|
|
})
|
2026-08-12 13:24:17 +08:00
|
|
|
const financePermissions = [
|
2026-08-20 16:13:51 +08:00
|
|
|
'platform:finance:customer-account:list', 'platform:finance:customer-arap:list',
|
2026-09-01 10:17:03 +08:00
|
|
|
'platform:finance:fee-source:edit',
|
2026-08-20 16:13:51 +08:00
|
|
|
'platform:finance:fee-source:review', 'platform:finance:customer-statement:list',
|
2026-08-12 13:24:17 +08:00
|
|
|
'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',
|
2026-08-13 14:03:54 +08:00
|
|
|
'platform:finance:settlement-ar:list', 'platform:finance:settlement-ap:create',
|
|
|
|
|
'platform:finance:settlement-ap:list',
|
2026-08-12 13:24:17 +08:00
|
|
|
]
|
2026-08-12 10:59:39 +08:00
|
|
|
|
|
|
|
|
function ok(data) {
|
|
|
|
|
return { http: 200, body: { code: '200', data }, requestId: 'request-test', traceId: 'trace-test' }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 17:52:46 +08:00
|
|
|
function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false,
|
|
|
|
|
missingSubject = false, omitPayableSource = false) {
|
2026-08-20 17:00:05 +08:00
|
|
|
process.env.MERCHANT_TEST_USERNAME = 'fixture-client'
|
|
|
|
|
process.env.MERCHANT_TEST_PASSWORD = 'fixture-password'
|
2026-08-12 10:59:39 +08:00
|
|
|
const marker = 'FIN-TEST-MARKER'
|
|
|
|
|
const receivables = [
|
|
|
|
|
{ feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' },
|
2026-08-12 12:35:29 +08:00
|
|
|
{ feeSourceId: '102', orderId: '202', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' },
|
2026-08-12 10:59:39 +08:00
|
|
|
{ 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',
|
2026-08-19 09:42:07 +08:00
|
|
|
businessDate: '2026-08-11', supplierId: '501', supplierName: 'Fixture Supplier' },
|
2026-08-12 12:47:06 +08:00
|
|
|
{ feeSourceId: null, sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY',
|
2026-08-19 09:42:07 +08:00
|
|
|
businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', supplierId: '501',
|
2026-08-12 12:47:06 +08:00
|
|
|
containerBookingId: 'CB-FAILED' }]
|
2026-08-12 13:24:17 +08:00
|
|
|
feeSources.push({ feeSourceId: '106', sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY',
|
2026-08-19 09:42:07 +08:00
|
|
|
businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', supplierId: '501',
|
2026-08-12 13:24:17 +08:00
|
|
|
containerBookingId: 'CB-COST-RESPONSE' })
|
2026-08-12 10:59:39 +08:00
|
|
|
const fixture = { marker, customerId: '11',
|
2026-08-12 12:47:06 +08:00
|
|
|
orders: feeSources.filter((item) => item.orderId)
|
|
|
|
|
.map((item) => ({ orderId: item.orderId, shipmentId: `SHIP-${item.orderId}` })),
|
2026-08-15 17:45:51 +08:00
|
|
|
feeSources: feeSources.map((item) => ({ ...item, customerId: '11',
|
|
|
|
|
waybillId: item.orderId ? `SHIP-${item.orderId}` : null, marker })) }
|
2026-08-12 10:59:39 +08:00
|
|
|
const statements = new Map()
|
|
|
|
|
const settlements = new Map()
|
|
|
|
|
const idempotency = new Map()
|
|
|
|
|
let statementSequence = 900
|
|
|
|
|
let settlementSequence = 1200
|
|
|
|
|
const calls = []
|
|
|
|
|
const steps = []
|
|
|
|
|
const coverageGaps = []
|
2026-09-01 10:17:03 +08:00
|
|
|
const submittedSourceIds = new Set()
|
2026-08-20 16:13:51 +08:00
|
|
|
const reviewedSourceIds = new Set()
|
2026-08-12 10:59:39 +08:00
|
|
|
|
|
|
|
|
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]
|
2026-08-20 16:13:51 +08:00
|
|
|
statement.revisions.push({ action: body.action })
|
2026-08-12 10:59:39 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-20 17:00:05 +08:00
|
|
|
if (body.action === 'ISSUE') statement.customerConfirmationStatus = 'PENDING_CONFIRMATION'
|
|
|
|
|
if (body.action === 'VOID' || body.action === 'REOPEN') statement.customerConfirmationStatus = 'NOT_REQUIRED'
|
2026-08-12 10:59:39 +08:00
|
|
|
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)))
|
2026-08-12 13:24:17 +08:00
|
|
|
if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') {
|
|
|
|
|
return ok(permissions)
|
|
|
|
|
}
|
2026-08-20 17:00:05 +08:00
|
|
|
if (method === 'POST' && requestPath === '/api/auth/login') {
|
|
|
|
|
return ok({ accessToken: 'client-token' })
|
|
|
|
|
}
|
2026-08-15 17:52:46 +08:00
|
|
|
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=11') {
|
2026-09-11 11:51:26 +08:00
|
|
|
return ok({ records: [{ customerId: '11', customerCode: '00011', customerName: 'Fixture Account',
|
2026-08-17 15:41:42 +08:00
|
|
|
contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100,
|
|
|
|
|
pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE',
|
|
|
|
|
lastChangedAt: '2026-08-10T09:00:00' }] })
|
2026-08-13 14:03:54 +08:00
|
|
|
}
|
|
|
|
|
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/accounts/11/ledger?')) {
|
2026-08-17 15:41:42 +08:00
|
|
|
return ok({ records: [{ customerId: '11', statementId: '1', ledgerId: '1',
|
|
|
|
|
occurredAt: '2026-08-10T09:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL',
|
|
|
|
|
documentId: '1', documentNo: 'REC-1', receivableChange: 100, payableChange: 0,
|
|
|
|
|
outstandingReceivableAfter: 100, operatorName: 'operator-7001', remark: marker }] })
|
2026-08-13 14:03:54 +08:00
|
|
|
}
|
2026-08-12 15:53:47 +08:00
|
|
|
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/iam/tenants?')) {
|
|
|
|
|
return ok({ records: [{ id: '11', status: 'ACTIVE' }, { id: '12', status: 'ACTIVE' }] })
|
|
|
|
|
}
|
2026-08-12 13:24:17 +08:00
|
|
|
if (denyFinanceWrites && method === 'POST' && requestPath.endsWith('/statements')) {
|
|
|
|
|
return { http: 200, body: { code: 'A0403', message: 'Access denied' },
|
|
|
|
|
requestId: 'request-denied', traceId: 'trace-denied' }
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
if (method === 'GET' && requestPath.includes('/fee-pool')) {
|
2026-08-15 17:52:46 +08:00
|
|
|
return ok(feeSources.filter((item) => item.feeSourceId && (!omitPayableSource || item.feeSourceId !== '105')).map((item, index) => {
|
2026-08-12 14:21:25 +08:00
|
|
|
const { feeSourceId, ...rest } = item
|
|
|
|
|
return { ...rest, poolItemId: `${item.direction}:${1001 + index}`, sourceId: feeSourceId,
|
2026-08-19 09:42:07 +08:00
|
|
|
reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE' }
|
2026-08-12 14:21:25 +08:00
|
|
|
}))
|
2026-08-12 10:59:39 +08:00
|
|
|
}
|
2026-08-20 16:13:51 +08:00
|
|
|
const feeSourceDetailMatch = requestPath.match(/\/fee-sources\/(\d+)$/)
|
|
|
|
|
if (method === 'GET' && feeSourceDetailMatch) {
|
|
|
|
|
const localId = feeSourceDetailMatch[1]
|
2026-09-01 10:17:03 +08:00
|
|
|
return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED'
|
|
|
|
|
: submittedSourceIds.has(localId) ? 'PENDING' : 'DRAFT',
|
|
|
|
|
sourceStatus: 'AVAILABLE', statementGenerated: false,
|
|
|
|
|
operationVersion: reviewedSourceIds.has(localId) ? 2 : submittedSourceIds.has(localId) ? 1 : 0 })
|
|
|
|
|
}
|
|
|
|
|
const feeSourceSubmitMatch = requestPath.match(/\/fee-sources\/(\d+)\/submit$/)
|
|
|
|
|
if (method === 'POST' && feeSourceSubmitMatch) {
|
|
|
|
|
submittedSourceIds.add(feeSourceSubmitMatch[1])
|
|
|
|
|
const submitted = { feeSourceId: feeSourceSubmitMatch[1], reviewStatus: 'PENDING',
|
|
|
|
|
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 }
|
|
|
|
|
if (idem) idempotency.set(idem, submitted)
|
|
|
|
|
return ok(submitted)
|
2026-08-20 16:13:51 +08:00
|
|
|
}
|
|
|
|
|
const feeSourceReviewMatch = requestPath.match(/\/fee-sources\/(\d+)\/review$/)
|
|
|
|
|
if (method === 'POST' && feeSourceReviewMatch) {
|
|
|
|
|
reviewedSourceIds.add(feeSourceReviewMatch[1])
|
|
|
|
|
const reviewed = { feeSourceId: feeSourceReviewMatch[1], reviewStatus: 'APPROVED',
|
2026-09-01 10:17:03 +08:00
|
|
|
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 2 }
|
2026-08-20 16:13:51 +08:00
|
|
|
if (idem) idempotency.set(idem, reviewed)
|
|
|
|
|
return ok(reviewed)
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
if (method === 'POST' && requestPath.endsWith('/statements')) {
|
|
|
|
|
if (body.currency !== 'CNY') return { http: 200, body: { code: 'FIN_400' } }
|
2026-08-12 15:53:47 +08:00
|
|
|
if (String(body.customerId) !== '11') return { http: 200, body: { code: 'FIN_404' } }
|
2026-08-12 13:55:16 +08:00
|
|
|
if (missingSubject) return { http: 200, body: { code: 'FIN_409', message: '平台财务主体未配置' },
|
|
|
|
|
requestId: 'request-subject', traceId: 'trace-subject' }
|
2026-08-12 10:59:39 +08:00
|
|
|
const selected = body.generationMode === 'BY_CUTOFF'
|
|
|
|
|
? receivables.filter((item) => item.businessDate >= body.periodStart && item.businessDate <= body.periodEnd)
|
2026-08-12 14:21:25 +08:00
|
|
|
: feeSources.filter((item, index) => body.feeSourceIds.includes(String(1001 + index)))
|
2026-08-12 10:59:39 +08:00
|
|
|
if (selected.some((item) => item.direction !== 'RECEIVABLE')) return { http: 200, body: { code: 'FIN_409' } }
|
|
|
|
|
const statementId = String(++statementSequence)
|
2026-08-13 14:03:54 +08:00
|
|
|
const statement = { statementId, customerId: '11', status: 'DRAFT', version: 0, revisions: [],
|
|
|
|
|
createdBy: '7001', createdAt: '2026-08-10T09:00:00', updatedBy: '7001', updatedAt: '2026-08-10T09:00:00',
|
2026-08-12 10:59:39 +08:00
|
|
|
lines: selected.map((item, index) => ({ statementLineId: `${statementId}${index + 1}`,
|
2026-08-12 14:21:25 +08:00
|
|
|
feeSourceId: String(1001 + feeSources.indexOf(item)), statementAmount: 100 + index, adjustmentReason: null })) }
|
2026-08-12 10:59:39 +08:00
|
|
|
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',
|
2026-08-13 09:52:59 +08:00
|
|
|
customerId: '11', reconciliationStatementId: body.reconciliationStatementId, status: 'OPEN',
|
|
|
|
|
paymentStatus: 'UNPAID', totalAmount: 100, paidAmount: 0, outstandingAmount: 100, version: 0 }
|
2026-08-12 10:59:39 +08:00
|
|
|
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',
|
2026-08-13 09:52:59 +08:00
|
|
|
customerId: String(body.customerId), supplierId: String(body.supplierId), status: 'OPEN',
|
|
|
|
|
paymentStatus: 'UNPAID', totalAmount: 100, paidAmount: 0, outstandingAmount: 100, version: 0 }
|
|
|
|
|
settlements.set(settlement.settlementId, settlement)
|
2026-08-12 10:59:39 +08:00
|
|
|
if (idem) idempotency.set(idem, settlement)
|
|
|
|
|
return ok(structuredClone(settlement))
|
|
|
|
|
}
|
2026-08-13 09:52:59 +08:00
|
|
|
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/receivable-settlements?')) {
|
|
|
|
|
return ok([...settlements.values()].filter((item) => item.direction === 'RECEIVABLE'))
|
|
|
|
|
}
|
|
|
|
|
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/payable-settlements?')) {
|
|
|
|
|
return ok([...settlements.values()].filter((item) => item.direction === 'PAYABLE'))
|
|
|
|
|
}
|
2026-08-13 14:03:54 +08:00
|
|
|
if (method === 'GET' && requestPath === '/api/platform/finance/v1/statements?customerId=11') {
|
|
|
|
|
return ok([...statements.values()].map((item) => ({
|
|
|
|
|
statementId: item.statementId, customerId: item.customerId, status: item.status,
|
|
|
|
|
createdBy: item.createdBy, createdAt: item.createdAt, updatedBy: item.updatedBy, updatedAt: item.updatedAt,
|
|
|
|
|
})))
|
2026-08-20 17:00:05 +08:00
|
|
|
}
|
|
|
|
|
if (method === 'GET' && requestPath === '/api/client/finance/v1/statements') {
|
|
|
|
|
return ok([...statements.values()].map((item) => ({
|
|
|
|
|
statementId: item.statementId, customerId: item.customerId, status: item.status,
|
|
|
|
|
version: item.version, customerConfirmationStatus: item.customerConfirmationStatus,
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
const clientConfirmMatch = requestPath.match(/\/api\/client\/finance\/v1\/statements\/(\d+)\/confirm$/)
|
|
|
|
|
if (method === 'POST' && clientConfirmMatch) {
|
|
|
|
|
const statement = statements.get(clientConfirmMatch[1])
|
|
|
|
|
statement.customerConfirmationStatus = 'CONFIRMED'
|
|
|
|
|
statement.version += 1
|
|
|
|
|
const result = { statementId: statement.statementId, confirmationStatus: 'CONFIRMED',
|
|
|
|
|
confirmedBy: '7002', confirmedByName: 'fixture-client', confirmedAt: '2026-08-10T10:00:00',
|
|
|
|
|
version: statement.version }
|
|
|
|
|
if (idem) idempotency.set(idem, result)
|
|
|
|
|
return ok(structuredClone(result))
|
2026-08-13 14:03:54 +08:00
|
|
|
}
|
|
|
|
|
const detailMatch = requestPath.match(/\/reconciliation-statements\/(\d+)$/)
|
|
|
|
|
if (method === 'GET' && detailMatch) return ok(responseForStatement(statements.get(detailMatch[1])))
|
2026-08-12 10:59:39 +08:00
|
|
|
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'/)
|
2026-08-12 13:24:17 +08:00
|
|
|
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/)
|
2026-08-12 13:55:16 +08:00
|
|
|
assert.match(source, /missing-active-finance-subject/)
|
2026-08-12 10:59:39 +08:00
|
|
|
assert.match(source, /businessDates\.size < 2/)
|
|
|
|
|
assert.match(source, /receivables\.length < 4/)
|
2026-08-12 12:35:29 +08:00
|
|
|
assert.match(source, /const groups = \[cutoffSources/)
|
2026-08-12 10:59:39 +08:00
|
|
|
assert.match(source, /\/payable-settlements/)
|
2026-08-13 09:52:59 +08:00
|
|
|
assert.match(source, /findSettlementInOfficialList/)
|
2026-08-13 14:03:54 +08:00
|
|
|
assert.match(source, /verifyIssuedStatementReadback/)
|
|
|
|
|
assert.match(source, /\/statements\?customerId=/)
|
|
|
|
|
assert.match(source, /\/reconciliation-statements\/\$\{encodeURIComponent\(targetId\)\}/)
|
|
|
|
|
assert.match(source, /statementRows\.every/)
|
|
|
|
|
assert.match(source, /Array\.isArray\(detail\?\.lines\)/)
|
|
|
|
|
assert.match(source, /Array\.isArray\(detail\?\.revisions\)/)
|
|
|
|
|
assert.match(source, /\['createdBy', 'createdAt', 'updatedBy', 'updatedAt'\]/)
|
|
|
|
|
assert.match(source, /STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'/)
|
|
|
|
|
assert.match(source, /LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'/)
|
|
|
|
|
assert.match(source, /legacy-customer-statement-view-permission/)
|
2026-08-13 09:52:59 +08:00
|
|
|
assert.match(source, /`正式\$\{label\}列表包含同一 settlementId`/)
|
|
|
|
|
assert.match(source, /findSettlementInOfficialList\(context, 'RECEIVABLE'/)
|
|
|
|
|
assert.match(source, /findSettlementInOfficialList\(context, 'PAYABLE'/)
|
2026-08-12 10:59:39 +08:00
|
|
|
assert.match(source, /fixture\.root\.statements = statementRows/)
|
|
|
|
|
assert.match(source, /fixture\.root\.receivableSettlements = arRows/)
|
2026-08-12 12:35:29 +08:00
|
|
|
assert.doesNotMatch(source, /generationMode:\s*['"]BY_CUTOFF['"]/)
|
2026-08-12 14:21:25 +08:00
|
|
|
assert.match(source, /\/fee-pool/)
|
2026-08-12 15:53:47 +08:00
|
|
|
assert.match(source, /\/api\/platform\/v1\/iam\/tenants\?page=1&size=100&status=ACTIVE/)
|
2026-08-12 10:59:39 +08:00
|
|
|
assert.match(source, /http: response\?\.http \?\? null/)
|
|
|
|
|
assert.match(source, /requestId: response\?\.requestId \?\? null/)
|
|
|
|
|
assert.match(source, /traceId: response\?\.traceId \?\? null/)
|
2026-08-13 14:03:54 +08:00
|
|
|
assert.match(source, /platform:finance:customer-account:list/)
|
|
|
|
|
assert.match(source, /platform:finance:customer-statement:list/)
|
|
|
|
|
assert.match(source, /platform:finance:settlement-ar:list/)
|
|
|
|
|
assert.match(source, /platform:finance:settlement-ap:list/)
|
|
|
|
|
assert.doesNotMatch(source, /platform:finance:settlement-(ar|ap):view/)
|
2026-08-15 17:52:46 +08:00
|
|
|
assert.match(source, /accounts\?page=1&pageSize=20&customerId=/)
|
2026-08-13 14:03:54 +08:00
|
|
|
assert.match(source, /\/accounts\/\$\{encodeURIComponent\(customerId\)\}\/ledger\?page=1&pageSize=200/)
|
|
|
|
|
assert.match(source, /balance.*recharge.*prepayment/)
|
|
|
|
|
assert.match(source, /accountReadback/)
|
2026-08-17 15:41:42 +08:00
|
|
|
assert.match(source, /accountInformationComplete/)
|
|
|
|
|
assert.match(source, /ledgerInformationComplete/)
|
|
|
|
|
assert.match(source, /客户账户基本信息、四项金额和最近变动时间完整/)
|
|
|
|
|
assert.match(source, /客户账户流水时间、业务类型、关联单据、金额和操作人完整/)
|
2026-08-12 10:59:39 +08:00
|
|
|
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)
|
2026-08-12 12:35:29 +08:00
|
|
|
assert.equal(fixture.statements[0].kind, 'SELECTED_DATE')
|
|
|
|
|
assert.equal(fixture.statements[0].status, 'ISSUED')
|
2026-08-12 10:59:39 +08:00
|
|
|
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')
|
2026-08-19 09:42:07 +08:00
|
|
|
assert.equal(fixture.payableSettlements[0].supplierId, '501')
|
2026-08-19 09:55:29 +08:00
|
|
|
assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Supplier')
|
2026-08-12 13:24:17 +08:00
|
|
|
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)
|
2026-08-12 12:47:06 +08:00
|
|
|
assert.equal(context.calls.some((call) => call.body?.feeSourceIds?.includes(null)), false)
|
2026-08-12 13:24:17 +08:00
|
|
|
assert.equal(context.calls.some((call) => call.body?.feeSourceIds?.includes('106')), false)
|
2026-08-12 14:21:25 +08:00
|
|
|
assert.equal(context.calls.some((call) => call.path.includes('/fee-pool')), true)
|
2026-08-13 09:52:59 +08:00
|
|
|
assert.equal(context.calls.filter((call) => call.method === 'GET'
|
|
|
|
|
&& call.path.includes('/receivable-settlements?customerId=11')).length, 3)
|
|
|
|
|
assert.equal(context.calls.filter((call) => call.method === 'GET'
|
|
|
|
|
&& call.path.includes('/payable-settlements?customerId=11')).length, 1)
|
2026-08-13 14:03:54 +08:00
|
|
|
assert.equal(context.calls.filter((call) => call.method === 'GET'
|
|
|
|
|
&& call.path === '/api/platform/finance/v1/statements?customerId=11').length, 3)
|
|
|
|
|
assert.equal(context.calls.filter((call) => call.method === 'GET'
|
|
|
|
|
&& /\/reconciliation-statements\/\d+$/.test(call.path)).length, 3)
|
2026-08-12 14:21:25 +08:00
|
|
|
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))
|
2026-08-12 10:59:39 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-13 14:03:54 +08:00
|
|
|
test('旧客户对账单 view 权限只能形成兼容 blocker,不能作为列表/详情验收通过依据', async () => {
|
|
|
|
|
const context = fakeContext(true, [...financePermissions, 'platform:finance:bill:view'])
|
|
|
|
|
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:bill:view')), true)
|
|
|
|
|
assert.equal(context.coverageGaps.some((gap) => gap.category === 'legacy-customer-statement-view-permission'), true)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-12 10:59:39 +08:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-19 09:42:07 +08:00
|
|
|
test('PAYABLE 只接受 supplierId 及 Warehouse 名称快照', async () => {
|
2026-08-12 10:59:39 +08:00
|
|
|
const context = fakeContext()
|
|
|
|
|
await run(context)
|
|
|
|
|
const payableRequest = context.calls.find((call) => call.path.endsWith('/payable-settlements'))
|
|
|
|
|
assert.equal(payableRequest.body.supplierId, '501')
|
2026-08-19 09:42:07 +08:00
|
|
|
assert.doesNotMatch(source, /partnerId|partnerName|partnerType/)
|
2026-08-12 10:59:39 +08:00
|
|
|
})
|
2026-08-12 13:24:17 +08:00
|
|
|
|
2026-08-15 17:45:51 +08:00
|
|
|
test('来源缺失逐条 customerId 时不得由 fixture 根字段静默补齐', async () => {
|
|
|
|
|
const context = fakeContext()
|
|
|
|
|
delete context.createdResources.financeFullFixtures.feeSources[0].customerId
|
|
|
|
|
|
|
|
|
|
await run(context)
|
|
|
|
|
|
|
|
|
|
assert.equal(context.steps.some((step) => step.name === 'Fixture 来源契约完整'
|
|
|
|
|
&& !step.passed && step.deploymentBlocker), true)
|
|
|
|
|
assert.equal(context.coverageGaps.some((gap) => gap.category === 'invalid-finance-fixture-contract'), true)
|
|
|
|
|
assert.equal(context.calls.some((call) => call.method !== 'GET'), false)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-15 17:52:46 +08:00
|
|
|
test('marker-owned PAYABLE 来源未入池时 fail-closed 且不使用 MANUAL_COST 替代', async () => {
|
|
|
|
|
const context = fakeContext(true, financePermissions, false, false, true)
|
|
|
|
|
|
|
|
|
|
await run(context)
|
|
|
|
|
|
|
|
|
|
assert.equal(context.steps.some((step) => step.name === '本轮费用来源进入 Finance 池'
|
|
|
|
|
&& !step.passed && step.deploymentBlocker), true)
|
|
|
|
|
assert.equal(context.coverageGaps.some((gap) => gap.category === 'unpublished-marker-owned-finance-source'), true)
|
|
|
|
|
assert.equal(context.calls.some((call) => call.method !== 'GET'), false)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-12 13:24:17 +08:00
|
|
|
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)
|
|
|
|
|
})
|
2026-08-12 13:55:16 +08:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|