支持财务经办与公司代表关联方还款确认(09-17-company-loan-principal-repayment)
公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。 仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。 贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。 授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。 本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。 已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。 按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。 限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。 生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
这个提交包含在:
@@ -0,0 +1,218 @@
|
||||
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');
|
||||
|
||||
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 = [];
|
||||
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();
|
||||
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(() => ({}))).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) {
|
||||
const response = p.waitForResponse(r => r.url().endsWith(`/payment-batches/${b.id}/receipt-attachments`) && r.request().method() === 'POST');
|
||||
await p.getByTestId('payment-batch-receipt-file-input').setInputFiles({ name: 'synthetic-receipt.pdf', mimeType: 'application/pdf', buffer: await f.pdf() });
|
||||
assert.equal((await response).status(), 200);
|
||||
const result = await f.processUploadedReceipt(cents, ref, { payeeAccount: s.lender.bankAccount });
|
||||
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 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 tech.waitForURL(/\/403$/); await expect(tech.getByRole('heading', { name: '公司借款', exact: true })).toHaveCount(0);
|
||||
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;
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户