From 431c36eae3a4c5827a2091c5f5a399028d327f16 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 7 Oct 2026 21:58:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=80=9F=E6=AC=BE=E5=AF=B9=E8=B4=A6=20S4?= =?UTF-8?q?=E2=80=93S8=EF=BC=9A=E4=BA=BA=E5=B7=A5=E6=9C=AA=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E3=80=81=E6=99=9A=E5=88=B0=E8=A1=A5=E8=AE=B0=E3=80=81?= =?UTF-8?q?=E8=BF=BD=E5=9B=9E=E4=B8=8E=E7=95=8C=E9=9D=A2=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=8809-17-company-loan-principal-repayment=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。 验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。 真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。 --- .../spec/hyb-oa/backend-fastify-prisma.md | 2 + .../design.md | 15 + .../implement.md | 35 +++ .../prd.md | 8 + client/src/api/companyLoans.ts | 42 ++- client/src/api/errorReasons.ts | 3 + .../common/LoanBankReconciliation.tsx | 108 +++++++ .../components/common/LoanPaymentDetails.tsx | 9 +- docs/试运行与自动化验证手册.md | 16 + .../qa/company-loan-bank-results-fixture.cjs | 278 ++++++++++++++++++ scripts/qa/company-loan-browser.cjs | 144 ++++++++- scripts/qa/company-loan-freeze-fixture.cjs | 31 ++ scripts/qa/company-loan-http-fixture.cjs | 46 ++- .../qa/company-loan-not-submitted-fixture.cjs | 140 +++++++++ .../migration.sql | 146 +++++++++ .../migration.sql | 28 ++ .../migration.sql | 224 ++++++++++++++ server/prisma/schema.prisma | 119 ++++++++ server/src/constants/errorReasons.js | 3 + server/src/routes/paymentBatches.js | 91 +++++- server/src/services/companyLoanPayments.js | 13 +- server/src/services/companyLoans.js | 4 +- server/src/services/loanBankResults.js | 213 ++++++++++++++ server/src/services/loanBankSubmissions.js | 2 +- server/src/services/loanBankTerminalFacts.js | 18 +- server/src/services/loanFreezes.js | 45 +++ server/src/services/loanNotSubmitted.js | 130 ++++++++ server/src/services/loanReleaseRecoveries.js | 2 +- .../services/paymentReceiptVerification.js | 1 + .../unit/fixtures/error-reason-messages.json | 3 + .../paymentBatches.reimbursement.test.js | 1 + .../paymentReceiptVerification.test.js | 7 + 32 files changed, 1902 insertions(+), 25 deletions(-) create mode 100644 client/src/components/common/LoanBankReconciliation.tsx create mode 100644 scripts/qa/company-loan-bank-results-fixture.cjs create mode 100644 scripts/qa/company-loan-freeze-fixture.cjs create mode 100644 scripts/qa/company-loan-not-submitted-fixture.cjs create mode 100644 server/prisma/migrations/20261008030000_loan_not_submitted/migration.sql create mode 100644 server/prisma/migrations/20261008040000_loan_freeze_reasons/migration.sql create mode 100644 server/prisma/migrations/20261008050000_loan_bank_result_events/migration.sql create mode 100644 server/src/services/loanBankResults.js create mode 100644 server/src/services/loanFreezes.js create mode 100644 server/src/services/loanNotSubmitted.js diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 9edcd1c9..bfd3b2a0 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -1124,6 +1124,8 @@ await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id); - Recovery snapshots bind material revisions, submission/fact/receipt identities, verification versions, independent participants and the current same-amount candidate set. Material writers (including OCR persistence) serialize on the batch; candidate formation also locks the lender. A local batch revision alone cannot detect another batch becoming a candidate. Validate at submission, approval and final cancellation, with physical document digest checks before release. - JSONB can reorder object keys. Compare snapshots using recursive key-canonicalized hashes, retaining array order, rather than hashing raw `JSON.stringify` results before and after storage. Keep snapshot payloads out of public response mapping. New evidence for the same bank transaction still requires new review. - Verify recovery through `RUN_ISOLATED_LOAN_DB_TEST=1 node scripts/qa/test-company-loan-db.cjs` with the locked BIFF8 Python environment: real HTTP/evidence/OCR, stale-round repair, no fallback after withdrawal, atomic rollback and deterministic interleavings with evidence, submissions, OCR and candidate formation. Existing browser checks do not prove the deferred S8 recovery UI; inline self-review is not independent review. +- Manual no-submission is a separate terminal fact, never a bank termination claim: fixed documentary categories, complete reviewed export/submission inventory, two distinct independent reviews, material/candidate snapshot and physical digest checks at final release. An empty submission attestation is valid only for actual exports and no historical submission rows; withdrawn submissions do not prove non-submission. +- Late-payment supplements and bank returns use an append-only ledger, not exportable payment batches. Include supplemental principal in both application balance calculations and SQL capacity checks. Their keyed transaction claims must exclude ordinary receipt claims and aliases in both directions under the shared receipt lock. Freeze causes close independently; partial returns and unresolved reservation conflicts keep their own causes active. The ordinary pre-payment gate must not block factual reconciliation. - ERP-specific API/adapters/jobs/enqueues are retired by `09-17-erp-exclusive-retirement`. Shared outbox/notification/WeCom and historical records remain. New ERP enqueue and direct dispatch reject; pending selection excludes `erp.*`; historical retry returns `integration.erp_retired` without mutating records. Never restore these retired consumers solely to satisfy old success-path tests. 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 b5ddf7fe..d1538c98 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md @@ -1,3 +1,18 @@ +## 2026-10-07 S4–S8 实施细化(沿已批准范围,尚待最终验收) + +- Q6 使用独立的人工声明与复核记录,不混入银行终态事实。三类附件固定分类;查询及对账覆盖首次导出至登记日(公司时区),每个相关导出人均须有文件去向说明。完整空提交清单可以独立复核;撤回登记不能充当“未提交”。混合批次仍逐条验证已提交部分的银行终态。 +- 每次人工声明绑定材料修订、全部导出/提交/核验、付款快照及同额候选。两名不同且当前合格的复核人分别决定。失效声明可以追加取代,原银行终态不改;最终取消重验全部条件、物理文件摘要及双审,数据库另外约束释放配对。 +- 冻结按来源事件逐条记录;只在新申请、付款前复核及导出入口检查。关闭函数仅供对账业务事务调用,没有通用解冻接口。误报须经独立人员凭更正证据确认;仅不确定或证据不足不能关闭。 +- 晚到成功及退款到账以人工核对银行原件的方式登记(不是 OCR 通过)。输入按既有回单归一化规则核对成功状态、双方账号、币种、金额和流水号,原始人工输入加密存储;查询完整输入必须填写用途并记审计。已取消批次可以另存银行证据,但不重开原付款回单、付款项或导出。 +- 归属批准到入账之间再次出现同额候选,最终入账重验会拒绝。可用同一交易的新登记取代未入账的旧批准,再独立复核;取代时保持至少一条冻结。银行交易的唯一认领在最终入账事务中消费,复核仅做冲突检查,避免尚未入账的失效批准占死交易身份。已入账的银行事实不能被重提覆盖。 +- 晚到补记与追回使用独立银行事件、交易认领及逐借款补记表,不创建付款批次。交易认领和原核销/别名空间相互排斥;复核与入账同样校验密钥和交易身份。补记成功之后,新申请可用本金要扣掉补记结清额,数据库容量检查也包含这部分。 +- 未释放的原占用在补记事务中以被银行事实推翻为原因释放,保留原终态和取消信息;其他仅占用的申请须先处理,不能算作已付。已经付掉的部分按差额列待追回;整笔超额时也保留银行全额和零结清记录。追回只接受实际到账并经复核,不用冲抵;部分到账不解冻。 +- 已有待复核或已批准的晚到成功时,旧终态取消入口拒绝,必须走对账补记;数据库延迟约束同样保护这条边界。取代已产生占用冲突的未入账事件时,同事务先转移冲突冻结再关闭旧原因;新事件入账或凭更正证据确认误报时关闭对应派生原因,不能留下不可处理的旧冻结。 +- 已用于误报解冻的更正证据属于已消费的复核依据,服务及数据库均禁止再作废。更正记录、原始银行事件及关闭依据保留,不回写原结论。 +- 终态界面支持一次关联多份同指令处理中回单。复核卡明确展示所选提交、终态类型、证据、了结回单;Q6 展示逐份导出及三类证据的准确绑定,失败归属展示覆盖指令及核验附件。历史作废回单仅可通过实际事实绑定、必填用途的对账读取查看;权限、隔离策略、物理摘要、审计及 no-store 保留,普通作废回单下载仍被拒绝,不恢复其付款效力。 +- S8 沿用既有 React 对话框和样式,未引入新视觉方案。页面集中展示导出、提交、证据、人工声明、补证轮次、银行事件和冻结原因;原制单不得再次导入网银的提示不是技术保证。 +- S0(存量、合格复核人数、银行编号关系及官方材料)、真实回单验收仍未完成;本轮资金与界面代理已完成修复后只读静态复核,运行时结果见 implement。该复核不替代异模型或业务人员验收。本地实现/合成验证不代表可以部署或真实还款。 + ## 2026-10-07 补证后恢复方案(SR,第 1 版,用户已批准本地实施) 本方案先完成设计,随后用户明确回复“批准”,授权 SR-A、SR-B 本地实施与验证;SR-C 随 S8,提交、推送与部署未授权。S3 已本地提交 `14aee82d`;下文历史设计及实施记录保留其原时点。本方案补充 3.2 的释放流程,不把普通补证归入 S6 的晚到成功。 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 1bb1a700..b3820bf3 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -1,3 +1,38 @@ +## 2026-10-07 后续收口执行(用户同意执行) + +按后续工作清单,先将已验证的 S4–S8 共 32 项改动本地提交,提交保留“真实回单待验、整体业务验收未通过”。随后补齐提交登记、清单复核、失败归属、晚到补记和追回的浏览器闭环,并准备发布前存量/迁移/回退检查材料。当前 inline 主会话直接实现和检查,不另派代理;此前两名代理的复核仅覆盖其当时差异。生产查询单独确认,人员与银行材料待外部答复;推送、部署和远端备份切换不在本次默认执行内。以下“32 项未提交”属于提交前验证时点。 + +- [ ] 本地提交已验证 S4–S8。 +- [ ] 补齐其余银行对账表单的电脑/手机闭环并修复发现的问题。 +- [ ] 在现有任务/恢复手册整理具体发布准备与未满足条件。 +- [ ] S0 生产只读核实及外部人员、银行材料确认(分别记录)。 + +## 2026-10-07 后续执行接续(用户要求已确定工作直接执行) + +沿既有设计与 S4–S8 清单推进本地实施、针对性验证与主会话检查,不再逐阶段等待通知。先 S4,再按依赖推进 S5、S6、S7、S8;提交、推送、生产操作仍遵守单独授权。SR-A/B 已本地提交 `2e00d30f`。前阶段 inline 下未派独立代理;本轮工具已可用,按 AGENTS 对资金流程的要求另派两名只读代理检查资金与界面,修复后静态复核通过(同模型,非业务独立验收)。 + +- [x] S4 本地实施与合成验证:Q6 三类证据独立分类、全量材料快照、两名独立复核、人工终态及最终取消重验;覆盖混合导出(已提交部分沿 S3,未提交部分逐份举证)。 +- [x] S5–S8 本地实施、针对性回归及只读静态复核;整体业务验收仍受 S0 和真实样本限制。 +- S4 技术边界:人工声明覆盖未关联提交的导出,不可覆盖任何已登记(含撤回)的提交;查询及对账单覆盖首次导出至登记日(公司时区),所有导出人的文件去向说明齐全。快照变更后重新登记、重新双审,原终态不改。附件分类不允许事后互换,不进入回单 OCR;真实材料及人员可用性仍待 S0。 + +### 本轮阶段结果(本地验证通过,尚未提交) + +- S4:新增 `loan_not_submitted_proposals/reviews`、三类固定证据分类、完整空清单复核、双人决定、人工终态、材料变更取代及最终取消重验。增量迁移 `20261008030000_loan_not_submitted`。 +- S5:新增逐原因冻结、付款前统一门禁;事实登记不冻结,没有通用管理员解冻接口。迁移 `20261008040000_loan_freeze_reasons`。 +- S6/S7:新增银行事实、跨核销空间交易认领、逐借款补记、实际追回;本金容量同时包含补记结清。迁移 `20261008050000_loan_bank_result_events`。当前原件人工录入/独立核对,不声称 OCR 或银行真实性自动验证。待审的同一交易可用新证据取代,先产生新冻结再关闭旧冻结;已复核银行事实的原件不得作废,物理文件损坏仍拒绝入账。 +- S8:详情页接入提交、清单、终态、失败归属、Q6、SR 恢复、晚到补记/追回;查询完整人工输入和原件要求用途与审计,电脑/手机完成 Q6、SR 恢复及多回单终态的完整弹窗流程;其他新增表单以 HTTP 与类型检查覆盖。沿用既有视觉组件。 +- 有效变异共 13/13 被测试捕获(6 个首轮、2 个金额边界、2 个最终入账守卫、3 个复核修复守卫)。包括三证类别、物理摘要、快照、冻结、交易身份、部分追回、占用冲突、超额拆分、最终候选、冻结转移、更正依据和旧取消旁路。部分变异仍被下游/数据库拒绝,测试捕获的是错误响应或错误原因,不能写成全部造成了错误释放。环境路径错误的首轮未计入;变异只在临时源码副本中进行,后续固定源码轮次摘要未变。 +- 最终隔离回归:81 个迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组通过,容器清理 PASS。包含整笔超额、逐原借款差额、跨流程交易复用、原审批人离职、审计回滚、候选变更重新复核及冻结/新申请/导出的确定性交错。固定前后端源码/构建副本的最后一轮再次得到相同通过结果,副本及自有容器已清理。浏览器覆盖原有 7 组及电脑/手机 Q6 双审、SR 补证、两份处理中回单终态释放(包括作废历史回单的复核下载)6 组。人工等待期在 HTTP 验证,浏览器合成配置设为 0,不等待现实三天。 +- 服务端首跑 4472 通过、8 失败、1 原有跳过(4481 总);8 个失败为报销回单旧测试替身缺少新增跨台账认领查询,补齐后相关两文件 150/150 通过,最后共享下载路径调整后再次 150/150 通过。合并覆盖 4480 项通过、1 项原有跳过,未将其描述为完整套件重新全跑通过。 +- 中间失败如实保留:浏览器精确标签、分页缓存等待、Q6 合成等待期设置及重新登录加载等待先后修正;运行器后来固定 client/dist 副本,避免与构建文件更换交错。失败轮次不计通过。首轮变异因临时 Python 路径错误停在制单生成,不计有效变异。 +- Prisma 校验、前端类型检查/构建、共享回单定向测试、UI 规则、错误码合同、敏感扫描、任务治理/流程合同及 git diff --check 通过。文档注册检查退出 0,保留原有 22 个未登记 docs 告警。 + +- 本轮只读复核修复:取代银行事件遗留冲突冻结;误报解冻依据可作废;终态界面只能选一份处理中回单;各复核卡缺准确材料绑定;已作废处理中回单无法供复核查看。分别补齐服务/数据库保护、历史证据读取及桌面手机回归。两名代理对修复后源码静态复核通过,最终运行时结果见上方;不把同模型审查当作外部业务验收。 +- 最终候选另外修正:晚到事实未处理时旧取消不能释放;归属候选在批准后变化时入账拒绝;唯一交易认领只在最终入账事务消费,允许未入账失效记录重新登记。 +- 所有测试只用合成数据和本次拥有的隔离容器。未查询生产、未提交推送部署;S0、真实样本和外部业务验收仍待完成,整体借款验收未通过。 + +复现入口沿用上面的隔离回归命令(具体命令见下方 SR 记录);新增 3 个迁移必须列入将来经过批准的预期迁移。本轮 32 项未提交,HEAD 为 `2e00d30f`;未推送、未部署、未查生产。S0 仍须核实存量、两名额外合格复核人、建行官方无结果材料和回单/指令编号对应。真实银行材料、银行事实的业务真实性以及异模型/业务独立验收不由合成数据证明。保持任务进行中,不归档。 + ## 2026-10-07 SR-A / SR-B 收尾与提交授权 用户明确批准“先收尾 SR-A/B,本地提交;S4 另批”。本次将下方 15 项实现、测试及文档作为一个本地提交收口,沿用已完成的运行时验证,收尾只更新记录并重验任务治理及差异格式。未推送、未部署、未查询生产。当前 inline 模式下独立复核仍待完成;界面留在 S8,真实回单待验,不能单独部署启用。整体任务保持进行中,不归档;以下“未提交”和 HEAD 描述为实施验证时点的记录。 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 8478bfa5..b6c47036 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -1,5 +1,9 @@ # 公司借款台账与本金归还 +## 2026-10-07 本地阶段状态 + +S4–S8 与 SR 界面已完成本地实现及合成回归:81 迁移、HTTP 95、浏览器 13、数据库约束 9 组通过,修复后的资金与界面只读静态复核通过(同模型)。S0、真实建行材料和业务独立验收仍未完成;不据此勾选整体真实还款验收或声明已部署。完整证据与限制见 implement.md 顶部。 + ## 2026-09-17 实施接续 用户明确要求借款尽快完成,沿本PRD与design既有个人出借人/CNY本金范围推进本地T1–T4,取代此前暂缓实施的优先级;不扩大为利息、备用金或通用付款。真实资料、余额和账户仍须正式录入核实,本地只用合成样例。风险与独立复核在交付/上线前保持未完成,不通过读取生产密钥或重跑旧恢复为本地开发解阻。 @@ -45,6 +49,10 @@ 付款成功与代账记账分开,记录凭证编号/记账确认及材料交接,不由OA自动生成会计分录。材料包括原借入凭证、协议/确认书、余额核对、审批及还款回单;本金不要求费用发票,不进入费用报销统计或费用预算。关键材料按适用访问权限留存,历史余额/分配不得无审计改写。 +## 2026-10-07 S4–S8 本地接续 + +用户要求沿已确定工作直接执行。S4–S8 的本地实现和验证继续推进,阶段证据在 implement 顶部;AC9–AC19 不因后端或合成测试通过而自动勾为整体验收通过。新银行事件均标明人工核对,不伪称真实银行/OCR 已验;生产查询、推送、部署仍未执行。 + ## Acceptance Criteria - [x] AC1:合成历史两笔借入可登记并确认余额;未确认/重复登记/无凭据不进入还款,历史录入不被统计成当天新增收款。 diff --git a/client/src/api/companyLoans.ts b/client/src/api/companyLoans.ts index 593c2bac..496b9509 100644 --- a/client/src/api/companyLoans.ts +++ b/client/src/api/companyLoans.ts @@ -17,7 +17,7 @@ export type CompanyLoan = { outstanding: string; available: string; reserved: string; settled: string; evidences: LoanEvidence[]; accountingConfirmationRef: string | null; }; -export type LoanRepayment = { +export type LoanRepayment = LoanReconciliation & { id: string; repaymentNo: string; lenderId: string; lenderName: string; amount: string; reason: string; bankResultState: string; paymentBatchId: string; status: string; accountingHandoffRef: string | null; accountingHandoffAt: string | null; @@ -61,3 +61,43 @@ export function uploadLoanEvidence(id: string, kind: string, file: File) { export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string) { return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId }) }); } + +export type LoanReconciliation = { + failureAttributions?: Array<{ id: string; status: string; proposedBy: string; basisNote: string; exclusionNote: string; coveredSubmissionIds: string[]; failureScope: string; receiptAttachmentIds: string[] }>; + bankSubmissions?: { + exports: Array<{ id: string; sequence: number; exportedAt: string; exportedBy: string; customerSerials: string[] }>; + legacy: { knownExportCount: number; unknownFacts: string[] } | null; + submissions: Array<{ id: string; exportId: string | null; bankInstructionRef: string; status: string; submittedAt: string; registeredBy: string; note: string }>; + attestation: { id: string; status: string; proposedBy: string; stale: boolean } | null; + }; + terminal?: { + evidences: Array<{ id: string; originalName: string; evidenceKind: string; voided: boolean }>; + facts: Array<{ id: string; submissionId: string; status: string; proposedBy: string; kind: string; note: string; terminalStatus: string; evidenceId: string | null; receiptAttachmentId: string | null; resolvesAttachmentIds: string[] }>; + }; + releaseRecovery?: { + current: { id: string; sequence: number; status: string; stale: boolean } | null; + history: Array<{ id: string; sequence: number; status: string; startedBy: string; reason: string }>; + }; + notSubmitted?: { history: Array<{ id: string; sequence: number; status: string; proposedBy: string; stale: boolean; + payload: { exportIds: string[]; queryEvidenceId: string; statementEvidenceId: string; exporterStatements: Array<{ exporterId: string; evidenceId: string }>; coverageFrom: string; coverageThrough: string; note: string }; reviews: Array<{ reviewedBy: string; decision: string; reason: string }> }> }; + freeze?: { frozen: boolean; reasons: Array<{ id: string; kind: string; status: string; paymentBatchId: string }> }; + bankResults?: { events: Array<{ id: string; kind: string; parentId: string | null; evidenceId: string; status: string; amount: string; + proposedBy: string; reviewedBy: string | null; reviewNote: string | null; outstandingReturn: string | null; + settlements: Array<{ loanId: string; bankAmount: string; settledAmount: string; receivableAmount: string }> }> }; +}; +export function uploadLoanBankEvidence(batchId: string, kind: string, file: File) { + const body = new FormData(); body.append("file", file); + return apiRequestData<{ id: string }>(`/api/payment-batches/${batchId}/loan-terminal-evidences?kind=${encodeURIComponent(kind)}`, { method: "POST", body }); +} +export function downloadLoanBankEvidence(batchId: string, id: string, reason: string) { + return apiRequestBlob(`/api/payment-batches/${batchId}/loan-terminal-evidences/${id}/file`, { headers: { "x-access-reason": encodeURIComponent(reason) } }); +} + +export function readLoanBankResultDetails(batchId: string, id: string, reason: string) { + return apiRequestData<{ fields: Array<{ Name: string; Value: string }>; attributionNote: string; exclusionNote: string }>(`/api/payment-batches/${batchId}/loan-bank-results/${id}/details`, { headers: { "x-access-reason": encodeURIComponent(reason) } }); +} + +export function downloadLoanReconciliationReceipt(batchId: string, attachmentId: string, reason: string) { + const query = new URLSearchParams({ purpose: "loan_reconciliation", reason }); + return apiRequestBlob(`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/file?${query}`); +} diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index d3037408..604e254f 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -1,4 +1,7 @@ export const ERROR_REASON_MESSAGES: Record = { + "loan.bank_result_reconcile_required": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。", + "loan.lender_frozen": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。", + "loan.not_submitted_invalid": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。", "loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。", "loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。", "loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。", diff --git a/client/src/components/common/LoanBankReconciliation.tsx b/client/src/components/common/LoanBankReconciliation.tsx new file mode 100644 index 00000000..28333ed8 --- /dev/null +++ b/client/src/components/common/LoanBankReconciliation.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "../../auth/AuthContext"; +import { saveLoanAction, uploadLoanBankEvidence, downloadLoanBankEvidence, readLoanBankResultDetails, downloadLoanReconciliationReceipt, type LoanRepayment } from "../../api/companyLoans"; +import { type PaymentBatchReceiptAttachment } from "../../api/paymentBatches"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "../ui/dialog"; +import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; +import { downloadBlob } from "../../lib/download"; +import { formatDisplayDate } from "../../lib/displayFormat"; + +const states: Record = { proposed: "待复核", approved: "复核通过", rejected: "已驳回", superseded: "已取代", collecting: "补证中", withdrawn: "已撤回", consumed: "已释放", booked: "已登记入账", dismissed: "已确认误报" }; +const categories: Record = { bank_terminal: "银行终态或更正书面证据", query_no_result: "指令查询无结果官方 PDF", account_statement: "同期付款账户对账单", exporter_statement: "导出人文件去向说明", late_success: "晚到成功回单", incoming_return: "出借人退款到账回单" }; +const actions: Record = { downloadReceipt: "查看对应付款回单", details: "查看录入的银行事实", upload: "上传银行证据", submission: "登记银行提交", withdrawSubmission: "撤回提交登记", attest: "确认提交清单完整", reviewList: "独立复核提交清单", terminal: "登记银行终态事实", reviewFact: "独立复核终态事实", confirm: "确认银行未付款", failureAttribution: "登记失败回单归属", reviewFailure: "复核失败归属", q6: "登记人工未提交", reviewQ6: "特别复核人工未提交", recovery: "发起补证恢复", submitRecovery: "送审补证恢复", reviewRecovery: "复核补证恢复", withdrawRecovery: "撤回补证恢复", cancel: "重验依据并取消释放", event: "登记晚到成功", refund: "登记追回到账", reviewEvent: "独立复核银行事实", apply: "登记补记或追回", download: "查看银行证据", void: "作废银行证据" }; + +export function LoanBankReconciliation({ repayment: r, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) { + const { me, permissions } = useAuth(); const cache = useQueryClient(); + const finance = !!me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role)) && permissions.includes("loan.manage"); + const reviewer = !!me?.roleCodes.some((role) => ["manager", "ceo"].includes(role)) && permissions.includes("payment.batch.review"); + const [details, setDetails] = useState> | null>(null); + const [action, setAction] = useState(null); const [target, setTarget] = useState(""); + const [pendingIds, setPendingIds] = useState([]); + const [values, setValues] = useState>({}); const [file, setFile] = useState(null); + const value = (key: string) => values[key] ?? ""; + const set = (key: string, next: string) => setValues((old) => ({ ...old, [key]: next })); + const base = `/payment-batches/${r.paymentBatchId}`; + const exports = r.bankSubmissions?.exports ?? []; const submissions = r.bankSubmissions?.submissions ?? []; + const evidences = r.terminal?.evidences ?? []; const current = r.releaseRecovery?.current; + const q6 = r.notSubmitted?.history[0]; const list = r.bankSubmissions?.attestation; + const unlinked = exports.filter((e) => !submissions.some((s) => s.status === "active" && s.exportId === e.id)); + const mutation = useMutation({ mutationFn: async () => { + const note = value("note"); const decision = value("decision") || "approve"; + const reviewBody = { decision, reason: note }; + if (action === "details") setDetails(await readLoanBankResultDetails(r.paymentBatchId, target, note)); + if (action === "upload") { if (!file) throw new Error("请选择证据文件"); await uploadLoanBankEvidence(r.paymentBatchId, value("category") || "bank_terminal", file); } + if (action === "download") { const result = await downloadLoanBankEvidence(r.paymentBatchId, target, note); downloadBlob(result.blob, result.filename ?? "银行证据"); } + if (action === "downloadReceipt") { const result = await downloadLoanReconciliationReceipt(r.paymentBatchId, target, note); downloadBlob(result.blob, result.filename ?? "付款回单"); } + if (action === "void") await saveLoanAction(`${base}/loan-terminal-evidences/${target}/void`, { reason: note }); + if (action === "submission") await saveLoanAction(`${base}/loan-bank-submissions`, { channel: value("export") ? "online_banking_import" : "manual_entry", ...(value("export") ? { exportId: value("export") } : {}), bankInstructionRef: value("reference"), submittedAt: new Date(value("date")).toISOString(), note }); + if (action === "withdrawSubmission") await saveLoanAction(`${base}/loan-bank-submissions/${target}/withdraw`, { reason: note }); + if (action === "attest") await saveLoanAction(`${base}/loan-submission-attestation`, { note }); + if (action === "reviewList") await saveLoanAction(`${base}/loan-submission-attestation/${target}/review`, reviewBody); + if (action === "terminal") await saveLoanAction(`${base}/loan-terminal-facts`, { submissionId: value("submission"), kind: value("receipt") ? "failure_receipt" : "termination_evidence", ...(value("receipt") ? { receiptAttachmentId: value("receipt") } : { evidenceId: value("evidence"), terminalStatus: value("terminalStatus") || "failed" }), resolvesAttachmentIds: pendingIds, note }); + if (action === "reviewFact") await saveLoanAction(`${base}/loan-terminal-facts/${target}/review`, reviewBody); + if (action === "confirm") await saveLoanAction(`${base}/loan-not-paid/review`, {}); + if (action === "failureAttribution") await saveLoanAction(`${base}/loan-receipt-attribution`, { kind: "failure", attachmentId: value("receipt"), ...(value("submission") ? { submissionId: value("submission") } : {}), basisKind: "bank_instruction_ref", basisNote: note, exclusionNote: value("exclusion") }); + if (action === "reviewFailure") await saveLoanAction(`${base}/loan-receipt-attribution/${target}/review`, reviewBody); + if (action === "q6") await saveLoanAction(`${base}/loan-not-submitted`, { exportIds: unlinked.map((e) => e.id), queryEvidenceId: value("query"), statementEvidenceId: value("statement"), exporterStatements: [...new Set(unlinked.map((e) => e.exportedBy))].map((id) => ({ exporterId: id, evidenceId: value(`exporter:${id}`) })), coverageFrom: new Date(value("from")).toISOString(), coverageThrough: new Date(value("through")).toISOString(), note }); + if (action === "reviewQ6") await saveLoanAction(`${base}/loan-not-submitted/${target}/review`, reviewBody); + if (action === "recovery") await saveLoanAction(`${base}/loan-release-recoveries`, { requestKey: crypto.randomUUID(), reason: note }); + if (action === "submitRecovery") await saveLoanAction(`${base}/loan-release-recoveries/${target}/submit`, {}); + if (action === "reviewRecovery") await saveLoanAction(`${base}/loan-release-recoveries/${target}/review`, reviewBody); + if (action === "withdrawRecovery") await saveLoanAction(`${base}/loan-release-recoveries/${target}/withdraw`, { reason: note }); + if (action === "cancel") await saveLoanAction(`${base}/cancel`, { reason: note, ...(current ? { recoveryId: current.id, requestKey: crypto.randomUUID() } : {}) }); + if (action === "event" || action === "refund") await saveLoanAction(`${base}/loan-bank-results`, { kind: action === "refund" ? "return" : "late_success", ...(action === "refund" ? { parentId: target } : {}), evidenceId: value("evidence"), requestKey: crypto.randomUUID(), attributionNote: note, exclusionNote: value("exclusion"), fields: [{ Name: "付款账号", Value: value("payer") }, { Name: "收款账号", Value: value("payee") }, { Name: "金额", Value: value("amount") }, { Name: "币种", Value: "CNY" }, { Name: "交易状态", Value: "交易成功" }, { Name: "流水号", Value: value("reference") }] }); + if (action === "reviewEvent") await saveLoanAction(`${base}/loan-bank-results/${target}/review`, { ...reviewBody, ...(decision === "reject" && value("evidence") ? { correctionEvidenceId: value("evidence"), correctionKind: value("correction") || "misassociated" } : {}) }); + if (action === "apply") await saveLoanAction(`${base}/loan-bank-results/${target}/apply`, {}); + }, onSuccess: async () => { if (action === "details") return; setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); } }); + function open(name: string, id = "") { setAction(name); setTarget(id); setValues({}); setPendingIds([]); setDetails(null); setFile(null); mutation.reset(); } + const button = (name: string, id = "") => ; + const input = (key: string, label: string, type = "text") => ; + const select = (key: string, label: string, choices: Array<[string, string]>, required = true) => ; + const evidenceSelect = (key: string, label: string, kind: string, required = true) => select(key, label, evidences.filter((e) => !e.voided && e.evidenceKind === kind).map((e) => [e.id, e.originalName]), required); + const exportLabel = (id: string | null) => id ? `第 ${exports.find((e) => e.id === id)?.sequence ?? "未知"} 次导出` : "系统外手工提交"; + const submissionLabel = (id: string) => submissions.find((s) => s.id === id)?.bankInstructionRef ?? "提交记录已不可用"; + const evidenceLink = (id: string) => ; + const receiptLink = (id: string) => ; + const receiptOptions: Array<[string, string]> = attachments.map((a, i) => [a.id, `回单 ${i + 1}${a.archivedAt ? "(已作废的历史事实)" : ""}`]); + const terminal = ["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(r.bankResultState); + return
+

银行结果对账

+ {r.freeze?.frozen ?

该出借人暂停新还款、复核和导出。仍可登记银行事实、对账和追回;所有冻结原因处理完成后才恢复付款。

: null} + {r.freeze?.reasons.filter((x) => x.status === "active").map((x) =>

待处理:{x.kind === "late_success" ? "晚到成功待确认或补记" : x.kind === "receivable" ? "超额付款待追回" : "其他申请占用冲突"}

)} + {r.bankSubmissions?.legacy ?

历史导出明细待核实,不能凭汇总释放额度。

: null} +

等待期只是内部谨慎期,不能证明银行未付款。线下核对不能直接解除系统占用。

+ {exports.map((e) =>

导出 {e.sequence} · {formatDisplayDate(e.exportedAt)} · 客户方流水号 {e.customerSerials.join("、")}

)} + {submissions.map((s) =>
指令 {s.bankInstructionRef} · {exportLabel(s.exportId)} · {formatDisplayDate(s.submittedAt)} · {s.note} · {s.status === "active" ? "已登记" : "已撤回登记(不代表银行撤销)"}{finance && s.status === "active" && r.status !== "cancelled" ? button("withdrawSubmission", s.id) : null}
)} + {list ?

提交清单:{states[list.status]}{list.stale ? ",已变化,须重新确认复核" : ""}

{reviewer && list.status === "proposed" && !list.stale && list.proposedBy !== me?.id ? button("reviewList", list.id) : null}
: null} +
{finance && ["reviewed", "failed", "cancelled"].includes(r.status) ? button("upload") : null}{finance && r.status !== "cancelled" ? <>{button("submission")}{button("attest")}{button("terminal")}{button("failureAttribution")} : null}{reviewer && r.bankResultState === "unknown" ? button("confirm") : null}
+ {evidences.map((e, evidenceIndex) =>
证据 {evidenceIndex + 1} · {categories[e.evidenceKind] ?? "银行证据"}:{e.originalName}{e.voided ? "(已作废)" : ""}{button("download", e.id)}{finance && !e.voided && r.status !== "cancelled" ? button("void", e.id) : null}
)} + {r.terminal?.facts.map((f, i) =>
终态事实 {i + 1}:{states[f.status]} · 指令 {submissionLabel(f.submissionId)} · {f.kind === "failure_receipt" ? "失败回单" : ({ failed: "已失败", revoked: "已撤销", rejected: "已拒绝" }[f.terminalStatus] ?? f.terminalStatus)} · {f.note}{f.evidenceId ? evidenceLink(f.evidenceId) : null}{f.receiptAttachmentId ? receiptLink(f.receiptAttachmentId) : null}{f.resolvesAttachmentIds.length ? 了结处理中回单: : null}{f.resolvesAttachmentIds.map((id) => {receiptLink(id)})}{reviewer && f.status === "proposed" && f.proposedBy !== me?.id ? button("reviewFact", f.id) : null}
)} + {r.failureAttributions?.map((a) =>

失败归属:{states[a.status]} · {a.basisNote}

{a.exclusionNote}

归属范围:{a.failureScope === "submission" ? "指定提交" : "本批次提交清单"};覆盖指令:{a.coveredSubmissionIds.map(submissionLabel).join("、")}

{a.receiptAttachmentIds.map((id) => {receiptLink(id)})}{reviewer && a.status === "proposed" && a.proposedBy !== me?.id ? button("reviewFailure", a.id) : null}
)} + {q6 ?

人工确认未提交:{states[q6.status]} · {q6.payload.note} · 覆盖 {formatDisplayDate(q6.payload.coverageFrom)} 至 {formatDisplayDate(q6.payload.coverageThrough)} · 已有 {q6.reviews.filter((x) => x.decision === "approve").length} 人同意{q6.stale ? ";材料已变化,须重新登记及双人复核" : ""}

覆盖导出:{q6.payload.exportIds.map(exportLabel).join("、")}

查询:{evidenceLink(q6.payload.queryEvidenceId)}对账单:{evidenceLink(q6.payload.statementEvidenceId)}{q6.payload.exporterStatements.map((x, i) => 导出人 {i + 1}({exports.filter((e) => e.exportedBy === x.exporterId && q6.payload.exportIds.includes(e.id)).map((e) => exportLabel(e.id)).join("、")}):{evidenceLink(x.evidenceId)})}
{reviewer && q6.status === "proposed" && !q6.stale && q6.proposedBy !== me?.id && !q6.reviews.some((x) => x.reviewedBy === me?.id) ? button("reviewQ6", q6.id) : null}
: null} + {finance && unlinked.length > 0 && r.status !== "cancelled" && (!q6 || q6.stale || q6.status === "rejected") ? button("q6") : null} + {current ?

补证恢复第 {current.sequence} 轮:{states[current.status]}{current.stale ? ";依据已变化,须重新发起" : ""}

{finance && current.status === "collecting" ? button("submitRecovery", current.id) : null}{finance && ["collecting", "proposed"].includes(current.status) ? button("withdrawRecovery", current.id) : null}{reviewer && current.status === "proposed" && !current.stale && r.releaseRecovery?.history[0]?.startedBy !== me?.id ? button("reviewRecovery", current.id) : null}
: null} + {finance && ["confirmed_failed", "confirmed_not_paid"].includes(r.bankResultState) && r.status !== "cancelled" && (!current || current.stale || ["withdrawn", "rejected"].includes(current.status)) ? button("recovery") : null} + {finance && terminal && r.status !== "cancelled" && !r.bankResults?.events.some((e) => e.kind === "late_success" && ["proposed", "approved"].includes(e.status)) ? button("cancel") : null} + {terminal ?

原制单文件不得再导入网银。系统不能使已下载文件失效;出现晚到成功应立即登记对账。

: null} + {finance && terminal ? button("event") : null} + {r.bankResults?.events.map((e, i) =>

{e.kind === "return" ? "追回到账" : "晚到成功"} {i + 1} · ¥{e.amount} · {states[e.status]}

{e.outstandingReturn ?

待追回 ¥{e.outstandingReturn}

: null}{e.settlements.map((s) =>

银行已付 ¥{s.bankAmount};结清本金 ¥{s.settledAmount};超额 ¥{s.receivableAmount}

)}
{button("download", e.evidenceId)}{button("details", e.id)}{reviewer && e.status === "proposed" && e.proposedBy !== me?.id ? button("reviewEvent", e.id) : null}{finance && e.status === "approved" ? button("apply", e.id) : null}{finance && e.status === "booked" && Number(e.outstandingReturn) > 0 ? button("refund", e.id) : null}
)} + { if (!next && !mutation.isPending) setAction(null); }}>{action ? actions[action] : "银行对账"}核对原始银行材料和具体指令。独立复核人不能兼任相关经办或收款人;材料不足时保持占用或冻结。人工未提交须三类材料齐全并由两名独立人员复核。 +
{ e.preventDefault(); mutation.mutate(); }}> + {details ?
{details.fields.map((f) =>

{f.Name}:{f.Value}

)}

{details.attributionNote}

{details.exclusionNote}

: null} + {mutation.error ?

{resolveErrorMessage(mutation.error)}

: null} + {action === "upload" ? <>{select("category", "证据类别", Object.entries(categories))} : null} + {action === "submission" ? <>{select("export", "使用的导出文件(手工录入可不选)", exports.map((e) => [e.id, `第 ${e.sequence} 次导出`]), false)}{input("reference", "银行指令号")}{input("date", "实际提交时间", "datetime-local")} : null} + {action === "terminal" || action === "failureAttribution" ? <>{select("submission", "对应银行提交", submissions.filter((s) => s.status === "active").map((s) => [s.id, s.bankInstructionRef]), action === "terminal")}{select("receipt", "失败回单(终态书面证据可不选)", receiptOptions, action === "failureAttribution")}{action === "terminal" ? <>{evidenceSelect("evidence", "银行终态书面证据", "bank_terminal", !value("receipt"))}{select("terminalStatus", "银行终态", [["failed", "已失败,不会再执行"], ["revoked", "已撤销,不会再执行"], ["rejected", "已拒绝,不会再执行"]], !value("receipt"))}
了结的同指令处理中回单(可多选){receiptOptions.map(([id, label]) => )}
: input("exclusion", "排除其他候选批次的依据")} : null} + {action === "q6" ? <>{evidenceSelect("query", "查询无结果官方 PDF", "query_no_result")}{evidenceSelect("statement", "同期对账单", "account_statement")}{[...new Set(unlinked.map((e) => e.exportedBy))].map((id, i) => evidenceSelect(`exporter:${id}`, `导出人 ${i + 1} 的文件去向说明`, "exporter_statement"))}{input("from", "查询与对账单覆盖开始", "datetime-local")}{input("through", "查询与对账单覆盖截至(须含登记日)", "datetime-local")} : null} + {action?.startsWith("review") ? select("decision", "复核结论", [["approve", "依据充分,同意"], ["reject", "不同意/有依据确认误报"]]) : null} + {action === "reviewEvent" && value("decision") === "reject" ? <>{evidenceSelect("evidence", "误关联或重复登记的更正证据(晚到成功必填)", "bank_terminal", false)}{select("correction", "关闭原因", [["misassociated", "误关联"], ["duplicate", "重复登记"]], false)} : null} + {action === "event" || action === "refund" ? <>{evidenceSelect("evidence", "对应的成功银行回单", action === "refund" ? "incoming_return" : "late_success")}{input("payer", "回单付款账号")}{input("payee", "回单收款账号")}{input("amount", "回单人民币金额")}{input("reference", "回单流水号")}{input("exclusion", "为什么不属于其他候选批次或交易")} : null} + {!['upload', 'submitRecovery', 'confirm', 'apply'].includes(action ?? '') ?