完善付款回单凭证核验与本地重检(09-17-company-loan-principal-repayment)

区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。

正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。

本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。

本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。

限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
这个提交包含在:
2026-10-09 06:18:09 +08:00
父节点 ce11dea976
当前提交 5a7a13524e
共修改 31 个文件,包含 1325 行新增和 49 行删除
+160
查看文件
@@ -95,6 +95,19 @@ module.exports = async function relatedFixture(h) {
await put({ ...original, authorizationEventId: 'damaged-synthetic-event' });
try { await check(); } finally { await put(original); }
}
async function legacyVerification(attachmentId) {
const last = await prisma.paymentReceiptVerification.findFirstOrThrow({ where: { attachment_id: attachmentId },
include: { matches: true }, orderBy: { created_at: 'desc' } });
const crypto = req('./src/services/crypto');
const old = JSON.parse(crypto.decryptStringOrThrow(config, last.normalized_encrypted));
delete old.documentPages; for (const page of old.pages) delete page.document;
// Seed an old-policy record, preserving every existing immutable record.
return prisma.paymentReceiptVerification.create({ data: { attachment_id: last.attachment_id, source_sha256: last.source_sha256,
source_version: last.source_version, status: last.status, pages_expected: last.pages_expected, pages_processed: last.pages_processed,
normalized_encrypted: crypto.encryptStringOrThrow(config, JSON.stringify(old)),
...(last.matches.length ? { matches: { create: last.matches.map(({ page_number, item_id, fingerprint, key_id }) =>
({ page_number, item_id, fingerprint, key_id })) } } : {}) } });
}
const originalWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 0;
const ui = [];
try {
@@ -248,6 +261,131 @@ module.exports = async function relatedFixture(h) {
assert.equal(stored.loan_approval_mode, 'related_party');
}
checks.push('rp_manual_receipt_decision_cold_reconstruction_and_historical_consumption');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-NONFINAL-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }, { document: { final: false } });
assert.equal(rr.result.status, 'needs_review');
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`;
const d = (await call(finance, 'GET', endpoint)).item;
assert.equal(d.pages[0].document.kind, 'non_final');
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成不得覆盖非最终说明', pages: [{
pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'explicit_success', originalChecked: true, note: '合成强行声明仍然必须被拦截' },
}] }, 409, 'payment_batch.receipt_final_evidence_required');
assert.equal((await balance(s)).outstanding, '500.00'); assert.equal((await balance(s)).reserved, '10.00');
}
checks.push('receipt_nonfinal_fields_match_but_final_settlement_and_manual_override_rejected');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-BOOKED-${randomUUID()}`, { payeeAccount: s.lender.bankAccount, transactionStatus: null });
assert.equal(rr.result.status, 'needs_review');
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`;
const d = (await call(finance, 'GET', endpoint)).item;
assert.equal(d.pages[0].document.kind, 'bank_receipt'); assert.equal(d.pages[0].transactionStatus, null);
await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成无状态也无记账依据',
pages: [{ pageNumber: 1, itemId: b.items[0].id }] }, 409, 'payment_batch.receipt_final_evidence_required');
const p = (await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成原件列明公司借方记账信息,未写成功字样', pages: [{
pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'booked_debit', originalChecked: true,
bookedOn: '2026-10-01', note: '合成正式回单记载公司付款账户借方已记账支出' },
}] })).item;
const original = (await call(manager, 'GET', endpoint)).item;
assert.equal(original.pages[0].transactionStatus, null);
assert.equal(original.proposal.pages[0].document.proof.basis, 'booked_debit');
await call(finance, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成自审被拒' }, 403);
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
await call(manager, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成代表公司核对完整原件及已记账依据' });
assert.equal((await call(finance, 'GET', `${base(b)}/receipt-readiness`)).item.ready, true);
await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await balance(s)).outstanding, '490.00'); assert.equal((await balance(s)).reserved, '0.00');
}
checks.push('receipt_booked_debit_without_invented_status_two_person_review_and_final_settlement');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-CREDIT-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }, { document: { direction: 'credit' } });
assert.equal(rr.result.status, 'needs_review');
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`;
const d = (await call(finance, 'GET', endpoint)).item;
await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成借入回单不能用于还本', pages: [{
pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'explicit_success', originalChecked: true, note: '合成方向矛盾必须拒绝' },
}] }, 409, 'payment_batch.receipt_final_evidence_required');
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
assert.equal((await balance(s)).outstanding, '500.00');
}
checks.push('receipt_incoming_direction_cannot_settle_outgoing_principal');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-LEGACY-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
const old = await legacyVerification(rr.attachment.id);
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}`;
const d = (await call(finance, 'GET', `${endpoint}/manual-verification`)).item;
assert.equal(d.documentInspectionRequired, true);
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
await call(manager, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 403);
await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version - 1 }, 409);
const verificationCount = await prisma.paymentReceiptVerification.count({ where: { attachment_id: rr.attachment.id } });
await pool.query(`CREATE FUNCTION synthetic_document_audit_fail() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action='PAYMENT_RECEIPT_DOCUMENT_CHECKED' THEN RAISE EXCEPTION 'synthetic document audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_document_audit_fail BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_document_audit_fail()');
try {
await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 500);
assert.equal((await prisma.paymentBatchReceiptAttachment.findUniqueOrThrow({ where: { id: rr.attachment.id } })).receipt_ocr_version, d.version);
assert.equal(await prisma.paymentReceiptVerification.count({ where: { attachment_id: rr.attachment.id } }), verificationCount);
assert.equal((await balance(s)).reserved, '10.00');
} finally {
await pool.query('DROP TRIGGER synthetic_document_audit_fail ON audit_logs');
await pool.query('DROP FUNCTION synthetic_document_audit_fail()');
}
const checked = (await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version })).item;
assert.equal(checked.version, d.version + 1);
assert.equal((await call(finance, 'GET', `${endpoint}/manual-verification`)).item.documentInspectionRequired, false);
await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 409);
assert.ok(await prisma.auditLog.findFirst({ where: { target_id: b.id, action: 'PAYMENT_RECEIPT_DOCUMENT_CHECKED' } }));
assert.equal((await prisma.paymentReceiptVerification.findUniqueOrThrow({ where: { id: old.id } })).normalized_encrypted, old.normalized_encrypted);
await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await balance(s)).outstanding, '490.00');
}
checks.push('receipt_legacy_matched_requires_versioned_local_inspection_without_cloud_retransmission');
checks.push('receipt_local_inspection_audit_failure_rolls_back_new_version_and_evidence');
{
const s = await setup(); const b = await batch(s); const reference = `RP-OLD-MANUAL-${randomUUID()}`;
const rr = await receipt(b.id, null, reference, { payeeAccount: s.lender.bankAccount });
const old = await legacyVerification(rr.attachment.id); const id = randomUUID();
const crypto = req('./src/services/crypto');
const oldFields = Object.entries({ 小写金额: '10.00', 币别: '人民币', 付款人账号: '8'.repeat(16),
收款人账号: s.lender.bankAccount, 凭证号: reference, 单据状态: '交易成功' }).map(([Name, Value]) => ({ Name, Value }));
// Represent a historically approved proposal without new document fields;
// current HTTP approval must never manufacture this obsolete evidence.
const saved = await prisma.$transaction(async tx => {
const storedBatch = await tx.paymentBatch.findUniqueOrThrow({ where: { id: b.id }, include: { items: true } });
const context = await req('./src/services/loanApprovalPolicy').decisionFields(tx, storedBatch, manager.id,
{ targetId: id, conflictIds: [finance.id] });
const p = await tx.paymentReceiptManualProposal.create({ data: { id, attachment_id: rr.attachment.id,
original_verification_id: old.id, source_sha256: old.source_sha256, source_version: old.source_version + 1,
status: 'approved', proposed_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), ...context,
payload_encrypted: crypto.encryptStringOrThrow(config, JSON.stringify({ pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: oldFields }],
reason: '合成旧策略人工核验', changedFields: [{ pageNumber: 1, fields: ['amount'] }] })),
review_note_encrypted: crypto.encryptStringOrThrow(config, '合成旧策略公司确认') } });
await tx.paymentBatchReceiptAttachment.update({ where: { id: rr.attachment.id },
data: { receipt_ocr_version: old.source_version + 1, receipt_ocr_state: 'manual_approved' } });
return p;
});
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}`;
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
assert.equal((await balance(s)).outstanding, '500.00');
assert.equal((await call(finance, 'GET', `${endpoint}/manual-verification`)).item.documentInspectionRequired, true);
const checked = (await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: saved.source_version })).item;
assert.equal(checked.status, 'needs_review');
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
const renewed = (await call(finance, 'POST', `${endpoint}/manual-verification`, { expectedVersion: checked.version,
reason: '合成本地更新后重新核对原件金额', pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: [{ Name: '小写金额', Value: '10.00' }] }] })).item;
await call(manager, 'POST', `${endpoint}/manual-verification/${renewed.id}/review`, { expectedVersion: renewed.version,
decision: 'approve', reason: '合成公司代表重新确认本轮依据' });
await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await prisma.paymentReceiptManualProposal.findUniqueOrThrow({ where: { id: saved.id } })).payload_encrypted, saved.payload_encrypted);
assert.equal((await prisma.paymentReceiptClaim.findFirstOrThrow({ where: { item_id: b.items[0].id } })).manual_proposal_id, renewed.id);
assert.equal((await balance(s)).outstanding, '490.00');
}
checks.push('receipt_old_manual_approval_needs_reinspection_and_new_confirmation_without_rewriting_history');
{
const s = await setup(); const a = await batch(s); await batch(s);
await receipt(a.id, '1000', `RP-ATTR-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
@@ -407,9 +545,31 @@ module.exports = async function relatedFixture(h) {
const state = await balance(s);
assert.equal(state.outstanding, '100.00'); assert.equal(state.reserved, kind === 'release' && !stopFirst ? '0.00' : '10.00');
}
for (const checkFirst of [true, false]) {
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-DOCUMENT-RACE-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
const old = await legacyVerification(rr.attachment.id);
const check = () => app.inject({ method: 'POST', url: `/api${base(b)}/receipt-attachments/${rr.attachment.id}/document-check`,
headers: finance.headers, payload: { expectedVersion: old.source_version } });
const execute = () => app.inject({ method: 'POST', url: `/api${base(b)}/execute`, headers: finance.headers, payload: {} });
const holder = await locks.connect(); let checked; let paid;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [b.id]);
if (checkFirst) { checked = check(); await waitFor(1); paid = execute(); }
else { paid = execute(); await waitFor(1); checked = check(); }
await waitFor(2); await holder.query('COMMIT');
const results = await Promise.all([checked, paid]);
assert.equal(results[0].statusCode, 200, 'local reinspection serializes with settlement');
assert.equal(results[1].statusCode, checkFirst ? 200 : 409, 'settlement uses evidence available at its turn');
assert.equal((await balance(s)).reserved, checkFirst ? '0.00' : '10.00');
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
if (!checkFirst) await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: b.items[0].id } }), 1);
}
} finally { await locks.end(); }
}
checks.push('rp_authority_stop_serializes_with_approval_export_fact_and_release_both_orders');
checks.push('receipt_local_inspection_and_final_settlement_serialize_in_both_orders');
}
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) {
const s = await setup('100.00', null);