完善付款回单凭证核验与本地重检(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 行删除
@@ -127,6 +127,31 @@ QA script patterns over new abstractions.
### Receipt OCR Matching Service Boundary ### Receipt OCR Matching Service Boundary
- Ordinary bank-payment evidence must retain a versioned `document` classification
(`bank_receipt`, `non_final`, `unknown`) in the encrypted recognition/proposal.
Bound local PDF inspection by input size, page count, worker memory and time;
unreadable text remains unknown. Preserve provider disclaimers as restrictions.
A title, matching fields, or absence of an extracted disclaimer is not sufficient.
Automatic matching needs a supported final receipt, explicit successful payment
and no incoming direction or conflicting document facts. Keep this gate in
evidence construction and final reconstruction, not the shared `matchBankSlip`
helper used by failed/voided/terminal-fact comparisons.
- Manual `paymentProof` must attest a checked original and record the basis/note.
`booked_debit` requires a valid non-future bank booking date and an empty original
success status; preserve `transactionStatusRaw=null` rather than fabricate bank
wording. New proof always needs the batch's applicable confirmation mode.
Non-final disclaimers, incoming direction and known contradictory facts cannot
be removed by manual corrections. Manual-source evidence is not automatic OCR.
- Old unconsumed evidence without document inspection cannot default to final.
Finance may explicitly POST `receipt-attachments/:attachmentId/document-check`
with `expectedVersion`; use the existing batch/claim/attachment locks, scope and
maker checks. Verify stored bytes against size/SHA, create a new version and audit
atomically, preserve history and never call cloud OCR. Old manual approval does
not transfer to the new version. Reinspect and settle under the same batch lock;
runtime tests must cover both orders, stale versions and audit rollback.
A historical OCR result with zero known pages must still expose the inspection
action; reject malformed/out-of-range counts without allocating from them.
- Failed evidence persistence is not OCR failure. In a separate bounded transaction, - Failed evidence persistence is not OCR failure. In a separate bounded transaction,
lock the batch then conditionally update the attachment by version/SHA/processing lock the batch then conditionally update the attachment by version/SHA/processing
and active governance predicates. Persist only allowlisted system_failed, and active governance predicates. Persist only allowlisted system_failed,
@@ -10,6 +10,17 @@
## Receipt Verification Dialog ## Receipt Verification Dialog
- Show document nature separately from extracted transaction status. A booked
official debit receipt may use the actual booking date and checked-original
declaration; do not ask the operator to invent a successful bank status.
Show that basis to the reviewer and keep save/review separate from settlement.
Non-final receipts or contradictory directions have no manual override.
- Old evidence needing local document inspection exposes an explicit finance
action in the existing dialog; retain history and explain the need to recheck.
Reset document-proof choices along with corrections on refresh. Verify the
actual desktop/mobile proof entry, required original check, applicable reviewer,
final settlement and non-final refusal through the rendered dialogs.
- When readiness is unmet and an active receipt has a system/key/snapshot failure, - When readiness is unmet and an active receipt has a system/key/snapshot failure,
the batch banner must direct prerequisite repair, not an unavailable verification the batch banner must direct prerequisite repair, not an unavailable verification
dialog. This is display only: a retained failed attachment must not block other dialog. This is display only: a retained failed attachment must not block other
@@ -1,3 +1,36 @@
## 2026-10-08 实样发现后的凭证处理方案(N1–N2 已本地实现)
本节对应 PRD 顶部 R4/AC5 补充及 implement 的 N1–N2,用户已明确“开始执行”。沿用已实现的两人关联模式、回单存储、人工核对和核销流程;不重新设计 S1–S8/SR,不把 S002 专项格式兼容纳入必修范围。
实现收敛:本地使用 PDF 文本解析读取票面限制,无法可靠解析的维持未知;新增的结构化凭证依据随既有加密核验/人工提案保存。人工可按正式回单的已记账支出和记账日期确认,不生成虚假的银行状态原文。旧证据通过有权经办显式发起的本地重检生成新版本,保留历史、不调用云 OCR;最终资金动作不默认接受缺少新版依据的旧记录。
本轮采用现有表,无新增迁移。新增 `paymentReceiptDocument` 服务和受限 PDF 文本工作线程,使用 `pdfjs-dist`;单文件最多 5 MB、20 页,工作线程限时 5 秒、旧生代内存上限 128 MB。结构化凭证类型为正式回单、非最终回执或未知,原始票面文字不落日志;识别异常维持未知,不能自动核销。
人工依据分“原件明确成功”和“正式回单已记账支出”。后者须填写真实且不晚于当天的记账日期、原件已核对声明及依据说明,保存 `transactionStatusRaw=null`;新增人工依据必须按该批次模式确认。既有非最终说明、贷方入账、多个回单或失败/处理中矛盾均不能由人工覆盖。自动凭证检查位于核验构建与最终重建,未放入失败/终态事实也使用的低层字段匹配函数。
旧记录显式重检接口为 `POST /payment-batches/:id/receipt-attachments/:attachmentId/document-check`,要求当前版本、财务权限和经办资格,核对原件实际大小与摘要、未被消费,再在同一事务新增核验版本与审计;旧人工确认不得转移到新版本。历史 OCR 为 0 页时详情仍可打开重检入口,负数、超上限等无效页数继续拒绝。已核销历史不自动重写。实现及验证证据见 implement 顶部;独立代码复核和真实还款闭环不在本轮完成结论内。
### 凭证性质、字段读取、交易归属分别判断
1. 凭证性质先区分已确认的最终付款依据、明确非最终依据、尚不确定。银行限制说明、实际记账方向和入账/付款信息属于判断依据;文件名、标题、OCR 请求成功或没有提取到免责声明均不足以判为最终依据。公司贷方借入和借方还本分开处理。
2. 自动路径只开放经过实际材料核验的格式与字段组合;识别来源必须能覆盖判断凭证性质所需内容。当前 `BankSlipOCR` 的六类归一化字段不能证明完整覆盖票面说明。可在本地核对 PDF 文字,缺文字/提取异常保持未知;本阶段不以接入新的云服务或实现所有扫描件 OCR 为前提。
3. 人工路径在原件实际信息足够时使用。沿既有财务录入、马经理关联方确认,补充可追溯的凭证性质及已记账依据,不补造成功状态。明确非最终的回执不能经普通人工决定升级;需要取得对应正式付款证据。没有可用最终证据则保持待核对,不能以“以后会补回单”先核销。
4. 凭证足以证明付款也不自动证明属于哪个批次。金额、币种、双方账户、付款方向、银行交易身份及既有候选/提交关联继续分别检查;回单编号、客户方流水号和网银指令号分开保存或引用,不靠人工照抄使之相等。编号不足时保持待对账。
### 依据留存、旧数据和最终消费
- 在既有证据模型上留存原件摘要/版本、凭证判断及其依据、自动或人工来源、决定上下文;核验结果持久化、人工冷重建和最终核销都必须保留并重验这些信息。不能只在上传时或页面增加提示。
- 换件、重新识别、作废和候选变化继续使旧依据失效。旧核验记录缺少凭证判断时不能默认通过;已记账记录保持历史不可变,需要核查的问题另行登记,不自动撤销本金核销或重写生产数据。
- 本轮凭证依据随现有加密核验和人工提案留存,未新增迁移。涉及共享回单服务时回归报销消费者,既有独立性、交易唯一性、失败/受理与资金约束保持。
- 验收覆盖明确非最终说明、字段中出现成功但票面有限制、无法完整判别、正式凭据没有字面成功状态、借入/支出方向不同、旧结果缺依据、人工重建及最终核销。去除关键凭证检查后,测试必须能区分错误放行或合同退化。
### 实际材料与完成边界
- 原四份获授权 PDF 和 OCR 配置读取范围继续有效,无需为相同范围重复授权;先复用既有结果和本地材料,不无目的重跑云端。新增两份正式借入回单目前只做了本地读取;确有必要发送新文件时再明确文件范围。
- S001/S003 作为非最终凭证反例;S002 维持既有需复核结果,可用于材料性质/人工依据分析,不要求兼容其全部 OCR 字段,也不为它索取保证金退款材料。新增两份借入回单用于本金及格式核对,不能充作还本付款正例。
- 优先用现有材料验证原件字段与人工处理是否可行;只有一个具体交易的完整归属无法核对时,才列明需要该笔交易的正式支出回单及网银实际提交详情。现有无真实借款还款时,不要求制造付款获取样本;借款全链用合成数据,首笔真实银行闭环在获准实际还款后验收。
- 正常办理必须有一条经验证可用的自动或人工路径;仅能拦截不代表可启用。首笔前的软件/材料验证与首笔后的实际银行闭环分别记录。异常缺实样保持未验,不提前认定失败释放等已具备真实银行接受性,缺证继续占用。
## 2026-10-08 财务经办与马经理关联方确认方案(本地实现及合成验证完成,已授权本地提交) ## 2026-10-08 财务经办与马经理关联方确认方案(本地实现及合成验证完成,已授权本地提交)
用户已确认马经理有权代表公司批准,并在规划完成后明确“开始实施”。马经理同时为股东和个人出借收款人;技术员工不参与复核、审批、见证,不增加权限或分派待办。公司授权这一业务事实不再待决。本节是本次权限模式变更的执行合同;本地实现及合成验证已完成,具体证据与剩余验收见 implement 顶部。下方历史设计中的独立复核,除 Q6 外按本节明确区分模式;本地改动不等于生产已具备该模式。 用户已确认马经理有权代表公司批准,并在规划完成后明确“开始实施”。马经理同时为股东和个人出借收款人;技术员工不参与复核、审批、见证,不增加权限或分派待办。公司授权这一业务事实不再待决。本节是本次权限模式变更的执行合同;本地实现及合成验证已完成,具体证据与剩余验收见 implement 顶部。下方历史设计中的独立复核,除 Q6 外按本节明确区分模式;本地改动不等于生产已具备该模式。
@@ -1,3 +1,90 @@
## 2026-10-09 N3 本地提交与新候选验证(独立代码复核待完成)
用户在 N1–N2 完成回执后明确“继续下一步”,本轮按已说明的 N3 接续本地提交和新候选验证。提交范围为已核对的 31 项改动;源码、测试及规约与上一轮最终清单逐项摘要一致,本轮仅补本节和 PRD 接续记录。提交、验证的实际结果以本轮回执及 Git 记录为准,不提前记为通过。
- 单独形成一个本地提交:`完善付款回单凭证核验与本地重检(09-17-company-loan-principal-repayment)`,包含 27 个修改文件、4 个新增文件;不加入输入材料、本地验证产物或个人日志。提交说明保留“独立代码复核及真实还款验收待完成”,不推送、不部署。
- 复用 N1–N2 已通过且输入未变化的针对性结果;完成本轮任务/流程/敏感/差异检查后提交。新提交固定后运行 `bash scripts/qa/run-release-gate.sh verify-candidate`,其范围为 static 和完整受管 full-local;不调用发布入口,不以旧候选或限定重验替代。
- 本轮回执固定为 `artifacts/release-gate/loan-receipt-n3-20261009/`,准备与清理记录为 `artifacts/qa-flow/loan-receipt-n3-20261009/`。测试期间冻结源码、测试和任务文档;结果、当前候选及复核交接记录写入本轮回执,不修改正在验证的提交。
- 本轮使用专用的临时浏览器目录和已有制单 Python 依赖环境;完整验证由既有受管入口建立新数据库及隔离网络,使用合成账号/材料。验证结束后只清理本轮拥有的临时资源,不读取真实 OCR 配置、不外发银行文件、不查询生产。
- 当前 developer 的 inline 指令明确禁止派 implement/check 子代理。主会话已完成可执行检查,但独立代码复核仍未完成,已有旧阶段代理报告不能覆盖关联模式和 N1–N2;保持独立复核、真实还款、生产启用的开放状态,不因本地提交或候选门禁通过而勾选整体任务完成。
- 门禁如发现本任务问题,在既有范围内修复并做相关验证,再形成新的本地修复提交与对应回执;保留失败证据,不修改原提交、不忽略失败或缩减正式门禁范围。N4 的生产查询、推送、部署和 N5–N6 的真实业务/付款不在本轮范围。
下方“未提交”等为 2026-10-08 完成时点;本轮候选与验证结论以以上回执为准。
## 2026-10-08 实际材料核对后的后续计划(N1–N2 本地完成,未提交)
用户先要求重新梳理规划,随后批准 N1–N2 本地实施、针对性验证和主会话检查。实施基线为已提交的 `ce11dea9`,其 `verify-candidate` 为 PASS:82 迁移、服务端 4519 通过/1 项既有可选跳过、浏览器 372 通过且无失败/跳过/重试;基线回执见 `artifacts/release-gate/loan-ce11dea9-20261008/`。本轮实现和验证见下方完成回执,旧候选门禁不覆盖本轮差异。没有本轮提交、推送、部署、生产访问或真实业务写入;任务仍为 `in_progress`。下方其他阶段状态保留为历史时点。
### 已完成及无需重复索取
- [x] A/B/C、S1–S8、SR 补证恢复、电脑/手机界面和两人关联模式已本地实现并提交,不再按未开发项目排期。
- [x] 两笔正式借入回单本地核对完成;用户已确认历史未还本金、本年无新增借还、开卡小额往来排除及沿用原个人收款账户。实际值见受控材料,不抄入版本库;真实 OA 台账尚未登记。
- [x] 财务经办、马经理公司代表权及关联身份已确认;技术员工不参与业务审批/复核/见证。关联 Q6 关闭,不再将第三人或查询无结果 PDF 列为普通还款前提。
- [x] 原四份获授权 PDF 已做真实 OCR 实测。S001/S003 字段匹配但为非最终回执;S002 保持需复核、专项适配后置。两份新借入原件为正式回单,不代表未来支出。没有执行真实核销或银行验真。
### 下一阶段顺序与完成条件
| 顺序 | 执行内容 | 完成条件及依赖 | 人工配合 |
|---|---|---|---|
| N1 凭证判断与最小修复 | 已实现正式回单、非最终回执及未知分类;自动核验、人工记账依据、旧证据本地重检、证据重建和最终消费共用有效依据 | 本地完成。非最终和未知不得自动核销;人工不得覆盖已知限制或补造银行成功文字,资金反例与变异通过;无新增迁移 | 现有余额、账户、人员不重问;当前无需新增人工配合 |
| N2 正常路径及材料预验 | 已复用六份材料本地核对,并以一份既有正式支出样本做真实字段的内存人工录入预验;隔离环境完成财务与经理的自动/人工流程、提交记录及拒绝场景 | 本地预验完成;合成借款可实际核销,电脑/手机均实操。历史样本只证明其原交易与格式,未证明真实借款还本及网银提交对应;真实闭环仍待 N6 | 当前不用重新下载材料或制造交易。首笔真实还款时保留本笔提交详情与正式付款回单;新增文件外发 OCR 才需明确新增文件范围 |
| N3 独立代码复核与候选验证 | 固定最终差异及证据,完成独立只读复核和修复后的针对性验证;获准本地提交后对干净新候选运行正式门禁 | 依赖 N1/N2;范围覆盖当前关联模式以及本轮凭证变化。记录复核来源与结论,当前 inline 主会话检查不替代独立复核。源码变化后不能沿用 `ce11dea9` 回执 | 不让公司技术员工做业务复核;独立审查执行条件不足时如实列明。提交另按明确授权办理 |
| N4 发布准备及获准发布 | 准备候选、真实迁移差集核实方案、数据库及附件快照、兼容处置和部署后电脑/手机检查;获准后只读复核生产,再按批准候选推送部署 | 依赖 N3 与正常路径可用;2026-10-07 空存量不是发布前现状,73→82 只是历史推算,实际差集现场核实。保留未验异常边界;产生关联决定后不能直接降级到旧语义代码 | 生产只读查询、推送、部署分别取得明确授权。独立备份切换仍按原范围处理,不能借安装脚本发布整个 main |
| N5 真实台账及授权登记 | 财务按已有原件和用户确认登记两笔历史借入、未还本金、核对截止日、双方账户及会计引用;登记已有公司代表权和覆盖借款,由马经理在 OA 确认 | 依赖已获准可用环境和真实业务录入授权;逐笔核对与现有会计负债记录一致,不重复生成历史现金流或会计凭证,不把 OCR 结果当成余额确认 | 财务实际录入并核对,马经理确认授权范围及相关声明;不重复向用户索取已确认事实,不要求技术员工参与 |
| N6 首笔真实还款及会计交接 | 确认本次金额/分配/日期,财务申请,马经理关联方批准,财务导出、在网银实际提交、留存提交详情与正式付款回单,核验/必要人工确认后核销,交代账留存 | 依赖 N5 及本笔真实付款批准。实际支付仍由有权人员在网银操作;只核销本次确实付款的本金。首笔成功后才能记录真实银行闭环验收;不把软件预验写成真实还款已通过 | 到办理时确认本次还多少及付款安排;不从剩余本金推定全额付款。保留银行指令详情、正式回单和代账交接结果;异常未知不重付 |
N1–N2 已按授权完成本地实施和验证;后续进入 N3 的独立代码复核、本地提交与新候选验证,再按 N4–N6 办理发布和真实业务。复核交接范围已列在本轮完成回执中;当前 inline 主会话不能代替独立复核。提交、发布及真实付款不包含在本轮实施授权里;已明确批准的同范围工作执行时不再逐步询问。
### 验收与取样纪律
- 原件类型、OCR 字段、交易归属、最终核销分别记录。现有实测止于匹配函数,不宣称已复现生产错误核销。正式回单没有字面成功状态的问题须按实际记账证据设计,不能以人工补写状态通过。
- N1 先跑相关回单/OCR/人工重建单测及借款隔离 HTTP/数据库资金反例;涉及页面再跑相应电脑/手机操作。覆盖共享报销路径,重点反例配有效变异。新候选形成后再运行完整正式门禁,本轮定向结果不代替该门禁。
- 真样本不足的异常路径继续标未验,保持银行事实、占用及冻结;不得用异常“人工说没付”绕过。若上线前仍不能验证一条正常最终付款路径,N4 不进入真实启用。
- 不再要求补本年完整银行流水、重新核实已确认借款是否还本、S002 保证金退款材料或重复下载同格式回单。确需补材料时明确具体交易、材料名称、缺失字段、取得方法和用途。
- 仅在现有材料无法回答本次需要时再问银行资料问题;普通 OCR 不以前置咨询银行为条件。新增文件云 OCR 授权、生产业务访问及真实写入与原四份样本授权分开。
### 规划及实施检查
- [x] 已重新核对当前候选回执、四份样本实测、本地新增回单分类及用户确认;没有新增云 OCR 调用或读取生产配置。
- [x] 规划阶段的任务校验、Trellis 治理(含敏感扫描)、流程合同 baseline 和 `git diff --check` 通过;文档注册检查零错误,保留原有“22 个 docs 未登记”的一条汇总告警。该时点仅三份既有任务文档新增内容,历史正文完整保留,尚未运行应用测试;实施后的验证见下方完成回执。
- [x] 用户明确“开始执行,需要我手动操作的及时告诉我”,批准 N1–N2 本地实施与针对性验证。实施期间需要人工配合时说明具体步骤和用途;不重复索取已确认事实或原四份样本授权。
- [x] N1:凭证识别及保存、自动/人工核验、旧记录本地重检、最终消费与页面接续。
- [x] N2:现有材料的受控本地核对、隔离资金与电脑/手机回归、定向变异、主会话检查及真实验收边界记录。无提交、推送、部署或真实数据写入授权。
### N1–N2 本轮完成回执
本轮 31 项改动为 27 个已跟踪文件修改及 4 个新增文件,均未提交。新增受限本地 PDF 凭证解析服务/工作线程、其单测和带真实可提取文字的合成回单工具;服务端新增 `pdfjs-dist` 依赖,继续使用部署及 CI 既有 Node 22。没有新增迁移,隔离数据库仍验证 82 个迁移。
- 正式回单、非最终回执、未知分类随加密核验证据保留。构建核验和最终消费均拒绝非最终、入账方向、已知状态矛盾和缺少依据;失败/终态流程共用的低层字段匹配规则未被付款凭证门禁替换。
- 正式回单没有字面成功状态时,可依据原件记账支出、实际记账日期和原件已核对声明提出人工核验。必须由该批次适用的确认人确认;关联模式仍为财务经办与马经理公司代表确认。保留原始状态为空,不生成虚假的“交易成功”。
- 旧核验缺少凭证性质时,财务在原弹窗显式更新,校验原件摘要/大小及版本后新增核验与审计,不发送云 OCR、不改历史或沿用旧人工批准。重检和最终核销按同一批次锁串行;审计失败整体回滚,已消费证据不可重检。
- 主会话检查补齐历史 OCR 零页结果的详情兼容:零页仍可进入本地重检,负数、超过 20 页或异常大页数拒绝;新增 4 项路由反例。此项仅改善详情入口,不放宽最终资金判断。
- 合成 PDF 票面金额、账户及银行编号与对应的模拟 OCR/人工录入一致。测试沿真实解析、归一化、加密核验、人工重建及核销,仅替换云端识别返回;不是只向匹配函数塞入“成功”结果。
验证证据统一位于 `artifacts/qa-flow/loan-receipt-evidence-20261008/`;各行是不同范围,不累计为独立测试总数:
| 验证 | 结果与准确范围 | 回执 |
|---|---|---|
| 完整借款 HTTP 与数据库 | 116 组 HTTP、9 组数据库约束、82 个迁移通过;含 21 组关联/本轮回单场景、拒绝后本金占用不变、旧自动/人工记录重检和两种确定性交错 | `loan-http-final.log` |
| 关联模式电脑/手机实操 | 各 9 组,共 18 组通过;实际填写记账依据/日期、必须勾选原件核对、公司代表确认、最终核销,非最终回执无手动覆盖或核销动作;本轮运行明确未跑 HTTP | `loan-ui-final.log`;截图索引在该日志 |
| 服务端全量 | 228 文件、4554 项通过,1 项既有可选跳过。随后仅改进合成 PDF 数据一致性和零页详情兼容,对应以下定向/HTTP/浏览器回归;不将新增 4 项虚报为已经重跑的全量数字 | `server-unit-final.log` |
| 最新回单及报销路由定向 | 7 文件、292 项通过,覆盖上述最后改动 | `receipt-targeted-latest.log` |
| 共享回单数据库及真实认证链 | 38 组通过,使用合成账号经真实密码/JWT;审核、读取、重复交易、事务及并发受约束 | `receipt-db.log` |
| 共享报销页面限定重验 | 24 项通过,受管环境 PASS,电脑/手机回单交互及实际报销付款链通过;不是整站正式门禁 | `web-shared-ready.log`、`web-shared-ready/results/` |
| 资金检查变异 | 基线通过,9 个不同守卫全部被具体断言捕获;对最终合成数据又复验同一组 9 个,不累计为 18 个守卫。临时副本测试,当前源码摘要未变、副本已删除 | `mutation-final/mutations.json`;首轮 `mutations.json` 保留 |
| 静态及合同 | Prisma 校验、前端类型检查/构建、错误码合同、UI 规则、敏感扫描、任务和流程/文档检查通过;最终文档检查回执见同目录 `checks-final.json` | 同目录检查日志 |
早期失败证据保留:`server-unit.log` 的 6 项制单测试因未指向现有 Python 依赖环境失败,指定既有环境后全量通过;`web-shared.log` 为缺 WebKit 导致的环境无效,使用本轮专用浏览器后受管重验通过;`loan-full.log` 已通过 HTTP 后在浏览器错误期待“禁用按钮”,产品实际隐藏该动作,改成验证不存在后相关电脑/手机 18 组通过。不得将这些早期日志记作整轮 PASS,也未无原因重跑已通过的无关测试。
### 实样预验、复核交接及待人工事项
- 六份已有材料均本地读取且原件摘要未变:S001/S003 为非最终回执,S002-A 为正式支出回单,S002-B 凭证性质未知,两份借入回单为正式入账回单。S002 仍仅用于格式预验,不作为借款或还本事实,专项 OCR 兼容继续后置。回执 `real-document-check.json`。
- S002-A 按原件实际金额、币种、双方账号、编号及日期,在内存中构造已记账支出的人工核验提案成功,要求后续业务确认,未编造成功状态。没有建立真实 OA 批次、真实批准或核销;未验证其银行提交对应或真实借款还款。原始字段没有写入报告,回执 `local-manual-proof-check.json`。
- 本轮新增云 OCR 调用为 0,未读取 OCR 配置或生产数据、未写真实台账、未付款。原四份样本既有 OCR 授权不扩大到新增借入文件;本地预验无需外发这些文件。
- 主会话按本任务验收检查了全部改动,包括 4 个未跟踪新文件,未发现尚未处理的本轮阻断;当前 inline 模式不能派 implement/check 子代理,**独立代码复核仍未完成**。N3 需同时复核关联模式与本轮差异,重点为原件限制/人工依据、旧证据重建、权限与锁序、共享报销消费者、页面及 9 项变异证据;不能仅检查 `git diff` 而遗漏新增文件。
- 当前无需用户补下载材料、询问银行或要求技术员工参与。获准上线和真实录入时,财务用已有凭据登记历史本金及公司授权,马经理在 OA 确认;首笔真实还款前再明确本次金额/分配,财务实际网银提交,保留该笔提交详情及正式付款回单。到对应步骤再给出操作指引,不从余额推定全额付款。
- 本轮独立代码复核、明确授权的本地提交、新候选正式门禁、获准发布和首笔真实还款闭环均未完成;AC5、AC8、AC27 及整体真实业务验收保持开放。没有推送、部署、远端备份切换、生产查询、真实写入或付款。
## 2026-10-08 关联方模式本地提交与正式验证(已授权) ## 2026-10-08 关联方模式本地提交与正式验证(已授权)
用户明确答复“是,授权”,同意将本轮 39 项改动作为一个完整的本地提交,并对干净的新候选运行正式验证。提交范围为 33 个修改文件和 6 个新增文件,含一个新增迁移;文件摘要已与上一轮收尾清单核对一致,本次只补授权及验证安排的记录。 用户明确答复“是,授权”,同意将本轮 39 项改动作为一个完整的本地提交,并对干净的新候选运行正式验证。提交范围为 33 个修改文件和 6 个新增文件,含一个新增迁移;文件摘要已与上一轮收尾清单核对一致,本次只补授权及验证安排的记录。
@@ -1,5 +1,32 @@
# 公司借款台账与本金归还 # 公司借款台账与本金归还
## 2026-10-09 N3 本地提交及新候选验证接续
用户明确“继续下一步”,沿已列明的 N3 处理 N1–N2 的 31 项本地改动:核对上一轮验证清单、补任务记录、单独本地提交并对干净的新候选运行 `verify-candidate`。范围、命令及失败处置见 implement 顶部;本轮实际候选及结果以 `artifacts/release-gate/loan-receipt-n3-20261009/` 为准,不沿用 `ce11dea9` 的通过结论。
当前 inline 指令禁止派 implement/check 子代理;独立代码复核与真实还款验收继续待完成。提交和候选验证不代表生产启用,未授权推送、部署、生产访问或真实业务写入。下方 N1–N2“未提交”为上一轮历史时点;整体任务保持 `in_progress`,AC5、AC8、AC27 等开放条件不提前勾选。
## 2026-10-08 实际银行材料核对后的接续(N1–N2 本地完成,未提交)
用户先要求重新梳理后续任务,随后明确“开始执行,需要我手动操作的及时告诉我”,批准 N1–N2 的本地实施、针对性验证及主会话检查。目标是形成可证明实际付款的回单处理路径,再完成独立代码复核、获准发布和首笔真实还款;执行顺序见 implement 顶部,凭证处理方案见 design 顶部。提交、推送、生产操作及真实付款不在本次范围。
N1–N2 已完成本地实现及可执行验证:正式/非最终/未知凭证区分、无字面成功状态的已记账支出人工核对、旧证据本地重检、最终核销及电脑/手机交互已贯通;原件限制与交易唯一性不能由人工覆盖。合成借款链和真实历史材料的内存预验分别留证,详细范围见 implement 顶部。新增 31 项本地改动尚未提交,无新增迁移;独立代码复核及真实银行闭环仍待完成。
- 实施基线为已提交候选 `ce11dea9`,关联方模式正式本地门禁已通过:82 个迁移、服务端 4519 项通过及 1 项既有可选跳过、浏览器 372 项通过且无失败/跳过/重试。证据为 `artifacts/release-gate/loan-ce11dea9-20261008/release-result.json` 及同目录 `local-followup.json`,不覆盖本轮未提交的凭证变化。本地 main 比本地 origin/main 多 13 个提交;未推送、未部署。
- 两笔历史借入的正式银行回单已做本地文字核对;用户已确认均未归还本金、本年无新增借还、开卡小额往来已归还且不计借款,并确认沿用原个人收款账户。具体金额、账号及原件仅在受控材料中保留。上述事实不再重复提问;财务正式录入时仍须留存原凭据、已有会计记录引用及核对截止日,不把历史借入当成当天新增收款。
- 财务经办、马经理代表公司作关联方确认;技术员工不参与业务复核、审批或见证。代表权不再待决,真实授权及范围尚未写入 OA。关联模式 Q6 继续关闭,不要求补足第三人或取得无结果查询 PDF 来办理正常还款。
- 获准的四份 PDF 此前已完成真实 OCR 调用。其中 S001/S003 虽字段匹配,但原件注明不作为最终交易依据;此前实样只执行到本地匹配函数,不能称为已复现生产错误核销。本轮借款完整链只用合成数据;另用 S002-A 的正式支出原件做内存人工核验预验,证明该格式可以留存记账依据,不代表其属于借款或已完成真实还本。S002 专项 OCR 适配仍后置。新取得的两份正式借入回单证明的是历史入账,不能充当未来还本的支出回单;本轮没有新增云 OCR 调用。
- 独立代码复核、正常付款凭据及提交归属实样验收、真实业务录入、发布与公网验收保持开放。此前银行流水及原件整理不等于这些项目已完成;2026-10-07 的生产空存量结论仅适用于该查询时点。
### R4/AC5 本次补充的凭证验收边界
本小节已随 N1–N2 获准实施,完成状态以 implement 的验证结果为准;其余 R1–R6 和原 AC 编号保持不变。
- 明确注明非最终依据、仅受理/处理中,或凭证性质不明的材料,可以留存,不能仅因金额、账户、状态字段匹配而核销。机器未提取出限制说明不等于原件没有该说明;普通人工录入或关联确认不能覆盖已知限制。
- 正式银行回单是否证明实际付款,应根据已核验格式的记账方向、记账/交易事实和关键字段共同判断。不能仅凭标题推定成功,也不能要求经办补写原件不存在的“交易成功”。正式借入回单的公司入账方向不能当作公司还本的付款方向。
- 原件足以证明付款而机器未能提全时,保留有依据的人工核对路径;经办录入原件实际信息、确认人按既定模式核对,记录原件及判断依据。原件不足、交易归属不明、字段冲突或重复交易仍不能放行。自动与人工结论分开记录。
- 核验、人工证据重建及最终核销须消费同一份仍有效的凭证依据;旧记录缺少新判断依据时不得直接沿用为自动通过,不能回写已发生的付款历史。验收必须包含最终资金动作及失败后本金/占用未变化,不能止于匹配函数。
## 2026-10-08 两人办理关联方还款(本地实施与合成验证完成,已授权本地提交及正式验证) ## 2026-10-08 两人办理关联方还款(本地实施与合成验证完成,已授权本地提交及正式验证)
- 用户确认:实际经办为一名财务兼人事行政;马经理为股东、个人出借收款人,且有权代表公司批准还款。技术员工不参与复核、审批或见证,不增加财务权限,不作为候选、任务接收人或业务验收所需人员。 - 用户确认:实际经办为一名财务兼人事行政;马经理为股东、个人出借收款人,且有权代表公司批准还款。技术员工不参与复核、审批或见证,不增加财务权限,不作为候选、任务接收人或业务验收所需人员。
@@ -123,6 +150,12 @@ R3 的模式同时作用于付款审批、提交清单、成功/失败归属、
## Current Status ## Current Status
N1–N2 在 `ce11dea9` 基线上完成本地实现和主会话检查,31 项改动未提交,无新增迁移。最新验证为 82 个迁移、116 组借款 HTTP、9 组数据库约束、18 组关联电脑/手机操作、38 组共享回单数据库/真实认证及 24 项共享报销页面限定重验通过;回单/报销最新定向 292 项通过,9 个资金守卫变异全部捕获。服务端全量为 4554 通过、1 项既有可选跳过,随后合成数据及零页详情的改动由上述最新定向覆盖,不虚报重新全量。Prisma、前端类型/构建及合同检查通过,准确时点和回执见 implement 顶部。
六份真实材料的本地分类及既有正式支出样本的内存人工录入预验已完成;未用真实借款执行核销,也未验证真实银行提交对应。当前不用用户补材料。独立代码复核、新候选正式门禁、获准发布/真实台账与授权登记、首笔真实还款闭环仍未完成;AC5、AC8、AC27 及整体业务验收不据本地结果勾选。原候选的正式门禁不覆盖当前差异,没有本轮提交、推送、部署、生产访问或真实写入。
### 关联方模式收尾记录(历史时点,最新状态见上文)
本地已有 A/B/C、S1–S8、SR恢复及电脑/手机界面,本轮另完成关联方模式 RP1–RP4 的实现与可执行验证,已获准本地提交及新候选正式验证。最新借款专项为82迁移、HTTP 109、浏览器33、数据库约束9组通过;关联模式单列14组HTTP及电脑/手机各7组浏览器,13个有效守卫变异均捕获。报销付款、付款权限和导航35项通过,现行差旅冻结保护另2项通过;一次误选封存差旅整链的403失败保留,未恢复差旅权限。服务端全量4519通过、1项原有跳过,后续小改的77项定向验证通过,详细先后和证据见implement。AC20–AC26仅完成本地合成验收;AC5、AC8、AC27及其余整体业务验收保持开放。 本地已有 A/B/C、S1–S8、SR恢复及电脑/手机界面,本轮另完成关联方模式 RP1–RP4 的实现与可执行验证,已获准本地提交及新候选正式验证。最新借款专项为82迁移、HTTP 109、浏览器33、数据库约束9组通过;关联模式单列14组HTTP及电脑/手机各7组浏览器,13个有效守卫变异均捕获。报销付款、付款权限和导航35项通过,现行差旅冻结保护另2项通过;一次误选封存差旅整链的403失败保留,未恢复差旅权限。服务端全量4519通过、1项原有跳过,后续小改的77项定向验证通过,详细先后和证据见implement。AC20–AC26仅完成本地合成验收;AC5、AC8、AC27及其余整体业务验收保持开放。
较早候选 `4c37fcee` 的正式门禁 PASS 只证明旧独立模式;本轮提交后单独生成 `verify-candidate` 回执,不提前宣称通过,主会话检查也不等同独立代码复核。不得以测试数字推定真实银行接受性或生产已经应用。 较早候选 `4c37fcee` 的正式门禁 PASS 只证明旧独立模式;本轮提交后单独生成 `verify-candidate` 回执,不提前宣称通过,主会话检查也不等同独立代码复核。不得以测试数字推定真实银行接受性或生产已经应用。
+2
查看文件
@@ -803,6 +803,8 @@ export const ERROR_REASON_MESSAGES: Record<string, string> = {
"payment_batch.receipt_transaction_reused": "回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。", "payment_batch.receipt_transaction_reused": "回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。",
"payment_batch.receipt_manual_state_changed": "回单或核验版本已变化,请刷新后重新查看当前状态。", "payment_batch.receipt_manual_state_changed": "回单或核验版本已变化,请刷新后重新查看当前状态。",
"payment_batch.receipt_manual_verification_required": "回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。", "payment_batch.receipt_manual_verification_required": "回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。",
"payment_batch.receipt_document_check_required": "这份回单尚未检查凭证性质,请在核验详情中更新凭证核验后重新核对。",
"payment_batch.receipt_final_evidence_required": "当前材料不能证明本笔付款已完成。请核对正式回单的实际付款或记账依据;非最终回执、入账方向不符或证据不明时不能登记付款。",
"payment_batch.receipt_key_unavailable": "回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。", "payment_batch.receipt_key_unavailable": "回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。",
"payment_batch.receipt_key_rotation_required": "回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。", "payment_batch.receipt_key_rotation_required": "回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。",
"payment_batch.receipt_snapshot_unsupported": "付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。", "payment_batch.receipt_snapshot_unsupported": "付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。",
+27 -1
查看文件
@@ -293,6 +293,23 @@ export async function retryPaymentBatchReceiptOcr(batchId: string, attachmentId:
); );
} }
export type ReceiptPaymentProof = {
basis: "explicit_success" | "booked_debit";
originalChecked: true;
note: string;
bookedOn?: string;
};
export type ReceiptDocumentEvidence = {
version: number;
kind: "bank_receipt" | "non_final" | "unknown";
source: "pdf_text" | "image" | "unreadable" | "manual";
direction: "debit" | "credit" | null;
explicitSuccess: boolean;
issues: string[];
proof?: ReceiptPaymentProof;
};
export type ReceiptVerificationPage = { export type ReceiptVerificationPage = {
pageNumber: number; pageNumber: number;
amount: string | null; amount: string | null;
@@ -302,10 +319,12 @@ export type ReceiptVerificationPage = {
transactionStatus: string | null; transactionStatus: string | null;
reference: string | null; reference: string | null;
itemId?: string; itemId?: string;
document?: ReceiptDocumentEvidence | null;
}; };
export type ReceiptVerificationDetail = { export type ReceiptVerificationDetail = {
canReview?: boolean; canReview?: boolean;
documentInspectionRequired?: boolean;
version: number; version: number;
state: string; state: string;
pagesExpected: number; pagesExpected: number;
@@ -331,7 +350,7 @@ export function fetchReceiptReadiness(batchId: string, signal?: AbortSignal) {
export function saveReceiptVerification(batchId: string, attachmentId: string, body: { export function saveReceiptVerification(batchId: string, attachmentId: string, body: {
expectedVersion: number; reason?: string; expectedVersion: number; reason?: string;
pages: { pageNumber: number; itemId: string; fields?: { Name: string; Value: string }[] }[]; pages: { pageNumber: number; itemId: string; fields?: { Name: string; Value: string }[]; paymentProof?: ReceiptPaymentProof }[];
}) { }) {
return apiRequestData<{ item: { id: string; status: string; version: number } }>( return apiRequestData<{ item: { id: string; status: string; version: number } }>(
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/manual-verification`, `/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/manual-verification`,
@@ -339,6 +358,13 @@ export function saveReceiptVerification(batchId: string, attachmentId: string, b
); );
} }
export function checkReceiptDocument(batchId: string, attachmentId: string, expectedVersion: number) {
return apiRequestData<{ item: { id: string; status: string; version: number } }>(
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/document-check`,
{ method: "POST", body: JSON.stringify({ expectedVersion }) },
);
}
export function reviewReceiptVerification(batchId: string, attachmentId: string, proposalId: string, body: { export function reviewReceiptVerification(batchId: string, attachmentId: string, proposalId: string, body: {
expectedVersion: number; decision: "approve" | "reject"; reason: string; expectedVersion: number; decision: "approve" | "reject"; reason: string;
}) { }) {
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
fetchReceiptVerification, saveReceiptVerification, reviewReceiptVerification, fetchReceiptVerification, saveReceiptVerification, reviewReceiptVerification, checkReceiptDocument,
type ReceiptVerificationPage, type ReceiptVerificationPage,
} from "../../api/paymentBatches"; } from "../../api/paymentBatches";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
@@ -13,7 +13,7 @@ import { Textarea } from "../ui/textarea";
const FIELD_NAMES = ["小写金额", "币别", "付款人账号", "收款人账号", "凭证号", "单据状态"] as const; const FIELD_NAMES = ["小写金额", "币别", "付款人账号", "收款人账号", "凭证号", "单据状态"] as const;
const CHANGE_LABELS: Record<string, string> = { const CHANGE_LABELS: Record<string, string> = {
amount: "金额", currency: "币种", payerAccount: "付款账号", payeeAccount: "收款账号", amount: "金额", currency: "币种", payerAccount: "付款账号", payeeAccount: "收款账号",
reference: "交易参考", transactionStatus: "交易状态", reference: "交易参考", transactionStatus: "交易状态", paymentProof: "付款凭证依据",
}; };
function PageFacts({ page }: { page?: ReceiptVerificationPage }) { function PageFacts({ page }: { page?: ReceiptVerificationPage }) {
@@ -22,7 +22,11 @@ function PageFacts({ page }: { page?: ReceiptVerificationPage }) {
<div>付款账号:{page?.payerAccountMasked ?? "未识别"}</div> <div>付款账号:{page?.payerAccountMasked ?? "未识别"}</div>
<div>收款账号:{page?.payeeAccountMasked ?? "未识别"}</div> <div>收款账号:{page?.payeeAccountMasked ?? "未识别"}</div>
<div>交易参考:{page?.reference ?? "未识别"}</div> <div>交易参考:{page?.reference ?? "未识别"}</div>
<div>状态:{{ success: "成功", failed: "失败", pending: "受理或处理中" }[page?.transactionStatus ?? ""] ?? "未确认"}</div> <div>状态:{page?.document?.proof?.basis === "booked_debit" ? "已记账支出(人工核对)" :
({ success: "成功", failed: "失败", pending: "受理或处理中" }[page?.transactionStatus ?? ""] ?? "未确认")}</div>
<div>凭证性质:{{ bank_receipt: "银行正式回单", non_final: "非最终回执", unknown: "需核对原件" }[page?.document?.kind ?? "unknown"]}</div>
{page?.document?.proof ? <div>付款依据:{page.document.proof.basis === "booked_debit" ?
`正式回单记载已记账支出,记账日期 ${page.document.proof.bookedOn}` : "正式回单原件明确显示付款成功"}</div> : null}
</div>; </div>;
} }
@@ -40,6 +44,7 @@ export function PaymentReceiptVerificationDialog(props: Props) {
const [editing, setEditing] = useState<Record<number, boolean>>({}); const [editing, setEditing] = useState<Record<number, boolean>>({});
const [changes, setChanges] = useState<Record<number, Record<string, string>>>({}); const [changes, setChanges] = useState<Record<number, Record<string, string>>>({});
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [proofs, setProofs] = useState<Record<number, { basis: "" | "explicit_success" | "booked_debit"; bookedOn: string; checked: boolean }>>({});
const query = useQuery({ const query = useQuery({
queryKey: ["receipt-verification", props.actorId, props.batchId, props.attachmentId], queryKey: ["receipt-verification", props.actorId, props.batchId, props.attachmentId],
queryFn: ({ signal }) => fetchReceiptVerification(props.batchId, props.attachmentId, signal), queryFn: ({ signal }) => fetchReceiptVerification(props.batchId, props.attachmentId, signal),
@@ -59,7 +64,14 @@ export function PaymentReceiptVerificationDialog(props: Props) {
const pageNumber = index + 1; const pageNumber = index + 1;
const fields = Object.entries(changes[pageNumber] ?? {}).filter(([, value]) => value.trim()) const fields = Object.entries(changes[pageNumber] ?? {}).filter(([, value]) => value.trim())
.map(([Name, Value]) => ({ Name, Value })); .map(([Name, Value]) => ({ Name, Value }));
return { pageNumber, itemId: selections[pageNumber] ?? "", ...(fields.length ? { fields } : {}) }; const proof = proofs[pageNumber];
if (proof?.basis === "booked_debit") {
const status = fields.find((field) => field.Name === "单据状态");
if (status) status.Value = ""; else fields.push({ Name: "单据状态", Value: "" });
}
return { pageNumber, itemId: selections[pageNumber] ?? "", ...(fields.length ? { fields } : {}),
...(proof?.basis ? { paymentProof: { basis: proof.basis, originalChecked: true as const, note: reason.trim(),
...(proof.basis === "booked_debit" ? { bookedOn: proof.bookedOn } : {}) } } : {}) };
}), }),
}), }),
onSuccess: changed, onSuccess: changed,
@@ -71,15 +83,26 @@ export function PaymentReceiptVerificationDialog(props: Props) {
), ),
onSuccess: changed, onSuccess: changed,
}); });
const busy = save.isPending || review.isPending; const inspect = useMutation({
mutationFn: () => checkReceiptDocument(props.batchId, props.attachmentId, detail!.version),
onSuccess: changed,
});
const busy = save.isPending || review.isPending || inspect.isPending;
const pageCount = detail && Number.isInteger(detail.pagesExpected) && detail.pagesExpected > 0 && const pageCount = detail && Number.isInteger(detail.pagesExpected) && detail.pagesExpected > 0 &&
detail.pagesExpected <= 20 ? detail.pagesExpected : 0; detail.pagesExpected <= 20 ? detail.pagesExpected : 0;
const editable = props.canManage && detail && ["needs_review", "ocr_failed"].includes(detail.state) && !query.isError; const editable = props.canManage && detail && !detail.documentInspectionRequired && ["needs_review", "ocr_failed"].includes(detail.state) && !query.isError;
const reviewable = props.canReview && detail?.canReview === true && detail?.proposal?.status === "pending_review" && const reviewable = props.canReview && detail?.canReview === true && detail?.proposal?.status === "pending_review" &&
props.actorId !== detail.proposal.proposedBy && props.actorId !== props.makerId && !query.isError; props.actorId !== detail.proposal.proposedBy && props.actorId !== props.makerId && !query.isError;
const complete = pageCount > 0 && Array.from({ length: pageCount }, (_unused, index) => selections[index + 1]).every(Boolean); const blocked = (page?: ReceiptVerificationPage) => page?.document?.kind === "non_final" ||
page?.document?.direction === "credit" || Boolean(page?.document?.issues.length);
const needsProof = (page?: ReceiptVerificationPage) => page?.document?.kind !== "bank_receipt" || !page.document.explicitSuccess;
const complete = pageCount > 0 && Array.from({ length: pageCount }, (_unused, index) => {
const n = index + 1; const page = detail?.pages.find((entry) => entry.pageNumber === n); const proof = proofs[n];
return selections[n] && !blocked(page) && (!needsProof(page) || (proof?.basis && proof.checked &&
reason.trim().length >= 5 && (proof.basis !== "booked_debit" || /^\d{4}-\d{2}-\d{2}$/.test(proof.bookedOn))));
}).every(Boolean);
const hasCorrections = Object.values(changes).some((fields) => Object.values(fields).some((value) => value.trim())); const hasCorrections = Object.values(changes).some((fields) => Object.values(fields).some((value) => value.trim()));
const error = query.error ?? save.error ?? review.error; const error = query.error ?? save.error ?? review.error ?? inspect.error;
return <Dialog open onOpenChange={(open) => { if (!open && !busy) props.onClose(); }}> return <Dialog open onOpenChange={(open) => { if (!open && !busy) props.onClose(); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
@@ -90,10 +113,15 @@ export function PaymentReceiptVerificationDialog(props: Props) {
{error ? <div role="alert" className="space-y-2 text-sm text-red-600"> {error ? <div role="alert" className="space-y-2 text-sm text-red-600">
<p>{resolveErrorMessage(error)}</p> <p>{resolveErrorMessage(error)}</p>
<Button variant="secondary" disabled={busy} onClick={() => { <Button variant="secondary" disabled={busy} onClick={() => {
setSelections({}); setChanges({}); setEditing({}); setReason(""); save.reset(); review.reset(); setSelections({}); setChanges({}); setEditing({}); setProofs({}); setReason(""); save.reset(); review.reset(); inspect.reset();
void query.refetch(); void query.refetch();
}}>刷新核验信息</Button> }}>刷新核验信息</Button>
</div> : null} </div> : null}
{detail?.documentInspectionRequired ? <div role="status" className="space-y-2 rounded-lg border p-3 text-sm">
<p>旧核验尚未记录凭证性质。更新后会保留原记录,并要求重新核对原件;不会重新发送文件识别。</p>
{props.canManage && !query.isError ? <Button variant="secondary" disabled={busy || query.isFetching}
onClick={() => inspect.mutate()}>{inspect.isPending ? "检查中…" : "更新凭证核验"}</Button> : null}
</div> : null}
{detail && !pageCount ? <p role="status">尚无完整页数,请先重新识别或上传可读取的原回单。</p> : null} {detail && !pageCount ? <p role="status">尚无完整页数,请先重新识别或上传可读取的原回单。</p> : null}
{Array.from({ length: pageCount }, (_unused, index) => index + 1).map((pageNumber) => { {Array.from({ length: pageCount }, (_unused, index) => index + 1).map((pageNumber) => {
const original = detail?.pages.find((page) => page.pageNumber === pageNumber); const original = detail?.pages.find((page) => page.pageNumber === pageNumber);
@@ -101,13 +129,14 @@ export function PaymentReceiptVerificationDialog(props: Props) {
return <section key={pageNumber} className="space-y-3 rounded-lg border p-3"> return <section key={pageNumber} className="space-y-3 rounded-lg border p-3">
<h3 className="font-medium">第{pageNumber}页 · 原识别结果</h3> <h3 className="font-medium">第{pageNumber}页 · 原识别结果</h3>
<PageFacts page={original} /> <PageFacts page={original} />
{blocked(original) ? <p role="status" className="text-sm text-red-600">这份材料不能证明本笔付款完成。请提供对应的正式付款回单;不能通过更正字段覆盖非最终说明、收付方向或交易矛盾。</p> : null}
{proposed ? <div className="space-y-2 border-t pt-3"> {proposed ? <div className="space-y-2 border-t pt-3">
<h4 className="font-medium">人工核验结果</h4> <h4 className="font-medium">人工核验结果</h4>
<PageFacts page={proposed} /> <PageFacts page={proposed} />
<p className="text-sm">对应明细:{props.items.find((item) => item.id === proposed.itemId)?.label ?? "当前明细不可用"}</p> <p className="text-sm">对应明细:{props.items.find((item) => item.id === proposed.itemId)?.label ?? "当前明细不可用"}</p>
<p className="text-sm">更正字段:{detail?.proposal?.changedFields.find((entry) => entry.pageNumber === pageNumber)?.fields.map((field) => CHANGE_LABELS[field] ?? "字段").join("、") || "未更正,仅关联"}</p> <p className="text-sm">更正字段:{detail?.proposal?.changedFields.find((entry) => entry.pageNumber === pageNumber)?.fields.map((field) => CHANGE_LABELS[field] ?? "字段").join("、") || "未更正,仅关联"}</p>
</div> : null} </div> : null}
{editable ? <> {editable && !blocked(original) ? <>
<label className="block space-y-1 text-sm"> <label className="block space-y-1 text-sm">
<span>第{pageNumber}页对应付款明细</span> <span>第{pageNumber}页对应付款明细</span>
<select className="min-h-11 w-full rounded-md border bg-white px-3" disabled={busy} <select className="min-h-11 w-full rounded-md border bg-white px-3" disabled={busy}
@@ -117,13 +146,39 @@ export function PaymentReceiptVerificationDialog(props: Props) {
{props.items.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)} {props.items.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</select> </select>
</label> </label>
{needsProof(original) ? <div className="space-y-3 rounded-md border p-3 text-sm">
<label className="block space-y-1">
<span>第{pageNumber}页付款凭证依据</span>
<select className="min-h-11 w-full rounded-md border bg-white px-3" disabled={busy}
value={proofs[pageNumber]?.basis ?? ""} onChange={(event) => setProofs((current) => ({ ...current,
[pageNumber]: { bookedOn: current[pageNumber]?.bookedOn ?? "", checked: false,
basis: event.target.value as "" | "explicit_success" | "booked_debit" } }))}>
<option value="">请先查看原件,再选择依据</option>
<option value="explicit_success">正式回单原件明确显示付款成功</option>
<option value="booked_debit">正式回单记载公司已记账支出</option>
</select>
</label>
{proofs[pageNumber]?.basis === "booked_debit" ? <label className="block space-y-1">
<span>第{pageNumber}页原件记账日期</span>
<Input type="date" disabled={busy} value={proofs[pageNumber]?.bookedOn ?? ""}
onChange={(event) => setProofs((current) => ({ ...current,
[pageNumber]: { ...current[pageNumber], bookedOn: event.target.value } }))} />
</label> : null}
<label className="flex min-h-11 items-start gap-2">
<input type="checkbox" className="mt-1" disabled={busy || !proofs[pageNumber]?.basis}
checked={proofs[pageNumber]?.checked ?? false} onChange={(event) => setProofs((current) => ({ ...current,
[pageNumber]: { ...current[pageNumber], checked: event.target.checked } }))} />
<span>已查看第{pageNumber}页完整原件,确认为本公司付款的正式回单,无非最终依据说明或矛盾;核验说明中填写原件依据。</span>
</label>
<p className="text-xs text-zinc-500">记账支出不要求补填“交易成功”。凭证性质或资金方向不能确认时,请先补证。</p>
</div> : null}
<Button type="button" variant="secondary" disabled={busy} onClick={() => { <Button type="button" variant="secondary" disabled={busy} onClick={() => {
setEditing((current) => ({ ...current, [pageNumber]: !current[pageNumber] })); setEditing((current) => ({ ...current, [pageNumber]: !current[pageNumber] }));
if (editing[pageNumber]) setChanges((current) => ({ ...current, [pageNumber]: {} })); if (editing[pageNumber]) setChanges((current) => ({ ...current, [pageNumber]: {} }));
}}>{editing[pageNumber] ? "取消本页更正" : "更正识别"}</Button> }}>{editing[pageNumber] ? "取消本页更正" : "更正识别"}</Button>
{editing[pageNumber] ? <div className="space-y-3"> {editing[pageNumber] ? <div className="space-y-3">
<p className="text-xs text-zinc-500">只填写要更正的字段,其他字段保留原识别值。更正后需要{props.relatedParty ? "公司代表确认" : "独立复核"}。</p> <p className="text-xs text-zinc-500">只填写要更正的字段,其他字段保留原识别值。更正后需要{props.relatedParty ? "公司代表确认" : "独立复核"}。</p>
{FIELD_NAMES.map((name) => <label key={name} className="block space-y-1 text-sm"> {FIELD_NAMES.filter((name) => name !== "单据状态" || proofs[pageNumber]?.basis !== "booked_debit").map((name) => <label key={name} className="block space-y-1 text-sm">
<span>第{pageNumber}页更正{name}</span> <span>第{pageNumber}页更正{name}</span>
{name === "单据状态" ? <select className="min-h-11 w-full rounded-md border bg-white px-3" {name === "单据状态" ? <select className="min-h-11 w-full rounded-md border bg-white px-3"
disabled={busy} value={changes[pageNumber]?.[name] ?? ""} onChange={(event) => disabled={busy} value={changes[pageNumber]?.[name] ?? ""} onChange={(event) =>
@@ -152,7 +207,7 @@ export function PaymentReceiptVerificationDialog(props: Props) {
onClick={() => save.mutate()}>{save.isPending ? "保存中…" : "保存核验"}</Button> : null} onClick={() => save.mutate()}>{save.isPending ? "保存中…" : "保存核验"}</Button> : null}
{reviewable ? <> {reviewable ? <>
<Button variant="secondary" disabled={busy || query.isFetching || !reason.trim()} onClick={() => review.mutate("reject")}>不通过</Button> <Button variant="secondary" disabled={busy || query.isFetching || !reason.trim()} onClick={() => review.mutate("reject")}>不通过</Button>
<Button disabled={busy || query.isFetching || !reason.trim()} onClick={() => review.mutate("approve")}>{props.relatedParty ? "公司代表确认" : "确认复核"}</Button> <Button disabled={busy || query.isFetching || !reason.trim() || detail?.documentInspectionRequired || detail?.pages.some(blocked)} onClick={() => review.mutate("approve")}>{props.relatedParty ? "公司代表确认" : "确认复核"}</Button>
</> : null} </> : null}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -196,7 +196,9 @@ export async function reimbursementPaymentFlow(page: Page) {
await page.goto(`/payment-batches?id=${batch.id}`); await page.goto(`/payment-batches?id=${batch.id}`);
expect((await finance.api.post(`/api/payment-batches/${batch.id}/execute`, {data: {}})).status()).toBe(409); expect((await finance.api.post(`/api/payment-batches/${batch.id}/execute`, {data: {}})).status()).toBe(409);
checkpoint("receipt-upload:start"); checkpoint("receipt-upload:start");
const receiptPdf = await syntheticPdf(`Synthetic receipt ${batch.id}`); const receiptPdf = await requireServer('../scripts/qa/synthetic-bank-receipt.cjs').syntheticBankReceiptPdf({
tag: `Synthetic receipt ${batch.id}`, reference: `SYNTHETIC-${batch.id}`, payerAccount: payer, payeeAccount: payee,
});
const uploading = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/payment-batches/${batch.id}/receipt-attachments`); const uploading = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/payment-batches/${batch.id}/receipt-attachments`);
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({name: "synthetic-receipt.pdf", mimeType: "application/pdf", buffer: receiptPdf}); await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({name: "synthetic-receipt.pdf", mimeType: "application/pdf", buffer: receiptPdf});
const upload = await uploading; const upload = await uploading;
@@ -582,9 +582,10 @@ async function mockSystemActionApis(
const verificationPath = `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/manual-verification`; const verificationPath = `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/manual-verification`;
if (path === verificationPath && request.method() === "GET") { if (path === verificationPath && request.method() === "GET") {
const facts = { pageNumber: 1, amount: "88.00", currency: "CNY", payerAccountMasked: "0000****2222", const facts = { pageNumber: 1, amount: "88.00", currency: "CNY", payerAccountMasked: "0000****2222",
payeeAccountMasked: "0000****4444", reference: "SYN-MANUAL-UI", transactionStatus: "success" }; payeeAccountMasked: "0000****4444", reference: "SYN-MANUAL-UI", transactionStatus: "success",
document: { version: 1, kind: "bank_receipt", source: "pdf_text", direction: "debit", explicitSuccess: true, issues: [] } };
await fulfillJson(route, { item: { version: 3, state: options.receiptManualReview ? "pending_review" : "needs_review", await fulfillJson(route, { item: { version: 3, state: options.receiptManualReview ? "pending_review" : "needs_review",
canReview: Boolean(options.receiptManualReview), pagesExpected: 1, pages: [facts], canReview: Boolean(options.receiptManualReview), documentInspectionRequired: false, pagesExpected: 1, pages: [facts],
proposal: options.receiptManualReview ? { id: "synthetic-proposal", status: "pending_review", proposal: options.receiptManualReview ? { id: "synthetic-proposal", status: "pending_review",
proposedBy: "another-finance", reviewedBy: null, reason: "合成核验说明", proposedBy: "another-finance", reviewedBy: null, reason: "合成核验说明",
changedFields: [{ pageNumber: 1, fields: ["amount"] }], changedFields: [{ pageNumber: 1, fields: ["amount"] }],
+2
查看文件
@@ -159,6 +159,8 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 付款批次列表不加载费用明细、回单文件名或其他高敏材料,只在授权详情中按需读取。 - 付款批次列表不加载费用明细、回单文件名或其他高敏材料,只在授权详情中按需读取。
- PDF、JPG、PNG、WEBP 银行回单可在付款批次详情中在线查看。在线查看由系统记录固定用途“付款批次内在线预览”并写审计,不要求用户重复填写。 - PDF、JPG、PNG、WEBP 银行回单可在付款批次详情中在线查看。在线查看由系统记录固定用途“付款批次内在线预览”并写审计,不要求用户重复填写。
- 回单下载到本地仍要求填写用途并写下载审计;治理标签使用“下载需说明用途”。在线查看和下载都必须重新检查权限、部门范围、作废/隔离状态、大小、存储对象和实际文件内容,不直接暴露存储路径。 - 回单下载到本地仍要求填写用途并写下载审计;治理标签使用“下载需说明用途”。在线查看和下载都必须重新检查权限、部门范围、作废/隔离状态、大小、存储对象和实际文件内容,不直接暴露存储路径。
- 付款核验分别判断凭证性质、字段和交易归属。载明“不作为最终依据”或仅表示提交、受理的回执不能用于登记付款完成,即使金额、账号和成功字样能识别;公司收到款项的借入回单也不能证明公司已经还款。机器无法判断凭证性质时,应核对完整原件,不能默认按正式付款回单处理。
- 正式回单没有“交易成功”字样,但原件明确记录本公司已经发生的支出时,财务可选择“正式回单记载公司已记账支出”,填写票面记账日期和依据说明,再由本批次适用的确认人核对。不要补写银行未记载的成功状态;非最终说明、方向矛盾、归属不清或重复交易不能通过此方式绕过。核验确认本身不登记付款,最终登记仍须重验;旧核验缺少凭证性质时须在详情中显式更新并重新核对。
## 差旅实报实销与支付证明 ## 差旅实报实销与支付证明
+23 -9
查看文件
@@ -168,7 +168,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
// BankSlipOCR response is synthetic, built from the intended slip fields. // BankSlipOCR response is synthetic, built from the intended slip fields.
const { recognizePaymentReceipt } = req('./src/services/paymentReceiptOcr'); const { recognizePaymentReceipt } = req('./src/services/paymentReceiptOcr');
const { PDFDocument } = req('pdf-lib'); const { PDFDocument } = req('pdf-lib');
const receiptPdf = async (pages = 1) => { const doc = await PDFDocument.create(); for (let i = 0; i < pages; i++) doc.addPage([200, 200]); return Buffer.from(await doc.save()); }; const { syntheticBankReceiptPdf, syntheticDocumentEvidence } = require('./synthetic-bank-receipt.cjs');
const receiptPdf = (pages = 1, options = {}) => syntheticBankReceiptPdf({ pages, ...options });
const onePagePdf = await receiptPdf(1); const onePagePdf = await receiptPdf(1);
const STATUS_WORDING = { success: '交易成功', failed: '交易失败', pending: '处理中' }; const STATUS_WORDING = { success: '交易成功', failed: '交易失败', pending: '处理中' };
function bankSlipFields(slip) { function bankSlipFields(slip) {
@@ -185,9 +186,17 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
} }
return fields; return fields;
} }
async function receipt(batchId, amount, reference, overrides = {}, { pdfPages = 1, normalizedPage = false } = {}) { async function receipt(batchId, amount, reference, overrides = {}, { pdfPages = 1, normalizedPage = false, document = {} } = {}) {
const boundary = `synthetic-${randomUUID()}`; const boundary = `synthetic-${randomUUID()}`;
const file = pdfPages === 1 ? onePagePdf : await receiptPdf(pdfPages); const status = overrides.transactionStatus === null ? 'booked' : overrides.transactionStatus ??
(overrides.statusWordings?.some((value) => ['交易失败', '转账失败', '付款失败', '支付失败'].includes(value)) ? 'failed' : 'success');
const minor = overrides.amount ?? amount;
const storedBatch = minor === null ? await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batchId }, include: { items: true } }) : null;
const displayedAmount = minor === null ? String(storedBatch.items[0].amount) :
`${BigInt(minor) / 100n}.${String(BigInt(minor) % 100n).padStart(2, '0')}`;
const file = await receiptPdf(pdfPages, { status, amount: displayedAmount, currency: overrides.currency ?? '人民币',
payerAccount: overrides.payerAccount ?? payerAccount, payeeAccount: overrides.payeeAccount ?? bankAccount,
reference: overrides.reference ?? reference ?? 'SYNTHETIC-UNREAD-REFERENCE', ...document });
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`, const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, 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`)]) }); 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`)]) });
@@ -200,7 +209,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
// normalizedPage: one explicitly synthetic defence-in-depth case that the // normalizedPage: one explicitly synthetic defence-in-depth case that the
// real normalizer cannot produce; every other receipt uses the real chain. // real normalizer cannot produce; every other receipt uses the real chain.
const recognize = normalizedPage const recognize = normalizedPage
? async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, issues: [], ...slip }] }) ? async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, issues: [], ...slip,
document: syntheticDocumentEvidence({ status: slip.transactionStatus ?? 'booked' }) }] })
: (args) => recognizePaymentReceipt({ ...args, createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: bankSlipFields(slip) }) }) }); : (args) => recognizePaymentReceipt({ ...args, createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: bankSlipFields(slip) }) }) });
const result = await runReceiptOcrOnce({ prisma, config, recognize }); const result = await runReceiptOcrOnce({ prisma, config, recognize });
return result; return result;
@@ -288,13 +298,15 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
checks.push('confirmed_bank_failure_requires_independent_matching_evidence'); checks.push('confirmed_bank_failure_requires_independent_matching_evidence');
const { item: manual } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '20.00' }] }); const { item: manual } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '20.00' }] });
await call(manager, 'POST', `/payment-batches/${manual.id}/review`, {}); await call(manager, 'POST', `/payment-batches/${manual.id}/review`, {});
const unclear = await receipt(manual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }); const unclear = await receipt(manual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] },
{ document: { reference: 'SYNTHETIC-MANUAL' } });
assert.equal(unclear.result.status, 'needs_review'); assert.equal(unclear.result.status, 'needs_review');
const currentAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: unclear.attachment.id } }); const currentAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: unclear.attachment.id } });
const manualBase = `/payment-batches/${manual.id}/receipt-attachments/${unclear.attachment.id}/manual-verification`; const manualBase = `/payment-batches/${manual.id}/receipt-attachments/${unclear.attachment.id}/manual-verification`;
const proposal = await call(finance, 'POST', manualBase, { expectedVersion: currentAttachment.receipt_ocr_version, const proposal = await call(finance, 'POST', manualBase, { expectedVersion: currentAttachment.receipt_ocr_version,
reason: '合成原件独立人工核对', pages: [{ pageNumber: 1, itemId: manual.items[0].id, fields: Object.entries({ reason: '合成原件独立人工核对', pages: [{ pageNumber: 1, itemId: manual.items[0].id,
小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: bankAccount, 凭证号: 'SYNTHETIC-MANUAL', 单据状态: '交易成功', paymentProof: { basis: 'booked_debit', originalChecked: true, bookedOn: '2026-10-01', note: '合成正式支出回单已记账,无成功状态原文' }, fields: Object.entries({
小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: bankAccount, 凭证号: 'SYNTHETIC-MANUAL', 单据状态: '',
}).map(([Name, Value]) => ({ Name, Value })) }] }); }).map(([Name, Value]) => ({ Name, Value })) }] });
const saved = proposal.item ?? proposal; const saved = proposal.item ?? proposal;
await call(finance, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成不允许自审' }, 403); await call(finance, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成不允许自审' }, 403);
@@ -495,7 +507,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
await call(reviewerB, 'POST', `/payment-batches/${viaManual.id}/review`, {}); await call(reviewerB, 'POST', `/payment-batches/${viaManual.id}/review`, {});
await exportOrder(viaManual.id); await exportOrder(viaManual.id);
await setStatus(reviewerB, 'archived'); await setStatus(lenderEmployee, 'archived'); await setStatus(reviewerB, 'archived'); await setStatus(lenderEmployee, 'archived');
const manualUnclear = await receipt(viaManual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }); const manualUnclear = await receipt(viaManual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] },
{ document: { status: 'success', reference: 'SYNTHETIC-POST-PAY-MANUAL', payeeAccount: staffLender.bankAccount } });
assert.equal(manualUnclear.result.status, 'needs_review'); assert.equal(manualUnclear.result.status, 'needs_review');
const manualAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: manualUnclear.attachment.id } }); const manualAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: manualUnclear.attachment.id } });
const manualPath = `/payment-batches/${viaManual.id}/receipt-attachments/${manualUnclear.attachment.id}/manual-verification`; const manualPath = `/payment-batches/${viaManual.id}/receipt-attachments/${manualUnclear.attachment.id}/manual-verification`;
@@ -1219,7 +1232,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
const c2manualBatch = await repay(probeLender.id, probeLoan.id, '6.00'); const c2manualBatch = await repay(probeLender.id, probeLoan.id, '6.00');
await call(manager, 'POST', `/payment-batches/${c2manualBatch.id}/review`, {}); await exportOrder(c2manualBatch.id); await call(manager, 'POST', `/payment-batches/${c2manualBatch.id}/review`, {}); await exportOrder(c2manualBatch.id);
const beforeManual = await probeBalance(); const beforeManual = await probeBalance();
const c2unclear = await receipt(c2manualBatch.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }); const c2unclear = await receipt(c2manualBatch.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] },
{ document: { status: 'success', reference: 'SYNTHETIC-C2-MANUAL', payeeAccount: probeLender.bankAccount } });
const c2attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: c2unclear.attachment.id } }); const c2attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: c2unclear.attachment.id } });
const c2path = `/payment-batches/${c2manualBatch.id}/receipt-attachments/${c2unclear.attachment.id}/manual-verification`; const c2path = `/payment-batches/${c2manualBatch.id}/receipt-attachments/${c2unclear.attachment.id}/manual-verification`;
const c2proposal = await call(dual, 'POST', c2path, { expectedVersion: c2attachment.receipt_ocr_version, reason: '合成人工核对', const c2proposal = await call(dual, 'POST', c2path, { expectedVersion: c2attachment.receipt_ocr_version, reason: '合成人工核对',
+49 -3
查看文件
@@ -4,6 +4,7 @@ const { mkdtempSync, mkdirSync, writeFileSync } = require('node:fs');
const { createRequire } = require('node:module'); const { createRequire } = require('node:module');
const req = createRequire(path.resolve(__dirname, '../../client/package.json')); const req = createRequire(path.resolve(__dirname, '../../client/package.json'));
const { chromium, expect } = req('@playwright/test'); const { chromium, expect } = req('@playwright/test');
const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs');
module.exports = async function relatedBrowser(h) { module.exports = async function relatedBrowser(h) {
const { finance, manager, ordinary, password, prisma, relatedParty, config } = 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() }); }); 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; 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'); 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); 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; await p.reload(); return result;
} }
try { try {
@@ -141,6 +146,47 @@ module.exports = async function relatedBrowser(h) {
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行'); await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
checks.push(`${s.mode}_related_manual_receipt_confirmation_and_rebuilt_settlement`); 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(); 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 f.receipt(failed.id, '700', ref, { payeeAccount: s.lender.bankAccount, transactionStatus: 'failed' });
await go(fp, failed); await go(fp, failed);
+160
查看文件
@@ -95,6 +95,19 @@ module.exports = async function relatedFixture(h) {
await put({ ...original, authorizationEventId: 'damaged-synthetic-event' }); await put({ ...original, authorizationEventId: 'damaged-synthetic-event' });
try { await check(); } finally { await put(original); } try { await check(); } finally { await put(original); }
} }
async function legacyVerification(attachmentId) {
const last = await prisma.paymentReceiptVerification.findFirstOrThrow({ where: { attachment_id: attachmentId },
include: { matches: true }, orderBy: { created_at: 'desc' } });
const crypto = req('./src/services/crypto');
const old = JSON.parse(crypto.decryptStringOrThrow(config, last.normalized_encrypted));
delete old.documentPages; for (const page of old.pages) delete page.document;
// Seed an old-policy record, preserving every existing immutable record.
return prisma.paymentReceiptVerification.create({ data: { attachment_id: last.attachment_id, source_sha256: last.source_sha256,
source_version: last.source_version, status: last.status, pages_expected: last.pages_expected, pages_processed: last.pages_processed,
normalized_encrypted: crypto.encryptStringOrThrow(config, JSON.stringify(old)),
...(last.matches.length ? { matches: { create: last.matches.map(({ page_number, item_id, fingerprint, key_id }) =>
({ page_number, item_id, fingerprint, key_id })) } } : {}) } });
}
const originalWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 0; const originalWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 0;
const ui = []; const ui = [];
try { try {
@@ -248,6 +261,131 @@ module.exports = async function relatedFixture(h) {
assert.equal(stored.loan_approval_mode, 'related_party'); assert.equal(stored.loan_approval_mode, 'related_party');
} }
checks.push('rp_manual_receipt_decision_cold_reconstruction_and_historical_consumption'); checks.push('rp_manual_receipt_decision_cold_reconstruction_and_historical_consumption');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-NONFINAL-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }, { document: { final: false } });
assert.equal(rr.result.status, 'needs_review');
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`;
const d = (await call(finance, 'GET', endpoint)).item;
assert.equal(d.pages[0].document.kind, 'non_final');
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成不得覆盖非最终说明', pages: [{
pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'explicit_success', originalChecked: true, note: '合成强行声明仍然必须被拦截' },
}] }, 409, 'payment_batch.receipt_final_evidence_required');
assert.equal((await balance(s)).outstanding, '500.00'); assert.equal((await balance(s)).reserved, '10.00');
}
checks.push('receipt_nonfinal_fields_match_but_final_settlement_and_manual_override_rejected');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-BOOKED-${randomUUID()}`, { payeeAccount: s.lender.bankAccount, transactionStatus: null });
assert.equal(rr.result.status, 'needs_review');
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`;
const d = (await call(finance, 'GET', endpoint)).item;
assert.equal(d.pages[0].document.kind, 'bank_receipt'); assert.equal(d.pages[0].transactionStatus, null);
await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成无状态也无记账依据',
pages: [{ pageNumber: 1, itemId: b.items[0].id }] }, 409, 'payment_batch.receipt_final_evidence_required');
const p = (await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成原件列明公司借方记账信息,未写成功字样', pages: [{
pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'booked_debit', originalChecked: true,
bookedOn: '2026-10-01', note: '合成正式回单记载公司付款账户借方已记账支出' },
}] })).item;
const original = (await call(manager, 'GET', endpoint)).item;
assert.equal(original.pages[0].transactionStatus, null);
assert.equal(original.proposal.pages[0].document.proof.basis, 'booked_debit');
await call(finance, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成自审被拒' }, 403);
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
await call(manager, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成代表公司核对完整原件及已记账依据' });
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'); assert.equal((await balance(s)).reserved, '0.00');
}
checks.push('receipt_booked_debit_without_invented_status_two_person_review_and_final_settlement');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-CREDIT-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }, { document: { direction: 'credit' } });
assert.equal(rr.result.status, 'needs_review');
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`;
const d = (await call(finance, 'GET', endpoint)).item;
await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成借入回单不能用于还本', pages: [{
pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'explicit_success', originalChecked: true, note: '合成方向矛盾必须拒绝' },
}] }, 409, 'payment_batch.receipt_final_evidence_required');
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
assert.equal((await balance(s)).outstanding, '500.00');
}
checks.push('receipt_incoming_direction_cannot_settle_outgoing_principal');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-LEGACY-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
const old = await legacyVerification(rr.attachment.id);
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}`;
const d = (await call(finance, 'GET', `${endpoint}/manual-verification`)).item;
assert.equal(d.documentInspectionRequired, true);
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
await call(manager, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 403);
await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version - 1 }, 409);
const verificationCount = await prisma.paymentReceiptVerification.count({ where: { attachment_id: rr.attachment.id } });
await pool.query(`CREATE FUNCTION synthetic_document_audit_fail() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action='PAYMENT_RECEIPT_DOCUMENT_CHECKED' THEN RAISE EXCEPTION 'synthetic document audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_document_audit_fail BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_document_audit_fail()');
try {
await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 500);
assert.equal((await prisma.paymentBatchReceiptAttachment.findUniqueOrThrow({ where: { id: rr.attachment.id } })).receipt_ocr_version, d.version);
assert.equal(await prisma.paymentReceiptVerification.count({ where: { attachment_id: rr.attachment.id } }), verificationCount);
assert.equal((await balance(s)).reserved, '10.00');
} finally {
await pool.query('DROP TRIGGER synthetic_document_audit_fail ON audit_logs');
await pool.query('DROP FUNCTION synthetic_document_audit_fail()');
}
const checked = (await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version })).item;
assert.equal(checked.version, d.version + 1);
assert.equal((await call(finance, 'GET', `${endpoint}/manual-verification`)).item.documentInspectionRequired, false);
await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 409);
assert.ok(await prisma.auditLog.findFirst({ where: { target_id: b.id, action: 'PAYMENT_RECEIPT_DOCUMENT_CHECKED' } }));
assert.equal((await prisma.paymentReceiptVerification.findUniqueOrThrow({ where: { id: old.id } })).normalized_encrypted, old.normalized_encrypted);
await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await balance(s)).outstanding, '490.00');
}
checks.push('receipt_legacy_matched_requires_versioned_local_inspection_without_cloud_retransmission');
checks.push('receipt_local_inspection_audit_failure_rolls_back_new_version_and_evidence');
{
const s = await setup(); const b = await batch(s); const reference = `RP-OLD-MANUAL-${randomUUID()}`;
const rr = await receipt(b.id, null, reference, { payeeAccount: s.lender.bankAccount });
const old = await legacyVerification(rr.attachment.id); const id = randomUUID();
const crypto = req('./src/services/crypto');
const oldFields = Object.entries({ 小写金额: '10.00', 币别: '人民币', 付款人账号: '8'.repeat(16),
收款人账号: s.lender.bankAccount, 凭证号: reference, 单据状态: '交易成功' }).map(([Name, Value]) => ({ Name, Value }));
// Represent a historically approved proposal without new document fields;
// current HTTP approval must never manufacture this obsolete evidence.
const saved = await prisma.$transaction(async tx => {
const storedBatch = await tx.paymentBatch.findUniqueOrThrow({ where: { id: b.id }, include: { items: true } });
const context = await req('./src/services/loanApprovalPolicy').decisionFields(tx, storedBatch, manager.id,
{ targetId: id, conflictIds: [finance.id] });
const p = await tx.paymentReceiptManualProposal.create({ data: { id, attachment_id: rr.attachment.id,
original_verification_id: old.id, source_sha256: old.source_sha256, source_version: old.source_version + 1,
status: 'approved', proposed_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), ...context,
payload_encrypted: crypto.encryptStringOrThrow(config, JSON.stringify({ pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: oldFields }],
reason: '合成旧策略人工核验', changedFields: [{ pageNumber: 1, fields: ['amount'] }] })),
review_note_encrypted: crypto.encryptStringOrThrow(config, '合成旧策略公司确认') } });
await tx.paymentBatchReceiptAttachment.update({ where: { id: rr.attachment.id },
data: { receipt_ocr_version: old.source_version + 1, receipt_ocr_state: 'manual_approved' } });
return p;
});
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}`;
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
assert.equal((await balance(s)).outstanding, '500.00');
assert.equal((await call(finance, 'GET', `${endpoint}/manual-verification`)).item.documentInspectionRequired, true);
const checked = (await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: saved.source_version })).item;
assert.equal(checked.status, 'needs_review');
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
const renewed = (await call(finance, 'POST', `${endpoint}/manual-verification`, { expectedVersion: checked.version,
reason: '合成本地更新后重新核对原件金额', pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: [{ Name: '小写金额', Value: '10.00' }] }] })).item;
await call(manager, 'POST', `${endpoint}/manual-verification/${renewed.id}/review`, { expectedVersion: renewed.version,
decision: 'approve', reason: '合成公司代表重新确认本轮依据' });
await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await prisma.paymentReceiptManualProposal.findUniqueOrThrow({ where: { id: saved.id } })).payload_encrypted, saved.payload_encrypted);
assert.equal((await prisma.paymentReceiptClaim.findFirstOrThrow({ where: { item_id: b.items[0].id } })).manual_proposal_id, renewed.id);
assert.equal((await balance(s)).outstanding, '490.00');
}
checks.push('receipt_old_manual_approval_needs_reinspection_and_new_confirmation_without_rewriting_history');
{ {
const s = await setup(); const a = await batch(s); await batch(s); const s = await setup(); const a = await batch(s); await batch(s);
await receipt(a.id, '1000', `RP-ATTR-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }); await receipt(a.id, '1000', `RP-ATTR-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
@@ -407,9 +545,31 @@ module.exports = async function relatedFixture(h) {
const state = await balance(s); const state = await balance(s);
assert.equal(state.outstanding, '100.00'); assert.equal(state.reserved, kind === 'release' && !stopFirst ? '0.00' : '10.00'); assert.equal(state.outstanding, '100.00'); assert.equal(state.reserved, kind === 'release' && !stopFirst ? '0.00' : '10.00');
} }
for (const checkFirst of [true, false]) {
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-DOCUMENT-RACE-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
const old = await legacyVerification(rr.attachment.id);
const check = () => app.inject({ method: 'POST', url: `/api${base(b)}/receipt-attachments/${rr.attachment.id}/document-check`,
headers: finance.headers, payload: { expectedVersion: old.source_version } });
const execute = () => app.inject({ method: 'POST', url: `/api${base(b)}/execute`, headers: finance.headers, payload: {} });
const holder = await locks.connect(); let checked; let paid;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [b.id]);
if (checkFirst) { checked = check(); await waitFor(1); paid = execute(); }
else { paid = execute(); await waitFor(1); checked = check(); }
await waitFor(2); await holder.query('COMMIT');
const results = await Promise.all([checked, paid]);
assert.equal(results[0].statusCode, 200, 'local reinspection serializes with settlement');
assert.equal(results[1].statusCode, checkFirst ? 200 : 409, 'settlement uses evidence available at its turn');
assert.equal((await balance(s)).reserved, checkFirst ? '0.00' : '10.00');
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
if (!checkFirst) await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: b.items[0].id } }), 1);
}
} finally { await locks.end(); } } finally { await locks.end(); }
} }
checks.push('rp_authority_stop_serializes_with_approval_export_fact_and_release_both_orders'); checks.push('rp_authority_stop_serializes_with_approval_export_fact_and_release_both_orders');
checks.push('receipt_local_inspection_and_final_settlement_serialize_in_both_orders');
} }
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) { if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) {
const s = await setup('100.00', null); const s = await setup('100.00', null);
+46
查看文件
@@ -0,0 +1,46 @@
const { createRequire } = require('node:module');
const path = require('node:path');
const req = createRequire(path.resolve(__dirname, '../../server/package.json'));
function syntheticReceiptText({ status = 'success', final = true, direction = 'debit', amount = '10.00',
currency = '人民币', payerAccount = '000011112222', payeeAccount = '000033334444',
reference = 'SYNTHETIC-REFERENCE', bookedOn = '2026-10-01' } = {}) {
const words = { success: '交易成功', failed: '交易失败', pending: '处理中', booked: '' };
return [final ? '中国建设银行单位客户专用回单' : '中国建设银行网上银行电子回执',
'合成公司测试回单 SYNTHETIC', `记账日期 ${bookedOn}`, direction === 'credit' ? '贷' : '借',
`金额 ${amount}`, `币别 ${currency}`, `付款人账号 ${payerAccount}`, `收款人账号 ${payeeAccount}`, `凭证号码 ${reference}`,
...(words[status] ? [`单据状态 ${words[status]}`] : []),
...(!final ? ['本回执不作为收、付款方交易的最终依据,正式回单请在交易成功后另打印。'] : [])].join('\n');
}
async function syntheticBankReceiptPdf({ pages = 1, tag = 'SYNTHETIC', ...options } = {}) {
const { PDFDocument, PDFHexString, beginText, endText, setFontAndSize, moveText, showText } = req('pdf-lib');
const doc = await PDFDocument.create(); doc.setTitle(tag);
const text = syntheticReceiptText(options);
const hex = (s) => Buffer.from(s, 'utf16le').swap16().toString('hex').toUpperCase();
const codes = [...new Set([...text.replace(/\n/g, '')])].map((c) => `<${hex(c)}> <${hex(c)}>`);
const cmap = `/CIDInit /ProcSet findresource begin 12 dict begin begincmap
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
/CMapName /Synthetic-UCS def /CMapType 2 def
1 begincodespacerange <0000> <FFFF> endcodespacerange
${codes.length} beginbfchar\n${codes.join('\n')}\nendbfchar
endcmap CMapName currentdict /CMap defineresource pop end end`;
const toUnicode = doc.context.register(doc.context.flateStream(cmap));
const cid = doc.context.register(doc.context.obj({ Type: 'Font', Subtype: 'CIDFontType0', BaseFont: 'STSong-Light',
CIDSystemInfo: { Registry: PDFHexString.fromText('Adobe'), Ordering: PDFHexString.fromText('GB1'), Supplement: 4 }, DW: 1000 }));
const font = doc.context.register(doc.context.obj({ Type: 'Font', Subtype: 'Type0', BaseFont: 'STSong-Light',
Encoding: 'Identity-H', DescendantFonts: [cid], ToUnicode: toUnicode }));
for (let n = 0; n < pages; n++) {
const page = doc.addPage([595, 842]);
const name = page.node.newFontDictionary('SYN', font);
text.split('\n').forEach((line, index) => page.pushOperators(beginText(), setFontAndSize(name, 10),
moveText(30, 790 - index * 22), showText(PDFHexString.of(hex(line))), endText()));
}
return Buffer.from(await doc.save());
}
function syntheticDocumentEvidence(options) {
return req('./src/services/paymentReceiptDocument').classifyReceiptText(syntheticReceiptText(options));
}
module.exports = { syntheticReceiptText, syntheticBankReceiptPdf, syntheticDocumentEvidence };
+2 -1
查看文件
@@ -5,6 +5,7 @@ const { readFileSync, readdirSync } = require("node:fs");
const { createRequire } = require("node:module"); const { createRequire } = require("node:module");
const path = require("node:path"); const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises"); const { setTimeout: delay } = require("node:timers/promises");
const { syntheticDocumentEvidence } = require("./synthetic-bank-receipt.cjs");
const root = path.resolve(__dirname, "../.."); const root = path.resolve(__dirname, "../..");
const serverRequire = createRequire(path.join(root, "server/package.json")); const serverRequire = createRequire(path.join(root, "server/package.json"));
@@ -63,7 +64,7 @@ async function main() {
const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({ const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222", 小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: reference, 收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: reference,
}).map(([Name, Value]) => ({ Name, Value }))) }; }).map(([Name, Value]) => ({ Name, Value }))), document: syntheticDocumentEvidence() };
const batch = await prisma.paymentBatch.create({ data: { const batch = await prisma.paymentBatch.create({ data: {
batch_no: `SYN-${randomUUID()}`, entity_type: "reimbursement", name: "synthetic", status: "reviewed", batch_no: `SYN-${randomUUID()}`, entity_type: "reimbursement", name: "synthetic", status: "reviewed",
created_by: employee.id, created_by: employee.id,
+263
查看文件
@@ -35,6 +35,7 @@
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"openid-client": "^6.8.2", "openid-client": "^6.8.2",
"pdf-lib": "1.17.1", "pdf-lib": "1.17.1",
"pdfjs-dist": "6.4.299",
"pg": "^8.18.0", "pg": "^8.18.0",
"pg-boss": "^10.3.0", "pg-boss": "^10.3.0",
"prisma": "^7.8.0", "prisma": "^7.8.0",
@@ -2010,6 +2011,256 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.10",
"@napi-rs/canvas-darwin-arm64": "1.0.10",
"@napi-rs/canvas-darwin-x64": "1.0.10",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz",
"integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz",
"integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz",
"integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz",
"integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz",
"integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz",
"integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz",
"integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz",
"integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz",
"integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz",
"integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@nodable/entities": { "node_modules/@nodable/entities": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
@@ -7593,6 +7844,18 @@
"tslib": "^1.11.1" "tslib": "^1.11.1"
} }
}, },
"node_modules/pdfjs-dist": {
"version": "6.4.299",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
"integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.10"
}
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
+1
查看文件
@@ -79,6 +79,7 @@
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"openid-client": "^6.8.2", "openid-client": "^6.8.2",
"pdf-lib": "1.17.1", "pdf-lib": "1.17.1",
"pdfjs-dist": "6.4.299",
"pg": "^8.18.0", "pg": "^8.18.0",
"pg-boss": "^10.3.0", "pg-boss": "^10.3.0",
"prisma": "^7.8.0", "prisma": "^7.8.0",
+2
查看文件
@@ -803,6 +803,8 @@ const ERROR_REASONS = [
{"code":"payment_batch.receipt_transaction_reused","domain":"payment_batch","audience":"user","defaultMessage":"回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。","recovery":"follow_guidance"}, {"code":"payment_batch.receipt_transaction_reused","domain":"payment_batch","audience":"user","defaultMessage":"回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_manual_state_changed","domain":"payment_batch","audience":"user","defaultMessage":"回单或核验版本已变化,请刷新后重新查看当前状态。","recovery":"follow_guidance"}, {"code":"payment_batch.receipt_manual_state_changed","domain":"payment_batch","audience":"user","defaultMessage":"回单或核验版本已变化,请刷新后重新查看当前状态。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_manual_verification_required","domain":"payment_batch","audience":"user","defaultMessage":"回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。","recovery":"follow_guidance"}, {"code":"payment_batch.receipt_manual_verification_required","domain":"payment_batch","audience":"user","defaultMessage":"回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_document_check_required","domain":"payment_batch","audience":"user","defaultMessage":"这份回单尚未检查凭证性质,请在核验详情中更新凭证核验后重新核对。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_final_evidence_required","domain":"payment_batch","audience":"user","defaultMessage":"当前材料不能证明本笔付款已完成。请核对正式回单的实际付款或记账依据;非最终回执、入账方向不符或证据不明时不能登记付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_key_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。","recovery":"follow_guidance"}, {"code":"payment_batch.receipt_key_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_key_rotation_required","domain":"payment_batch","audience":"user","defaultMessage":"回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。","recovery":"follow_guidance"}, {"code":"payment_batch.receipt_key_rotation_required","domain":"payment_batch","audience":"user","defaultMessage":"回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_snapshot_unsupported","domain":"payment_batch","audience":"user","defaultMessage":"付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。","recovery":"follow_guidance"}, {"code":"payment_batch.receipt_snapshot_unsupported","domain":"payment_batch","audience":"user","defaultMessage":"付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。","recovery":"follow_guidance"},
+28 -1
查看文件
@@ -13,7 +13,7 @@ const { uploadTerminalEvidence, voidTerminalEvidence, proposeTerminalFact, revie
const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { ERROR_REASONS } = require("../constants/errorReasons"); const { ERROR_REASONS } = require("../constants/errorReasons");
const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification"); const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification");
const { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail } = require("../services/paymentReceiptManualPersistence"); const { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail, refreshReceiptDocument } = require("../services/paymentReceiptManualPersistence");
const { resolvePaymentBatchCreationReadiness, paymentReadinessError } = require("../services/paymentBatchReadiness"); const { resolvePaymentBatchCreationReadiness, paymentReadinessError } = require("../services/paymentBatchReadiness");
const crypto = require("node:crypto"); const crypto = require("node:crypto");
const path = require("node:path"); const path = require("node:path");
@@ -113,6 +113,11 @@ const RECEIPT_MANUAL_SCHEMA = z.object({
fields: z.array(z.object({ fields: z.array(z.object({
Name: z.string().max(64), Value: z.string().max(256), Name: z.string().max(64), Value: z.string().max(256),
}).strict()).min(1).max(200).optional(), }).strict()).min(1).max(200).optional(),
paymentProof: z.object({
basis: z.enum(["explicit_success", "booked_debit"]), originalChecked: z.literal(true),
note: z.string().trim().min(5).max(500),
bookedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString, { message: "invalid_calendar_date" }).optional(),
}).strict().optional(),
}).strict()).min(1).max(20), }).strict()).min(1).max(20),
}).strict(); }).strict();
@@ -124,6 +129,12 @@ const RECEIPT_MANUAL_REVIEW_SCHEMA = z.object({
function mapManualReceiptError(error) { function mapManualReceiptError(error) {
if (!(error instanceof ReceiptVerificationError)) throw error; if (!(error instanceof ReceiptVerificationError)) throw error;
if (error.code === "receipt_document_check_required") {
throw buildBusinessValidationError("payment_batch.receipt_document_check_required", {}, 409);
}
if (["receipt_document_not_final", "receipt_document_proof_required"].includes(error.code)) {
throw buildBusinessValidationError("payment_batch.receipt_final_evidence_required", {}, 409);
}
if (error.code === "receipt_key_unavailable") { if (error.code === "receipt_key_unavailable") {
throw buildBusinessValidationError("payment_batch.receipt_key_unavailable", {}, 409); throw buildBusinessValidationError("payment_batch.receipt_key_unavailable", {}, 409);
} }
@@ -2611,6 +2622,22 @@ async function paymentBatchesRoutes(fastify) {
} }
}); });
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/document-check", { preHandler: authenticateBatch }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const body = parseBody(z.object({ expectedVersion: z.number().int().min(0).max(2147483646) }).strict(), request.body);
try {
const item = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
return refreshReceiptDocument(tx, fastify.config, request, { ...body, batchId, attachmentId });
}, { timeout: 10000 });
return sendData(reply, request, { item });
} catch (error) { mapManualReceiptError(error); }
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: authenticateBatch }, async (request, reply) => { fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: authenticateBatch }, async (request, reply) => {
ensureBatchManagePermission(fastify, request); ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify); await ensureFeatureEnabled(fastify);
@@ -0,0 +1,131 @@
const { Worker } = require("node:worker_threads");
const path = require("node:path");
const { isValidDateOnlyString, currentDateString } = require("../utils/date");
const DOCUMENT_VERSION = 1;
const NON_FINAL = /不(?:能)?(?:作为|用作).{0,32}(?:最终|记账|入账).{0,20}(?:依据|凭证)|(?:正式|最终)回单.{0,16}(?:另行|另|重新)(?:打印|下载)|仅(?:表示|代表).{0,12}(?:受理|提交)|仅供参考/u;
const SUCCESS = /(?:单据状态|交易状态)[::]?(?:交易|转账|付款|支付)成功/u;
const NEGATIVE = /(?:单据状态|交易状态)[::]?(?:交易失败|转账失败|付款失败|支付失败|已撤销|已冲正|已退汇|未成功)/u;
const PENDING = /(?:单据状态|交易状态)[::]?(?:已受理|受理成功|处理中|待处理)/u;
function unknownDocument(source = "unreadable") {
return { version: DOCUMENT_VERSION, kind: "unknown", source, direction: null,
explicitSuccess: false, issues: [] };
}
// This describes the document, not bank authenticity or a payment authorization.
// A recognizable title alone never establishes a completed payment.
function classifyReceiptText(text) {
if (typeof text !== "string" || text.length > 100000) return unknownDocument();
const compact = text.normalize("NFKC").replace(/\s/g, "");
const result = unknownDocument("pdf_text");
if (NON_FINAL.test(compact)) result.kind = "non_final";
const issues = [];
if (NEGATIVE.test(compact)) issues.push("document_transaction_failed");
if (PENDING.test(compact)) issues.push("document_transaction_pending");
const title = "中国建设银行单位客户专用回单";
const count = compact.split(title).length - 1;
if (count > 1) issues.push("document_multiple_receipts");
const readable = compact.length >= 50 && !/[\uFFFD\uE000-\uF8FF]/u.test(compact);
if (result.kind !== "non_final" && readable && count === 1 && compact.includes("金额") &&
/币[别种]/u.test(compact) && /(?:流水号|凭证号码?)/u.test(compact) && /记[账帐]/u.test(compact)) {
result.kind = "bank_receipt";
}
// Only interpret the standalone debit/credit cell of this known receipt type.
const directions = new Set(text.split(/\n/).map((s) => s.trim()).filter((s) => ["借", "贷", "借方", "贷方"].includes(s))
.map((s) => s.startsWith("借") ? "debit" : "credit"));
if (result.kind === "bank_receipt" && directions.size === 1) result.direction = [...directions][0];
if (directions.size > 1) issues.push("document_direction_ambiguous");
result.explicitSuccess = result.kind === "bank_receipt" && SUCCESS.test(compact);
result.issues = issues;
return result;
}
function withProviderRestrictions(document, fields) {
const result = safeDocument(document) ?? unknownDocument();
if (!Array.isArray(fields)) return result;
const text = fields.slice(0, 200).map((field) =>
`${typeof field?.Name === "string" ? field.Name : ""}:${typeof field?.Value === "string" ? field.Value : ""}`)
.join(" ").normalize("NFKC").replace(/\s/g, "").slice(0, 100000);
if (NON_FINAL.test(text)) result.kind = "non_final";
return result;
}
function safeDocument(document) {
if (document?.version !== DOCUMENT_VERSION || !["bank_receipt", "non_final", "unknown"].includes(document.kind) ||
!["pdf_text", "image", "unreadable", "manual"].includes(document.source) ||
![null, "debit", "credit"].includes(document.direction) ||
typeof document.explicitSuccess !== "boolean" || !Array.isArray(document.issues) ||
document.issues.some((issue) => !["document_transaction_failed", "document_transaction_pending",
"document_multiple_receipts", "document_direction_ambiguous"].includes(issue))) return null;
const result = { version: DOCUMENT_VERSION, kind: document.kind, source: document.source,
direction: document.direction, explicitSuccess: document.explicitSuccess, issues: [...new Set(document.issues)] };
if (document.source === "manual") {
const proof = safeManualProof(document.proof);
if (!proof) return null;
result.proof = proof;
}
return result;
}
function documentForPage(recognition, pageNumber) {
const page = recognition?.pages?.find((entry) => entry.pageNumber === pageNumber);
// An explicitly invalid or obsolete page result must not borrow a different
// successful inspection. The fallback is only for pages OCR never processed.
if (page && Object.hasOwn(page, "document")) return safeDocument(page.document);
return safeDocument(recognition?.documentPages?.find((entry) => entry.pageNumber === pageNumber)?.document);
}
function documentBlocksPayment(document) {
return document?.kind === "non_final" || document?.direction === "credit" || Boolean(document?.issues?.length);
}
function paymentDocumentReady(page, { allowManual = false } = {}) {
const document = safeDocument(page?.document);
if (!document || document.kind !== "bank_receipt" || documentBlocksPayment(document)) return false;
if (document.source === "manual") return allowManual &&
(document.proof.basis === "booked_debit" || (document.proof.basis === "explicit_success" && page.transactionStatus === "success"));
return document.source === "pdf_text" && document.explicitSuccess && page.transactionStatus === "success";
}
function safeManualProof(proof) {
if (!proof || proof.originalChecked !== true || !["explicit_success", "booked_debit"].includes(proof.basis) ||
typeof proof.note !== "string" || proof.note.trim().length < 5 || proof.note.length > 500) return null;
if (proof.basis === "booked_debit" && (typeof proof.bookedOn !== "string" || !isValidDateOnlyString(proof.bookedOn) ||
proof.bookedOn > currentDateString("Asia/Shanghai"))) return null;
if (proof.basis === "explicit_success" && proof.bookedOn !== undefined && proof.bookedOn !== null) return null;
return { basis: proof.basis, originalChecked: true, note: proof.note.trim(),
...(proof.basis === "booked_debit" ? { bookedOn: proof.bookedOn } : {}) };
}
async function inspectReceiptDocument({ buffer, mimeType, pagesExpected }) {
if (!Number.isInteger(pagesExpected) || pagesExpected < 1 || pagesExpected > 20) return [];
const unknown = () => Array.from({ length: pagesExpected }, (_, index) => ({
pageNumber: index + 1, document: unknownDocument(mimeType === "application/pdf" ? "unreadable" : "image"),
}));
if (mimeType !== "application/pdf" || !Buffer.isBuffer(buffer) || !buffer.length || buffer.length > 5 * 1024 * 1024) return unknown();
// Bound hostile PDF parsing independently of the request/event loop. No URLs,
// external fonts, OCR calls, raw text logging or persisted extraction payloads.
return new Promise((resolve) => {
const worker = new Worker(path.join(__dirname, "paymentReceiptDocumentWorker.js"), {
workerData: { bytes: new Uint8Array(buffer), pagesExpected },
resourceLimits: { maxOldGenerationSizeMb: 128 }, stdout: true, stderr: true,
});
worker.stdout.resume(); worker.stderr.resume();
let settled = false;
const finish = (pages) => {
if (settled) return;
settled = true; clearTimeout(timer); void worker.terminate(); resolve(pages);
};
const timer = setTimeout(() => finish(unknown()), 5000);
worker.once("message", (message) => {
if (!Array.isArray(message?.texts) || message.texts.length !== pagesExpected) return finish(unknown());
finish(message.texts.map((text, index) => ({ pageNumber: index + 1, document: classifyReceiptText(text) })));
});
worker.once("error", () => finish(unknown()));
worker.once("exit", () => finish(unknown()));
});
}
module.exports = { DOCUMENT_VERSION, unknownDocument, classifyReceiptText, withProviderRestrictions,
safeDocument, safeManualProof, documentForPage, documentBlocksPayment, paymentDocumentReady, inspectReceiptDocument };
@@ -0,0 +1,34 @@
const { parentPort, workerData } = require("node:worker_threads");
(async () => {
let task;
try {
const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
task = getDocument({ data: workerData.bytes, verbosity: 0, stopAtErrors: true,
isEvalSupported: false, useSystemFonts: false, disableFontFace: true, useWorkerFetch: false, useWasm: false });
const pdf = await task.promise;
if (pdf.numPages !== workerData.pagesExpected) throw new Error("page_count");
const texts = [];
for (let number = 1; number <= pdf.numPages; number++) {
const page = await pdf.getPage(number);
const content = await page.getTextContent();
if (content.items.length > 20000) throw new Error("text_limit");
let y = null;
const pieces = [];
for (const item of content.items) {
if (typeof item.str !== "string") continue;
const nextY = Math.round(item.transform?.[5] ?? 0);
if (y !== null && Math.abs(nextY - y) > 2) pieces.push("\n");
pieces.push(item.str, item.hasEOL ? "\n" : " "); y = nextY;
}
const text = pieces.join("");
if (text.length > 100000) throw new Error("text_limit");
texts.push(text); page.cleanup();
}
parentPort.postMessage({ texts });
} catch {
parentPort.postMessage({ failed: true });
} finally {
if (task) await task.destroy().catch(() => {});
}
})().catch(() => parentPort.postMessage({ failed: true }));
@@ -1,4 +1,4 @@
const { ReceiptVerificationError, batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification"); const { ReceiptVerificationError, batchCandidates, transactionFingerprint, storeReceiptRecognition } = require("./paymentReceiptVerification");
const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("./paymentReceiptManualReview"); const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("./paymentReceiptManualReview");
const { encryptStringOrThrow, decryptStringOrThrow } = require("./crypto"); const { encryptStringOrThrow, decryptStringOrThrow } = require("./crypto");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
@@ -7,6 +7,10 @@ const { normalizeBankSlip } = require("./paymentReceiptMatching");
const { maskBankAccount } = require("./crypto"); const { maskBankAccount } = require("./crypto");
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions"); const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
const loanApprovalPolicy = require("./loanApprovalPolicy"); const loanApprovalPolicy = require("./loanApprovalPolicy");
const { safeManualProof, safeDocument, documentForPage, inspectReceiptDocument } = require("./paymentReceiptDocument");
const { createHash } = require("node:crypto");
const { readFileBuffer } = require("./storage");
const { PDFDocument } = require("pdf-lib");
function requireFact(condition, code) { function requireFact(condition, code) {
if (!condition) throw new ReceiptVerificationError(code); if (!condition) throw new ReceiptVerificationError(code);
@@ -128,8 +132,10 @@ function safePages(pages) {
requireFact(Array.isArray(page?.fields) && page.fields.length > 0 && page.fields.length <= 200 && requireFact(Array.isArray(page?.fields) && page.fields.length > 0 && page.fields.length <= 200 &&
page.fields.every((field) => typeof field?.Name === "string" && field.Name.length <= 64 && page.fields.every((field) => typeof field?.Name === "string" && field.Name.length <= 64 &&
typeof field.Value === "string" && field.Value.length <= 256), "receipt_manual_fields_invalid"); typeof field.Value === "string" && field.Value.length <= 256), "receipt_manual_fields_invalid");
const proof = page.paymentProof === undefined ? undefined : safeManualProof(page.paymentProof);
requireFact(proof !== null, "receipt_document_proof_required");
return { pageNumber: page.pageNumber, itemId: page.itemId, return { pageNumber: page.pageNumber, itemId: page.itemId,
fields: page.fields.map(({ Name, Value }) => ({ Name, Value })) }; fields: page.fields.map(({ Name, Value }) => ({ Name, Value })), ...(proof ? { paymentProof: proof } : {}) };
}); });
} }
@@ -144,7 +150,7 @@ function resolvePageFields(pages, original) {
const values = { const values = {
小写金额: amount, 币别: source?.currency ?? "", 付款人账号: source?.payerAccount ?? "", 小写金额: amount, 币别: source?.currency ?? "", 付款人账号: source?.payerAccount ?? "",
收款人账号: source?.payeeAccount ?? "", 凭证号: source?.reference ?? "", 收款人账号: source?.payeeAccount ?? "", 凭证号: source?.reference ?? "",
单据状态: source?.transactionStatus === "success" ? "交易成功" : "", 单据状态: source?.transactionStatus === "success" ? source.transactionStatusRaw ?? "" : "",
}; };
if (page.fields !== undefined) { if (page.fields !== undefined) {
requireFact(Array.isArray(page.fields) && page.fields.length <= 7 && requireFact(Array.isArray(page.fields) && page.fields.length <= 7 &&
@@ -154,7 +160,8 @@ function resolvePageFields(pages, original) {
for (const field of page.fields) values[field.Name] = field.Value; for (const field of page.fields) values[field.Name] = field.Value;
} }
return { pageNumber: page.pageNumber, itemId: page.itemId, return { pageNumber: page.pageNumber, itemId: page.itemId,
fields: Object.entries(values).map(([Name, Value]) => ({ Name, Value })) }; fields: Object.entries(values).map(([Name, Value]) => ({ Name, Value })),
...(page.paymentProof !== undefined ? { paymentProof: page.paymentProof } : {}) };
}); });
} }
@@ -164,7 +171,13 @@ function pageSummary(page) {
return { pageNumber: page.pageNumber, amount, currency: page.currency ?? null, return { pageNumber: page.pageNumber, amount, currency: page.currency ?? null,
payerAccountMasked: page.payerAccount ? maskBankAccount(page.payerAccount) : null, payerAccountMasked: page.payerAccount ? maskBankAccount(page.payerAccount) : null,
payeeAccountMasked: page.payeeAccount ? maskBankAccount(page.payeeAccount) : null, payeeAccountMasked: page.payeeAccount ? maskBankAccount(page.payeeAccount) : null,
transactionStatus: page.transactionStatus ?? null, reference: page.reference ?? null }; transactionStatus: page.transactionStatus ?? null, reference: page.reference ?? null,
document: safeDocument(page.document) };
}
function inspectionComplete(recognition) {
return Number.isInteger(recognition?.pagesExpected) && recognition.pagesExpected > 0 && recognition.pagesExpected <= 20 &&
Array.from({ length: recognition.pagesExpected }, (_, i) => documentForPage(recognition, i + 1)).every(Boolean);
} }
async function readManualReceiptDetail(tx, config, { batchId, attachmentId, reviewer, hasReviewPermission }) { async function readManualReceiptDetail(tx, config, { batchId, attachmentId, reviewer, hasReviewPermission }) {
@@ -179,11 +192,13 @@ async function readManualReceiptDetail(tx, config, { batchId, attachmentId, revi
where: { attachment_id: attachmentId }, orderBy: [{ created_at: "desc" }, { id: "desc" }], where: { attachment_id: attachmentId }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
}); });
if (!original) return { version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state, if (!original) return { version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
pagesExpected: 0, pages: [], proposal: null }; documentInspectionRequired: true, pagesExpected: 0, pages: [], proposal: null };
requireFact(original.attachment_id === attachmentId && original.source_sha256 === attachment.file_sha256 && requireFact(original.attachment_id === attachmentId && original.source_sha256 === attachment.file_sha256 &&
original.source_version + (proposal ? 1 : 0) === attachment.receipt_ocr_version && original.source_version + (proposal ? 1 : 0) === attachment.receipt_ocr_version &&
(!proposal || proposal.source_sha256 === attachment.file_sha256), "receipt_source_changed"); (!proposal || proposal.source_sha256 === attachment.file_sha256), "receipt_source_changed");
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted)); const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
requireFact(Number.isInteger(recognition.pagesExpected) && recognition.pagesExpected >= 0 &&
recognition.pagesExpected <= 20 && Array.isArray(recognition.pages), "receipt_pages_unresolved");
const payload = proposal ? JSON.parse(decryptStringOrThrow(config, proposal.payload_encrypted)) : null; const payload = proposal ? JSON.parse(decryptStringOrThrow(config, proposal.payload_encrypted)) : null;
const batch = proposal ? await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } }) : null; const batch = proposal ? await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } }) : null;
let canReview = false; let canReview = false;
@@ -204,17 +219,81 @@ async function readManualReceiptDetail(tx, config, { batchId, attachmentId, revi
} }
return { return {
canReview, canReview,
documentInspectionRequired: !inspectionComplete(recognition),
version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state, version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
pagesExpected: recognition.pagesExpected, pages: recognition.pages.map(pageSummary), pagesExpected: recognition.pagesExpected, pages: Array.from({ length: recognition.pagesExpected }, (_, index) => {
const pageNumber = index + 1;
return pageSummary({ ...recognition.pages.find((page) => page.pageNumber === pageNumber),
pageNumber, document: documentForPage(recognition, pageNumber) });
}),
proposal: proposal ? { id: proposal.id, status: proposal.status, proposedBy: proposal.proposed_by, proposal: proposal ? { id: proposal.id, status: proposal.status, proposedBy: proposal.proposed_by,
approvalMode: batch?.entity_type === "loan_repayment" ? (await loanApprovalPolicy.repaymentFor(tx, batch)).approval_mode : "independent", approvalMode: batch?.entity_type === "loan_repayment" ? (await loanApprovalPolicy.repaymentFor(tx, batch)).approval_mode : "independent",
reviewedBy: proposal.reviewed_by, reason: payload.reason, changedFields: payload.changedFields, reviewedBy: proposal.reviewed_by, reason: payload.reason, changedFields: payload.changedFields,
pages: payload.pages.map((page) => ({ pages: payload.pages.map((page) => {
...pageSummary({ pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) }), itemId: page.itemId, const proof = safeManualProof(page.paymentProof);
})) } : null, return { ...pageSummary({ pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields),
...(proof?.basis === "booked_debit" ? { transactionStatus: "success" } : {}),
document: proof ? { version: 1, kind: "bank_receipt", source: "manual", direction: "debit",
explicitSuccess: proof.basis === "explicit_success", issues: [], proof } : documentForPage(recognition, page.pageNumber),
}), itemId: page.itemId };
}) } : null,
}; };
} }
async function refreshReceiptDocument(tx, config, request, input) {
const { batch, attachment } = await lockContext(tx, input);
await loanApprovalPolicy.assertMaker(tx, batch, request.employee.id);
requireFact(attachment.receipt_ocr_version < 2147483647 &&
["legacy", "matched", "needs_review", "ocr_failed", "manual_rejected", "manual_approved", "association_ready", "pending_review"]
.includes(attachment.receipt_ocr_state), "receipt_manual_not_available");
requireFact(await tx.paymentReceiptClaim.count({ where: { verification: { attachment_id: attachment.id } } }) === 0,
"receipt_transaction_reused");
const proposal = await tx.paymentReceiptManualProposal.findUnique({
where: { attachment_id_source_version: { attachment_id: attachment.id, source_version: attachment.receipt_ocr_version } },
include: { original_verification: true },
});
const original = proposal?.original_verification ?? await tx.paymentReceiptVerification.findFirst({
where: { attachment_id: attachment.id }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
if (original) requireFact(original.source_sha256 === attachment.file_sha256 &&
original.source_version + (proposal ? 1 : 0) === attachment.receipt_ocr_version, "receipt_source_changed");
const recognition = original ? JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted)) :
{ status: "ocr_failed", pagesExpected: 0, pagesProcessed: 0, pages: [], reason: "receipt_not_recognized" };
requireFact(!inspectionComplete(recognition), "receipt_manual_not_available");
requireFact(attachment.file_size > 0 && attachment.file_size <= 5 * 1024 * 1024, "receipt_unavailable");
let buffer;
try { buffer = await readFileBuffer({ config, storagePath: attachment.storage_path }); }
catch { throw new ReceiptVerificationError("receipt_unavailable"); }
requireFact(buffer.length === attachment.file_size && createHash("sha256").update(buffer).digest("hex") === attachment.file_sha256,
"receipt_source_changed");
let pagesExpected;
try {
if (attachment.mime_type === "application/pdf") pagesExpected = (await PDFDocument.load(buffer)).getPageCount();
else {
requireFact(["image/png", "image/jpeg", "image/webp"].includes(attachment.mime_type), "receipt_unavailable");
const meta = await require("sharp")(buffer, { limitInputPixels: 40000000 }).metadata();
requireFact((meta.pages ?? 1) === 1, "receipt_unavailable"); pagesExpected = 1;
}
} catch { throw new ReceiptVerificationError("receipt_unavailable"); }
requireFact(pagesExpected > 0 && pagesExpected <= 20 && (!recognition.pagesExpected || recognition.pagesExpected === pagesExpected),
"receipt_pages_unresolved");
const documentPages = await inspectReceiptDocument({ buffer, mimeType: attachment.mime_type, pagesExpected });
requireFact(Array.isArray(recognition.pages), "receipt_pages_unresolved");
const current = { ...recognition, pagesExpected, documentPages,
pages: recognition.pages.map((page) => ({ ...page, document: documentPages[page.pageNumber - 1]?.document })) };
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: {
receipt_ocr_version: { increment: 1 }, receipt_ocr_state: "needs_review", receipt_ocr_started_at: null,
} });
const verification = await storeReceiptRecognition(tx, config, { batchId: batch.id, attachmentId: attachment.id,
sourceSha256: attachment.file_sha256, expectedVersion: attachment.receipt_ocr_version + 1, recognition: current });
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: { receipt_ocr_state: verification.status } });
await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id,
action: "PAYMENT_RECEIPT_DOCUMENT_CHECKED", targetType: "payment_batch", targetId: batch.id,
extra: { attachmentId: attachment.id, verificationId: verification.id, version: attachment.receipt_ocr_version + 1,
documentVersion: 1, cloudOcrCalled: false } }) });
return { id: attachment.id, status: verification.status, version: attachment.receipt_ocr_version + 1 };
}
function summarize(proposal) { function summarize(proposal) {
return { id: proposal.id, status: proposal.status, version: proposal.source_version }; return { id: proposal.id, status: proposal.status, version: proposal.source_version };
} }
@@ -310,4 +389,4 @@ async function reviewManualReceiptProposal(tx, config, request, input, hasReview
return summarize(reviewed); return summarize(reviewed);
} }
module.exports = { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail, loadApprovedManualReceiptEvidence }; module.exports = { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail, loadApprovedManualReceiptEvidence, refreshReceiptDocument };
@@ -1,5 +1,6 @@
const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching"); const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching");
const { ReceiptVerificationError, transactionFingerprint } = require("./paymentReceiptVerification"); const { ReceiptVerificationError, transactionFingerprint } = require("./paymentReceiptVerification");
const { documentForPage, documentBlocksPayment, paymentDocumentReady, safeManualProof } = require("./paymentReceiptDocument");
const FIELDS = ["amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference"]; const FIELDS = ["amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference"];
const BLOCKED_ISSUES = ["transaction_failed", "transaction_pending"]; const BLOCKED_ISSUES = ["transaction_failed", "transaction_pending"];
@@ -55,12 +56,34 @@ function buildManualReceiptProposal(config, { original, pages, candidates }) {
requireFact(!previous || (!["failed", "pending"].includes(previous.transactionStatus) && requireFact(!previous || (!["failed", "pending"].includes(previous.transactionStatus) &&
!(previous.issues ?? []).some((issue) => BLOCKED_ISSUES.includes(issue))), !(previous.issues ?? []).some((issue) => BLOCKED_ISSUES.includes(issue))),
"receipt_transaction_not_successful"); "receipt_transaction_not_successful");
const document = documentForPage(original, page.pageNumber);
requireFact(document, "receipt_document_check_required");
requireFact(!documentBlocksPayment(document), "receipt_document_not_final");
const normalized = { pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) }; const normalized = { pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) };
normalized.document = document;
if (page.paymentProof !== undefined) {
const proof = safeManualProof(page.paymentProof);
requireFact(proof, "receipt_document_proof_required");
if (proof.basis === "booked_debit") {
// Booked debit is a separate bank fact, not invented OCR status wording.
requireFact(normalized.transactionStatus === null && normalized.issues.includes("transactionStatus_unresolved") &&
!page.fields.some((field) => ["单据状态", "交易状态"].includes(field.Name?.trim()) && field.Value?.trim()),
"receipt_document_proof_required");
normalized.transactionStatus = "success";
normalized.transactionStatusRaw = null;
normalized.issues = normalized.issues.filter((issue) => issue !== "transactionStatus_unresolved");
} else requireFact(normalized.transactionStatus === "success", "receipt_document_proof_required");
normalized.document = { version: document.version, kind: "bank_receipt", source: "manual", direction: "debit",
explicitSuccess: proof.basis === "explicit_success", issues: [], proof };
requiresIndependentReview = true;
}
requireFact(paymentDocumentReady(normalized, { allowManual: true }), "receipt_document_proof_required");
const candidate = candidates.find((entry) => entry.id === page.itemId); const candidate = candidates.find((entry) => entry.id === page.itemId);
requireFact(candidate, "receipt_item_unavailable"); requireFact(candidate, "receipt_item_unavailable");
const match = matchBankSlip(normalized, [candidate]); const match = matchBankSlip(normalized, [candidate]);
requireFact(match.status === "matched", "receipt_manual_match_required"); requireFact(match.status === "matched", "receipt_manual_match_required");
const changed = FIELDS.filter((field) => previous?.[field] !== normalized[field]); const changed = FIELDS.filter((field) => previous?.[field] !== normalized[field]);
if (page.paymentProof !== undefined) changed.push("paymentProof");
if (changed.length || previous?.issues?.length) requiresIndependentReview = true; if (changed.length || previous?.issues?.length) requiresIndependentReview = true;
const { fingerprint } = transactionFingerprint(config, normalized); const { fingerprint } = transactionFingerprint(config, normalized);
requireFact(!fingerprints.has(fingerprint), "receipt_transaction_reused"); requireFact(!fingerprints.has(fingerprint), "receipt_transaction_reused");
+11 -1
查看文件
@@ -1,6 +1,7 @@
const { PDFDocument } = require("pdf-lib"); const { PDFDocument } = require("pdf-lib");
const sharp = require("sharp"); const sharp = require("sharp");
const { normalizeBankSlip } = require("./paymentReceiptMatching"); const { normalizeBankSlip } = require("./paymentReceiptMatching");
const { inspectReceiptDocument, withProviderRestrictions } = require("./paymentReceiptDocument");
function createReceiptOcrClient(config) { function createReceiptOcrClient(config) {
if (config?.ocrProvider && config.ocrProvider !== "tencent") return null; if (config?.ocrProvider && config.ocrProvider !== "tencent") return null;
@@ -44,6 +45,7 @@ async function recognizePaymentReceipt({ buffer, mimeType, config, createClient
} }
const encoded = payload.toString("base64"); const encoded = payload.toString("base64");
if (encoded.length > 7 * 1024 * 1024) return { ...result, reason: "receipt_encoded_size_limit" }; if (encoded.length > 7 * 1024 * 1024) return { ...result, reason: "receipt_encoded_size_limit" };
result.documentPages = await inspectReceiptDocument({ buffer, mimeType, pagesExpected: result.pagesExpected });
let client; let client;
try { try {
client = createClient(config); client = createClient(config);
@@ -54,7 +56,15 @@ async function recognizePaymentReceipt({ buffer, mimeType, config, createClient
const response = await client.BankSlipOCR({ const response = await client.BankSlipOCR({
ImageBase64: encoded, IsPdf: isPdf, ...(isPdf ? { PdfPageNumber: pageNumber } : {}), ImageBase64: encoded, IsPdf: isPdf, ...(isPdf ? { PdfPageNumber: pageNumber } : {}),
}); });
result.pages.push({ pageNumber, ...normalizeBankSlip(response?.BankSlipInfos) }); const document = withProviderRestrictions(result.documentPages[pageNumber - 1]?.document, response?.BankSlipInfos);
result.documentPages[pageNumber - 1] = { pageNumber, document };
const normalized = normalizeBankSlip(response?.BankSlipInfos);
if ((document.explicitSuccess && ["failed", "pending"].includes(normalized.transactionStatus)) ||
(document.issues.includes("document_transaction_failed") && normalized.transactionStatus !== "failed") ||
(document.issues.includes("document_transaction_pending") && normalized.transactionStatus !== "pending")) {
normalized.issues = [...new Set([...normalized.issues, "transactionStatus_ambiguous"])];
}
result.pages.push({ pageNumber, ...normalized, document });
result.pagesProcessed += 1; result.pagesProcessed += 1;
} }
} catch { } catch {
@@ -1,6 +1,7 @@
const { createHmac, createHash } = require("node:crypto"); const { createHmac, createHash } = require("node:crypto");
const { encryptStringOrThrow, decryptStringOrThrow, resolveFieldEncryptionKey } = require("./crypto"); const { encryptStringOrThrow, decryptStringOrThrow, resolveFieldEncryptionKey } = require("./crypto");
const { matchBankSlip } = require("./paymentReceiptMatching"); const { matchBankSlip } = require("./paymentReceiptMatching");
const { documentForPage, safeDocument, paymentDocumentReady } = require("./paymentReceiptDocument");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { resolveItemPaymentChannel } = require("./paymentProcessingProgress"); const { resolveItemPaymentChannel } = require("./paymentProcessingProgress");
@@ -53,7 +54,8 @@ function buildVerificationData(config, batch, attachment, recognition) {
requireFact(attachment.payment_batch_id === batch.id && !attachment.archived_at && requireFact(attachment.payment_batch_id === batch.id && !attachment.archived_at &&
attachment.governance_status === "approved", "receipt_unavailable"); attachment.governance_status === "approved", "receipt_unavailable");
const candidates = batchCandidates(config, batch); const candidates = batchCandidates(config, batch);
const pages = recognition.pages ?? []; const pages = (recognition.pages ?? []).map((page) => ({ ...page,
document: documentForPage(recognition, page.pageNumber) }));
const complete = recognition.status === "recognized" && recognition.pagesExpected > 0 && const complete = recognition.status === "recognized" && recognition.pagesExpected > 0 &&
recognition.pagesExpected <= 20 && recognition.pagesProcessed === recognition.pagesExpected && recognition.pagesExpected <= 20 && recognition.pagesProcessed === recognition.pagesExpected &&
pages.length === recognition.pagesExpected && pages.length === recognition.pagesExpected &&
@@ -61,6 +63,7 @@ function buildVerificationData(config, batch, attachment, recognition) {
const matches = []; const matches = [];
if (complete) { if (complete) {
for (const page of pages) { for (const page of pages) {
if (!paymentDocumentReady(page)) break;
const match = matchBankSlip(page, candidates); const match = matchBankSlip(page, candidates);
if (match.status !== "matched") break; if (match.status !== "matched") break;
matches.push({ page_number: page.pageNumber, item_id: match.itemId, ...transactionFingerprint(config, page) }); matches.push({ page_number: page.pageNumber, item_id: match.itemId, ...transactionFingerprint(config, page) });
@@ -77,8 +80,10 @@ function buildVerificationData(config, batch, attachment, recognition) {
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({ normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
status: recognition.status, pagesExpected: recognition.pagesExpected, status: recognition.status, pagesExpected: recognition.pagesExpected,
pagesProcessed: recognition.pagesProcessed, reason: recognition.reason, pagesProcessed: recognition.pagesProcessed, reason: recognition.reason,
...(Array.isArray(recognition.documentPages) ? { documentPages: recognition.documentPages.slice(0, 20).map((page) =>
({ pageNumber: page.pageNumber, document: safeDocument(page.document) })) } : {}),
pages: pages.map((page) => Object.fromEntries( pages: pages.map((page) => Object.fromEntries(
["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "transactionStatusRaw", "reference", "issues"] ["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "transactionStatusRaw", "reference", "issues", "document"]
.map((key) => [key, page[key]]), .map((key) => [key, page[key]]),
)), )),
})), })),
@@ -803,6 +803,8 @@
"payment_batch.source_status_invalid": "来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。", "payment_batch.source_status_invalid": "来源单据尚未满足付款条件或已经付款,请刷新并核实审批与付款状态。",
"payment_batch.already_paid_conflict": "来源单据已有不同的付款记录,请核实已有记录,勿重复付款。", "payment_batch.already_paid_conflict": "来源单据已有不同的付款记录,请核实已有记录,勿重复付款。",
"payment_batch.source_amount_changed": "来源单据金额已变化,请核对单据后重新安排付款。", "payment_batch.source_amount_changed": "来源单据金额已变化,请核对单据后重新安排付款。",
"payment_batch.receipt_document_check_required": "这份回单尚未检查凭证性质,请在核验详情中更新凭证核验后重新核对。",
"payment_batch.receipt_final_evidence_required": "当前材料不能证明本笔付款已完成。请核对正式回单的实际付款或记账依据;非最终回执、入账方向不符或证据不明时不能登记付款。",
"payment_batch.settled_transaction_required": "尚未找到已结算的交易,请确认结算记录后再登记付款。", "payment_batch.settled_transaction_required": "尚未找到已结算的交易,请确认结算记录后再登记付款。",
"payment_batch.receipt_security_failed": "付款回单未通过安全检查,请联系管理员核实并重新上传有效回单。", "payment_batch.receipt_security_failed": "付款回单未通过安全检查,请联系管理员核实并重新上传有效回单。",
"payment_batch.receipt_field_invalid": "请通过回单上传入口重新选择文件。", "payment_batch.receipt_field_invalid": "请通过回单上传入口重新选择文件。",
@@ -535,6 +535,7 @@ function buildPrisma(options = {}) {
小写金额: String(item.amount), 币别: item.currency_code, 付款人账号: payerAccount, 小写金额: String(item.amount), 币别: item.currency_code, 付款人账号: payerAccount,
收款人账号: payeeAccount, 单据状态: "交易成功", 凭证号: `SYN-${item.id}`, 收款人账号: payeeAccount, 单据状态: "交易成功", 凭证号: `SYN-${item.id}`,
}).map(([Name, Value]) => ({ Name, Value }))), }).map(([Name, Value]) => ({ Name, Value }))),
document: require("../../../../scripts/qa/synthetic-bank-receipt.cjs").syntheticDocumentEvidence(),
})); }));
const data = buildVerificationData(config, state.batch, attachment, { const data = buildVerificationData(config, state.batch, attachment, {
status: "recognized", pagesExpected: pages.length, pagesProcessed: pages.length, pages, status: "recognized", pagesExpected: pages.length, pagesProcessed: pages.length, pages,
@@ -573,7 +574,8 @@ describe("manual receipt verification routes", () => {
source_version: 0, source_sha256: attachment.file_sha256, source_version: 0, source_sha256: attachment.file_sha256,
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({ normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
status: "needs_review", pagesExpected: 1, pagesProcessed: 1, status: "needs_review", pagesExpected: 1, pagesProcessed: 1,
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), amount: null, issues: ["amount_unresolved"] }], pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), amount: null, issues: ["amount_unresolved"],
document: require("../../../../scripts/qa/synthetic-bank-receipt.cjs").syntheticDocumentEvidence() }],
})) }; })) };
let stored; let stored;
tx.paymentReceiptVerification = { findFirst: vi.fn(async () => original) }; tx.paymentReceiptVerification = { findFirst: vi.fn(async () => original) };
@@ -644,6 +646,26 @@ describe("manual receipt verification routes", () => {
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
}); });
it.each([0, -1, 21, 1000000000])("bounds historical page metadata while allowing local reinspection: %s", async (pagesExpected) => {
const { app, tx, state, tempDir } = await buildApp();
setupEvidence(tx, state);
const original = await tx.paymentReceiptVerification.findFirst();
original.normalized_encrypted = encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, JSON.stringify({
status: "ocr_failed", pagesExpected, pagesProcessed: 0, pages: [],
}));
state.receiptAttachments[0].receipt_ocr_state = "ocr_failed";
try {
const response = await app.inject({ method: "GET", url, headers });
expect(response.statusCode).toBe(pagesExpected === 0 ? 200 : 409);
if (pagesExpected === 0) expect(response.json().data.item).toMatchObject({
documentInspectionRequired: true, pagesExpected: 0, pages: [], proposal: null,
});
expect(tx.paymentReceiptManualProposal.create).not.toHaveBeenCalled();
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each(["foreign", "voided", "stale"])("does not expose details for %s evidence", async (stateKind) => { it.each(["foreign", "voided", "stale"])("does not expose details for %s evidence", async (stateKind) => {
const { app, tx, state, tempDir } = await buildApp(); const { app, tx, state, tempDir } = await buildApp();
setupEvidence(tx, state); setupEvidence(tx, state);
@@ -0,0 +1,74 @@
const { classifyReceiptText, inspectReceiptDocument, safeManualProof, paymentDocumentReady,
withProviderRestrictions, documentForPage } = require("../../../src/services/paymentReceiptDocument");
const { recognizePaymentReceipt } = require("../../../src/services/paymentReceiptOcr");
const { buildVerificationData } = require("../../../src/services/paymentReceiptVerification");
const { encryptStringOrThrow } = require("../../../src/services/crypto");
const { syntheticReceiptText, syntheticBankReceiptPdf } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs");
describe("receipt documentary evidence", () => {
test.each([
"本回执不作为收、付款方交易的最终依据,正式回单请在交易成功后另打印。",
"此凭证仅表示银行受理。", "该回执不能作为入账凭证", "本单仅供参考",
])("a restriction overrides even a formal title and success field: %s", (notice) => {
expect(classifyReceiptText(`${syntheticReceiptText()}\n${notice}`).kind).toBe("non_final");
});
test("a title by itself is not sufficient evidence", () => {
expect(classifyReceiptText("中国建设银行单位客户专用回单").kind).toBe("unknown");
});
test("multiple transactions on one page are not a single receipt", () => {
expect(classifyReceiptText(syntheticReceiptText() + syntheticReceiptText()).issues).toContain("document_multiple_receipts");
});
test("provider restriction fields cannot disappear during normalization", () => {
const document = withProviderRestrictions(classifyReceiptText(syntheticReceiptText()), [
{ Name: "注意事项", Value: "本回执不作为收付款方交易的最终依据" },
]);
expect(document.kind).toBe("non_final");
});
test.each(["2026-02-30", "2026-13-01", "", "20261001", "2999-01-01"])("rejects invalid or future booking date %s", (bookedOn) => {
expect(safeManualProof({ basis: "booked_debit", originalChecked: true, note: "合成记账依据完整", bookedOn })).toBeNull();
});
test("obsolete page inspection cannot fall back to a different successful result", () => {
const document = classifyReceiptText(syntheticReceiptText());
const documentPages = [{ pageNumber: 1, document }];
expect(documentForPage({ pages: [], documentPages }, 1)).toEqual(document);
expect(documentForPage({ pages: [{ pageNumber: 1, document: { ...document, version: 0 } }], documentPages }, 1)).toBeNull();
expect(documentForPage({ pages: [{ pageNumber: 1, document: null }], documentPages }, 1)).toBeNull();
});
test("even an approved-looking manual document cannot enter automatic verification", () => {
const page = { transactionStatus: "success", document: { ...classifyReceiptText(syntheticReceiptText()), source: "manual",
proof: { basis: "explicit_success", originalChecked: true, note: "合成人工付款依据" } } };
expect(paymentDocumentReady(page)).toBe(false);
expect(paymentDocumentReady(page, { allowManual: true })).toBe(true);
});
test.each([{ final: false, kind: "non_final" }, { status: "booked", kind: "bank_receipt" },
{ direction: "credit", kind: "bank_receipt" }])("parses actual synthetic PDF text %j", async ({ kind, ...options }) => {
const pages = await inspectReceiptDocument({ buffer: await syntheticBankReceiptPdf(options), mimeType: "application/pdf", pagesExpected: 1 });
expect(pages[0].document.kind).toBe(kind);
expect(pages[0].document.source).toBe("pdf_text");
if (options.direction) expect(pages[0].document.direction).toBe(options.direction);
});
test("unreadable data remains unknown instead of assuming a missing disclaimer", async () => {
const pages = await inspectReceiptDocument({ buffer: Buffer.from("%PDF-invalid"), mimeType: "application/pdf", pagesExpected: 1 });
expect(pages[0].document.kind).toBe("unknown");
});
test("non-final PDF passes field recognition but cannot become matched payment evidence", async () => {
const fields = Object.entries({ 小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
收款人账号: "000033334444", 凭证号: "SYN-DOCUMENT", 单据状态: "交易成功" }).map(([Name, Value]) => ({ Name, Value }));
const recognition = await recognizePaymentReceipt({ buffer: await syntheticBankReceiptPdf({ final: false }), mimeType: "application/pdf",
createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: fields }) }) });
expect(recognition.status).toBe("recognized");
const config = { fieldEncryptionKey: Buffer.alloc(32, 6).toString("base64") };
const batch = { id: "batch", entity_type: "reimbursement",
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, "000011112222") },
items: [{ id: "item", status: "pending", amount: "10.00", currency_code: "CNY",
snapshot_json: { payee: { bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, "000033334444") } } }] };
const data = buildVerificationData(config, batch, { id: "attachment", payment_batch_id: "batch", governance_status: "approved",
file_sha256: "f".repeat(64), receipt_ocr_version: 1 }, recognition);
expect(data.status).toBe("needs_review"); expect(data.matches).toBeUndefined();
});
test("contradictory native success and OCR failure cannot establish strict bank failure", async () => {
const recognition = await recognizePaymentReceipt({ buffer: await syntheticBankReceiptPdf(), mimeType: "application/pdf",
createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: [{ Name: "交易状态", Value: "交易失败" }] }) }) });
expect(recognition.pages[0].issues).toContain("transactionStatus_ambiguous");
});
});
@@ -1,5 +1,6 @@
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching"); const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("../../../src/services/paymentReceiptManualReview"); const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("../../../src/services/paymentReceiptManualReview");
const { syntheticDocumentEvidence } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs");
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") }; const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
@@ -50,7 +51,8 @@ function fixture() {
}).map(([Name, Value]) => ({ Name, Value })); }).map(([Name, Value]) => ({ Name, Value }));
return { return {
original: { status: "recognized", pagesExpected: 1, pagesProcessed: 1, original: { status: "recognized", pagesExpected: 1, pagesProcessed: 1,
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields) }] }, documentPages: [{ pageNumber: 1, document: syntheticDocumentEvidence() }],
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), document: syntheticDocumentEvidence() }] },
pages: [{ pageNumber: 1, itemId: "item-2", fields }], pages: [{ pageNumber: 1, itemId: "item-2", fields }],
candidates: ["item-1", "item-2"].map((id) => ({ candidates: ["item-1", "item-2"].map((id) => ({
id, amount: "10.00", currency: "CNY", payerAccount: "000011112222", payeeAccount: "000033334444", id, amount: "10.00", currency: "CNY", payerAccount: "000011112222", payeeAccount: "000033334444",
@@ -59,6 +61,41 @@ function fixture() {
} }
describe("manual receipt proposal is evidence preparation, not authorization", () => { describe("manual receipt proposal is evidence preparation, not authorization", () => {
test("booked debit uses actual bank booking facts without inventing success wording", () => {
const input = fixture();
input.original.pages[0].document = syntheticDocumentEvidence({ status: "booked" });
input.original.pages[0].transactionStatus = null;
input.original.pages[0].transactionStatusRaw = null;
input.pages[0].fields.find((field) => field.Name === "单据状态").Value = "";
input.pages[0].paymentProof = { basis: "booked_debit", bookedOn: "2026-10-01", originalChecked: true,
note: "合成正式回单以公司付款账户记载借方支出" };
const result = buildManualReceiptProposal(config, input);
expect(result.requiresIndependentReview).toBe(true);
expect(result.recognition.pages[0]).toMatchObject({ transactionStatus: "success", transactionStatusRaw: null,
document: { source: "manual", proof: { basis: "booked_debit", bookedOn: "2026-10-01" } } });
expect(result.changedFields[0].fields).toContain("paymentProof");
});
test.each([{ kind: "non_final" }, { direction: "credit" }, { issues: ["document_transaction_pending"] },
{ issues: ["document_multiple_receipts"] }])("manual declarations cannot overwrite documentary restrictions %j", (change) => {
const input = fixture();
input.original.pages[0].document = { ...syntheticDocumentEvidence(), ...change };
input.pages[0].paymentProof = { basis: "explicit_success", originalChecked: true, note: "合成强行确认不能覆盖已知限制" };
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_document_not_final");
});
test("unknown original needs an explicit, reviewed final-document declaration", () => {
const input = fixture();
input.original.pages[0].document = { ...syntheticDocumentEvidence(), kind: "unknown", explicitSuccess: false };
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_document_proof_required");
input.pages[0].paymentProof = { basis: "explicit_success", originalChecked: true, note: "合成原件为正式付款回单,已检查全部页及说明" };
expect(buildManualReceiptProposal(config, input).requiresIndependentReview).toBe(true);
});
test("legacy records require local reinspection before a manual proposal", () => {
const input = fixture(); delete input.original.documentPages; delete input.original.pages[0].document;
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_document_check_required");
});
test("explicit same-amount association needs no field reentry review", () => { test("explicit same-amount association needs no field reentry review", () => {
const input = fixture(); const input = fixture();
const before = JSON.stringify(input); const before = JSON.stringify(input);
@@ -83,7 +120,8 @@ describe("manual receipt proposal is evidence preparation, not authorization", (
test("OCR failure requires independent review even with fully matching manual fields", () => { test("OCR failure requires independent review even with fully matching manual fields", () => {
const input = fixture(); const input = fixture();
input.original = { status: "ocr_failed", pagesExpected: 1, pagesProcessed: 0, pages: [] }; input.original = { status: "ocr_failed", pagesExpected: 1, pagesProcessed: 0, pages: [],
documentPages: [{ pageNumber: 1, document: syntheticDocumentEvidence() }] };
expect(buildManualReceiptProposal(config, input).requiresIndependentReview).toBe(true); expect(buildManualReceiptProposal(config, input).requiresIndependentReview).toBe(true);
}); });
@@ -107,7 +145,8 @@ describe("manual receipt proposal is evidence preparation, not authorization", (
const input = fixture(); const input = fixture();
input.pages[0].fields.find((field) => field.Name === name).Value = value; input.pages[0].fields.find((field) => field.Name === name).Value = value;
input.reason = "测试理由不能免除核验"; input.reason = "测试理由不能免除核验";
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_manual_match_required"); expect(() => buildManualReceiptProposal(config, input)).toThrow(name === "单据状态" ?
"receipt_document_proof_required" : "receipt_manual_match_required");
}, },
); );
@@ -132,6 +171,7 @@ describe("manual receipt proposal is evidence preparation, not authorization", (
test("duplicate transaction on two pages cannot pay two items", () => { test("duplicate transaction on two pages cannot pay two items", () => {
const input = fixture(); const input = fixture();
input.original.pagesExpected = 2; input.original.pagesExpected = 2;
input.original.documentPages.push({ pageNumber: 2, document: syntheticDocumentEvidence() });
input.pages.push({ ...input.pages[0], pageNumber: 2, itemId: "item-1" }); input.pages.push({ ...input.pages[0], pageNumber: 2, itemId: "item-1" });
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_reused"); expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_reused");
}); });
@@ -139,6 +179,7 @@ describe("manual receipt proposal is evidence preparation, not authorization", (
test("two transactions cannot be assigned to one reimbursement", () => { test("two transactions cannot be assigned to one reimbursement", () => {
const input = fixture(); const input = fixture();
input.original.pagesExpected = 2; input.original.pagesExpected = 2;
input.original.documentPages.push({ pageNumber: 2, document: syntheticDocumentEvidence() });
input.pages.push({ ...input.pages[0], pageNumber: 2, input.pages.push({ ...input.pages[0], pageNumber: 2,
fields: input.pages[0].fields.map((field) => field.Name === "凭证号" ? fields: input.pages[0].fields.map((field) => field.Name === "凭证号" ?
{ ...field, Value: "SYN-SECOND" } : field) }); { ...field, Value: "SYN-SECOND" } : field) });
@@ -1,6 +1,7 @@
const { encryptStringOrThrow, decryptStringOrThrow } = require("../../../src/services/crypto"); const { encryptStringOrThrow, decryptStringOrThrow } = require("../../../src/services/crypto");
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching"); const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
const { buildVerificationData, claimVerifiedReceipts, transactionFingerprint, canRetryReceiptRecognition } = require("../../../src/services/paymentReceiptVerification"); const { buildVerificationData, claimVerifiedReceipts, transactionFingerprint, canRetryReceiptRecognition } = require("../../../src/services/paymentReceiptVerification");
const { syntheticDocumentEvidence } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs");
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") }; const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
@@ -8,7 +9,7 @@ function fixture() {
const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({ const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222", 小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-REF", 收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-REF",
}).map(([Name, Value]) => ({ Name, Value }))) }; }).map(([Name, Value]) => ({ Name, Value }))), document: syntheticDocumentEvidence() };
const batch = { const batch = {
id: "batch", entity_type: "reimbursement", id: "batch", entity_type: "reimbursement",
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, page.payerAccount) }, payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, page.payerAccount) },
@@ -31,6 +32,23 @@ function fixture() {
} }
describe("receipt evidence and transaction claims", () => { describe("receipt evidence and transaction claims", () => {
test.each([null, { version: 0 }, { ...syntheticDocumentEvidence(), kind: "non_final" },
{ ...syntheticDocumentEvidence(), direction: "credit" }, { ...syntheticDocumentEvidence(), explicitSuccess: false }])(
"field matches alone cannot prove final payment (%j)", (document) => {
const { batch, attachment, recognition } = fixture();
recognition.pages[0].document = document;
const data = buildVerificationData(config, batch, attachment, recognition);
expect(data.status).toBe("needs_review");
expect(data.matches).toBeUndefined();
});
test("final claim rebuild rejects an old matched record with no document basis", async () => {
const { tx, batch, verification, recognition } = fixture();
delete recognition.pages[0].document;
verification.normalized_encrypted = encryptStringOrThrow(config, JSON.stringify(recognition));
await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_match_changed" });
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
});
test("bank transactions already used for supplements or returns cannot be claimed again", async () => { test("bank transactions already used for supplements or returns cannot be claimed again", async () => {
const { tx, batch } = fixture(); const { tx, batch } = fixture();
tx.loanBankEventClaim.count.mockResolvedValue(1); tx.loanBankEventClaim.count.mockResolvedValue(1);