文件
hyb-oa/scripts/qa/company-loan-http-fixture.cjs
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

332 行
28 KiB
JavaScript

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(); }
};