借款登记支持上传原借入回单识别预填(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-10 10:34:40 +08:00
共同撰写人 Claude Opus 5.5
父节点 45737ccb9a
当前提交 2063ff1e93
共修改 22 个文件,包含 2056 行新增和 47 行删除
+7 -3
查看文件
@@ -79,7 +79,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) {
const received = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST');
await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n') });
buffer: Buffer.from(`%PDF-1.4\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) });
assert.equal((await received).status(), 200);
}
await card.getByRole('button', { name: '确认期初余额' }).click();
@@ -182,7 +182,9 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
for (const [index, batchId] of reconciliationUi.batches.entries()) {
const mobile = index === 1;
async function session(who) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
// Reconciliation forms fill UTC wall-clock times; pin the browser to UTC
// instead of inheriting the host timezone (fails 00:00–08:00 in CST).
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block', timezoneId: 'UTC' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) })));
@@ -236,7 +238,9 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
for (const recovery of [false, true]) for (const [index, batch] of (recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).entries()) {
const mobile = index === 1;
async function session(who) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
// Reconciliation forms fill UTC wall-clock times; pin the browser to UTC
// instead of inheriting the host timezone (fails 00:00–08:00 in CST).
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block', timezoneId: 'UTC' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) })));
+4 -3
查看文件
@@ -147,7 +147,7 @@ module.exports = async function employeeBrowser(h) {
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) {
const result = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST');
await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n') });
buffer: Buffer.from(`%PDF-1.4\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) });
assert.equal((await result).status(), 200);
}
await loanCard.getByRole('button', { name: '确认期初余额', exact: true }).click();
@@ -159,8 +159,9 @@ module.exports = async function employeeBrowser(h) {
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached();
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${lender.id}"]`).count()); i++) {
assert.ok(i < 20);
const response = page.waitForResponse(r => r.url().includes('/loan-lenders?page='));
await dialog.getByRole('button', { name: '下一页出借人', exact: true }).click(); await response;
// Await both together so a timeout reaches the catch below (and its screenshot).
await Promise.all([page.waitForResponse(r => r.url().includes('/loan-lenders?page=')),
dialog.getByRole('button', { name: '下一页出借人', exact: true }).click({ timeout: 30000 })]);
}
await dialog.getByLabel('授权出借人').selectOption(lender.id);
await dialog.getByRole('checkbox', { name: new RegExp(loan.loan_no) }).check();
+11
查看文件
@@ -111,6 +111,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required');
const material = Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n');
async function upload(kind, data = material, filename = 'synthetic.pdf', type = 'application/pdf', status = 200, loanId = loan.id) {
// An original borrow-in is unique per lender; each synthetic loan has its own.
if (kind === 'incoming' && data === material) data = Buffer.from(`%PDF-1.4\n% synthetic-incoming-${loanId}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`);
const boundary = `synthetic-${randomUUID()}`;
const payload = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]);
const response = await app.inject({ method: 'POST', url: `/api/company-loans/${loanId}/evidences/${kind}`,
@@ -416,6 +418,14 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
}
const employeeHarness = { prisma, pool, call, app, req, finance, manager, ordinary,
config, password, actor, company, bank, confirmedLoan, repay, exportOrder, receipt };
// Incoming-receipt scenarios add lenders; in a full run they go last so they
// cannot shift the pagination other browser scenarios rely on.
const runIncoming = () => require('./company-loan-incoming-fixture.cjs')({ ...employeeHarness, verifiedLender });
if (process.env.RUN_ISOLATED_LOAN_INCOMING_ONLY === '1') {
const incomingReceipt = await runIncoming();
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-incoming-browser.cjs')({ app, finance, otherFinance: await actor(['finance']), password, prisma, config, company, incomingReceipt });
return;
}
const employeeSources = await require('./company-loan-employee-fixture.cjs')(employeeHarness);
if (process.env.RUN_ISOLATED_LOAN_EMPLOYEE_MUTATIONS === '1') await require('./company-loan-employee-mutations.cjs')(
employeeHarness, require('./company-loan-employee-fixture.cjs'));
@@ -1391,6 +1401,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
}
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty, employeeSources });
}
await runIncoming();
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
} finally { await app.close(); await prisma.$disconnect(); }
};
@@ -0,0 +1,186 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const { createHash, randomUUID } = require('node:crypto');
const { mkdtempSync, mkdirSync } = require('node:fs');
const { createRequire } = require('node:module');
const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs');
const root = path.resolve(__dirname, '../..');
const clientRequire = createRequire(path.join(root, 'client/package.json'));
const { chromium, expect } = clientRequire('@playwright/test');
// N5-P desktop and mobile: upload once, check prefilled values, correct, save
// loan and original together; restricted files, late results and actor switches.
module.exports = async function incomingBrowser(h) {
const { app, finance, otherFinance, password, prisma, config, company, incomingReceipt } = h;
const lender = incomingReceipt.lender;
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
const base = path.join(root, 'artifacts/qa-flow/loan-incoming-prefill-20261009'); mkdirSync(base, { recursive: true });
const output = mkdtempSync(path.join(base, 'browser-'));
const oldOrigin = config.appOrigin; config.appOrigin = origin;
const checks = [];
const sha = (buffer) => createHash('sha256').update(buffer).digest('hex');
const pdf = (options = {}) => syntheticBankReceiptPdf({ direction: 'credit', status: 'booked', amount: '50,000.00',
reference: `UI-IN-${randomUUID().slice(0, 8)}`, bookedOn: '2025-03-14',
extraLines: [`付款人名称 ${lender.name}`, `收款人名称 ${company.name}`], ...options });
try {
for (const mode of ['desktop', 'mobile']) {
const mobile = mode === 'mobile'; const prefix = mobile ? '/mobile' : '';
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
try {
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${prefix}/company-loans`)}`);
await page.getByTestId('login-identifier').fill(finance.email); await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
const openDialog = async () => {
await page.getByRole('button', { name: '上传回单登记借入', exact: true }).click();
const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: '上传回单登记借入' })).toBeVisible();
return dialog;
};
const choose = async (dialog, buffer) => dialog.getByLabel('原借入回单', { exact: true })
.setInputFiles({ name: 'incoming.pdf', mimeType: 'application/pdf', buffer });
const recognized = () => page.waitForResponse(r => r.url().endsWith('/api/company-loans/incoming-receipt/recognize') && r.request().method() === 'POST');
// Normal path: prefilled values are checked, one corrected, saved with the original.
const reference = `UI-IN-${mode}-${randomUUID().slice(0, 6)}`.toUpperCase();
const file = await pdf({ reference });
let dialog = await openDialog();
const first = recognized(); await choose(dialog, file); assert.equal((await first).status(), 200);
await expect(dialog.getByLabel('原始本金', { exact: true })).toHaveValue('50000.00');
await expect(dialog.getByLabel('实际到账日期', { exact: true })).toHaveValue('2025-03-14');
await expect(dialog.getByLabel('原借入凭据唯一引用', { exact: true })).toHaveValue(reference);
await expect(dialog.getByLabel('出借人', { exact: true })).toHaveValue(lender.id);
await expect(dialog.getByLabel('回单识别结果')).toContainText('已从 PDF 文字读取');
await dialog.getByLabel('实际到账日期', { exact: true }).fill('2025-03-15');
await expect(dialog.getByText('已人工修改')).toBeVisible();
await dialog.getByLabel('核对日未还本金', { exact: true }).fill('30000.00');
await dialog.getByLabel('余额核对截止日', { exact: true }).fill('2026-01-01');
const saveButton = dialog.getByRole('button', { name: '保存借款及原件', exact: true });
await expect(saveButton).toBeDisabled();
await dialog.getByRole('checkbox', { name: '已查看完整原件,金额、日期和凭据号与原件一致' }).check();
await dialog.getByRole('checkbox', { name: '已核对这笔入账是向该出借人的借款,且收款主体为所选公司' }).check();
// The same actor's expired access token is refreshed once and the save completes.
let saveAttempts = 0;
const expireOnce = async route => {
saveAttempts++;
if (saveAttempts === 1) return route.fulfill({ status: 401, contentType: 'application/json',
body: JSON.stringify({ requestId: 'synthetic-expired-access', error: { code: 'AUTH_001', message: '合成过期令牌' } }) });
return route.continue();
};
await page.route('**/api/company-loans/from-incoming-receipt', expireOnce);
try {
const saved = page.waitForResponse(r => r.url().endsWith('/api/company-loans/from-incoming-receipt') && r.request().method() === 'POST' && r.status() === 200);
await saveButton.click(); await saved;
await expect(page.getByRole('dialog')).not.toBeVisible();
assert.equal(saveAttempts, 2, 'same-actor expiry keeps the normal single refresh retry');
} finally { await page.unroute('**/api/company-loans/from-incoming-receipt', expireOnce); }
const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference, lender_id: lender.id }, include: { evidences: true } });
assert.equal(loan.confirmation_status, 'unconfirmed');
assert.equal(loan.received_date.toISOString().slice(0, 10), '2025-03-15');
assert.deepEqual(loan.evidences.map(e => [e.kind, e.file_sha256]), [['incoming', sha(file)]]);
const card = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${loan.loan_no}`, exact: true }) });
await expect(card).toContainText('余额待核实');
await card.locator('summary').click(); await expect(card.getByRole('button', { name: 'incoming.pdf' })).toBeVisible();
const scroll = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
assert.ok(scroll <= 1, `${mode} has no horizontal overflow`);
await page.screenshot({ path: path.join(output, `incoming-${mode}-saved.png`), fullPage: true });
checks.push(`${mode}_upload_prefill_correct_same_actor_refresh_and_save_with_original`);
// A restricted document cannot be saved through this entry.
dialog = await openDialog();
const restricted = recognized(); await choose(dialog, await pdf({ final: false })); await restricted;
await expect(dialog.getByRole('alert')).toContainText('不能作为原借入回单');
await expect(dialog.getByRole('button', { name: '保存借款及原件', exact: true })).toBeDisabled();
await page.screenshot({ path: path.join(output, `incoming-${mode}-blocked.png`), fullPage: true });
// A late result for a replaced file never fills the new form.
let release; const gate = new Promise(resolve => { release = resolve; });
let held = 0;
const delay = async route => { held++; if (held === 1) await gate; return route.continue(); };
await page.route('**/api/company-loans/incoming-receipt/recognize', delay);
try {
const late = pdf({ amount: '11.00' }); const latest = pdf({ amount: '12.00' });
await choose(dialog, await late);
await expect.poll(() => held).toBe(1);
const second = recognized(); await choose(dialog, await latest); await second;
release();
await expect(dialog.getByLabel('原始本金', { exact: true })).toHaveValue('12.00');
await page.waitForTimeout(300);
await expect(dialog.getByLabel('原始本金', { exact: true })).toHaveValue('12.00');
} finally { release(); await page.unroute('**/api/company-loans/incoming-receipt/recognize', delay); }
checks.push(`${mode}_restricted_blocked_and_late_result_cannot_overwrite_new_file`);
// A refreshed lender list with a new version clears the checks; saving
// needs a fresh review and then carries the new version.
const versionRef = `UI-VER-${mode}-${randomUUID().slice(0, 6)}`.toUpperCase();
const versionFile = await pdf({ reference: versionRef, amount: '14.00' });
const fourth = recognized(); await choose(dialog, versionFile); await fourth;
await dialog.getByLabel('核对日未还本金', { exact: true }).fill('14.00');
await dialog.getByLabel('余额核对截止日', { exact: true }).fill('2026-01-01');
const originalBox = dialog.getByRole('checkbox', { name: '已查看完整原件,金额、日期和凭据号与原件一致' });
const natureBox = dialog.getByRole('checkbox', { name: '已核对这笔入账是向该出借人的借款,且收款主体为所选公司' });
await originalBox.check(); await natureBox.check();
const row = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
await prisma.loanLender.update({ where: { id: lender.id }, data: { account_version: { increment: 1 } } });
await page.waitForTimeout(5500);
const refetched = page.waitForResponse(r => new URL(r.url()).pathname === '/api/loan-lenders' && r.request().method() === 'GET');
await context.setOffline(true); await context.setOffline(false); await refetched;
await expect(dialog.getByText('所选出借人的资料已更新,请重新核对后再勾选确认。')).toBeVisible();
await expect(originalBox).not.toBeChecked(); await expect(natureBox).not.toBeChecked();
await expect(dialog.getByRole('button', { name: '保存借款及原件', exact: true })).toBeDisabled();
await originalBox.check(); await natureBox.check();
const versionSave = page.waitForResponse(r => r.url().endsWith('/api/company-loans/from-incoming-receipt') && r.request().method() === 'POST');
await dialog.getByRole('button', { name: '保存借款及原件', exact: true }).click();
const versionResponse = await versionSave;
assert.equal(versionResponse.status(), 200);
assert.equal(JSON.parse(versionResponse.request().postDataBuffer().toString('latin1').match(/name="payload"\r\n\r\n(.*?)\r\n/s)[1]).expectedLenderAccountVersion, row.account_version + 1);
assert.equal(await prisma.companyLoan.count({ where: { origin_reference: versionRef } }), 1);
await expect(page.getByRole('dialog')).not.toBeVisible();
checks.push(`${mode}_refreshed_lender_version_clears_checks_and_requires_new_review`);
dialog = await openDialog();
// A held save answered with a late 401 after a real actor switch is aborted, not replayed.
const switchRef = `UI-SWITCH-${mode}-${randomUUID().slice(0, 6)}`.toUpperCase();
const switchFile = await pdf({ reference: switchRef, amount: '13.00' });
const third = recognized(); await choose(dialog, switchFile); await third;
await dialog.getByLabel('核对日未还本金', { exact: true }).fill('13.00');
await dialog.getByLabel('余额核对截止日', { exact: true }).fill('2026-01-01');
await dialog.getByRole('checkbox', { name: '已查看完整原件,金额、日期和凭据号与原件一致' }).check();
await dialog.getByRole('checkbox', { name: '已核对这笔入账是向该出借人的借款,且收款主体为所选公司' }).check();
await page.evaluate(() => { window.__syntheticIncomingDocument = 'same-document'; });
const within = (to) => page.evaluate(url => { window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate')); }, to);
let releaseSave; const saveGate = new Promise(resolve => { releaseSave = resolve; });
let started; const holding = new Promise(resolve => { started = resolve; });
let attempts = 0; let firstRequest; let aborted = false;
const onFailed = request => { if (request === firstRequest) aborted = /aborted/i.test(request.failure()?.errorText ?? ''); };
const handler = async route => {
attempts++;
if (attempts > 1) return route.continue();
firstRequest = route.request(); started(); await saveGate;
await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late-401',
error: { code: 'AUTH_001', message: '合成旧会话迟到响应' } }) }).catch(() => {});
};
page.on('requestfailed', onFailed); await page.route('**/api/company-loans/from-incoming-receipt', handler);
try {
await dialog.getByRole('button', { name: '保存借款及原件', exact: true }).click(); await holding;
await within('/mobile/me');
await page.getByRole('button', { name: '退出登录', exact: true }).click();
await expect(page.getByTestId('login-submit')).toBeVisible();
await page.getByTestId('login-identifier').fill(otherFinance.email); await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click(); await expect(page.getByTestId('login-submit')).toHaveCount(0);
assert.equal(await page.evaluate(() => window.__syntheticIncomingDocument), 'same-document');
releaseSave();
await expect.poll(() => aborted, { message: 'leaving the form must abort its old save' }).toBe(true);
assert.equal(attempts, 1, 'a late 401 must never replay the old save as the new actor');
assert.equal(await prisma.companyLoan.count({ where: { origin_reference: switchRef } }), 0);
} finally { releaseSave(); page.off('requestfailed', onFailed); await page.unroute('**/api/company-loans/from-incoming-receipt', handler); }
checks.push(`${mode}_late_401_save_aborted_across_real_actor_switch`);
} catch (error) {
await page.screenshot({ path: path.join(output, `incoming-${mode}-failure.png`), fullPage: true, timeout: 5000 }).catch(() => {});
throw error;
} finally { await context.close(); }
}
} finally { config.appOrigin = oldOrigin; await browser.close(); }
console.log(JSON.stringify({ stage: 'loan_incoming_browser', status: 'PASS', checks, output: path.relative(root, output) }));
return checks;
};
@@ -0,0 +1,207 @@
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 };
};
+48 -3
查看文件
@@ -4,13 +4,13 @@ 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' } = {}) {
reference = 'SYNTHETIC-REFERENCE', bookedOn = '2026-10-01', extraLines = [] } = {}) {
const words = { success: '交易成功', failed: '交易失败', pending: '处理中', booked: '' };
return [final ? '中国建设银行单位客户专用回单' : '中国建设银行网上银行电子回执',
'合成公司测试回单 SYNTHETIC', `记账日期 ${bookedOn}`, direction === 'credit' ? '贷' : '借',
`金额 ${amount}`, `币别 ${currency}`, `付款人账号 ${payerAccount}`, `收款人账号 ${payeeAccount}`, `凭证号码 ${reference}`,
...(words[status] ? [`单据状态 ${words[status]}`] : []),
...(!final ? ['本回执不作为收、付款方交易的最终依据,正式回单请在交易成功后另打印。'] : [])].join('\n');
...(!final ? ['本回执不作为收、付款方交易的最终依据,正式回单请在交易成功后另打印。'] : []), ...extraLines].join('\n');
}
async function syntheticBankReceiptPdf({ pages = 1, tag = 'SYNTHETIC', ...options } = {}) {
@@ -39,8 +39,53 @@ endcmap CMapName currentdict /CMap defineresource pop end end`;
return Buffer.from(await doc.save());
}
// Synthetic replica of the CCB "单位客户专用回单" table layout (all values fake):
// out-of-order text items, stacked 付款人/收款人 column headers, a vertical copy
// note that also contains 收款人, an unlabelled header date and 大写/小写 amounts.
async function syntheticCcbIncomingPdf({ tag = 'SYNTHETIC-CCB', amount = '50,000.00', capital = '伍万元整',
date = ['2025', '03', '14'], serial = '123456789AB12CDEFG3', payerName = '合成甲', payerAccount = '6222000011112222',
payeeName = '合成公司', payeeAccount = '33050161000000000001', title = '单位客户专用回单',
direction = '贷', copyNote = '(第一联)(收款人回单)', headers = ['付款人', '收款人'], dateDy = 0, extraItems = [] } = {}) {
const { PDFDocument, PDFHexString, beginText, endText, setFontAndSize, moveText, showText } = req('pdf-lib');
const items = [
...(serial ? [['流水号:' + serial, 388, 785]] : []),
// dateDy models the small baseline jitter of real PDFs within one row; date: null omits it.
...(date ? [['年', 272, 785], ['月', 292, 785], ['日', 312, 785],
[date[0], 252, 785 + dateDy], [date[1], 282, 785 + dateDy], [date[2], 302, 785 + dateDy]] : []), ['币别:人民币', 40, 785],
['中国建设银行' + title, 172, 806],
...[...headers[0]].map((c, i) => [c, 29, 769 - i * 10]), ...[...headers[1]].map((c, i) => [c, 284, 769 - i * 10]),
['全', 52, 772], ['称', 72, 772], [payerName, 92, 772], ['全', 307, 772], ['称', 327, 772], [payeeName, 347, 772],
['账', 52, 759], ['号', 72, 759], [payerAccount, 92, 759], ['账', 307, 759], ['号', 327, 759], [payeeAccount, 347, 759],
['开户行', 52, 745], ['中国建设银行合成支行', 92, 745], ['开户行', 307, 745], ['中国建设银行合成支行', 347, 745],
...[...copyNote].map((c, i) => [c, 540, 751 - i * 10]),
['金额', 47, 731], ['(大写)人民币' + capital, 92, 731], ['(小写)¥' + amount, 347, 731],
['凭证种类', 37, 716], ['凭证号码', 293, 716], ['摘要', 37, 702], ['借款', 92, 702], [direction, 500, 690],
['打印时间:2026-10-08 10:00:00', 25, 596], ['本回单为合成测试回单,仅用于验证版式解析,已记账。', 25, 585], ...extraItems,
];
const doc = await PDFDocument.create(); doc.setTitle(tag);
const hex = (t) => Buffer.from(t, 'utf16le').swap16().toString('hex').toUpperCase();
const chars = [...new Set(items.map(([t]) => t).join(''))];
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
${Array.from({ length: Math.ceil(chars.length / 100) }, (_, i) => chars.slice(i * 100, i * 100 + 100))
.map((chunk) => `${chunk.length} beginbfchar\n${chunk.map((c) => `<${hex(c)}> <${hex(c)}>`).join('\n')}\nendbfchar`).join('\n')}
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 }));
const page = doc.addPage([595, 842]);
const name = page.node.newFontDictionary('SYN', font);
// Real receipts use roughly 5pt glyphs; larger text would run off the page.
for (const [text, x, y] of items) page.pushOperators(beginText(), setFontAndSize(name, 5), moveText(x, y), showText(PDFHexString.of(hex(text))), endText());
return Buffer.from(await doc.save());
}
function syntheticDocumentEvidence(options) {
return req('./src/services/paymentReceiptDocument').classifyReceiptText(syntheticReceiptText(options));
}
module.exports = { syntheticReceiptText, syntheticBankReceiptPdf, syntheticDocumentEvidence };
module.exports = { syntheticReceiptText, syntheticBankReceiptPdf, syntheticCcbIncomingPdf, syntheticDocumentEvidence };