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