借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)
新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。 验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。 真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
这个提交包含在:
@@ -6,11 +6,25 @@ 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 }) {
|
||||
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi }) {
|
||||
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 = [];
|
||||
async function findOnPages(page, locator, endpoint) {
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
|
||||
if (await locator.count()) return;
|
||||
const next = page.getByRole('button', { name: '下一页', exact: true });
|
||||
await expect(next).toBeEnabled();
|
||||
const currentLabel = await page.getByText(/^第 \d+ 页$/).first().innerText();
|
||||
await next.click();
|
||||
await expect(page.getByText(currentLabel, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
|
||||
}
|
||||
throw new Error('synthetic record not found after pagination');
|
||||
}
|
||||
|
||||
try {
|
||||
for (const mobile of [false, true]) {
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
||||
@@ -36,6 +50,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
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 findOnPages(page, lenderCard, 'loan-lenders');
|
||||
await lenderCard.getByRole('button', { name: '核实身份关联' }).click();
|
||||
dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('出借人对应身份').selectOption('external');
|
||||
@@ -58,6 +73,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
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 findOnPages(page, card, 'company-loans');
|
||||
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');
|
||||
@@ -89,6 +105,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
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 findOnPages(page, frozenCard, 'loan-lenders');
|
||||
await expect(frozenCard).toContainText('有还款正在办理');
|
||||
await expect(frozenCard.getByRole('button', { name: '核实身份关联' })).toBeDisabled();
|
||||
await expect(frozenCard.getByRole('button', { name: '更新收款资料' })).toBeDisabled();
|
||||
@@ -155,6 +172,131 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
assert.equal(settled.statusCode, 200, 'settles after UI re-registration and review');
|
||||
checks.push('desktop_stale_attribution_reregistered_reviewed_and_settled');
|
||||
}
|
||||
if (reconciliationUi) {
|
||||
async function confirmDialog(dialog, step) {
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
try { await expect(dialog).not.toBeVisible(); }
|
||||
catch { throw new Error(`synthetic Q6 UI ${step}: ${await dialog.getByRole('alert').allTextContents()} (dialog remains open)`); }
|
||||
}
|
||||
for (const [index, batchId] of reconciliationUi.batches.entries()) {
|
||||
const mobile = index === 1;
|
||||
async function session(who) {
|
||||
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();
|
||||
page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) })));
|
||||
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batchId}`)}`);
|
||||
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 });
|
||||
return { context, page, panel };
|
||||
}
|
||||
let view = await session(finance);
|
||||
for (const kind of ['query_no_result', 'account_statement', 'exporter_statement']) {
|
||||
await view.panel.getByRole('button', { name: '上传银行证据', exact: true }).click();
|
||||
const dialog = view.page.getByRole('dialog'); await dialog.getByLabel('证据类别').selectOption(kind);
|
||||
await dialog.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await reconciliationUi.bankLetterPdf() });
|
||||
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
|
||||
}
|
||||
await view.panel.getByRole('button', { name: '登记人工未提交', exact: true }).click();
|
||||
let dialog = view.page.getByRole('dialog');
|
||||
await dialog.getByLabel('查询无结果官方 PDF', { exact: true }).selectOption({ index: 1 });
|
||||
await dialog.getByLabel('同期对账单', { exact: true }).selectOption({ index: 1 });
|
||||
await dialog.getByLabel('导出人 1 的文件去向说明', { exact: true }).selectOption({ index: 1 });
|
||||
const now = new Date(); const earlier = new Date(now.getTime() - 86400000);
|
||||
// Browser local timezone is UTC in this synthetic context.
|
||||
await dialog.getByLabel('查询与对账单覆盖开始').fill(earlier.toISOString().slice(0, 16));
|
||||
await dialog.getByLabel('查询与对账单覆盖截至(须含登记日)').fill(now.toISOString().slice(0, 16));
|
||||
await dialog.getByLabel('依据与说明').fill('合成浏览器逐份核对官方查询、对账单和文件去向');
|
||||
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
|
||||
await expect(view.panel).toContainText('已有 0 人同意');
|
||||
await expect(view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true })).toHaveCount(0);
|
||||
await view.context.close();
|
||||
for (const [n, who] of [manager, reconciliationUi.independent].entries()) {
|
||||
view = await session(who); await view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true }).click();
|
||||
dialog = view.page.getByRole('dialog'); await dialog.getByLabel('复核结论').selectOption('approve');
|
||||
await dialog.getByLabel('依据与说明').fill('合成浏览器独立复核全部证据与未提交结论');
|
||||
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
|
||||
await expect(view.panel).toContainText(`已有 ${n + 1} 人同意`);
|
||||
await expect(view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true })).toHaveCount(0);
|
||||
await view.context.close();
|
||||
}
|
||||
view = await session(finance); await view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true }).click();
|
||||
dialog = view.page.getByRole('dialog'); await dialog.getByLabel('依据与说明').fill('合成浏览器特别复核完成后重验并取消');
|
||||
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
|
||||
await expect(view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true })).toHaveCount(0);
|
||||
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
|
||||
await view.page.screenshot({ path: path.join(output, `${mobile ? 'mobile' : 'desktop'}-manual-not-submitted.png`), fullPage: true });
|
||||
await view.context.close();
|
||||
checks.push(`${mobile ? 'mobile' : 'desktop'}_manual_not_submitted_evidence_upload_two_independent_reviews_and_release`);
|
||||
}
|
||||
}
|
||||
if (reconciliationUi) {
|
||||
for (const recovery of [false, true]) for (const [index, batch] of (recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).entries()) {
|
||||
const mobile = index === 1;
|
||||
async function session(who) {
|
||||
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();
|
||||
page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) })));
|
||||
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 });
|
||||
return { context, page, panel };
|
||||
}
|
||||
let view = await session(finance);
|
||||
async function action(name, fill) {
|
||||
await 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 reconciliation ${name}: ${await dialog.getByRole('alert').allTextContents()}`); }
|
||||
}
|
||||
if (recovery) {
|
||||
await action('发起补证恢复');
|
||||
await action('上传银行证据', async (dialog) => {
|
||||
await dialog.getByLabel('证据类别').selectOption('bank_terminal');
|
||||
await dialog.getByLabel('证据文件').setInputFiles({ name: 'replacement-terminal.pdf', mimeType: 'application/pdf', buffer: await reconciliationUi.bankLetterPdf() });
|
||||
});
|
||||
}
|
||||
await action('登记银行终态事实', async (dialog) => {
|
||||
await dialog.getByLabel('对应银行提交').selectOption({ index: 1 });
|
||||
await dialog.getByLabel('银行终态书面证据').selectOption({ index: 1 });
|
||||
await dialog.getByLabel('银行终态', { exact: true }).selectOption('failed');
|
||||
if (!recovery) { const boxes = dialog.getByRole('checkbox'); await expect(boxes).toHaveCount(2); for (const box of await boxes.all()) await box.check(); }
|
||||
});
|
||||
await expect(view.panel).toContainText(batch.instruction); await view.context.close();
|
||||
view = await session(manager);
|
||||
await expect(view.panel).toContainText(batch.instruction);
|
||||
if (!recovery) {
|
||||
await expect(view.panel).toContainText('了结处理中回单');
|
||||
const receiptButtons = view.panel.getByRole('button', { name: /^付款回单 [12]/ });
|
||||
await expect(receiptButtons).toHaveCount(2);
|
||||
for (const receiptButton of await receiptButtons.all()) {
|
||||
await receiptButton.click(); const dialog = view.page.getByRole('dialog');
|
||||
await dialog.getByLabel('依据与说明').fill('合成复核指定银行指令的历史回单事实');
|
||||
const downloaded = view.page.waitForEvent('download');
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click(); await downloaded;
|
||||
await expect(dialog).not.toBeVisible();
|
||||
}
|
||||
}
|
||||
await action('独立复核终态事实', async (dialog) => dialog.getByLabel('复核结论').selectOption('approve'));
|
||||
if (!recovery) await action('确认银行未付款');
|
||||
await view.context.close(); view = await session(finance);
|
||||
if (recovery) {
|
||||
await action('送审补证恢复'); await view.context.close(); view = await session(manager);
|
||||
await action('复核补证恢复', async (dialog) => dialog.getByLabel('复核结论').selectOption('approve'));
|
||||
await view.context.close(); view = await session(finance);
|
||||
}
|
||||
await action('重验依据并取消释放');
|
||||
await expect(view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true })).toHaveCount(0);
|
||||
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
|
||||
await view.page.screenshot({ path: path.join(output, `${mobile ? 'mobile' : 'desktop'}-${recovery ? 'recovery' : 'terminal-multiple-receipts'}.png`), fullPage: true });
|
||||
await view.context.close(); checks.push(`${mobile ? 'mobile' : 'desktop'}_${recovery ? 'recovery_start_evidence_review_round_release' : 'terminal_multiple_pending_receipts_independent_review_release'}`);
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户