const assert = require('node:assert/strict'); const path = require('node:path'); const { mkdtempSync, mkdirSync, writeFileSync } = require('node:fs'); const { createRequire } = require('node:module'); const req = createRequire(path.resolve(__dirname, '../../client/package.json')); const { chromium, expect } = req('@playwright/test'); const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs'); module.exports = async function relatedBrowser(h) { const { finance, manager, ordinary, password, prisma, relatedParty, config } = h; const own = !h.browser; const browser = h.browser ?? await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); const origin = h.origin ?? await h.app.listen({ host: '127.0.0.1', port: 0 }); const root = path.resolve(__dirname, '../..'); mkdirSync(path.join(root, 'artifacts/qa-flow/loan-related-20261008'), { recursive: true }); const output = h.output ?? mkdtempSync(path.join(root, 'artifacts/qa-flow/loan-related-20261008/browser-')); const f = relatedParty.helpers; const checks = []; const oldWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 0; const oldOrigin = config.appOrigin; config.appOrigin = origin; try { for (const s of relatedParty.ui) { const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : ''; const contexts = []; const technicalFinanceRequests = []; console.log(JSON.stringify({ stage: 'loan_related_browser_start', mode: s.mode })); async function login(who, route = `${prefix}/company-loans`) { const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); contexts.push(context); await context.route('**/*', r => r.request().url().startsWith(`${origin}/`) ? r.continue() : r.abort()); const p = await context.newPage(); if (who.id === ordinary.id) p.on('request', request => { const pathname = new URL(request.url()).pathname; if (/^\/api\/(company-loans|loan-lenders|loan-repayments|loan-related-authorizations)(\/|$)/.test(pathname)) technicalFinanceRequests.push(pathname); }); await p.goto(`${origin}/login?redirect=${encodeURIComponent(route)}`); await p.getByTestId('login-identifier').fill(who.email); await p.getByTestId('login-password').fill(password); await p.getByTestId('login-submit').click(); await p.waitForURL(url => !url.pathname.includes('login')); return p; } const go = async (p, b) => { await p.goto(`${origin}${prefix}/payment-batches?id=${b.id}`); await expect(p.getByLabel('关联方还款模式')).toBeVisible({ timeout: 20000 }); }; const paymentButton = (p, kind) => p.getByTestId(mobile ? 'payment-batch-mobile-actions' : 'payment-batch-action-panel').getByTestId(`payment-batch-action-${kind}`); async function submit(p, fill, confirm = '确认') { const d = p.getByRole('dialog'); if (fill) await fill(d); const response = p.waitForResponse(r => r.url().startsWith(`${origin}/api/`) && !r.url().includes('/api/auth/') && ['POST', 'PATCH'].includes(r.request().method())); await d.getByRole('button', { name: confirm, exact: true }).click(); const result = await response; if (!result.ok()) { const failure = (await result.json().catch(() => ({}))).error?.details?.reasonCode; throw new Error(`related ${s.mode} ${confirm}: HTTP ${result.status()} ${failure ?? ''}`); } try { await expect(d).not.toBeVisible({ timeout: 15000 }); } catch { throw new Error(`related ${s.mode} dialog: ${await d.getByRole('alert').allTextContents()}`); } return result; } async function recon(p, label, fill) { await p.getByRole('region', { name: '银行结果对账', exact: true }).getByRole('button', { name: label, exact: true }).click(); return submit(p, async d => { if (fill) await fill(d); if (await d.getByLabel('依据与说明').count()) await d.getByLabel('依据与说明').fill('合成材料唯一定位具体银行指令及全部候选批次'); }); } const review = (p, label) => recon(p, label, d => d.getByLabel('复核结论').selectOption('approve')); async function upload(p, kind) { const response = await recon(p, '上传银行证据', async d => { await d.getByLabel('证据类别').selectOption(kind); await d.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await f.pdf() }); }); return (await response.json()).data.id; } async function receiptUpload(p, b, cents, ref, overrides = {}, document = {}) { const response = p.waitForResponse(r => r.url().endsWith(`/payment-batches/${b.id}/receipt-attachments`) && r.request().method() === 'POST'); const status = overrides.transactionStatus === null ? 'booked' : overrides.transactionStatus ?? 'success'; await p.getByTestId('payment-batch-receipt-file-input').setInputFiles({ name: 'synthetic-receipt.pdf', mimeType: 'application/pdf', buffer: await syntheticBankReceiptPdf({ status, tag: ref, reference: ref, amount: cents === null ? String(b.items[0].amount) : `${BigInt(cents) / 100n}.${String(BigInt(cents) % 100n).padStart(2, '0')}`, payerAccount: '8'.repeat(16), payeeAccount: s.lender.bankAccount, ...document }) }); assert.equal((await response).status(), 200); const result = await f.processUploadedReceipt(cents, ref, { payeeAccount: s.lender.bankAccount, ...overrides }); await p.reload(); return result; } try { const fp = await login(finance); const mp = await login(manager); await fp.getByRole('tab', { name: '公司代表授权', exact: true }).click(); await fp.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click(); const dialog = fp.getByRole('dialog'); await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached(); for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${s.lender.id}"]`).count()); i++) { assert.ok(i < 12); await expect(dialog.getByRole('button', { name: '下一页出借人' })).toBeEnabled(); const response = fp.waitForResponse(r => r.url().includes('/loan-lenders?page=')); await dialog.getByRole('button', { name: '下一页出借人' }).click(); await response; } await dialog.getByLabel('授权出借人').selectOption(s.lender.id); await dialog.getByRole('checkbox', { name: new RegExp(s.loan.loanNo) }).check(); await submit(fp, d => d.getByLabel('代表权及授权范围依据').fill('合成已确认公司代表权,限定本公司与这笔借款')); const a = await prisma.loanRelatedAuthorization.findFirstOrThrow({ where: { lender_id: s.lender.id }, orderBy: { version: 'desc' } }); await expect(fp.getByRole('button', { name: '代表公司确认授权范围', exact: true })).toHaveCount(0); await mp.reload(); await expect(mp.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0); await expect(mp.getByRole('button', { name: '登记公司代表授权范围', exact: true })).toHaveCount(0); const card = mp.locator('section.card-std').filter({ hasText: s.lender.name }); await expect(card).toBeVisible(); await card.getByRole('button', { name: '代表公司确认授权范围' }).click(); await expect(mp.getByRole('dialog')).toContainText(s.loan.loanNo); await submit(mp, d => d.getByLabel('确认或停止的理由').fill('合成代表公司确认已登记范围与本人关联身份')); await fp.getByRole('tab', { name: '出借人', exact: true }).click(); const lenderCard = fp.locator('section.card-std').filter({ hasText: s.lender.name }); for (let page = 1; !(await lenderCard.count()); page++) { assert.ok(page < 20); const next = fp.getByRole('button', { name: '下一页', exact: true }); await expect(next).toBeEnabled(); const loaded = fp.waitForResponse(r => r.url().includes(`/loan-lenders?page=${page + 1}&`)); await next.click(); await loaded; await expect(fp.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0); } await fp.getByRole('button', { name: '申请还本', exact: true }).click(); const creation = fp.getByRole('dialog'); await creation.getByLabel('出借人', { exact: true }).selectOption(s.lender.id); await creation.getByLabel(`分配 ${s.loan.originReference}`, { exact: true }).fill('10.00'); await submit(fp, d => d.getByLabel('说明', { exact: true }).fill('合成关联方正常还本'), '提交还款申请'); await fp.waitForURL(/payment-batches\?id=/); const b = await prisma.paymentBatch.findFirstOrThrow({ where: { loan_repayment: { lender_id: s.lender.id } }, orderBy: { created_at: 'desc' }, include: { items: true } }); await go(mp, b); await expect(mp.getByTestId('payment-batch-action-downloadOrder')).toHaveCount(0); await mp.getByRole('button', { name: '查看完整账号', exact: true }).click(); await mp.getByRole('dialog').getByLabel('查看收款账号原因').fill('合成公司代表核对本笔收款账号'); await mp.getByRole('dialog').getByRole('button', { name: '确认查看', exact: true }).click(); await expect(mp.getByRole('dialog')).toContainText(s.lender.bankAccount); await mp.getByRole('dialog').getByRole('button', { name: '返回', exact: true }).click(); await paymentButton(mp, 'review').click(); await submit(mp, undefined, '代表公司确认'); await go(fp, b); await paymentButton(fp, 'downloadOrder').click(); const downloaded = fp.waitForEvent('download'); await submit(fp, undefined, '确认下载'); await downloaded; assert.equal((await receiptUpload(fp, b, '1000', `RP-UI-${s.mode}`)).status, 'matched'); await expect(paymentButton(fp, 'execute')).toBeEnabled({ timeout: 15000 }); await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行'); assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: b.loan_repayment_id } })).reservation_state, 'settled'); await expect(fp.getByLabel('关联方还款模式')).toBeVisible(); checks.push(`${s.mode}_related_scope_company_confirmation_payee_read_export_ocr_settlement`); const returned = await f.draft(s, '9.00'); await go(mp, returned); await mp.getByRole('button', { name: '更多', exact: true }).click(); await mp.getByRole('menuitem', { name: '退回付款申请', exact: true }).click(); await submit(mp, d => d.getByLabel('付款退回理由').fill('合成公司代表退回,请更正付款金额'), '确认退回'); await expect(mp.getByTestId('payment-batch-action-review')).toHaveCount(0); assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: returned.loanRepaymentId } })).reservation_state, 'reserved'); await go(fp, returned); await expect(fp.getByText('退回未释放占用,财务须取消原申请后重新提交。', { exact: false })).toBeVisible(); await fp.getByRole('button', { name: '更多', exact: true }).click(); await fp.getByRole('menuitem', { name: '取消批次', exact: true }).click(); await submit(fp, d => d.getByLabel('批次取消原因').fill('合成按公司代表退回要求取消后重新提交'), '确认取消'); assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: returned.loanRepaymentId } })).reservation_state, 'released'); checks.push(`${s.mode}_related_return_reason_finance_cancel_and_retained_decision`); const manual = await f.batch(s, '20.00'); await go(fp, manual); assert.equal((await receiptUpload(fp, manual, null, `RP-UI-MANUAL-${s.mode}`)).status, 'needs_review'); await fp.getByRole('button', { name: '核验详情', exact: true }).click(); let d = fp.getByRole('dialog'); await d.getByLabel('第1页对应付款明细').selectOption(manual.items[0].id); await d.getByRole('button', { name: '更正识别', exact: true }).click(); await d.getByLabel('第1页更正小写金额').fill('20.00'); await submit(fp, d => d.getByLabel('更正说明(仅关联可留空)').fill('合成回单原件金额清晰,人工补录'), '保存核验'); await go(mp, manual); await mp.getByRole('button', { name: '核验详情', exact: true }).click(); await expect(mp.getByRole('dialog').getByRole('button', { name: '保存核验' })).toHaveCount(0); await submit(mp, d => d.getByLabel('复核说明').fill('合成公司代表逐项核对回单原件'), '公司代表确认'); await go(fp, manual); await expect(paymentButton(fp, 'execute')).toBeEnabled(); await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行'); checks.push(`${s.mode}_related_manual_receipt_confirmation_and_rebuilt_settlement`); const booked = await f.batch(s, '18.00'); await go(fp, booked); assert.equal((await receiptUpload(fp, booked, '1800', `RP-UI-BOOKED-${s.mode}`, { transactionStatus: null })).status, 'needs_review'); await fp.getByRole('button', { name: '核验详情', exact: true }).click(); d = fp.getByRole('dialog'); await expect(d).toContainText('状态:未确认'); await d.getByLabel('第1页对应付款明细').selectOption(booked.items[0].id); await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled(); await d.getByLabel('第1页付款凭证依据').selectOption('booked_debit'); await d.getByLabel('第1页原件记账日期').fill('2026-10-01'); await d.getByRole('button', { name: '更正识别', exact: true }).click(); await expect(d.getByLabel('第1页更正单据状态')).toHaveCount(0); await d.getByLabel('更正说明(仅关联可留空)').fill('合成正式回单为本公司借方记账,未记载成功状态字样'); await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled(); await d.getByRole('checkbox', { name: /已查看第1页完整原件/ }).check(); await submit(fp, undefined, '保存核验'); await fp.getByRole('button', { name: '核验详情', exact: true }).click(); await expect(fp.getByRole('dialog').getByRole('button', { name: '公司代表确认', exact: true })).toHaveCount(0); await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); await expect(fp.getByTestId('payment-batch-action-execute')).toHaveCount(0); await go(mp, booked); await mp.getByRole('button', { name: '核验详情', exact: true }).click(); d = mp.getByRole('dialog'); await expect(d).toContainText('已记账支出(人工核对)'); await expect(d).toContainText('记账日期 2026-10-01'); await expect(d.getByRole('button', { name: '保存核验', exact: true })).toHaveCount(0); assert.equal(await mp.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false); await mp.screenshot({ path: path.join(output, `related-${s.mode}-booked-proof.png`), fullPage: true }); await submit(mp, current => current.getByLabel('复核说明').fill('合成代表公司核对原件借方记账日期与付款明细'), '公司代表确认'); await go(fp, booked); await expect(paymentButton(fp, 'execute')).toBeEnabled(); await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行'); assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: booked.loanRepaymentId } })).reservation_state, 'settled'); checks.push(`${s.mode}_booked_receipt_proof_dialog_company_confirmation_and_settlement`); const nonfinal = await f.batch(s, '12.00'); await go(fp, nonfinal); assert.equal((await receiptUpload(fp, nonfinal, '1200', `RP-UI-NONFINAL-${s.mode}`, {}, { final: false })).status, 'needs_review'); await fp.getByRole('button', { name: '核验详情', exact: true }).click(); d = fp.getByRole('dialog'); await expect(d).toContainText('非最终回执'); await expect(d.getByRole('button', { name: '更正识别', exact: true })).toHaveCount(0); await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled(); await d.getByRole('button', { name: '关闭', exact: true }).click(); await expect(fp.getByTestId('payment-batch-action-execute')).toHaveCount(0); checks.push(`${s.mode}_nonfinal_receipt_has_no_manual_override_or_settlement`); const failed = await f.batch(s, '7.00'); await f.batch(s, '7.00'); const ref = `RP-UI-FAIL-${s.mode}`.toUpperCase(); await f.receipt(failed.id, '700', ref, { payeeAccount: s.lender.bankAccount, transactionStatus: 'failed' }); await go(fp, failed); await expect(fp.getByRole('button', { name: '登记人工未提交', exact: true })).toHaveCount(0); await recon(fp, '登记银行提交', async d => { await d.getByLabel('使用的导出文件(手工录入可不选)').selectOption({ index: 1 }); await d.getByLabel('银行指令号').fill(ref); await d.getByLabel('实际提交时间').fill(new Date().toISOString().slice(0, 16)); }); await recon(fp, '确认提交清单完整'); await go(mp, failed); await review(mp, '公司代表确认提交清单'); await go(fp, failed); await recon(fp, '登记失败回单归属', async d => { await d.getByLabel('对应银行提交').selectOption({ index: 1 }); await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 }); await d.getByLabel('排除其他候选批次的依据').fill('合成其他同金额批次对应不同的银行指令'); }); await go(mp, failed); await review(mp, '公司代表确认失败归属'); await go(fp, failed); await recon(fp, '登记银行终态事实', async d => { await d.getByLabel('对应银行提交').selectOption({ index: 1 }); await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 }); }); await go(mp, failed); await review(mp, '公司代表确认终态事实'); await recon(mp, '确认银行未付款'); await go(fp, failed); await recon(fp, '发起补证恢复'); await recon(fp, '送审补证恢复'); await go(mp, failed); await review(mp, '公司代表确认补证恢复'); await go(fp, failed); await recon(fp, '重验依据并取消释放'); assert.equal(await prisma.loanRepaymentAllocation.count({ where: { repayment_id: failed.loanRepaymentId, reservation_state: 'reserved' } }), 0); checks.push(`${s.mode}_related_submission_failure_attribution_terminal_recovery_release`); const late = await f.setup('100.00'); const old = await f.batch(late, '80.00'); await f.terminate(late, old, true); const paid = await f.batch(late, '60.00'); await f.receipt(paid.id, '6000', `RP-UI-ALREADY-${s.mode}`, { payeeAccount: late.lender.bankAccount }); // Seed the distinct already-paid transaction; every new reconciliation // action under test below is made through the rendered dialogs. const execute = await h.relatedParty.helpers.execute(paid); assert.equal(execute.item.status, 'executed'); const count = await prisma.paymentBatch.count(); async function bankFields(d, amount, reference, evidenceId, incoming = false) { await d.getByLabel('对应的成功银行回单').selectOption(evidenceId); await d.getByLabel('回单付款账号').fill(incoming ? late.lender.bankAccount : '8'.repeat(16)); await d.getByLabel('回单收款账号').fill(incoming ? '8'.repeat(16) : late.lender.bankAccount); await d.getByLabel('回单人民币金额').fill(amount); await d.getByLabel('回单流水号').fill(reference); await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成原件指明原批次,逐笔排除其他同金额付款'); } await go(fp, old); const lateEvidence = await upload(fp, 'late_success'); await recon(fp, '登记晚到成功', d => bankFields(d, '80.00', `RP-UI-LATE-${s.mode}`, lateEvidence)); await go(mp, old); await review(mp, '公司代表确认银行事实'); await go(fp, old); await recon(fp, '登记补记或追回'); await expect(fp.getByRole('region', { name: '银行结果对账', exact: true })).toContainText('待追回 ¥40.00'); for (const [amount, left] of [['15.00', '25.00'], ['25.00', '0.00']]) { const returnEvidence = await upload(fp, 'incoming_return'); await recon(fp, '登记追回到账', d => bankFields(d, amount, `RP-UI-RETURN-${s.mode}-${amount}`, returnEvidence, true)); await go(mp, old); await review(mp, '公司代表确认银行事实'); await go(fp, old); await recon(fp, '登记补记或追回'); await expect(fp.getByRole('region', { name: '银行结果对账', exact: true })).toContainText(`待追回 ¥${left}`); assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lender.id, status: 'active' } }), left === '0.00' ? 0 : 1); } assert.equal(await prisma.paymentBatch.count(), count); checks.push(`${s.mode}_related_late_success_partial_and_full_return_no_payment_batch`); const duplicateEvidence = await upload(fp, 'late_success'); await recon(fp, '登记晚到成功', d => bankFields(d, '80.00', `RP-UI-LATE-${s.mode}`, duplicateEvidence)); const correctionEvidence = await upload(fp, 'bank_terminal'); await go(mp, old); await recon(mp, '公司代表确认银行事实', async d => { await d.getByLabel('复核结论').selectOption('reject'); const field = d.getByLabel('误关联或重复登记的更正证据(晚到成功必填)'); await field.selectOption(correctionEvidence); await d.getByLabel('关闭原因').selectOption('duplicate'); }); assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lender.id, status: 'active' } }), 0); await expect(mp.getByRole('region', { name: '银行结果对账', exact: true })).toContainText('已确认误报'); assert.equal(await mp.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false); await mp.screenshot({ path: path.join(output, `related-${s.mode}-closed.png`), fullPage: true }); checks.push(`${s.mode}_related_evidenced_duplicate_dismissal_and_no_overflow`); const tech = await login(ordinary); await expect(tech.getByRole('heading', { name: '出借人确认借款', exact: true })).toBeVisible(); await expect(tech.getByText('暂无可查看的出借人确认。', { exact: true })).toBeVisible(); await expect(tech.getByRole('tablist')).toHaveCount(0); await expect(tech.getByRole('button', { name: /登记借入|登记出借人|申请还本|发起出借人确认|登记公司代表授权范围|代表公司确认授权范围|停止新付款授权/ })).toHaveCount(0); assert.deepEqual(technicalFinanceRequests, [], 'technical employee own declarations must not load finance or company-authority data'); assert.equal(await prisma.notification.count({ where: { recipient_employee_id: ordinary.id, target_id: { in: [b.id, failed.id, old.id] } } }), 0); // Stopping from the actual scope card uses the current grant only. await mp.goto(`${origin}${prefix}/company-loans`); const originalCard = mp.locator('section.card-std').filter({ hasText: s.lender.name }); await originalCard.getByRole('button', { name: '停止新付款授权', exact: true }).click(); await submit(mp, d => d.getByLabel('确认或停止的理由').fill('合成暂停后续新付款,保留对账')); assert.equal((await prisma.loanRelatedAuthorizationEvent.findFirstOrThrow({ where: { authorization_id: a.id }, orderBy: { sequence: 'desc' } })).payment_enabled, false); checks.push(`${s.mode}_technical_employee_excluded_and_scope_stop_via_ui`); } catch (error) { for (const [index, context] of contexts.entries()) { const p = context.pages()[0]; if (!p) continue; await p.screenshot({ path: path.join(output, `failed-${s.mode}-${index}.png`), fullPage: true }).catch(() => {}); writeFileSync(path.join(output, `failed-${s.mode}-${index}.json`), JSON.stringify({ path: new URL(p.url()).pathname, alerts: await p.getByRole('alert').allTextContents(), headings: await p.getByRole('heading').allTextContents(), buttons: await p.getByRole('button').allTextContents(), }, null, 2)); } throw error; } finally { for (const context of contexts) await context.close(); } } } finally { config.appOrigin = oldOrigin; if (oldWait === undefined) delete config.loanReleaseWaitDays; else config.loanReleaseWaitDays = oldWait; if (own) await browser.close(); } console.log(JSON.stringify({ stage: 'loan_related_browser', status: 'PASS', checks, output: path.relative(root, output) })); return checks; };