文件
hyb-oa/scripts/qa/company-loan-bank-results-fixture.cjs
T
wangsli 431c36eae3 借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)
新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。

验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。

真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
2026-10-07 21:58:03 +08:00

279 行
22 KiB
JavaScript

const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
module.exports = async function bankResultsFixture(h) {
const { prisma, pool, call, app, req, finance, manager, independent, verifiedLender, confirmedLoan, repay,
exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid } = h;
const { PDFDocument } = req('pdf-lib');
const config = h.config;
const { decryptStringOrThrow } = req('./src/services/crypto');
async function upload(batchId, kind) {
const doc = await PDFDocument.create(); doc.setTitle(`synthetic-${randomUUID()}`); doc.addPage([200, 200]);
const boundary = `synthetic-${randomUUID()}`;
const res = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/loan-terminal-evidences?kind=${kind}`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="synthetic.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), Buffer.from(await doc.save()), Buffer.from(`\r\n--${boundary}--\r\n`)]) });
assert.equal(res.statusCode, 200); return res.json().data.id;
}
async function setup(cancelled = true) {
const lender = await verifiedLender(`合成晚到${randomUUID().slice(0, 6)}`, null, '5');
const loan = await confirmedLoan(lender.id, '100.00', '合成晚到借款');
const batch = await repay(lender.id, loan.id, '80.00');
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id);
const exp = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: batch.id } });
const sub = await register(batch.id, `LATE-${randomUUID()}`, exp.id); await attest(batch.id);
const ev = await uploadEvidence(batch.id);
const fact = await proposeFact(batch.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: ev });
await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id);
if (cancelled) await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '合成原终态取消' });
const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id } });
return { lender, loan, batch, payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted) };
}
function fields(s, amount = '80.00', incoming = false, ref = `BANK-${randomUUID()}`) {
return [{ Name: '付款账号', Value: incoming ? s.lender.bankAccount : s.payer }, { Name: '收款账号', Value: incoming ? s.payer : s.lender.bankAccount },
{ Name: '金额', Value: amount }, { Name: '币种', Value: 'CNY' }, { Name: '交易状态', Value: '交易成功' }, { Name: '流水号', Value: ref }];
}
async function propose(s, kind = 'late_success', parentId, amount, ref, status = 200) {
return call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results`, { kind, ...(parentId ? { parentId } : {}),
evidenceId: await upload(s.batch.id, kind === 'return' ? 'incoming_return' : 'late_success'), requestKey: randomUUID(),
fields: fields(s, amount, kind === 'return', ref), attributionNote: '合成银行附言唯一定位原批次的付款事实', exclusionNote: '合成银行查询材料逐一排除其他相同金额候选' }, status);
}
const review = (s, e, who = independent, body = {}, status = 200) => call(who, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results/${e.id}/review`, { decision: 'approve', reason: '合成独立核对流水归属及银行证据', ...body }, status);
const apply = (s, e, status = 200) => call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results/${e.id}/apply`, {}, status);
const balances = (s) => call(finance, 'GET', `/company-loans/${s.loan.id}`);
const checks = [];
for (const cancelled of [true, false]) {
const s = await setup(cancelled); const count = await prisma.paymentBatch.count();
const e = await propose(s);
if (!cancelled) await call(finance, 'POST', `/payment-batches/${s.batch.id}/cancel`, { reason: '合成不得用旧结论释放' }, 409, 'loan.bank_result_reconcile_required');
await review(s, e, finance, {}, 403); await review(s, e);
await apply(s, e); await apply(s, e);
assert.equal((await balances(s)).outstanding, '20.00'); assert.equal((await balances(s)).reserved, '0.00');
assert.equal(await prisma.paymentBatch.count(), count);
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.batch.loanRepaymentId } })).bank_result_state, 'confirmed_not_paid');
const record = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: e.id } });
assert.equal(record.bank_amount.toFixed(2), '80.00'); assert.equal(record.settled_amount.toFixed(2), '80.00');
await assert.rejects(prisma.loanSupplementalSettlement.update({ where: { id: record.id }, data: { settled_amount: '1.00' } }));
}
checks.push('s6_late_success_cancelled_or_still_reserved_books_once_without_new_payment_batch');
{
const s = await setup(); const conflict = await repay(s.lender.id, s.loan.id, '70.00');
const e = await propose(s); await review(s, e); assert.equal((await apply(s, e)).conflict, true);
assert.equal((await balances(s)).outstanding, '100.00');
await call(manager, 'POST', `/payment-batches/${conflict.id}/review`, {}, 409, 'loan.lender_frozen');
await call(finance, 'POST', `/payment-batches/${conflict.id}/cancel`, { reason: '合成清除未付款占用' });
await apply(s, e); assert.equal((await balances(s)).outstanding, '20.00');
}
checks.push('s6_reserved_conflict_is_not_overpayment_and_blocks_until_resolved');
{
const s = await setup();
// Another real payment uses the existing HTTP/OCR settlement path.
const paid = await repay(s.lender.id, s.loan.id, '60.00');
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
await h.receipt(paid.id, '6000', `ALREADY-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
const e = await propose(s); await review(s, e); await apply(s, e);
const row = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: e.id } });
assert.equal(row.settled_amount.toFixed(2), '40.00'); assert.equal(row.receivable_amount.toFixed(2), '40.00');
assert.equal((await balances(s)).outstanding, '0.00');
await propose(s, 'return', e.id, '0.00', undefined, 409);
const ret = await propose(s, 'return', e.id, '15.00'); await review(s, ret); await apply(s, ret);
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
const rest = await propose(s, 'return', e.id, '25.00'); await review(s, rest); await apply(s, rest);
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
assert.equal((await balances(s)).outstanding, '0.00');
}
checks.push('s6_s7_overpayment_is_difference_partial_return_keeps_frozen_full_return_closes');
{
const s = await setup(); const e = await propose(s);
await review(s, e, independent, { decision: 'reject' }, 409);
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
const correctionEvidenceId = await upload(s.batch.id, 'bank_terminal');
await review(s, e, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId });
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event_id: e.id } }), 0);
assert.equal((await balances(s)).outstanding, '100.00');
}
checks.push('s5_false_alarm_requires_evidenced_independent_dismissal_uncertainty_stays_frozen');
console.log(JSON.stringify({ stage: "s6_s7", status: "PASS", checks }));
{
const s = await setup();
const paid = await repay(s.lender.id, s.loan.id, '100.00');
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
const paidRef = `FULL-${randomUUID()}`;
await h.receipt(paid.id, '10000', paidRef, { payeeAccount: s.lender.bankAccount });
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
const event = await propose(s); await review(s, event); await apply(s, event);
const row = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: event.id } });
assert.equal(row.settled_amount.toFixed(2), '0.00'); assert.equal(row.receivable_amount.toFixed(2), '80.00');
assert.equal(row.bank_amount.toFixed(2), '80.00');
// Same transaction as an ordinary executed payment must be rejected.
const reused = await propose(s, 'late_success', undefined, '80.00', paidRef);
await review(s, reused, independent, {}, 409);
// The review-only correction exit does not erase the existing receivable cause.
const correctionEvidenceId = await upload(s.batch.id, 'bank_terminal');
await review(s, reused, independent, { decision: 'reject', correctionKind: 'duplicate', correctionEvidenceId });
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
}
checks.push('s6_full_excess_retains_bank_amount_and_shared_transaction_identity_blocks_reuse');
{
const s = await setup(); const event = await propose(s); await review(s, event);
// Late factual payment must use original account snapshots even after legal
// master-data changes, and a departed original approver cannot strand it.
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
try { await apply(s, event); } finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
assert.equal((await balances(s)).outstanding, '20.00');
}
checks.push('s6_original_approver_departure_does_not_block_bank_fact_booking');
{
const s = await setup(); const event = await propose(s); await review(s, event);
await pool.query(`CREATE FUNCTION synthetic_bank_result_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF NEW.action='LOAN_LATE_SUCCESS_BOOKED' THEN RAISE EXCEPTION 'synthetic late audit failure'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_bank_result_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_bank_result_audit_failure()');
try { await apply(s, event, 500); }
finally { await pool.query('DROP TRIGGER synthetic_bank_result_audit_failure ON audit_logs'); await pool.query('DROP FUNCTION synthetic_bank_result_audit_failure()'); }
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event_id: event.id } }), 0);
assert.equal((await balances(s)).outstanding, '100.00');
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: event.id } })).status, 'approved');
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
await apply(s, event);
}
checks.push('s6_bookkeeping_audit_failure_rolls_back_amounts_status_and_freezes');
const { Pool } = req('pg'); const lockPool = new Pool({ ...pool.options, max: 3 });
async function waiters(n) {
for (let i = 0; i < 300; i++) {
const result = await lockPool.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type='Lock' AND datname=current_database()");
if (result.rows[0].n >= n) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error('bank result requests did not queue on lender');
}
try {
for (const freezeFirst of [false, true]) {
const s = await setup(); const next = await repay(s.lender.id, s.loan.id, '10.00');
await call(manager, 'POST', `/payment-batches/${next.id}/review`, {});
const evidenceId = await upload(s.batch.id, 'late_success');
const holder = await lockPool.connect();
const freeze = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-results`, headers: finance.headers,
payload: { kind: 'late_success', evidenceId, requestKey: randomUUID(), fields: fields(s), attributionNote: '合成确定性交错银行归属说明', exclusionNote: '合成确定性交错排除其他付款批次' } });
const exporting = () => app.inject({ method: 'POST', url: `/api/payment-batches/${next.id}/payment-order`, headers: finance.headers, payload: {} });
let first, second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
first = freezeFirst ? freeze() : exporting(); await waiters(1);
second = freezeFirst ? exporting() : freeze(); await waiters(2);
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
} finally { await holder.query('ROLLBACK'); holder.release(); }
assert.equal(first.statusCode, 200); assert.equal(second.statusCode, freezeFirst ? 409 : 200);
assert.equal(await prisma.paymentOrderExport.count({ where: { payment_batch_id: next.id } }), freezeFirst ? 0 : 1);
}
for (const bookFirst of [false, true]) {
const s = await setup(); const event = await propose(s); await review(s, event);
const holder = await lockPool.connect();
const book = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-results/${event.id}/apply`, headers: finance.headers, payload: {} });
const request = () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers,
payload: { lenderId: s.lender.id, reason: '合成并发超额申请', requestKey: randomUUID(), allocations: [{ loanId: s.loan.id, amount: '30.00' }] } });
let first, second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
first = bookFirst ? book() : request(); await waiters(1);
second = bookFirst ? request() : book(); await waiters(2);
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
} finally { await holder.query('ROLLBACK'); holder.release(); }
assert.equal(first.statusCode, bookFirst ? 200 : 409); assert.equal(second.statusCode, bookFirst ? 409 : 200);
assert.equal((await balances(s)).outstanding, '20.00'); assert.equal((await balances(s)).reserved, '0.00');
}
} finally { await lockPool.end(); }
checks.push('s5_s6_freeze_vs_export_and_supplement_vs_new_request_deterministic_both_orders');
{
const s = await setup(false); const ref = `REPLACE-${randomUUID()}`;
const old = await propose(s, 'late_success', undefined, '80.00', ref);
const oldRow = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } });
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${oldRow.evidence_id}/void`, { reason: '合成待审材料换版' });
await review(s, old, independent, {}, 409);
const fresh = await propose(s, 'late_success', undefined, '80.00', ref);
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } })).status, 'superseded');
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
await review(s, fresh);
const current = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: fresh.id } });
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${current.evidence_id}/void`, { reason: '合成已审事实不可作废' }, 409, 'loan.bank_result_reconcile_required');
await apply(s, fresh);
}
checks.push('s6_pending_evidence_replaced_without_unfreeze_reviewed_bank_fact_evidence_retained');
{
const s = await setup(); const otherLoan = await confirmedLoan(s.lender.id, '100.00', '合成多借款补记第二笔');
const combined = (await call(finance, 'POST', '/loan-repayments', { lenderId: s.lender.id, reason: '合成跨原借款逐笔补记', requestKey: randomUUID(),
allocations: [{ loanId: s.loan.id, amount: '20.00' }, { loanId: otherLoan.id, amount: '40.00' }] })).item;
await call(manager, 'POST', `/payment-batches/${combined.id}/review`, {}); await exportOrder(combined.id);
const exp = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: combined.id } });
const submission = await register(combined.id, `COMBINED-${randomUUID()}`, exp.id); await attest(combined.id);
const evidenceId = await uploadEvidence(combined.id);
const fact = await proposeFact(combined.id, { submissionId: submission.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
await reviewFact(combined.id, fact.id); await confirmNotPaid(combined.id);
await call(finance, 'POST', `/payment-batches/${combined.id}/cancel`, { reason: '合成多借款原结论取消' });
for (const [loanId, amount] of [[s.loan.id, '90.00'], [otherLoan.id, '70.00']]) {
const paid = await repay(s.lender.id, loanId, amount);
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
await h.receipt(paid.id, String(Number(amount) * 100), `MULTI-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
}
const combinedSource = { ...s, batch: combined }; const event = await propose(combinedSource, 'late_success', undefined, '60.00');
await review(combinedSource, event); await apply(combinedSource, event);
const rows = await prisma.loanSupplementalSettlement.findMany({ where: { event_id: event.id } });
assert.equal(rows.length, 2);
const a = rows.find((row) => row.loan_id === s.loan.id); const b = rows.find((row) => row.loan_id === otherLoan.id);
assert.deepEqual([a.settled_amount.toFixed(2), a.receivable_amount.toFixed(2)], ['10.00', '10.00']);
assert.deepEqual([b.settled_amount.toFixed(2), b.receivable_amount.toFixed(2)], ['30.00', '10.00']);
assert.equal((await balances(s)).outstanding, '0.00');
assert.equal((await call(finance, 'GET', `/company-loans/${otherLoan.id}`)).outstanding, '0.00');
}
checks.push('s6_multiple_original_loans_settle_each_difference_without_cross_loan_offset');
{
const s = await setup(); const competitor = await repay(s.lender.id, s.loan.id, '80.00');
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
const ref = `STALE-APPROVED-${randomUUID()}`;
const old = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, old);
const submitted = await register(competitor.id, `NEW-CANDIDATE-${randomUUID()}`, null);
await apply(s, old, 409);
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: old.id } }), 0, 'review does not consume a transaction');
await attest(competitor.id); const evidenceId = await uploadEvidence(competitor.id);
const fact = await proposeFact(competitor.id, { submissionId: submitted.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
await reviewFact(competitor.id, fact.id); await confirmNotPaid(competitor.id);
await call(finance, 'POST', `/payment-batches/${competitor.id}/cancel`, { reason: '合成其他候选已确认终止' });
const fresh = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, fresh); await apply(s, fresh);
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } })).status, 'superseded');
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: fresh.id } }), 1);
assert.equal((await balances(s)).outstanding, '20.00');
}
checks.push('s6_approved_attribution_stales_on_new_candidate_reregistration_rechecks_then_claims_once');
for (const dismiss of [false, true]) {
const s = await setup(); const conflict = await repay(s.lender.id, s.loan.id, '70.00');
const ref = `REPLACE-CONFLICT-${randomUUID()}`;
const old = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, old);
assert.equal((await apply(s, old)).conflict, true);
const fresh = await propose(s, 'late_success', undefined, '80.00', ref);
assert.equal(await prisma.loanFreezeReason.count({ where: { source_key: old.id, status: 'active' } }), 0);
assert.equal(await prisma.loanFreezeReason.count({ where: { source_key: fresh.id, status: 'active' } }), 2);
if (dismiss) {
await review(s, fresh, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: await upload(s.batch.id, 'bank_terminal') });
assert.equal((await balances(s)).outstanding, '100.00');
} else {
await review(s, fresh);
await call(finance, 'POST', `/payment-batches/${conflict.id}/cancel`, { reason: '合成解除其他申请占用' });
await apply(s, fresh); assert.equal((await balances(s)).outstanding, '20.00');
}
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
}
checks.push('s6_replacement_transfers_conflict_freeze_and_booking_or_evidenced_dismissal_closes_it');
{
const s = await setup(false); const event = await propose(s);
const correction = await upload(s.batch.id, 'bank_terminal');
await review(s, event, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: correction });
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${correction}/void`, { reason: '合成不得撤销已消费的误报更正依据' }, 409, 'loan.bank_result_reconcile_required');
await assert.rejects(prisma.loanBankTerminalEvidence.update({ where: { id: correction }, data: { voided_at: new Date(), voided_by: finance.id, void_reason: '合成数据库旁路拒绝' } }));
assert.equal((await prisma.loanBankTerminalEvidence.findUniqueOrThrow({ where: { id: correction } })).voided_at, null);
assert.equal((await balances(s)).reserved, '80.00');
}
checks.push('s5_consumed_false_alarm_correction_cannot_be_voided_in_service_or_database');
return checks;
};