借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效 - 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离 - 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页 - 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表) - 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -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(); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
在新工单中引用
屏蔽一个用户