文件
hyb-oa/scripts/qa/company-loan-reconciliation-browser.cjs
T
wangsli和Claude Opus 5.5 3c5cc544f5 追回证据刷新期间禁用选择并修复员工资料登录误报(09-17-company-loan-principal-repayment)
- 上传银行证据后、刷新完成前,证据下拉框与确认按钮禁用并提示,避免空证据提交
- 员工来源版本去掉 updated_at(登录会刷新),保留全部真实资料字段的拦截
- 补登录误报回归、字段变更拦截、两项变异及刷新挂起的浏览器检查
- 任务记录 E 节与 AC52–55,独立只读复核无阻断项

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

249 行
18 KiB
JavaScript

const assert = require('node:assert/strict');
const path = require('node:path');
const { createRequire } = require('node:module');
const req = createRequire(path.resolve(__dirname, '../../client/package.json'));
const { expect } = req('@playwright/test');
// Synthetic fixtures only; every tested action below goes through the actual UI.
module.exports = async function reconciliationBrowser({ browser, origin, output, finance, manager, password, prisma, fixtures }) {
const checks = [];
for (const mobile of [false, true]) {
const mode = mobile ? 'mobile' : 'desktop';
let view;
let traceNumber = 0;
async function closeView() {
if (!view) return;
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund') {
await view.context.tracing.stop({ path: path.join(output, `${mode}-refund-${++traceNumber}.zip`) });
}
await view.context.close(); view = null;
}
const uploadedByKind = new Map();
async function login(who, batch) {
await closeView();
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();
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund') {
await context.tracing.start({ screenshots: true, snapshots: true });
}
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batch.id}`)}`);
await page.getByTestId('login-identifier').fill(who.email);
await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click();
await page.waitForURL(/\/payment-batches\?id=/);
const panel = page.getByRole('region', { name: '银行结果对账', exact: true });
await expect(panel).toBeVisible({ timeout: 20000 });
view = { context, page, panel };
}
async function action(name, fill, scope) {
await (scope ?? view.panel).getByRole('button', { name, exact: true }).click();
const dialog = view.page.getByRole('dialog');
if (fill) await fill(dialog);
if (await dialog.getByLabel('依据与说明').count()) await dialog.getByLabel('依据与说明').fill('合成银行材料唯一定位本批次并独立核对全部指令');
const invalid = await dialog.locator('input:invalid, select:invalid, textarea:invalid').evaluateAll(elements =>
elements.map(el => el.getAttribute('aria-label') ?? el.labels?.[0]?.textContent?.trim().slice(0, 80) ?? el.tagName));
assert.deepEqual(invalid, [], `synthetic UI ${name}: invalid form controls`);
await dialog.getByRole('button', { name: '确认', exact: true }).click();
try { await expect(dialog).not.toBeVisible(); }
catch {
await view.page.screenshot({ path: path.join(output, `${mode}-reconciliation-failure.png`), fullPage: true });
throw new Error(`synthetic UI ${name}: ${await dialog.getByRole('alert').allTextContents()}; submit=${await dialog.locator('button[type="submit"]').textContent()}`);
}
}
async function review(name = '独立复核银行事实') {
await action(name, d => d.getByLabel('复核结论').selectOption('approve'));
}
async function upload(kind) {
const uploaded = view.page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/loan-terminal-evidences') &&
response.request().method() === 'POST');
await action('上传银行证据', async d => {
await d.getByLabel('证据类别').selectOption(kind);
await d.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await fixtures.bankLetterPdf() });
});
const response = await uploaded; assert.equal(response.status(), 200);
uploadedByKind.set(kind, (await response.json()).data.id);
}
async function bankFields(d, s, amount, reference, incoming = false) {
const evidenceId = uploadedByKind.get(incoming ? 'incoming_return' : 'late_success');
assert.ok(evidenceId, 'use the specific uploaded document, never the placeholder or an older option');
await d.getByLabel('对应的成功银行回单').selectOption(evidenceId);
await expect(d.getByLabel('对应的成功银行回单')).toHaveValue(evidenceId);
await d.getByLabel('回单付款账号').fill(incoming ? s.payee : s.payer);
await d.getByLabel('回单收款账号').fill(incoming ? s.payer : s.payee);
await d.getByLabel('回单人民币金额').fill(amount);
await d.getByLabel('回单流水号').fill(reference);
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成官方查询逐一排除其他同金额付款和退款交易');
}
// The upload dialog closes before the batch detail refetch lands. Hold that
// refetch: evidence choices and submit stay disabled with a notice (no POST
// can leave with the placeholder) until the uploaded document is selectable.
async function uploadWithHeldRefresh(late, record = () => {}) {
const page = view.page;
const endpoint = `/api/payment-batches/${late.id}/loan-bank-results`;
const detail = url => new URL(url).pathname === `/api/payment-batches/${late.id}`;
let releaseRefresh;
const refreshGate = new Promise(resolve => { releaseRefresh = resolve; });
const started = page.waitForRequest(r => detail(r.url()) && r.method() === 'GET');
const slow = async route => { if (route.request().method() === 'GET') await refreshGate; await route.continue(); };
await page.route(detail, slow);
let submitted = 0;
const countPost = request => { if (new URL(request.url()).pathname === endpoint && request.method() === 'POST') submitted++; };
page.on('request', countPost);
try {
await upload('incoming_return'); await started;
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click();
const d = page.getByRole('dialog'); const select = d.getByLabel('对应的成功银行回单');
const confirm = d.getByRole('button', { name: '确认', exact: true });
assert.deepEqual(await select.locator('option').evaluateAll(os => os.map(o => o.value)), [''], 'the held refetch has not landed');
await expect(select).toBeDisabled(); await expect(confirm).toBeDisabled();
await expect(d.getByRole('status').filter({ hasText: '正在刷新证据列表' })).toBeVisible();
assert.equal(submitted, 0);
record('refresh_pending_choices_disabled', { optionCount: 1, selectDisabled: true, submitDisabled: true, postCount: submitted });
releaseRefresh();
const uploadedId = uploadedByKind.get('incoming_return');
await expect(select).toBeEnabled({ timeout: 20000 });
await expect(select.locator(`option[value="${uploadedId}"]`)).toBeAttached();
await expect(confirm).toBeEnabled(); assert.equal(submitted, 0);
await page.keyboard.press('Escape'); await expect(d).not.toBeVisible();
} finally { releaseRefresh(); page.off('request', countPost); await page.unroute(detail, slow); }
}
async function diagnoseRefund(late) {
const page = view.page;
const endpoint = `/api/payment-batches/${late.id}/loan-bank-results`;
const detail = url => new URL(url).pathname === `/api/payment-batches/${late.id}`;
const post = r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST';
const record = (step, extra = {}) => console.log(JSON.stringify({ stage: 'loan_refund_submission_diagnostic', mode, step, batchId: late.id, ...extra }));
await uploadWithHeldRefresh(late, record);
// A real business refusal is distinguishable from native validation and
// leaves a nonempty error tied to this exact POST and server request ID.
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click();
let d = page.getByRole('dialog');
await bankFields(d, late, '15.00', `${late.reference}-WRONG-DIRECTION`, true);
await d.getByLabel('回单付款账号').fill(late.payer); await d.getByLabel('回单收款账号').fill(late.payee);
await d.getByLabel('依据与说明').fill('合成错误方向拒绝诊断');
const refused = page.waitForResponse(post);
await d.getByRole('button', { name: '确认', exact: true }).click();
const refusal = await refused; assert.equal(refusal.status(), 409);
const safeError = await refusal.json(); assert.equal(safeError.error.details.reasonCode, 'loan.bank_result_reconcile_required'); assert.ok(safeError.requestId);
await expect(d.getByRole('alert')).toContainText(/\S/);
record('server_refused', { method: 'POST', status: 409, requestId: safeError.requestId, reasonCode: safeError.error.details.reasonCode });
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: late.id, kind: 'return' } }), 0);
await page.keyboard.press('Escape'); await expect(d).not.toBeVisible();
// Hold the registration request, then hold the refresh after a real 200.
// Database checks separate request pending from committed-but-stale UI.
let releasePost;
const postGate = new Promise(resolve => { releasePost = resolve; });
const eventRoute = url => new URL(url).pathname === endpoint;
const atPost = page.waitForRequest(r => eventRoute(r.url()) && r.method() === 'POST');
const delayedPost = async route => { await postGate; await route.continue(); };
await page.route(eventRoute, delayedPost);
let releaseAfter;
const afterGate = new Promise(resolve => { releaseAfter = resolve; });
const atAfter = page.waitForRequest(r => detail(r.url()) && r.method() === 'GET');
const delayedAfter = async route => { if (route.request().method() === 'GET') await afterGate; await route.continue(); };
await page.route(detail, delayedAfter);
try {
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click(); d = page.getByRole('dialog');
await bankFields(d, late, '15.00', `${late.reference}-RETURN-15.00`, true); await d.getByLabel('依据与说明').fill('合成请求与成功后刷新延迟诊断');
const succeeded = page.waitForResponse(post);
await d.getByRole('button', { name: '确认', exact: true }).click(); await atPost;
await expect(d.getByRole('button', { name: '处理中…', exact: true })).toBeDisabled();
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: late.id, kind: 'return' } }), 0);
record('post_pending', { method: 'POST', eventCount: 0 });
releasePost(); const response = await succeeded; assert.equal(response.status(), 200);
const result = await response.json(); const event = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: result.data.id } });
assert.equal(event.evidence_id, uploadedByKind.get('incoming_return')); assert.equal(event.amount.toFixed(2), '15.00'); assert.equal(event.status, 'proposed');
const parent = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: event.parent_id } });
assert.equal(parent.payment_batch_id, late.id); assert.equal(parent.kind, 'late_success'); assert.equal(parent.status, 'booked');
await atAfter; await expect(d).not.toBeVisible();
record('post_200_refresh_pending', { method: 'POST', status: 200, requestId: result.requestId, eventId: event.id, evidenceId: event.evidence_id });
releaseAfter(); await expect(view.panel).toContainText(/追回到账 \d+ · ¥15\.00 · 待复核/);
record('refresh_rendered', { eventId: event.id });
} finally { releasePost(); releaseAfter(); await page.unroute(eventRoute, delayedPost); await page.unroute(detail, delayedAfter); }
}
try {
const failed = fixtures.failures[Number(mobile)];
await login(finance, failed);
await action('登记银行提交', async d => {
await d.getByLabel('使用的导出文件(手工录入可不选)').selectOption({ index: 1 });
await d.getByLabel('银行指令号').fill(failed.reference);
await d.getByLabel('实际提交时间').fill(new Date().toISOString().slice(0, 16));
});
await action('确认提交清单完整');
await expect(view.panel.getByRole('button', { name: '独立复核提交清单', exact: true })).toHaveCount(0);
await login(manager, failed); await review('独立复核提交清单');
await login(finance, failed);
await action('登记失败回单归属', async d => {
await d.getByLabel('对应银行提交').selectOption({ index: 1 });
await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 });
await d.getByLabel('排除其他候选批次的依据').fill('合成银行指令唯一定位本批,其他同额批次对应另一指令');
});
await expect(view.panel.getByRole('button', { name: '复核失败归属', exact: true })).toHaveCount(0);
await login(manager, failed); await expect(view.panel).toContainText(failed.reference); await review('复核失败归属');
await login(finance, failed);
await action('登记银行终态事实', async d => {
await d.getByLabel('对应银行提交').selectOption({ index: 1 });
await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 });
});
await login(manager, failed); await review('独立复核终态事实'); await action('确认银行未付款');
await login(finance, failed); await action('重验依据并取消释放');
const original = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: failed.repaymentId }, include: { allocations: true } });
assert.equal(original.bank_result_state, 'confirmed_not_paid');
assert.ok(original.allocations.every(a => a.reservation_state === 'released'));
await view.page.screenshot({ path: path.join(output, `${mode}-submission-failure-attribution.png`), fullPage: true });
checks.push(`${mode}_submission_inventory_failure_attribution_terminal_and_release_via_ui`);
const late = fixtures.late[Number(mobile)];
const batchCount = await prisma.paymentBatch.count();
await login(finance, late); await upload('late_success');
await action('登记晚到成功', d => bankFields(d, late, '80.00', late.reference));
await expect(view.panel).toContainText('该出借人暂停新还款');
await expect(view.panel.getByRole('button', { name: '独立复核银行事实', exact: true })).toHaveCount(0);
await login(manager, late);
await view.panel.getByRole('button', { name: '查看录入的银行事实', exact: true }).click();
let dialog = view.page.getByRole('dialog');
await dialog.getByLabel('依据与说明').fill('合成独立复核原件与人工输入的对应关系');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog.getByLabel('已授权查看的银行事实')).toContainText(late.reference);
await view.page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible();
await review(); await login(finance, late); await action('登记补记或追回');
await expect(view.panel).toContainText('待追回 ¥40.00');
for (const [amount, remaining] of [['15.00', '25.00'], ['25.00', '0.00']]) {
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund' && amount === '15.00') {
await diagnoseRefund(late);
} else {
if (amount === '15.00') await uploadWithHeldRefresh(late); else await upload('incoming_return');
await action('登记追回到账', d => bankFields(d, late, amount, `${late.reference}-RETURN-${amount}`, true));
}
await login(manager, late); await review(); await login(finance, late); await action('登记补记或追回');
await expect(view.panel).toContainText(`待追回 ¥${remaining}`);
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lenderId, status: 'active' } }), remaining === '0.00' ? 0 : 1);
}
assert.equal(await prisma.paymentBatch.count(), batchCount, 'factual entries never create payment batches');
await expect(view.panel.getByRole('button', { name: '登记追回到账', exact: true })).toHaveCount(0);
checks.push(`${mode}_late_success_details_independent_review_partial_and_full_return_via_ui`);
checks.push(`${mode}_evidence_choices_disabled_until_refresh_after_upload_via_ui`);
// Registering a duplicate remains an auditable false alarm; correction
// needs a bank document and independent decision, not a bare dismissal.
await upload('late_success'); await action('登记晚到成功', d => bankFields(d, late, '80.00', late.reference));
await upload('bank_terminal');
await login(manager, late);
await action('独立复核银行事实', async d => {
await d.getByLabel('复核结论').selectOption('reject');
const correction = d.getByLabel('误关联或重复登记的更正证据(晚到成功必填)');
await correction.selectOption({ index: (await correction.locator('option').count()) - 1 });
await d.getByLabel('关闭原因').selectOption('duplicate');
});
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lenderId, status: 'active' } }), 0);
await expect(view.panel).toContainText('已确认误报');
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
await view.page.screenshot({ path: path.join(output, `${mode}-late-return-and-dismissal.png`), fullPage: true });
checks.push(`${mode}_evidenced_duplicate_false_alarm_closed_by_independent_reviewer_via_ui`);
} finally { await closeView(); }
}
return checks;
};