完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
@@ -0,0 +1,331 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { randomBytes, randomUUID } = require('node:crypto');
|
||||
const { createRequire } = require('node:module');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const req = createRequire(path.join(root, 'server/package.json'));
|
||||
const { PrismaClient } = req('@prisma/client');
|
||||
const { PrismaPg } = req('@prisma/adapter-pg');
|
||||
const { hashPassword } = req('./src/utils/crypto');
|
||||
|
||||
module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) });
|
||||
const app = req('fastify')({ logger: false });
|
||||
const config = {
|
||||
fieldEncryptionKey: randomBytes(32).toString('base64'), jwtSecret: randomBytes(48).toString('hex'),
|
||||
accessTokenTtl: '30m', refreshTtlSeconds: 3600, refreshCookieName: 'synthetic_refresh', cookieSecure: false,
|
||||
rateLimitEnabled: false, emailEnabled: false, appOrigin: 'http://127.0.0.1',
|
||||
storageDir: path.join(scratch, 'materials'), storageDriver: 'local', appTimezone: 'Asia/Shanghai',
|
||||
paymentBatchEnabled: true,
|
||||
};
|
||||
app.decorate('config', config); app.decorate('prisma', prisma);
|
||||
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({
|
||||
code: error.code ?? 'SYS_001', details: error.details ?? {},
|
||||
}));
|
||||
await app.register(req('@fastify/cookie'));
|
||||
await app.register(req('@fastify/multipart'));
|
||||
await app.register(req('./src/plugins/auth'));
|
||||
await app.register(req('./src/routes/auth'), { prefix: '/api/auth' });
|
||||
await app.register(req('./src/routes/companyLoans'), { prefix: '/api' });
|
||||
await app.register(req('./src/routes/paymentBatches'), { prefix: '/api' });
|
||||
for (const route of ['me', 'systemWorkspace', 'notifications', 'masterData', 'paymentAccounts']) {
|
||||
await app.register(req(`./src/routes/${route}`), { prefix: '/api' });
|
||||
}
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
|
||||
await app.register(req('@fastify/static'), { root: path.join(root, 'client/dist') });
|
||||
app.setNotFoundHandler((request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ code: 'SYS_404' }) : reply.sendFile('index.html'));
|
||||
}
|
||||
await app.ready();
|
||||
const checks = [];
|
||||
try {
|
||||
const company = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成公司' } });
|
||||
const bank = await prisma.bankInstitution.upsert({ where: { code: 'CCB' }, update: {}, create: { code: 'CCB', name: '合成建行' } });
|
||||
const department = await prisma.department.create({ data: { name: '合成财务部' } });
|
||||
await prisma.featureFlag.create({ data: { flag_key: 'payment_batch_v1', override_mode: 'force_on', rollout_type: 'global' } });
|
||||
const password = `SyntH!${randomBytes(16).toString('hex')}`;
|
||||
const passwordHash = await hashPassword(password);
|
||||
async function actor(roleCodes) {
|
||||
const id = randomUUID();
|
||||
const employee = await prisma.employee.create({ data: {
|
||||
id, email: `${id}@example.invalid`, name: '合成用户', department_id: department.id,
|
||||
legal_entity_id: company.id, title: '合成岗位', hire_date: new Date('2025-01-01'),
|
||||
bank_name: '合成', bank_account_encrypted: req('./src/services/crypto').encryptStringOrThrow(config, '7'.repeat(16)), password_hash: passwordHash,
|
||||
roles: { create: roleCodes.map(role_code => ({ role_code })) },
|
||||
} });
|
||||
const login = await app.inject({ method: 'POST', url: '/api/auth/login',
|
||||
headers: { 'x-device-fingerprint': randomUUID() }, payload: { identifier: employee.email, password } });
|
||||
assert.equal(login.statusCode, 200, 'real synthetic password login');
|
||||
return { id, email: employee.email, headers: { authorization: `Bearer ${login.json().data.accessToken}` } };
|
||||
}
|
||||
const finance = await actor(['finance']);
|
||||
const manager = await actor(['manager']);
|
||||
const system = await actor(['system_admin']);
|
||||
const admin = await actor(['admin']);
|
||||
const ordinary = await actor(['employee']);
|
||||
async function call(who, method, url, payload, status = 200, reason) {
|
||||
const response = await app.inject({ method, url: `/api${url}`, headers: who?.headers ?? {}, payload });
|
||||
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().details?.reasonCode ?? ''}`);
|
||||
if (reason) assert.equal(response.json().details?.reasonCode, reason);
|
||||
return response.json().data;
|
||||
}
|
||||
for (const who of [manager, system, admin, ordinary]) {
|
||||
await call(who, 'GET', '/company-loans', undefined, 403);
|
||||
await call(who, 'POST', '/loan-lenders', {}, 403);
|
||||
}
|
||||
await call(null, 'GET', '/company-loans', undefined, 401);
|
||||
checks.push('real_login_and_explicit_finance_permissions');
|
||||
const bankAccount = '9'.repeat(16); // synthetic account, never log its value
|
||||
const lenderInput = { requestKey: randomUUID(), legalEntityId: company.id, name: '合成出借人',
|
||||
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount, accountName: '合成出借人' };
|
||||
const lender = await call(finance, 'POST', '/loan-lenders', lenderInput);
|
||||
assert.equal((await call(finance, 'POST', '/loan-lenders', lenderInput)).id, lender.id);
|
||||
await call(finance, 'POST', '/loan-lenders', { ...lenderInput, name: 'different' }, 409, 'loan.duplicate_registration');
|
||||
assert.equal(lender.identityStatus, 'unverified');
|
||||
assert.ok(!JSON.stringify(lender).includes(bankAccount));
|
||||
assert.ok(!JSON.stringify(lender).includes('v1:'));
|
||||
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
|
||||
expectedVersion: 1, employeeId: null, externalPersonConfirmed: false, reason: '合成核实',
|
||||
}, 400, 'loan.invalid_input');
|
||||
const verified = await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
|
||||
expectedVersion: 1, employeeId: null, externalPersonConfirmed: true, reason: '合成非员工核实',
|
||||
});
|
||||
assert.equal(verified.identityStatus, 'verified');
|
||||
checks.push('lender_encryption_identity_confirmation_and_idempotency');
|
||||
const input = { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成原借入凭据一',
|
||||
originalPrincipal: '1000.10', openingOutstandingPrincipal: '800.05',
|
||||
receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true };
|
||||
const loan = await call(finance, 'POST', '/company-loans', input);
|
||||
assert.equal(loan.available, '0.00'); assert.equal(loan.outstanding, '800.05');
|
||||
assert.equal((await call(finance, 'POST', '/company-loans', input)).id, loan.id);
|
||||
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID() }, 409, 'loan.duplicate_registration');
|
||||
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), receivedDate: '2025-02-30' }, 400);
|
||||
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required');
|
||||
const material = Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n');
|
||||
async function upload(kind, data = material, filename = 'synthetic.pdf', type = 'application/pdf', status = 200, loanId = loan.id) {
|
||||
const boundary = `synthetic-${randomUUID()}`;
|
||||
const payload = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]);
|
||||
const response = await app.inject({ method: 'POST', url: `/api/company-loans/${loanId}/evidences/${kind}`,
|
||||
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload });
|
||||
assert.equal(response.statusCode, status, `upload ${kind}`);
|
||||
return response.json().data;
|
||||
}
|
||||
await upload('agreement', Buffer.from('<script>synthetic</script>'), 'bad.pdf', 'application/pdf', 400);
|
||||
const evidence = await upload('agreement'); await upload('incoming'); await upload('accounting_confirmation');
|
||||
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required');
|
||||
await upload('adjustment');
|
||||
const confirmed = await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' });
|
||||
assert.equal(confirmed.available, '800.05'); assert.equal(confirmed.version, 2);
|
||||
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 2, accountingConfirmationRef: '改写' }, 409, 'loan.confirmed_immutable');
|
||||
checks.push('historical_balance_evidence_and_outside_repayment_required');
|
||||
await call(ordinary, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, { reason: '合成读取' }, 403);
|
||||
await call(finance, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, {}, 400);
|
||||
const downloaded = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${evidence.id}/download`,
|
||||
headers: finance.headers, payload: { reason: '合成核对读取' } });
|
||||
assert.equal(downloaded.statusCode, 200);
|
||||
assert.ok(downloaded.headers['content-disposition'].startsWith('attachment;'));
|
||||
assert.equal(downloaded.headers['x-content-type-options'], 'nosniff');
|
||||
assert.ok(downloaded.headers['cache-control'].includes('no-store'));
|
||||
checks.push('scoped_evidence_download_and_headers');
|
||||
// Real database audit failure must roll back the source write and not merely log it.
|
||||
await pool.query(`CREATE FUNCTION synthetic_fail_loan_audit() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN IF NEW.action='COMPANY_LOAN_REGISTERED' THEN RAISE EXCEPTION 'synthetic audit unavailable'; END IF; RETURN NEW; END $$`);
|
||||
await pool.query('CREATE TRIGGER synthetic_loan_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_loan_audit()');
|
||||
const count = await prisma.companyLoan.count();
|
||||
try {
|
||||
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成失败' }, 500);
|
||||
assert.equal(await prisma.companyLoan.count(), count);
|
||||
} finally {
|
||||
await pool.query('DROP TRIGGER synthetic_loan_audit ON audit_logs');
|
||||
await pool.query('DROP FUNCTION synthetic_fail_loan_audit()');
|
||||
}
|
||||
checks.push('real_audit_failure_atomic_rollback');
|
||||
const { encryptStringOrThrow } = req('./src/services/crypto');
|
||||
const payerAccount = '8'.repeat(16);
|
||||
await prisma.paymentAccount.create({ data: { legal_entity_id: company.id, bank_institution_id: bank.id,
|
||||
account_name: '合成付款公司', account_number_encrypted: encryptStringOrThrow(config, payerAccount) } });
|
||||
const second = await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成原借入凭据二',
|
||||
originalPrincipal: '300.05', openingOutstandingPrincipal: '300.05' });
|
||||
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, second.id);
|
||||
await call(finance, 'POST', `/company-loans/${second.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对二' });
|
||||
const repaymentInput = { requestKey: randomUUID(), lenderId: lender.id, reason: '合成两笔借入部分还本',
|
||||
allocations: [{ loanId: loan.id, amount: '100.10' }, { loanId: second.id, amount: '200.05' }] };
|
||||
const { item: batch } = await call(finance, 'POST', '/loan-repayments', repaymentInput);
|
||||
assert.equal(batch.items.length, 1); assert.equal(String(batch.totalAmount), '300.15');
|
||||
assert.equal((await call(finance, 'POST', '/loan-repayments', repaymentInput)).item.id, batch.id);
|
||||
await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, reason: 'changed' }, 409, 'loan.duplicate_registration');
|
||||
const balance = await call(finance, 'GET', `/company-loans/${loan.id}`);
|
||||
assert.equal(balance.outstanding, '800.05'); assert.equal(balance.reserved, '100.10'); assert.equal(balance.available, '699.95');
|
||||
await call(finance, 'POST', `/payment-batches/${batch.id}/review`, {}, 403);
|
||||
await call(admin, 'POST', `/payment-batches/${batch.id}/execute`, {}, 403);
|
||||
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {});
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
|
||||
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 409, 'payment_batch.bank_receipt_required');
|
||||
await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '提前交接' }, 409, 'loan.handoff_not_ready');
|
||||
checks.push('two_loans_one_payment_partial_allocation_idempotency_and_review');
|
||||
const { runReceiptOcrOnce } = req('./src/jobs/paymentReceiptOcrJob');
|
||||
async function receipt(batchId, amount, reference, overrides = {}) {
|
||||
const boundary = `synthetic-${randomUUID()}`;
|
||||
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
|
||||
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
||||
assert.equal(response.statusCode, 200, 'receipt upload');
|
||||
const recognition = { status: 'recognized', pagesExpected: 1, pagesProcessed: 1,
|
||||
pages: [{ pageNumber: 1, amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount,
|
||||
transactionStatus: 'success', reference, issues: [], ...overrides }] };
|
||||
// Only the cloud recognizer is replaced. Queue claiming, local storage,
|
||||
// normalization, verification, transaction claims and HTTP writes are real.
|
||||
const result = await runReceiptOcrOnce({ prisma, config, recognize: async () => recognition });
|
||||
return { attachment: response.json().data.items[0], result };
|
||||
}
|
||||
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
|
||||
assert.equal(exportResponse.statusCode, 200, 'loan bank-order export');
|
||||
assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1');
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
|
||||
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe');
|
||||
checks.push('actual_biff8_export_keeps_principal_and_prevents_unsafe_release');
|
||||
const receiptResult = await receipt(batch.id, '30015', 'SYNTHETIC-BANK-1');
|
||||
assert.equal(receiptResult.result.status, 'matched');
|
||||
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '未知银行结果不能释放' }, 409, 'loan.bank_state_unsafe');
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
|
||||
await pool.query(`CREATE FUNCTION synthetic_fail_settlement_audit() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN IF NEW.action='LOAN_PRINCIPAL_SETTLED' THEN RAISE EXCEPTION 'synthetic settlement audit unavailable'; END IF; RETURN NEW; END $$`);
|
||||
await pool.query('CREATE TRIGGER synthetic_settlement_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_settlement_audit()');
|
||||
try {
|
||||
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 500);
|
||||
assert.equal(await prisma.paymentReceiptClaim.count(), 0);
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
|
||||
} finally {
|
||||
await pool.query('DROP TRIGGER synthetic_settlement_audit ON audit_logs');
|
||||
await pool.query('DROP FUNCTION synthetic_fail_settlement_audit()');
|
||||
}
|
||||
const paid = await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {});
|
||||
assert.equal(paid.item.status, 'executed');
|
||||
const after = await call(finance, 'GET', `/company-loans/${loan.id}`);
|
||||
assert.equal(after.outstanding, '699.95'); assert.equal(after.reserved, '0.00');
|
||||
assert.equal(await prisma.paymentReceiptClaim.count(), 1);
|
||||
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {});
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95');
|
||||
await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '合成会计凭证' });
|
||||
checks.push('real_receipt_worker_atomic_claim_settlement_rollback_retry_and_handoff');
|
||||
const nextInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '300.15' }] };
|
||||
const { item: duplicate } = await call(finance, 'POST', '/loan-repayments', nextInput);
|
||||
await call(manager, 'POST', `/payment-batches/${duplicate.id}/review`, {});
|
||||
await receipt(duplicate.id, '30015', 'SYNTHETIC-BANK-1');
|
||||
await call(finance, 'POST', `/payment-batches/${duplicate.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused');
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95');
|
||||
const concurrentInput = { ...repaymentInput, allocations: [{ loanId: loan.id, amount: '300.00' }] };
|
||||
const concurrent = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments',
|
||||
headers: finance.headers, payload: { ...concurrentInput, requestKey: randomUUID() } })));
|
||||
assert.deepEqual(concurrent.map(r => r.statusCode).sort(), [200, 409]);
|
||||
const winner = concurrent.find(r => r.statusCode === 200).json().data.item;
|
||||
await call(finance, 'POST', `/payment-batches/${winner.id}/cancel`, { reason: '合成未发送取消' });
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80');
|
||||
checks.push('duplicate_transaction_refusal_real_http_concurrency_and_safe_cancel');
|
||||
const { item: failedBank } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '10.00' }] });
|
||||
await call(manager, 'POST', `/payment-batches/${failedBank.id}/review`, {});
|
||||
const failureReceipt = await receipt(failedBank.id, '1000', 'SYNTHETIC-FAILED', { transactionStatus: 'failed', issues: ['transaction_failed'] });
|
||||
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
|
||||
await call(finance, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 403);
|
||||
await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id });
|
||||
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
|
||||
await call(finance, 'POST', `/payment-batches/${failedBank.id}/cancel`, { reason: '合成银行明确失败' });
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80');
|
||||
checks.push('confirmed_bank_failure_requires_independent_matching_evidence');
|
||||
const { item: manual } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '20.00' }] });
|
||||
await call(manager, 'POST', `/payment-batches/${manual.id}/review`, {});
|
||||
const unclear = await receipt(manual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
|
||||
assert.equal(unclear.result.status, 'needs_review');
|
||||
const currentAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: unclear.attachment.id } });
|
||||
const manualBase = `/payment-batches/${manual.id}/receipt-attachments/${unclear.attachment.id}/manual-verification`;
|
||||
const proposal = await call(finance, 'POST', manualBase, { expectedVersion: currentAttachment.receipt_ocr_version,
|
||||
reason: '合成原件独立人工核对', pages: [{ pageNumber: 1, itemId: manual.items[0].id, fields: Object.entries({
|
||||
小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: bankAccount, 凭证号: 'SYNTHETIC-MANUAL', 单据状态: '交易成功',
|
||||
}).map(([Name, Value]) => ({ Name, Value })) }] });
|
||||
const saved = proposal.item ?? proposal;
|
||||
await call(finance, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成不允许自审' }, 403);
|
||||
await call(manager, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成独立复核' });
|
||||
await call(finance, 'POST', `/payment-batches/${manual.id}/execute`, {});
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95');
|
||||
checks.push('external_lender_two_person_manual_receipt_and_real_settlement');
|
||||
// Cross-source claim protection: seed only the prior reimbursement facts,
|
||||
// then use actual receipt upload/worker/execute HTTP. No reimbursement paid
|
||||
// result is seeded or asserted by this setup.
|
||||
const source = await prisma.reimbursementForm.create({ data: {
|
||||
form_number: `SYN-${randomUUID()}`, submitter_id: ordinary.id, owner_id: ordinary.id,
|
||||
department_id: department.id, reimbursement_date: new Date('2026-01-01'), total_amount: '300.15', status: 'approved',
|
||||
} });
|
||||
const savedBatch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id }, include: { items: true } });
|
||||
const crossBatch = await prisma.paymentBatch.create({ data: {
|
||||
batch_no: `SYN-${randomUUID()}`, name: '合成跨业务去重', entity_type: 'reimbursement', status: 'reviewed',
|
||||
created_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), legal_entity_id: company.id,
|
||||
payment_account_id: savedBatch.payment_account_id, payment_account_snapshot_json: savedBatch.payment_account_snapshot_json,
|
||||
default_payment_channel: 'bank', items: { create: { entity_id: source.id, entity_code: source.form_number, entity_title: '合成报销来源',
|
||||
amount: '300.15', snapshot_json: savedBatch.items[0].snapshot_json } },
|
||||
} });
|
||||
await receipt(crossBatch.id, '30015', 'SYNTHETIC-BANK-1');
|
||||
await call(finance, 'POST', `/payment-batches/${crossBatch.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused');
|
||||
assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: source.id } })).status, 'approved');
|
||||
checks.push('same_bank_transaction_rejected_across_loan_and_reimbursement_http');
|
||||
|
||||
for (const [label, overrides] of [
|
||||
['amount', { amount: '101' }], ['currency', { currency: 'USD' }],
|
||||
['payer', { payerAccount: '1'.repeat(16) }], ['payee', { payeeAccount: '2'.repeat(16) }],
|
||||
['pending', { transactionStatus: 'pending', issues: ['transaction_pending'] }],
|
||||
]) {
|
||||
const { item: rejected } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput,
|
||||
requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] });
|
||||
await call(manager, 'POST', `/payment-batches/${rejected.id}/review`, {});
|
||||
const mismatch = await receipt(rejected.id, '100', `SYNTHETIC-NEGATIVE-${label}`, overrides);
|
||||
assert.equal(mismatch.result.status, 'needs_review');
|
||||
await call(finance, 'POST', `/payment-batches/${rejected.id}/execute`, {}, 409);
|
||||
await call(manager, 'POST', `/payment-batches/${rejected.id}/bank-failure-review`, { attachmentId: mismatch.attachment.id }, 409);
|
||||
await call(finance, 'POST', `/payment-batches/${rejected.id}/cancel`, { reason: '不明或不符不得释放' }, 409, 'loan.bank_state_unsafe');
|
||||
}
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95');
|
||||
checks.push('wrong_amount_currency_both_accounts_and_pending_never_settle_or_release');
|
||||
const noReviewerInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] };
|
||||
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
|
||||
try { await call(finance, 'POST', '/loan-repayments', noReviewerInput, 409, 'loan.reviewer_required'); }
|
||||
finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
|
||||
const sameKey = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: noReviewerInput })));
|
||||
assert.deepEqual(sameKey.map(r => r.statusCode), [200, 200]);
|
||||
assert.equal(sameKey[0].json().data.item.id, sameKey[1].json().data.item.id);
|
||||
await call(finance, 'POST', `/payment-batches/${sameKey[0].json().data.item.id}/cancel`, { reason: '合成并发验证结束' });
|
||||
checks.push('no_eligible_reviewer_blocks_and_concurrent_same_key_is_idempotent');
|
||||
const evidencePath = `/company-loans/${loan.id}/evidences/${evidence.id}/download`;
|
||||
const reviewerDownload = await app.inject({ method: 'POST', url: `/api${evidencePath}`, headers: manager.headers,
|
||||
payload: { reason: '合成还款审批材料', paymentBatchId: manual.id } });
|
||||
assert.equal(reviewerDownload.statusCode, 200);
|
||||
await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 403);
|
||||
const { item: stale } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] });
|
||||
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
|
||||
expectedVersion: verified.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成收款人关联经理',
|
||||
});
|
||||
await call(manager, 'POST', `/payment-batches/${stale.id}/review`, {}, 403);
|
||||
const independent = await actor(['ceo']);
|
||||
await call(independent, 'POST', `/payment-batches/${stale.id}/review`, {}, 409, 'loan.version_changed');
|
||||
await call(finance, 'POST', `/payment-batches/${stale.id}/cancel`, { reason: '合成未发起身份更正' });
|
||||
const { item: ownPayee } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] });
|
||||
await call(manager, 'POST', `/payment-batches/${ownPayee.id}/review`, {}, 403);
|
||||
await call(independent, 'POST', `/payment-batches/${ownPayee.id}/review`, {});
|
||||
await call(finance, 'POST', `/payment-batches/${ownPayee.id}/cancel`, { reason: '合成验证结束' });
|
||||
checks.push('scoped_reviewer_materials_payee_self_review_and_identity_version_guards');
|
||||
const { item: oldAccount } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] });
|
||||
const changedAccount = await call(finance, 'PATCH', `/loan-lenders/${lender.id}/account`, {
|
||||
expectedVersion: lender.accountVersion, bankInstitutionId: bank.id, bankName: '合成变更开户行',
|
||||
bankAccount: '3'.repeat(16), accountName: '合成出借人',
|
||||
});
|
||||
assert.equal(changedAccount.identityStatus, 'unverified');
|
||||
await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.identity_required');
|
||||
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
|
||||
expectedVersion: changedAccount.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成账户变更后重新核实',
|
||||
});
|
||||
await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.version_changed');
|
||||
await call(finance, 'POST', `/payment-batches/${oldAccount.id}/cancel`, { reason: '合成旧账户未发起取消' });
|
||||
checks.push('account_changes_revoke_identity_and_invalidate_old_unpaid_snapshot');
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
|
||||
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma });
|
||||
}
|
||||
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
|
||||
} finally { await app.close(); await prisma.$disconnect(); }
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户