补齐借款对账界面验收并记录 S0 只读核实(09-17-company-loan-principal-repayment)
电脑和手机覆盖提交清单、失败归属、晚到补记、部分及全额追回和有依据的误报关闭;不修改应用业务代码。最终隔离验证 81 迁移、HTTP 95、浏览器 19、数据库约束 9 组通过,脚本和治理检查通过。 获准只读核实:生产 OA 借款模块当前无记录,已应用 73 个迁移与本地校验值一致,待执行 8 个;不证明银行或线下没有付款。记录迁移差集、发布步骤和回退边界。 人员、银行材料、真实样本及业务独立验收待完成;正式发布门禁尚未运行。本次仅本地提交,不推送、不部署。
这个提交包含在:
@@ -0,0 +1,130 @@
|
||||
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;
|
||||
async function login(who, batch) {
|
||||
if (view) await view.context.close();
|
||||
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();
|
||||
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('合成银行材料唯一定位本批次并独立核对全部指令');
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
try { await expect(dialog).not.toBeVisible(); }
|
||||
catch { throw new Error(`synthetic UI ${name}: ${await dialog.getByRole('alert').allTextContents()}`); }
|
||||
}
|
||||
async function review(name = '独立复核银行事实') {
|
||||
await action(name, d => d.getByLabel('复核结论').selectOption('approve'));
|
||||
}
|
||||
async function upload(kind) {
|
||||
await action('上传银行证据', async d => {
|
||||
await d.getByLabel('证据类别').selectOption(kind);
|
||||
await d.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await fixtures.bankLetterPdf() });
|
||||
});
|
||||
}
|
||||
async function bankFields(d, s, amount, reference, incoming = false) {
|
||||
const options = d.getByLabel('对应的成功银行回单').locator('option');
|
||||
await d.getByLabel('对应的成功银行回单').selectOption({ index: (await options.count()) - 1 });
|
||||
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('合成官方查询逐一排除其他同金额付款和退款交易');
|
||||
}
|
||||
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']]) {
|
||||
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`);
|
||||
|
||||
// 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 { if (view) await view.context.close(); }
|
||||
}
|
||||
return checks;
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户