借款对账 SR-A/B:补证恢复与复核后释放(09-17-company-loan-principal-repayment)

保留原银行终态及依据,以恢复轮次绑定材料、候选和独立性;最终取消重验并原子消费批准、释放占用和记录审计。新增增量迁移、HTTP 回归及数据库保护。

验证:78 个迁移、HTTP 72 组、既有浏览器 7 组、数据库约束 9 组通过;7 个变异均被抓住。服务端首跑 4470 通过、6 失败、1 跳过,补齐 BIFF8 测试依赖后相关 146 项通过,未重跑完整套件。

限制:inline 主会话自查不等于独立复核,独立复核待完成;界面未完成,不能单独部署;真实回单待验。新恢复界面留待 S8,S4 另批,整体借款功能尚未完成验收。本次仅本地提交,不推送、不部署、不查询生产。
这个提交包含在:
2026-10-07 20:07:57 +08:00
父节点 14aee82d5c
当前提交 2e00d30fd0
共修改 15 个文件,包含 960 行新增和 9 行删除
+3 -1
查看文件
@@ -202,7 +202,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
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.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().details)}`);
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');
@@ -1155,6 +1155,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
await assert.rejects(prisma.loanBankTerminalFact.update({ where: { id: fact1.id }, data: { terminal_status: 'failed' } }));
await assert.rejects(prisma.loanBankTerminalFact.delete({ where: { id: fact1.id } }));
checks.push('s3_evidence_and_fact_history_immutable');
checks.push(...await require('./company-loan-recovery-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
delete config.loanReleaseWaitDays;
// History guards in the database.
@@ -0,0 +1,350 @@
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
// Real HTTP/auth, bank-evidence upload and OCR supplied by the isolated loan fixture.
module.exports = async function recoveryFixture(h) {
const { prisma, pool, call, app, finance, manager, independent, dual, actor, config, req,
verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence,
proposeFact, reviewFact, confirmNotPaid, receipt } = h;
const checks = [];
const lender = await verifiedLender('合成恢复出借人', null, '2');
const loan = await confirmedLoan(lender.id, '5000.00', '合成恢复借款');
const balances = () => call(finance, 'GET', `/company-loans/${loan.id}`);
let amount = 100;
async function setup(kind = 'notpaid', who = finance) {
const cents = ++amount; const value = (cents / 100).toFixed(2);
const batch = await repay(lender.id, loan.id, value);
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {});
await exportOrder(batch.id);
const [exp] = await prisma.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id } });
const ref = `SYNTHETIC-RECOVERY-${randomUUID()}`.toUpperCase();
const submission = await register(batch.id, ref, exp.id);
await attest(batch.id);
let evidenceId; let attachmentId;
if (kind === 'failed') {
const result = await receipt(batch.id, String(cents), ref, { payeeAccount: lender.bankAccount, transactionStatus: 'failed' });
attachmentId = result.attachment.id;
await call(manager, 'POST', `/payment-batches/${batch.id}/bank-failure-review`, { attachmentId });
} else {
evidenceId = await uploadEvidence(batch.id);
const fact = await proposeFact(batch.id, { submissionId: submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId });
await reviewFact(batch.id, fact.id);
await confirmNotPaid(batch.id);
}
const original = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: batch.loanRepaymentId } });
return { batch, submission, ref, cents, evidenceId, attachmentId, original, who };
}
const url = (s) => `/payment-batches/${s.batch.id}/loan-release-recoveries`;
const start = (s, payload = { requestKey: randomUUID(), reason: '合成补充有效释放依据' }, status = 200, reason) =>
call(s.who, 'POST', url(s), payload, status, reason);
const submit = (s, r, status = 200, reason) => call(s.who, 'POST', `${url(s)}/${r.id}/submit`, {}, status, reason);
const review = (s, r, decision = 'approve', who = independent, status = 200, reason) =>
call(who, 'POST', `${url(s)}/${r.id}/review`, { decision, reason: '合成独立恢复复核' }, status, reason);
const cancel = (s, r, key = randomUUID(), status = 200, reason) => call(finance, 'POST', `/payment-batches/${s.batch.id}/cancel`,
{ reason: '合成补证后最终取消', ...(r ? { recoveryId: r.id, requestKey: key } : {}) }, status, reason);
const withdraw = (s, r) => call(s.who, 'POST', `${url(s)}/${r.id}/withdraw`, { reason: '合成撤回恢复申请' });
async function voidEvidence(s, evidenceId = s.evidenceId) {
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${evidenceId}/void`, { reason: '合成证据换版' });
}
async function repair(s) {
const evidenceId = await uploadEvidence(s.batch.id);
const fact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId });
await reviewFact(s.batch.id, fact.id);
s.evidenceId = evidenceId;
return evidenceId;
}
async function ready() {
const s = await setup(); await voidEvidence(s); const r = await start(s); await repair(s); await submit(s, r); await review(s, r);
return { s, r };
}
// Both historical outcomes recover through uploaded evidence and independent
// review; neither original terminal field changes, and only cancellation releases.
for (const kind of ['notpaid', 'failed']) {
const s = await setup(kind);
if (kind === 'notpaid') {
await voidEvidence(s);
const probeEvidence = await uploadEvidence(s.batch.id);
await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: probeEvidence }, 409, 'loan.recovery_required');
}
else await call(finance, 'POST', `/payment-batches/${s.batch.id}/receipt-attachments/${s.attachmentId}/void`, { reason: '合成失败回单换版' });
const input = { requestKey: randomUUID(), reason: '合成补证请求幂等' };
const r = await start(s, input);
assert.equal((await start(s, input)).id, r.id);
await start(s, undefined, 409, 'loan.recovery_exists');
if (kind === 'notpaid') await repair(s);
else {
const failed = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'failed' });
const f = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: failed.attachment.id });
await reviewFact(s.batch.id, f.id);
}
const before = await balances();
await cancel(s, null, undefined, 409, 'loan.recovery_required');
await submit(s, r); await review(s, r, 'approve', finance, 403);
await review(s, r);
assert.equal((await balances()).reserved, before.reserved);
const cancelKey = randomUUID(); await cancel(s, r, cancelKey); await cancel(s, r, cancelKey);
await cancel(s, r, randomUUID(), 409);
const after = await balances();
assert.equal(after.outstanding, before.outstanding);
assert.equal(Number(after.reserved), Number((Number(before.reserved) - s.cents / 100).toFixed(2)));
const original = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.original.id } });
assert.equal(original.bank_result_state, s.original.bank_result_state);
assert.equal(original.bank_result_ref, s.original.bank_result_ref);
await start(s, undefined, 409, 'loan.recovery_unavailable');
await call(finance, 'POST', `/payment-batches/${s.batch.id}/payment-order`, {}, 409);
await call(finance, 'POST', `/payment-batches/${s.batch.id}/execute`, {}, 409);
assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_RELEASE_RECOVERY_CONSUMED', target_id: s.original.id } }), 1);
}
checks.push('sr_both_terminal_outcomes_recover_without_rewriting_history_idempotent_release');
// Withdraw/reject never revive the original release path, even when its
// original documents remain valid. A new round is required and can succeed.
for (const decision of ['withdraw', 'reject']) {
const s = await setup(); const r = await start(s);
if (decision === 'withdraw') await withdraw(s, r);
else { await submit(s, r); await review(s, r, 'reject'); }
await cancel(s, null, undefined, 409, 'loan.recovery_required');
const next = await start(s); assert.equal(next.sequence, 2);
await submit(s, next); await review(s, next); await cancel(s, next);
}
checks.push('sr_withdraw_reject_require_fresh_round_no_original_conclusion_fallback');
// Evidence void after approval: the current round becomes stale, can be
// superseded and repaired. Returning to old evidence never restores approval.
{
const { s, r } = await ready(); const before = await balances();
await voidEvidence(s);
await cancel(s, r, undefined, 409);
const state = await call(finance, 'GET', url(s)); assert.equal(state.current.stale, true);
assert.equal((await balances()).reserved, before.reserved);
const next = await start(s); await repair(s); await submit(s, next); await review(s, next); await cancel(s, next);
assert.equal((await prisma.loanReleaseRecovery.findUnique({ where: { id: r.id } })).status, 'superseded');
}
checks.push('sr_stale_approval_can_be_superseded_and_repaired');
// New submission then withdrawal, followed by a fresh attestation, cannot
// revive a saved approval. It also cannot erase an unexplained receipt.
{
const { s, r } = await ready();
const extra = await register(s.batch.id, `SYNTHETIC-EXTRA-${randomUUID()}`.toUpperCase(), null);
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成登记纠错' });
await attest(s.batch.id);
await cancel(s, r, undefined, 409, 'loan.recovery_stale');
const next = await start(s); await submit(s, next); await review(s, next); await cancel(s, next);
}
checks.push('sr_submission_aba_does_not_revive_approval');
// Candidate membership changed in another batch: target's material revision
// is unchanged, but its saved candidate set must no longer authorize release.
{
const { s, r } = await ready();
const competitor = await repay(lender.id, loan.id, (s.cents / 100).toFixed(2));
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
await register(competitor.id, `SYNTHETIC-CANDIDATE-${randomUUID()}`.toUpperCase(), null);
await cancel(s, r, undefined, 409, 'loan.recovery_stale');
const next = await start(s); await submit(s, next); await review(s, next); await cancel(s, next);
}
checks.push('sr_new_same_amount_candidate_invalidates_approval');
// A newly discovered second submission needs its own independently reviewed
// terminal fact; the original termination cannot stand in for it.
{
const s = await setup(); const r = await start(s);
const extra = await register(s.batch.id, `SYNTHETIC-MISSING-${randomUUID()}`.toUpperCase(), null);
await attest(s.batch.id);
await submit(s, r, 409, 'loan.terminal_coverage_incomplete');
const ev = await uploadEvidence(s.batch.id);
const fact = await proposeFact(s.batch.id, { submissionId: extra.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: ev });
await reviewFact(s.batch.id, fact.id);
await submit(s, r); await review(s, r); await cancel(s, r);
}
checks.push('sr_every_additional_submission_needs_own_terminal_fact');
// A late successful receipt blocks recovery even after the receipt is voided.
{
const s = await setup(); const r = await start(s);
const paid = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'success' });
await submit(s, r, 409, 'loan.receipt_unresolved_for_release');
await call(finance, 'POST', `/payment-batches/${s.batch.id}/receipt-attachments/${paid.attachment.id}/void`, { reason: '合成成功回单作废不抹除事实' });
await submit(s, r, 409, 'loan.receipt_unresolved_for_release');
await cancel(s, null, undefined, 409, 'loan.recovery_required');
}
checks.push('sr_success_fact_blocks_release_even_after_void');
// Physical bytes, not just a still-valid database ID, must match the evidence.
{
const s = await setup(); const r = await start(s);
const ev = await prisma.loanBankTerminalEvidence.findUnique({ where: { id: s.evidenceId } });
const { readFileBuffer, writeBuffer } = req('./src/services/storage');
const original = await readFileBuffer({ config, storagePath: ev.storage_path });
await writeBuffer({ config, key: ev.storage_path, buffer: Buffer.from('synthetic corrupted document') });
try { await submit(s, r, 409, 'loan.terminal_evidence_invalid'); }
finally { await writeBuffer({ config, key: ev.storage_path, buffer: original }); }
await submit(s, r); await review(s, r); await cancel(s, r);
}
checks.push('sr_physical_evidence_digest_revalidated');
// Re-uploading the same failed transaction after approval changes evidence,
// so approval must be replaced even though the bank reference is identical.
{
const s = await setup('failed'); const r = await start(s);
const oldFact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: s.attachmentId });
await reviewFact(s.batch.id, oldFact.id); await submit(s, r); await review(s, r);
await call(finance, 'POST', `/payment-batches/${s.batch.id}/receipt-attachments/${s.attachmentId}/void`, { reason: '合成同交易换回单' });
const replacement = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'failed' });
await cancel(s, r, undefined, 409);
const next = await start(s);
const newFact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: replacement.attachment.id });
await reviewFact(s.batch.id, newFact.id); await submit(s, next); await review(s, next); await cancel(s, next);
}
checks.push('sr_same_transaction_new_receipt_requires_fresh_review');
// Same-instruction terminal evidence resolves a pending receipt in recovery.
{
const s = await setup(); const r = await start(s);
const pending = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'pending' });
await submit(s, r, 409, 'loan.receipt_unresolved_for_release');
await voidEvidence(s);
const ev = await uploadEvidence(s.batch.id);
const fact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'termination_evidence', terminalStatus: 'revoked',
evidenceId: ev, resolvesAttachmentIds: [pending.attachment.id] });
await reviewFact(s.batch.id, fact.id); await submit(s, r); await review(s, r); await cancel(s, r);
}
checks.push('sr_pending_receipt_resolved_by_same_instruction_terminal_fact');
// Finance+manager cannot review own round; current reviewer departure blocks
// approval, while old approver departure does not erase the bank conclusion.
{
const s = await setup('notpaid', dual); const r = await start(s); await submit(s, r);
await review(s, r, 'approve', dual, 403);
const departed = await actor(['manager']);
await prisma.employee.update({ where: { id: departed.id }, data: { status: 'archived' } });
await review(s, r, 'approve', departed, 403);
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
try { await review(s, r); await cancel(s, r); }
finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
}
checks.push('sr_current_independence_and_historical_approver_departure');
// Wait applies at submit, approve and final release (test configuration only).
{
const s = await setup(); const r = await start(s);
config.loanReleaseWaitDays = 3; await submit(s, r, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0; await submit(s, r);
config.loanReleaseWaitDays = 3; await review(s, r, 'approve', independent, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0; await review(s, r);
config.loanReleaseWaitDays = 3; await cancel(s, r, undefined, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0; await cancel(s, r);
}
checks.push('sr_wait_period_on_submission_approval_and_final_cancel');
// Real SQL rollback: fail at the LAST audit, after recovery consumption,
// allocation release and batch cancellation; all must roll back together.
{
const { s, r } = await ready(); const before = await balances();
await assert.rejects(prisma.loanReleaseRecovery.update({ where: { id: r.id }, data: { status: 'superseded', ended_at: new Date(), snapshot_json: {} } }));
await assert.rejects(prisma.loanReleaseMaterialRevision.update({ where: { payment_batch_id: s.batch.id }, data: { revision: 0 } }));
await assert.rejects(prisma.loanReleaseMaterialRevision.delete({ where: { payment_batch_id: s.batch.id } }));
await assert.rejects(prisma.$transaction(async tx => {
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: s.batch.loanRepaymentId }, data: { reservation_state: 'released', released_at: new Date(), release_reason: '合成绕过恢复批准' } });
await tx.paymentBatch.update({ where: { id: s.batch.id }, data: { status: 'cancelled' } });
}));
await pool.query(`CREATE FUNCTION synthetic_sr_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF NEW.action='PAYMENT_BATCH_CANCELLED' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_sr_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_sr_audit_failure()');
try { await cancel(s, r, undefined, 500); }
finally { await pool.query('DROP TRIGGER synthetic_sr_audit_failure ON audit_logs'); await pool.query('DROP FUNCTION synthetic_sr_audit_failure()'); }
assert.equal((await balances()).reserved, before.reserved);
assert.equal((await prisma.paymentBatch.findUnique({ where: { id: s.batch.id } })).status, 'reviewed');
assert.equal((await prisma.loanReleaseRecovery.findUnique({ where: { id: r.id } })).status, 'approved');
await cancel(s, r);
await assert.rejects(prisma.loanReleaseRecovery.update({ where: { id: r.id }, data: { status: 'approved' } }));
await assert.rejects(prisma.loanReleaseRecovery.delete({ where: { id: r.id } }));
const ev = await prisma.loanReleaseRecoveryEvent.findFirst({ where: { recovery_id: r.id } });
await assert.rejects(prisma.loanReleaseRecoveryEvent.update({ where: { id: ev.id }, data: { note: '篡改' } }));
await assert.rejects(prisma.loanReleaseRecoveryEvent.delete({ where: { id: ev.id } }));
}
checks.push('sr_atomic_release_rollback_and_database_history_guards');
// Deterministic batch-lock ordering: mutation-first invalidates approval,
// release-first closes the evidence. Observe actual SQL waits, not timing luck.
const { Pool } = req('pg'); const lockPool = new Pool({ ...pool.options, max: 3 });
const waiters = async (n) => {
for (let i = 0; i < 300; i++) {
const r = await lockPool.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type='Lock' AND datname=current_database()");
if (r.rows[0].n >= n) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error('recovery requests did not queue on database lock');
};
try {
for (const mutationKind of ['evidence', 'submission']) for (const releaseFirst of [true, false]) {
const { s, r } = await ready(); const key = randomUUID(); const holder = await lockPool.connect();
const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers,
payload: { reason: '合成并发释放', recoveryId: r.id, requestKey: key } });
const mutation = () => mutationKind === 'evidence' ? app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-terminal-evidences/${s.evidenceId}/void`,
headers: finance.headers, payload: { reason: '合成并发作废' } }) : app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-submissions`,
headers: finance.headers, payload: { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: `SYNTHETIC-RACE-${randomUUID()}`.toUpperCase(), note: '合成并发补登提交' } });
let first; let second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [s.batch.id]);
first = releaseFirst ? release() : mutation(); await waiters(1);
second = releaseFirst ? mutation() : release(); 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, 409);
const allocation = await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } });
assert.equal(allocation.reservation_state, releaseFirst ? 'released' : 'reserved');
}
// Other-batch candidate formation shares the lender lock with release.
for (const releaseFirst of [true, false]) {
const { s, r } = await ready();
const competitor = await repay(lender.id, loan.id, (s.cents / 100).toFixed(2));
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
const holder = await lockPool.connect();
const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers,
payload: { reason: '合成候选并发释放', recoveryId: r.id, requestKey: randomUUID() } });
const mutation = () => app.inject({ method: 'POST', url: `/api/payment-batches/${competitor.id}/loan-bank-submissions`, headers: finance.headers,
payload: { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: `SYNTHETIC-CANDIDATE-RACE-${randomUUID()}`.toUpperCase(), note: '合成候选并发提交' } });
let first; let second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [lender.id]);
first = releaseFirst ? release() : mutation(); await waiters(1);
second = releaseFirst ? mutation() : release(); 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, releaseFirst ? 200 : 409);
const allocation = await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } });
assert.equal(allocation.reservation_state, releaseFirst ? 'released' : 'reserved');
}
// Exercise the actual OCR persistence transaction against final release.
// The source recognition is the real normalized output from the failed PDF.
for (const releaseFirst of [true, false]) {
const s = await setup('failed'); const r = await start(s);
const f = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: s.attachmentId });
await reviewFact(s.batch.id, f.id); await submit(s, r); await review(s, r);
const attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: s.attachmentId } });
const verification = await prisma.paymentReceiptVerification.findFirst({ where: { attachment_id: s.attachmentId }, orderBy: { created_at: 'desc' } });
const recognition = JSON.parse(req('./src/services/crypto').decryptStringOrThrow(config, verification.normalized_encrypted));
const holder = await lockPool.connect();
const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers,
payload: { reason: '合成核验落库并发释放', recoveryId: r.id, requestKey: randomUUID() } });
const mutation = () => prisma.$transaction(tx => req('./src/services/paymentReceiptVerification').storeReceiptRecognition(tx, config,
{ batchId: s.batch.id, attachmentId: s.attachmentId, sourceSha256: attachment.file_sha256, expectedVersion: attachment.receipt_ocr_version, recognition }))
.then(() => ({ statusCode: 200 }), error => { if (error.code === 'receipt_batch_not_editable') return { statusCode: 409 }; throw error; });
let first; let second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [s.batch.id]);
first = releaseFirst ? release() : mutation(); await waiters(1);
second = releaseFirst ? mutation() : release(); 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, 409);
const allocation = await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } });
assert.equal(allocation.reservation_state, releaseFirst ? 'released' : 'reserved');
}
} finally { await lockPool.end(); }
checks.push('sr_final_release_and_evidence_or_submission_deterministic_both_orders');
checks.push('sr_candidate_formation_and_ocr_persistence_serialize_with_final_release');
return checks;
};