文件
hyb-oa/scripts/qa/company-loan-incoming-fixture.cjs
T
wangsli和Claude Opus 5.5 2063ff1e93
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
借款登记支持上传原借入回单识别预填(09-17-company-loan-principal-repayment)
- 新增识别与保存接口:可读 PDF 本地解析,必要时有界调用现有回单 OCR;预填上下文加密绑定经办人、文件和 30 分钟
- 适配建行单位客户专用回单表格版式:按位置归行排序、竖排栏头、顶行无标签日期、大小写金额
- 拦截非最终、失败/处理中、多笔交易(含跨页无关联、编号冲突)、非人民币、公司支出和其他收款主体
- 金额、日期、编号整段校验;借款与原件同事务保存,失败回滚并清理对象;同一出借人原件唯一
- 保存绑定所选出借人版本;前端冻结核对时版本,迟到结果与切换账号不覆盖、不重发
- 测试:单测、隔离 HTTP/浏览器、变异;对账浏览器场景固定 UTC;五轮独立复核通过
- 无新增迁移

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 10:34:40 +08:00

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 };
};