diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs index 0606d38dc..88a4d2267 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -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/) diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index 5e324067e..4c3d3e0d2 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -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) } diff --git a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs index a24d4774a..9e4c975a3 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs @@ -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 = [] diff --git a/scripts/admin-test-env-modules/finance-settlement-full.mjs b/scripts/admin-test-env-modules/finance-settlement-full.mjs index d13116394..dc887cae9 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full.mjs @@ -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)