diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md index d1538c98..6afb0cf3 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md @@ -11,7 +11,7 @@ - 已用于误报解冻的更正证据属于已消费的复核依据,服务及数据库均禁止再作废。更正记录、原始银行事件及关闭依据保留,不回写原结论。 - 终态界面支持一次关联多份同指令处理中回单。复核卡明确展示所选提交、终态类型、证据、了结回单;Q6 展示逐份导出及三类证据的准确绑定,失败归属展示覆盖指令及核验附件。历史作废回单仅可通过实际事实绑定、必填用途的对账读取查看;权限、隔离策略、物理摘要、审计及 no-store 保留,普通作废回单下载仍被拒绝,不恢复其付款效力。 - S8 沿用既有 React 对话框和样式,未引入新视觉方案。页面集中展示导出、提交、证据、人工声明、补证轮次、银行事件和冻结原因;原制单不得再次导入网银的提示不是技术保证。 -- S0(存量、合格复核人数、银行编号关系及官方材料)、真实回单验收仍未完成;本轮资金与界面代理已完成修复后只读静态复核,运行时结果见 implement。该复核不替代异模型或业务人员验收。本地实现/合成验证不代表可以部署或真实还款。 +- S0 存量已于 2026-10-07 获准只读核实:OA 借款记录为 0,发布前需重核。合格复核人数、银行编号关系及官方材料、真实回单验收仍未完成;本轮资金与界面代理已完成修复后只读静态复核,运行时结果见 implement。该复核不替代异模型或业务人员验收。本地实现/合成验证不代表可以部署或真实还款。 ## 2026-10-07 补证后恢复方案(SR,第 1 版,用户已批准本地实施) diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md index b3820bf3..2e89f904 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -1,11 +1,43 @@ ## 2026-10-07 后续收口执行(用户同意执行) -按后续工作清单,先将已验证的 S4–S8 共 32 项改动本地提交,提交保留“真实回单待验、整体业务验收未通过”。随后补齐提交登记、清单复核、失败归属、晚到补记和追回的浏览器闭环,并准备发布前存量/迁移/回退检查材料。当前 inline 主会话直接实现和检查,不另派代理;此前两名代理的复核仅覆盖其当时差异。生产查询单独确认,人员与银行材料待外部答复;推送、部署和远端备份切换不在本次默认执行内。以下“32 项未提交”属于提交前验证时点。 +按后续工作清单,先将已验证的 S4–S8 共 32 项改动本地提交,提交保留“真实回单待验、整体业务验收未通过”。随后补齐提交登记、清单复核、失败归属、晚到补记和追回的浏览器闭环,并准备发布前存量/迁移/回退检查材料。当前 inline 主会话直接实现和检查,不另派代理;此前两名代理的复核仅覆盖其当时差异。用户本轮已明确授权生产借款存量的只读汇总查询;人员与银行材料答复“不清楚”,保持待核实;推送、部署和远端备份切换不在本次默认执行内。以下“32 项未提交”属于提交前验证时点。 -- [ ] 本地提交已验证 S4–S8。 -- [ ] 补齐其余银行对账表单的电脑/手机闭环并修复发现的问题。 -- [ ] 在现有任务/恢复手册整理具体发布准备与未满足条件。 -- [ ] S0 生产只读核实及外部人员、银行材料确认(分别记录)。 +- [x] 本地提交已验证 S4–S8:`431c36ea`,32 项,未推送。 +- [x] 补齐其余银行对账表单的电脑/手机闭环;最终固定副本通过,在本次本地提交授权内将新增测试及 S0/发布准备记录单独提交。 +- [x] 在本任务记录具体迁移差集、发布顺序、回退限制和未满足条件。 +- [x] S0 生产存量只读核实完成;人员与银行材料仍待外部核实。 + +### 剩余界面验收补测 + +新增 `company-loan-reconciliation-browser.cjs`,由既有隔离浏览器入口调用。合成种子通过正式接口准备,实际待验动作由页面完成:电脑/手机各走提交登记→清单确认/独立复核→失败归属/独立复核→失败回单终态→取消;以及晚到成功原件上传/登记→用途审计查看输入→独立复核→补记→部分与全额追回→凭更正材料独立关闭重复误报。断言经办人无自审按钮、原占用释放、待追回变化、部分追回仍冻结、全部追回后关闭对应冻结、不创建新付款批次及手机无横向溢出。 + +未修改应用业务代码或新增资金规则。首轮用例把归一化后的大写指令号与小写测试预期比较而失败,已修正测试预期;后一轮通过。最新副本补明确选择新上传的更正证据,再验证最终用例。首轮失败不计通过;不新增变异测试或重复整套服务端单测,因为本轮仅补浏览器覆盖与文档。 + +复现:在隔离源码/构建副本内执行 `RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs`,BIFF8 使用已准备的受控 Python 环境。最终固定副本通过 81 迁移、HTTP 95、浏览器 19、数据库约束 9 组,自有副本/容器清理 PASS。脚本语法、任务、治理、流程合同、敏感扫描与 git diff --check 通过。应用代码与依赖未变,复用先前通过的构建、类型及单元测试证据。主会话遵循 inline 直接检查,没有新增代理复核,也不替代财务/银行事实验收。 + +### S0 只读核实及发布准备(2026-10-07) + +用户明确授权本轮生产借款存量只读汇总;通过既有生产连接,在 `REPEATABLE READ READ ONLY` 事务中查询并 `ROLLBACK`,设置查询/锁等待超时,未输出账户、原件或业务正文,未运行部署/迁移。核实时点 2026-10-07 22:08–22:09(北京时间)。 + +- OA 中出借人、借款、还款批次、占用分配和借款回单均为 0;已确认失败未取消也是 0。当前没有系统内借款存量需要回填;这不证明银行或线下没有付款。发布前必须再次核对,不能把本次空库结论永久复用。 +- 已应用迁移 73 个,未完成迁移 0;与本地同名迁移的 SHA-256 全部一致,没有生产多出的迁移。本地 81 个,差集为以下 8 个(不是仅本轮新增的 3 个): + 1. `20261006010000_loan_receipt_attribution` + 2. `20261007010000_loan_bank_submissions` + 3. `20261007020000_loan_failure_attribution` + 4. `20261008010000_loan_bank_terminal_facts` + 5. `20261008020000_loan_release_recoveries` + 6. `20261008030000_loan_not_submitted` + 7. `20261008040000_loan_freeze_reasons` + 8. `20261008050000_loan_bank_result_events` +- 人员、官方无结果 PDF、回单与指令编号关系:用户答复“不清楚”,均保留待核实;Q6 不能宣称具备实际使用条件。脱敏只读结果保存在本轮 S0 本地证据目录的 summary.json。 + +发布准备顺序(本轮只准备,不执行发布): +1. 完成剩余浏览器闭环及业务/真实样本验收,固定最终已提交候选;不得把本轮合成回归当作正式 release-gate 回执。 +2. 对固定干净候选运行 `bash scripts/qa/run-release-gate.sh verify-candidate`,绑定当次回执;当前仍有待验证/未提交的补测,不提前运行大范围门禁或写“可部署”。不能用 `all`/`ci-release` 代替本地验证,因为它们会进入生产发布链。 +3. 经单独批准后做发布前只读核实,将实际差集作为 `EXPECTED_PENDING_MIGRATIONS`。若生产出现新增借款/批次、迁移差异或未完成项,重新评估,不自动套用本次 8 项或空存量结论。 +4. 按既定部署前手动快照方案确认快照完成与恢复证据。备份脚本切换属于另一项远端授权,仅安装指定 refresh 脚本及执行既定停用顺序,不借此部署应用。 +5. 推送、部署各自批准后才执行迁移/应用发布;部署后核对应用版本、81 项预期迁移、readiness、权限和借款只读页面。真实借款录入/付款仍需业务明确批准,不能制造付款验收。 +6. 回退保留新增表和历史事实,不删迁移或重置数据库。一旦生成补记/追回等新事实,旧代码未计算新台账,不能直接降级;停止新付款并按获批兼容修复或恢复方案处理。确认此前没有新资金事实,也仍需具体回退审批,不能默认恢复覆盖期间其他业务。 ## 2026-10-07 后续执行接续(用户要求已确定工作直接执行) diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md index b6c47036..2b706464 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -2,7 +2,7 @@ ## 2026-10-07 本地阶段状态 -S4–S8 与 SR 界面已完成本地实现及合成回归:81 迁移、HTTP 95、浏览器 13、数据库约束 9 组通过,修复后的资金与界面只读静态复核通过(同模型)。S0、真实建行材料和业务独立验收仍未完成;不据此勾选整体真实还款验收或声明已部署。完整证据与限制见 implement.md 顶部。 +S4–S8 已本地提交 `431c36ea`;补齐剩余界面后合成回归为 81 迁移、HTTP 95、浏览器 19、数据库约束 9 组通过。应用代码沿用修复后的同模型只读静态复核,本轮只补测试与记录。S0 生产只读查询确认 OA 借款模块当前记录为 0,73 个已应用迁移校验值一致,待执行 8 个;不代表银行或线下没有付款。人员、真实建行材料和业务独立验收仍未完成,正式发布门禁尚待执行;不据此勾选整体真实还款验收或声明已部署。完整证据与限制见 implement.md 顶部。 ## 2026-09-17 实施接续 diff --git a/scripts/qa/company-loan-browser.cjs b/scripts/qa/company-loan-browser.cjs index fe146b15..b8676eaa 100644 --- a/scripts/qa/company-loan-browser.cjs +++ b/scripts/qa/company-loan-browser.cjs @@ -297,6 +297,9 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa await view.context.close(); checks.push(`${mobile ? 'mobile' : 'desktop'}_${recovery ? 'recovery_start_evidence_review_round_release' : 'terminal_multiple_pending_receipts_independent_review_release'}`); } } + if (reconciliationUi) checks.push(...await require('./company-loan-reconciliation-browser.cjs')({ + browser, origin, output, finance, manager, password, prisma, fixtures: reconciliationUi, + })); 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(); diff --git a/scripts/qa/company-loan-http-fixture.cjs b/scripts/qa/company-loan-http-fixture.cjs index 60ab3e28..c599b3d4 100644 --- a/scripts/qa/company-loan-http-fixture.cjs +++ b/scripts/qa/company-loan-http-fixture.cjs @@ -1301,6 +1301,33 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { } (recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).push({ id: batch.id, instruction: ref }); } + reconciliationUi.failures = []; reconciliationUi.late = []; + for (const mode of ['desktop', 'mobile']) { + const failLender = await verifiedLender(`合成失败界面-${mode}`, null, '7'); + const failLoan = await confirmedLoan(failLender.id, '100.00', `合成失败归属-${mode}`); + const target = await repay(failLender.id, failLoan.id, '7.00'); + const competitor = await repay(failLender.id, failLoan.id, '7.00'); + for (const b of [target, competitor]) { await call(manager, 'POST', `/payment-batches/${b.id}/review`, {}); await exportOrder(b.id); } + const reference = `UI-FAIL-${mode}`.toUpperCase(); + await receipt(target.id, '700', reference, { payeeAccount: failLender.bankAccount, transactionStatus: 'failed' }); + reconciliationUi.failures.push({ id: target.id, repaymentId: target.loanRepaymentId, reference }); + + const lateLender = await verifiedLender(`合成追回界面-${mode}`, null, '8'); + const lateLoan = await confirmedLoan(lateLender.id, '100.00', `合成晚到追回-${mode}`); + const original = await repay(lateLender.id, lateLoan.id, '80.00'); + await call(manager, 'POST', `/payment-batches/${original.id}/review`, {}); await exportOrder(original.id); + const exp = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: original.id } }); + const sub = await register(original.id, `UI-ORIGINAL-${mode}`, exp.id); await attest(original.id); + const fact = await proposeFact(original.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: await uploadEvidence(original.id) }); + await reviewFact(original.id, fact.id); await confirmNotPaid(original.id); await cancelBatch(original.id); + const paid = await repay(lateLender.id, lateLoan.id, '60.00'); + await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id); + await receipt(paid.id, '6000', `UI-PAID-${mode}`, { payeeAccount: lateLender.bankAccount }); + await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {}); + const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: original.id } }); + 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 }); } console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks })); diff --git a/scripts/qa/company-loan-reconciliation-browser.cjs b/scripts/qa/company-loan-reconciliation-browser.cjs new file mode 100644 index 00000000..8fff99ca --- /dev/null +++ b/scripts/qa/company-loan-reconciliation-browser.cjs @@ -0,0 +1,130 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const req = createRequire(path.resolve(__dirname, '../../client/package.json')); +const { expect } = req('@playwright/test'); + +// Synthetic fixtures only; every tested action below goes through the actual UI. +module.exports = async function reconciliationBrowser({ browser, origin, output, finance, manager, password, prisma, fixtures }) { + const checks = []; + for (const mobile of [false, true]) { + const mode = mobile ? 'mobile' : 'desktop'; + let view; + 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' }); + await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); + const page = await context.newPage(); + await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batch.id}`)}`); + await page.getByTestId('login-identifier').fill(who.email); + await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); + await page.waitForURL(/\/payment-batches\?id=/); + const panel = page.getByRole('region', { name: '银行结果对账', exact: true }); + await expect(panel).toBeVisible({ timeout: 20000 }); + view = { context, page, panel }; + } + async function action(name, fill, scope) { + await (scope ?? view.panel).getByRole('button', { name, exact: true }).click(); + const dialog = view.page.getByRole('dialog'); + if (fill) await fill(dialog); + if (await dialog.getByLabel('依据与说明').count()) await dialog.getByLabel('依据与说明').fill('合成银行材料唯一定位本批次并独立核对全部指令'); + 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()}`); } + } + async function review(name = '独立复核银行事实') { + await action(name, d => d.getByLabel('复核结论').selectOption('approve')); + } + async function upload(kind) { + await action('上传银行证据', async d => { + await d.getByLabel('证据类别').selectOption(kind); + await d.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await fixtures.bankLetterPdf() }); + }); + } + async function bankFields(d, s, amount, reference, incoming = false) { + const options = d.getByLabel('对应的成功银行回单').locator('option'); + await d.getByLabel('对应的成功银行回单').selectOption({ index: (await options.count()) - 1 }); + await d.getByLabel('回单付款账号').fill(incoming ? s.payee : s.payer); + await d.getByLabel('回单收款账号').fill(incoming ? s.payer : s.payee); + await d.getByLabel('回单人民币金额').fill(amount); + await d.getByLabel('回单流水号').fill(reference); + await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成官方查询逐一排除其他同金额付款和退款交易'); + } + try { + const failed = fixtures.failures[Number(mobile)]; + await login(finance, failed); + await action('登记银行提交', async d => { + await d.getByLabel('使用的导出文件(手工录入可不选)').selectOption({ index: 1 }); + await d.getByLabel('银行指令号').fill(failed.reference); + await d.getByLabel('实际提交时间').fill(new Date().toISOString().slice(0, 16)); + }); + await action('确认提交清单完整'); + await expect(view.panel.getByRole('button', { name: '独立复核提交清单', exact: true })).toHaveCount(0); + await login(manager, failed); await review('独立复核提交清单'); + await login(finance, failed); + await action('登记失败回单归属', async d => { + await d.getByLabel('对应银行提交').selectOption({ index: 1 }); + await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 }); + await d.getByLabel('排除其他候选批次的依据').fill('合成银行指令唯一定位本批,其他同额批次对应另一指令'); + }); + await expect(view.panel.getByRole('button', { name: '复核失败归属', exact: true })).toHaveCount(0); + await login(manager, failed); await expect(view.panel).toContainText(failed.reference); await review('复核失败归属'); + await login(finance, failed); + await action('登记银行终态事实', async d => { + await d.getByLabel('对应银行提交').selectOption({ index: 1 }); + await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 }); + }); + await login(manager, failed); await review('独立复核终态事实'); await action('确认银行未付款'); + await login(finance, failed); await action('重验依据并取消释放'); + const original = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: failed.repaymentId }, include: { allocations: true } }); + assert.equal(original.bank_result_state, 'confirmed_not_paid'); + assert.ok(original.allocations.every(a => a.reservation_state === 'released')); + await view.page.screenshot({ path: path.join(output, `${mode}-submission-failure-attribution.png`), fullPage: true }); + checks.push(`${mode}_submission_inventory_failure_attribution_terminal_and_release_via_ui`); + + const late = fixtures.late[Number(mobile)]; + const batchCount = await prisma.paymentBatch.count(); + await login(finance, late); await upload('late_success'); + await action('登记晚到成功', d => bankFields(d, late, '80.00', late.reference)); + await expect(view.panel).toContainText('该出借人暂停新还款'); + await expect(view.panel.getByRole('button', { name: '独立复核银行事实', exact: true })).toHaveCount(0); + await login(manager, late); + await view.panel.getByRole('button', { name: '查看录入的银行事实', exact: true }).click(); + let dialog = view.page.getByRole('dialog'); + await dialog.getByLabel('依据与说明').fill('合成独立复核原件与人工输入的对应关系'); + await dialog.getByRole('button', { name: '确认', exact: true }).click(); + await expect(dialog.getByLabel('已授权查看的银行事实')).toContainText(late.reference); + await view.page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible(); + await review(); await login(finance, late); await action('登记补记或追回'); + await expect(view.panel).toContainText('待追回 ¥40.00'); + for (const [amount, remaining] of [['15.00', '25.00'], ['25.00', '0.00']]) { + await upload('incoming_return'); + await action('登记追回到账', d => bankFields(d, late, amount, `${late.reference}-RETURN-${amount}`, true)); + await login(manager, late); await review(); await login(finance, late); await action('登记补记或追回'); + await expect(view.panel).toContainText(`待追回 ¥${remaining}`); + assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lenderId, status: 'active' } }), remaining === '0.00' ? 0 : 1); + } + assert.equal(await prisma.paymentBatch.count(), batchCount, 'factual entries never create payment batches'); + await expect(view.panel.getByRole('button', { name: '登记追回到账', exact: true })).toHaveCount(0); + checks.push(`${mode}_late_success_details_independent_review_partial_and_full_return_via_ui`); + + // Registering a duplicate remains an auditable false alarm; correction + // needs a bank document and independent decision, not a bare dismissal. + await upload('late_success'); await action('登记晚到成功', d => bankFields(d, late, '80.00', late.reference)); + await upload('bank_terminal'); + await login(manager, late); + await action('独立复核银行事实', async d => { + await d.getByLabel('复核结论').selectOption('reject'); + const correction = d.getByLabel('误关联或重复登记的更正证据(晚到成功必填)'); + await correction.selectOption({ index: (await correction.locator('option').count()) - 1 }); + await d.getByLabel('关闭原因').selectOption('duplicate'); + }); + assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lenderId, status: 'active' } }), 0); + await expect(view.panel).toContainText('已确认误报'); + assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false); + await view.page.screenshot({ path: path.join(output, `${mode}-late-return-and-dismissal.png`), fullPage: true }); + checks.push(`${mode}_evidenced_duplicate_false_alarm_closed_by_independent_reviewer_via_ui`); + } finally { if (view) await view.context.close(); } + } + return checks; +};