fix finance acceptance source ownership checks

This commit is contained in:
2026-08-12 15:53:47 +08:00
parent 645d20665c
commit 6a26b17e0a
4 changed files with 40 additions and 17 deletions
@@ -75,6 +75,9 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') {
return ok(permissions)
}
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/iam/tenants?')) {
return ok({ records: [{ id: '11', status: 'ACTIVE' }, { id: '12', status: 'ACTIVE' }] })
}
if (denyFinanceWrites && method === 'POST' && requestPath.endsWith('/statements')) {
return { http: 200, body: { code: 'A0403', message: 'Access denied' },
requestId: 'request-denied', traceId: 'trace-denied' }
@@ -88,6 +91,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
}
if (method === 'POST' && requestPath.endsWith('/statements')) {
if (body.currency !== 'CNY') return { http: 200, body: { code: 'FIN_400' } }
if (String(body.customerId) !== '11') return { http: 200, body: { code: 'FIN_404' } }
if (missingSubject) return { http: 200, body: { code: 'FIN_409', message: '平台财务主体未配置' },
requestId: 'request-subject', traceId: 'trace-subject' }
const selected = body.generationMode === 'BY_CUTOFF'
@@ -160,6 +164,7 @@ test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输
assert.match(source, /fixture\.root\.receivableSettlements = arRows/)
assert.doesNotMatch(source, /generationMode:\s*['"]BY_CUTOFF['"]/)
assert.match(source, /\/fee-pool/)
assert.match(source, /\/api\/platform\/v1\/iam\/tenants\?page=1&size=100&status=ACTIVE/)
assert.match(source, /http: response\?\.http \?\? null/)
assert.match(source, /requestId: response\?\.requestId \?\? null/)
assert.match(source, /traceId: response\?\.traceId \?\? null/)
@@ -104,11 +104,16 @@ async function normalizeFixture(context) {
supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null,
}
})
const uncreatedSources = attemptedSources.filter((source) => !source.feeSourceId || isUnboundContainerCost(source.raw))
const hasContainerAllocations = list(root.allocations).length > 0
const uncreatedSources = attemptedSources.filter((source) => !source.feeSourceId
|| (isUnboundContainerCost(source.raw) && !hasContainerAllocations))
return {
root, customerId,
sources: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw)),
uncreatedSources,
sourceMappings: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw))
.map((source) => ({ sourceFeeId: source.sourceFeeId, feeSourceId: source.feeSourceId,
sourceType: source.raw?.sourceType ?? null, direction: source.direction })),
}
}
@@ -263,19 +268,6 @@ export async function run(context) {
return
}
const foreign = fixture.sources.find((source) => source.customerId !== fixture.customerId)
if (foreign) {
const response = await write(context, 'POST', `${BASE}/statements`, {
customerId: fixture.customerId, currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [foreign.feeSourceId], periodStart: foreign.businessDate,
periodEnd: foreign.businessDate, remark: `${context.marker}-foreign`,
}, 'cross-customer-rejected')
context.assert(MODULE, 'SELECTED 拒绝混入其他客户来源', response, rejectedWith(response, 'FIN_409'),
`code=${response.body?.code ?? 'missing'}`, OPTIONS)
} else {
context.addCoverageGap(MODULE, SCENARIO, 'cross-customer-isolation',
'Fixture 未提供其他客户 marker 来源,无法执行跨客户负向请求', 'missing-cross-customer-fixture')
}
const allPeriod = dates(receivables)
const currencyResponse = await write(context, 'POST', `${BASE}/statements`, {
customerId: fixture.customerId, currency: 'USD', generationMode: 'SELECTED',
@@ -331,6 +323,25 @@ export async function run(context) {
finish(context, start)
return
}
if (index === 0) {
const customerResponse = await context.request('GET', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE')
const otherCustomer = rows(customerResponse?.body?.data)
.find((item) => id(item?.id ?? item?.tenantId) !== fixture.customerId)
if (otherCustomer) {
const otherCustomerId = id(otherCustomer.id ?? otherCustomer.tenantId)
const isolationResponse = await write(context, 'POST', `${BASE}/statements`, {
customerId: otherCustomerId, currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [groups[index][0].feeSourceId], periodStart: groups[index][0].businessDate,
periodEnd: groups[index][0].businessDate, remark: `${context.marker}-foreign`,
}, 'cross-customer-rejected')
context.assert(MODULE, 'SELECTED 拒绝归入其他客户的本轮来源', isolationResponse,
rejectedWith(isolationResponse, 'FIN_404'),
`sourceCustomerId=${fixture.customerId}, requestedCustomerId=${otherCustomerId}, code=${isolationResponse.body?.code ?? 'missing'}`, OPTIONS)
} else {
context.addCoverageGap(MODULE, SCENARIO, 'cross-customer-isolation',
'未找到另一个 ACTIVE 客户,无法用本轮来源执行跨客户归属负向请求', 'missing-cross-customer-fixture')
}
}
context.assert(MODULE, `第 ${index + 1} 张仅使用分组来源`, created.response,
created.statement && sameIds(lineSourceIds(created.statement), groups[index].map((source) => source.feeSourceId)),
`expected=${groups[index].length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS)
@@ -414,5 +425,6 @@ export async function run(context) {
paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version,
feeSourceIds: payables.map((source) => source.feeSourceId),
}] : []
fixture.root.sourceMappings = fixture.sourceMappings
finish(context, start)
}
@@ -10,6 +10,8 @@ function fakeContext(overrides = {}) {
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'] },
@@ -21,7 +23,7 @@ function fakeContext(overrides = {}) {
{ 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' }],
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 = []
@@ -77,8 +77,12 @@ export async function run(context) {
return
}
const manualIds = values(fixtures.feeSources).filter((fee) => fee.sourceType === 'MANUAL').map((fee) => id(fee.feeSourceId))
const usedIds = new Set(statements.flatMap((statement) => values(statement.feeSourceIds).map(id)))
const manualIds = values(fixtures.sourceMappings).filter((source) => source.sourceType === 'MANUAL')
.map((source) => id(source.feeSourceId)).filter(Boolean)
const usedIds = new Set([
...statements.flatMap((statement) => values(statement.feeSourceIds).map(id)),
...payables.flatMap((settlement) => values(settlement.feeSourceIds).map(id)),
])
context.assert(MODULE, '手动上传费用出账后来源完结', null,
manualIds.length > 0 && manualIds.every((feeId) => usedIds.has(feeId)),
`manual=${manualIds.length}, settled=${manualIds.filter((feeId) => usedIds.has(feeId)).length}`, OPTIONS)