完善付款回单凭证核验与本地重检(09-17-company-loan-principal-repayment)
区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。 正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。 本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。 本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。 限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
这个提交包含在:
@@ -168,7 +168,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
// BankSlipOCR response is synthetic, built from the intended slip fields.
|
||||
const { recognizePaymentReceipt } = req('./src/services/paymentReceiptOcr');
|
||||
const { PDFDocument } = req('pdf-lib');
|
||||
const receiptPdf = async (pages = 1) => { const doc = await PDFDocument.create(); for (let i = 0; i < pages; i++) doc.addPage([200, 200]); return Buffer.from(await doc.save()); };
|
||||
const { syntheticBankReceiptPdf, syntheticDocumentEvidence } = require('./synthetic-bank-receipt.cjs');
|
||||
const receiptPdf = (pages = 1, options = {}) => syntheticBankReceiptPdf({ pages, ...options });
|
||||
const onePagePdf = await receiptPdf(1);
|
||||
const STATUS_WORDING = { success: '交易成功', failed: '交易失败', pending: '处理中' };
|
||||
function bankSlipFields(slip) {
|
||||
@@ -185,9 +186,17 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
async function receipt(batchId, amount, reference, overrides = {}, { pdfPages = 1, normalizedPage = false } = {}) {
|
||||
async function receipt(batchId, amount, reference, overrides = {}, { pdfPages = 1, normalizedPage = false, document = {} } = {}) {
|
||||
const boundary = `synthetic-${randomUUID()}`;
|
||||
const file = pdfPages === 1 ? onePagePdf : await receiptPdf(pdfPages);
|
||||
const status = overrides.transactionStatus === null ? 'booked' : overrides.transactionStatus ??
|
||||
(overrides.statusWordings?.some((value) => ['交易失败', '转账失败', '付款失败', '支付失败'].includes(value)) ? 'failed' : 'success');
|
||||
const minor = overrides.amount ?? amount;
|
||||
const storedBatch = minor === null ? await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batchId }, include: { items: true } }) : null;
|
||||
const displayedAmount = minor === null ? String(storedBatch.items[0].amount) :
|
||||
`${BigInt(minor) / 100n}.${String(BigInt(minor) % 100n).padStart(2, '0')}`;
|
||||
const file = await receiptPdf(pdfPages, { status, amount: displayedAmount, currency: overrides.currency ?? '人民币',
|
||||
payerAccount: overrides.payerAccount ?? payerAccount, payeeAccount: overrides.payeeAccount ?? bankAccount,
|
||||
reference: overrides.reference ?? reference ?? 'SYNTHETIC-UNREAD-REFERENCE', ...document });
|
||||
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`), file, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
||||
@@ -200,7 +209,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
// normalizedPage: one explicitly synthetic defence-in-depth case that the
|
||||
// real normalizer cannot produce; every other receipt uses the real chain.
|
||||
const recognize = normalizedPage
|
||||
? async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, issues: [], ...slip }] })
|
||||
? async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, issues: [], ...slip,
|
||||
document: syntheticDocumentEvidence({ status: slip.transactionStatus ?? 'booked' }) }] })
|
||||
: (args) => recognizePaymentReceipt({ ...args, createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: bankSlipFields(slip) }) }) });
|
||||
const result = await runReceiptOcrOnce({ prisma, config, recognize });
|
||||
return result;
|
||||
@@ -288,13 +298,15 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
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: [] });
|
||||
const unclear = await receipt(manual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] },
|
||||
{ document: { reference: 'SYNTHETIC-MANUAL' } });
|
||||
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', 单据状态: '交易成功',
|
||||
reason: '合成原件独立人工核对', pages: [{ pageNumber: 1, itemId: manual.items[0].id,
|
||||
paymentProof: { basis: 'booked_debit', originalChecked: true, bookedOn: '2026-10-01', note: '合成正式支出回单已记账,无成功状态原文' }, 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);
|
||||
@@ -495,7 +507,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
await call(reviewerB, 'POST', `/payment-batches/${viaManual.id}/review`, {});
|
||||
await exportOrder(viaManual.id);
|
||||
await setStatus(reviewerB, 'archived'); await setStatus(lenderEmployee, 'archived');
|
||||
const manualUnclear = await receipt(viaManual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
|
||||
const manualUnclear = await receipt(viaManual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] },
|
||||
{ document: { status: 'success', reference: 'SYNTHETIC-POST-PAY-MANUAL', payeeAccount: staffLender.bankAccount } });
|
||||
assert.equal(manualUnclear.result.status, 'needs_review');
|
||||
const manualAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: manualUnclear.attachment.id } });
|
||||
const manualPath = `/payment-batches/${viaManual.id}/receipt-attachments/${manualUnclear.attachment.id}/manual-verification`;
|
||||
@@ -1219,7 +1232,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
const c2manualBatch = await repay(probeLender.id, probeLoan.id, '6.00');
|
||||
await call(manager, 'POST', `/payment-batches/${c2manualBatch.id}/review`, {}); await exportOrder(c2manualBatch.id);
|
||||
const beforeManual = await probeBalance();
|
||||
const c2unclear = await receipt(c2manualBatch.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
|
||||
const c2unclear = await receipt(c2manualBatch.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] },
|
||||
{ document: { status: 'success', reference: 'SYNTHETIC-C2-MANUAL', payeeAccount: probeLender.bankAccount } });
|
||||
const c2attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: c2unclear.attachment.id } });
|
||||
const c2path = `/payment-batches/${c2manualBatch.id}/receipt-attachments/${c2unclear.attachment.id}/manual-verification`;
|
||||
const c2proposal = await call(dual, 'POST', c2path, { expectedVersion: c2attachment.receipt_ocr_version, reason: '合成人工核对',
|
||||
|
||||
在新工单中引用
屏蔽一个用户