借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效
- 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离
- 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页
- 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表)
- 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-10 15:22:12 +08:00
共同撰写人 Claude Opus 5.5
父节点 2063ff1e93
当前提交 4a5cd8f2b3
共修改 34 个文件,包含 1610 行新增和 41 行删除
+11 -3
查看文件
@@ -310,13 +310,21 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
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();
const financeReads = [];
page.on('request', request => {
if (['/api/company-loans', '/api/loan-lenders', '/api/loan-repayments'].includes(new URL(request.url()).pathname)) financeReads.push(request.url());
});
await page.goto(`${origin}/login?redirect=%2Fcompany-loans`);
await page.getByTestId('login-identifier').fill(ordinary.email);
await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click();
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).not.toBeVisible();
await page.waitForURL(/\/403$/);
checks.push('ordinary_employee_cannot_open_loan_ledger');
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: '出借人确认借款', exact: true })).toBeVisible();
await expect(page.getByText('暂无可查看的出借人确认。', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0);
await expect(page.getByRole('tablist', { name: '借款台账视图' })).toHaveCount(0);
assert.deepEqual(financeReads, [], 'own declaration entry never queries the finance ledger');
checks.push('ordinary_employee_only_own_acknowledgements_without_finance_ledger');
await context.close();
console.log(JSON.stringify({ stage: 'loan_browser', status: 'PASS', checks, output: path.relative(root, output) }));
} finally { await browser.close(); }
+10
查看文件
@@ -421,6 +421,12 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
// Incoming-receipt scenarios add lenders; in a full run they go last so they
// cannot shift the pagination other browser scenarios rely on.
const runIncoming = () => require('./company-loan-incoming-fixture.cjs')({ ...employeeHarness, verifiedLender });
const runLenderAck = () => require('./company-loan-lender-ack-fixture.cjs')({ ...employeeHarness, verifiedLender, admin, system });
if (process.env.RUN_ISOLATED_LOAN_ACK_ONLY === '1') {
const lenderAck = await runLenderAck();
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-lender-ack-browser.cjs')({ ...employeeHarness, lenderAck });
return;
}
if (process.env.RUN_ISOLATED_LOAN_INCOMING_ONLY === '1') {
const incomingReceipt = await runIncoming();
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-incoming-browser.cjs')({ app, finance, otherFinance: await actor(['finance']), password, prisma, config, company, incomingReceipt });
@@ -1402,6 +1408,10 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty, employeeSources });
}
await runIncoming();
// Keep the new lenders last so existing pagination scenarios retain their
// own deterministic data; the acknowledgement UI handles its own pages.
const lenderAck = await runLenderAck();
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-lender-ack-browser.cjs')({ ...employeeHarness, lenderAck });
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
} finally { await app.close(); await prisma.$disconnect(); }
};
@@ -0,0 +1,272 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const { createRequire } = require('node:module');
const { mkdirSync, mkdtempSync } = require('node:fs');
const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs');
const root = path.resolve(__dirname, '../..');
const { chromium, expect } = createRequire(path.join(root, 'client/package.json'))('@playwright/test');
module.exports = async function lenderAckBrowser(h) {
const { app, finance, ordinary, password, prisma, config, lenderAck } = h;
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
const address = app.server.address();
const origin = address ? `http://127.0.0.1:${address.port}` : await app.listen({ host: '127.0.0.1', port: 0 });
const oldOrigin = config.appOrigin; config.appOrigin = origin;
const base = path.join(root, 'artifacts/qa-flow/loan-lender-ack-20261010'); mkdirSync(base, { recursive: true });
const output = mkdtempSync(path.join(base, 'browser-')); const checks = [];
const apiRoot = '/api/loan-lender-acknowledgements';
try {
for (const s of lenderAck.browserScenarios) {
const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : '';
const contexts = []; const lenderPages = new Map();
async function openNotice(page, acknowledgementId) {
const loaded = page.waitForResponse(r => {
const url = new URL(r.url());
return r.request().method() === 'GET' && r.status() === 200 && url.pathname === '/api/notifications'
&& url.searchParams.get('type') === 'loan.lender_acknowledgement_requested'
&& url.searchParams.get('page') === '1' && url.searchParams.get('pageSize') === '20'
&& url.searchParams.get('unreadOnly') === '0';
});
await page.goto(`${origin}${prefix}/notifications?type=loan.lender_acknowledgement_requested`);
const loadedResponse = await loaded;
const items = (await loadedResponse.json()).data.items;
const index = items.findIndex(item => item.targetType === 'loan_lender_acknowledgement' && item.targetId === acknowledgementId);
assert.ok(index >= 0, `the actual lender inbox must include this statement notice: ${JSON.stringify({
query: new URL(loadedResponse.url()).search, items: items.length, mode: s.mode,
})}`);
if (mobile) {
const cards = page.getByTestId('mobile-notification-card'); await expect(cards).toHaveCount(items.length);
await cards.nth(index).click();
} else {
const entry = page.getByRole('link', { name: '查看详情', exact: true }).and(
page.locator(`[href="${prefix}/company-loans?lenderAcknowledgementId=${acknowledgementId}"]`));
await expect(entry).toBeVisible(); await entry.click();
}
await expect(page.getByRole('dialog')).toBeVisible();
assert.equal(new URL(page.url()).searchParams.get('lenderAcknowledgementId'), acknowledgementId);
}
async function login(who, acknowledgementId) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 },
serviceWorkers: 'block', timezoneId: 'UTC' }); contexts.push(context);
await context.route('**/*', (route) => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
if (who.id === finance.id) page.on('response', async response => {
const url = new URL(response.url());
if (url.pathname === '/api/loan-lenders' && response.status() === 200) {
const body = await response.json().catch(() => null);
if (body?.data?.items) lenderPages.set(Number(url.searchParams.get('page') ?? 1), body.data.items);
}
});
const viaNotice = who.id === s.signer.id;
const target = viaNotice ? mobile ? '/mobile/me' : '/portal' : `${prefix}/company-loans`;
await page.goto(`${origin}/login?redirect=${encodeURIComponent(target)}`);
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click();
if (viaNotice) {
await expect(page.getByTestId('login-submit')).toHaveCount(0);
if (mobile) await expect(page.getByRole('button', { name: '退出登录', exact: true })).toBeVisible();
else await expect(page.locator('nav').first()).toBeVisible();
await expect(page.getByRole('link', { name: '公司借款', exact: true })).toHaveCount(0);
await openNotice(page, acknowledgementId);
await expect(page.getByRole('heading', { name: '出借人借款声明', exact: true })).toBeVisible();
checks.push(`${s.mode}_own_lender_has_no_permanent_navigation_and_enters_statement_from_notice`);
}
else await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
return page;
}
let fp; let lp;
try {
fp = await login(finance);
await fp.getByRole('tab', { name: '出借人确认', exact: true }).click();
async function openProposalUi(selected) {
await fp.getByRole('button', { name: '发起出借人确认', exact: true }).click();
const dialog = fp.getByRole('dialog'); const select = dialog.getByLabel('声明出借人');
await expect(select).toBeVisible();
for (let pageNumber = 1; pageNumber <= 20; pageNumber++) {
await expect.poll(() => lenderPages.has(pageNumber)).toBe(true);
const expected = ['', ...lenderPages.get(pageNumber)
.filter(l => l.employeeId && l.employeeId !== finance.id && l.identityStatus === 'verified').map(l => l.id)];
await expect.poll(() => select.locator('option').evaluateAll(options => options.map(o => o.value))).toEqual(expected);
if (await select.locator(`option[value="${s.lender.id}"]`).count()) break;
const next = dialog.getByRole('button', { name: '下一页出借人', exact: true }); await expect(next).toBeEnabled();
await next.click();
}
await select.selectOption(s.lender.id);
for (const loan of selected) await dialog.getByRole('checkbox', { name: new RegExp(loan.loanNo) }).check();
return dialog;
}
async function proposeUi(selected) {
const dialog = await openProposalUi(selected);
await dialog.getByLabel('发起说明', { exact: true }).fill('合成界面借款事实及余额核对');
const response = fp.waitForResponse(r => new URL(r.url()).pathname === apiRoot && r.request().method() === 'POST');
await dialog.getByRole('button', { name: '发送给出借人', exact: true }).click();
const result = await response; assert.equal(result.status(), 200); await expect(dialog).not.toBeVisible();
return (await result.json()).data;
}
const record = await proposeUi(s.loans.slice(0, 2));
const returned = await proposeUi([s.loans[2]]);
const financeCard = fp.locator('section.card-std').filter({ hasText: s.lender.name }).filter({ hasText: '2 笔借款' }).first();
await financeCard.getByRole('button', { name: '查看借款确认记录' }).click();
await expect(fp.getByRole('dialog').getByRole('button', { name: '确认借款声明', exact: true })).toHaveCount(0);
await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
checks.push(`${s.mode}_finance_proposes_multiple_loans_but_cannot_sign`);
// A URL detail must override a draft in the same document; switching A
// to B and browser-back to A must query/render the corresponding record.
await fp.evaluate(() => { window.__syntheticAckHistoryDocument = 'same-document'; });
const draft = await openProposalUi([s.loans[2]]);
await expect(draft.getByLabel('声明出借人')).toHaveValue(s.lender.id);
await expect(draft.locator('legend')).toContainText('已选 1 笔');
await draft.getByLabel('发起说明', { exact: true }).fill('合成未发送草稿');
async function selectStatementInSpa(id) {
await fp.evaluate((id) => {
const url = new URL(window.location.href); url.searchParams.set('lenderAcknowledgementId', id);
window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate'));
}, id);
}
async function expectStatement(id, included, excluded) {
assert.equal(new URL(fp.url()).searchParams.get('lenderAcknowledgementId'), id);
const details = fp.getByRole('dialog');
await expect(details.getByRole('heading', { name: '出借人借款声明', exact: true })).toBeVisible();
for (const loan of included) await expect(details).toContainText(loan.loanNo);
for (const loan of excluded) await expect(details).not.toContainText(loan.loanNo);
await expect(details.getByLabel('发起说明', { exact: true })).toHaveCount(0);
}
await selectStatementInSpa(record.id);
await expectStatement(record.id, s.loans.slice(0, 2), [s.loans[2]]);
await selectStatementInSpa(returned.id);
await expectStatement(returned.id, [s.loans[2]], s.loans.slice(0, 2));
await fp.goBack();
await expectStatement(record.id, s.loans.slice(0, 2), [s.loans[2]]);
await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
await fp.getByRole('button', { name: '发起出借人确认', exact: true }).click();
await expect(fp.getByRole('dialog').getByLabel('发起说明', { exact: true })).toHaveValue('');
await expect(fp.getByRole('dialog').getByLabel('声明出借人')).toHaveValue('');
await expect(fp.getByRole('dialog').locator('legend')).toContainText('已选 0 笔');
assert.equal(await fp.evaluate(() => window.__syntheticAckHistoryDocument), 'same-document');
await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
checks.push(`${s.mode}_statement_url_overrides_draft_switches_records_and_browser_back_without_stale_draft`);
lp = await login(s.signer, record.id);
await expect(lp.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0);
await expect(lp.getByRole('button', { name: '发起出借人确认', exact: true })).toHaveCount(0);
let dialog = lp.getByRole('dialog');
for (const loan of s.loans.slice(0, 2)) await expect(dialog).toContainText(loan.loanNo);
await expect(dialog).toContainText('2025年3月1日'); await expect(dialog).toContainText('2026年1月1日');
assert.ok(await dialog.evaluate(el => el.scrollWidth - el.clientWidth) <= 1, 'statement dialog has no horizontal overflow');
await lp.screenshot({ path: path.join(output, `${s.mode}-statement.png`), fullPage: true });
const [downloadResponse, download] = await Promise.all([
lp.waitForResponse(r => r.url().endsWith('/download') && r.request().method() === 'POST'),
lp.waitForEvent('download'), dialog.getByRole('button', { name: '查看原借入凭据:synthetic.pdf', exact: true }).first().click(),
]);
assert.equal(downloadResponse.status(), 200); assert.match(downloadResponse.request().headers()['content-type'], /application\/json/);
assert.equal(await download.failure(), null);
const confirm = dialog.getByRole('button', { name: '确认借款声明', exact: true }); await expect(confirm).toBeDisabled();
await dialog.getByRole('checkbox').check(); await dialog.getByLabel('确认或退回说明').fill('合成本人核对原件及金额无误');
// Same identity retains one normal token refresh, without duplicate decisions.
let attempts = 0;
const expireOnce = route => ++attempts === 1 ? route.fulfill({ status: 401, contentType: 'application/json',
body: JSON.stringify({ requestId: 'synthetic-expired', error: { code: 'AUTH_001', message: '合成过期' } }) }) : route.continue();
await lp.route(`**${apiRoot}/${record.id}/decision`, expireOnce);
try {
await Promise.all([lp.waitForResponse(r => r.url().endsWith(`/${record.id}/decision`) && r.status() === 200), confirm.click()]);
await expect(dialog).not.toBeVisible(); assert.equal(attempts, 2);
} finally { await lp.unroute(`**${apiRoot}/${record.id}/decision`, expireOnce); }
assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: record.id } }), 1);
await expect(lp.getByText('出借人已确认', { exact: true })).toBeVisible();
assert.ok(await lp.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) <= 1);
await lp.screenshot({ path: path.join(output, `${s.mode}-confirmed.png`), fullPage: true });
checks.push(`${s.mode}_own_lender_downloads_original_checks_statement_and_confirms_with_same_actor_refresh`);
// Finance still needs accounting evidence; actual UI upload then confirms balance.
await fp.getByRole('tab', { name: '借款台账', exact: true }).click();
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 uploaded = fp.waitForResponse(r => r.url().endsWith('/evidences/accounting_confirmation'));
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 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('余额已确认');
checks.push(`${s.mode}_accounting_required_ui_upload_then_balance_confirmation`);
const beforeRenewal = await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } });
await lenderAck.upload(loan);
await openNotice(lp, record.id);
await expect(lp.getByRole('dialog')).toContainText('内容或来源已变化,需重新发起');
await expect(lp.getByRole('dialog').getByRole('button', { name: '确认借款声明', exact: true })).toHaveCount(0);
await lp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
checks.push(`${s.mode}_historical_notice_opens_invalid_statement_without_reactivating_confirmation`);
await fp.getByRole('tab', { name: '出借人确认', exact: true }).click();
const renewed = await proposeUi([loan]);
await openNotice(lp, renewed.id);
dialog = lp.getByRole('dialog'); await dialog.getByRole('checkbox').check();
await dialog.getByLabel('确认或退回说明').fill('合成本人重新核对新原件及原截至日余额');
await Promise.all([lp.waitForResponse(r => r.url().endsWith(`/${renewed.id}/decision`) && r.status() === 200),
dialog.getByRole('button', { name: '确认借款声明', exact: true }).click()]);
await expect(dialog).not.toBeVisible();
assert.deepEqual(await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } }), beforeRenewal);
checks.push(`${s.mode}_invalid_statement_can_be_renewed_after_balance_confirmation_without_changing_balance`);
await fp.getByRole('tab', { name: '出借人确认', exact: true }).click();
await lp.reload(); await lp.getByRole('button', { name: '核对并处理借款声明', exact: true }).click();
dialog = lp.getByRole('dialog'); await dialog.getByLabel('确认或退回说明').fill('合成借款金额待财务重新核对');
await Promise.all([lp.waitForResponse(r => r.url().endsWith(`/${returned.id}/decision`) && r.status() === 200),
dialog.getByRole('button', { name: '退回财务', exact: true }).click()]);
await expect(dialog).not.toBeVisible(); await expect(lp.getByText('已退回财务', { exact: true })).toBeVisible();
const reissued = await proposeUi([s.loans[2]]); assert.notEqual(reissued.id, returned.id);
checks.push(`${s.mode}_lender_returns_and_finance_reissues_immutable_request`);
// Hold a decision, switch to another real account in the same SPA, deliver
// a late 401: the old mutation is aborted and never replayed as new actor.
await lp.reload(); await lp.getByRole('button', { name: '核对并处理借款声明', exact: true }).click();
dialog = lp.getByRole('dialog'); await dialog.getByRole('checkbox').check();
await dialog.getByLabel('确认或退回说明').fill('合成账号切换中的旧确认');
let release; const gate = new Promise(resolve => { release = resolve; }); let attemptsSwitch = 0; let firstRequest; let aborted = false;
let started; const holding = new Promise(resolve => { started = resolve; });
const onFailed = request => { if (request === firstRequest) aborted = /aborted/i.test(request.failure()?.errorText ?? ''); };
const handler = async route => { attemptsSwitch++; firstRequest = route.request(); started(); await gate;
await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late',
error: { code: 'AUTH_001', message: '合成迟到响应' } }) }).catch(() => {}); };
lp.on('requestfailed', onFailed); await lp.route(`**${apiRoot}/${reissued.id}/decision`, handler);
try {
await lp.evaluate(() => { window.__syntheticAckDocument = 'same-document'; });
await dialog.getByRole('button', { name: '确认借款声明', exact: true }).click(); await holding;
await lp.evaluate(() => { window.history.pushState({}, '', '/mobile/me'); window.dispatchEvent(new PopStateEvent('popstate')); });
await lp.getByRole('button', { name: '退出登录', exact: true }).click();
await expect(lp.getByTestId('login-submit')).toBeVisible();
await lp.getByTestId('login-identifier').fill(ordinary.email); await lp.getByTestId('login-password').fill(password);
await lp.getByTestId('login-submit').click(); await expect(lp.getByTestId('login-submit')).toHaveCount(0);
release(); await expect.poll(() => aborted).toBe(true);
assert.equal(await lp.evaluate(() => window.__syntheticAckDocument), 'same-document'); assert.equal(attemptsSwitch, 1);
assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: reissued.id } }), 0);
} finally { release(); lp.off('requestfailed', onFailed); await lp.unroute(`**${apiRoot}/${reissued.id}/decision`, handler); }
checks.push(`${s.mode}_late_401_decision_aborted_across_real_actor_switch_without_replay`);
} catch (error) {
for (const [name, page] of [['finance', fp], ['lender', lp]]) if (page)
await page.screenshot({ path: path.join(output, `${s.mode}-${name}-failure.png`), fullPage: true, timeout: 5000 }).catch(() => {});
throw error;
} finally { for (const context of contexts) await context.close(); }
}
} finally { config.appOrigin = oldOrigin; await browser.close(); }
console.log(JSON.stringify({ stage: 'loan_lender_ack_browser', status: 'PASS', checks, output: path.relative(root, output) }));
return checks;
};
@@ -0,0 +1,289 @@
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
const { setTimeout: delay } = require('node:timers/promises');
const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs');
const { NOTIFICATION_TYPES, IM_SUPPORTED_NOTIFICATION_TYPES } = require('../../server/src/constants/notificationTypes');
module.exports = async function lenderAckFixture(h) {
const { prisma, pool, app, call, finance, manager, ordinary, actor, verifiedLender, repay } = h;
const checks = [];
const root = '/loan-lender-acknowledgements';
async function upload(loan, kind = 'incoming') {
const boundary = `synthetic-${randomUUID()}`;
const buffer = await syntheticBankReceiptPdf({ reference: `ACK-${randomUUID()}`, amount: '100.00', direction: 'credit' });
const res = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${kind}`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="synthetic.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), buffer,
Buffer.from(`\r\n--${boundary}--\r\n`)]) });
assert.equal(res.statusCode, 200, `upload ${kind}`); return { evidence: res.json().data, buffer };
}
async function loan(lender, extra = {}, withOriginal = true) {
const value = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id,
originReference: `SYN-ACK-${randomUUID()}`, originalPrincipal: '100.00', openingOutstandingPrincipal: '100.00',
receivedDate: '2025-03-01', balanceAsOf: '2026-01-01', historicalEntry: true, ...extra });
if (withOriginal) value.original = (await upload(value)).evidence;
return value;
}
async function setup(role = 'employee', count = 1) {
const signer = await actor([role]);
const lender = await verifiedLender(`合成声明出借人-${randomUUID().slice(0, 6)}`, signer.id, '4');
const loans = []; for (let i = 0; i < count; i++) loans.push(await loan(lender));
return { signer, lender, loans };
}
const body = (s, selected = s.loans) => ({ lenderId: s.lender.id, expectedIdentityVersion: s.lender.identityVersion,
loans: selected.map((l) => ({ id: l.id, version: l.version })), requestKey: randomUUID(), reason: '合成请本人核对借款事实和未还本金' });
const propose = (s) => call(finance, 'POST', root, body(s));
const decisionBody = (record, action = 'confirm') => ({ action, contentHash: record.contentHash,
reason: action === 'confirm' ? '合成本人已核对并确认' : '合成金额有误退回核对', originalChecked: action === 'confirm' });
const decide = (s, record, action = 'confirm', status = 200, reason) => call(s.signer, 'POST', `${root}/${record.id}/decision`, decisionBody(record, action), status, reason);
const confirmBalance = (l, status = 200) => call(finance, 'POST', `/company-loans/${l.id}/confirm`, {
expectedVersion: l.version, accountingConfirmationRef: '合成代账明细账科目及期间引用',
}, status, status === 409 ? 'loan.evidence_required' : undefined);
const s = await setup('manager', 2); const input = body(s);
const a = await call(finance, 'POST', root, input);
assert.equal(a.status, 'pending'); assert.equal(a.loans.length, 2); assert.equal(a.canDecide, false);
assert.equal((await call(finance, 'POST', root, input)).id, a.id);
await call(finance, 'POST', root, { ...input, reason: '合成换参数重用请求键' }, 409, 'loan.duplicate_registration');
await call(finance, 'POST', root, body(s), 409, 'loan.lender_ack_exists');
const noticeType = NOTIFICATION_TYPES.loanLenderAcknowledgementRequested;
const notices = await prisma.notification.findMany({ where: { target_type: 'loan_lender_acknowledgement', target_id: a.id } });
assert.equal(notices.length, 1); assert.equal(notices[0].recipient_employee_id, s.signer.id); assert.equal(notices[0].type, noticeType);
assert.ok(!IM_SUPPORTED_NOTIFICATION_TYPES.includes(noticeType), 'lender statement notice stays in OA');
assert.equal(await prisma.notificationDelivery.count({ where: { notification_id: notices[0].id } }), 0);
const inbox = await call(s.signer, 'GET', `/notifications?type=${noticeType}`);
assert.equal(inbox.items.filter(item => item.targetId === a.id).length, 1);
for (const who of [ordinary, finance, manager]) {
assert.equal((await call(who, 'GET', `/notifications?type=${noticeType}`)).items.filter(item => item.targetId === a.id).length, 0);
}
const noticeCopy = notices[0].title + notices[0].body;
for (const privateValue of [s.lender.bankAccount, s.lender.name, '100.00']) assert.ok(!noticeCopy.includes(privateValue));
checks.push('proposal_notice_only_for_actual_lender_no_duplicate_on_retry_or_external_delivery');
assert.ok(!JSON.stringify(a).includes(s.lender.bankAccount));
for (const item of a.loans) { assert.equal(item.originalPrincipal, '100.00'); assert.match(item.originals[0].sha256, /^[a-f0-9]{64}$/); }
checks.push('same_lender_multi_loan_snapshot_request_binding_and_duplicate_refusal');
await call(null, 'GET', root, undefined, 401);
await call(ordinary, 'POST', root, input, 403);
assert.equal((await call(ordinary, 'GET', root)).items.length, 0);
for (const who of [ordinary, manager]) {
await call(who, 'GET', `${root}/${a.id}`, undefined, 404);
await call(who, 'POST', `${root}/${a.id}/decision`, decisionBody(a), 404);
}
await call(finance, 'POST', `${root}/${a.id}/decision`, decisionBody(a), 403);
await call(s.signer, 'GET', '/company-loans', undefined, 403);
await call(s.signer, 'POST', `${root}/${a.id}/decision`, { ...decisionBody(a), originalChecked: false }, 400);
await call(s.signer, 'POST', `${root}/${a.id}/decision`, { ...decisionBody(a), contentHash: '0'.repeat(64) }, 409, 'loan.lender_ack_changed');
const download = (who, l, evidence, extra) => app.inject({ method: 'POST', url: `/api/company-loans/${l.id}/evidences/${evidence.id}/download`,
headers: who.headers, payload: { reason: '合成核对本人原借入凭据', lenderAcknowledgementId: a.id, ...extra } });
assert.equal((await download(s.signer, s.loans[0], s.loans[0].original)).statusCode, 200);
assert.equal((await download(ordinary, s.loans[0], s.loans[0].original)).statusCode, 404);
const accounting = await upload(s.loans[0], 'accounting_confirmation');
assert.equal((await download(s.signer, s.loans[0], accounting.evidence)).statusCode, 404);
assert.equal((await download(s.signer, s.loans[0], s.loans[0].original, { paymentBatchId: randomUUID() })).statusCode, 400);
checks.push('only_actual_lender_can_read_originals_and_decide_no_finance_or_representative_substitution');
await confirmBalance(s.loans[0], 409); // Accounting alone cannot replace agreement/ack.
await decide(s, a); await decide(s, a); // Same decision retry is idempotent.
await decide(s, a, 'return', 409, 'loan.lender_ack_decided');
await confirmBalance(s.loans[1], 409); // Valid ack cannot replace accounting.
await upload(s.loans[1], 'accounting_confirmation');
for (const l of s.loans) {
const value = await confirmBalance(l); assert.equal(value.confirmationStatus, 'confirmed');
assert.equal(value.lenderAcknowledgement.id, a.id);
}
assert.equal((await call(s.signer, 'GET', `${root}/${a.id}`)).status, 'confirmed');
const authority = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: s.lender.id,
loanIds: s.loans.map((l) => l.id), basis: '合成已有公司代表权及指定借款范围依据', requestKey: randomUUID() });
await call(s.signer, 'POST', `/loan-related-authorizations/${authority.id}/actions`, { action: 'confirm', reason: '合成以公司代表身份单独确认授权' });
const batch = await repay(s.lender.id, s.loans[0].id, '10.00');
assert.equal(batch.loanRepayment.approvalMode, 'related_party');
await call(s.signer, 'POST', `/payment-batches/${batch.id}/review`, {});
await h.exportOrder(batch.id);
checks.push('online_ack_replaces_only_agreement_and_reaches_separate_authority_repayment_review_and_export');
const ordinaryCase = await setup(); const ordinaryAck = await propose(ordinaryCase);
assert.equal((await call(ordinaryCase.signer, 'GET', `${root}/${ordinaryAck.id}`)).canDecide, true);
await decide(ordinaryCase, ordinaryAck, 'return');
assert.equal((await call(finance, 'GET', `${root}/${ordinaryAck.id}`)).status, 'returned');
const resent = await propose(ordinaryCase); await decide(ordinaryCase, resent);
assert.notEqual(resent.id, ordinaryAck.id);
checks.push('ordinary_employee_lender_can_return_and_confirm_new_request_without_manager_permission');
const partial = await loan(ordinaryCase.lender, { openingOutstandingPrincipal: '80.00' });
const partialScope = { ...ordinaryCase, loans: [partial] }; const partialAck = await propose(partialScope); await decide(partialScope, partialAck);
await upload(partial, 'accounting_confirmation'); await confirmBalance(partial, 409);
await upload(partial, 'adjustment'); await confirmBalance(partial);
const paper = await loan(ordinaryCase.lender); await upload(paper, 'agreement'); await upload(paper, 'accounting_confirmation'); await confirmBalance(paper);
checks.push('outside_repayment_evidence_still_required_and_paper_agreement_remains_valid');
const missing = await loan(ordinaryCase.lender, {}, false);
await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing] }), 409, 'loan.lender_ack_scope_invalid');
await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing, s.loans[0]] }), 409, 'loan.lender_ack_scope_invalid');
await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing, missing] }), 409, 'loan.lender_ack_scope_invalid');
const ownLender = await verifiedLender('合成财务本人出借人', finance.id, '2');
const ownLoan = await loan(ownLender);
await call(finance, 'POST', root, body({ lender: ownLender, loans: [ownLoan] }), 409, 'loan.lender_ack_scope_invalid');
checks.push('scope_requires_distinct_maker_verified_lender_same_company_unconfirmed_loans_and_originals');
await assert.rejects(prisma.loanLenderAcknowledgement.update({ where: { id: a.id }, data: { reason: '合成篡改声明' } }));
await assert.rejects(prisma.loanLenderAcknowledgementLoan.update({ where: { acknowledgement_id_loan_id: { acknowledgement_id: a.id, loan_id: s.loans[0].id } }, data: { snapshot_json: {} } }));
await assert.rejects(prisma.loanLenderAcknowledgementDecision.update({ where: { acknowledgement_id: a.id }, data: { reason: '合成篡改决定' } }));
await assert.rejects(prisma.loanLenderAcknowledgementDecision.delete({ where: { acknowledgement_id: a.id } }));
await assert.rejects(prisma.loanLenderAcknowledgementLoan.deleteMany({ where: { acknowledgement_id: a.id } }));
checks.push('database_rejects_snapshot_decision_and_scope_history_rewrites_or_deletion');
for (const [column, changed] of [['original_principal', '110.00'], ['opening_outstanding_principal', '90.00'], ['received_date', '2025-03-02'], ['balance_as_of', '2026-01-02']]) {
const l = await loan(ordinaryCase.lender); const itemScope = { ...ordinaryCase, loans: [l] }; const record = await propose(itemScope); await decide(itemScope, record);
const before = (await pool.query(`SELECT ${column}::text AS value FROM company_loans WHERE id=$1`, [l.id])).rows[0].value;
await pool.query(`UPDATE company_loans SET ${column}=$1 WHERE id=$2`, [changed, l.id]);
await pool.query(`UPDATE company_loans SET ${column}=$1 WHERE id=$2`, [before, l.id]);
assert.equal((await call(ordinaryCase.signer, 'GET', `${root}/${record.id}`)).status, 'invalid');
await upload(l, 'accounting_confirmation'); await confirmBalance(l, 409);
await assert.rejects(prisma.loanLenderAcknowledgement.update({ where: { id: record.id }, data: { invalidated_at: null, invalid_reason: null } }));
}
checks.push('each_amount_and_date_change_permanently_invalidates_even_after_restoring_old_values');
const changedOriginal = await setup('employee', 2); const originalAck = await propose(changedOriginal); await decide(changedOriginal, originalAck);
const addition = await upload(changedOriginal.loans[0]);
await prisma.loanEvidence.update({ where: { id: addition.evidence.id }, data: { archived_at: new Date() } });
const oldRead = await call(changedOriginal.signer, 'GET', `${root}/${originalAck.id}`);
assert.equal(oldRead.status, 'invalid'); assert.equal(oldRead.canReadOriginals, false);
for (const l of changedOriginal.loans) { await upload(l, 'accounting_confirmation'); await confirmBalance(l, 409); }
const identityCase = await setup(); const identityAck = await propose(identityCase); await decide(identityCase, identityAck);
await prisma.loanLender.update({ where: { id: identityCase.lender.id }, data: { identity_version: { increment: 1 } } });
await prisma.loanLender.update({ where: { id: identityCase.lender.id }, data: { identity_version: identityCase.lender.identityVersion } });
assert.equal((await call(identityCase.signer, 'GET', `${root}/${identityAck.id}`)).status, 'invalid');
checks.push('original_or_identity_changes_invalidate_whole_group_and_cannot_reactivate');
const rollbackCase = await setup(); const rollbackBody = body(rollbackCase);
async function auditFailure(action, run) {
await pool.query(`CREATE FUNCTION synthetic_fail_lender_ack_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='${action}' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_fail_lender_ack_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_lender_ack_audit()');
try { await run(); } finally { await pool.query('DROP TRIGGER synthetic_fail_lender_ack_audit ON audit_logs'); await pool.query('DROP FUNCTION synthetic_fail_lender_ack_audit()'); }
}
await auditFailure('LOAN_LENDER_ACK_PROPOSED', () => call(finance, 'POST', root, rollbackBody, 500));
assert.equal(await prisma.loanLenderAcknowledgement.count({ where: { request_key: rollbackBody.requestKey } }), 0);
assert.equal(await prisma.notification.count({ where: { recipient_employee_id: rollbackCase.signer.id, type: noticeType } }), 0);
const rollbackAck = await call(finance, 'POST', root, rollbackBody);
await auditFailure('LOAN_LENDER_ACK_CONFIRMED', () => decide(rollbackCase, rollbackAck, 'confirm', 500));
assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: rollbackAck.id } }), 0);
checks.push('proposal_and_decision_audit_failures_roll_back_completely');
const notificationFailure = await setup(); const notificationBody = body(notificationFailure);
const auditsBefore = await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_ACK_PROPOSED' } });
await pool.query(`CREATE FUNCTION synthetic_fail_ack_notice() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.type='${noticeType}' THEN RAISE EXCEPTION 'synthetic notification failure'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_fail_ack_notice BEFORE INSERT ON notifications FOR EACH ROW EXECUTE FUNCTION synthetic_fail_ack_notice()');
try { await call(finance, 'POST', root, notificationBody, 500); }
finally { await pool.query('DROP TRIGGER synthetic_fail_ack_notice ON notifications'); await pool.query('DROP FUNCTION synthetic_fail_ack_notice()'); }
assert.equal(await prisma.loanLenderAcknowledgement.count({ where: { request_key: notificationBody.requestKey } }), 0);
assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_ACK_PROPOSED' } }), auditsBefore);
assert.equal(await prisma.notification.count({ where: { recipient_employee_id: notificationFailure.signer.id, type: noticeType } }), 0);
const afterNotificationFailure = await call(finance, 'POST', root, notificationBody);
assert.equal(await prisma.notification.count({ where: { target_id: afterNotificationFailure.id, type: noticeType } }), 1);
checks.push('notification_write_failure_rolls_back_statement_and_success_audit_then_same_request_can_retry');
const racing = await setup(); const raceInput = body(racing);
const pair = await Promise.all([call(finance, 'POST', root, raceInput), call(finance, 'POST', root, raceInput)]);
assert.equal(pair[0].id, pair[1].id);
assert.equal(await prisma.notification.count({ where: { target_id: pair[0].id, type: noticeType } }), 1);
const responses = await Promise.all(['confirm', 'return'].map((action) => app.inject({ method: 'POST',
url: `/api${root}/${pair[0].id}/decision`, headers: racing.signer.headers, payload: decisionBody(pair[0], action) })));
assert.deepEqual(responses.map((r) => r.statusCode).sort(), [200, 409]);
assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: pair[0].id } }), 1);
checks.push('concurrent_duplicate_proposal_and_conflicting_decisions_have_one_result');
const sourceRace = await setup(); const sourceAck = await propose(sourceRace);
const locker = await pool.connect();
try {
await locker.query('BEGIN'); await locker.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [sourceRace.lender.id]);
const pending = decide(sourceRace, sourceAck, 'confirm', 409, 'loan.lender_ack_changed');
let blocked = false;
for (let i = 0; i < 80; i++) { const state = await pool.query("SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%loan_lenders%' LIMIT 1");
if (state.rowCount) { blocked = true; break; } await delay(20); }
assert.ok(blocked, 'confirmation must wait for the source lock');
await locker.query('UPDATE company_loans SET opening_outstanding_principal=90 WHERE id=$1', [sourceRace.loans[0].id]);
await locker.query('COMMIT'); await pending;
assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: sourceAck.id } }), 0);
} finally { await locker.query('ROLLBACK'); locker.release(); }
checks.push('observed_source_lock_interleaving_rejects_stale_confirmation');
for (const change of ['identity', 'sibling_original']) {
const balanceRace = await setup('employee', 2); const balanceAck = await propose(balanceRace);
await decide(balanceRace, balanceAck); await upload(balanceRace.loans[0], 'accounting_confirmation');
const sourceWriter = await pool.connect(); let pending;
try {
await sourceWriter.query('BEGIN');
await sourceWriter.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [balanceRace.lender.id]);
pending = app.inject({ method: 'POST', url: `/api/company-loans/${balanceRace.loans[0].id}/confirm`,
headers: finance.headers, payload: { expectedVersion: balanceRace.loans[0].version, accountingConfirmationRef: '合成并发代账引用' } });
// Observe an actual waiter rather than relying on a scheduled delay.
let blocked = false;
for (let i = 0; i < 80; i++) {
const state = await pool.query("SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%loan_lenders%' LIMIT 1");
if (state.rowCount) { blocked = true; break; } await delay(20);
}
assert.ok(blocked, `balance confirmation must wait for ${change} writer`);
if (change === 'identity') await sourceWriter.query('UPDATE loan_lenders SET identity_version=identity_version+1 WHERE id=$1', [balanceRace.lender.id]);
else await sourceWriter.query('UPDATE loan_evidences SET original_name=$1 WHERE id=$2', ['synthetic-changed.pdf', balanceRace.loans[1].original.id]);
await sourceWriter.query('COMMIT');
const response = await pending; assert.equal(response.statusCode, 409);
assert.equal(response.json().error.details.reasonCode, 'loan.evidence_required');
assert.equal((await prisma.companyLoan.findUniqueOrThrow({ where: { id: balanceRace.loans[0].id } })).confirmation_status, 'unconfirmed');
} finally { await sourceWriter.query('ROLLBACK'); sourceWriter.release(); if (pending) await pending; }
}
checks.push('balance_consumption_serializes_identity_and_group_original_changes_and_rechecks_after_wait');
const paperCurrent = await call(finance, 'GET', `/company-loans?lenderId=${ordinaryCase.lender.id}`);
const paperLoan = paperCurrent.items.find(l => l.id === paper.id);
assert.equal(paperLoan.lenderAcknowledgementRenewable, false);
await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [paperLoan] }), 409, 'loan.lender_ack_scope_invalid');
for (const change of ['original', 'employee_refresh']) {
const renewal = await setup('manager', 2); const previous = await propose(renewal); await decide(renewal, previous);
for (let i = 0; i < renewal.loans.length; i++) {
await upload(renewal.loans[i], 'accounting_confirmation'); renewal.loans[i] = await confirmBalance(renewal.loans[i]);
}
async function grant() {
const authorization = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: renewal.lender.id,
loanIds: renewal.loans.map(l => l.id), basis: '合成声明恢复后仍需独立办理公司代表授权', requestKey: randomUUID() });
await call(renewal.signer, 'POST', `/loan-related-authorizations/${authorization.id}/actions`, { action: 'confirm', reason: '合成公司代表身份确认' });
}
await grant();
const before = await prisma.companyLoan.findUniqueOrThrow({ where: { id: renewal.loans[0].id } });
if (change === 'original') await upload(renewal.loans[1]);
else {
await prisma.employee.update({ where: { id: renewal.signer.id }, data: { name: renewal.lender.name,
bank_institution_id: h.bank.id, bank_name: '合成建行员工收款资料' } });
const source = await call(finance, 'GET', `/loan-lenders/employee-source/${renewal.signer.id}?legalEntityId=${renewal.lender.legalEntityId}`);
renewal.lender = await call(finance, 'POST', `/loan-lenders/${renewal.lender.id}/employee-refresh`, {
expectedSourceVersion: source.sourceVersion, expectedIdentityVersion: renewal.lender.identityVersion,
expectedAccountVersion: renewal.lender.accountVersion, detailsConfirmed: true, reason: '合成刷新员工收款资料' });
}
assert.equal((await call(renewal.signer, 'GET', `${root}/${previous.id}`)).status, 'invalid');
const payBody = { requestKey: randomUUID(), lenderId: renewal.lender.id, reason: '合成验证续办还款',
allocations: [{ loanId: renewal.loans[0].id, amount: '10.00' }] };
await call(finance, 'POST', '/loan-repayments', payBody, 409, 'loan.evidence_required');
const projected = await call(finance, 'GET', `/company-loans?lenderId=${renewal.lender.id}`);
assert.ok(projected.items.every(l => l.lenderAcknowledgementRenewable));
// Renew one member of the invalid group; returning it does not trap renewal.
const selected = { ...renewal, loans: [renewal.loans[0]] };
const back = await propose(selected); await decide(selected, back, 'return');
const freshAck = await propose(selected); await decide(selected, freshAck);
assert.equal((await call(renewal.signer, 'GET', `${root}/${previous.id}`)).status, 'invalid');
assert.deepEqual(await prisma.companyLoan.findUniqueOrThrow({ where: { id: renewal.loans[0].id } }), before,
'renewal never edits the loan or its original balance confirmation');
if (change === 'employee_refresh') {
const refusedPayment = await app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: payBody });
assert.equal(refusedPayment.statusCode, 409, 'lender statement does not renew company authority');
await grant();
}
await call(finance, 'POST', '/loan-repayments', payBody);
}
checks.push('confirmed_balance_can_renew_invalid_statement_after_original_or_employee_refresh_without_rewriting_loan');
checks.push('renewal_supports_return_and_partial_group_but_does_not_replace_company_authority_or_open_paper_only_scope');
const browserScenarios = [];
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) browserScenarios.push({ mode, ...await setup('employee', 4) });
console.log(JSON.stringify({ stage: 'loan_lender_ack_http', status: 'PASS', checks }));
return { browserScenarios, upload, loan, body, decide, propose, setup, checks };
};
@@ -10,6 +10,7 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
for (const mobile of [false, true]) {
const mode = mobile ? 'mobile' : 'desktop';
let view;
const uploadedByKind = new Map();
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' });
@@ -28,22 +29,34 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
const dialog = view.page.getByRole('dialog');
if (fill) await fill(dialog);
if (await dialog.getByLabel('依据与说明').count()) await dialog.getByLabel('依据与说明').fill('合成银行材料唯一定位本批次并独立核对全部指令');
const invalid = await dialog.locator('input:invalid, select:invalid, textarea:invalid').evaluateAll(elements =>
elements.map(el => el.getAttribute('aria-label') ?? el.labels?.[0]?.textContent?.trim().slice(0, 80) ?? el.tagName));
assert.deepEqual(invalid, [], `synthetic UI ${name}: invalid form controls`);
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()}`); }
catch {
await view.page.screenshot({ path: path.join(output, `${mode}-reconciliation-failure.png`), fullPage: true });
throw new Error(`synthetic UI ${name}: ${await dialog.getByRole('alert').allTextContents()}; submit=${await dialog.locator('button[type="submit"]').textContent()}`);
}
}
async function review(name = '独立复核银行事实') {
await action(name, d => d.getByLabel('复核结论').selectOption('approve'));
}
async function upload(kind) {
const uploaded = view.page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/loan-terminal-evidences') &&
response.request().method() === 'POST');
await action('上传银行证据', async d => {
await d.getByLabel('证据类别').selectOption(kind);
await d.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await fixtures.bankLetterPdf() });
});
const response = await uploaded; assert.equal(response.status(), 200);
uploadedByKind.set(kind, (await response.json()).data.id);
}
async function bankFields(d, s, amount, reference, incoming = false) {
const options = d.getByLabel('对应的成功银行回单').locator('option');
await d.getByLabel('对应的成功银行回单').selectOption({ index: (await options.count()) - 1 });
const evidenceId = uploadedByKind.get(incoming ? 'incoming_return' : 'late_success');
assert.ok(evidenceId, 'use the specific uploaded document, never the placeholder or an older option');
await d.getByLabel('对应的成功银行回单').selectOption(evidenceId);
await expect(d.getByLabel('对应的成功银行回单')).toHaveValue(evidenceId);
await d.getByLabel('回单付款账号').fill(incoming ? s.payee : s.payer);
await d.getByLabel('回单收款账号').fill(incoming ? s.payer : s.payee);
await d.getByLabel('回单人民币金额').fill(amount);
+11 -1
查看文件
@@ -18,11 +18,16 @@ module.exports = async function relatedBrowser(h) {
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'));
@@ -237,7 +242,12 @@ module.exports = async function relatedBrowser(h) {
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);
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`);