支持财务经办与公司代表关联方还款确认(09-17-company-loan-principal-repayment)
公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。 仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。 贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。 授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。 本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。 已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。 按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。 限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。 生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
这个提交包含在:
@@ -6,7 +6,7 @@ const root = path.resolve(__dirname, '../..');
|
||||
const req = createRequire(path.join(root, 'client/package.json'));
|
||||
const { chromium, expect } = req('@playwright/test');
|
||||
|
||||
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi }) {
|
||||
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi, config, relatedParty }) {
|
||||
const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-'));
|
||||
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
|
||||
@@ -300,6 +300,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
if (reconciliationUi) checks.push(...await require('./company-loan-reconciliation-browser.cjs')({
|
||||
browser, origin, output, finance, manager, password, prisma, fixtures: reconciliationUi,
|
||||
}));
|
||||
if (relatedParty) checks.push(...await require('./company-loan-related-browser.cjs')({ browser, origin, output, finance, manager, ordinary, password, prisma, config, relatedParty }));
|
||||
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();
|
||||
|
||||
@@ -192,6 +192,10 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), file, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
||||
assert.equal(response.statusCode, 200, 'receipt upload');
|
||||
const result = await processUploadedReceipt(amount, reference, overrides, { normalizedPage });
|
||||
return { attachment: response.json().data.items[0], result };
|
||||
}
|
||||
async function processUploadedReceipt(amount, reference, overrides = {}, { normalizedPage = false } = {}) {
|
||||
const slip = { amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount, transactionStatus: 'success', reference, ...overrides };
|
||||
// normalizedPage: one explicitly synthetic defence-in-depth case that the
|
||||
// real normalizer cannot produce; every other receipt uses the real chain.
|
||||
@@ -199,7 +203,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
? async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, issues: [], ...slip }] })
|
||||
: (args) => recognizePaymentReceipt({ ...args, createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: bankSlipFields(slip) }) }) });
|
||||
const result = await runReceiptOcrOnce({ prisma, config, recognize });
|
||||
return { attachment: response.json().data.items[0], result };
|
||||
return result;
|
||||
}
|
||||
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
|
||||
assert.equal(exportResponse.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().details)}`);
|
||||
@@ -368,11 +372,19 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
requestKey: randomUUID(), lenderId, reason: '合成还本', allocations: [{ loanId, amount }] })).item;
|
||||
async function exportOrder(batchId, status = 200, reason) {
|
||||
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/payment-order`, headers: finance.headers, payload: {} });
|
||||
assert.equal(response.statusCode, status, `export ${response.statusCode} ${status === 200 ? '' : response.body.slice(0, 200)}`);
|
||||
assert.equal(response.statusCode, status, `export ${response.statusCode} ${response.headers['content-type']?.includes('json') ? response.json().details?.reasonCode ?? '' : '<binary omitted>'}`);
|
||||
if (reason) assert.equal(response.json().details?.reasonCode, reason);
|
||||
}
|
||||
const lenderState = async (id) => (await call(finance, 'GET', '/loan-lenders')).items.find((item) => item.id === id);
|
||||
const independent = await actor(['ceo']);
|
||||
const runRelated = () => require('./company-loan-related-fixture.cjs')({ prisma, pool, call, app, req, finance, manager, ordinary, independent, config, password,
|
||||
verifiedLender, confirmedLoan, repay, exportOrder, receipt, processUploadedReceipt });
|
||||
if (process.env.RUN_ISOLATED_LOAN_RELATED_ONLY === '1') {
|
||||
const relatedParty = await runRelated();
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-related-browser.cjs')({ app, finance, manager, ordinary, password, prisma, config, relatedParty });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Lender linked to a manager: that manager cannot approve, an independent CEO can.
|
||||
const linked = await verifiedLender('合成出借人乙', manager.id, '4');
|
||||
@@ -1173,6 +1185,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
||||
checks.push(...await require('./company-loan-bank-results-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
|
||||
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
||||
const relatedParty = await runRelated();
|
||||
checks.push(...relatedParty.checks);
|
||||
delete config.loanReleaseWaitDays;
|
||||
|
||||
// History guards in the database.
|
||||
@@ -1328,7 +1342,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
reconciliationUi.late.push({ id: original.id, lenderId: lateLender.id, reference: `UI-LATE-${mode}`,
|
||||
payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted), payee: lateLender.bankAccount });
|
||||
}
|
||||
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi });
|
||||
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty });
|
||||
}
|
||||
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
|
||||
} finally { await app.close(); await prisma.$disconnect(); }
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { mkdtempSync, mkdirSync, writeFileSync } = require('node:fs');
|
||||
const { createRequire } = require('node:module');
|
||||
const req = createRequire(path.resolve(__dirname, '../../client/package.json'));
|
||||
const { chromium, expect } = req('@playwright/test');
|
||||
|
||||
module.exports = async function relatedBrowser(h) {
|
||||
const { finance, manager, ordinary, password, prisma, relatedParty, config } = h;
|
||||
const own = !h.browser; const browser = h.browser ?? await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
|
||||
const origin = h.origin ?? await h.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const root = path.resolve(__dirname, '../..'); mkdirSync(path.join(root, 'artifacts/qa-flow/loan-related-20261008'), { recursive: true });
|
||||
const output = h.output ?? mkdtempSync(path.join(root, 'artifacts/qa-flow/loan-related-20261008/browser-'));
|
||||
const f = relatedParty.helpers; const checks = [];
|
||||
const oldWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 0;
|
||||
const oldOrigin = config.appOrigin; config.appOrigin = origin;
|
||||
try {
|
||||
for (const s of relatedParty.ui) {
|
||||
const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : ''; const contexts = [];
|
||||
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();
|
||||
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'));
|
||||
return p;
|
||||
}
|
||||
const go = async (p, b) => { await p.goto(`${origin}${prefix}/payment-batches?id=${b.id}`); await expect(p.getByLabel('关联方还款模式')).toBeVisible({ timeout: 20000 }); };
|
||||
const paymentButton = (p, kind) => p.getByTestId(mobile ? 'payment-batch-mobile-actions' : 'payment-batch-action-panel').getByTestId(`payment-batch-action-${kind}`);
|
||||
async function submit(p, fill, confirm = '确认') {
|
||||
const d = p.getByRole('dialog'); if (fill) await fill(d);
|
||||
const response = p.waitForResponse(r => r.url().startsWith(`${origin}/api/`) &&
|
||||
!r.url().includes('/api/auth/') && ['POST', 'PATCH'].includes(r.request().method()));
|
||||
await d.getByRole('button', { name: confirm, exact: true }).click();
|
||||
const result = await response;
|
||||
if (!result.ok()) {
|
||||
const failure = (await result.json().catch(() => ({}))).details?.reasonCode;
|
||||
throw new Error(`related ${s.mode} ${confirm}: HTTP ${result.status()} ${failure ?? ''}`);
|
||||
}
|
||||
try { await expect(d).not.toBeVisible({ timeout: 15000 }); }
|
||||
catch { throw new Error(`related ${s.mode} dialog: ${await d.getByRole('alert').allTextContents()}`); }
|
||||
return result;
|
||||
}
|
||||
async function recon(p, label, fill) {
|
||||
await p.getByRole('region', { name: '银行结果对账', exact: true }).getByRole('button', { name: label, exact: true }).click();
|
||||
return submit(p, async d => { if (fill) await fill(d); if (await d.getByLabel('依据与说明').count()) await d.getByLabel('依据与说明').fill('合成材料唯一定位具体银行指令及全部候选批次'); });
|
||||
}
|
||||
const review = (p, label) => recon(p, label, d => d.getByLabel('复核结论').selectOption('approve'));
|
||||
async function upload(p, kind) {
|
||||
const response = await recon(p, '上传银行证据', async d => { await d.getByLabel('证据类别').selectOption(kind); await d.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await f.pdf() }); });
|
||||
return (await response.json()).data.id;
|
||||
}
|
||||
async function receiptUpload(p, b, cents, ref) {
|
||||
const response = p.waitForResponse(r => r.url().endsWith(`/payment-batches/${b.id}/receipt-attachments`) && r.request().method() === 'POST');
|
||||
await p.getByTestId('payment-batch-receipt-file-input').setInputFiles({ name: 'synthetic-receipt.pdf', mimeType: 'application/pdf', buffer: await f.pdf() });
|
||||
assert.equal((await response).status(), 200);
|
||||
const result = await f.processUploadedReceipt(cents, ref, { payeeAccount: s.lender.bankAccount });
|
||||
await p.reload(); return result;
|
||||
}
|
||||
try {
|
||||
const fp = await login(finance); const mp = await login(manager);
|
||||
await fp.getByRole('tab', { name: '公司代表授权', exact: true }).click();
|
||||
await fp.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
|
||||
const dialog = fp.getByRole('dialog');
|
||||
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached();
|
||||
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${s.lender.id}"]`).count()); i++) {
|
||||
assert.ok(i < 12); await expect(dialog.getByRole('button', { name: '下一页出借人' })).toBeEnabled();
|
||||
const response = fp.waitForResponse(r => r.url().includes('/loan-lenders?page=')); await dialog.getByRole('button', { name: '下一页出借人' }).click(); await response;
|
||||
}
|
||||
await dialog.getByLabel('授权出借人').selectOption(s.lender.id);
|
||||
await dialog.getByRole('checkbox', { name: new RegExp(s.loan.loanNo) }).check();
|
||||
await submit(fp, d => d.getByLabel('代表权及授权范围依据').fill('合成已确认公司代表权,限定本公司与这笔借款'));
|
||||
const a = await prisma.loanRelatedAuthorization.findFirstOrThrow({ where: { lender_id: s.lender.id }, orderBy: { version: 'desc' } });
|
||||
await expect(fp.getByRole('button', { name: '代表公司确认授权范围', exact: true })).toHaveCount(0);
|
||||
await mp.reload();
|
||||
await expect(mp.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0);
|
||||
await expect(mp.getByRole('button', { name: '登记公司代表授权范围', exact: true })).toHaveCount(0);
|
||||
const card = mp.locator('section.card-std').filter({ hasText: s.lender.name });
|
||||
await expect(card).toBeVisible();
|
||||
await card.getByRole('button', { name: '代表公司确认授权范围' }).click();
|
||||
await expect(mp.getByRole('dialog')).toContainText(s.loan.loanNo);
|
||||
await submit(mp, d => d.getByLabel('确认或停止的理由').fill('合成代表公司确认已登记范围与本人关联身份'));
|
||||
await fp.getByRole('tab', { name: '出借人', exact: true }).click();
|
||||
const lenderCard = fp.locator('section.card-std').filter({ hasText: s.lender.name });
|
||||
for (let page = 1; !(await lenderCard.count()); page++) {
|
||||
assert.ok(page < 20);
|
||||
const next = fp.getByRole('button', { name: '下一页', exact: true }); await expect(next).toBeEnabled();
|
||||
const loaded = fp.waitForResponse(r => r.url().includes(`/loan-lenders?page=${page + 1}&`));
|
||||
await next.click(); await loaded;
|
||||
await expect(fp.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
|
||||
}
|
||||
await fp.getByRole('button', { name: '申请还本', exact: true }).click();
|
||||
const creation = fp.getByRole('dialog'); await creation.getByLabel('出借人', { exact: true }).selectOption(s.lender.id);
|
||||
await creation.getByLabel(`分配 ${s.loan.originReference}`, { exact: true }).fill('10.00');
|
||||
await submit(fp, d => d.getByLabel('说明', { exact: true }).fill('合成关联方正常还本'), '提交还款申请');
|
||||
await fp.waitForURL(/payment-batches\?id=/);
|
||||
const b = await prisma.paymentBatch.findFirstOrThrow({ where: { loan_repayment: { lender_id: s.lender.id } }, orderBy: { created_at: 'desc' }, include: { items: true } });
|
||||
await go(mp, b);
|
||||
await expect(mp.getByTestId('payment-batch-action-downloadOrder')).toHaveCount(0);
|
||||
await mp.getByRole('button', { name: '查看完整账号', exact: true }).click();
|
||||
await mp.getByRole('dialog').getByLabel('查看收款账号原因').fill('合成公司代表核对本笔收款账号');
|
||||
await mp.getByRole('dialog').getByRole('button', { name: '确认查看', exact: true }).click();
|
||||
await expect(mp.getByRole('dialog')).toContainText(s.lender.bankAccount);
|
||||
await mp.getByRole('dialog').getByRole('button', { name: '返回', exact: true }).click();
|
||||
await paymentButton(mp, 'review').click(); await submit(mp, undefined, '代表公司确认');
|
||||
await go(fp, b); await paymentButton(fp, 'downloadOrder').click();
|
||||
const downloaded = fp.waitForEvent('download'); await submit(fp, undefined, '确认下载'); await downloaded;
|
||||
assert.equal((await receiptUpload(fp, b, '1000', `RP-UI-${s.mode}`)).status, 'matched');
|
||||
await expect(paymentButton(fp, 'execute')).toBeEnabled({ timeout: 15000 });
|
||||
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
|
||||
assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: b.loan_repayment_id } })).reservation_state, 'settled');
|
||||
await expect(fp.getByLabel('关联方还款模式')).toBeVisible();
|
||||
checks.push(`${s.mode}_related_scope_company_confirmation_payee_read_export_ocr_settlement`);
|
||||
|
||||
const returned = await f.draft(s, '9.00'); await go(mp, returned);
|
||||
await mp.getByRole('button', { name: '更多', exact: true }).click();
|
||||
await mp.getByRole('menuitem', { name: '退回付款申请', exact: true }).click();
|
||||
await submit(mp, d => d.getByLabel('付款退回理由').fill('合成公司代表退回,请更正付款金额'), '确认退回');
|
||||
await expect(mp.getByTestId('payment-batch-action-review')).toHaveCount(0);
|
||||
assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: returned.loanRepaymentId } })).reservation_state, 'reserved');
|
||||
await go(fp, returned); await expect(fp.getByText('退回未释放占用,财务须取消原申请后重新提交。', { exact: false })).toBeVisible();
|
||||
await fp.getByRole('button', { name: '更多', exact: true }).click();
|
||||
await fp.getByRole('menuitem', { name: '取消批次', exact: true }).click();
|
||||
await submit(fp, d => d.getByLabel('批次取消原因').fill('合成按公司代表退回要求取消后重新提交'), '确认取消');
|
||||
assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: returned.loanRepaymentId } })).reservation_state, 'released');
|
||||
checks.push(`${s.mode}_related_return_reason_finance_cancel_and_retained_decision`);
|
||||
|
||||
const manual = await f.batch(s, '20.00'); await go(fp, manual);
|
||||
assert.equal((await receiptUpload(fp, manual, null, `RP-UI-MANUAL-${s.mode}`)).status, 'needs_review');
|
||||
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
|
||||
let d = fp.getByRole('dialog'); await d.getByLabel('第1页对应付款明细').selectOption(manual.items[0].id);
|
||||
await d.getByRole('button', { name: '更正识别', exact: true }).click();
|
||||
await d.getByLabel('第1页更正小写金额').fill('20.00');
|
||||
await submit(fp, d => d.getByLabel('更正说明(仅关联可留空)').fill('合成回单原件金额清晰,人工补录'), '保存核验');
|
||||
await go(mp, manual); await mp.getByRole('button', { name: '核验详情', exact: true }).click();
|
||||
await expect(mp.getByRole('dialog').getByRole('button', { name: '保存核验' })).toHaveCount(0);
|
||||
await submit(mp, d => d.getByLabel('复核说明').fill('合成公司代表逐项核对回单原件'), '公司代表确认');
|
||||
await go(fp, manual); await expect(paymentButton(fp, 'execute')).toBeEnabled();
|
||||
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
|
||||
checks.push(`${s.mode}_related_manual_receipt_confirmation_and_rebuilt_settlement`);
|
||||
|
||||
const failed = await f.batch(s, '7.00'); await f.batch(s, '7.00'); const ref = `RP-UI-FAIL-${s.mode}`.toUpperCase();
|
||||
await f.receipt(failed.id, '700', ref, { payeeAccount: s.lender.bankAccount, transactionStatus: 'failed' });
|
||||
await go(fp, failed);
|
||||
await expect(fp.getByRole('button', { name: '登记人工未提交', exact: true })).toHaveCount(0);
|
||||
await recon(fp, '登记银行提交', async d => { await d.getByLabel('使用的导出文件(手工录入可不选)').selectOption({ index: 1 }); await d.getByLabel('银行指令号').fill(ref); await d.getByLabel('实际提交时间').fill(new Date().toISOString().slice(0, 16)); });
|
||||
await recon(fp, '确认提交清单完整'); await go(mp, failed); await review(mp, '公司代表确认提交清单');
|
||||
await go(fp, failed); await recon(fp, '登记失败回单归属', async d => { await d.getByLabel('对应银行提交').selectOption({ index: 1 }); await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 }); await d.getByLabel('排除其他候选批次的依据').fill('合成其他同金额批次对应不同的银行指令'); });
|
||||
await go(mp, failed); await review(mp, '公司代表确认失败归属');
|
||||
await go(fp, failed); await recon(fp, '登记银行终态事实', async d => { await d.getByLabel('对应银行提交').selectOption({ index: 1 }); await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 }); });
|
||||
await go(mp, failed); await review(mp, '公司代表确认终态事实'); await recon(mp, '确认银行未付款');
|
||||
await go(fp, failed); await recon(fp, '发起补证恢复'); await recon(fp, '送审补证恢复');
|
||||
await go(mp, failed); await review(mp, '公司代表确认补证恢复');
|
||||
await go(fp, failed); await recon(fp, '重验依据并取消释放');
|
||||
assert.equal(await prisma.loanRepaymentAllocation.count({ where: { repayment_id: failed.loanRepaymentId, reservation_state: 'reserved' } }), 0);
|
||||
checks.push(`${s.mode}_related_submission_failure_attribution_terminal_recovery_release`);
|
||||
|
||||
const late = await f.setup('100.00'); const old = await f.batch(late, '80.00'); await f.terminate(late, old, true);
|
||||
const paid = await f.batch(late, '60.00'); await f.receipt(paid.id, '6000', `RP-UI-ALREADY-${s.mode}`, { payeeAccount: late.lender.bankAccount });
|
||||
// Seed the distinct already-paid transaction; every new reconciliation
|
||||
// action under test below is made through the rendered dialogs.
|
||||
const execute = await h.relatedParty.helpers.execute(paid); assert.equal(execute.item.status, 'executed');
|
||||
const count = await prisma.paymentBatch.count();
|
||||
async function bankFields(d, amount, reference, evidenceId, incoming = false) {
|
||||
await d.getByLabel('对应的成功银行回单').selectOption(evidenceId);
|
||||
await d.getByLabel('回单付款账号').fill(incoming ? late.lender.bankAccount : '8'.repeat(16)); await d.getByLabel('回单收款账号').fill(incoming ? '8'.repeat(16) : late.lender.bankAccount);
|
||||
await d.getByLabel('回单人民币金额').fill(amount); await d.getByLabel('回单流水号').fill(reference);
|
||||
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成原件指明原批次,逐笔排除其他同金额付款');
|
||||
}
|
||||
await go(fp, old); const lateEvidence = await upload(fp, 'late_success'); await recon(fp, '登记晚到成功', d => bankFields(d, '80.00', `RP-UI-LATE-${s.mode}`, lateEvidence));
|
||||
await go(mp, old); await review(mp, '公司代表确认银行事实'); await go(fp, old); await recon(fp, '登记补记或追回');
|
||||
await expect(fp.getByRole('region', { name: '银行结果对账', exact: true })).toContainText('待追回 ¥40.00');
|
||||
for (const [amount, left] of [['15.00', '25.00'], ['25.00', '0.00']]) {
|
||||
const returnEvidence = await upload(fp, 'incoming_return'); await recon(fp, '登记追回到账', d => bankFields(d, amount, `RP-UI-RETURN-${s.mode}-${amount}`, returnEvidence, true));
|
||||
await go(mp, old); await review(mp, '公司代表确认银行事实'); await go(fp, old); await recon(fp, '登记补记或追回');
|
||||
await expect(fp.getByRole('region', { name: '银行结果对账', exact: true })).toContainText(`待追回 ¥${left}`);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lender.id, status: 'active' } }), left === '0.00' ? 0 : 1);
|
||||
}
|
||||
assert.equal(await prisma.paymentBatch.count(), count);
|
||||
checks.push(`${s.mode}_related_late_success_partial_and_full_return_no_payment_batch`);
|
||||
const duplicateEvidence = await upload(fp, 'late_success'); await recon(fp, '登记晚到成功', d => bankFields(d, '80.00', `RP-UI-LATE-${s.mode}`, duplicateEvidence)); const correctionEvidence = await upload(fp, 'bank_terminal');
|
||||
await go(mp, old); await recon(mp, '公司代表确认银行事实', async d => {
|
||||
await d.getByLabel('复核结论').selectOption('reject');
|
||||
const field = d.getByLabel('误关联或重复登记的更正证据(晚到成功必填)'); await field.selectOption(correctionEvidence);
|
||||
await d.getByLabel('关闭原因').selectOption('duplicate');
|
||||
});
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lender.id, status: 'active' } }), 0);
|
||||
await expect(mp.getByRole('region', { name: '银行结果对账', exact: true })).toContainText('已确认误报');
|
||||
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);
|
||||
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`);
|
||||
const originalCard = mp.locator('section.card-std').filter({ hasText: s.lender.name });
|
||||
await originalCard.getByRole('button', { name: '停止新付款授权', exact: true }).click();
|
||||
await submit(mp, d => d.getByLabel('确认或停止的理由').fill('合成暂停后续新付款,保留对账'));
|
||||
assert.equal((await prisma.loanRelatedAuthorizationEvent.findFirstOrThrow({ where: { authorization_id: a.id }, orderBy: { sequence: 'desc' } })).payment_enabled, false);
|
||||
checks.push(`${s.mode}_technical_employee_excluded_and_scope_stop_via_ui`);
|
||||
} catch (error) {
|
||||
for (const [index, context] of contexts.entries()) {
|
||||
const p = context.pages()[0]; if (!p) continue;
|
||||
await p.screenshot({ path: path.join(output, `failed-${s.mode}-${index}.png`), fullPage: true }).catch(() => {});
|
||||
writeFileSync(path.join(output, `failed-${s.mode}-${index}.json`), JSON.stringify({
|
||||
path: new URL(p.url()).pathname, alerts: await p.getByRole('alert').allTextContents(),
|
||||
headings: await p.getByRole('heading').allTextContents(), buttons: await p.getByRole('button').allTextContents(),
|
||||
}, null, 2));
|
||||
}
|
||||
throw error;
|
||||
} finally { for (const context of contexts) await context.close(); }
|
||||
}
|
||||
} finally { config.appOrigin = oldOrigin; if (oldWait === undefined) delete config.loanReleaseWaitDays; else config.loanReleaseWaitDays = oldWait; if (own) await browser.close(); }
|
||||
console.log(JSON.stringify({ stage: 'loan_related_browser', status: 'PASS', checks, output: path.relative(root, output) }));
|
||||
return checks;
|
||||
};
|
||||
@@ -0,0 +1,421 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
|
||||
// Two real authenticated employees perform every related-party action. Other
|
||||
// actors are used only for refusal tests; all documents and accounts are synthetic.
|
||||
module.exports = async function relatedFixture(h) {
|
||||
const { prisma, pool, app, req, call, finance, manager, ordinary, independent, config,
|
||||
verifiedLender, confirmedLoan, repay, exportOrder, receipt } = h;
|
||||
const checks = [];
|
||||
const base = b => `/payment-batches/${b.id}`;
|
||||
const approve = (b, who = manager, status = 200) => call(who, 'POST', `${base(b)}/review`, {}, status);
|
||||
const cancel = (b, extra = {}, status = 200, reason) => call(finance, 'POST', `${base(b)}/cancel`, { reason: '合成有依据释放占用', ...extra }, status, reason);
|
||||
const balance = s => call(finance, 'GET', `/company-loans/${s.loan.id}`);
|
||||
const action = (a, name, who = finance, status = 200) => call(who, 'POST', `/loan-related-authorizations/${a.id}/actions`, { action: name, reason: '合成公司授权范围确认或停止' }, status);
|
||||
async function authorization(lender, loans, confirm = true) {
|
||||
const body = { lenderId: lender.id, loanIds: loans.map(l => l.id), basis: '合成已确认的公司代表权及指定借款范围', requestKey: randomUUID() };
|
||||
const a = await call(finance, 'POST', '/loan-related-authorizations', body);
|
||||
assert.equal((await call(finance, 'POST', '/loan-related-authorizations', body)).id, a.id);
|
||||
if (confirm) await action(a, 'confirm', manager);
|
||||
return a;
|
||||
}
|
||||
async function setup(principal = '500.00', confirm = true) {
|
||||
const lender = await verifiedLender(`合成关联方${randomUUID().slice(0, 6)}`, manager.id, '6');
|
||||
const loan = await confirmedLoan(lender.id, principal, `合成关联借入${randomUUID().slice(0, 6)}`);
|
||||
const auth = confirm === null ? null : await authorization(lender, [loan], confirm);
|
||||
return { lender, loan, auth };
|
||||
}
|
||||
async function batch(s, amount = '10.00', exported = true) {
|
||||
const b = await repay(s.lender.id, s.loan.id, amount);
|
||||
assert.equal(b.loanRepayment.approvalMode, 'related_party');
|
||||
await approve(b); if (exported) await exportOrder(b.id);
|
||||
return b;
|
||||
}
|
||||
const fields = (s, amount, ref, incoming = false) => [
|
||||
{ Name: '付款账号', Value: incoming ? s.lender.bankAccount : '8'.repeat(16) },
|
||||
{ Name: '收款账号', Value: incoming ? '8'.repeat(16) : s.lender.bankAccount },
|
||||
{ Name: '金额', Value: amount }, { Name: '币种', Value: 'CNY' },
|
||||
{ Name: '交易状态', Value: '交易成功' }, { Name: '流水号', Value: ref },
|
||||
];
|
||||
const pdf = async () => { const doc = await req('pdf-lib').PDFDocument.create(); doc.setTitle(`synthetic-${randomUUID()}`); doc.addPage([200, 200]); return Buffer.from(await doc.save()); };
|
||||
async function evidence(b, kind = 'bank_terminal', who = finance, status = 200) {
|
||||
const boundary = `synthetic-${randomUUID()}`;
|
||||
const res = await app.inject({ method: 'POST', url: `/api${base(b)}/loan-terminal-evidences?kind=${kind}`,
|
||||
headers: { ...who.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`), await pdf(), Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
||||
assert.equal(res.statusCode, status, `related evidence: ${res.statusCode}`);
|
||||
return res.json().data?.id;
|
||||
}
|
||||
async function submission(b, ref = `RP-${randomUUID()}`, exported = null) {
|
||||
const exp = exported ?? await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: b.id }, orderBy: { sequence: 'desc' } });
|
||||
return call(finance, 'POST', `${base(b)}/loan-bank-submissions`, { channel: exp ? 'online_banking_import' : 'manual_entry',
|
||||
...(exp ? { exportId: exp.id } : {}), bankInstructionRef: ref, submittedAt: new Date().toISOString(), note: '合成实际向银行提交的指令' });
|
||||
}
|
||||
async function attest(b) {
|
||||
const p = await call(finance, 'POST', `${base(b)}/loan-submission-attestation`, { note: '合成逐笔核对全部实际银行提交' });
|
||||
await call(manager, 'POST', `${base(b)}/loan-submission-attestation/${p.id}/review`, { decision: 'approve', reason: '合成公司代表确认清单完整' });
|
||||
return p;
|
||||
}
|
||||
async function fact(b, sub, input = {}) {
|
||||
return call(finance, 'POST', `${base(b)}/loan-terminal-facts`, { submissionId: sub.id, kind: 'termination_evidence',
|
||||
evidenceId: await evidence(b), terminalStatus: 'failed', note: '合成银行明确该指令失败且不会再执行', ...input });
|
||||
}
|
||||
const review = (b, p, type = 'loan-terminal-facts', status = 200, who = manager, more = {}) => call(who, 'POST', `${base(b)}/${type}/${p.id}/review`, { decision: 'approve', reason: '合成公司代表按原件确认银行事实', ...more }, status);
|
||||
const notPaid = (b, status = 200, reason) => call(manager, 'POST', `${base(b)}/loan-not-paid/review`, {}, status, reason);
|
||||
async function terminate(s, b, release = false) {
|
||||
const sub = await submission(b); await attest(b); const f = await fact(b, sub); await review(b, f); await notPaid(b);
|
||||
if (release) await cancel(b);
|
||||
return { sub, f, evidenceId: (await prisma.loanBankTerminalFact.findUniqueOrThrow({ where: { id: f.id } })).evidence_id };
|
||||
}
|
||||
async function attribution(b, extra = {}) {
|
||||
return call(finance, 'POST', `${base(b)}/loan-receipt-attribution`, { basisKind: 'bank_written_confirmation',
|
||||
basisNote: '合成银行查询唯一定位此交易对应本批次', exclusionNote: '合成逐笔排除其余同金额批次的付款指令', ...extra });
|
||||
}
|
||||
async function bankEvent(s, b, kind = 'late_success', amount = '80.00', parentId, ref = `RP-EVENT-${randomUUID()}`) {
|
||||
return call(finance, 'POST', `${base(b)}/loan-bank-results`, { kind, ...(parentId ? { parentId } : {}),
|
||||
evidenceId: await evidence(b, kind === 'return' ? 'incoming_return' : 'late_success'), requestKey: randomUUID(),
|
||||
fields: fields(s, amount, ref, kind === 'return'), attributionNote: '合成原件明确对应原批次银行付款', exclusionNote: '合成逐笔排除其他所有相同金额候选' });
|
||||
}
|
||||
const apply = (b, e, status = 200) => call(finance, 'POST', `${base(b)}/loan-bank-results/${e.id}/apply`, {}, status);
|
||||
// Deliberately corrupt only this isolated fixture's decision metadata to
|
||||
// verify the final consumer independently of the DB write guard. Always
|
||||
// restore both the original context and trigger before leaving the scenario.
|
||||
async function damagedDecision(table, id, check) {
|
||||
assert.ok(['payment_batches', 'payment_receipt_manual_proposals'].includes(table));
|
||||
const original = (await pool.query(`SELECT loan_approval_context FROM ${table} WHERE id=$1`, [id])).rows[0].loan_approval_context;
|
||||
async function put(context) {
|
||||
const tx = await pool.connect();
|
||||
try {
|
||||
await tx.query('BEGIN'); await tx.query(`ALTER TABLE ${table} DISABLE TRIGGER loan_decision_context`);
|
||||
await tx.query(`UPDATE ${table} SET loan_approval_context=$1 WHERE id=$2`, [JSON.stringify(context), id]);
|
||||
await tx.query('SET CONSTRAINTS ALL IMMEDIATE');
|
||||
await tx.query(`ALTER TABLE ${table} ENABLE TRIGGER loan_decision_context`); await tx.query('COMMIT');
|
||||
} catch (e) { await tx.query('ROLLBACK'); throw e; } finally { tx.release(); }
|
||||
}
|
||||
await put({ ...original, authorizationEventId: 'damaged-synthetic-event' });
|
||||
try { await check(); } finally { await put(original); }
|
||||
}
|
||||
const originalWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 0;
|
||||
const ui = [];
|
||||
try {
|
||||
if (process.env.RUN_ISOLATED_LOAN_RELATED_UI_ONLY !== '1') {
|
||||
{
|
||||
const s = await setup('100.00', null);
|
||||
const old = await repay(s.lender.id, s.loan.id, '1.00');
|
||||
assert.equal(old.loanRepayment.approvalMode, 'independent');
|
||||
const a = await authorization(s.lender, [s.loan], false);
|
||||
await action(a, 'confirm', finance, 403); await action(a, 'confirm', ordinary, 403);
|
||||
await approve(old, manager, 403);
|
||||
await action(a, 'confirm', manager);
|
||||
await approve(old, manager, 403); await approve(old, independent); await cancel(old);
|
||||
const outside = await confirmedLoan(s.lender.id, '10.00', `RP-OUT-${randomUUID()}`);
|
||||
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: s.lender.id, reason: '合成范围外申请', allocations: [{ loanId: outside.id, amount: '1.00' }] }, 409, 'loan.related_scope_invalid');
|
||||
const other = await setup();
|
||||
await call(finance, 'POST', '/loan-related-authorizations', { lenderId: s.lender.id, loanIds: [other.loan.id], basis: '合成不得跨出借人混入范围', requestKey: randomUUID() }, 409);
|
||||
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: s.lender.id, reason: '合成客户端伪造模式', approvalMode: 'related_party', allocations: [{ loanId: s.loan.id, amount: '1.00' }] }, 400);
|
||||
const b = await batch(s, '2.00', false);
|
||||
await call(ordinary, 'GET', `/loan-related-authorizations/${a.id}`, undefined, 403);
|
||||
await call(independent, 'GET', `/loan-related-authorizations/${a.id}`, undefined, 403);
|
||||
await call(manager, 'GET', '/company-loans', undefined, 403);
|
||||
await call(ordinary, 'GET', base(b), undefined, 403);
|
||||
const scoped = await call(manager, 'GET', `/loan-related-authorizations/${a.id}`);
|
||||
assert.deepEqual(scoped.loans.map(l => l.id), [s.loan.id]);
|
||||
assert.ok(!JSON.stringify(scoped).includes(s.lender.bankAccount));
|
||||
await call(manager, 'POST', `/company-loans/${outside.id}/evidences/${outside.evidences?.[0]?.id ?? randomUUID()}/download`, { reason: '合成范围外材料', authorizationId: a.id }, 404);
|
||||
await assert.rejects(pool.query("UPDATE loan_repayments SET approval_mode='related_party',authorization_id=$1,approval_context=$2 WHERE id=$3", [a.id, JSON.stringify(b.loanRepayment), old.loanRepaymentId]), e => e.code === '23514');
|
||||
await assert.rejects(pool.query('UPDATE loan_related_authorizations SET basis=$1 WHERE id=$2', ['合成禁止改写授权', a.id]), e => e.code === '23514');
|
||||
await assert.rejects(pool.query('DELETE FROM loan_related_authorization_events WHERE authorization_id=$1', [a.id]), e => e.code === '23514');
|
||||
await assert.rejects(pool.query('INSERT INTO loan_related_authorization_loans(authorization_id,loan_id) VALUES($1,$2)', [a.id, outside.id]), e => e.code === '23514');
|
||||
const stored = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: b.id } });
|
||||
assert.equal(stored.loan_approval_mode, 'related_party'); assert.equal(stored.loan_authorization_id, a.id);
|
||||
await assert.rejects(pool.query("UPDATE loan_repayments SET approval_context=jsonb_set(approval_context,'{authorizationVersion}','99') WHERE id=$1", [b.loanRepaymentId]), e => e.code === '23514');
|
||||
await assert.rejects(pool.query('UPDATE payment_batches SET reviewed_by=$1 WHERE id=$2', [finance.id, b.id]), e => e.code === '23514');
|
||||
await assert.rejects(pool.query("UPDATE payment_batches SET loan_approval_context=jsonb_set(loan_approval_context,'{purpose}','\"reconcile\"') WHERE id=$1", [b.id]), e => e.code === '23514');
|
||||
await cancel(b);
|
||||
}
|
||||
checks.push('rp_scope_activation_exact_identity_old_mode_immutable_and_scoped_reads');
|
||||
{
|
||||
const s = await setup('100.00', false);
|
||||
await pool.query(`CREATE FUNCTION synthetic_related_authority_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF NEW.action='LOAN_RELATED_AUTHORIZATION_DECIDED' THEN RAISE EXCEPTION 'synthetic authority audit failure'; END IF; RETURN NEW; END $$`);
|
||||
await pool.query('CREATE TRIGGER synthetic_related_authority_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_related_authority_audit_failure()');
|
||||
try { await action(s.auth, 'confirm', manager, 500); }
|
||||
finally {
|
||||
await pool.query('DROP TRIGGER synthetic_related_authority_audit_failure ON audit_logs');
|
||||
await pool.query('DROP FUNCTION synthetic_related_authority_audit_failure()');
|
||||
}
|
||||
assert.equal(await prisma.loanRelatedAuthorizationEvent.count({ where: { authorization_id: s.auth.id } }), 0);
|
||||
await action(s.auth, 'confirm', manager); const b = await batch(s, '1.00', false); await cancel(b);
|
||||
}
|
||||
checks.push('rp_authority_confirmation_audit_failure_rolls_back_activation');
|
||||
{
|
||||
const s = await setup(); const oldBatch = await batch(s, '2.00', false);
|
||||
const renewed = await authorization(s.lender, [s.loan]);
|
||||
await action(s.auth, 'stop_payment', finance, 409); await action(s.auth, 'stop_reconcile', manager, 409);
|
||||
assert.equal((await call(manager, 'GET', `/loan-related-authorizations/${renewed.id}`)).paymentEnabled, true);
|
||||
await assert.rejects(pool.query("INSERT INTO loan_related_authorization_events(id,authorization_id,sequence,action,payment_enabled,reconcile_enabled,actor_id,reason) VALUES($1,$2,2,'stop_payment',false,true,$3,'合成旧授权不得假装停止当前范围')", [randomUUID(), s.auth.id, finance.id]), e => e.code === '23514');
|
||||
const currentBatch = await batch(s, '3.00', false); const pending = await repay(s.lender.id, s.loan.id, '1.00');
|
||||
const context = (await prisma.paymentBatch.findUniqueOrThrow({ where: { id: currentBatch.id } })).loan_approval_context;
|
||||
const forged = { ...context, targetId: pending.id, batchId: pending.id, repaymentId: pending.loanRepaymentId, approverId: finance.id };
|
||||
await assert.rejects(pool.query("UPDATE payment_batches SET status='reviewed',reviewed_by=$1,reviewed_at=now(),loan_approval_mode='related_party',loan_authorization_id=$2,loan_approval_context=$3 WHERE id=$4", [manager.id, renewed.id, JSON.stringify(forged), pending.id]), e => e.code === '23514');
|
||||
assert.equal((await prisma.paymentBatch.findUniqueOrThrow({ where: { id: oldBatch.id } })).loan_authorization_id, s.auth.id);
|
||||
await cancel(pending); await cancel(currentBatch); await cancel(oldBatch);
|
||||
}
|
||||
checks.push('rp_renewed_scope_rejects_stale_stop_and_forged_first_decision_actor');
|
||||
{
|
||||
const s = await setup(); const b = await repay(s.lender.id, s.loan.id, '9.00');
|
||||
const endpoint = `${base(b)}/review`;
|
||||
await assert.rejects(pool.query("UPDATE payment_batches SET status='reviewed',reviewed_by=$1,reviewed_at=now() WHERE id=$2", [manager.id, b.id]), e => e.code === '23514');
|
||||
await call(finance, 'POST', endpoint, { decision: 'reject', comment: '合成经办人不得代替公司代表退回' }, 403);
|
||||
await call(manager, 'POST', endpoint, { decision: 'reject' }, 400);
|
||||
const before = await balance(s);
|
||||
await call(manager, 'POST', endpoint, { decision: 'reject', comment: '合成付款金额需更正,退回原申请' });
|
||||
const returned = (await call(finance, 'GET', base(b))).item;
|
||||
assert.equal(returned.status, 'draft'); assert.equal(returned.reviewRequirement.nextStep, 'returned');
|
||||
assert.equal(returned.relatedPaymentDecision, 'reject');
|
||||
assert.equal(returned.processingProgress.stages[1].result, '已退回');
|
||||
assert.equal(returned.processingProgress.stages[2].result, '待财务处理');
|
||||
assert.equal((await balance(s)).outstanding, before.outstanding); assert.equal((await balance(s)).reserved, before.reserved);
|
||||
await approve(b, manager, 409);
|
||||
await assert.rejects(pool.query("UPDATE payment_batches SET status='reviewed' WHERE id=$1", [b.id]), e => e.code === '23514');
|
||||
await assert.rejects(pool.query("UPDATE payment_batches SET review_comment='合成篡改退回理由' WHERE id=$1", [b.id]), e => e.code === '23514');
|
||||
const deniedExport = await app.inject({ method: 'POST', url: `/api${base(b)}/payment-order`, headers: finance.headers, payload: {} });
|
||||
assert.ok(deniedExport.statusCode >= 400 && deniedExport.statusCode < 500);
|
||||
assert.equal(await prisma.paymentOrderExport.count({ where: { payment_batch_id: b.id } }), 0);
|
||||
const notified = await prisma.notification.findMany({ where: { target_id: b.id, type: 'payment_batch.returned' } });
|
||||
assert.deepEqual(notified.map(n => n.recipient_employee_id), [finance.id]);
|
||||
await cancel(b); assert.equal((await balance(s)).reserved, '0.00');
|
||||
assert.equal((await prisma.paymentBatch.findUniqueOrThrow({ where: { id: b.id } })).loan_approval_context.decision, 'reject');
|
||||
const replacement = await batch(s, '8.00', false); assert.notEqual(replacement.id, b.id); await cancel(replacement);
|
||||
}
|
||||
checks.push('rp_return_keeps_reservation_immutable_reason_and_requires_finance_cancel_before_resubmit');
|
||||
{
|
||||
const s = await setup(); const b = await batch(s);
|
||||
const detail = (await call(manager, 'GET', base(b))).item;
|
||||
assert.equal(detail.loanRepayment.actionCapabilities.canPrepare, false);
|
||||
assert.equal(detail.loanRepayment.actionCapabilities.canReviewReconciliation, true);
|
||||
assert.equal(detail.processingProgress.stages[1].label, '公司代表关联方确认');
|
||||
const notifications = await prisma.notification.findMany({ where: { target_id: b.id, type: 'payment_batch.review_requested' } });
|
||||
assert.deepEqual(notifications.map(n => n.recipient_employee_id), [manager.id]);
|
||||
assert.ok(notifications[0].title.includes('关联方'));
|
||||
const audits = await prisma.auditLog.findMany({ where: { target_id: b.id, action: 'PAYMENT_BATCH_REVIEWED' } });
|
||||
assert.ok(JSON.stringify(audits).includes(s.auth.id));
|
||||
await action(s.auth, 'stop_payment');
|
||||
await exportOrder(b.id, 409, 'loan.related_authorization_required');
|
||||
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
|
||||
try {
|
||||
const rr = await receipt(b.id, '1000', `RP-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
||||
assert.equal(rr.result.status, 'matched'); await call(finance, 'POST', `${base(b)}/execute`, {});
|
||||
assert.equal((await balance(s)).outstanding, '490.00');
|
||||
} finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
|
||||
await call(finance, 'POST', `/loan-repayments/${b.loanRepaymentId}/accounting-handoff`, { reference: '合成已付会计交接' });
|
||||
}
|
||||
checks.push('rp_two_person_ocr_settlement_survives_payment_stop_and_historical_departure');
|
||||
{
|
||||
const s = await setup(); const b = await batch(s);
|
||||
await receipt(b.id, '1000', `RP-DAMAGED-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
||||
const before = await balance(s);
|
||||
await damagedDecision('payment_batches', b.id, async () => {
|
||||
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
|
||||
const after = await balance(s); assert.equal(after.outstanding, before.outstanding); assert.equal(after.reserved, before.reserved);
|
||||
});
|
||||
await call(finance, 'POST', `${base(b)}/execute`, {});
|
||||
}
|
||||
checks.push('rp_final_settlement_rejects_corrupt_approval_provenance_without_money_change');
|
||||
|
||||
{
|
||||
const s = await setup(); const b = await batch(s);
|
||||
const rr = await receipt(b.id, null, `RP-MANUAL-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
||||
assert.equal(rr.result.status, 'needs_review');
|
||||
const attachment = await prisma.paymentBatchReceiptAttachment.findUniqueOrThrow({ where: { id: rr.attachment.id } });
|
||||
const endpoint = `${base(b)}/receipt-attachments/${attachment.id}/manual-verification`;
|
||||
const p = (await call(finance, 'POST', endpoint, { expectedVersion: attachment.receipt_ocr_version, reason: '合成原件金额清晰人工录入',
|
||||
pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: [{ Name: '小写金额', Value: '10.00' }] }] })).item;
|
||||
const d = (await call(manager, 'GET', endpoint)).item;
|
||||
assert.equal(d.canReview, true); assert.equal(d.proposal.approvalMode, 'related_party');
|
||||
await call(manager, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成代表公司核对原件金额' });
|
||||
await damagedDecision('payment_receipt_manual_proposals', p.id, async () => {
|
||||
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
|
||||
assert.equal((await balance(s)).outstanding, '500.00'); assert.equal((await balance(s)).reserved, '10.00');
|
||||
});
|
||||
await action(s.auth, 'stop_payment'); await action(s.auth, 'stop_reconcile');
|
||||
// Cold detail/readiness and final consumer must rebuild the saved evidence
|
||||
// from its actual authorization, not apply independent-payee rules again.
|
||||
assert.equal((await call(finance, 'GET', `${base(b)}/receipt-readiness`)).item.ready, true);
|
||||
await call(finance, 'POST', `${base(b)}/execute`, {});
|
||||
assert.equal((await balance(s)).outstanding, '490.00');
|
||||
const stored = await prisma.paymentReceiptManualProposal.findUniqueOrThrow({ where: { id: p.id } });
|
||||
assert.equal(stored.loan_approval_mode, 'related_party');
|
||||
}
|
||||
checks.push('rp_manual_receipt_decision_cold_reconstruction_and_historical_consumption');
|
||||
{
|
||||
const s = await setup(); const a = await batch(s); await batch(s);
|
||||
await receipt(a.id, '1000', `RP-ATTR-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
||||
const before = await balance(s);
|
||||
await call(finance, 'POST', `${base(a)}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
assert.equal((await balance(s)).reserved, before.reserved);
|
||||
const p = await attribution(a); await review(a, p, 'loan-receipt-attribution');
|
||||
await batch(s); await call(finance, 'POST', `${base(a)}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
const p2 = await attribution(a); await review(a, p2, 'loan-receipt-attribution');
|
||||
await action(s.auth, 'stop_payment'); await call(finance, 'POST', `${base(a)}/execute`, {});
|
||||
assert.equal((await balance(s)).outstanding, '490.00');
|
||||
assert.equal((await prisma.loanReceiptAttribution.findUniqueOrThrow({ where: { id: p.id } })).status, 'superseded');
|
||||
}
|
||||
checks.push('rp_same_amount_attribution_staleness_and_reconfirmation_before_settlement');
|
||||
{
|
||||
const s = await setup(); const b = await batch(s); await batch(s);
|
||||
const ref = `RP-FAILED-${randomUUID()}`;
|
||||
const sub = await submission(b, ref); await attest(b);
|
||||
const rr = await receipt(b.id, '1000', ref, { payeeAccount: s.lender.bankAccount, transactionStatus: 'failed' });
|
||||
const endpoint = `${base(b)}/bank-failure-review`;
|
||||
await call(manager, 'POST', endpoint, { attachmentId: rr.attachment.id }, 409, 'loan.receipt_attribution_required');
|
||||
const p = await attribution(b, { kind: 'failure', attachmentId: rr.attachment.id, basisKind: 'bank_instruction_ref' });
|
||||
await review(b, p, 'loan-receipt-attribution');
|
||||
config.loanReleaseWaitDays = 3;
|
||||
await call(manager, 'POST', endpoint, { attachmentId: rr.attachment.id }, 409, 'loan.release_wait_period');
|
||||
config.loanReleaseWaitDays = 0;
|
||||
await call(manager, 'POST', endpoint, { attachmentId: rr.attachment.id });
|
||||
await action(s.auth, 'stop_reconcile'); await cancel(b, {}, 409, 'loan.related_authorization_required');
|
||||
const renew = await authorization(s.lender, [s.loan]); await action(renew, 'stop_payment');
|
||||
await cancel(b); assert.equal((await balance(s)).outstanding, '500.00');
|
||||
assert.equal(await prisma.loanRepaymentAllocation.count({ where: { repayment_id: b.loanRepaymentId, reservation_state: 'reserved' } }), 0);
|
||||
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: b.loanRepaymentId } })).authorization_id, s.auth.id);
|
||||
assert.ok(sub.id);
|
||||
}
|
||||
checks.push('rp_failed_attribution_wait_current_release_authority_and_new_grant_version');
|
||||
{
|
||||
const s = await setup(); const b = await batch(s); await exportOrder(b.id);
|
||||
const exps = await prisma.paymentOrderExport.findMany({ where: { payment_batch_id: b.id }, orderBy: { sequence: 'asc' } });
|
||||
const one = await submission(b, `RP-ONE-${randomUUID()}`, exps[0]);
|
||||
const two = await submission(b, `RP-TWO-${randomUUID()}`, exps[1]); await attest(b);
|
||||
const f1 = await fact(b, one); await review(b, f1);
|
||||
await notPaid(b, 409, 'loan.terminal_coverage_incomplete');
|
||||
const f2 = await fact(b, two); await review(b, f2); await notPaid(b);
|
||||
const ev = (await prisma.loanBankTerminalFact.findUniqueOrThrow({ where: { id: f1.id } })).evidence_id;
|
||||
await call(finance, 'POST', `${base(b)}/loan-terminal-evidences/${ev}/void`, { reason: '合成补证换版' });
|
||||
await cancel(b, {}, 409);
|
||||
const recovery = await call(finance, 'POST', `${base(b)}/loan-release-recoveries`, { requestKey: randomUUID(), reason: '合成重新补齐第一条指令终态' });
|
||||
const replacement = await fact(b, one); await review(b, replacement);
|
||||
await call(finance, 'POST', `${base(b)}/loan-release-recoveries/${recovery.id}/submit`, {});
|
||||
await review(b, recovery, 'loan-release-recoveries');
|
||||
await cancel(b, { recoveryId: recovery.id, requestKey: randomUUID() });
|
||||
assert.equal((await balance(s)).reserved, '0.00'); assert.equal((await balance(s)).outstanding, '500.00');
|
||||
}
|
||||
checks.push('rp_every_export_submission_terminal_and_evidence_recovery_full_consumer');
|
||||
{
|
||||
const s = await setup(); const b = await batch(s);
|
||||
await prisma.employeeRole.create({ data: { employee_id: manager.id, role_code: 'finance' } });
|
||||
await prisma.employeeRole.create({ data: { employee_id: finance.id, role_code: 'manager' } });
|
||||
try {
|
||||
await call(manager, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: s.lender.id, reason: '合成经办冲突', allocations: [{ loanId: s.loan.id, amount: '1.00' }] }, 403, 'loan.related_actor_conflict');
|
||||
for (const suffix of ['payment-order', 'loan-bank-submissions', 'loan-submission-attestation', 'loan-terminal-facts', 'loan-release-recoveries', 'loan-receipt-attribution', 'loan-bank-results', 'execute', 'cancel']) {
|
||||
await call(manager, 'POST', `${base(b)}/${suffix}`, {}, 403, 'loan.related_actor_conflict');
|
||||
}
|
||||
await evidence(b, 'bank_terminal', manager, 403);
|
||||
const sub = await submission(b); const f = await fact(b, sub);
|
||||
await review(b, f, 'loan-terminal-facts', 403, finance);
|
||||
const q = { exportIds: [(await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: b.id } })).id], queryEvidenceId: randomUUID(), statementEvidenceId: randomUUID(), exporterStatements: [{ exporterId: finance.id, evidenceId: randomUUID() }], coverageFrom: new Date().toISOString(), coverageThrough: new Date().toISOString(), note: '合成不可用的人工未提交例外' };
|
||||
await call(finance, 'POST', `${base(b)}/loan-not-submitted`, q, 409, 'loan.related_q6_unavailable');
|
||||
await call(manager, 'POST', `${base(b)}/loan-not-submitted/${randomUUID()}/review`, { decision: 'approve', reason: '合成不能伪造双人特别复核' }, 409, 'loan.related_q6_unavailable');
|
||||
const policy = req('./src/services/loanApprovalPolicy');
|
||||
const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: b.id } });
|
||||
await assert.rejects(() => policy.assertNotRelatedQ6(prisma, saved), e => e.details?.reasonCode === 'loan.related_q6_unavailable');
|
||||
const caps = (await call(manager, 'GET', base(b))).item.loanRepayment.actionCapabilities;
|
||||
assert.equal(caps.canPrepare, false); assert.equal(caps.canUseNotSubmitted, false);
|
||||
// A second token for the same employee is still the same identity.
|
||||
const login = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { identifier: manager.email, password: h.password }, headers: { 'x-device-fingerprint': randomUUID() } });
|
||||
assert.equal(login.statusCode, 200);
|
||||
await call({ headers: { authorization: `Bearer ${login.json().data.accessToken}` } }, 'POST', `${base(b)}/loan-bank-submissions`, {}, 403, 'loan.related_actor_conflict');
|
||||
} finally {
|
||||
await prisma.employeeRole.deleteMany({ where: { employee_id: manager.id, role_code: 'finance' } });
|
||||
await prisma.employeeRole.deleteMany({ where: { employee_id: finance.id, role_code: 'manager' } });
|
||||
}
|
||||
}
|
||||
checks.push('rp_only_payee_conflict_waived_multiple_roles_tokens_and_q6_refused');
|
||||
for (const [cancelled, paidAmount, reserveAmount] of [[false, '0.00', '0.00'], [true, '0.00', '70.00'], [true, '60.00', '0.00'], [true, '100.00', '0.00']]) {
|
||||
const s = await setup('100.00'); const b = await batch(s, '80.00'); await terminate(s, b, cancelled);
|
||||
if (paidAmount !== '0.00') {
|
||||
const p = await batch(s, paidAmount); await receipt(p.id, String(Math.round(Number(paidAmount) * 100)), `RP-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
||||
await call(finance, 'POST', `${base(p)}/execute`, {});
|
||||
}
|
||||
const reserved = reserveAmount !== '0.00' ? await repay(s.lender.id, s.loan.id, reserveAmount) : null;
|
||||
const count = await prisma.paymentBatch.count(); const e = await bankEvent(s, b);
|
||||
await review(b, e, 'loan-bank-results');
|
||||
if (reserved) { await apply(b, e); assert.equal((await balance(s)).outstanding, '100.00'); await cancel(reserved); }
|
||||
await action(s.auth, 'stop_payment'); await action(s.auth, 'stop_reconcile');
|
||||
await apply(b, e); await apply(b, e);
|
||||
const excess = Math.max(0, Number(paidAmount) - 20);
|
||||
const result = await prisma.loanSupplementalSettlement.findFirstOrThrow({ where: { event_id: e.id } });
|
||||
assert.equal(result.bank_amount.toFixed(2), '80.00'); assert.equal(result.receivable_amount.toFixed(2), excess.toFixed(2));
|
||||
assert.equal(await prisma.paymentBatch.count(), count);
|
||||
assert.equal((await balance(s)).reserved, '0.00');
|
||||
if (excess) {
|
||||
await authorization(s.lender, [s.loan]);
|
||||
for (const value of [15, excess - 15]) {
|
||||
const ret = await bankEvent(s, b, 'return', value.toFixed(2), e.id); await review(b, ret, 'loan-bank-results'); await apply(b, ret);
|
||||
if (value === 15) assert.ok(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }));
|
||||
}
|
||||
}
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
|
||||
}
|
||||
checks.push('rp_late_success_reserved_conflict_partial_and_full_excess_historical_booking_returns');
|
||||
{
|
||||
const s = await setup('100.00'); const b = await batch(s, '80.00'); await terminate(s, b, true);
|
||||
const e = await bankEvent(s, b); const e2 = await bankEvent(s, b, 'late_success', '80.00', undefined, `RP-OTHER-${randomUUID()}`);
|
||||
await review(b, e, 'loan-bank-results', 409, manager, { decision: 'reject' });
|
||||
const correction = await evidence(b);
|
||||
await review(b, e, 'loan-bank-results', 200, manager, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: correction });
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
||||
await review(b, e2, 'loan-bank-results', 200, manager, { decision: 'reject', correctionKind: 'duplicate', correctionEvidenceId: await evidence(b) });
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
|
||||
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event: { payment_batch_id: b.id } } }), 0);
|
||||
}
|
||||
checks.push('rp_evidenced_false_alarm_closes_only_its_own_freeze');
|
||||
{
|
||||
const { Pool } = req('pg'); const locks = new Pool({ ...pool.options, max: 3 });
|
||||
async function waitFor(n) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const rows = await locks.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'");
|
||||
if (rows.rows[0].n >= n) return; await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`related authority race did not queue ${n} requests`);
|
||||
}
|
||||
try {
|
||||
for (const kind of ['approval', 'export', 'fact', 'release']) for (const stopFirst of [true, false]) {
|
||||
const s = await setup('100.00'); const b = await repay(s.lender.id, s.loan.id, '10.00');
|
||||
let f;
|
||||
if (kind !== 'approval') await approve(b);
|
||||
if (['fact', 'release'].includes(kind)) { await exportOrder(b.id); const sub = await submission(b); await attest(b); f = await fact(b, sub); }
|
||||
if (kind === 'release') { await review(b, f); await notPaid(b); }
|
||||
const reconcile = ['fact', 'release'].includes(kind);
|
||||
const request = () => app.inject({ method: 'POST', headers: (['approval', 'fact'].includes(kind) ? manager : finance).headers,
|
||||
url: `/api${base(b)}/${kind === 'approval' ? 'review' : kind === 'export' ? 'payment-order' : kind === 'fact' ? `loan-terminal-facts/${f.id}/review` : 'cancel'}`,
|
||||
payload: kind === 'fact' ? { decision: 'approve', reason: '合成并发公司确认' } : kind === 'release' ? { reason: '合成并发最终释放' } : {} });
|
||||
const stop = () => app.inject({ method: 'POST', url: `/api/loan-related-authorizations/${s.auth.id}/actions`, headers: finance.headers,
|
||||
payload: { action: reconcile ? 'stop_reconcile' : 'stop_payment', reason: '合成并发停止授权' } });
|
||||
const holder = await locks.connect(); let decision; let stopped;
|
||||
try {
|
||||
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
|
||||
if (stopFirst) { stopped = stop(); await waitFor(1); decision = request(); }
|
||||
else { decision = request(); await waitFor(1); stopped = stop(); }
|
||||
await waitFor(2); await holder.query('COMMIT');
|
||||
const results = await Promise.all([decision, stopped]);
|
||||
assert.equal(results[1].statusCode, 200, `stop during ${kind}`);
|
||||
assert.equal(results[0].statusCode, stopFirst ? 409 : 200, `${kind} stopFirst=${stopFirst}: ${results[0].statusCode}`);
|
||||
if (stopFirst) assert.equal(results[0].json().details.reasonCode, 'loan.related_authorization_required');
|
||||
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
|
||||
const state = await balance(s);
|
||||
assert.equal(state.outstanding, '100.00'); assert.equal(state.reserved, kind === 'release' && !stopFirst ? '0.00' : '10.00');
|
||||
}
|
||||
} finally { await locks.end(); }
|
||||
}
|
||||
checks.push('rp_authority_stop_serializes_with_approval_export_fact_and_release_both_orders');
|
||||
}
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) {
|
||||
const s = await setup('100.00', null);
|
||||
ui.push({ ...s, mode });
|
||||
}
|
||||
} finally { if (originalWait === undefined) delete config.loanReleaseWaitDays; else config.loanReleaseWaitDays = originalWait; }
|
||||
console.log(JSON.stringify({ stage: 'loan_related_http', status: checks.length ? 'PASS' : 'NOT_RUN', checks }));
|
||||
return { checks, ui, helpers: { execute: b => call(finance, 'POST', `${base(b)}/execute`, {}), draft: (s, amount) => repay(s.lender.id, s.loan.id, amount), setup, authorization, batch, receipt, processUploadedReceipt: h.processUploadedReceipt, submission, attest, evidence, fact, review, notPaid, terminate, pdf, bankEvent } };
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户