- 新增识别与保存接口:可读 PDF 本地解析,必要时有界调用现有回单 OCR;预填上下文加密绑定经办人、文件和 30 分钟 - 适配建行单位客户专用回单表格版式:按位置归行排序、竖排栏头、顶行无标签日期、大小写金额 - 拦截非最终、失败/处理中、多笔交易(含跨页无关联、编号冲突)、非人民币、公司支出和其他收款主体 - 金额、日期、编号整段校验;借款与原件同事务保存,失败回滚并清理对象;同一出借人原件唯一 - 保存绑定所选出借人版本;前端冻结核对时版本,迟到结果与切换账号不覆盖、不重发 - 测试:单测、隔离 HTTP/浏览器、变异;对账浏览器场景固定 UTC;五轮独立复核通过 - 无新增迁移 Co-Authored-By: Claude Opus 5.5 <[email protected]>
208 行
16 KiB
JavaScript
208 行
16 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { randomUUID, createHash } = require('node:crypto');
|
|
const { existsSync, readdirSync } = require('node:fs');
|
|
const path = require('node:path');
|
|
const { syntheticBankReceiptPdf, syntheticCcbIncomingPdf } = require('./synthetic-bank-receipt.cjs');
|
|
|
|
// N5-P: original borrow-in receipt prefill. Synthetic PDFs only; the provider is
|
|
// unconfigured here, so scanned/unreadable files take the manual path.
|
|
module.exports = async function incomingReceiptFixture(h) {
|
|
const { prisma, pool, call, app, req, finance, manager, ordinary, config, actor, company, verifiedLender } = h;
|
|
const incoming = req('./src/services/loanIncomingReceipt');
|
|
const checks = [];
|
|
const sha = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
|
const storedOriginals = () => {
|
|
const dir = path.join(config.storageDir, 'company-loan-evidence', 'incoming');
|
|
return existsSync(dir) ? readdirSync(dir).length : 0;
|
|
};
|
|
function multipart(who, url, file, payload, { name = 'receipt.pdf', type = 'application/pdf' } = {}) {
|
|
const boundary = `synthetic-${randomUUID()}`;
|
|
const parts = [];
|
|
if (payload !== undefined) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="payload"\r\n\r\n${JSON.stringify(payload)}\r\n`));
|
|
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${type}\r\n\r\n`), file,
|
|
Buffer.from(`\r\n--${boundary}--\r\n`));
|
|
return app.inject({ method: 'POST', url: `/api${url}`, headers: { ...(who?.headers ?? {}), 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
payload: Buffer.concat(parts) });
|
|
}
|
|
async function expectStatus(promise, status, reason) {
|
|
const response = await promise;
|
|
assert.equal(response.statusCode, status, `${response.statusCode} ${response.json().error?.details?.reasonCode ?? ''}`);
|
|
if (reason) assert.equal(response.json().error?.details?.reasonCode, reason);
|
|
return response.json().data;
|
|
}
|
|
const recognize = (file, who = finance, status = 200, reason) =>
|
|
expectStatus(multipart(who, '/company-loans/incoming-receipt/recognize', file), status, reason);
|
|
const save = (file, payload, who = finance, status = 200, reason) =>
|
|
expectStatus(multipart(who, '/company-loans/from-incoming-receipt', file, payload), status, reason);
|
|
const receiptPdf = (lenderName, options = {}) => syntheticBankReceiptPdf({ direction: 'credit', status: 'booked',
|
|
amount: '50,000.00', reference: `SYN-IN-${randomUUID().slice(0, 8)}`, bookedOn: '2025-03-14',
|
|
extraLines: [`付款人名称 ${lenderName}`, `收款人名称 ${company.name}`], ...options });
|
|
|
|
const lender = await verifiedLender('合成借入回单出借人', null, '6');
|
|
const file = await receiptPdf(lender.name);
|
|
const before = { loans: await prisma.companyLoan.count(), evidences: await prisma.loanEvidence.count(), stored: storedOriginals() };
|
|
for (const [who, status] of [[null, 401], [manager, 403], [ordinary, 403]]) await recognize(file, who, status);
|
|
await recognize(Buffer.from('%PNG not a receipt'), finance, 400, 'loan.incoming_receipt_invalid');
|
|
await expectStatus(multipart(finance, '/company-loans/incoming-receipt/recognize', Buffer.alloc(5 * 1024 * 1024 + 10, 37)), 400);
|
|
const prefill = await recognize(file);
|
|
assert.deepEqual(prefill.blocks, []); assert.equal(prefill.ocr, 'not_needed'); assert.equal(prefill.direction, 'credit');
|
|
assert.deepEqual({ ...prefill.fields, originReference: undefined }, { originalPrincipal: '50000.00', receivedDate: '2025-03-14',
|
|
originReference: undefined, referenceKind: 'voucher', currency: 'CNY', payerName: lender.name,
|
|
payerAccountMasked: prefill.fields.payerAccountMasked, payeeName: company.name, payeeAccountMasked: prefill.fields.payeeAccountMasked });
|
|
assert.deepEqual(prefill.suggestions, { legalEntityId: company.id, lenderId: lender.id });
|
|
assert.ok(!JSON.stringify(prefill).includes('000011112222'), 'recognition returns masked accounts only');
|
|
assert.deepEqual({ loans: await prisma.companyLoan.count(), evidences: await prisma.loanEvidence.count(), stored: storedOriginals() }, before,
|
|
'recognition never creates a loan, an original or a stored object');
|
|
checks.push('incoming_recognition_prefill_masked_and_non_persistent');
|
|
|
|
const payload = { context: prefill.context, requestKey: randomUUID(), lenderId: lender.id,
|
|
expectedLenderIdentityVersion: lender.identityVersion, expectedLenderAccountVersion: lender.accountVersion,
|
|
originReference: prefill.fields.originReference, originalPrincipal: prefill.fields.originalPrincipal,
|
|
openingOutstandingPrincipal: '30000.00', receivedDate: '2025-03-15', balanceAsOf: '2026-01-01', historicalEntry: true,
|
|
originalChecked: true, natureConfirmed: true };
|
|
for (const change of [{ originalChecked: false }, { natureConfirmed: undefined }, { confirmationStatus: 'confirmed' },
|
|
{ balanceAsOf: '2999-01-01' }, { openingOutstandingPrincipal: '60000.00' }]) {
|
|
await save(file, { ...payload, ...change }, finance, 400);
|
|
}
|
|
for (const who of [manager, ordinary]) await save(file, payload, who, 403);
|
|
const saved = await save(file, payload);
|
|
assert.equal(saved.created, true);
|
|
const loan = saved.item;
|
|
assert.equal(loan.confirmationStatus, 'unconfirmed'); assert.equal(loan.available, '0.00');
|
|
assert.equal(loan.receivedDate, '2025-03-15');
|
|
const originals = loan.evidences.filter((e) => e.kind === 'incoming');
|
|
assert.equal(originals.length, 1);
|
|
const row = await prisma.loanEvidence.findUniqueOrThrow({ where: { id: originals[0].id } });
|
|
assert.equal(row.file_sha256, sha(file)); assert.equal(row.governance_status, 'approved');
|
|
const audit = await prisma.auditLog.findFirstOrThrow({ where: { target_id: loan.id, action: 'COMPANY_LOAN_REGISTERED' } });
|
|
assert.equal(audit.extra.source, 'incoming_receipt');
|
|
assert.deepEqual(audit.extra.correctedFields, ['receivedDate']);
|
|
assert.ok(!JSON.stringify(audit.extra).includes('000011112222'), 'audit keeps no account or raw text');
|
|
assert.equal(await prisma.auditLog.count({ where: { target_id: loan.id, action: 'COMPANY_LOAN_EVIDENCE_UPLOADED' } }), 1);
|
|
assert.equal(storedOriginals(), before.stored + 1);
|
|
checks.push('incoming_save_creates_unconfirmed_loan_with_original_and_correction_audit');
|
|
|
|
const retried = await save(file, payload);
|
|
assert.equal(retried.created, false); assert.equal(retried.item.id, loan.id);
|
|
assert.equal(await prisma.loanEvidence.count({ where: { loan_id: loan.id } }), 1);
|
|
assert.equal(storedOriginals(), before.stored + 1, 'an idempotent retry removes its own new object');
|
|
const otherFile = await receiptPdf(lender.name, { amount: '10.00' });
|
|
const otherPrefill = await recognize(otherFile);
|
|
await save(otherFile, { ...payload, context: otherPrefill.context }, finance, 409, 'loan.duplicate_registration');
|
|
await save(file, { ...payload, requestKey: randomUUID(), originReference: '合成另一引用' }, finance, 409, 'loan.incoming_receipt_duplicate');
|
|
const manualLoan = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成手工登记',
|
|
originalPrincipal: '10.00', openingOutstandingPrincipal: '10.00', receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true });
|
|
await expectStatus(multipart(finance, `/company-loans/${manualLoan.id}/evidences/incoming`, file), 409, 'loan.incoming_receipt_duplicate');
|
|
assert.equal(storedOriginals(), before.stored + 1);
|
|
checks.push('incoming_retry_request_key_and_cross_entry_original_duplicates');
|
|
|
|
const otherFinance = await actor(['finance']);
|
|
await save(file, { ...payload, requestKey: randomUUID() }, otherFinance, 409, 'loan.incoming_receipt_context_invalid');
|
|
await save(otherFile, { ...payload, requestKey: randomUUID() }, finance, 409, 'loan.incoming_receipt_context_invalid');
|
|
await save(file, { ...payload, requestKey: randomUUID(), context: `${prefill.context.slice(0, -6)}AAAAAA` }, finance, 409, 'loan.incoming_receipt_context_invalid');
|
|
const expired = incoming.sealContext(config, { v: 1, purpose: incoming.PURPOSE, parserVersion: incoming.PARSER_VERSION,
|
|
actorId: finance.id, fileSha256: sha(otherFile), issuedAt: Date.now() - 3_600_000, expiresAt: Date.now() - 1000, fields: {}, blocks: [] });
|
|
await save(otherFile, { ...payload, requestKey: randomUUID(), originReference: '合成过期', context: expired }, finance, 409, 'loan.incoming_receipt_context_invalid');
|
|
// The lender checked in the form must still be the same version at save time.
|
|
const changedFile = await receiptPdf(lender.name, { amount: '15.00' });
|
|
const changedPrefill = await recognize(changedFile);
|
|
const changedInput = { ...payload, requestKey: randomUUID(), context: changedPrefill.context, originReference: '合成出借人已变更',
|
|
originalPrincipal: '15.00', openingOutstandingPrincipal: '15.00' };
|
|
await prisma.loanLender.update({ where: { id: lender.id }, data: { account_version: { increment: 1 } } });
|
|
try {
|
|
await save(changedFile, changedInput, finance, 409, 'loan.version_changed');
|
|
await save(changedFile, { ...changedInput, expectedLenderAccountVersion: lender.accountVersion + 1, expectedLenderIdentityVersion: lender.identityVersion + 1 },
|
|
finance, 409, 'loan.version_changed');
|
|
} finally { await prisma.loanLender.update({ where: { id: lender.id }, data: { account_version: lender.accountVersion } }); }
|
|
assert.equal(await prisma.companyLoan.count({ where: { origin_reference: '合成出借人已变更' } }), 0);
|
|
checks.push('incoming_context_bound_to_actor_file_integrity_lifetime_and_lender_version');
|
|
|
|
for (const [options, block] of [[{ final: false }, 'non_final'], [{ status: 'failed' }, 'transaction_failed'],
|
|
[{ currency: 'USD' }, 'non_cny']]) {
|
|
const restricted = await receiptPdf(lender.name, options);
|
|
const result = await recognize(restricted);
|
|
assert.ok(result.blocks.includes(block), `${block} reported`);
|
|
await save(restricted, { ...payload, requestKey: randomUUID(), originReference: `合成受限-${block}`, context: result.context }, finance, 409, 'loan.incoming_receipt_blocked');
|
|
}
|
|
// Company expense has no payee line, so only the expense rule can refuse it.
|
|
for (const extraLines of [[`付款人名称 ${company.name}`], [`付款人名称 ${lender.name}`, '收款人名称 合成另一收款主体']]) {
|
|
const misdirected = await receiptPdf(lender.name, { direction: extraLines[0].includes(company.name) ? 'debit' : 'credit', extraLines });
|
|
const result = await recognize(misdirected);
|
|
await save(misdirected, { ...payload, requestKey: randomUUID(), originReference: `合成方向-${randomUUID().slice(0, 6)}`, context: result.context },
|
|
finance, 409, 'loan.incoming_receipt_blocked');
|
|
}
|
|
assert.equal(storedOriginals(), before.stored + 1, 'refused saves leave no stored object');
|
|
checks.push('incoming_non_final_failed_foreign_currency_company_expense_and_other_payee_blocked');
|
|
|
|
const { PDFDocument } = req('pdf-lib');
|
|
const blankDoc = await PDFDocument.create(); blankDoc.addPage(); blankDoc.setTitle(`synthetic-scan-${randomUUID()}`);
|
|
const scanned = Buffer.from(await blankDoc.save());
|
|
const manual = await recognize(scanned);
|
|
assert.equal(manual.ocr, 'unavailable');
|
|
assert.deepEqual(manual.fieldIssues, { amount: 'missing', receivedDate: 'missing', reference: 'missing' });
|
|
assert.equal(manual.fields.originalPrincipal, null);
|
|
const manualSaved = await save(scanned, { ...payload, requestKey: randomUUID(), context: manual.context, originReference: '合成扫描人工补录',
|
|
originalPrincipal: '20.00', openingOutstandingPrincipal: '20.00', receivedDate: '2025-04-01' });
|
|
assert.equal(manualSaved.created, true);
|
|
assert.deepEqual((await prisma.auditLog.findFirstOrThrow({ where: { target_id: manualSaved.item.id, action: 'COMPANY_LOAN_REGISTERED' } })).extra.correctedFields, []);
|
|
checks.push('incoming_unreadable_without_provider_completes_by_manual_entry');
|
|
|
|
// The CCB table layout of the real borrow-in receipts (synthetic replica).
|
|
const ccbFile = await syntheticCcbIncomingPdf({ payerName: lender.name, payeeName: company.name, serial: `CCB${randomUUID().replace(/-/g, '').slice(0, 16)}`,
|
|
amount: '12,345.67', capital: '壹万贰仟叁佰肆拾伍元陆角柒分' });
|
|
const ccb = await recognize(ccbFile);
|
|
assert.deepEqual(ccb.blocks, []); assert.equal(ccb.ocr, 'not_needed'); assert.equal(ccb.direction, 'credit');
|
|
assert.equal(ccb.fields.originalPrincipal, '12345.67'); assert.equal(ccb.fields.receivedDate, '2025-03-14');
|
|
assert.equal(ccb.fields.referenceKind, 'serial'); assert.equal(ccb.fields.payerName, lender.name); assert.equal(ccb.fields.payeeName, company.name);
|
|
assert.deepEqual(ccb.suggestions, { legalEntityId: company.id, lenderId: lender.id });
|
|
const ccbSaved = await save(ccbFile, { ...payload, requestKey: randomUUID(), context: ccb.context, originReference: ccb.fields.originReference,
|
|
originalPrincipal: ccb.fields.originalPrincipal, openingOutstandingPrincipal: '12345.67', receivedDate: ccb.fields.receivedDate });
|
|
assert.equal(ccbSaved.created, true);
|
|
assert.deepEqual((await prisma.auditLog.findFirstOrThrow({ where: { target_id: ccbSaved.item.id, action: 'COMPANY_LOAN_REGISTERED' } })).extra.correctedFields, []);
|
|
checks.push('incoming_ccb_table_layout_prefills_suggests_parties_and_saves_without_provider');
|
|
|
|
// Real rollback: the second audit fails inside the transaction.
|
|
const rollbackFile = await receiptPdf(lender.name, { amount: '30.00' });
|
|
const rollbackPrefill = await recognize(rollbackFile);
|
|
await pool.query(`CREATE FUNCTION synthetic_fail_incoming_audit() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN RAISE EXCEPTION 'synthetic incoming audit failure'; END $$`);
|
|
await pool.query(`CREATE TRIGGER synthetic_fail_incoming_audit BEFORE INSERT ON audit_logs FOR EACH ROW
|
|
WHEN (NEW.action = 'COMPANY_LOAN_EVIDENCE_UPLOADED' AND NEW.extra->>'kind' = 'incoming') EXECUTE FUNCTION synthetic_fail_incoming_audit()`);
|
|
const rollbackKey = randomUUID();
|
|
try {
|
|
await save(rollbackFile, { ...payload, requestKey: rollbackKey, context: rollbackPrefill.context, originReference: '合成审计失败回滚',
|
|
originalPrincipal: '30.00', openingOutstandingPrincipal: '30.00' }, finance, 500);
|
|
} finally {
|
|
await pool.query('DROP TRIGGER synthetic_fail_incoming_audit ON audit_logs');
|
|
await pool.query('DROP FUNCTION synthetic_fail_incoming_audit()');
|
|
}
|
|
assert.equal(await prisma.companyLoan.count({ where: { request_key: rollbackKey } }), 0);
|
|
assert.equal(await prisma.loanEvidence.count({ where: { file_sha256: sha(rollbackFile) } }), 0);
|
|
assert.equal(storedOriginals(), before.stored + 3, 'failed transaction removes the object it wrote');
|
|
checks.push('incoming_audit_failure_rolls_back_loan_original_and_object');
|
|
|
|
// Same original, two new requests at once: the lender lock admits exactly one.
|
|
const raceFile = await receiptPdf(lender.name, { amount: '40.00' });
|
|
const racePrefill = await recognize(raceFile);
|
|
const race = await Promise.all(['甲', '乙'].map((tag) => multipart(finance, '/company-loans/from-incoming-receipt', raceFile, { ...payload,
|
|
requestKey: randomUUID(), context: racePrefill.context, originReference: `合成并发-${tag}`, originalPrincipal: '40.00', openingOutstandingPrincipal: '40.00' })));
|
|
assert.deepEqual(race.map((r) => r.statusCode).sort(), [200, 409]);
|
|
assert.equal(race.find((r) => r.statusCode === 409).json().error.details.reasonCode, 'loan.incoming_receipt_duplicate');
|
|
// Old upload and new save racing for one original also admit exactly one.
|
|
const crossFile = await receiptPdf(lender.name, { amount: '41.00' });
|
|
const crossPrefill = await recognize(crossFile);
|
|
const crossLoan = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成旧入口竞争',
|
|
originalPrincipal: '41.00', openingOutstandingPrincipal: '41.00', receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true });
|
|
const cross = await Promise.all([
|
|
multipart(finance, `/company-loans/${crossLoan.id}/evidences/incoming`, crossFile),
|
|
multipart(finance, '/company-loans/from-incoming-receipt', crossFile, { ...payload, requestKey: randomUUID(), context: crossPrefill.context,
|
|
originReference: '合成新入口竞争', originalPrincipal: '41.00', openingOutstandingPrincipal: '41.00' }),
|
|
]);
|
|
assert.deepEqual(cross.map((r) => r.statusCode).sort(), [200, 409]);
|
|
assert.equal(await prisma.loanEvidence.count({ where: { file_sha256: sha(crossFile), kind: 'incoming' } }), 1);
|
|
checks.push('incoming_concurrent_saves_and_old_upload_race_admit_one_original');
|
|
|
|
console.log(JSON.stringify({ stage: 'loan_incoming_receipt_http', status: 'PASS', checks }));
|
|
return { lender, file, loan };
|
|
};
|