文件
hyb-oa/scripts/qa/company-loan-browser.cjs
T
wangsli和Claude Opus 5.5 b88790d396 借款还款同金额回单须经独立复核确认归属后方可核销(09-17-company-loan-principal-repayment)
- 同一出借人同金额的其他批次曾导出或上传回单(含确认失败后已取消的)时,
  最终核销入口要求已复核的归属确认;OCR 匹配与人工回单复核均不能绕过,
  证据不足保持待对账,本金与占用不变;存在候选时失败回单不能释放占用
- 新表 loan_receipt_attributions(迁移 20261006010000):绑定交易指纹、
  回单核验与人工录入证据、当时候选集合;依据类型不含时间吻合;复核人须
  当前有资格且独立于登记人、批次创建人、提交人、导出人、回单上传人、
  人工录入人与出借员工;失效记录在重新登记时标为 superseded,历史只增不改
- 借款回单上传取出借人锁,与核销候选检查串行;串行只保证按当时已知候选
  判断,核销之后才出现的新证据留待晚到结果对账流程处理
- 付款就绪检查反映归属要求;页面登记/复核/失效重登,写明同额分期每期需确认
- 测试:两个错误核销复现改为回归断言;证据绑定、确定性并发两种顺序、
  PC 浏览器完整登记复核重登流程;变异测试均变红

注意:借款功能尚不具备真实还款验收条件(失败回单与对账流程、离职后确认
失败、真实回单样本、手机端弹窗、独立复核待完成);未推送、未部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 20:20:51 +08:00

172 行
12 KiB
JavaScript

const assert = require('node:assert/strict');
const { mkdtempSync } = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const root = path.resolve(__dirname, '../..');
const req = createRequire(path.join(root, 'client/package.json'));
const { chromium, expect } = req('@playwright/test');
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi }) {
const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-'));
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
const checks = [];
try {
for (const mobile of [false, true]) {
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();
const mode = mobile ? 'mobile' : 'desktop';
const target = mobile ? '/mobile/company-loans' : '/company-loans';
await page.goto(`${origin}/login?redirect=${encodeURIComponent(target)}`);
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();
await page.getByRole('button', { name: '登记出借人', exact: true }).click();
let dialog = page.getByRole('dialog');
await dialog.getByLabel('公司主体').selectOption(company.id);
const lenderName = `合成浏览器出借人-${mode}`;
await dialog.getByLabel('出借人姓名').fill(lenderName);
await dialog.getByLabel('收款银行').selectOption(bank.id);
await dialog.getByLabel('开户行名称').fill('合成建行');
await dialog.getByLabel('账户名', { exact: true }).fill(lenderName);
await dialog.getByLabel('收款账号', { exact: true }).fill('6'.repeat(16));
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await page.getByRole('tab', { name: '出借人', exact: true }).click();
const lenderCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) });
await lenderCard.getByRole('button', { name: '核实身份关联' }).click();
dialog = page.getByRole('dialog');
await dialog.getByLabel('出借人对应身份').selectOption('external');
await dialog.getByLabel('说明', { exact: true }).fill('合成浏览器非员工身份核实');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(lenderCard).toContainText('身份已核实');
await page.getByRole('button', { name: '登记借入', exact: true }).click();
dialog = page.getByRole('dialog');
const lender = await prisma.loanLender.findFirstOrThrow({ where: { name: lenderName } });
await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id);
const reference = `合成浏览器原借入-${mode}`;
await dialog.getByLabel('原借入凭据唯一引用').fill(reference);
await dialog.getByLabel('原始本金').fill('300.05');
await dialog.getByLabel('核对日未还本金').fill('300.05');
await dialog.getByLabel('实际到账日期').fill('2025-01-01');
await dialog.getByLabel('余额核对截止日').fill('2026-01-01');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await page.getByRole('tab', { name: '借款台账', exact: true }).click();
const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference } });
const card = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${loan.loan_no}`, exact: true }) });
await card.locator('summary').click();
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') });
assert.equal((await received).status(), 200);
}
await card.getByRole('button', { name: '确认期初余额' }).click();
dialog = page.getByRole('dialog');
await dialog.getByLabel('代账余额确认引用').fill('合成代账核对');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(card).toContainText('余额已确认');
await page.getByRole('button', { name: '申请还本', exact: true }).click();
dialog = page.getByRole('dialog');
await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id);
await dialog.getByLabel(`分配 ${reference}`, { exact: true }).fill('100.05');
await dialog.getByLabel('说明', { exact: true }).fill('合成浏览器部分还本');
await dialog.getByRole('button', { name: '提交还款申请', exact: true }).click();
await expect(page.getByText('原借款分配与会计交接', { exact: true })).toBeVisible();
await expect(page.getByText('可复核:', { exact: false }).first()).toBeVisible();
await expect(page.getByText('暂无企微个人消息发送成功记录', { exact: false }).first()).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1);
assert.equal(overflow, false, `${mode} horizontal overflow`);
await page.screenshot({ path: path.join(output, `${mode}-repayment.png`), fullPage: true });
checks.push(`${mode}_real_login_register_materials_confirm_submit_and_named_progress`);
await page.goBack();
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
await page.screenshot({ path: path.join(output, `${mode}-lenders-after-submit.png`), fullPage: true });
await page.getByRole('tab', { name: '出借人', exact: true }).click();
const frozenCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) });
await expect(frozenCard).toContainText('有还款正在办理');
await expect(frozenCard.getByRole('button', { name: '核实身份关联' })).toBeDisabled();
await expect(frozenCard.getByRole('button', { name: '更新收款资料' })).toBeDisabled();
checks.push(`${mode}_in_flight_repayment_disables_lender_maintenance`);
await context.close();
}
if (attributionUi) {
// Two separate sessions: finance registers, an independent manager reviews.
async function session(who) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`/payment-batches?id=${attributionUi.batchId}`)}`);
await page.getByTestId('login-identifier').fill(who.email);
await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click();
const panel = page.getByLabel('回单归属确认');
await expect(panel).toContainText('回单归属待确认');
return { context, page, panel };
}
async function register(page, panel) {
await panel.getByRole('button', { name: '登记回单归属依据' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('归属依据类型').selectOption('customer_serial');
await dialog.getByLabel('依据说明').fill('合成浏览器:回单客户方流水号与本批次制单流水号一致');
await dialog.getByLabel('排除其他候选批次的理由').fill('合成浏览器:其他候选批次的制单流水号均不一致');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(panel).toContainText('待独立复核');
}
async function approve(page, panel) {
await expect(panel).toContainText('待独立复核');
await panel.getByRole('button', { name: '独立复核回单归属' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('复核结论').selectOption('approve');
await dialog.getByLabel('复核说明').fill('合成浏览器独立复核');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(panel).toContainText('已通过独立复核');
}
// Each step uses a fresh login: a full reload drops the session in this harness.
let view = await session(finance);
await expect(view.panel.getByRole('button', { name: '独立复核回单归属' })).toHaveCount(0);
await register(view.page, view.panel);
await expect(view.panel.getByRole('button', { name: '独立复核回单归属' })).toHaveCount(0);
await view.context.close();
view = await session(manager);
await expect(view.panel.getByRole('button', { name: '登记回单归属依据' })).toHaveCount(0);
await approve(view.page, view.panel);
await view.context.close();
checks.push('desktop_attribution_register_and_independent_review');
// A new same-amount competitor reaches the bank: the approval is stale and
// finance can re-register, then the manager reviews again.
await attributionUi.addCompetitor();
view = await session(finance);
await expect(view.panel).toContainText('须重新登记并复核');
await register(view.page, view.panel);
await view.context.close();
view = await session(manager);
await approve(view.page, view.panel);
await view.page.screenshot({ path: path.join(output, 'desktop-attribution.png'), fullPage: true });
await view.context.close();
const settled = await attributionUi.execute();
assert.equal(settled.statusCode, 200, 'settles after UI re-registration and review');
checks.push('desktop_stale_attribution_reregistered_reviewed_and_settled');
}
const context = await browser.newContext({ serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
await page.goto(`${origin}/login?redirect=%2Fcompany-loans`);
await page.getByTestId('login-identifier').fill(ordinary.email);
await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click();
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).not.toBeVisible();
await page.waitForURL(/\/403$/);
checks.push('ordinary_employee_cannot_open_loan_ledger');
await context.close();
console.log(JSON.stringify({ stage: 'loan_browser', status: 'PASS', checks, output: path.relative(root, output) }));
} finally { await browser.close(); }
};