完善付款回单凭证核验与本地重检(09-17-company-loan-principal-repayment)

区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。

正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。

本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。

本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。

限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
这个提交包含在:
2026-10-09 06:18:09 +08:00
父节点 ce11dea976
当前提交 5a7a13524e
共修改 31 个文件,包含 1325 行新增和 49 行删除
+49 -3
查看文件
@@ -4,6 +4,7 @@ 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');
const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs');
module.exports = async function relatedBrowser(h) {
const { finance, manager, ordinary, password, prisma, relatedParty, config } = h;
@@ -52,11 +53,15 @@ module.exports = async function relatedBrowser(h) {
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) {
async function receiptUpload(p, b, cents, ref, overrides = {}, document = {}) {
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() });
const status = overrides.transactionStatus === null ? 'booked' : overrides.transactionStatus ?? 'success';
await p.getByTestId('payment-batch-receipt-file-input').setInputFiles({ name: 'synthetic-receipt.pdf', mimeType: 'application/pdf',
buffer: await syntheticBankReceiptPdf({ status, tag: ref, reference: ref,
amount: cents === null ? String(b.items[0].amount) : `${BigInt(cents) / 100n}.${String(BigInt(cents) % 100n).padStart(2, '0')}`,
payerAccount: '8'.repeat(16), payeeAccount: s.lender.bankAccount, ...document }) });
assert.equal((await response).status(), 200);
const result = await f.processUploadedReceipt(cents, ref, { payeeAccount: s.lender.bankAccount });
const result = await f.processUploadedReceipt(cents, ref, { payeeAccount: s.lender.bankAccount, ...overrides });
await p.reload(); return result;
}
try {
@@ -141,6 +146,47 @@ module.exports = async function relatedBrowser(h) {
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
checks.push(`${s.mode}_related_manual_receipt_confirmation_and_rebuilt_settlement`);
const booked = await f.batch(s, '18.00'); await go(fp, booked);
assert.equal((await receiptUpload(fp, booked, '1800', `RP-UI-BOOKED-${s.mode}`, { transactionStatus: null })).status, 'needs_review');
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
d = fp.getByRole('dialog');
await expect(d).toContainText('状态:未确认');
await d.getByLabel('第1页对应付款明细').selectOption(booked.items[0].id);
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled();
await d.getByLabel('第1页付款凭证依据').selectOption('booked_debit');
await d.getByLabel('第1页原件记账日期').fill('2026-10-01');
await d.getByRole('button', { name: '更正识别', exact: true }).click();
await expect(d.getByLabel('第1页更正单据状态')).toHaveCount(0);
await d.getByLabel('更正说明(仅关联可留空)').fill('合成正式回单为本公司借方记账,未记载成功状态字样');
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled();
await d.getByRole('checkbox', { name: /已查看第1页完整原件/ }).check();
await submit(fp, undefined, '保存核验');
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
await expect(fp.getByRole('dialog').getByRole('button', { name: '公司代表确认', exact: true })).toHaveCount(0);
await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
await expect(fp.getByTestId('payment-batch-action-execute')).toHaveCount(0);
await go(mp, booked); await mp.getByRole('button', { name: '核验详情', exact: true }).click();
d = mp.getByRole('dialog');
await expect(d).toContainText('已记账支出(人工核对)'); await expect(d).toContainText('记账日期 2026-10-01');
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toHaveCount(0);
assert.equal(await mp.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
await mp.screenshot({ path: path.join(output, `related-${s.mode}-booked-proof.png`), fullPage: true });
await submit(mp, current => current.getByLabel('复核说明').fill('合成代表公司核对原件借方记账日期与付款明细'), '公司代表确认');
await go(fp, booked); await expect(paymentButton(fp, 'execute')).toBeEnabled();
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: booked.loanRepaymentId } })).reservation_state, 'settled');
checks.push(`${s.mode}_booked_receipt_proof_dialog_company_confirmation_and_settlement`);
const nonfinal = await f.batch(s, '12.00'); await go(fp, nonfinal);
assert.equal((await receiptUpload(fp, nonfinal, '1200', `RP-UI-NONFINAL-${s.mode}`, {}, { final: false })).status, 'needs_review');
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
d = fp.getByRole('dialog'); await expect(d).toContainText('非最终回执');
await expect(d.getByRole('button', { name: '更正识别', exact: true })).toHaveCount(0);
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled();
await d.getByRole('button', { name: '关闭', exact: true }).click();
await expect(fp.getByTestId('payment-batch-action-execute')).toHaveCount(0);
checks.push(`${s.mode}_nonfinal_receipt_has_no_manual_override_or_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);