完善付款回单凭证核验与本地重检(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: '合成人工核对',
|
||||
|
||||
@@ -4,6 +4,7 @@ const { mkdtempSync, mkdirSync, writeFileSync } = require('node:fs');
|
||||
const { createRequire } = require('node:module');
|
||||
const req = createRequire(path.resolve(__dirname, '../../client/package.json'));
|
||||
const { chromium, expect } = req('@playwright/test');
|
||||
const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs');
|
||||
|
||||
module.exports = async function relatedBrowser(h) {
|
||||
const { finance, manager, ordinary, password, prisma, relatedParty, config } = h;
|
||||
@@ -52,11 +53,15 @@ module.exports = async function relatedBrowser(h) {
|
||||
const response = await recon(p, '上传银行证据', async d => { await d.getByLabel('证据类别').selectOption(kind); await d.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await f.pdf() }); });
|
||||
return (await response.json()).data.id;
|
||||
}
|
||||
async function receiptUpload(p, b, cents, ref) {
|
||||
async function receiptUpload(p, b, cents, ref, overrides = {}, document = {}) {
|
||||
const response = p.waitForResponse(r => r.url().endsWith(`/payment-batches/${b.id}/receipt-attachments`) && r.request().method() === 'POST');
|
||||
await p.getByTestId('payment-batch-receipt-file-input').setInputFiles({ name: 'synthetic-receipt.pdf', mimeType: 'application/pdf', buffer: await f.pdf() });
|
||||
const status = overrides.transactionStatus === null ? 'booked' : overrides.transactionStatus ?? 'success';
|
||||
await p.getByTestId('payment-batch-receipt-file-input').setInputFiles({ name: 'synthetic-receipt.pdf', mimeType: 'application/pdf',
|
||||
buffer: await syntheticBankReceiptPdf({ status, tag: ref, reference: ref,
|
||||
amount: cents === null ? String(b.items[0].amount) : `${BigInt(cents) / 100n}.${String(BigInt(cents) % 100n).padStart(2, '0')}`,
|
||||
payerAccount: '8'.repeat(16), payeeAccount: s.lender.bankAccount, ...document }) });
|
||||
assert.equal((await response).status(), 200);
|
||||
const result = await f.processUploadedReceipt(cents, ref, { payeeAccount: s.lender.bankAccount });
|
||||
const result = await f.processUploadedReceipt(cents, ref, { payeeAccount: s.lender.bankAccount, ...overrides });
|
||||
await p.reload(); return result;
|
||||
}
|
||||
try {
|
||||
@@ -141,6 +146,47 @@ module.exports = async function relatedBrowser(h) {
|
||||
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
|
||||
checks.push(`${s.mode}_related_manual_receipt_confirmation_and_rebuilt_settlement`);
|
||||
|
||||
const booked = await f.batch(s, '18.00'); await go(fp, booked);
|
||||
assert.equal((await receiptUpload(fp, booked, '1800', `RP-UI-BOOKED-${s.mode}`, { transactionStatus: null })).status, 'needs_review');
|
||||
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
|
||||
d = fp.getByRole('dialog');
|
||||
await expect(d).toContainText('状态:未确认');
|
||||
await d.getByLabel('第1页对应付款明细').selectOption(booked.items[0].id);
|
||||
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled();
|
||||
await d.getByLabel('第1页付款凭证依据').selectOption('booked_debit');
|
||||
await d.getByLabel('第1页原件记账日期').fill('2026-10-01');
|
||||
await d.getByRole('button', { name: '更正识别', exact: true }).click();
|
||||
await expect(d.getByLabel('第1页更正单据状态')).toHaveCount(0);
|
||||
await d.getByLabel('更正说明(仅关联可留空)').fill('合成正式回单为本公司借方记账,未记载成功状态字样');
|
||||
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled();
|
||||
await d.getByRole('checkbox', { name: /已查看第1页完整原件/ }).check();
|
||||
await submit(fp, undefined, '保存核验');
|
||||
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
|
||||
await expect(fp.getByRole('dialog').getByRole('button', { name: '公司代表确认', exact: true })).toHaveCount(0);
|
||||
await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
|
||||
await expect(fp.getByTestId('payment-batch-action-execute')).toHaveCount(0);
|
||||
await go(mp, booked); await mp.getByRole('button', { name: '核验详情', exact: true }).click();
|
||||
d = mp.getByRole('dialog');
|
||||
await expect(d).toContainText('已记账支出(人工核对)'); await expect(d).toContainText('记账日期 2026-10-01');
|
||||
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toHaveCount(0);
|
||||
assert.equal(await mp.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
|
||||
await mp.screenshot({ path: path.join(output, `related-${s.mode}-booked-proof.png`), fullPage: true });
|
||||
await submit(mp, current => current.getByLabel('复核说明').fill('合成代表公司核对原件借方记账日期与付款明细'), '公司代表确认');
|
||||
await go(fp, booked); await expect(paymentButton(fp, 'execute')).toBeEnabled();
|
||||
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
|
||||
assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: booked.loanRepaymentId } })).reservation_state, 'settled');
|
||||
checks.push(`${s.mode}_booked_receipt_proof_dialog_company_confirmation_and_settlement`);
|
||||
|
||||
const nonfinal = await f.batch(s, '12.00'); await go(fp, nonfinal);
|
||||
assert.equal((await receiptUpload(fp, nonfinal, '1200', `RP-UI-NONFINAL-${s.mode}`, {}, { final: false })).status, 'needs_review');
|
||||
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
|
||||
d = fp.getByRole('dialog'); await expect(d).toContainText('非最终回执');
|
||||
await expect(d.getByRole('button', { name: '更正识别', exact: true })).toHaveCount(0);
|
||||
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled();
|
||||
await d.getByRole('button', { name: '关闭', exact: true }).click();
|
||||
await expect(fp.getByTestId('payment-batch-action-execute')).toHaveCount(0);
|
||||
checks.push(`${s.mode}_nonfinal_receipt_has_no_manual_override_or_settlement`);
|
||||
|
||||
const failed = await f.batch(s, '7.00'); await f.batch(s, '7.00'); const ref = `RP-UI-FAIL-${s.mode}`.toUpperCase();
|
||||
await f.receipt(failed.id, '700', ref, { payeeAccount: s.lender.bankAccount, transactionStatus: 'failed' });
|
||||
await go(fp, failed);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const { createRequire } = require('node:module');
|
||||
const path = require('node:path');
|
||||
const req = createRequire(path.resolve(__dirname, '../../server/package.json'));
|
||||
|
||||
function syntheticReceiptText({ status = 'success', final = true, direction = 'debit', amount = '10.00',
|
||||
currency = '人民币', payerAccount = '000011112222', payeeAccount = '000033334444',
|
||||
reference = 'SYNTHETIC-REFERENCE', bookedOn = '2026-10-01' } = {}) {
|
||||
const words = { success: '交易成功', failed: '交易失败', pending: '处理中', booked: '' };
|
||||
return [final ? '中国建设银行单位客户专用回单' : '中国建设银行网上银行电子回执',
|
||||
'合成公司测试回单 SYNTHETIC', `记账日期 ${bookedOn}`, direction === 'credit' ? '贷' : '借',
|
||||
`金额 ${amount}`, `币别 ${currency}`, `付款人账号 ${payerAccount}`, `收款人账号 ${payeeAccount}`, `凭证号码 ${reference}`,
|
||||
...(words[status] ? [`单据状态 ${words[status]}`] : []),
|
||||
...(!final ? ['本回执不作为收、付款方交易的最终依据,正式回单请在交易成功后另打印。'] : [])].join('\n');
|
||||
}
|
||||
|
||||
async function syntheticBankReceiptPdf({ pages = 1, tag = 'SYNTHETIC', ...options } = {}) {
|
||||
const { PDFDocument, PDFHexString, beginText, endText, setFontAndSize, moveText, showText } = req('pdf-lib');
|
||||
const doc = await PDFDocument.create(); doc.setTitle(tag);
|
||||
const text = syntheticReceiptText(options);
|
||||
const hex = (s) => Buffer.from(s, 'utf16le').swap16().toString('hex').toUpperCase();
|
||||
const codes = [...new Set([...text.replace(/\n/g, '')])].map((c) => `<${hex(c)}> <${hex(c)}>`);
|
||||
const cmap = `/CIDInit /ProcSet findresource begin 12 dict begin begincmap
|
||||
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
|
||||
/CMapName /Synthetic-UCS def /CMapType 2 def
|
||||
1 begincodespacerange <0000> <FFFF> endcodespacerange
|
||||
${codes.length} beginbfchar\n${codes.join('\n')}\nendbfchar
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end`;
|
||||
const toUnicode = doc.context.register(doc.context.flateStream(cmap));
|
||||
const cid = doc.context.register(doc.context.obj({ Type: 'Font', Subtype: 'CIDFontType0', BaseFont: 'STSong-Light',
|
||||
CIDSystemInfo: { Registry: PDFHexString.fromText('Adobe'), Ordering: PDFHexString.fromText('GB1'), Supplement: 4 }, DW: 1000 }));
|
||||
const font = doc.context.register(doc.context.obj({ Type: 'Font', Subtype: 'Type0', BaseFont: 'STSong-Light',
|
||||
Encoding: 'Identity-H', DescendantFonts: [cid], ToUnicode: toUnicode }));
|
||||
for (let n = 0; n < pages; n++) {
|
||||
const page = doc.addPage([595, 842]);
|
||||
const name = page.node.newFontDictionary('SYN', font);
|
||||
text.split('\n').forEach((line, index) => page.pushOperators(beginText(), setFontAndSize(name, 10),
|
||||
moveText(30, 790 - index * 22), showText(PDFHexString.of(hex(line))), endText()));
|
||||
}
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
|
||||
function syntheticDocumentEvidence(options) {
|
||||
return req('./src/services/paymentReceiptDocument').classifyReceiptText(syntheticReceiptText(options));
|
||||
}
|
||||
|
||||
module.exports = { syntheticReceiptText, syntheticBankReceiptPdf, syntheticDocumentEvidence };
|
||||
@@ -5,6 +5,7 @@ const { readFileSync, readdirSync } = require("node:fs");
|
||||
const { createRequire } = require("node:module");
|
||||
const path = require("node:path");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
const { syntheticDocumentEvidence } = require("./synthetic-bank-receipt.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const serverRequire = createRequire(path.join(root, "server/package.json"));
|
||||
@@ -63,7 +64,7 @@ async function main() {
|
||||
const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({
|
||||
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: reference,
|
||||
}).map(([Name, Value]) => ({ Name, Value }))) };
|
||||
}).map(([Name, Value]) => ({ Name, Value }))), document: syntheticDocumentEvidence() };
|
||||
const batch = await prisma.paymentBatch.create({ data: {
|
||||
batch_no: `SYN-${randomUUID()}`, entity_type: "reimbursement", name: "synthetic", status: "reviewed",
|
||||
created_by: employee.id,
|
||||
|
||||
在新工单中引用
屏蔽一个用户