借款材料区按需显示并前置缺项提示(09-17-company-loan-principal-repayment)
- 材料区只为仍缺的材料提供上传:原件已存显示可查看,在线确认后收起协议框,历史还款依据仅在登记期初小于原本金时出现 - 余额材料改中性文案(公司银行流水或代账明细账),确认字段改为“余额依据说明”,按钮改为“确认借款余额”并前置列出缺项 - 确认弹窗跟随最新列表状态:刷新中、刷新失败、版本变化、已确认或缺项时禁用按钮与提交 - 浏览器测试覆盖显隐、缺项、材料失效、在线确认失效、刷新与弹窗打开后状态变化;同步操作手册 - 只读复核 P2 已修复关闭;不改后端、接口与数据库 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -76,15 +76,23 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
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();
|
||||
// No online statement for an external lender: the agreement is offered as
|
||||
// one of two options, and every gap is named before confirmation.
|
||||
await expect(card).toContainText('有效在线确认或协议/借款确认书,任选一种');
|
||||
await expect(card.getByRole('button', { name: '去出借人确认查看或办理', exact: true })).toHaveCount(0);
|
||||
await expect(card.locator(`[id="${loan.id}-missing"]`)).toHaveText('还缺:原借入凭据、余额核对材料、协议或借款确认书');
|
||||
await expect(card.getByRole('button', { name: '确认借款余额', exact: true })).toBeDisabled();
|
||||
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\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) });
|
||||
assert.equal((await received).status(), 200);
|
||||
}
|
||||
await card.getByRole('button', { name: '确认期初余额' }).click();
|
||||
await expect(card.locator(`[id="${loan.id}-incoming"]`)).toHaveCount(0);
|
||||
await expect(card).toContainText('已保存,可查看');
|
||||
await card.getByRole('button', { name: '确认借款余额' }).click();
|
||||
dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('代账余额确认引用').fill('合成代账核对');
|
||||
await dialog.getByLabel('余额依据说明').fill('合成银行流水核对');
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(card).toContainText('余额已确认');
|
||||
|
||||
@@ -150,8 +150,8 @@ module.exports = async function employeeBrowser(h) {
|
||||
buffer: Buffer.from(`%PDF-1.4\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) });
|
||||
assert.equal((await result).status(), 200);
|
||||
}
|
||||
await loanCard.getByRole('button', { name: '确认期初余额', exact: true }).click();
|
||||
await page.getByRole('dialog').getByLabel('代账余额确认引用').fill('合成会计往来余额核对记录'); await submit(page);
|
||||
await loanCard.getByRole('button', { name: '确认借款余额', exact: true }).click();
|
||||
await page.getByRole('dialog').getByLabel('余额依据说明').fill('合成会计往来余额核对记录'); await submit(page);
|
||||
await expect(loanCard).toContainText('余额已确认');
|
||||
await page.getByRole('tab', { name: '公司代表授权', exact: true }).click();
|
||||
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
|
||||
|
||||
@@ -183,29 +183,138 @@ module.exports = async function lenderAckBrowser(h) {
|
||||
await fp.reload();
|
||||
const loan = s.loans[0];
|
||||
const card = fp.locator('section.card-std').filter({ has: fp.getByRole('heading', { name: `${s.lender.name} · ${loan.loanNo}`, exact: true }) });
|
||||
for (let i = 0; i < 30 && !await card.count(); i++) {
|
||||
const first = fp.locator('section.card-std').first(); await expect(first).toBeVisible();
|
||||
if (await card.count()) break;
|
||||
const oldText = await first.textContent(); const next = fp.getByRole('button', { name: '下一页', exact: true });
|
||||
await expect(next).toBeEnabled();
|
||||
await Promise.all([fp.waitForResponse(r => new URL(r.url()).pathname === '/api/company-loans'), next.click()]);
|
||||
await expect(first).not.toHaveText(oldText);
|
||||
}
|
||||
await card.locator('summary').click(); await expect(card).toContainText('已有有效的出借人在线确认');
|
||||
await card.getByRole('button', { name: '确认期初余额', exact: true }).click();
|
||||
dialog = fp.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账明细科目和期间');
|
||||
const denied = fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`));
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click(); assert.equal((await denied).status(), 409);
|
||||
await expect(dialog.getByRole('alert')).toBeVisible(); await dialog.press('Escape'); await expect(dialog).not.toBeVisible();
|
||||
const showCard = async () => {
|
||||
for (let i = 0; i < 30 && !await card.count(); i++) {
|
||||
const first = fp.locator('section.card-std').first(); await expect(first).toBeVisible();
|
||||
if (await card.count()) break;
|
||||
const oldText = await first.textContent(); const next = fp.getByRole('button', { name: '下一页', exact: true });
|
||||
await expect(next).toBeEnabled();
|
||||
await Promise.all([fp.waitForResponse(r => new URL(r.url()).pathname === '/api/company-loans'), next.click()]);
|
||||
await expect(first).not.toHaveText(oldText);
|
||||
}
|
||||
};
|
||||
await showCard();
|
||||
await card.locator('summary').click(); await expect(card).toContainText('出借人已在线确认');
|
||||
// With the original saved and a valid online statement, only the balance
|
||||
// material is still needed: no agreement or repayment-evidence upload, and
|
||||
// confirmation stays disabled with the gap named up front.
|
||||
await expect(card).toContainText('已保存,可查看');
|
||||
await expect(card.locator(`[id="${loan.id}-agreement"]`)).toHaveCount(0);
|
||||
await expect(card.locator(`[id="${loan.id}-incoming"]`)).toHaveCount(0);
|
||||
await expect(card.locator(`[id="${loan.id}-adjustment"]`)).toHaveCount(0);
|
||||
await expect(card.locator(`[id="${loan.id}-missing"]`)).toHaveText('还缺:余额核对材料');
|
||||
await expect(card.getByRole('button', { name: '确认借款余额', exact: true })).toBeDisabled();
|
||||
const uploaded = fp.waitForResponse(r => r.url().endsWith('/evidences/accounting_confirmation'));
|
||||
await card.getByLabel('余额核对材料', { exact: true }).setInputFiles({ name: 'synthetic-accounting.pdf', mimeType: 'application/pdf',
|
||||
await card.getByLabel('上传余额核对材料', { exact: true }).setInputFiles({ name: 'synthetic-accounting.pdf', mimeType: 'application/pdf',
|
||||
buffer: await syntheticBankReceiptPdf({ reference: `SYN-ACCOUNTING-${s.mode}`, amount: '100.00' }) });
|
||||
assert.equal((await uploaded).status(), 200);
|
||||
await card.getByRole('button', { name: '确认期初余额', exact: true }).click();
|
||||
dialog = fp.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账明细科目和期间');
|
||||
await expect(card.locator(`[id="${loan.id}-missing"]`)).toHaveCount(0);
|
||||
await expect(card.getByRole('button', { name: '确认借款余额', exact: true })).toBeEnabled();
|
||||
// Display rules against edge states the real list can carry; only this
|
||||
// loan's fields in the list response are altered, saving stays real.
|
||||
const listUrl = (u) => new URL(u).pathname === '/api/company-loans';
|
||||
const asListed = async (patch, check) => {
|
||||
const handler = async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.continue();
|
||||
const response = await route.fetch(); const body = await response.json();
|
||||
for (const item of body.data?.items ?? []) if (item.id === loan.id) patch(item);
|
||||
await route.fulfill({ response, json: body });
|
||||
};
|
||||
await fp.route(listUrl, handler);
|
||||
try { await fp.reload(); await showCard(); await card.locator('summary').click(); await check(); }
|
||||
finally { await fp.unroute(listUrl, handler); }
|
||||
};
|
||||
const confirmButton = card.getByRole('button', { name: '确认借款余额', exact: true });
|
||||
const missingLine = card.locator(`[id="${loan.id}-missing"]`);
|
||||
await asListed((item) => { item.outstanding = '40.00'; }, async () => {
|
||||
await expect(card.locator(`[id="${loan.id}-adjustment"]`)).toHaveCount(0); await expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
await asListed((item) => { item.openingOutstandingPrincipal = '80.00'; }, async () => {
|
||||
await expect(card.locator(`[id="${loan.id}-adjustment"]`)).toHaveCount(1);
|
||||
await expect(missingLine).toHaveText('还缺:系统外已还本金依据'); await expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
await asListed((item) => { for (const e of item.evidences) if (e.kind === 'accounting_confirmation') e.governanceStatus = 'quarantined'; }, async () => {
|
||||
await expect(missingLine).toHaveText('还缺:余额核对材料'); await expect(confirmButton).toBeDisabled();
|
||||
await expect(card).toContainText('(未通过检查,不计入)');
|
||||
});
|
||||
await asListed((item) => { item.lenderAcknowledgement = null; }, async () => {
|
||||
await expect(card.locator(`[id="${loan.id}-agreement"]`)).toHaveCount(1);
|
||||
await expect(missingLine).toHaveText('还缺:协议或借款确认书'); await expect(confirmButton).toBeDisabled();
|
||||
await expect(card.getByRole('button', { name: '去出借人确认查看或办理', exact: true })).toBeVisible();
|
||||
});
|
||||
// A failed refresh after an upload must not leave confirmation enabled on the old snapshot.
|
||||
await fp.reload(); await showCard(); await card.locator('summary').click(); await expect(confirmButton).toBeEnabled();
|
||||
const failRefresh = (route) => route.request().method() === 'GET' ? route.fulfill({ status: 500, json: { error: { code: 'SYS_500', message: 'synthetic' } } }) : route.continue();
|
||||
await fp.route(listUrl, failRefresh);
|
||||
try {
|
||||
const again = fp.waitForResponse(r => r.url().endsWith('/evidences/accounting_confirmation'));
|
||||
await card.getByLabel('上传余额核对材料', { exact: true }).setInputFiles({ name: 'synthetic-accounting-2.pdf', mimeType: 'application/pdf',
|
||||
buffer: await syntheticBankReceiptPdf({ reference: `SYN-ACCOUNTING-2-${s.mode}`, amount: '100.00' }) });
|
||||
assert.equal((await again).status(), 200);
|
||||
// Wait for the final failure (after the retry), not the in-flight refresh.
|
||||
await expect(fp.getByRole('alert').first()).toBeVisible({ timeout: 20000 });
|
||||
// A failed list hides the cards entirely; either way no confirmation is offered.
|
||||
await expect.poll(async () => !await confirmButton.count() || await confirmButton.isDisabled()).toBe(true);
|
||||
} finally { await fp.unroute(listUrl, failRefresh); }
|
||||
await fp.reload(); await showCard(); await card.locator('summary').click(); await expect(confirmButton).toBeEnabled();
|
||||
checks.push(`${s.mode}_material_slots_follow_registered_balance_validity_statement_and_refresh_state`);
|
||||
await card.getByRole('button', { name: '确认借款余额', exact: true }).click();
|
||||
dialog = fp.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: '确认借款余额', exact: true })).toBeVisible();
|
||||
await expect(dialog).toContainText('示例:公司银行流水');
|
||||
await expect(dialog.getByLabel('余额依据说明')).toHaveValue('');
|
||||
await dialog.getByLabel('余额依据说明').fill('合成公司银行流水,借入日至核对截止日');
|
||||
// With the dialog already open, a reconnect refresh (past the 5 s staleTime)
|
||||
// that is pending, finally fails, or drops the online statement blocks saving
|
||||
// — button and keyboard submit alike — until the live list is good again.
|
||||
let confirmPosts = 0;
|
||||
const countConfirm = (r) => { if (r.url().endsWith(`/${loan.id}/confirm`) && r.method() === 'POST') confirmPosts++; };
|
||||
fp.on('request', countConfirm);
|
||||
const dialogConfirm = dialog.getByRole('button', { name: '确认', exact: true });
|
||||
const blocker = dialog.locator('#loan-confirm-blocker');
|
||||
const reconnect = async () => {
|
||||
await fp.waitForTimeout(5200);
|
||||
await fp.evaluate(() => { window.dispatchEvent(new Event('offline')); window.dispatchEvent(new Event('online')); });
|
||||
};
|
||||
const blockedSubmit = async () => {
|
||||
await expect(dialogConfirm).toBeDisabled(); await dialog.getByLabel('余额依据说明').press('Enter');
|
||||
await fp.waitForTimeout(300); assert.equal(confirmPosts, 0, 'a blocked dialog must not save');
|
||||
};
|
||||
try {
|
||||
let release; const gate = new Promise((resolve) => { release = resolve; });
|
||||
// After a failed assertion the cleanup may unroute first; the late continue is then moot.
|
||||
const held = async (route) => { if (route.request().method() === 'GET') await gate; await route.continue().catch(() => {}); };
|
||||
await fp.route(listUrl, held);
|
||||
try {
|
||||
await reconnect(); await expect(blocker).toHaveText('借款列表正在刷新,请稍候。'); await blockedSubmit();
|
||||
const landed = fp.waitForResponse((r) => listUrl(r.url()) && r.request().method() === 'GET'); release(); await landed;
|
||||
} finally { release(); await fp.unroute(listUrl, held); }
|
||||
await expect(blocker).toHaveCount(0); await expect(dialogConfirm).toBeEnabled();
|
||||
|
||||
await fp.route(listUrl, failRefresh);
|
||||
try {
|
||||
await reconnect(); await expect(blocker).toHaveText('借款列表未能刷新,请关闭后重新打开。', { timeout: 20000 }); await blockedSubmit();
|
||||
} finally { await fp.unroute(listUrl, failRefresh); }
|
||||
await reconnect(); await expect(blocker).toHaveCount(0, { timeout: 20000 }); await expect(dialogConfirm).toBeEnabled();
|
||||
|
||||
const lapsed = async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.continue();
|
||||
const response = await route.fetch(); const body = await response.json();
|
||||
for (const item of body.data?.items ?? []) if (item.id === loan.id) item.lenderAcknowledgement = null;
|
||||
await route.fulfill({ response, json: body });
|
||||
};
|
||||
await fp.route(listUrl, lapsed);
|
||||
try {
|
||||
await reconnect(); await expect(blocker).toHaveText('还缺:协议或借款确认书', { timeout: 20000 }); await blockedSubmit();
|
||||
} finally { await fp.unroute(listUrl, lapsed); }
|
||||
await reconnect(); await expect(blocker).toHaveCount(0, { timeout: 20000 }); await expect(dialogConfirm).toBeEnabled();
|
||||
} finally { fp.off('request', countConfirm); }
|
||||
checks.push(`${s.mode}_open_confirm_dialog_follows_live_refresh_error_and_statement_state`);
|
||||
await Promise.all([fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`) && r.status() === 200),
|
||||
dialog.getByRole('button', { name: '确认', exact: true }).click()]);
|
||||
await expect(dialog).not.toBeVisible(); await expect(card).toContainText('余额已确认');
|
||||
// The failed-refresh case remounted the cards, which collapses the section again.
|
||||
if (!await card.locator('details').evaluate((d) => d.open)) await card.locator('summary').click();
|
||||
await expect(card.getByRole('button', { name: '查看或办理公司代表授权', exact: true })).toBeVisible();
|
||||
checks.push(`${s.mode}_accounting_required_ui_upload_then_balance_confirmation`);
|
||||
|
||||
const beforeRenewal = await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } });
|
||||
|
||||
在新工单中引用
屏蔽一个用户