fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -18,6 +18,107 @@ QA script patterns over new abstractions.
|
|||||||
|
|
||||||
## Common Checks
|
## Common Checks
|
||||||
|
|
||||||
|
### Receipt OCR Matching Service Boundary
|
||||||
|
|
||||||
|
- Failed evidence persistence is not OCR failure. In a separate bounded transaction,
|
||||||
|
lock the batch then conditionally update the attachment by version/SHA/processing
|
||||||
|
and active governance predicates. Persist only allowlisted system_failed,
|
||||||
|
key_failed or snapshot_failed states; never an exception message or OCR payload.
|
||||||
|
A failed fallback write returns failure_recording_failed, not a claimed durable
|
||||||
|
failure. Do not automatically retry cloud calls or overwrite a newer job/void.
|
||||||
|
- System failure states do not permit ordinary OCR retry or manual proposals.
|
||||||
|
Administrator recovery requires fixing the prerequisite and a separately
|
||||||
|
authorized version-bound requeue; no bulk reset or silent cloud retransmission.
|
||||||
|
|
||||||
|
- Bank reimbursement execute calls claimVerifiedReceipts inside the existing
|
||||||
|
payment transaction after batch/source locks. Receipt existence alone never
|
||||||
|
permits registration. Existing non-bank and travel-history paths are not
|
||||||
|
silently converted into the reimbursement BankSlipOCR contract.
|
||||||
|
- Claims reference the exact approved manual proposal. A unique transaction-alias
|
||||||
|
table reserves both original and corrected identities against one claim.
|
||||||
|
Original-reference reuse, concurrent manual/automatic claims and changed current
|
||||||
|
manual-reviewer roles/participants fail closed; aliases and payment roll back
|
||||||
|
together. Keep the migration's existing-claim alias backfill.
|
||||||
|
- GET receipt-readiness uses RepeatableRead READ ONLY with the explicit readOnly
|
||||||
|
service mode: no FOR UPDATE/SHARE, advisory locks or inserts. It is a UI hint;
|
||||||
|
execution always repeats the locked validation. Never pass readOnly from user
|
||||||
|
input into payment execution.
|
||||||
|
|
||||||
|
- Manual persistence uses a separate proposal tied to original verification, SHA
|
||||||
|
and incremented attachment version. Save and review audit share the transaction;
|
||||||
|
only id/status/version leave mutation endpoints. Association-ready means
|
||||||
|
unchanged item association, not independent approval; manual approval does
|
||||||
|
not itself record payment. Rejected proposals remain immutable history and
|
||||||
|
manual rejection permits explicit OCR retry.
|
||||||
|
- Manual endpoints preserve finance management or manager/ceo review permission,
|
||||||
|
source-department and feature gates. Persistence locks batch, claim advisory,
|
||||||
|
then attachment, rechecks original and corrected transaction claims, and rejects
|
||||||
|
stale version/source or self-review. Approval revalidates matching; execute must
|
||||||
|
still revalidate and claim in the actual payment transaction.
|
||||||
|
|
||||||
|
- Manual proposal preparation in paymentReceiptManualReview is not authorization:
|
||||||
|
corrections or unavailable OCR require independent review; unchanged exact-match
|
||||||
|
item association does not. Preserve original evidence, changed-field provenance,
|
||||||
|
and original transaction fingerprints for persistent duplicate checks.
|
||||||
|
- Never rewrite original failed/pending transactions into successful evidence,
|
||||||
|
including conflicting status aliases. Original cross-page duplicate identities
|
||||||
|
remain blocked even when manual fields change the reference or payer account.
|
||||||
|
Internal normalized accounts must not be serialized into API responses.
|
||||||
|
- Independent exception review requires explicit review permission and manager/ceo
|
||||||
|
business role, outside maker, proposer and every source owner/submitter. Missing
|
||||||
|
identity fails closed. Pure helper validation does not replace route scope,
|
||||||
|
transaction/version guards, audit or execute-time revalidation.
|
||||||
|
|
||||||
|
- OCR upload/retry eligibility requires reimbursement plus a pending/failed bank
|
||||||
|
item. The worker rechecks under the batch lock before reading storage or calling
|
||||||
|
cloud OCR; out-of-scope queued receipts return to legacy without transmission.
|
||||||
|
Cash and travel attachment storage is not permission to send them to BankSlipOCR.
|
||||||
|
- New eligible receipt uploads queue in the attachment/audit transaction; legacy attachments
|
||||||
|
are not automatically sent to cloud OCR. cron and pg-boss share DB claiming;
|
||||||
|
the dedicated `payment.receipt.ocr` queue disables automatic retries.
|
||||||
|
- `POST /payment-batches/:id/receipt-attachments/:attachmentId/ocr-retry` requires
|
||||||
|
finance management, feature/department and batch-state gates plus an integer
|
||||||
|
`expectedVersion`. Version/state rejection uses
|
||||||
|
`payment_batch.receipt_retry_unavailable`, not a generic unexplained failure.
|
||||||
|
Requeue and audit commit together. Processing is eligible only after 15 minutes;
|
||||||
|
queued/matched are not eligible. No amount/plaintext or OCR call in this route.
|
||||||
|
- Attachment GET exposes `ocrState/ocrVersion/ocrStartedAt/ocrRetryAvailable`;
|
||||||
|
the same eligibility function is used on retry. Eligibility is not permission:
|
||||||
|
callers still need authorized editable batch context.
|
||||||
|
|
||||||
|
- Persistence: `storeReceiptRecognition(tx, config, { batchId, attachmentId,
|
||||||
|
sourceSha256, expectedVersion, recognition })` locks batch then attachment,
|
||||||
|
rejects voided/changed/superseded input, and writes encrypted allowlisted evidence.
|
||||||
|
Future scheduling/correction must increment the attachment version under the same locks.
|
||||||
|
- `claimVerifiedReceipts(tx, config, batch)` requires a locked current batch and
|
||||||
|
the same transaction as payment recording. Revalidate latest evidence and frozen
|
||||||
|
data, then claim unique transaction fingerprint and item. Never call in a separate
|
||||||
|
committed transaction or consider it complete HTTP authorization by itself.
|
||||||
|
- Claim lock namespace is `ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims` (1002).
|
||||||
|
With Prisma, project an integer from `pg_advisory_xact_lock`; selecting its void
|
||||||
|
return directly can fail with P2010. Do not remove serialization to hide that failure.
|
||||||
|
- Different fingerprint key IDs in historical claims block registration until an
|
||||||
|
explicitly reviewed key migration; key rotation must not defeat deduplication.
|
||||||
|
DB validation: `RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs`.
|
||||||
|
|
||||||
|
- Internal services: `normalizeBankSlip(fields)`, `matchBankSlip(receipt, candidates)`
|
||||||
|
in paymentReceiptMatching and `recognizePaymentReceipt({ buffer, mimeType, config })`
|
||||||
|
in paymentReceiptOcr. These helpers are not themselves payment authorization.
|
||||||
|
- BankSlipOCR keys vary by layout; support known aliases, reject conflicting or
|
||||||
|
repeated semantic keys as ambiguous, never silently pick the first transaction.
|
||||||
|
Currency/account/principal matching is per item, not against the batch total.
|
||||||
|
- Normalize money with strict grouping/precision and integer minor units; preserve
|
||||||
|
account leading zeroes. Masked accounts and non-final transaction states cannot
|
||||||
|
establish a match. OCR matching never establishes bank authenticity.
|
||||||
|
- Process all PDF pages, retain expected/processed counts, and never accept a
|
||||||
|
partially failed response as a complete receipt. Provider errors are sanitized;
|
||||||
|
no raw response or public receipt URL. Normalized accounts remain internal
|
||||||
|
sensitive data, not API/log payloads.
|
||||||
|
- Unit contracts: paymentReceiptMatching.test.js and paymentReceiptOcr.test.js.
|
||||||
|
Their passing does not prove persistent duplicate prevention, independent
|
||||||
|
manual review, source-state revalidation, or execute transaction safety; those
|
||||||
|
gates require separate database/HTTP acceptance before wiring payment writes.
|
||||||
|
|
||||||
- `cd server && node --check src/routes/reimbursements.js`
|
- `cd server && node --check src/routes/reimbursements.js`
|
||||||
- `cd server && node --check src/routes/workflow.js`
|
- `cd server && node --check src/routes/workflow.js`
|
||||||
- `cd server && ./scripts/regression-workflow-actions.sh`
|
- `cd server && ./scripts/regression-workflow-actions.sh`
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Frontend React / Vite / Design
|
# Frontend React / Vite / Design
|
||||||
|
|
||||||
|
## Receipt Verification Dialog
|
||||||
|
|
||||||
|
- Reuse the receipt card and existing Dialog/Input/Button patterns. No separate
|
||||||
|
receipt center. Show original and proposed summaries; account fields remain masked.
|
||||||
|
Original-file preview keeps its existing authorized path.
|
||||||
|
- Unchanged association submits page/item only. Corrections submit changed fields
|
||||||
|
only; never round-trip account masks as bank accounts. Unedited fields are merged
|
||||||
|
against the server's version-bound original evidence, not a client snapshot.
|
||||||
|
- Detail query keys include actor/batch/attachment and forward AbortSignal. Disable
|
||||||
|
background form refetch; explicit refresh clears selections, corrections and reason.
|
||||||
|
Identity or batch switches close the dialog, and unmounted mutation callbacks must
|
||||||
|
not refresh another identity's screen.
|
||||||
|
- Backend canReview is an eligibility projection, not authorization. Hide review
|
||||||
|
actions when false; actual review still checks role, permission and all participants.
|
||||||
|
Save/review never call payment execution or upload.
|
||||||
|
|
||||||
## Source Of Truth
|
## Source Of Truth
|
||||||
|
|
||||||
Frontend visual and interaction design uses the installed `frontend-design`
|
Frontend visual and interaction design uses the installed `frontend-design`
|
||||||
|
|||||||
@@ -1,4 +1,58 @@
|
|||||||
# 业务闭环方案(规划,未实施)
|
# 业务闭环方案(分阶段实施,发布状态见执行记录)
|
||||||
|
|
||||||
|
## 2026-09-13 回单核对增量设计
|
||||||
|
|
||||||
|
### 2026-09-14 人工核验执行细化
|
||||||
|
|
||||||
|
最终占用实施:PaymentReceiptClaim补具体人工提案外键,另增交易身份别名唯一表,原/新身份均绑定同一个claim及item。自动和人工路径共用事务advisory锁;别名与claim一起回滚,登记前再次检查人工原证据、提案版本、当前审核人角色/权限及全部参与人关系。银行渠道只核银行项,不把现金历史强改为银行。占用服务、execute及UI readiness已接线并完成本地定向验证;不允许通过测试mock绕过业务门禁。整批独立复核及生产验收仍未完成。
|
||||||
|
|
||||||
|
页面接线:沿既有回单卡打开核验对话框,不新建中心。只读详情返回版本、页数、逐页识别摘要及人工提案摘要,账号始终掩码;原件继续用既有受控预览。默认保留识别字段,只选对应付款明细;更正时仅提交有变化的字段,服务端在当前版本原证据上合并,不让前端回传掩码覆盖完整账号。未知/失败/处理中状态不默认成功,修改内容与复核理由不入普通日志。详情查询绑定当前身份并传AbortSignal,切账号/批次关闭对话框,不共享上一身份数据。页面操作不调用execute。
|
||||||
|
|
||||||
|
持久化顺序:新增独立人工提案表,绑定原verification、附件SHA和递增版本,原证据不覆盖。保存更正为pending_review;仅消歧为association_ready,不冒充独立审核通过。复核只能处理当前版本pending_review,拒绝为rejected、批准为approved;更正内容整包加密,API仅安全摘要。保存与决定均与审计同事务,审核前重建匹配并检查当前交易占用。此表不是paid或claim凭证,后续execute需重新核版本、提案和原/新指纹。新增迁移只在一次性数据库验证;生产不执行,存在提案后不得删表回退,前向修复为默认。
|
||||||
|
|
||||||
|
execute接线的硬约束:人工提案先获批不等于已占用银行交易;两个批次先后核验通过仍须在登记时串行化重查。若更正了可识别的原付款账号/参考,最终登记除保存新指纹外还需持久保留原身份的唯一占用关系,不能只在提案时查一次原claim,否则以后使用原参考的另一回单可能绕过重复检测。最终claim须关联具体人工提案版本/批准记录,不仅引用原OCR结果。此部分已通过具体提案外键及交易身份别名实现,并验证登记事务回滚与自动/人工竞争;不代表生产已启用。
|
||||||
|
|
||||||
|
先实现内部人工提案校验,不先开付款旁路。输入为服务端读取的原识别证据、冻结候选以及逐页人工字段和明细选择;页数完整、每页唯一明细、交易身份唯一,金额/币种/完整账号仍重新精确匹配。只做同额消歧且未改识别事实的关联不增加日常审批;任何字段更正或OCR不可用均进入独立复核。原证据的明确失败/处理中不能由人工改成成功;状态别名冲突中出现失败/处理中也保留否决线索。
|
||||||
|
|
||||||
|
人工提案保留原证据引用及规范化字段来源,内部敏感结果必须加密后落库,不能直接序列化为API响应。纯函数结果不构成授权或付款凭证;后续事务接口须校验当前附件版本/SHA、审批角色与非经办/非申请人身份,审计与提案同事务落库。未接这些接口和前端前,execute不接半成品门禁,不部署。
|
||||||
|
|
||||||
|
此节为已采纳方向的技术拆解,未改变运行时。旧制单与布局内容作为历史阶段保留;不把旧“未实施”标题当成当前全部功能状态。
|
||||||
|
|
||||||
|
### 接入与边界
|
||||||
|
|
||||||
|
2026-09-14接线更新:新上传与附件记录同事务置queued,启动路径在jobsEnabled下分别接cron/pgboss独立回单tick;历史legacy不自动处理。显式重试采用expectedVersion CAS(批次→附件锁),已排队/已匹配不重排,processing达到15分钟或legacy/ocr_failed/needs_review才可重试,递增版本使旧结果立即失效并写审计。读取接口投影状态/版本/开始时间和同源重试资格,不返回规范化账号;PC/手机沿原回单卡片展示及5秒有界到终态轮询。该段替代下方历史“未接线”描述,不表示人工核验或execute守卫已经接入。
|
||||||
|
|
||||||
|
后台单次执行器采用数据库queued/processing状态及版本化领取,既有附件默认legacy不自动重识别;claim在短事务内完成,文件读取和外调在事务外,结果另起事务核版本。进程中断留下processing供后续明确重试,不能在不知道腾讯是否已处理时无限自动重传。最终cron/pgboss只作为唤醒机制,数据库领取是共同排他事实源;调度与上传入队尚未接线。
|
||||||
|
|
||||||
|
- 复用paymentBatches回单上传/预览/作废/execute入口及现有权限、部门范围、制单人和复核人分离规则,不新建回单中心。
|
||||||
|
- 专用BankSlipOCR适配,不调用发票落库/绑定/归档业务。复用腾讯SDK配置与有界后台作业机制前,先核实现有队列的任务隔离和幂等合同。
|
||||||
|
- 官方腾讯SDK确认交易金额/币种/账号/流水/手续费/日期等字段及PDF逐页请求;不承诺所有建行版式都有明确成功状态、置信度或流水。WEBP等已接受文件需安全转换或标待核验,不将旧上传格式悄悄禁用。
|
||||||
|
- 原始文件保持私有;腾讯请求走服务端受控传输,不建公开下载URL。只持久化必要规范化字段,账户加密及交易身份带密钥摘要方案须结合既有加密合同细化;日志只留原因码和请求标识,不保存整个OCR响应。
|
||||||
|
|
||||||
|
### 核验模型与一致性
|
||||||
|
|
||||||
|
2026-09-13 持久化实施:PaymentReceiptVerification绑定附件SHA、receipt_ocr_version与页数,规范化白名单字段整包加密;PaymentReceiptMatch按页关联batch item;PaymentReceiptClaim以HMAC(CCB域、付款账号、交易参考)为主键且item唯一。指纹使用现有fieldEncryptionKey并存key ID;若历史claim使用其他key ID则阻断,必须另审密钥轮换迁移,禁止换密钥绕过去重。流水号优先、凭证号为备选,缺失/冲突待核验;这不是银行验真。
|
||||||
|
|
||||||
|
storeReceiptRecognition由已授权内部调用方在事务内调用,锁批次后锁附件,再核状态/批次关联/SHA/版本;晚到作业不能恢复作废或新版核验。未来重试/人工更正须先在同锁序下递增receipt_ocr_version。claimVerifiedReceipts要求调用方先锁批次,随后固定命名空间1002的事务advisory锁串行化占用,再锁附件;重新解密核验当前冻结付款数据,只接受最新同版本有效完整结果。claim与最终登记必须同事务提交/回滚,数据库双唯一约束为最后保护。
|
||||||
|
|
||||||
|
迁移为新增三个表及附件默认0版本列,不回填历史paid核验结果、不删除业务资料。回退空的新结构可仅在隔离环境或另批操作下逆向删除;一旦生成证据/claim,生产不允许直接删表或退无门禁旧应用继续登记,需保持写入隔离并前向修复/兼容修订。当前服务未接路由、队列及人工复核,数据库测试通过不代表授权/公网业务链通过。
|
||||||
|
|
||||||
|
- 文件安全治理与交易核验必须独立;现有governance_status=approved不表示金额/付款已核验。另建核验版本、处理状态、页/交易记录及付款明细关联,不复用附件标签表示业务放行。
|
||||||
|
- 区分等待识别、处理中、待核验、匹配、不符、识别失败;多页完整性、页内交易数量及未知格式均显式处理。晚到作业需比对版本和作废状态,不复活过期证据。
|
||||||
|
- 金额用精确十进制或最小货币单位比较;不合并同人多单。账户身份和交易币种/本金为必要证据,编号和日期用于消歧但不能覆盖硬性不符。
|
||||||
|
- 重复交易以银行/付款账户/交易身份建立持久化占用和事务约束;哈希相同不足以判断全部重复,换文件/重传也不能重复登记。同额歧义进入人工关联而非按列表顺序猜。
|
||||||
|
- 上传和OCR不写paid。execute在现有锁/事务边界重新核有效证据、匹配版本、来源单状态/金额、重复交易占用,再允许现有登记流程;前端禁用不代替后端守卫。
|
||||||
|
- 人工更正保留字段来源和历史;确需OCR故障例外时采用独立非经办复核,不免除明确不符/重复/失败交易约束。具体接口/权限与数据约束在实施前补齐,不能先发布可绕过的临时入口。
|
||||||
|
|
||||||
|
### 历史与发布
|
||||||
|
|
||||||
|
已executed/已付款不重开、不自动回填“已核验”。尚未登记的旧批次不能仅因旧附件存在绕过新门禁,需适用新核验或明确受控人工核验。迁移采用附加结构,不删除原件或改历史金额。
|
||||||
|
|
||||||
|
本增量会增加登记安全门:旧镜像可能忽略核验结果,回退不能只以schema兼容判安全。发布前必须冻结新候选、核备份/迁移/回退策略;已产生核验数据后禁止未经评估直接退旧版继续登记。
|
||||||
|
|
||||||
|
### 已读取的外部依据与自限
|
||||||
|
|
||||||
|
2026-09-13腾讯云官方Python SDK:ocr/v20181119/ocr_client.py的BankSlipOCR及models.py的BankSlipOCRRequest/Response。支持银行回单字段识别和PdfPageNumber逐页请求;它不是银行交易查询或验真接口。链接:https://github.com/TencentCloud/tencentcloud-sdk-python/tree/master/tencentcloud/ocr/v20181119 。未取得可靠建行验真或国内产品详细规则资料,不虚构行业强制规则;真实建行样式与外调效果仍待专门授权验证。
|
||||||
|
|
||||||
## 2026-09-11 已批准备注增量
|
## 2026-09-11 已批准备注增量
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,275 @@
|
|||||||
# 执行顺序(本地技术审查/修复已批准,真实业务动作另批)
|
# 执行顺序(本地技术审查/修复已批准,真实业务动作另批)
|
||||||
|
|
||||||
|
## 2026-09-15 本地提交授权
|
||||||
|
|
||||||
|
用户对33路径一笔本地提交明确回复“是”。提交前实际HEAD为229867f5,真实暂存区为空,R3清单33份中仅本implement.md有已披露的复核/样本/发布准备记录差异,其余32份内容与冻结摘要一致。只按R3 manifest.files逐路径暂存;排除的09-04两份文档及统一审批中心文档不纳入,不提交artifacts或workspace。此次授权不包含推送、部署、归档或更改分支;实际提交SHA以git回执为准,不预先声称成功。
|
||||||
|
|
||||||
|
## 2026-09-14 提交清单与发布执行方案(待授权)
|
||||||
|
|
||||||
|
再次核对R3后:仅本implement.md因外部复核结论、生产只读/样本记录增加而不同;其余32份冻结文件零漂移。产品代码仍为已独立审过的R3,不因事实记录更新冒称源码重验。本轮未再次访问生产、发送OCR、提交或推送。
|
||||||
|
|
||||||
|
### 提交合同
|
||||||
|
|
||||||
|
建议一笔:fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)。
|
||||||
|
|
||||||
|
范围严格采用R3 manifest.files的33个路径:2份模块spec、当前task的prd/design/implement、5份client文件、隔离DB脚本、3份附加迁移及schema、3份常量/原因文件(含服务端夹具)、job接线与worker、付款路由、5份服务和相关后端测试。精确名单以manifest为准,implement使用本次事实记录更新版;实际提交前再次逐路径核对,禁止git add .。
|
||||||
|
|
||||||
|
排除09-04的design.md/implement.md及docs/需求-统一审批中心规约.md;artifacts内原件检查工具、发布包和日志不混入源码提交;workspace日志不提交。不得根据detached HEAD自动切分支、改远端、amend或归档任务。用户批准提交后只提交,不默认推送/部署;提交完成记录实际SHA及相对R3仅文档差异。
|
||||||
|
|
||||||
|
### 发布门禁与验证步骤
|
||||||
|
|
||||||
|
1. 提交后若另获推送/发布授权,固定实际候选SHA、树和三份迁移清单;已有生产版本229867f5和元数据只是观察时事实,发布前重查版本/迁移/磁盘/备份时点与完整性/独立回滚镜像,不沿用旧容量或例外。使用existing数据模式,不跑初始化seed。
|
||||||
|
2. 未决事项逐项交代:生产密钥高熵来源未有生成记录;一份真实逐笔单页样本成功,不证明多笔/汇总模型;当前无reviewed/failed报销批次,不证明未来人工例外一定有合格复核人;真实JWT浏览器DB同链未测。不得把单页通过描述成全面生产验收。若当前使用情形必须依赖未验证的模型或没有合格例外复核人,暂停相应上线决定,不靠默认放行。
|
||||||
|
3. 部署动作会执行已审附加迁移,并在应用启动时创建/调度payment.receipt.ocr队列;这些不是只读检查,必须包含在明确发布授权中。若生产版本或迁移前提变化,停止重新核对,不盲目应用旧候选。
|
||||||
|
4. 上线后首先验公网ready/build、容器健康、迁移成功;受控只读核验pgboss队列/调度已注册及周期任务完成记录/失败统计,跨至少两个已配置周期观察。空队列成功tick只能证明调度及空轮询可用,不能说真实回单已消费。无允许的排队回单时不伪造paid或自动重排旧附件;真实端到端任务需另获明确样本/云调用与测试写入授权,禁止以财务实际付款探路。
|
||||||
|
5. 执行端检查桌面/手机已构建页面、失败状态提示和权限拒绝;不要求员工做软件回归。没有对生产附件操作的授权不上传、作废、人工复核或登记付款。单元/合成HTTP/browser证据如实复用,不冒充生产真实认证链。
|
||||||
|
6. 发现新队列启动失败、迁移失败、健康异常或错误增加即停止继续放行。先冻结进一步业务动作、保存脱敏故障证据,再按独立授权处理恢复。全局JOBS_ENABLED会影响其他任务,不作为无条件回退开关;不删除claim/alias/人工证据,不降级为仅附件存在即可登记。旧镜像忽略门禁,不能自动以镜像回退宣称安全恢复。
|
||||||
|
|
||||||
|
### 人工例外的人员安排
|
||||||
|
|
||||||
|
按实际批次列制单人、提案人、所有来源owner/submitter,再从在职且具manager/ceo角色和payment.batch.review权限者排除这些人。不得把普通付款复核人直接视为回单例外复核人;本人报销且仅本人持CEO时可能无人合格。无人合格即报告业务阻断,由用户另行决定职责安排,不加通配、不摘审批角色、不自动改账号关系。当前无需让任何员工提前操作或等待测试。
|
||||||
|
|
||||||
|
完成状态:提交/发布方案已准备,R3内容复核通过,真实一份逐笔样本匹配通过;提交、推送、部署和上线消费验收均尚未完成。
|
||||||
|
|
||||||
|
## 2026-09-14 一份历史已付款回单受控样本
|
||||||
|
|
||||||
|
用户明确授权一份已付款历史回单、最多一页一次腾讯调用,仅回传状态与匹配结论。选择此前已确认的历史单笔批次及报销,对象须唯一、executed/paid、附件approved且未归档,生产版本仍229867f5;不扩大搜索其他原件。先在RepeatableRead READ ONLY事务读取必要的冻结快照/原单金额及附件引用,退出事务后在生产容器内存读原件并核大小/SHA/单页,解密冻结账号只用于内存精确比较,不回传账号或金额。
|
||||||
|
|
||||||
|
实际2026-09-14T13:58:05.284Z完成,externalCalls=1,使用R3冻结matching源码(SHA256 9a1a42d0ec5a9a60ce7de1a58f1ef505e88526d0515e4a854fd617c2107d5fb8)在内存执行:normalizedTransactionStatus=success;amount/currency/payerAccount/payeeAccount/reference均可解析,normalizationIssues=[],matchStatus=matched,matchReasons=[],receiptMatchesSourceAmount=true。原件及原响应未保存到开发机或新文件,未写paid/claim/附件状态,未新增OCR重试。
|
||||||
|
|
||||||
|
证据artifacts/qa-layout/receipt-release-preconditions/sample-result.json;受控脚本check-sample.mjs只留安全结果,传输/执行结果不明时明确UNKNOWN_DO_NOT_RETRY,不自动重发。该样本证明当前冻结匹配器适配这一份真实单页逐笔回单,不证明多笔批次/汇总回单模型,不能称银行验真或到账证明;也不证明新worker在生产消费,因为它未部署。
|
||||||
|
|
||||||
|
发布前剩余:多笔形态如需宣称支持需另取授权样本;高熵密钥来源及真实JWT浏览器DB同链仍未验证,例外独立复核人须按实际来源/提案人判定。当前无待登记reviewed/failed批次,不伪造真人付款来验。未提交/推送/部署、不覆盖R3包;提交范围与发布/上线队列验证仍另行授权。
|
||||||
|
|
||||||
|
## 2026-09-14 发布前受控元数据检查
|
||||||
|
|
||||||
|
用户“执行”后按已说明边界开展一次生产只读元数据检查;未发送真实回单、未读账号/原件、未改配置或业务数据,不执行部署。使用既有SSH别名和systemd Compose身份,运行内存脚本;数据库RepeatableRead READ ONLY、statement_timeout10s、有界100条,仅回传聚合资格/配置存在性。证据artifacts/qa-layout/receipt-release-preconditions/result.json,观察时间2026-09-14T13:49:50.438Z。
|
||||||
|
|
||||||
|
结果PASS_METADATA_ONLY:生产BUILD_SHA仍229867f5fd4223d6efadb543a134696f65095763,OCR_PROVIDER=tencent、JOB_DRIVER=pgboss、JOBS_ENABLED=true,腾讯凭据字段与加密密钥已配置。未输出值,配置存在不证明权限/解密可用或高熵来源。
|
||||||
|
|
||||||
|
新增paymentReceiptOcrJob.js在线上不存在,符合未部署事实;不能将jobsEnabled说成新回单队列已消费,queueConsumptionTested=false。当前reviewed/failed报销批次0,因此没有本轮待登记对象可验证例外复核人,也没有这些批次的legacy附件恢复对象;不据此宣称没有其他未付款/草稿报销或所有报销已完成。历史“唯一待登记批次”记录不沿用。
|
||||||
|
|
||||||
|
真实回单状态/逐笔或汇总形态仍未核验。既有脱敏历史证据只能证明一次单页OCR调用及金额匹配,不能替代新模型。下一受控样本建议限定已登记历史回单一份、最多一页/一次腾讯调用、仅回传字段存在性与规范化状态及匹配布尔值,不改原件/paid/claim,不留原响应;样本对象和调用范围明确后再执行,不将本轮元数据读取扩大为任意原件读取。密钥生成来源需依据受控生成记录,不通过读取密钥猜测熵。
|
||||||
|
|
||||||
|
本轮不动R3冻结包、不新增产品源码,未提交/推送/部署。仍需受控样本验证、新版本部署后的队列检查及发布时重新核生产资源/备份/回滚制品;本次结果不构成发布放行。
|
||||||
|
|
||||||
|
## 2026-09-14 R3独立复核通过与发布说明
|
||||||
|
|
||||||
|
用户独立只读结论:R3包完整性及内容通过,无阻断项;A1/A2/错误分类成立。适用manifest a415ccde2121d927d3aea77d54cba584ef30bd1cda691a1c4fa2d9d9b4f4b74a、树a972f00b80b78ae6cff66cff154b149e854145ce。记录前重新核对33份工作源码零漂移;本节是复核后发布说明增补,形成task文档差异,冻结sources/README/manifest/证据不覆盖。reviewer未重跑产品测试,不新增独立测试通过计数;内容通过不等于生产已可用或获得发布授权。
|
||||||
|
|
||||||
|
### 发布说明必须保留的边界
|
||||||
|
|
||||||
|
- 系统失败禁止普通重试仅适用于已成功落库的system_failed/key_failed/snapshot_failed。若失败状态本身也写不进去,附件可能仍为processing,15分钟后现有超时分支允许人工重试,可能再次发送OCR。超时不证明故障已修复;管理员应先查安全告警与系统前置,财务不要反复重试。此例外不称为自动重试,也不宣称已彻底禁止故障后的云重传。
|
||||||
|
- 当前没有管理员重排队路由或脚本。“联系管理员”首先是诊断并修复密钥/快照/数据库等前置,不代表有现成恢复按钮。修复前置后,在批次仍可编辑、有权限及真实数据/云操作授权的条件下,可重新上传同一份原回单作为新附件进行识别;旧失败附件保留作证,不删除、不伪造新回单,也不重新银行付款。重复交易守卫照常执行;如系统判多笔/歧义不能强行绕过。直接数据库重排队只能另批受控方案,不作为现成能力承诺。
|
||||||
|
- 全人工录入是「原件留存 + 经办录入 + 独立人员复核」,不是机器核实内容一致,更不是银行验真或到账证明。
|
||||||
|
- 批次级横幅/readiness/execute在缺有效证据时仍可能提示人工核验,与系统失败附件卡不一致。以附件系统失败提示为准,不尝试禁用的核验入口;这是已知非阻断体验问题,留原任务后续小修,不另建任务。
|
||||||
|
- 批次离开可编辑状态后附件可能保留processing展示;越界queued清理会消耗一个tick。二者作为已知非阻断限制保留,不解释为付款已失败或实际已执行。
|
||||||
|
|
||||||
|
### 其他发现的明确处置
|
||||||
|
|
||||||
|
1. 登记事务的employees/employee_roles FOR SHARE:本版暂保留,保障当前资格重验;优化须有并发撤权验证,不能为了缩锁面删除安全保证。
|
||||||
|
2. claim RESTRICT与批次item Cascade:当前无删批次路径,本版保留证据保护;未来清理/纠错需专门设计,不顺手改级联删除。
|
||||||
|
3. manual_rejected后先重做OCR:本版保留并披露额外云调用,后续在原任务评估可复用原证据的修订提案,不静默改当前状态机。
|
||||||
|
4. key_id明文摘要:仅在高熵随机32字节密钥前提下接受设计;生产密钥生成来源仍待受控核实,不能把“代码校验32字节”说成已证明高熵。不读取或展示密钥,不批准轮换。
|
||||||
|
|
||||||
|
### 发布前顺序与未覆盖
|
||||||
|
|
||||||
|
先准备明确范围的受控核验方案,另获生产/真实回单/腾讯调用授权后核实:逐笔或汇总回单模型与状态、实际OCR provider和job消费、合格独立复核人、legacy未付回单路径及高熵密钥来源。财务创建、CEO复核批次不代表该CEO可复核本人报销的人工回单例外。真实JWT+浏览器+DB同链、断电/断网仍未测;软件本地验证由执行端承担,不让员工反复探路。
|
||||||
|
|
||||||
|
随后冻结待提交范围并按授权提交推送,刷新生产版本/迁移/磁盘/备份完整性/独立回滚制品,另批新候选发布;不得沿用旧发布例外。新门禁证据写入后不能以仅退旧镜像恢复安全语义。当前未提交、未推送、未部署、未连生产,仍不是任务整体完成。
|
||||||
|
|
||||||
|
## 2026-09-14 A2与错误分类实施
|
||||||
|
|
||||||
|
用户批准按修订合同执行。利用已有varchar状态字段持久记录system_failed/key_failed/snapshot_failed,不新增迁移、不储存异常原文。证据事务失败后另开maxWait5s/timeout10s事务,先锁批次,再按附件id/批次/SHA/版本/processing/未归档/approved进行条件更新(UPDATE取得附件行锁);批次不可编辑或条件失效返回discarded。回写也失败明确failure_recording_failed,日志保留安全outcome,不宣称已落库。没有把数据库整体故障伪装为可可靠持久化。
|
||||||
|
|
||||||
|
缺密钥在读文件/云调用前拦住并尝试写key_failed。密钥轮换、业务/银行不支持、身份不全有独立原因码,前后端及夹具同步;batchCandidates显式核密钥,避免通用crypto错误绕过原因映射。系统失败无普通重新识别/人工录入资格;页面指向管理员并阻断登记,不自动重传。管理员恢复须修好前置后另批按版本重排队,本轮不提供无门禁“解除失败”按钮,也未执行生产回填。全人工路径是经办录入与独立人员复核,不是机器核实原件内容。
|
||||||
|
|
||||||
|
验证:后端8文件412/412;隔离Postgres及HTTP36项通过并清理,新增缺密钥零文件/云调用、真实SQL除零导致证据事务中止后独立事务持久写system_failed。job单测新增安全分类、回写失败及旧版本条件不命中。测试覆盖不等于实际断网/断电实验;尚未验证真实生产worker及真实回单状态。前端最终构建与新增双端错误提示验收进行中,结果在交审包记录,不提前宣称通过。
|
||||||
|
|
||||||
|
最终前端build/typecheck通过(PWA115);桌面1440/手机390共16项浏览器通过、零重试,含三类系统失败无重传/无核验/无付款动作及原人工关联更正/复核/OCR重试回归。预览已停止。严格原因码检查发现动态查表无法静态登记,改为四个字面量分支后重验,不把脚本退出0误认JSON pass=false通过。R3入口artifacts/qa-layout/receipt-verification-review-20260914-r3/README.md;旧包不覆盖,未提交/推送/部署。最终路由映射改为等价静态分支后重跑八文件;DB36证据来自该机械调整之前,其事务/job输入未变化,明确复用,不新增DB通过计数。
|
||||||
|
|
||||||
|
## 2026-09-14 外部六项复核归属与后续修订合同
|
||||||
|
|
||||||
|
收到用户只读内容复核:A1发送范围、A2落库失败悬挂为阻断,其余六项为建议及未覆盖项。现场以SHA核对:被描述的7处漂移精确对应原manifest afc15486f000f6edac6724b313a55734a914474527a49455a632d57de4c42929、树5f58dc07f8b2e1d127f7fe3ca5d0561201532d67;R2 manifest 214be14fed3e1c0588f7b60e8450c656237e2487424e1096a618849fe9e2f762、树e1a9c52f40a421c2952ff4a7e53ee90483ea4871当时33份源码零漂移。不能根据目录称呼/mtime把这份结论错绑R2,也不把R2自检当外部通过。此记录写入后工作树文档自然不同于R2,旧包均不覆盖。
|
||||||
|
|
||||||
|
A1本地修复与402/34验证仍成立,新增范围闸待独立内容复核。A2确认尚未修复:持久化事务异常会留下processing,仅返回persistence_failed,不具备持久的失败分类。本次只核验审查对象并细化处理合同,没有宣称新增代码或测试通过;不为了目录改名生成无代码变化的R3。
|
||||||
|
|
||||||
|
下一修订顺序:
|
||||||
|
1. 在原证据事务回滚后,以独立有界事务、批次→附件锁和版本/SHA/processing条件写安全失败分类;晚到失败不覆盖重试、作废、已完成或人工结果。数据库整体不可用时不能保证回写,输出安全失败告警并明确回写未完成,不返回已持久化成功。
|
||||||
|
2. 系统/密钥/银行快照问题与OCR识别失败分开;识别失败可以人工例外,系统失败引导管理员,不把无证据的失败伪装为可直接人工核验。不得自动循环发送腾讯,修复系统前不能用“重新识别”掩盖故障。
|
||||||
|
3. 为密钥不可用/轮换、银行或业务不支持、参与身份缺失补独立原因解释,前后端与原因码夹具同步;保留匹配问题的原通用提示。
|
||||||
|
4. 补持久化抛错、回写抛错、晚到旧版本、作废竞争、错误分类和零重传测试;实际数据库故障注入及页面失败提示验证后冻结R3,提供相对R2及原包的追溯,不继承旧包审查通过。
|
||||||
|
|
||||||
|
其他备注处置:HR行锁不能在无并发替代方案下直接删除,保留登记时当前资格保证;RESTRICT是证据保护,不改为级联删除,未来删批次另审;人工否决后重发OCR作为后续体验项记录,不顺带改状态机;密钥高熵来源在发布前受控核验,不读取或展示密钥;全人工例外必须描述为“原件留存、经办录入与独立人员复核”,不是机器验证内容一致、银行验真或到账证明。
|
||||||
|
|
||||||
|
外部未覆盖项继续保留:真实逐笔/汇总回单形态与状态、运行中worker、legacy未付回单恢复、合格例外复核人、真实JWT浏览器DB同链。不能将曾成功调用腾讯或本金字段匹配扩成完整真实回单模型已验证。生产/云读取、角色更改、提交推送及部署仍未执行。
|
||||||
|
|
||||||
|
## 2026-09-14 交审后主会话发现:OCR发送范围
|
||||||
|
|
||||||
|
用户批准继续既定范围。六项主会话检查发现上传统一queued,retry及worker没有银行报销范围门,非银行/历史差旅回单可能在付款守卫拒绝前已发送云端。这是本地源码路径发现,未证明生产发生外传;原冻结包不改,包完整性PASS不覆盖此行为。
|
||||||
|
|
||||||
|
修正范围:上传仅待登记银行报销入队;retry同样限制;worker在批次锁内重查,不合范围的旧queued恢复legacy,不读文件、不调用云端。保留现金/差旅附件保存与既有付款路径。补上传/retry/worker反例及银行正例,定向回归后重新提供增量交审证据;不改角色、迁移或生产配置。
|
||||||
|
|
||||||
|
最终验证:八文件402/402同轮通过;真实一次性Postgres及HTTP34项通过,新增现金/差旅/已登记项的worker零读取零云调用检查,清理完成。语法、diff及Trellis治理通过。上传/查询响应也同步关闭非范围重试资格。前端源码没改,原16项浏览器与build只作为复用证据,本轮未重跑;真实JWT浏览器DB同链仍未完成。
|
||||||
|
|
||||||
|
执行命令:server下沿上一轮八文件npm test命令(使用既有PAYMENT_ORDER_PYTHON_BIN);根目录RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs。首轮定向131/131及中途DB31项为过程记录,最终402/34取代,不相加。原包不覆盖;R2交审入口artifacts/qa-layout/receipt-verification-review-20260914-r2/README.md,含整批及相对原包的增量补丁、脱敏单测日志和隔离DB回执。整批独立内容审查、提交推送和受控生产核验仍待完成。
|
||||||
|
|
||||||
|
## 2026-09-14 整批交审准备
|
||||||
|
|
||||||
|
用户同意下一步,继续本地交审与发布前提准备;不是生产访问、真实回单发送、迁移或发布授权。官方task指针仍为本09-08任务,注入的09-04流程状态不改变执行合同。
|
||||||
|
|
||||||
|
交审包入口:artifacts/qa-layout/receipt-verification-review-20260914/README.md。以当前HEAD为基线,冻结本任务tracked及新增文件的补丁、逐文件摘要与重放树;排除09-04的design/implement及统一审批中心文档。使用临时index,不改变真实暂存区、HEAD、远端,不生成提交。包内不复制真实票据、OCR响应或银行资料。
|
||||||
|
|
||||||
|
本轮仅修正文档当前状态,不修改产品源码,不重复计入上一轮398/31/16项验证。inline模式下不派实现/检查代理,整批独立复核标为待外部只读复核,不以主会话准备材料代替独立结论。交审重点和发布前受控检查见包内README;先交审,发现项闭合后再申请有明确范围的生产核验,最后另批候选发布。
|
||||||
|
|
||||||
|
## 2026-09-14 最终输入验证与交审边界
|
||||||
|
|
||||||
|
本节更新下列历史阶段的“尚未接线/待重跑”状态,不抹去中间失败及分轮记录。最终银行候选过滤只处理银行项;补现金/银行混合历史回归,现金项不要求银行快照或占用。
|
||||||
|
|
||||||
|
- 后端八文件同轮398/398通过:付款路由119、匹配40、OCR9、核验证据20、人工核验39、OCR任务8、pgboss3、错误原因160。沿用既有BIFF8 Python环境,没有新增云调用。
|
||||||
|
- 一次性真实Postgres与真实Fastify HTTP共31项通过,含提案、独立复核、只读资格检查、登记、重复拦截、来源金额变化全事务回滚以及混合渠道;容器和临时数据清理完成。认证是合成actor夹具,不是正式JWT登录。
|
||||||
|
- 最终前端build(含typecheck)通过,PWA115项;最终构建浏览器16/16同轮通过、零重试,覆盖桌面/窄屏核验及登记状态。浏览器API/auth为mock,不将它和另一组真实DB HTTP证据合称真实登录浏览器全链。
|
||||||
|
- 最终浏览器输出为artifacts/qa-layout/receipt-execution-gate-final-ui;本轮自有预览进程已停止。Prisma、语法、严格原因码、process baseline、Trellis治理及diff检查通过;源码输入未变的检查复用,不增加一次通过计数。
|
||||||
|
|
||||||
|
交审重点:整个回单增量的角色/参与人隔离、原证据不可覆盖、异步版本竞争、原/新交易身份占用、最终付款事务、掩码与密文边界、双端动作及新增迁移兼容性。既有局部复核不能替代整批独立只读复核;当前inline主会话自检完成,独立复核仍待完成,不宣称已放行。
|
||||||
|
|
||||||
|
发布前仍需另批受控核验真实回单交易状态、运行中的OCR/job配置及例外可用的独立复核人,再刷新生产版本/迁移/磁盘/备份/独立回滚制品证据。真实样本不能自动匹配且无合格独立复核人时,应报告业务阻断,不能改为通配或本人批准。已有paid历史不重开;占用/别名/人工证据写入后旧应用不识别新门禁,不得声称只退镜像即可安全回退。
|
||||||
|
|
||||||
|
| 代码 | 发布 | 业务验收 | 等谁 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 自动/人工核验到付款登记已接通,本地定向验证通过 | 未提交、未推送、未部署,生产迁移未执行 | 合成隔离链通过;新增门禁尚无生产验收,不替代既有真实付款事实 | 整批独立只读复核;生产/云核验及发布另行授权 |
|
||||||
|
|
||||||
|
## 2026-09-14 付款登记最终门禁与交易别名占用
|
||||||
|
|
||||||
|
已接execute:仅银行渠道报销项消费有效OCR/人工证据;保持原批次复核、来源行锁、金额/状态检查和事务边界。补PaymentReceiptClaim.manual_proposal_id与原/新交易身份唯一别名表,同namespace1002锁串行化;最终登记、claim、别名同事务。人工更正的原参考也被占用,另一文件/批次换回原参考不能复用;审核人当前在职/角色/复核权限和所有来源owner/submitter再次验证。不改变既有非银行/差旅历史处理,不重开已付款;新增migration只在一次性DB验证,生产未执行。
|
||||||
|
|
||||||
|
新增只读receipt-readiness,以RepeatableRead无行锁/无claim写入重新计算回单资格;前端绑定身份、批次和附件版本/状态,查询成功ready才显示登记动作,加载/错误/未匹配均不放行,可重新核对或查看详情。它不是付款授权,execute仍加锁重验。已登记批次不显示“尚未登记”或“当前渠道不需银行回单”等矛盾说明。
|
||||||
|
|
||||||
|
后端8文件397/397通过;其中原正例因没有核验证据首轮7项失败,补明确verifiedReceipt合成冻结快照和真实纯匹配服务生成的证据,未mock最终claim放行;另补无证据、旧版本、错金额、作废、重复、只读readiness零占用和实际execute占用用例。旧差旅历史不并入新报销OCR规则。Prisma validate/generate、语法和diff通过。
|
||||||
|
|
||||||
|
真实一次性Postgres30项通过,包含新增原/新别名原子回滚、双身份唯一及幂等、当前角色/参与人变更拒绝、自动与人工并发仅一个claim。新增真实Fastify路由+真实DB链:提交人工核验→ready=false/execute409→独立复核→只读ready=true零占用→execute写paid及具体提案claim→重放409;来源金额变化使claim/alias/付款整体回滚。身份为显式合成actor fixture,不冒充正式JWT登录;邮件/IM/ERP全关闭,无云调用、无生产访问,容器/tmpfs已清理。
|
||||||
|
|
||||||
|
测试过程保留:首轮HTTP集成夹具把featureFlag.flag_key写成key,验证失败后修正;随后一次Docker子命令失败未定位(原输出只有行号),补安全operation/exit诊断及“容器已不存在时不覆盖原异常”的清理处理后完整重跑30项通过,不把未知原因说成已定位。DB脚本出口只有安全结果,没有原票据/账号/原始payload。
|
||||||
|
|
||||||
|
浏览器第一轮12/12通过(1440/390未核验不得登记、人工关联/更正、独立复核、重试,加非银行路径与查询失败),API/auth为mock,真实DB HTTP另行验证,不宣称同一浏览器完整真实JWT链。最终文案后需以最后构建再跑对应浏览器集合。整个OCR增量仍待独立只读复核及发布前受控样本/运行前提验证;真实样本交易状态值未在本轮读取,不假定都能自动匹配,缺独立合格复核人的例外不得靠通配自审。未提交/推送/部署,不沿用旧发布授权。
|
||||||
|
|
||||||
|
## 2026-09-14 核验详情与双端操作页面接入
|
||||||
|
|
||||||
|
新增GET manual-verification,沿既有登录/批次读取/功能和来源部门守卫,RepeatableRead只读事务内核附件版本/SHA;只返回规范化摘要、掩码账号、提案更正字段与说明,不返回密文包/原响应/完整账号。canReview由同一独立复核规则按全部来源参与人计算,仍非写入授权。保存支持只提交字段差异,服务端从绑定原证据补齐其他字段;不让客户端回传掩码账号覆盖真实账号。
|
||||||
|
|
||||||
|
沿回单卡“核验详情”打开既有Dialog风格组件,按页展示原识别和人工结果。仅选对应明细不重填字段和理由;更正只提交非空改动字段并要求说明;原状态不明不默认成功。独立审批人查看更正并复核,不合格账号只见等待说明。详情身份/批次/附件query key加signal,关闭后台表单刷新;切身份/批次关闭,卸载后的变更回调不刷新新身份页面。上传/在线查看/下载保留,人工保存/复核均零execute请求。未使用新视觉体系,沿现有组件和样式。
|
||||||
|
|
||||||
|
验证:8文件391/391同轮通过,真实隔离Postgres24项通过并清理(新增只读详情事务与掩码检查);真实DB仍为服务级测试,HTTP为Fastify注入+mockDB。前端初次typecheck因三个未使用参数失败,改用明确_unused参数后最终typecheck通过;build/PWA115项通过,最终固定源码又运行npx vite build后进行浏览器验收。1440/390的仅关联、字段更正、独立复核、OCR失败重试共8/8同轮通过、零重试,保存载荷无账号重填且上传/execute计数为零。测试API/auth为mock,不宣称生产或真实认证整链。
|
||||||
|
|
||||||
|
浏览器命令:client下E2E_BASE_URL=http://127.0.0.1:4188 npx playwright test tests/e2e/system-action-confirmation.spec.ts --grep '回单人工核验|回单独立复核|回单OCR失败' --workers=1 --retries=0 --reporter=list --output=../artifacts/qa-layout/receipt-manual-detail-ui。server八文件联合命令同上一节并新增断言到现有路由文件。根目录RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs。diff与Trellis治理通过;旧punycode/Browserslist/NO_COLOR警告未扩修。
|
||||||
|
|
||||||
|
仍未完成:execute最终事务消费自动/人工证据、人工更正前后交易身份持久唯一占用、具体提案关联、来源金额/状态及当前审批资格重验,以及真实DB HTTP/双端全链和整个增量独立只读复核。此次inline主执行自检,不继承前轮局部review结论。页面完成不代表最终付款门禁启用;未部署、未提交/推送、未连生产或调用真实OCR,不重开已付款。下一步直接完成登记门禁与整链,不让员工承担软件QA。
|
||||||
|
|
||||||
|
## 2026-09-14 人工保存与独立复核事务接入
|
||||||
|
|
||||||
|
实际task指针仍为09-08;注入09-04状态未作为换任务依据。新增PaymentReceiptManualProposal附加迁移及内部持久化服务,绑定原verification、SHA和递增版本,原记录保留。内容及理由加密;只关联未改事实为association_ready、系统关联理由,不要求二次录入或审批;更正为pending_review。原/新指纹和明细已有claim时拒绝,未知密钥轮换拒绝。复核重验原件版本/匹配/占用以及全部source owner/submitter,审批者不得为制单人/提案人/申请参与人;批准manual_approved、拒绝manual_rejected,均不写paid。拒绝记录保留,允许显式OCR重试取得新版本。
|
||||||
|
|
||||||
|
接入两个受控POST:receipt-attachments/:attachmentId/manual-verification与manual-verification/:proposalId/review;沿既有登录、财务管理或manager/ceo复核角色权限、开关与来源部门守卫,服务内再次锁批次→claim advisory→附件。保存/决定/版本/审计同事务;响应只有id/status/version,未知内部错误不透出原payload。新增四项注册错误说明并前后端对齐。前端只补新状态文案,人工表单/核验详情还未接。
|
||||||
|
|
||||||
|
验证:8文件387/387同轮通过(付款路由109、manual39、verification19、matching40、job8、OCR9、pgboss3、错误原因160)。一次性Postgres全迁移22项通过、容器/tmpfs清理完成,新增保存/审计回滚、并发版本、加密安全摘要、自审拒绝、独立复核仅一次且不付款、原指纹跨批不可改掉、SHA变化拒绝、拒绝留痕及显式重试、审计FK失败全回滚、同额消歧无需重填。HTTP测试为Fastify注入+模拟DB,真实DB测试为服务层,不冒充真实DB HTTP或浏览器全链。
|
||||||
|
|
||||||
|
命令:server下PAYMENT_ORDER_PYTHON_BIN=/tmp/hyb-oa-biff8-test-venv/bin/python npm test -- tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/services/paymentReceiptMatching.test.js tests/unit/services/paymentReceiptOcr.test.js tests/unit/services/paymentReceiptVerification.test.js tests/unit/services/paymentReceiptManualReview.test.js tests/unit/services/paymentReceiptOcrJob.test.js tests/unit/jobs/pgBossJobs.test.js tests/unit/scripts/error-reason-client.test.js;根目录RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs。Prisma validate/generate、node --check、错误原因strict、process baseline、diff通过。旧SDK punycode警告未扩修。
|
||||||
|
|
||||||
|
最终补验:保存递增与复核读取的整数上界分开,最后有效版本仍可复核、不溢出;付款路由109/109再次通过,真实DB完整重跑23项通过并清理,包含最终整数版本回归。之前387为修正上界前联合运行,未变输入复用、不增加累计通过数。前端npm run typecheck及npm run build(含116项PWA预缓存)通过;Trellis治理和最终diff通过。旧Browserslist提示未扩修。
|
||||||
|
|
||||||
|
当前主线仍未完成:受控核验详情/人工表单/审核页面、execute对自动/人工证据同事务最终重验与claim、真实DB HTTP和双端整链验收。本轮新增事务/接口待独立只读复核;不继承上一轮纯函数局部复核通过。无生产访问、无真实云调用、无提交/推送/部署;不提前发布门禁,不让员工试错。
|
||||||
|
|
||||||
|
## 2026-09-14 人工提案内部校验(接口未接入)
|
||||||
|
|
||||||
|
当前继续原09-08,未新建task。新增paymentReceiptManualReview内部服务:完整页数/逐页关联、逐项精确金额币种和完整账号匹配、同额消歧、字段更正来源清单、原交易指纹保留。只关联且事实未变不额外复核;字段更正、缺字段或OCR故障须独立复核。新增身份守卫要求既有manager/ceo业务角色加复核权限,排除制单人、提案人以及所有报销归属人和提交人;未知身份拒绝,admin通配不能代替业务角色或绕过自审。
|
||||||
|
|
||||||
|
规范化保留失败/处理中别名冲突的否决线索,人工不得改为成功。只读复核发现原跨页重复可改参考后变成普通更正,已补原指纹重复硬阻断,增加改参考/付款账号不能掩盖重复的用例。原指纹返回仅供未来事务内跨批占用检查,不是公开API字段;规范化账号仍限内部加密存储。
|
||||||
|
|
||||||
|
验证:8文件联合362/362通过;修复复核finding后两文件79/79(manual39、matching40),其余输入未变,不宣称365同轮。运行server下npm test -- tests/unit/services/paymentReceiptManualReview.test.js tests/unit/services/paymentReceiptMatching.test.js;联合付款回归使用已登记PAYMENT_ORDER_PYTHON_BIN。RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs的13项通过并清理完成,无生产/腾讯调用。node --check、git diff --check、Trellis治理通过。数据库检查仍针对既有OCR/claim服务,不冒充人工提案落库测试。
|
||||||
|
|
||||||
|
独立只读复核补记:本轮manual helper、状态别名否决和对应测试在修复后复核通过,无新增finding。reviewer报告79项内存断言通过,非Vitest,不与主执行79/79累加;未审整个OCR增量/HTTP/事务接线,不以本次局部复核抵扣最终全量复核。
|
||||||
|
|
||||||
|
未完成:人工提案持久化/版本与SHA/audit事务、跨批原指纹检查、受控接口及前端、execute同事务接线、完整HTTP和双端验收、整个增量独立复核、授权发布。当前不改变线上付款流程、不重开已付款、不提交/推送/部署。下一步按上述顺序接人工提案与复核事务,再接登记门禁,不单独部署此内部helper。
|
||||||
|
|
||||||
|
## 2026-09-14 上传入队、调度与重试接入
|
||||||
|
|
||||||
|
已按批准接入新上传receipt_ocr_state=queued(与附件/审计同事务),既有附件仍legacy不自动外发。cron及pgboss均唤醒共同DB领取器;pgboss独立payment.receipt.ocr队列、retryLimit=0,不借发票落库链。上传/重试接口不在HTTP请求内调用腾讯,失败不反复外发;off/mock配置禁止回退真实腾讯调用。
|
||||||
|
|
||||||
|
重试POST receipt-attachments/:attachmentId/ocr-retry要求财务管理权限、功能/来源部门、可编辑批次、有效未作废回单及expectedVersion。queued/matched不重排,processing满15分钟才允许显式重试,失败/待核验/legacy可重试;同事务递增版本及写重试审计,双击/并发旧版本409。前端从服务端接收资格布尔值和状态/版本,桌面手机保留原布局,展示识别状态和“重新识别”,queued/processing按5秒刷新。不返回OCR明文;重试不上传新文件、不登记付款。
|
||||||
|
|
||||||
|
验证:7文件联合324/324(付款路由91、verification19、matching40、job8、OCR7、pgboss3、错误原因156);之后补off/mock禁止真实外调两项,OCR文件9/9,去重总326,不宣称326同轮。Prisma schema前轮已验证且本轮未改;typecheck/build/PWA、错误原因strict、process baseline、Trellis和diff通过。13项一次性Postgres检查通过,含并发重试仅一个版本成功;容器/tmpfs清理完成。本轮无生产访问或真实OCR外调。
|
||||||
|
|
||||||
|
双端页面:初次dev服务1440用例在login-identifier出现前超时、390通过,不算该轮全绿;使用本轮已通过build的preview,不改断言/超时,1440/390同轮2/2通过、零自动重试。证明识别失败→重试→queued、按钮消失,重试一次且付款登记/上传请求零次;API mock,不冒充真实认证/后端整链。两组证据分别为artifacts/qa-layout/receipt-ocr-retry-ui与receipt-ocr-retry-built-ui。首轮付款测试缺BIFF8解释器导致两例409,恢复已登记解释器配置后原84与新增7路由全部通过;新增重试测试首轮漏认证头,补合成认证头而非放宽鉴权。
|
||||||
|
|
||||||
|
仍未交付:人工更正/消歧及独立例外、execute接核验claim同事务、核验结果详情与完整HTTP/双端闭环、独立只读复核和授权部署。因此不把当前匹配文案或已接队列当作付款核验门禁已经启用。下一步重点完成异常处理与登记门禁后一次交付,未提交/推送/部署。
|
||||||
|
|
||||||
|
## 2026-09-13 实施第三段:后台单次识别执行器
|
||||||
|
|
||||||
|
新增claimReceiptJob/runReceiptOcrOnce,仅处理显式queued、未作废且治理approved、批次reviewed/failed的回单。锁序为批次→附件,SKIP LOCKED使两个worker只能一个取得任务;领取时版本递增,旧匹配立即失效。真实文件读取前限制大小,完整性检查失败不向腾讯发送;无自动重试,返回只含安全状态。结果落库复用批次/附件/版本守卫,晚到结果discarded,持久化异常保留persistence_failed而不伪报OCR成功。
|
||||||
|
|
||||||
|
迁移新增receipt_ocr_state默认legacy及started_at:既有附件不会自动被外发。新上传入队、cron/pgboss调度、停机超时后的人工重试及页面尚未接入,当前executor未自动启动;processing失败不能静默重传,后续提供显式重试并递增版本。未改变生产或实际上传行为。
|
||||||
|
|
||||||
|
验证:四文件同轮69/69单测通过;Prisma generate/validate通过。一次性Postgres全迁移及12项检查通过,新增并发worker单次领取/旧证据失效,以及文件错误零外调且失败状态真实持久化;容器/tmpfs已清理。执行命令为server下npm test -- tests/unit/services/paymentReceiptMatching.test.js tests/unit/services/paymentReceiptOcr.test.js tests/unit/services/paymentReceiptVerification.test.js tests/unit/services/paymentReceiptOcrJob.test.js,以及RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs。非HTTP/浏览器验收,无腾讯/生产调用,未独立复核。
|
||||||
|
|
||||||
|
下一步仍需调度/上传入队和重试、人工核验、execute API接线、错误解释与双端页面,再完整验收;69/12不替代这些未执行项。产品入口不得在人工异常处置未完整时先发布强门禁。当前未提交/部署,用户已批准同范围不重复索取实施授权。
|
||||||
|
|
||||||
|
## 2026-09-13 实施第二段:持久化与事务占用守卫
|
||||||
|
|
||||||
|
新增三张核验/页匹配/交易占用表、附件OCR版本列和附加迁移;规范化白名单字段加密存储、无原始响应。storeReceiptRecognition在批次/附件锁内检查版本与SHA;claimVerifiedReceipts在调用方事务内重验附件治理、来源版本、完整页数、冻结金额/账号与持久化匹配指纹,跨批/并发重复用advisory锁和数据库唯一约束共同防护。key ID变化阻断,不允许轮换加密密钥绕过去重;后续密钥迁移另审。
|
||||||
|
|
||||||
|
最终63项单测(matching40、OCR7、verification16)同轮通过,之前47/62为子集不累加。Prisma generate/validate及服务语法通过;生成使用现有链接node_modules,不修改其他工作树源码。RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs在一次性Postgres16中重放全部迁移,10项检查通过:迁移、事务回滚、幂等、跨批重复、并发重复、作废、晚到作业、SHA变化、版本失效、数据库唯一约束。退出后容器/tmpfs清理完成,无生产访问/云调用/真实文件,非HTTP测试。
|
||||||
|
|
||||||
|
中间失败保留事实:首轮数据库失败只有通用错误,补安全code/行号后定位Prisma P2010;直接选择pg_advisory_xact_lock的void返回不兼容,将结果投影为整数后数据库通过。未取消锁、增超时或降低断言。随后补版本与过期结果守卫后按最终输入再次跑完整10项,最终结果才作为当前验收。
|
||||||
|
|
||||||
|
仍待:队列/上传实际接入、权限受控人工核验与独立例外、execute路由同事务调用、错误原因客户端映射、页面、HTTP/双端完整场景与独立只读复核。当前新守卫是未接入产品入口的服务,不能声称线上已启用;未提交/推送/生产迁移/部署,不归档任务。下一步从队列版本化调度和路由接线继续,不重复当前测试通过计数。
|
||||||
|
|
||||||
|
## 2026-09-13 实施第一段:OCR适配与纯匹配服务
|
||||||
|
|
||||||
|
用户授权继续完成,沿当前09-08任务实施,无生产访问/外调/写入。本段新增paymentReceiptMatching与paymentReceiptOcr及对应两个单测文件;既有09-04发布记录、根checkout治理改动保留,不混提交。
|
||||||
|
|
||||||
|
- 字段别名覆盖真实样本返回的“小写金额/币别/付款人账号/收款人账号/凭证号/单据状态”;测试仅用合成字段值。金额严格千分位和两位小数,BigInt最小单位字符串比较,不使用parseFloat或去掉所有非数字来猜值;账号保留前导零,掩码不作完整账号匹配。
|
||||||
|
- 别名冲突、重复字段(可能页内多笔)、缺失或歧义保持待核验;同人同额候选不自动选第一条、不比较批次合计。受理/处理中/状态不明不能被认作成功;显式失败/撤销/冲正/退汇返回不匹配。
|
||||||
|
- 专用BankSlipOCR适配只在调用时创建客户端,默认有界30秒请求、不自动重试;PDF逐页处理并记录预期/实际页数,后页失败保留失败状态;5MiB原件/7MiB编码/20页为当前技术安全上限,超限进入待核验而非拒收附件。WEBP在内存转换PNG,校验实际格式及像素/多帧限制,不暴露URL或保存原始响应。
|
||||||
|
- 纯服务返回的规范化账号/交易参考仅为内部敏感输入,禁止直接作为API响应或日志;匹配结果仅包含状态/候选ID/原因。matched不表示银行验真,也未包含跨批数据库去重、源单复核或交易占用,不可直接替换execute门禁。
|
||||||
|
|
||||||
|
验证:首次新测试因模块尚不存在而收集失败(不是40项断言已跑红);实现后matching 40/40,适配加入后两个文件同轮47/47。命令为server下npm test -- tests/unit/services/paymentReceiptMatching.test.js tests/unit/services/paymentReceiptOcr.test.js;两个服务node --check和git diff --check通过。SDK加载产生既有punycode弃用警告,不影响通过;本轮全mock客户端,没有追加真实腾讯调用。
|
||||||
|
|
||||||
|
尚未完成:持久化schema/迁移和加密/交易去重约束、后台作业及晚到取消、权限受控人工核验、execute事务门禁、页面、隔离数据库/HTTP/浏览器整链及独立复核。当前产品入口尚未接入新服务,没有给线上系统宣称已核回单。下一步从核验持久化/事务合同接续,不重复请求已批准同范围授权;提交/推送/生产迁移/部署仍另批。
|
||||||
|
|
||||||
|
## 2026-09-13 权限调整后真实回单OCR复验
|
||||||
|
|
||||||
|
用户告知已添加权限并明确要求重试。对同一限定单页回单执行两次BankSlipOCR:13:32:13Z接口成功,返回17字段,但初版探针仅按“交易金额”读取而未取得金额,故当次金额核对为null,不能算失败金额或全量识别失败;13:32:53Z仅扩展探针金额别名和安全字段名诊断后,识别“小写金额”成功,回单/冻结付款明细/源报销单金额三者一致。返回字段包含付款人账号、收款人账号、币别、凭证号、单据状态等;本次未比较账号值、未解释状态值、未核凭证去重或银行验真,不能把字段名称存在当成内容已核对。
|
||||||
|
|
||||||
|
两次各1请求,无自动重试,均仅内存读取原件/响应,不保存字段值或原始响应、不改付款记录;旧授权错误回执保留。证据为receipt-ocr-retry-result.json、receipt-ocr-field-diagnostic.json。BankSlipOCR实际字段有版式差异,正式适配须覆盖别名并保留未知/歧义待核验,不能使用初版fields白名单布尔值判定回单缺字段。本轮仅诊断脚本调整,不是产品接入或部署。
|
||||||
|
|
||||||
|
用户扩大了云权限,成功只说明当前调用获准,不证明新增权限全部必要。建议管理员保留既有发票功能所需权限和本次BankSlipOCR最小授权,移除无关新增权限后再验证;执行端未读取或修改CAM策略。下一步实施字段规范化/交易匹配与安全登记门禁,已有paid历史不重开。
|
||||||
|
|
||||||
|
## 2026-09-13 获批真实回单OCR试验:授权门阻断
|
||||||
|
|
||||||
|
用户明确授权读取刚上传的目标付款回单并发送腾讯BankSlipOCR,仅反馈字段/金额匹配,不展示账号/正文、不修改付款记录。沿此前已核单号/批次限定唯一明细与唯一未归档且治理approved的回单;事务RepeatableRead READ ONLY、10秒语句超时、ROLLBACK。真实文件仅在生产进程内存读取,核大小/SHA/PDF签名和单页,未下载到开发机、未保存原始响应。
|
||||||
|
|
||||||
|
12:02:14Z生产229867f5回读batch=executed、报销=paid且paid_at非空,批次明细金额与源报销金额一致;仅此目标单据,不代表三张重提单已独立核验。回单1页且完整性检查通过,已有腾讯凭据和SDK具备BankSlipOCR方法。
|
||||||
|
|
||||||
|
调用BankSlipOCR一次,返回AuthFailure.UnauthorizedOperation,试验PAUSED,未取得识别结果、未核回单金额。不能将凭据存在/发票OCR可用解释为回单API已获授权;不自行放宽CAM策略、不换凭据或回退调用其他OCR绕过拒绝,也不重复重试。
|
||||||
|
|
||||||
|
待腾讯云管理员核查当前调用身份对ocr:BankSlipOCR的最小调用权限及该服务开通状态;本轮只证明服务返回授权错误,不断言是欠费、未开通或某条CAM规则的具体原因。修复后可按同一限定样本复验,不改OA付款流程或历史paid状态。执行脚本try-receipt-ocr.py及receipt-ocr-result.json在既有finance-owner-release证据目录,Python/内嵌Node语法检查通过。没有生产业务写入、部署、云权限写入或自动补审计事件。
|
||||||
|
|
||||||
|
## 2026-09-13 回单自动核对接续
|
||||||
|
|
||||||
|
用户采纳“自动识别与匹配、正常不加负担、异常待核验”的方案。本轮复用09-08更新PRD/design/实施清单,官方current已切换并回读;保留09-04未提交发布记录及根checkout其他改动,不混入当前任务。当前只完成规划与接口调查,运行时代码、迁移、测试和部署尚未实施。
|
||||||
|
|
||||||
|
- [x] 确认当前上传只保存附件及审计、登记要求有效回单存在并比较源单/批次金额;没有回单金额解析或匹配。
|
||||||
|
- [x] 固定已批准范围、异常保存/阻断边界、腾讯专用识别与真实数据外发授权边界;明确不以OCR代替验真。
|
||||||
|
- [ ] 细化核验数据结构/索引、页级作业幂等、敏感字段加密和交易去重、人工核验资格;先审既有队列/付款事务/加密合同,不复用发票业务写入路径。
|
||||||
|
- [ ] 先写金额、账户、币种、交易状态、重复、同额歧义、多页漏页及人工来源的失败测试,再实现纯规范化/匹配服务。
|
||||||
|
- [ ] 接入专用Tencent/mock适配与隔离作业、附加迁移和权限受控核验接口,处理作废/重试/晚到作业及并发占用。
|
||||||
|
- [ ] execute增加服务端核验门禁;覆盖非本人独立复核、普通理由不能绕过、历史已付款不改及旧未登记批次不豁免。
|
||||||
|
- [ ] 现有付款详情显示处理结果与异常入口,正常不多确认;仅维护已有样式,桌面/手机及批次多笔回归。
|
||||||
|
- [ ] 本地单测、一次性Postgres/HTTP/浏览器全链与独立只读review;真实OCR与合成结果分别报告。
|
||||||
|
- [ ] 更新业务事实源与稳定spec,冻结审核输入;提交/推送/生产迁移/部署另批。真实回单外调及实际付款不在本轮自动操作范围。
|
||||||
|
|
||||||
|
下一步是数据/匹配合同与失败测试,不继续调整制单备注、不新建任务、不重跑或重复计数上轮519项单测。现有09-04发布验收不作为新回单核验通过证据。
|
||||||
|
|
||||||
## 本轮复核输入与提交准备(未提交)
|
## 本轮复核输入与提交准备(未提交)
|
||||||
|
|
||||||
用户批准整理两笔独立改动的复核/提交方案。基线均为5d8cdf12;01为制单备注5文件,02为UI布局11文件,路径无重叠。仅生成补丁、摘要清单并用临时索引验证顺序合并,不改变实际HEAD/index,不创建commit,不推送或部署。复核入口位于layout副本的artifacts/qa-layout/review-preparation/README.md;临时合并树不是发布候选commit。既有验证按输入不变复用,本轮不新增测试通过计数;UI补跑外层143、网银导入未验证、未独立复核等边界保留。提交前须核对冻结摘要,发布前还需验证合并后的实际候选,不能把单包测试冒充集成验收。
|
用户批准整理两笔独立改动的复核/提交方案。基线均为5d8cdf12;01为制单备注5文件,02为UI布局11文件,路径无重叠。仅生成补丁、摘要清单并用临时索引验证顺序合并,不改变实际HEAD/index,不创建commit,不推送或部署。复核入口位于layout副本的artifacts/qa-layout/review-preparation/README.md;临时合并树不是发布候选commit。既有验证按输入不变复用,本轮不新增测试通过计数;UI补跑外层143、网银导入未验证、未独立复核等边界保留。提交前须核对冻结摘要,发布前还需验证合并后的实际候选,不能把单包测试冒充集成验收。
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# 报销付款与银行制单业务闭环
|
# 报销付款与银行制单业务闭环
|
||||||
|
|
||||||
|
## 2026-09-13 银行回单自动核对(本地实施及定向验证完成,待整批独立复核与发布)
|
||||||
|
|
||||||
|
用户反馈已完成一次付款及回单上传,暂未发现问题,三张退回报销已重提;此为业务反馈,尚未独立回读生产付款登记状态。既有回单仅附件安全/存在性检查,付款登记比较报销金额与冻结批次明细金额,不核验回单内交易。本增量复用本任务,不重开任务、不追改已付款历史。
|
||||||
|
|
||||||
|
目标:财务上传建行回单,系统自动识别并匹配对应付款明细;正常不重复填写/关联/审批,异常才处理。一个报销单对应一笔转账,批次可多单,PDF可多页多笔。回单识别/匹配不等于银行验真或到账证明,不做银企直连、其他银行适配、部分付款、退款。
|
||||||
|
|
||||||
|
验收要求:
|
||||||
|
- 文件通过既有安全检查后保存;内容不匹配或OCR失败不拒收原件、不自动登记付款。作废/隔离回单不得作为有效付款证据。
|
||||||
|
- 自动核对每笔交易本金及币种、付款与收款账户,对照组批冻结信息并保留原报销金额一致性守卫;手续费不计入本金。不得只比批次总额或姓名/账号尾号。
|
||||||
|
- 成功回单、受理凭证、失败记录分开;状态不明不能推断成功。流水号等交易身份用于重复使用检测,文件哈希仅辅助。
|
||||||
|
- 报销编号/日期仅辅助匹配;同人同金额多笔不猜关联,PDF任何页未处理不得宣称整个文件核验完成。
|
||||||
|
- 信息唯一匹配后显示“回单信息匹配”,保留财务付款登记动作,不自动改paid、不增加日常二次审批。
|
||||||
|
- 识别不清/缺字段/匹配不唯一为待核验;有权人员可查看原件、修正识别字段或选择明细,保留原识别结果、修改来源与审计。不能把人工更正伪装成自动通过。
|
||||||
|
- 确认金额/账户不符、失败交易或重复使用必须阻止对应登记;普通理由不可绕过。OCR不可用的人工例外须有记录的独立复核,不能默认放行或借admin通配自审。
|
||||||
|
- 本地合成测试覆盖正常、跨批重复、错金额/币种/账户、受理/失败、缺字段、同额歧义、多页漏页、作废、OCR失败及并发登记;执行端承担软件回归。
|
||||||
|
|
||||||
|
生产迁移、真实回单读取及发送腾讯、生产配置、部署和真实付款另行授权。已有发票OCR授权不自动涵盖银行回单,原件/账号/原始OCR响应不进聊天或测试夹具。
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
### 2026-09-12 制单备注与编号解释(自解释方案已批准,增量未部署)
|
### 2026-09-12 制单备注与编号解释(自解释方案已批准,增量未部署)
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export const ERROR_REASON_MESSAGES: Record<string, string> = {
|
|||||||
"payment_batch.invalid_identifier": "付款记录标识已失效,请刷新后重新选择。",
|
"payment_batch.invalid_identifier": "付款记录标识已失效,请刷新后重新选择。",
|
||||||
"payment_batch.invalid_input": "付款操作内容不完整或格式不正确,请核对页面必填项后重试。",
|
"payment_batch.invalid_input": "付款操作内容不完整或格式不正确,请核对页面必填项后重试。",
|
||||||
"payment_batch.bank_receipt_required": "银行付款登记前必须上传有效银行回单,请先补充回单。",
|
"payment_batch.bank_receipt_required": "银行付款登记前必须上传有效银行回单,请先补充回单。",
|
||||||
|
"payment_batch.receipt_retry_unavailable": "回单状态已变化或仍在识别,请刷新后查看;处理中超过15分钟仍未完成时可重试。",
|
||||||
|
"payment_batch.receipt_independent_review_required": "回单人工核验需要独立审批人复核;制单人、核验人及报销归属人或提交人不能自审。",
|
||||||
|
"payment_batch.receipt_transaction_reused": "回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。",
|
||||||
|
"payment_batch.receipt_manual_state_changed": "回单或核验版本已变化,请刷新后重新查看当前状态。",
|
||||||
|
"payment_batch.receipt_manual_verification_required": "回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。",
|
||||||
|
"payment_batch.receipt_key_unavailable": "回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。",
|
||||||
|
"payment_batch.receipt_key_rotation_required": "回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。",
|
||||||
|
"payment_batch.receipt_snapshot_unsupported": "付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。",
|
||||||
|
"payment_batch.receipt_identity_unresolved": "无法完整确认报销参与人身份,请管理员核实人员关系后再复核;不能自行批准。",
|
||||||
"payment_batch.department_unavailable": "当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。",
|
"payment_batch.department_unavailable": "当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。",
|
||||||
"payment_batch.source_not_found": "部分来源单据已不存在或不可用,请刷新后重新选择。",
|
"payment_batch.source_not_found": "部分来源单据已不存在或不可用,请刷新后重新选择。",
|
||||||
"payment_batch.daily_sequence_exhausted": "今日付款批次编号已用完,请联系管理员处理,勿重复提交。",
|
"payment_batch.daily_sequence_exhausted": "今日付款批次编号已用完,请联系管理员处理,勿重复提交。",
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ export type PaymentBatchCandidatesResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PaymentBatchReceiptAttachment = {
|
export type PaymentBatchReceiptAttachment = {
|
||||||
|
ocrState?: "legacy" | "queued" | "processing" | "ocr_failed" | "needs_review" | "matched"
|
||||||
|
| "pending_review" | "association_ready" | "manual_approved" | "manual_rejected"
|
||||||
|
| "system_failed" | "key_failed" | "snapshot_failed";
|
||||||
|
ocrVersion?: number;
|
||||||
|
ocrStartedAt?: string | null;
|
||||||
|
ocrRetryAvailable?: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
@@ -276,3 +282,65 @@ export async function voidPaymentBatchReceiptAttachment(batchId: string, attachm
|
|||||||
{ method: "POST", body: JSON.stringify({ reason }) },
|
{ method: "POST", body: JSON.stringify({ reason }) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function retryPaymentBatchReceiptOcr(batchId: string, attachmentId: string, expectedVersion: number) {
|
||||||
|
return apiRequestData<{ item: { id: string; ocrState: string; ocrVersion: number } }>(
|
||||||
|
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/ocr-retry`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ expectedVersion }) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReceiptVerificationPage = {
|
||||||
|
pageNumber: number;
|
||||||
|
amount: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
payerAccountMasked: string | null;
|
||||||
|
payeeAccountMasked: string | null;
|
||||||
|
transactionStatus: string | null;
|
||||||
|
reference: string | null;
|
||||||
|
itemId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReceiptVerificationDetail = {
|
||||||
|
canReview?: boolean;
|
||||||
|
version: number;
|
||||||
|
state: string;
|
||||||
|
pagesExpected: number;
|
||||||
|
pages: ReceiptVerificationPage[];
|
||||||
|
proposal: null | {
|
||||||
|
id: string; status: string; proposedBy: string; reviewedBy: string | null; reason: string;
|
||||||
|
changedFields: { pageNumber: number; fields: string[] }[];
|
||||||
|
pages: ReceiptVerificationPage[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchReceiptVerification(batchId: string, attachmentId: string, signal?: AbortSignal) {
|
||||||
|
return apiRequestData<{ item: ReceiptVerificationDetail }>(
|
||||||
|
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/manual-verification`, { signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchReceiptReadiness(batchId: string, signal?: AbortSignal) {
|
||||||
|
return apiRequestData<{ item: { ready: boolean } }>(
|
||||||
|
`/api/payment-batches/${batchId}/receipt-readiness`, { signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveReceiptVerification(batchId: string, attachmentId: string, body: {
|
||||||
|
expectedVersion: number; reason?: string;
|
||||||
|
pages: { pageNumber: number; itemId: string; fields?: { Name: string; Value: string }[] }[];
|
||||||
|
}) {
|
||||||
|
return apiRequestData<{ item: { id: string; status: string; version: number } }>(
|
||||||
|
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/manual-verification`,
|
||||||
|
{ method: "POST", body: JSON.stringify(body) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewReceiptVerification(batchId: string, attachmentId: string, proposalId: string, body: {
|
||||||
|
expectedVersion: number; decision: "approve" | "reject"; reason: string;
|
||||||
|
}) {
|
||||||
|
return apiRequestData<{ item: { id: string; status: string; version: number } }>(
|
||||||
|
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/manual-verification/${proposalId}/review`,
|
||||||
|
{ method: "POST", body: JSON.stringify(body) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
fetchReceiptVerification, saveReceiptVerification, reviewReceiptVerification,
|
||||||
|
type ReceiptVerificationPage,
|
||||||
|
} from "../../api/paymentBatches";
|
||||||
|
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
|
||||||
|
const FIELD_NAMES = ["小写金额", "币别", "付款人账号", "收款人账号", "凭证号", "单据状态"] as const;
|
||||||
|
const CHANGE_LABELS: Record<string, string> = {
|
||||||
|
amount: "金额", currency: "币种", payerAccount: "付款账号", payeeAccount: "收款账号",
|
||||||
|
reference: "交易参考", transactionStatus: "交易状态",
|
||||||
|
};
|
||||||
|
|
||||||
|
function PageFacts({ page }: { page?: ReceiptVerificationPage }) {
|
||||||
|
return <div className="space-y-1 break-all text-sm text-zinc-600">
|
||||||
|
<div>金额:{page?.amount ?? "未识别"} · 币种:{page?.currency ?? "未识别"}</div>
|
||||||
|
<div>付款账号:{page?.payerAccountMasked ?? "未识别"}</div>
|
||||||
|
<div>收款账号:{page?.payeeAccountMasked ?? "未识别"}</div>
|
||||||
|
<div>交易参考:{page?.reference ?? "未识别"}</div>
|
||||||
|
<div>状态:{{ success: "成功", failed: "失败", pending: "受理或处理中" }[page?.transactionStatus ?? ""] ?? "未确认"}</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
actorId: string; batchId: string; attachmentId: string; makerId: string | null;
|
||||||
|
canManage: boolean; canReview: boolean;
|
||||||
|
items: { id: string; label: string }[];
|
||||||
|
onClose: () => void; onChanged: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentReceiptVerificationDialog(props: Props) {
|
||||||
|
const mounted = useRef(true);
|
||||||
|
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||||
|
const [selections, setSelections] = useState<Record<number, string>>({});
|
||||||
|
const [editing, setEditing] = useState<Record<number, boolean>>({});
|
||||||
|
const [changes, setChanges] = useState<Record<number, Record<string, string>>>({});
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["receipt-verification", props.actorId, props.batchId, props.attachmentId],
|
||||||
|
queryFn: ({ signal }) => fetchReceiptVerification(props.batchId, props.attachmentId, signal),
|
||||||
|
enabled: Boolean(props.actorId), staleTime: 0, gcTime: 0,
|
||||||
|
refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false,
|
||||||
|
});
|
||||||
|
const detail = query.data?.item;
|
||||||
|
const changed = () => {
|
||||||
|
if (!mounted.current) return;
|
||||||
|
props.onChanged();
|
||||||
|
props.onClose();
|
||||||
|
};
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => saveReceiptVerification(props.batchId, props.attachmentId, {
|
||||||
|
expectedVersion: detail!.version, ...(reason.trim() ? { reason: reason.trim() } : {}),
|
||||||
|
pages: Array.from({ length: detail!.pagesExpected }, (_unused, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
const fields = Object.entries(changes[pageNumber] ?? {}).filter(([, value]) => value.trim())
|
||||||
|
.map(([Name, Value]) => ({ Name, Value }));
|
||||||
|
return { pageNumber, itemId: selections[pageNumber] ?? "", ...(fields.length ? { fields } : {}) };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
onSuccess: changed,
|
||||||
|
});
|
||||||
|
const review = useMutation({
|
||||||
|
mutationFn: (decision: "approve" | "reject") => reviewReceiptVerification(
|
||||||
|
props.batchId, props.attachmentId, detail!.proposal!.id,
|
||||||
|
{ expectedVersion: detail!.version, decision, reason: reason.trim() },
|
||||||
|
),
|
||||||
|
onSuccess: changed,
|
||||||
|
});
|
||||||
|
const busy = save.isPending || review.isPending;
|
||||||
|
const pageCount = detail && Number.isInteger(detail.pagesExpected) && detail.pagesExpected > 0 &&
|
||||||
|
detail.pagesExpected <= 20 ? detail.pagesExpected : 0;
|
||||||
|
const editable = props.canManage && detail && ["needs_review", "ocr_failed"].includes(detail.state) && !query.isError;
|
||||||
|
const reviewable = props.canReview && detail?.canReview === true && detail?.proposal?.status === "pending_review" &&
|
||||||
|
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 hasCorrections = Object.values(changes).some((fields) => Object.values(fields).some((value) => value.trim()));
|
||||||
|
const error = query.error ?? save.error ?? review.error;
|
||||||
|
return <Dialog open onOpenChange={(open) => { if (!open && !busy) props.onClose(); }}>
|
||||||
|
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>回单核验</DialogTitle>
|
||||||
|
<DialogDescription>核对原回单与付款明细。账号仅显示掩码;需要核对完整内容时请使用回单“在线查看”。核验不代表银行验真,也不会自动登记付款。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{query.isPending ? <p role="status">正在加载核验信息…</p> : null}
|
||||||
|
{error ? <div role="alert" className="space-y-2 text-sm text-red-600">
|
||||||
|
<p>{resolveErrorMessage(error)}</p>
|
||||||
|
<Button variant="secondary" disabled={busy} onClick={() => {
|
||||||
|
setSelections({}); setChanges({}); setEditing({}); setReason(""); save.reset(); review.reset();
|
||||||
|
void query.refetch();
|
||||||
|
}}>刷新核验信息</Button>
|
||||||
|
</div> : null}
|
||||||
|
{detail && !pageCount ? <p role="status">尚无完整页数,请先重新识别或上传可读取的原回单。</p> : null}
|
||||||
|
{Array.from({ length: pageCount }, (_unused, index) => index + 1).map((pageNumber) => {
|
||||||
|
const original = detail?.pages.find((page) => page.pageNumber === pageNumber);
|
||||||
|
const proposed = detail?.proposal?.pages.find((page) => page.pageNumber === pageNumber);
|
||||||
|
return <section key={pageNumber} className="space-y-3 rounded-lg border p-3">
|
||||||
|
<h3 className="font-medium">第{pageNumber}页 · 原识别结果</h3>
|
||||||
|
<PageFacts page={original} />
|
||||||
|
{proposed ? <div className="space-y-2 border-t pt-3">
|
||||||
|
<h4 className="font-medium">人工核验结果</h4>
|
||||||
|
<PageFacts page={proposed} />
|
||||||
|
<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>
|
||||||
|
</div> : null}
|
||||||
|
{editable ? <>
|
||||||
|
<label className="block space-y-1 text-sm">
|
||||||
|
<span>第{pageNumber}页对应付款明细</span>
|
||||||
|
<select className="min-h-11 w-full rounded-md border bg-white px-3" disabled={busy}
|
||||||
|
value={selections[pageNumber] ?? ""} onChange={(event) =>
|
||||||
|
setSelections((current) => ({ ...current, [pageNumber]: event.target.value }))}>
|
||||||
|
<option value="">请选择对应明细</option>
|
||||||
|
{props.items.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Button type="button" variant="secondary" disabled={busy} onClick={() => {
|
||||||
|
setEditing((current) => ({ ...current, [pageNumber]: !current[pageNumber] }));
|
||||||
|
if (editing[pageNumber]) setChanges((current) => ({ ...current, [pageNumber]: {} }));
|
||||||
|
}}>{editing[pageNumber] ? "取消本页更正" : "更正识别"}</Button>
|
||||||
|
{editing[pageNumber] ? <div className="space-y-3">
|
||||||
|
<p className="text-xs text-zinc-500">只填写要更正的字段,其他字段保留原识别值。更正后需要独立复核。</p>
|
||||||
|
{FIELD_NAMES.map((name) => <label key={name} className="block space-y-1 text-sm">
|
||||||
|
<span>第{pageNumber}页更正{name}</span>
|
||||||
|
{name === "单据状态" ? <select className="min-h-11 w-full rounded-md border bg-white px-3"
|
||||||
|
disabled={busy} value={changes[pageNumber]?.[name] ?? ""} onChange={(event) =>
|
||||||
|
setChanges((current) => ({ ...current, [pageNumber]: { ...current[pageNumber], [name]: event.target.value } }))}>
|
||||||
|
<option value="">不更正</option><option value="交易成功">原件明确显示交易成功</option>
|
||||||
|
</select> : <Input autoComplete="off" disabled={busy} maxLength={256}
|
||||||
|
placeholder="不更正则留空" value={changes[pageNumber]?.[name] ?? ""}
|
||||||
|
onChange={(event) => setChanges((current) => ({
|
||||||
|
...current, [pageNumber]: { ...current[pageNumber], [name]: event.target.value },
|
||||||
|
}))} />}
|
||||||
|
</label>)}
|
||||||
|
</div> : null}
|
||||||
|
</> : null}
|
||||||
|
</section>;
|
||||||
|
})}
|
||||||
|
{detail?.proposal ? <p className="whitespace-pre-wrap break-words text-sm">核验说明:{detail.proposal.reason}</p> : null}
|
||||||
|
{detail?.proposal?.status === "pending_review" && !reviewable ?
|
||||||
|
<p role="status" className="text-sm text-zinc-600">等待有资格的独立审批人复核;经办人和报销参与人不能自审。</p> : null}
|
||||||
|
{editable || reviewable ? <label className="block space-y-1 text-sm">
|
||||||
|
<span>{reviewable ? "复核说明" : "更正说明(仅关联可留空)"}</span>
|
||||||
|
<Textarea value={reason} maxLength={500} disabled={busy} onChange={(event) => setReason(event.target.value)} />
|
||||||
|
</label> : null}
|
||||||
|
<DialogFooter className="flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" disabled={busy} onClick={props.onClose}>关闭</Button>
|
||||||
|
{editable ? <Button disabled={busy || query.isFetching || !complete || (hasCorrections && !reason.trim())}
|
||||||
|
onClick={() => save.mutate()}>{save.isPending ? "保存中…" : "保存核验"}</Button> : null}
|
||||||
|
{reviewable ? <>
|
||||||
|
<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")}>确认复核</Button>
|
||||||
|
</> : null}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
updatePaymentBatchItem,
|
updatePaymentBatchItem,
|
||||||
uploadPaymentBatchReceiptAttachments,
|
uploadPaymentBatchReceiptAttachments,
|
||||||
voidPaymentBatchReceiptAttachment,
|
voidPaymentBatchReceiptAttachment,
|
||||||
|
retryPaymentBatchReceiptOcr,
|
||||||
|
fetchReceiptReadiness,
|
||||||
type PaymentBatchReceiptAttachment,
|
type PaymentBatchReceiptAttachment,
|
||||||
type PaymentChannel,
|
type PaymentChannel,
|
||||||
} from "../api/paymentBatches";
|
} from "../api/paymentBatches";
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
BusinessTable,
|
BusinessTable,
|
||||||
} from "../components/patterns/BusinessListTemplate";
|
} from "../components/patterns/BusinessListTemplate";
|
||||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||||
|
import { PaymentReceiptVerificationDialog } from "../components/common/PaymentReceiptVerificationDialog";
|
||||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
@@ -170,6 +173,9 @@ export function PaymentBatchesPage() {
|
|||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
const [actionReason, setActionReason] = useState("");
|
const [actionReason, setActionReason] = useState("");
|
||||||
|
const [verificationTarget, setVerificationTarget] = useState<{
|
||||||
|
actorId: string; batchId: string; attachmentId: string;
|
||||||
|
} | null>(null);
|
||||||
const [revealedPayeeAccount, setRevealedPayeeAccount] = useState("");
|
const [revealedPayeeAccount, setRevealedPayeeAccount] = useState("");
|
||||||
const [receiptPreview, setReceiptPreview] = useState<{
|
const [receiptPreview, setReceiptPreview] = useState<{
|
||||||
attachment: PaymentBatchReceiptAttachment;
|
attachment: PaymentBatchReceiptAttachment;
|
||||||
@@ -184,6 +190,7 @@ export function PaymentBatchesPage() {
|
|||||||
}, [receiptPreview?.objectUrl]);
|
}, [receiptPreview?.objectUrl]);
|
||||||
|
|
||||||
const selectedBatchId = searchParams.get("id") ?? "";
|
const selectedBatchId = searchParams.get("id") ?? "";
|
||||||
|
useEffect(() => { setVerificationTarget(null); }, [me?.id, selectedBatchId]);
|
||||||
const requestedView = searchParams.get("view");
|
const requestedView = searchParams.get("view");
|
||||||
const activeView = selectedBatchId
|
const activeView = selectedBatchId
|
||||||
? "detail"
|
? "detail"
|
||||||
@@ -254,7 +261,11 @@ export function PaymentBatchesPage() {
|
|||||||
const receiptAttachmentsQuery = useQuery({
|
const receiptAttachmentsQuery = useQuery({
|
||||||
queryKey: ["payment-batch-receipts", selectedBatch?.id],
|
queryKey: ["payment-batch-receipts", selectedBatch?.id],
|
||||||
enabled: Boolean(selectedBatch?.id),
|
enabled: Boolean(selectedBatch?.id),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnWindowFocus: "always",
|
||||||
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
|
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
|
||||||
|
refetchInterval: (query) => query.state.data?.items.some((item) =>
|
||||||
|
["queued", "processing"].includes(item.ocrState ?? "")) ? 5000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const canEditSelectedBatch =
|
const canEditSelectedBatch =
|
||||||
@@ -286,9 +297,17 @@ export function PaymentBatchesPage() {
|
|||||||
const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length;
|
const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length;
|
||||||
const needsBankReceipt =
|
const needsBankReceipt =
|
||||||
canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0;
|
canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0;
|
||||||
|
const receiptReadinessQuery = useQuery({
|
||||||
|
queryKey: ["payment-receipt-readiness", me?.id, selectedBatch?.id,
|
||||||
|
(receiptAttachmentsQuery.data?.items ?? []).map((item) => `${item.id}:${item.ocrVersion}:${item.ocrState}:${item.archivedAt}`).join("|")],
|
||||||
|
enabled: Boolean(me?.id && canExecuteSelectedBatch && requiresBankReceipt && activeReceiptCount > 0),
|
||||||
|
queryFn: ({ signal }) => fetchReceiptReadiness(selectedBatch!.id, signal),
|
||||||
|
staleTime: 0, gcTime: 0, retry: false, refetchOnWindowFocus: "always",
|
||||||
|
});
|
||||||
const canRegisterPaymentComplete =
|
const canRegisterPaymentComplete =
|
||||||
canExecuteSelectedBatch &&
|
canExecuteSelectedBatch &&
|
||||||
(!requiresBankReceipt || (receiptAttachmentsQuery.isSuccess && activeReceiptCount > 0));
|
(!requiresBankReceipt || (receiptAttachmentsQuery.isSuccess && activeReceiptCount > 0 &&
|
||||||
|
receiptReadinessQuery.isSuccess && !receiptReadinessQuery.isFetching && receiptReadinessQuery.data.item.ready));
|
||||||
const canCancelSelectedBatch =
|
const canCancelSelectedBatch =
|
||||||
canManageBatch &&
|
canManageBatch &&
|
||||||
!!selectedBatch &&
|
!!selectedBatch &&
|
||||||
@@ -495,6 +514,21 @@ export function PaymentBatchesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retryReceiptOcrMutation = useMutation({
|
||||||
|
mutationFn: ({ batchId, attachment }: { batchId: string; attachment: PaymentBatchReceiptAttachment }) =>
|
||||||
|
retryPaymentBatchReceiptOcr(batchId, attachment.id, attachment.ocrVersion ?? 0),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setError("");
|
||||||
|
await refresh();
|
||||||
|
toast.success("回单已加入识别队列");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message = resolveError(err);
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||||
|
|
||||||
const downloadReceiptMutation = useMutation({
|
const downloadReceiptMutation = useMutation({
|
||||||
@@ -959,7 +993,9 @@ export function PaymentBatchesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-zinc-900">银行回单</h3>
|
<h3 className="text-base font-semibold text-zinc-900">银行回单</h3>
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
{!requiresBankReceipt
|
{selectedBatch.status === "executed"
|
||||||
|
? "付款已登记完成,回单与核验记录作为凭据保留;核验不代表银行验真。"
|
||||||
|
: !requiresBankReceipt
|
||||||
? "当前付款渠道不要求银行回单,核对付款结果后可直接登记完成。"
|
? "当前付款渠道不要求银行回单,核对付款结果后可直接登记完成。"
|
||||||
: selectedBatch.status === "draft"
|
: selectedBatch.status === "draft"
|
||||||
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
|
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
|
||||||
@@ -994,6 +1030,16 @@ export function PaymentBatchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{canExecuteSelectedBatch && requiresBankReceipt && activeReceiptCount > 0 && !canRegisterPaymentComplete ? (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3 text-sm" role="status">
|
||||||
|
<p>{receiptReadinessQuery.isFetching ? "正在核对回单…" : receiptReadinessQuery.isError ?
|
||||||
|
resolveError(receiptReadinessQuery.error) : "回单尚未满足付款登记条件,请查看核验详情,完成识别、更正或独立复核。"}</p>
|
||||||
|
<Button type="button" size="sm" variant="secondary" disabled={receiptReadinessQuery.isFetching}
|
||||||
|
onClick={() => { void receiptReadinessQuery.refetch(); void receiptAttachmentsQuery.refetch(); }}>
|
||||||
|
重新核对回单
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{receiptAttachmentsQuery.isError ? (
|
{receiptAttachmentsQuery.isError ? (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
<span>{resolveError(receiptAttachmentsQuery.error)}</span>
|
<span>{resolveError(receiptAttachmentsQuery.error)}</span>
|
||||||
@@ -1018,8 +1064,26 @@ export function PaymentBatchesPage() {
|
|||||||
{attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""}
|
{attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""}
|
||||||
</div>
|
</div>
|
||||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||||
|
{attachment.ocrState && !attachment.archivedAt ? (
|
||||||
|
<div className="mt-1 text-xs text-zinc-500" role="status">
|
||||||
|
{{ legacy: "尚未识别", queued: "等待识别", processing: "正在识别",
|
||||||
|
system_failed: "核验结果保存失败,请联系管理员;不要重复识别或登记付款",
|
||||||
|
key_failed: "核验密钥不可用,请联系管理员修复;不要重复识别",
|
||||||
|
snapshot_failed: "付款业务或银行快照不支持,请联系管理员核实",
|
||||||
|
ocr_failed: "识别未完成,可重试", needs_review: "待核验", matched: "回单信息匹配,非银行验真",
|
||||||
|
pending_review: "人工核验待独立复核", association_ready: "明细已关联",
|
||||||
|
manual_approved: "人工核验已复核,非银行验真", manual_rejected: "人工核验未通过" }[attachment.ocrState]}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{!attachment.archivedAt && attachment.ocrState &&
|
||||||
|
!["queued", "processing", "system_failed", "key_failed", "snapshot_failed"].includes(attachment.ocrState) && me?.id ? (
|
||||||
|
<Button type="button" size="sm" variant="secondary" className="min-h-11"
|
||||||
|
onClick={() => setVerificationTarget({
|
||||||
|
actorId: me.id, batchId: selectedBatch.id, attachmentId: attachment.id,
|
||||||
|
})}>核验详情</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1040,6 +1104,13 @@ export function PaymentBatchesPage() {
|
|||||||
>
|
>
|
||||||
下载
|
下载
|
||||||
</Button>
|
</Button>
|
||||||
|
{canEditReceiptAttachments && attachment.ocrRetryAvailable ? (
|
||||||
|
<Button type="button" size="sm" variant="secondary" className="min-h-11"
|
||||||
|
disabled={retryReceiptOcrMutation.isPending}
|
||||||
|
onClick={() => retryReceiptOcrMutation.mutate({ batchId: selectedBatch.id, attachment })}>
|
||||||
|
重新识别
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{canEditReceiptAttachments ? (
|
{canEditReceiptAttachments ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1065,7 +1136,7 @@ export function PaymentBatchesPage() {
|
|||||||
))}
|
))}
|
||||||
{requiresBankReceipt && receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
|
{requiresBankReceipt && receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
|
||||||
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
|
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
|
||||||
暂无银行回单;银行转账必须先上传回单,才能登记付款完成。
|
暂无银行回单;银行转账必须先上传回单并完成核验,才能登记付款完成。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1374,6 +1445,18 @@ export function PaymentBatchesPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
{verificationTarget && me?.id === verificationTarget.actorId &&
|
||||||
|
selectedBatch?.id === verificationTarget.batchId ? (
|
||||||
|
<PaymentReceiptVerificationDialog
|
||||||
|
key={`${me.id}:${selectedBatch.id}:${verificationTarget.attachmentId}`}
|
||||||
|
actorId={me.id} batchId={selectedBatch.id} attachmentId={verificationTarget.attachmentId}
|
||||||
|
makerId={selectedBatch.createdBy ?? null}
|
||||||
|
canManage={Boolean(canEditReceiptAttachments)} canReview={Boolean(canReviewBatch)}
|
||||||
|
items={selectedBatch.items.map((item) => ({ id: item.id, label: `${item.entityCode} · ${formatMoney(item.amount)}` }))}
|
||||||
|
onClose={() => setVerificationTarget(null)}
|
||||||
|
onChanged={() => { void receiptAttachmentsQuery.refetch(); }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</ListWorkspace>
|
</ListWorkspace>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,6 +303,9 @@ async function mockSystemActionApis(
|
|||||||
batchStatus?: SyntheticPaymentBatchStatus;
|
batchStatus?: SyntheticPaymentBatchStatus;
|
||||||
hasReceipt?: boolean;
|
hasReceipt?: boolean;
|
||||||
receiptQueryError?: boolean;
|
receiptQueryError?: boolean;
|
||||||
|
receiptOcrState?: "ocr_failed" | "queued" | "system_failed" | "key_failed" | "snapshot_failed";
|
||||||
|
receiptManualReview?: boolean;
|
||||||
|
receiptReady?: boolean;
|
||||||
highRisk?: boolean;
|
highRisk?: boolean;
|
||||||
paymentChannel?: "bank" | "cash";
|
paymentChannel?: "bank" | "cash";
|
||||||
canExportPaymentOrder?: boolean;
|
canExportPaymentOrder?: boolean;
|
||||||
@@ -319,6 +322,7 @@ async function mockSystemActionApis(
|
|||||||
const currentMe = { ...me, ...options.meOverride };
|
const currentMe = { ...me, ...options.meOverride };
|
||||||
const batchStatus = options.batchStatus ?? "reviewed";
|
const batchStatus = options.batchStatus ?? "reviewed";
|
||||||
let hasReceipt = options.hasReceipt ?? true;
|
let hasReceipt = options.hasReceipt ?? true;
|
||||||
|
let receiptOcrState = options.receiptOcrState;
|
||||||
let paymentOrderExportCount = options.paymentOrderExportCount ?? 0;
|
let paymentOrderExportCount = options.paymentOrderExportCount ?? 0;
|
||||||
let currentReimbursementStatus = options.reimbursementStatus ?? "draft";
|
let currentReimbursementStatus = options.reimbursementStatus ?? "draft";
|
||||||
const calls = {
|
const calls = {
|
||||||
@@ -331,6 +335,9 @@ async function mockSystemActionApis(
|
|||||||
previewPaymentReceipt: 0,
|
previewPaymentReceipt: 0,
|
||||||
uploadPaymentReceipt: 0,
|
uploadPaymentReceipt: 0,
|
||||||
voidPaymentReceipt: 0,
|
voidPaymentReceipt: 0,
|
||||||
|
retryReceiptOcr: 0,
|
||||||
|
manualReceiptSaves: [] as Array<Record<string, unknown>>,
|
||||||
|
manualReceiptReviews: [] as Array<Record<string, unknown>>,
|
||||||
attachmentDeleteReasons: [] as string[],
|
attachmentDeleteReasons: [] as string[],
|
||||||
receiptVoidReasons: [] as string[],
|
receiptVoidReasons: [] as string[],
|
||||||
reimbursementUpdates: [] as Array<Record<string, unknown>>,
|
reimbursementUpdates: [] as Array<Record<string, unknown>>,
|
||||||
@@ -568,6 +575,39 @@ async function mockSystemActionApis(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === `/api/payment-batches/${paymentBatchId}/receipt-readiness` && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, { item: { ready: options.receiptReady ?? true } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const verificationPath = `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/manual-verification`;
|
||||||
|
if (path === verificationPath && request.method() === "GET") {
|
||||||
|
const facts = { pageNumber: 1, amount: "88.00", currency: "CNY", payerAccountMasked: "0000****2222",
|
||||||
|
payeeAccountMasked: "0000****4444", reference: "SYN-MANUAL-UI", transactionStatus: "success" };
|
||||||
|
await fulfillJson(route, { item: { version: 3, state: options.receiptManualReview ? "pending_review" : "needs_review",
|
||||||
|
canReview: Boolean(options.receiptManualReview), pagesExpected: 1, pages: [facts],
|
||||||
|
proposal: options.receiptManualReview ? { id: "synthetic-proposal", status: "pending_review",
|
||||||
|
proposedBy: "another-finance", reviewedBy: null, reason: "合成核验说明",
|
||||||
|
changedFields: [{ pageNumber: 1, fields: ["amount"] }],
|
||||||
|
pages: [{ ...facts, itemId: "payment-batch-item-action-confirm" }] } : null } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === verificationPath && request.method() === "POST") {
|
||||||
|
calls.manualReceiptSaves.push(request.postDataJSON());
|
||||||
|
await fulfillJson(route, { item: { id: "synthetic-proposal", version: 4, status: "pending_review" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `${verificationPath}/synthetic-proposal/review` && request.method() === "POST") {
|
||||||
|
calls.manualReceiptReviews.push(request.postDataJSON());
|
||||||
|
await fulfillJson(route, { item: { id: "synthetic-proposal", version: 3, status: "approved" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/ocr-retry` && request.method() === "POST") {
|
||||||
|
expect(request.postDataJSON()).toEqual({ expectedVersion: 3 });
|
||||||
|
calls.retryReceiptOcr += 1;
|
||||||
|
receiptOcrState = "queued";
|
||||||
|
await fulfillJson(route, { item: { id: paymentReceiptId, ocrState: "queued", ocrVersion: 4 } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") {
|
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") {
|
||||||
if (options.receiptQueryError) {
|
if (options.receiptQueryError) {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -585,6 +625,9 @@ async function mockSystemActionApis(
|
|||||||
{
|
{
|
||||||
id: paymentReceiptId,
|
id: paymentReceiptId,
|
||||||
originalName: "合成银行回单.pdf",
|
originalName: "合成银行回单.pdf",
|
||||||
|
ocrState: receiptOcrState,
|
||||||
|
ocrVersion: receiptOcrState === "queued" ? 4 : 3,
|
||||||
|
ocrRetryAvailable: receiptOcrState === "ocr_failed",
|
||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
fileSize: 2048,
|
fileSize: 2048,
|
||||||
fileSha256: "synthetic-receipt-hash",
|
fileSha256: "synthetic-receipt-hash",
|
||||||
@@ -1312,6 +1355,94 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
|||||||
await expect(dialog).not.toBeVisible();
|
await expect(dialog).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
for (const state of ["system_failed", "key_failed", "snapshot_failed"] as const) {
|
||||||
|
test(`回单系统失败引导管理员且不重传 ${width} ${state}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const calls = await mockSystemActionApis(page, { receiptOcrState: state, receiptReady: false });
|
||||||
|
await login(page, user);
|
||||||
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||||
|
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
|
||||||
|
await expect(receipt).toContainText("请联系管理员");
|
||||||
|
await expect(receipt.getByRole("button", { name: "重新识别", exact: true })).toHaveCount(0);
|
||||||
|
await expect(receipt.getByRole("button", { name: "核验详情", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "登记付款完成", exact: true })).toHaveCount(0);
|
||||||
|
expect(calls.retryReceiptOcr).toBe(0);
|
||||||
|
expect(calls.executePaymentBatch).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test(`回单未核验不能登记付款 ${width}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed", receiptReady: false });
|
||||||
|
await login(page, user);
|
||||||
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||||
|
await expect(page.getByText("回单尚未满足付款登记条件", { exact: false })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "登记付款完成", exact: true })).toHaveCount(0);
|
||||||
|
expect(calls.executePaymentBatch).toBe(0);
|
||||||
|
});
|
||||||
|
for (const correction of [false, true]) {
|
||||||
|
test(`回单人工核验只提交关联或更正且不付款 ${width} ${correction}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed" });
|
||||||
|
await login(page, user);
|
||||||
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||||
|
await page.getByRole("button", { name: "核验详情", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await expect(dialog).toContainText("0000****2222");
|
||||||
|
await dialog.getByRole("combobox", { name: "第1页对应付款明细" }).selectOption("payment-batch-item-action-confirm");
|
||||||
|
if (correction) {
|
||||||
|
await dialog.getByRole("button", { name: "更正识别", exact: true }).click();
|
||||||
|
await dialog.getByRole("textbox", { name: "第1页更正小写金额", exact: true }).fill("88.00");
|
||||||
|
await expect(dialog.getByRole("button", { name: "保存核验", exact: true })).toBeDisabled();
|
||||||
|
await dialog.getByRole("textbox", { name: "更正说明(仅关联可留空)", exact: true }).fill("核对原件更正金额");
|
||||||
|
}
|
||||||
|
await expect(dialog).toBeInViewport();
|
||||||
|
await dialog.getByRole("button", { name: "保存核验", exact: true }).click();
|
||||||
|
await expect.poll(() => calls.manualReceiptSaves.length).toBe(1);
|
||||||
|
expect(calls.manualReceiptSaves[0]).toEqual({
|
||||||
|
expectedVersion: 3, ...(correction ? { reason: "核对原件更正金额" } : {}),
|
||||||
|
pages: [{ pageNumber: 1, itemId: "payment-batch-item-action-confirm",
|
||||||
|
...(correction ? { fields: [{ Name: "小写金额", Value: "88.00" }] } : {}) }],
|
||||||
|
});
|
||||||
|
expect(calls.executePaymentBatch).toBe(0);
|
||||||
|
expect(calls.uploadPaymentReceipt).toBe(0);
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test(`回单独立复核查看更正且不触发付款 ${width}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed", receiptManualReview: true,
|
||||||
|
meOverride: { id: "independent-receipt-reviewer" } });
|
||||||
|
await login(page, user);
|
||||||
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||||
|
await page.getByRole("button", { name: "核验详情", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await expect(dialog).toContainText("人工核验结果");
|
||||||
|
await expect(dialog).toContainText("更正字段:金额");
|
||||||
|
await expect(dialog.getByRole("button", { name: "确认复核", exact: true })).toBeDisabled();
|
||||||
|
await dialog.getByRole("textbox", { name: "复核说明", exact: true }).fill("已核对合成原回单");
|
||||||
|
await dialog.getByRole("button", { name: "确认复核", exact: true }).click();
|
||||||
|
await expect.poll(() => calls.manualReceiptReviews.length).toBe(1);
|
||||||
|
expect(calls.manualReceiptReviews[0]).toEqual({ expectedVersion: 3, decision: "approve", reason: "已核对合成原回单" });
|
||||||
|
expect(calls.executePaymentBatch).toBe(0);
|
||||||
|
expect(calls.manualReceiptSaves).toHaveLength(0);
|
||||||
|
});
|
||||||
|
test(`回单OCR失败可重试且不触发付款登记 ${width}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const calls = await mockSystemActionApis(page, { receiptOcrState: "ocr_failed" });
|
||||||
|
await login(page, user);
|
||||||
|
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||||
|
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
|
||||||
|
await expect(receipt).toContainText("识别未完成,可重试");
|
||||||
|
await receipt.getByRole("button", { name: "重新识别", exact: true }).click();
|
||||||
|
await expect(receipt).toContainText("等待识别");
|
||||||
|
await expect(receipt.getByRole("button", { name: "重新识别", exact: true })).toHaveCount(0);
|
||||||
|
expect(calls.retryReceiptOcr).toBe(1);
|
||||||
|
expect(calls.executePaymentBatch).toBe(0);
|
||||||
|
expect(calls.uploadPaymentReceipt).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("付款回单作废要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
|
test("付款回单作废要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
|
||||||
const calls = await mockSystemActionApis(page);
|
const calls = await mockSystemActionApis(page);
|
||||||
await login(page, user);
|
await login(page, user);
|
||||||
|
|||||||
@@ -0,0 +1,520 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const { randomUUID } = require("node:crypto");
|
||||||
|
const { readFileSync, readdirSync } = require("node:fs");
|
||||||
|
const { createRequire } = require("node:module");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { setTimeout: delay } = require("node:timers/promises");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "../..");
|
||||||
|
const serverRequire = createRequire(path.join(root, "server/package.json"));
|
||||||
|
const docker = (args, input) => {
|
||||||
|
try {
|
||||||
|
return execFileSync("docker", args, {
|
||||||
|
input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 60000,
|
||||||
|
}).trim();
|
||||||
|
} catch (error) {
|
||||||
|
error.dockerOperation = args[0];
|
||||||
|
error.dockerExit = Number.isInteger(error.status) ? error.status : null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
assert.equal(process.env.RUN_ISOLATED_DB_TEST, "1");
|
||||||
|
let container;
|
||||||
|
let prisma;
|
||||||
|
let pool;
|
||||||
|
const passed = [];
|
||||||
|
try {
|
||||||
|
const password = randomUUID();
|
||||||
|
container = docker(["run", "-d", "--rm", "--pull=never", "--tmpfs", "/var/lib/postgresql/data",
|
||||||
|
"-p", "127.0.0.1::5432", "-e", `POSTGRES_PASSWORD=${password}`, "postgres:16-alpine"]);
|
||||||
|
let ready = false;
|
||||||
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||||
|
try { docker(["exec", container, "pg_isready", "-U", "postgres"]); ready = true; break; }
|
||||||
|
catch { await delay(1000); }
|
||||||
|
}
|
||||||
|
assert.ok(ready);
|
||||||
|
const migrations = path.join(root, "server/prisma/migrations");
|
||||||
|
for (const directory of readdirSync(migrations).sort()) {
|
||||||
|
if (directory === "migration_lock.toml") continue;
|
||||||
|
docker(["exec", "-i", container, "psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", "-U", "postgres"],
|
||||||
|
readFileSync(path.join(migrations, directory, "migration.sql")));
|
||||||
|
}
|
||||||
|
passed.push("all_migrations");
|
||||||
|
const address = docker(["port", container, "5432/tcp"]);
|
||||||
|
assert.match(address, /^127\.0\.0\.1:\d+$/);
|
||||||
|
process.env.DATABASE_URL = `postgresql://postgres:${password}@${address}/postgres`;
|
||||||
|
({ prisma, pool } = serverRequire("./src/prismaClient").createPrismaClient());
|
||||||
|
const { encryptStringOrThrow } = serverRequire("./src/services/crypto");
|
||||||
|
const { normalizeBankSlip } = serverRequire("./src/services/paymentReceiptMatching");
|
||||||
|
const { claimVerifiedReceipts, storeReceiptRecognition, queueReceiptRetry } = serverRequire("./src/services/paymentReceiptVerification");
|
||||||
|
const { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail } = serverRequire("./src/services/paymentReceiptManualPersistence");
|
||||||
|
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
||||||
|
const department = await prisma.department.create({ data: { name: "synthetic-receipt" } });
|
||||||
|
const employee = await prisma.employee.create({ data: {
|
||||||
|
name: "synthetic", email: "[email protected]", employee_no: "synthetic-receipt",
|
||||||
|
department_id: department.id, title: "synthetic", hire_date: new Date("2026-09-13"),
|
||||||
|
bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash",
|
||||||
|
} });
|
||||||
|
async function fixture(reference, manual = false) {
|
||||||
|
const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({
|
||||||
|
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||||
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: reference,
|
||||||
|
}).map(([Name, Value]) => ({ Name, Value }))) };
|
||||||
|
const batch = await prisma.paymentBatch.create({ data: {
|
||||||
|
batch_no: `SYN-${randomUUID()}`, entity_type: "reimbursement", name: "synthetic", status: "reviewed",
|
||||||
|
created_by: employee.id,
|
||||||
|
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, page.payerAccount) },
|
||||||
|
items: { create: { entity_id: randomUUID(), entity_code: "SYN", entity_title: "synthetic",
|
||||||
|
amount: "10.00", snapshot_json: { payee: { bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, page.payeeAccount) } } } },
|
||||||
|
}, include: { items: true } });
|
||||||
|
const attachment = await prisma.paymentBatchReceiptAttachment.create({ data: {
|
||||||
|
payment_batch_id: batch.id, storage_path: "synthetic-no-file", original_name: "synthetic.pdf",
|
||||||
|
mime_type: "application/pdf", file_size: 1, file_sha256: "a".repeat(64), uploaded_by: employee.id,
|
||||||
|
} });
|
||||||
|
const recognition = { status: "recognized", pagesExpected: 1, pagesProcessed: 1, pages: [page] };
|
||||||
|
if (manual) {
|
||||||
|
recognition.status = "needs_review";
|
||||||
|
page.amount = null;
|
||||||
|
page.issues = ["amount_unresolved"];
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: attachment.id },
|
||||||
|
data: { receipt_ocr_state: "needs_review" } });
|
||||||
|
}
|
||||||
|
await prisma.$transaction((tx) => storeReceiptRecognition(tx, config, {
|
||||||
|
batchId: batch.id, attachmentId: attachment.id, sourceSha256: attachment.file_sha256, expectedVersion: 0, recognition,
|
||||||
|
}));
|
||||||
|
return { batch, attachment, recognition };
|
||||||
|
}
|
||||||
|
async function claim(batch, abort = false) {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id=${batch.id}::uuid FOR UPDATE`;
|
||||||
|
await claimVerifiedReceipts(tx, config, batch);
|
||||||
|
if (abort) throw new Error("synthetic_abort");
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
}
|
||||||
|
const first = await fixture("SYN-SAME");
|
||||||
|
await assert.rejects(claim(first.batch, true), /synthetic_abort/);
|
||||||
|
assert.equal(await prisma.paymentReceiptClaim.count(), 0);
|
||||||
|
passed.push("claim_rolls_back_with_transaction");
|
||||||
|
await claim(first.batch);
|
||||||
|
await claim(first.batch);
|
||||||
|
assert.equal(await prisma.paymentReceiptClaim.count(), 1);
|
||||||
|
passed.push("idempotent_claim");
|
||||||
|
const second = await fixture("SYN-SAME");
|
||||||
|
await assert.rejects(claim(second.batch), { code: "receipt_transaction_reused" });
|
||||||
|
passed.push("cross_batch_duplicate_blocked");
|
||||||
|
const concurrentFirst = await fixture("SYN-CONCURRENT");
|
||||||
|
const concurrentSecond = await fixture("SYN-CONCURRENT");
|
||||||
|
const outcomes = await Promise.allSettled([claim(concurrentFirst.batch), claim(concurrentSecond.batch)]);
|
||||||
|
assert.equal(outcomes.filter((outcome) => outcome.status === "fulfilled").length, 1);
|
||||||
|
assert.equal(outcomes.find((outcome) => outcome.status === "rejected").reason.code, "receipt_transaction_reused");
|
||||||
|
passed.push("concurrent_duplicate_blocked");
|
||||||
|
const voided = await fixture("SYN-VOID");
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: voided.attachment.id }, data: { archived_at: new Date() } });
|
||||||
|
await assert.rejects(claim(voided.batch), { code: "receipt_verification_required" });
|
||||||
|
passed.push("voided_receipt_blocked");
|
||||||
|
const verificationCount = await prisma.paymentReceiptVerification.count();
|
||||||
|
await assert.rejects(prisma.$transaction((tx) => storeReceiptRecognition(tx, config, {
|
||||||
|
batchId: voided.batch.id, attachmentId: voided.attachment.id,
|
||||||
|
sourceSha256: voided.attachment.file_sha256, expectedVersion: 0, recognition: voided.recognition,
|
||||||
|
})), { code: "receipt_unavailable" });
|
||||||
|
assert.equal(await prisma.paymentReceiptVerification.count(), verificationCount);
|
||||||
|
passed.push("late_result_cannot_revive_voided_receipt");
|
||||||
|
await assert.rejects(prisma.$transaction((tx) => storeReceiptRecognition(tx, config, {
|
||||||
|
batchId: first.batch.id, attachmentId: first.attachment.id,
|
||||||
|
sourceSha256: "b".repeat(64), expectedVersion: 0, recognition: first.recognition,
|
||||||
|
})), { code: "receipt_source_changed" });
|
||||||
|
passed.push("changed_source_rejected");
|
||||||
|
const superseded = await fixture("SYN-SUPERSEDED");
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({
|
||||||
|
where: { id: superseded.attachment.id }, data: { receipt_ocr_version: { increment: 1 } },
|
||||||
|
});
|
||||||
|
await assert.rejects(prisma.$transaction((tx) => storeReceiptRecognition(tx, config, {
|
||||||
|
batchId: superseded.batch.id, attachmentId: superseded.attachment.id, expectedVersion: 0,
|
||||||
|
sourceSha256: superseded.attachment.file_sha256, recognition: superseded.recognition,
|
||||||
|
})), { code: "receipt_job_superseded" });
|
||||||
|
await assert.rejects(claim(superseded.batch), { code: "receipt_verification_required" });
|
||||||
|
passed.push("superseded_job_and_old_match_blocked");
|
||||||
|
const { claimReceiptJob, runReceiptOcrOnce } = serverRequire("./src/jobs/paymentReceiptOcrJob");
|
||||||
|
for (const scenario of ["cash", "travel", "settled"]) {
|
||||||
|
const outside = await fixture(`SYN-SCOPE-${scenario}`);
|
||||||
|
if (scenario === "settled") {
|
||||||
|
await prisma.paymentBatchItem.update({ where: { id: outside.batch.items[0].id }, data: { status: "executed" } });
|
||||||
|
} else {
|
||||||
|
await prisma.paymentBatch.update({ where: { id: outside.batch.id },
|
||||||
|
data: scenario === "cash" ? { default_payment_channel: "cash" } : { entity_type: "travel_order" } });
|
||||||
|
}
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({
|
||||||
|
where: { id: outside.attachment.id }, data: { receipt_ocr_state: "queued" },
|
||||||
|
});
|
||||||
|
let externalOperations = 0;
|
||||||
|
const forbidden = async () => { externalOperations += 1; throw new Error("synthetic_forbidden"); };
|
||||||
|
assert.deepEqual(await runReceiptOcrOnce({ prisma, config, readBuffer: forbidden, recognize: forbidden }), { status: "idle" });
|
||||||
|
assert.equal(externalOperations, 0);
|
||||||
|
const retained = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: outside.attachment.id } });
|
||||||
|
assert.equal(retained.receipt_ocr_state, "legacy");
|
||||||
|
assert.equal(retained.receipt_ocr_version, 0);
|
||||||
|
passed.push(`worker_${scenario}_retains_evidence_without_file_read_or_cloud_call`);
|
||||||
|
}
|
||||||
|
const queued = await fixture("SYN-QUEUE");
|
||||||
|
const keyFailure = await fixture("SYN-KEY-FAILURE");
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({
|
||||||
|
where: { id: keyFailure.attachment.id }, data: { receipt_ocr_state: "queued" },
|
||||||
|
});
|
||||||
|
let forbiddenReads = 0;
|
||||||
|
const forbiddenRead = async () => { forbiddenReads += 1; throw new Error("synthetic_forbidden"); };
|
||||||
|
assert.deepEqual(await runReceiptOcrOnce({ prisma, config: {}, readBuffer: forbiddenRead, recognize: forbiddenRead }),
|
||||||
|
{ status: "key_failed" });
|
||||||
|
assert.equal(forbiddenReads, 0);
|
||||||
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({
|
||||||
|
where: { id: keyFailure.attachment.id },
|
||||||
|
})).receipt_ocr_state, "key_failed");
|
||||||
|
passed.push("missing_key_terminal_state_persisted_without_cloud");
|
||||||
|
const persistFailure = await fixture("SYN-PERSIST-FAILURE");
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({
|
||||||
|
where: { id: persistFailure.attachment.id }, data: { receipt_ocr_state: "queued" },
|
||||||
|
});
|
||||||
|
const faultyPrisma = { $transaction: (callback, options) => prisma.$transaction(async (tx) => {
|
||||||
|
const proxy = new Proxy(tx, { get(target, property) {
|
||||||
|
if (property === "paymentReceiptVerification") return { create: async () => {
|
||||||
|
await tx.$executeRawUnsafe('SELECT 1 / 0');
|
||||||
|
} };
|
||||||
|
return target[property];
|
||||||
|
} });
|
||||||
|
return callback(proxy);
|
||||||
|
}, options) };
|
||||||
|
assert.deepEqual(await runReceiptOcrOnce({ prisma: faultyPrisma, config,
|
||||||
|
readBuffer: forbiddenRead, recognize: forbiddenRead }), { status: "system_failed" });
|
||||||
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({
|
||||||
|
where: { id: persistFailure.attachment.id },
|
||||||
|
})).receipt_ocr_state, "system_failed");
|
||||||
|
passed.push("aborted_database_transaction_records_failure_in_separate_transaction");
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({
|
||||||
|
where: { id: queued.attachment.id }, data: { receipt_ocr_state: "queued" },
|
||||||
|
});
|
||||||
|
const workers = await Promise.all([claimReceiptJob(prisma), claimReceiptJob(prisma)]);
|
||||||
|
assert.equal(workers.filter(Boolean).length, 1);
|
||||||
|
assert.equal(workers.find(Boolean).receipt_ocr_version, 1);
|
||||||
|
await assert.rejects(claim(queued.batch), { code: "receipt_verification_required" });
|
||||||
|
passed.push("concurrent_worker_single_claim_and_version_invalidation");
|
||||||
|
const brokenFile = await fixture("SYN-QUEUE-FAIL");
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({
|
||||||
|
where: { id: brokenFile.attachment.id }, data: { receipt_ocr_state: "queued" },
|
||||||
|
});
|
||||||
|
let externalCalls = 0;
|
||||||
|
const failedJob = await runReceiptOcrOnce({ prisma, config,
|
||||||
|
readBuffer: async () => Buffer.from("wrong-file"),
|
||||||
|
recognize: async () => { externalCalls += 1; throw new Error("must_not_call"); },
|
||||||
|
});
|
||||||
|
assert.equal(failedJob.status, "ocr_failed");
|
||||||
|
assert.equal(externalCalls, 0);
|
||||||
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({
|
||||||
|
where: { id: brokenFile.attachment.id },
|
||||||
|
})).receipt_ocr_state, "ocr_failed");
|
||||||
|
passed.push("worker_integrity_failure_persisted_without_external_call");
|
||||||
|
const failedAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: brokenFile.attachment.id } });
|
||||||
|
async function retryReceipt() {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id=${brokenFile.batch.id}::uuid FOR UPDATE`;
|
||||||
|
return queueReceiptRetry(tx, { batchId: brokenFile.batch.id, attachmentId: failedAttachment.id,
|
||||||
|
expectedVersion: failedAttachment.receipt_ocr_version });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const retries = await Promise.allSettled([retryReceipt(), retryReceipt()]);
|
||||||
|
assert.equal(retries.filter((outcome) => outcome.status === "fulfilled").length, 1);
|
||||||
|
assert.equal(retries.find((outcome) => outcome.status === "rejected").reason.code, "receipt_job_superseded");
|
||||||
|
passed.push("concurrent_retry_single_version");
|
||||||
|
const existing = await prisma.paymentReceiptClaim.findFirst();
|
||||||
|
await assert.rejects(prisma.paymentReceiptClaim.create({ data: {
|
||||||
|
fingerprint: existing.fingerprint, key_id: existing.key_id,
|
||||||
|
item_id: second.batch.items[0].id, verification_id: existing.verification_id,
|
||||||
|
} }), { code: "P2002" });
|
||||||
|
passed.push("database_unique_constraint");
|
||||||
|
const reviewer = await prisma.employee.create({ data: {
|
||||||
|
name: "synthetic-reviewer", email: "[email protected]", employee_no: "synthetic-reviewer",
|
||||||
|
department_id: department.id, title: "synthetic", hire_date: new Date("2026-09-13"),
|
||||||
|
bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash",
|
||||||
|
roles: { create: { role_code: "ceo" } },
|
||||||
|
} });
|
||||||
|
function requestFor(actor, roleCodes = ["finance"]) {
|
||||||
|
return { id: randomUUID(), headers: {}, employee: { id: actor.id, roleCodes } };
|
||||||
|
}
|
||||||
|
async function manualFixture(reference) {
|
||||||
|
const context = await fixture(reference, true);
|
||||||
|
await prisma.reimbursementForm.create({ data: {
|
||||||
|
id: context.batch.items[0].entity_id, submitter_id: employee.id, owner_id: employee.id,
|
||||||
|
department_id: department.id, reimbursement_date: new Date("2026-09-14"),
|
||||||
|
status: "approved", total_amount: "10.00",
|
||||||
|
} });
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
function proposalInput(context, reference) {
|
||||||
|
return { batchId: context.batch.id, attachmentId: context.attachment.id, expectedVersion: 0,
|
||||||
|
reason: "合成测试更正", pages: [{ pageNumber: 1, itemId: context.batch.items[0].id,
|
||||||
|
fields: Object.entries({
|
||||||
|
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||||
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: reference,
|
||||||
|
}).map(([Name, Value]) => ({ Name, Value })) }] };
|
||||||
|
}
|
||||||
|
const manual = await manualFixture("SYN-MANUAL-SAVE");
|
||||||
|
const manualInput = proposalInput(manual, "SYN-MANUAL-SAVE");
|
||||||
|
const auditBefore = await prisma.auditLog.count();
|
||||||
|
await assert.rejects(prisma.$transaction(async (tx) => {
|
||||||
|
await saveManualReceiptProposal(tx, config, requestFor(employee), manualInput);
|
||||||
|
throw new Error("synthetic_abort");
|
||||||
|
}), /synthetic_abort/);
|
||||||
|
assert.equal(await prisma.paymentReceiptManualProposal.count(), 0);
|
||||||
|
assert.equal(await prisma.auditLog.count(), auditBefore);
|
||||||
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: manual.attachment.id } })).receipt_ocr_version, 0);
|
||||||
|
passed.push("manual_proposal_and_audit_rollback");
|
||||||
|
const saves = await Promise.allSettled([1, 2].map(() => prisma.$transaction((tx) =>
|
||||||
|
saveManualReceiptProposal(tx, config, requestFor(employee), manualInput))));
|
||||||
|
assert.equal(saves.filter((outcome) => outcome.status === "fulfilled").length, 1);
|
||||||
|
assert.equal(saves.find((outcome) => outcome.status === "rejected").reason.code, "receipt_job_superseded");
|
||||||
|
const saved = saves.find((outcome) => outcome.status === "fulfilled").value;
|
||||||
|
assert.equal(saved.status, "pending_review");
|
||||||
|
assert.equal(saved.version, 1);
|
||||||
|
assert.deepEqual(Object.keys(saved).sort(), ["id", "status", "version"]);
|
||||||
|
const stored = await prisma.paymentReceiptManualProposal.findUnique({ where: { id: saved.id } });
|
||||||
|
assert.ok(!stored.payload_encrypted.includes("SYN-MANUAL-SAVE"));
|
||||||
|
await assert.rejects(claim(manual.batch), { code: "receipt_verification_required" });
|
||||||
|
passed.push("manual_concurrent_save_encrypted_and_not_payment");
|
||||||
|
const reviewInput = { ...manualInput, expectedVersion: saved.version, proposalId: saved.id,
|
||||||
|
decision: "approve", reason: "合成独立核对" };
|
||||||
|
await assert.rejects(prisma.$transaction((tx) =>
|
||||||
|
reviewManualReceiptProposal(tx, config, requestFor(employee, ["ceo"]), reviewInput, true)),
|
||||||
|
{ code: "receipt_review_identity_conflict" });
|
||||||
|
passed.push("manual_self_review_blocked");
|
||||||
|
const reviews = await Promise.allSettled([1, 2].map(() => prisma.$transaction((tx) =>
|
||||||
|
reviewManualReceiptProposal(tx, config, requestFor(reviewer, ["ceo"]), reviewInput, true))));
|
||||||
|
assert.equal(reviews.filter((outcome) => outcome.status === "fulfilled").length, 1);
|
||||||
|
assert.equal(reviews.find((outcome) => outcome.status === "rejected").reason.code, "receipt_manual_not_reviewable");
|
||||||
|
assert.equal((await prisma.paymentReceiptManualProposal.findUnique({ where: { id: saved.id } })).status, "approved");
|
||||||
|
await claim(manual.batch);
|
||||||
|
const manualClaim = await prisma.paymentReceiptClaim.findUnique({ where: { item_id: manual.batch.items[0].id } });
|
||||||
|
assert.equal(manualClaim.manual_proposal_id, saved.id);
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: manual.batch.items[0].entity_id } })).paid_at, null);
|
||||||
|
passed.push("manual_claim_links_approved_proposal_without_recording_payment");
|
||||||
|
const reusedOriginal = await manualFixture("SYN-SAME");
|
||||||
|
await assert.rejects(prisma.$transaction((tx) => saveManualReceiptProposal(tx, config,
|
||||||
|
requestFor(employee), proposalInput(reusedOriginal, "SYN-NEW-REFERENCE"))),
|
||||||
|
{ code: "receipt_transaction_reused" });
|
||||||
|
passed.push("manual_original_cross_batch_duplicate_cannot_be_corrected_away");
|
||||||
|
const changed = await manualFixture("SYN-MANUAL-CHANGED");
|
||||||
|
const changedInput = proposalInput(changed, "SYN-MANUAL-CHANGED");
|
||||||
|
const changedSaved = await prisma.$transaction((tx) => saveManualReceiptProposal(tx, config, requestFor(employee), changedInput));
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: changed.attachment.id },
|
||||||
|
data: { file_sha256: "b".repeat(64) } });
|
||||||
|
await assert.rejects(prisma.$transaction((tx) => reviewManualReceiptProposal(tx, config,
|
||||||
|
requestFor(reviewer, ["ceo"]), { ...changedInput, expectedVersion: 1,
|
||||||
|
proposalId: changedSaved.id, decision: "approve", reason: "合成测试" }, true)),
|
||||||
|
{ code: "receipt_source_changed" });
|
||||||
|
passed.push("manual_changed_source_blocks_review");
|
||||||
|
const rejected = await manualFixture("SYN-MANUAL-REJECT");
|
||||||
|
const rejectedInput = proposalInput(rejected, "SYN-MANUAL-REJECT");
|
||||||
|
const rejectedSaved = await prisma.$transaction((tx) =>
|
||||||
|
saveManualReceiptProposal(tx, config, requestFor(employee), rejectedInput));
|
||||||
|
const rejectedDecision = await prisma.$transaction((tx) => reviewManualReceiptProposal(tx, config,
|
||||||
|
requestFor(reviewer, ["ceo"]), { ...rejectedInput, expectedVersion: 1,
|
||||||
|
proposalId: rejectedSaved.id, decision: "reject", reason: "合成拒绝" }, true));
|
||||||
|
assert.equal(rejectedDecision.status, "rejected");
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id=${rejected.batch.id}::uuid FOR UPDATE`;
|
||||||
|
await queueReceiptRetry(tx, { batchId: rejected.batch.id, attachmentId: rejected.attachment.id, expectedVersion: 1 });
|
||||||
|
});
|
||||||
|
assert.equal((await prisma.paymentReceiptManualProposal.findUnique({ where: { id: rejectedSaved.id } })).status, "rejected");
|
||||||
|
passed.push("manual_rejection_preserved_and_explicit_retry_available");
|
||||||
|
const auditFailure = await manualFixture("SYN-AUDIT-FAILURE");
|
||||||
|
await assert.rejects(prisma.$transaction((tx) => saveManualReceiptProposal(tx, config,
|
||||||
|
requestFor({ id: randomUUID() }), proposalInput(auditFailure, "SYN-AUDIT-FAILURE"))), { code: "P2003" });
|
||||||
|
assert.equal(await prisma.paymentReceiptManualProposal.count({ where: { attachment_id: auditFailure.attachment.id } }), 0);
|
||||||
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: auditFailure.attachment.id } })).receipt_ocr_version, 0);
|
||||||
|
passed.push("manual_audit_failure_rolls_back_proposal_and_version");
|
||||||
|
const association = await fixture("SYN-ASSOCIATION");
|
||||||
|
await prisma.paymentBatchItem.create({ data: {
|
||||||
|
payment_batch_id: association.batch.id, entity_id: randomUUID(), entity_code: "SYN-OTHER",
|
||||||
|
entity_title: "synthetic", amount: "10.00", snapshot_json: association.batch.items[0].snapshot_json,
|
||||||
|
} });
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: association.attachment.id },
|
||||||
|
data: { receipt_ocr_state: "needs_review" } });
|
||||||
|
const associated = await prisma.$transaction((tx) => saveManualReceiptProposal(tx, config, requestFor(employee), {
|
||||||
|
batchId: association.batch.id, attachmentId: association.attachment.id, expectedVersion: 0,
|
||||||
|
pages: [{ pageNumber: 1, itemId: association.batch.items[0].id }],
|
||||||
|
}));
|
||||||
|
assert.equal(associated.status, "association_ready");
|
||||||
|
assert.equal((await prisma.paymentReceiptManualProposal.findUnique({ where: { id: associated.id } })).reviewed_by, null);
|
||||||
|
passed.push("manual_unchanged_association_needs_no_reentry_or_second_approval");
|
||||||
|
const lastVersion = await manualFixture("SYN-LAST-VERSION");
|
||||||
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: lastVersion.attachment.id },
|
||||||
|
data: { receipt_ocr_version: 2147483646 } });
|
||||||
|
await prisma.paymentReceiptVerification.updateMany({ where: { attachment_id: lastVersion.attachment.id },
|
||||||
|
data: { source_version: 2147483646 } });
|
||||||
|
const lastInput = { ...proposalInput(lastVersion, "SYN-LAST-VERSION"), expectedVersion: 2147483646 };
|
||||||
|
const lastSaved = await prisma.$transaction((tx) => saveManualReceiptProposal(tx, config, requestFor(employee), lastInput));
|
||||||
|
assert.equal(lastSaved.version, 2147483647);
|
||||||
|
const lastReview = await prisma.$transaction((tx) => reviewManualReceiptProposal(tx, config,
|
||||||
|
requestFor(reviewer, ["ceo"]), { ...lastInput, proposalId: lastSaved.id,
|
||||||
|
expectedVersion: lastSaved.version, decision: "approve", reason: "合成边界测试" }, true));
|
||||||
|
assert.equal(lastReview.status, "approved");
|
||||||
|
passed.push("manual_final_integer_version_still_reviewable_without_overflow");
|
||||||
|
const detailBefore = await prisma.auditLog.count();
|
||||||
|
const detail = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
|
||||||
|
return readManualReceiptDetail(tx, config, { batchId: manual.batch.id, attachmentId: manual.attachment.id,
|
||||||
|
reviewer: requestFor(reviewer, ["ceo"]).employee, hasReviewPermission: true });
|
||||||
|
}, { isolationLevel: "RepeatableRead" });
|
||||||
|
assert.equal(detail.proposal.status, "approved");
|
||||||
|
assert.equal(detail.proposal.pages[0].amount, "10.00");
|
||||||
|
assert.ok(!JSON.stringify(detail).includes("000011112222"));
|
||||||
|
assert.ok(!JSON.stringify(detail).includes("000033334444"));
|
||||||
|
assert.equal(await prisma.auditLog.count(), detailBefore);
|
||||||
|
passed.push("manual_detail_readonly_and_accounts_masked");
|
||||||
|
async function approvedCorrection(originalReference, correctedReference) {
|
||||||
|
const context = await manualFixture(originalReference);
|
||||||
|
const input = proposalInput(context, correctedReference);
|
||||||
|
const proposal = await prisma.$transaction((tx) => saveManualReceiptProposal(tx, config, requestFor(employee), input));
|
||||||
|
await prisma.$transaction((tx) => reviewManualReceiptProposal(tx, config, requestFor(reviewer, ["ceo"]),
|
||||||
|
{ ...input, proposalId: proposal.id, expectedVersion: proposal.version, decision: "approve", reason: "合成独立复核" }, true));
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
const aliased = await approvedCorrection("SYN-ORIGINAL-ALIAS", "SYN-CORRECTED-ALIAS");
|
||||||
|
const aliasesBefore = await prisma.paymentReceiptTransactionAlias.count();
|
||||||
|
await assert.rejects(claim(aliased.batch, true), /synthetic_abort/);
|
||||||
|
assert.equal(await prisma.paymentReceiptTransactionAlias.count(), aliasesBefore);
|
||||||
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: aliased.batch.items[0].id } }), 0);
|
||||||
|
passed.push("manual_claim_and_original_alias_roll_back_together");
|
||||||
|
await claim(aliased.batch);
|
||||||
|
await claim(aliased.batch);
|
||||||
|
const aliasedClaim = await prisma.paymentReceiptClaim.findUnique({ where: { item_id: aliased.batch.items[0].id } });
|
||||||
|
assert.equal(await prisma.paymentReceiptTransactionAlias.count({ where: { claim_fingerprint: aliasedClaim.fingerprint } }), 2);
|
||||||
|
const originalReuse = await fixture("SYN-ORIGINAL-ALIAS");
|
||||||
|
const correctedReuse = await fixture("SYN-CORRECTED-ALIAS");
|
||||||
|
await assert.rejects(claim(originalReuse.batch), { code: "receipt_transaction_reused" });
|
||||||
|
await assert.rejects(claim(correctedReuse.batch), { code: "receipt_transaction_reused" });
|
||||||
|
passed.push("original_and_corrected_transaction_identity_both_unique_and_idempotent");
|
||||||
|
const identityChanged = await approvedCorrection("SYN-ROLE-ORIGINAL", "SYN-ROLE-CORRECTED");
|
||||||
|
await prisma.employeeRole.delete({ where: { employee_id_role_code: { employee_id: reviewer.id, role_code: "ceo" } } });
|
||||||
|
await assert.rejects(claim(identityChanged.batch), { code: "receipt_review_permission_required" });
|
||||||
|
await prisma.employeeRole.create({ data: { employee_id: reviewer.id, role_code: "ceo" } });
|
||||||
|
await prisma.reimbursementForm.update({ where: { id: identityChanged.batch.items[0].entity_id },
|
||||||
|
data: { owner_id: reviewer.id } });
|
||||||
|
await assert.rejects(claim(identityChanged.batch), { code: "receipt_review_identity_conflict" });
|
||||||
|
passed.push("manual_execution_rechecks_current_reviewer_role_and_participants");
|
||||||
|
const concurrentManual = await approvedCorrection("SYN-RACE-ORIGINAL", "SYN-RACE-CORRECTED");
|
||||||
|
const concurrentAuto = await fixture("SYN-RACE-ORIGINAL");
|
||||||
|
const aliasRace = await Promise.allSettled([claim(concurrentManual.batch), claim(concurrentAuto.batch)]);
|
||||||
|
assert.equal(aliasRace.filter((result) => result.status === "fulfilled").length, 1);
|
||||||
|
assert.equal(aliasRace.find((result) => result.status === "rejected").reason.code, "receipt_transaction_reused");
|
||||||
|
passed.push("concurrent_manual_and_auto_original_identity_only_one_claim");
|
||||||
|
const mixed = await fixture("SYN-MIXED-BANK");
|
||||||
|
const cashItem = await prisma.paymentBatchItem.create({ data: {
|
||||||
|
payment_batch_id: mixed.batch.id, entity_id: randomUUID(), entity_code: "SYN-CASH",
|
||||||
|
entity_title: "synthetic", amount: "10.00", payment_channel_override: "cash", snapshot_json: {},
|
||||||
|
} });
|
||||||
|
const mixedBatch = await prisma.paymentBatch.findUnique({ where: { id: mixed.batch.id }, include: { items: true } });
|
||||||
|
await claim(mixedBatch);
|
||||||
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: cashItem.id } }), 0);
|
||||||
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: mixed.batch.items[0].id } }), 1);
|
||||||
|
passed.push("mixed_history_only_bank_item_requires_and_consumes_receipt");
|
||||||
|
const { FEATURE_FLAG_KEYS } = serverRequire("./src/services/featureFlags");
|
||||||
|
await prisma.featureFlag.upsert({ where: { flag_key: FEATURE_FLAG_KEYS.paymentBatch },
|
||||||
|
create: { flag_key: FEATURE_FLAG_KEYS.paymentBatch, override_mode: "force_on", rollout_type: "global" },
|
||||||
|
update: { override_mode: "force_on", rollout_type: "global" } });
|
||||||
|
await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "finance" } });
|
||||||
|
const { hasPermission, resolvePermissionsForRoles } = serverRequire("./src/constants/permissions");
|
||||||
|
const { AppError } = serverRequire("./src/utils/AppError");
|
||||||
|
const app = serverRequire("fastify")();
|
||||||
|
app.decorate("prisma", prisma);
|
||||||
|
app.decorate("config", { ...config, paymentBatchEnabled: true, appTimezone: "Asia/Shanghai",
|
||||||
|
erpSyncEnabled: false, emailEnabled: false, imNotifierEnabled: false, appOrigin: "http://127.0.0.1" });
|
||||||
|
app.decorate("authenticate", async (request) => {
|
||||||
|
const actorId = request.headers.authorization === "Bearer synthetic-finance" ? employee.id :
|
||||||
|
request.headers.authorization === "Bearer synthetic-reviewer" ? reviewer.id : null;
|
||||||
|
if (!actorId) throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||||
|
const actor = await prisma.employee.findUnique({ where: { id: actorId }, include: { roles: true } });
|
||||||
|
request.employee = { ...actor, roleCodes: actor.roles.map((role) => role.role_code) };
|
||||||
|
});
|
||||||
|
app.decorate("hasPermission", (actor, permission) => hasPermission(resolvePermissionsForRoles(actor.roleCodes), permission));
|
||||||
|
app.setErrorHandler((error, request, reply) => reply.code(error instanceof AppError ? error.statusCode : 500)
|
||||||
|
.send({ error: { code: error.code ?? "unexpected", details: error instanceof AppError ? error.details : undefined } }));
|
||||||
|
await app.register(serverRequire("./src/routes/paymentBatches"), { prefix: "/api" });
|
||||||
|
const financeHeaders = { authorization: "Bearer synthetic-finance" };
|
||||||
|
const reviewerHeaders = { authorization: "Bearer synthetic-reviewer" };
|
||||||
|
async function readyHttp(context) {
|
||||||
|
await prisma.paymentBatch.update({ where: { id: context.batch.id }, data: {
|
||||||
|
reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: "bank",
|
||||||
|
} });
|
||||||
|
return `/api/payment-batches/${context.batch.id}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const httpManual = await manualFixture("SYN-HTTP-ORIGINAL");
|
||||||
|
const httpBase = await readyHttp(httpManual);
|
||||||
|
const httpProposalUrl = `${httpBase}/receipt-attachments/${httpManual.attachment.id}/manual-verification`;
|
||||||
|
const input = proposalInput(httpManual, "SYN-HTTP-CORRECTED");
|
||||||
|
const payload = { expectedVersion: input.expectedVersion, reason: input.reason, pages: input.pages };
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: httpProposalUrl, payload })).statusCode, 401);
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: httpProposalUrl, headers: reviewerHeaders, payload })).statusCode, 403);
|
||||||
|
const submitted = await app.inject({ method: "POST", url: httpProposalUrl, headers: financeHeaders, payload });
|
||||||
|
assert.equal(submitted.statusCode, 200);
|
||||||
|
const submittedItem = submitted.json().data.item;
|
||||||
|
assert.equal((await app.inject({ method: "GET", url: `${httpBase}/receipt-readiness`, headers: financeHeaders })).json().data.item.ready, false);
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: `${httpBase}/execute`, headers: financeHeaders, payload: {} })).statusCode, 409);
|
||||||
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: httpManual.batch.items[0].id } }), 0);
|
||||||
|
const httpReview = await app.inject({ method: "POST", url: `${httpProposalUrl}/${submittedItem.id}/review`,
|
||||||
|
headers: reviewerHeaders, payload: { expectedVersion: submittedItem.version, decision: "approve", reason: "合成HTTP复核" } });
|
||||||
|
assert.equal(httpReview.statusCode, 200);
|
||||||
|
const claimCount = await prisma.paymentReceiptClaim.count();
|
||||||
|
const aliasesCount = await prisma.paymentReceiptTransactionAlias.count();
|
||||||
|
assert.equal((await app.inject({ method: "GET", url: `${httpBase}/receipt-readiness`, headers: financeHeaders })).json().data.item.ready, true);
|
||||||
|
assert.equal(await prisma.paymentReceiptClaim.count(), claimCount);
|
||||||
|
assert.equal(await prisma.paymentReceiptTransactionAlias.count(), aliasesCount);
|
||||||
|
const completed = await app.inject({ method: "POST", url: `${httpBase}/execute`, headers: financeHeaders, payload: {} });
|
||||||
|
assert.equal(completed.statusCode, 200);
|
||||||
|
assert.equal(completed.json().data.item.status, "executed");
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: httpManual.batch.items[0].entity_id } })).status, "paid");
|
||||||
|
assert.equal((await prisma.paymentReceiptClaim.findUnique({ where: { item_id: httpManual.batch.items[0].id } })).manual_proposal_id, submittedItem.id);
|
||||||
|
assert.equal((await app.inject({ method: "POST", url: `${httpBase}/execute`, headers: financeHeaders, payload: {} })).statusCode, 409);
|
||||||
|
passed.push("real_db_http_manual_review_readiness_execution_and_replay_guard");
|
||||||
|
const rollback = await approvedCorrection("SYN-HTTP-ROLLBACK-OLD", "SYN-HTTP-ROLLBACK-NEW");
|
||||||
|
const rollbackBase = await readyHttp(rollback);
|
||||||
|
await prisma.reimbursementForm.update({ where: { id: rollback.batch.items[0].entity_id }, data: { total_amount: "11.00" } });
|
||||||
|
const beforeAliases = await prisma.paymentReceiptTransactionAlias.count();
|
||||||
|
const refused = await app.inject({ method: "POST", url: `${rollbackBase}/execute`, headers: financeHeaders, payload: {} });
|
||||||
|
assert.equal(refused.statusCode, 409);
|
||||||
|
assert.equal(refused.json().error.details.payment, "source_amount_changed");
|
||||||
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: rollback.batch.items[0].id } }), 0);
|
||||||
|
assert.equal(await prisma.paymentReceiptTransactionAlias.count(), beforeAliases);
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: rollback.batch.items[0].entity_id } })).status, "approved");
|
||||||
|
passed.push("real_db_http_source_amount_change_rolls_back_claim_alias_and_payment");
|
||||||
|
} finally { await app.close(); }
|
||||||
|
} finally {
|
||||||
|
try { if (prisma) await prisma.$disconnect(); }
|
||||||
|
finally {
|
||||||
|
try { if (pool) await pool.end(); }
|
||||||
|
finally {
|
||||||
|
if (container) {
|
||||||
|
try { docker(["rm", "-f", container]); }
|
||||||
|
catch (error) {
|
||||||
|
if (docker(["ps", "-a", "--no-trunc", "--format", "{{.ID}}"]).split("\n").includes(container)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({ status: "PASS_ISOLATED_RECEIPT_DB", passed, cleanupComplete: true,
|
||||||
|
productionAccess: false, cloudCalls: false, httpTested: true, authentication: "synthetic_actor_fixture" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
const location = String(error.stack ?? "").match(/test-payment-receipt-db\.cjs:(\d+):(\d+)/);
|
||||||
|
console.error(JSON.stringify({ status: "ISOLATED_RECEIPT_DB_FAILED",
|
||||||
|
dockerOperation: error.dockerOperation ?? null, dockerExit: error.dockerExit ?? null,
|
||||||
|
code: /^[A-Za-z0-9_]+$/.test(error.code ?? "") ? error.code : null,
|
||||||
|
actualCode: /^[A-Za-z0-9_]+$/.test(error.actual?.code ?? "") ? error.actual.code : null,
|
||||||
|
actualName: /^[A-Za-z0-9_]+$/.test(error.actual?.name ?? "") ? error.actual.name : null,
|
||||||
|
databaseCode: /^[A-Za-z0-9_]+$/.test(error.actual?.meta?.code ?? "") ? error.actual.meta.code : null,
|
||||||
|
line: location ? Number(location[1]) : null }));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
ALTER TABLE "payment_batch_receipt_attachments" ADD COLUMN "receipt_ocr_version" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "payment_batch_receipt_attachments"
|
||||||
|
ADD COLUMN "receipt_ocr_state" VARCHAR(32) NOT NULL DEFAULT 'legacy',
|
||||||
|
ADD COLUMN "receipt_ocr_started_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE TABLE "payment_receipt_verifications" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"attachment_id" UUID NOT NULL,
|
||||||
|
"source_sha256" VARCHAR(64) NOT NULL,
|
||||||
|
"source_version" INTEGER NOT NULL,
|
||||||
|
"status" VARCHAR(32) NOT NULL,
|
||||||
|
"pages_expected" INTEGER NOT NULL,
|
||||||
|
"pages_processed" INTEGER NOT NULL,
|
||||||
|
"normalized_encrypted" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "payment_receipt_verifications_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "payment_receipt_verifications_pages_check" CHECK (
|
||||||
|
pages_expected >= 0 AND pages_processed BETWEEN 0 AND pages_expected
|
||||||
|
),
|
||||||
|
CONSTRAINT "payment_receipt_verifications_attachment_id_fkey" FOREIGN KEY ("attachment_id")
|
||||||
|
REFERENCES "payment_batch_receipt_attachments"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX "payment_receipt_verifications_attachment_id_created_at_idx"
|
||||||
|
ON "payment_receipt_verifications"("attachment_id", "created_at");
|
||||||
|
|
||||||
|
CREATE TABLE "payment_receipt_matches" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"verification_id" UUID NOT NULL,
|
||||||
|
"item_id" UUID NOT NULL,
|
||||||
|
"page_number" INTEGER NOT NULL CHECK (page_number BETWEEN 1 AND 20),
|
||||||
|
"fingerprint" VARCHAR(64) NOT NULL,
|
||||||
|
"key_id" VARCHAR(64) NOT NULL,
|
||||||
|
CONSTRAINT "payment_receipt_matches_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "payment_receipt_matches_verification_id_fkey" FOREIGN KEY ("verification_id")
|
||||||
|
REFERENCES "payment_receipt_verifications"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "payment_receipt_matches_item_id_fkey" FOREIGN KEY ("item_id")
|
||||||
|
REFERENCES "payment_batch_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "payment_receipt_matches_verification_id_page_number_key"
|
||||||
|
ON "payment_receipt_matches"("verification_id", "page_number");
|
||||||
|
CREATE UNIQUE INDEX "payment_receipt_matches_verification_id_item_id_key"
|
||||||
|
ON "payment_receipt_matches"("verification_id", "item_id");
|
||||||
|
CREATE INDEX "payment_receipt_matches_item_id_idx" ON "payment_receipt_matches"("item_id");
|
||||||
|
|
||||||
|
CREATE TABLE "payment_receipt_claims" (
|
||||||
|
"fingerprint" VARCHAR(64) NOT NULL,
|
||||||
|
"key_id" VARCHAR(64) NOT NULL,
|
||||||
|
"item_id" UUID NOT NULL,
|
||||||
|
"verification_id" UUID NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "payment_receipt_claims_pkey" PRIMARY KEY ("fingerprint"),
|
||||||
|
CONSTRAINT "payment_receipt_claims_item_id_fkey" FOREIGN KEY ("item_id")
|
||||||
|
REFERENCES "payment_batch_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "payment_receipt_claims_verification_id_fkey" FOREIGN KEY ("verification_id")
|
||||||
|
REFERENCES "payment_receipt_verifications"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "payment_receipt_claims_item_id_key" ON "payment_receipt_claims"("item_id");
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE "payment_receipt_manual_proposals" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"attachment_id" UUID NOT NULL REFERENCES "payment_batch_receipt_attachments"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
"original_verification_id" UUID NOT NULL REFERENCES "payment_receipt_verifications"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
"source_sha256" VARCHAR(64) NOT NULL,
|
||||||
|
"source_version" INTEGER NOT NULL CHECK ("source_version" > 0),
|
||||||
|
"status" VARCHAR(32) NOT NULL CHECK ("status" IN ('pending_review', 'association_ready', 'approved', 'rejected')),
|
||||||
|
"proposed_by" UUID NOT NULL,
|
||||||
|
"reviewed_by" UUID,
|
||||||
|
"payload_encrypted" TEXT NOT NULL,
|
||||||
|
"review_note_encrypted" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"reviewed_at" TIMESTAMP(3),
|
||||||
|
CONSTRAINT "payment_receipt_manual_review_decision_check" CHECK (
|
||||||
|
("status" IN ('pending_review', 'association_ready') AND "reviewed_by" IS NULL AND "reviewed_at" IS NULL)
|
||||||
|
OR ("status" IN ('approved', 'rejected') AND "reviewed_by" IS NOT NULL AND "reviewed_at" IS NOT NULL
|
||||||
|
AND "reviewed_by" <> "proposed_by")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "payment_receipt_manual_proposals_attachment_id_source_version_key"
|
||||||
|
ON "payment_receipt_manual_proposals"("attachment_id", "source_version");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE "payment_receipt_claims" ADD COLUMN "manual_proposal_id" UUID;
|
||||||
|
ALTER TABLE "payment_receipt_claims" ADD CONSTRAINT "payment_receipt_claims_manual_proposal_id_fkey"
|
||||||
|
FOREIGN KEY ("manual_proposal_id") REFERENCES "payment_receipt_manual_proposals"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
CREATE TABLE "payment_receipt_transaction_aliases" (
|
||||||
|
"fingerprint" VARCHAR(64) PRIMARY KEY,
|
||||||
|
"key_id" VARCHAR(64) NOT NULL,
|
||||||
|
"claim_fingerprint" VARCHAR(64) NOT NULL REFERENCES "payment_receipt_claims"("fingerprint") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX "payment_receipt_transaction_aliases_claim_fingerprint_idx"
|
||||||
|
ON "payment_receipt_transaction_aliases"("claim_fingerprint");
|
||||||
|
INSERT INTO "payment_receipt_transaction_aliases" ("fingerprint", "key_id", "claim_fingerprint")
|
||||||
|
SELECT "fingerprint", "key_id", "fingerprint" FROM "payment_receipt_claims";
|
||||||
@@ -1966,6 +1966,8 @@ model PaymentBatchItem {
|
|||||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
|
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([payment_batch_id, status], map: "idx_payment_batch_items_batch_status")
|
@@index([payment_batch_id, status], map: "idx_payment_batch_items_batch_status")
|
||||||
|
receipt_matches PaymentReceiptMatch[]
|
||||||
|
receipt_claim PaymentReceiptClaim?
|
||||||
@@index([entity_id], map: "idx_payment_batch_items_entity_id")
|
@@index([entity_id], map: "idx_payment_batch_items_entity_id")
|
||||||
@@map("payment_batch_items")
|
@@map("payment_batch_items")
|
||||||
}
|
}
|
||||||
@@ -1993,6 +1995,11 @@ model PaymentBatchReceiptAttachment {
|
|||||||
|
|
||||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
|
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
|
||||||
uploader Employee @relation("PaymentBatchReceiptAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
|
uploader Employee @relation("PaymentBatchReceiptAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||||
|
verifications PaymentReceiptVerification[]
|
||||||
|
manual_proposals PaymentReceiptManualProposal[]
|
||||||
|
receipt_ocr_version Int @default(0)
|
||||||
|
receipt_ocr_state String @default("legacy") @db.VarChar(32)
|
||||||
|
receipt_ocr_started_at DateTime?
|
||||||
|
|
||||||
@@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created")
|
@@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created")
|
||||||
@@index([uploaded_by], map: "idx_payment_batch_receipts_uploader")
|
@@index([uploaded_by], map: "idx_payment_batch_receipts_uploader")
|
||||||
@@ -2002,6 +2009,88 @@ model PaymentBatchReceiptAttachment {
|
|||||||
@@map("payment_batch_receipt_attachments")
|
@@map("payment_batch_receipt_attachments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PaymentReceiptVerification {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
attachment_id String @db.Uuid
|
||||||
|
source_sha256 String @db.VarChar(64)
|
||||||
|
source_version Int
|
||||||
|
status String @db.VarChar(32)
|
||||||
|
pages_expected Int
|
||||||
|
pages_processed Int
|
||||||
|
normalized_encrypted String
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
attachment PaymentBatchReceiptAttachment @relation(fields: [attachment_id], references: [id], onDelete: Restrict)
|
||||||
|
matches PaymentReceiptMatch[]
|
||||||
|
claims PaymentReceiptClaim[]
|
||||||
|
manual_proposals PaymentReceiptManualProposal[]
|
||||||
|
|
||||||
|
@@index([attachment_id, created_at])
|
||||||
|
@@map("payment_receipt_verifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PaymentReceiptManualProposal {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
attachment_id String @db.Uuid
|
||||||
|
original_verification_id String @db.Uuid
|
||||||
|
source_sha256 String @db.VarChar(64)
|
||||||
|
source_version Int
|
||||||
|
status String @db.VarChar(32)
|
||||||
|
proposed_by String @db.Uuid
|
||||||
|
reviewed_by String? @db.Uuid
|
||||||
|
payload_encrypted String
|
||||||
|
review_note_encrypted String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
reviewed_at DateTime?
|
||||||
|
attachment PaymentBatchReceiptAttachment @relation(fields: [attachment_id], references: [id], onDelete: Restrict)
|
||||||
|
original_verification PaymentReceiptVerification @relation(fields: [original_verification_id], references: [id], onDelete: Restrict)
|
||||||
|
claims PaymentReceiptClaim[]
|
||||||
|
|
||||||
|
@@unique([attachment_id, source_version])
|
||||||
|
@@map("payment_receipt_manual_proposals")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PaymentReceiptMatch {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
verification_id String @db.Uuid
|
||||||
|
item_id String @db.Uuid
|
||||||
|
page_number Int
|
||||||
|
fingerprint String @db.VarChar(64)
|
||||||
|
key_id String @db.VarChar(64)
|
||||||
|
verification PaymentReceiptVerification @relation(fields: [verification_id], references: [id], onDelete: Restrict)
|
||||||
|
item PaymentBatchItem @relation(fields: [item_id], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@unique([verification_id, page_number])
|
||||||
|
@@unique([verification_id, item_id])
|
||||||
|
@@index([item_id])
|
||||||
|
@@map("payment_receipt_matches")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PaymentReceiptClaim {
|
||||||
|
fingerprint String @id @db.VarChar(64)
|
||||||
|
key_id String @db.VarChar(64)
|
||||||
|
item_id String @unique @db.Uuid
|
||||||
|
verification_id String @db.Uuid
|
||||||
|
manual_proposal_id String? @db.Uuid
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
item PaymentBatchItem @relation(fields: [item_id], references: [id], onDelete: Restrict)
|
||||||
|
verification PaymentReceiptVerification @relation(fields: [verification_id], references: [id], onDelete: Restrict)
|
||||||
|
manual_proposal PaymentReceiptManualProposal? @relation(fields: [manual_proposal_id], references: [id], onDelete: Restrict)
|
||||||
|
aliases PaymentReceiptTransactionAlias[]
|
||||||
|
|
||||||
|
@@map("payment_receipt_claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PaymentReceiptTransactionAlias {
|
||||||
|
fingerprint String @id @db.VarChar(64)
|
||||||
|
key_id String @db.VarChar(64)
|
||||||
|
claim_fingerprint String @db.VarChar(64)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
claim PaymentReceiptClaim @relation(fields: [claim_fingerprint], references: [fingerprint], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([claim_fingerprint])
|
||||||
|
@@map("payment_receipt_transaction_aliases")
|
||||||
|
}
|
||||||
|
|
||||||
model PortalAnnouncement {
|
model PortalAnnouncement {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
title String
|
title String
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const ADVISORY_LOCK_NAMESPACES = Object.freeze({
|
const ADVISORY_LOCK_NAMESPACES = Object.freeze({
|
||||||
paymentBatchDailySequence: 1001,
|
paymentBatchDailySequence: 1001,
|
||||||
|
paymentReceiptClaims: 1002,
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ const ERROR_REASONS = [
|
|||||||
{"code":"payment_batch.invalid_identifier","domain":"payment_batch","audience":"user","defaultMessage":"付款记录标识已失效,请刷新后重新选择。","recovery":"follow_guidance"},
|
{"code":"payment_batch.invalid_identifier","domain":"payment_batch","audience":"user","defaultMessage":"付款记录标识已失效,请刷新后重新选择。","recovery":"follow_guidance"},
|
||||||
{"code":"payment_batch.invalid_input","domain":"payment_batch","audience":"user","defaultMessage":"付款操作内容不完整或格式不正确,请核对页面必填项后重试。","recovery":"follow_guidance"},
|
{"code":"payment_batch.invalid_input","domain":"payment_batch","audience":"user","defaultMessage":"付款操作内容不完整或格式不正确,请核对页面必填项后重试。","recovery":"follow_guidance"},
|
||||||
{"code":"payment_batch.bank_receipt_required","domain":"payment_batch","audience":"user","defaultMessage":"银行付款登记前必须上传有效银行回单,请先补充回单。","recovery":"follow_guidance"},
|
{"code":"payment_batch.bank_receipt_required","domain":"payment_batch","audience":"user","defaultMessage":"银行付款登记前必须上传有效银行回单,请先补充回单。","recovery":"follow_guidance"},
|
||||||
|
{"code":"payment_batch.receipt_retry_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"回单状态已变化或仍在识别,请刷新后查看;处理中超过15分钟仍未完成时可重试。","recovery":"follow_guidance"},
|
||||||
|
{"code":"payment_batch.receipt_independent_review_required","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_verification_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_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_identity_unresolved","domain":"payment_batch","audience":"user","defaultMessage":"无法完整确认报销参与人身份,请管理员核实人员关系后再复核;不能自行批准。","recovery":"follow_guidance"},
|
||||||
{"code":"payment_batch.department_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。","recovery":"follow_guidance"},
|
{"code":"payment_batch.department_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。","recovery":"follow_guidance"},
|
||||||
{"code":"payment_batch.source_not_found","domain":"payment_batch","audience":"user","defaultMessage":"部分来源单据已不存在或不可用,请刷新后重新选择。","recovery":"follow_guidance"},
|
{"code":"payment_batch.source_not_found","domain":"payment_batch","audience":"user","defaultMessage":"部分来源单据已不存在或不可用,请刷新后重新选择。","recovery":"follow_guidance"},
|
||||||
{"code":"payment_batch.daily_sequence_exhausted","domain":"payment_batch","audience":"user","defaultMessage":"今日付款批次编号已用完,请联系管理员处理,勿重复提交。","recovery":"follow_guidance"},
|
{"code":"payment_batch.daily_sequence_exhausted","domain":"payment_batch","audience":"user","defaultMessage":"今日付款批次编号已用完,请联系管理员处理,勿重复提交。","recovery":"follow_guidance"},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { startDelegationRevokeJob } = require("./delegationRevokeJob");
|
const { startDelegationRevokeJob } = require("./delegationRevokeJob");
|
||||||
const { startInvoiceArchiveJob } = require("./invoiceArchiveJob");
|
const { startInvoiceArchiveJob } = require("./invoiceArchiveJob");
|
||||||
const { startOcrJob } = require("./ocrJob");
|
const { startOcrJob } = require("./ocrJob");
|
||||||
|
const { startReceiptOcrJob } = require("./paymentReceiptOcrJob");
|
||||||
const { startIntegrationOutboxWorker } = require("./integrationOutboxWorker");
|
const { startIntegrationOutboxWorker } = require("./integrationOutboxWorker");
|
||||||
const { startErpMasterDataSyncJob } = require("./erpMasterDataSyncJob");
|
const { startErpMasterDataSyncJob } = require("./erpMasterDataSyncJob");
|
||||||
const { startPgBossJobs } = require("./pgBossJobs");
|
const { startPgBossJobs } = require("./pgBossJobs");
|
||||||
@@ -18,6 +19,7 @@ async function startJobs(fastify) {
|
|||||||
|
|
||||||
startDelegationRevokeJob(fastify);
|
startDelegationRevokeJob(fastify);
|
||||||
startOcrJob(fastify);
|
startOcrJob(fastify);
|
||||||
|
startReceiptOcrJob(fastify);
|
||||||
startInvoiceArchiveJob(fastify);
|
startInvoiceArchiveJob(fastify);
|
||||||
startIntegrationOutboxWorker(fastify);
|
startIntegrationOutboxWorker(fastify);
|
||||||
startErpMasterDataSyncJob(fastify);
|
startErpMasterDataSyncJob(fastify);
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
const { createHash } = require("node:crypto");
|
||||||
|
const { readFileBuffer } = require("../services/storage");
|
||||||
|
const { recognizePaymentReceipt } = require("../services/paymentReceiptOcr");
|
||||||
|
const { storeReceiptRecognition } = require("../services/paymentReceiptVerification");
|
||||||
|
const { requiresBankReceipt } = require("../services/paymentProcessingProgress");
|
||||||
|
const { resolveFieldEncryptionKey } = require("../services/crypto");
|
||||||
|
|
||||||
|
async function recordReceiptSystemFailure(prisma, attachment, state) {
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id = ${attachment.payment_batch_id}::uuid FOR UPDATE`;
|
||||||
|
const batch = await tx.paymentBatch.findUnique({ where: { id: attachment.payment_batch_id } });
|
||||||
|
if (!batch || !["reviewed", "failed"].includes(batch.status)) return { status: "discarded" };
|
||||||
|
const result = await tx.paymentBatchReceiptAttachment.updateMany({
|
||||||
|
where: { id: attachment.id, payment_batch_id: attachment.payment_batch_id,
|
||||||
|
receipt_ocr_version: attachment.receipt_ocr_version, file_sha256: attachment.file_sha256,
|
||||||
|
receipt_ocr_state: "processing", archived_at: null, governance_status: "approved" },
|
||||||
|
data: { receipt_ocr_state: state },
|
||||||
|
});
|
||||||
|
return { status: result.count === 1 ? state : "discarded" };
|
||||||
|
}, { maxWait: 5000, timeout: 10000 });
|
||||||
|
} catch {
|
||||||
|
return { status: "failure_recording_failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runReceiptOcrTick(fastify) {
|
||||||
|
try {
|
||||||
|
const result = await runReceiptOcrOnce({ prisma: fastify.prisma, config: fastify.config });
|
||||||
|
if (!["idle", "matched", "needs_review", "discarded"].includes(result.status)) {
|
||||||
|
fastify.log.warn({ outcome: result.status }, "Receipt OCR requires attention");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
fastify.log.warn({ outcome: "tick_failed" }, "Receipt OCR tick failed");
|
||||||
|
return { status: "tick_failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReceiptOcrJob(fastify) {
|
||||||
|
const cron = require("node-cron");
|
||||||
|
let running = false;
|
||||||
|
const task = cron.schedule(fastify.config.ocrPollCron, async () => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
try { await runReceiptOcrTick(fastify); }
|
||||||
|
finally { running = false; }
|
||||||
|
});
|
||||||
|
fastify.addHook("onClose", async () => { task.stop(); task.destroy(); });
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startReceiptPgBossJob(fastify, boss) {
|
||||||
|
const queue = "payment.receipt.ocr";
|
||||||
|
await boss.createQueue(queue, { retryLimit: 0, expireInSeconds: 900 });
|
||||||
|
await boss.work(queue, { batchSize: 1 }, async () => runReceiptOcrTick(fastify));
|
||||||
|
await boss.schedule(queue, fastify.config.ocrPollCron, null, {
|
||||||
|
tz: fastify.config.appTimezone, singletonKey: queue, retryLimit: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimReceiptJob(prisma) {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const batches = await tx.$queryRaw`
|
||||||
|
SELECT batch.id FROM payment_batches batch
|
||||||
|
WHERE batch.status IN ('reviewed', 'failed') AND EXISTS (
|
||||||
|
SELECT 1 FROM payment_batch_receipt_attachments attachment
|
||||||
|
WHERE attachment.payment_batch_id = batch.id
|
||||||
|
AND attachment.receipt_ocr_state = 'queued'
|
||||||
|
AND attachment.archived_at IS NULL AND attachment.governance_status = 'approved'
|
||||||
|
) ORDER BY batch.id FOR UPDATE SKIP LOCKED LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!batches.length) return null;
|
||||||
|
const batch = await tx.paymentBatch.findUnique({ where: { id: batches[0].id }, include: { items: true } });
|
||||||
|
if (batch?.entity_type !== "reimbursement" || !requiresBankReceipt(batch)) {
|
||||||
|
await tx.paymentBatchReceiptAttachment.updateMany({
|
||||||
|
where: { payment_batch_id: batches[0].id, receipt_ocr_state: "queued" },
|
||||||
|
data: { receipt_ocr_state: "legacy" },
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const attachments = await tx.$queryRaw`
|
||||||
|
SELECT id FROM payment_batch_receipt_attachments
|
||||||
|
WHERE payment_batch_id = ${batches[0].id}::uuid AND receipt_ocr_state = 'queued'
|
||||||
|
AND archived_at IS NULL AND governance_status = 'approved'
|
||||||
|
ORDER BY created_at, id FOR UPDATE SKIP LOCKED LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!attachments.length) return null;
|
||||||
|
return tx.paymentBatchReceiptAttachment.update({
|
||||||
|
where: { id: attachments[0].id },
|
||||||
|
data: { receipt_ocr_state: "processing", receipt_ocr_started_at: new Date(),
|
||||||
|
receipt_ocr_version: { increment: 1 } },
|
||||||
|
});
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runReceiptOcrOnce({ prisma, config, readBuffer = readFileBuffer, recognize = recognizePaymentReceipt }) {
|
||||||
|
const attachment = await claimReceiptJob(prisma);
|
||||||
|
if (!attachment) return { status: "idle" };
|
||||||
|
if (!resolveFieldEncryptionKey(config)) {
|
||||||
|
return recordReceiptSystemFailure(prisma, attachment, "key_failed");
|
||||||
|
}
|
||||||
|
let recognition;
|
||||||
|
try {
|
||||||
|
if (attachment.file_size <= 0 || attachment.file_size > 5 * 1024 * 1024) throw new Error("size");
|
||||||
|
const buffer = await readBuffer({ config, storagePath: attachment.storage_path });
|
||||||
|
if (buffer.length !== attachment.file_size ||
|
||||||
|
createHash("sha256").update(buffer).digest("hex") !== attachment.file_sha256) throw new Error("integrity");
|
||||||
|
recognition = await recognize({ buffer, mimeType: attachment.mime_type, config });
|
||||||
|
} catch {
|
||||||
|
recognition = { status: "ocr_failed", pagesExpected: 0, pagesProcessed: 0, pages: [],
|
||||||
|
reason: "receipt_processing_failed" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const verification = await storeReceiptRecognition(tx, config, {
|
||||||
|
batchId: attachment.payment_batch_id, attachmentId: attachment.id,
|
||||||
|
sourceSha256: attachment.file_sha256, expectedVersion: attachment.receipt_ocr_version, recognition,
|
||||||
|
});
|
||||||
|
await tx.paymentBatchReceiptAttachment.update({
|
||||||
|
where: { id: attachment.id },
|
||||||
|
data: { receipt_ocr_state: recognition.status === "ocr_failed" ? "ocr_failed" : verification.status },
|
||||||
|
});
|
||||||
|
return { status: recognition.status === "ocr_failed" ? "ocr_failed" : verification.status };
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
} catch (error) {
|
||||||
|
if (["receipt_job_superseded", "receipt_source_changed", "receipt_unavailable", "receipt_batch_not_editable"]
|
||||||
|
.includes(error.code)) return { status: "discarded" };
|
||||||
|
const state = ["receipt_key_unavailable", "receipt_key_rotation_required"].includes(error.code) ? "key_failed" :
|
||||||
|
["receipt_bank_unsupported", "receipt_business_unsupported"].includes(error.code) ? "snapshot_failed" : "system_failed";
|
||||||
|
return recordReceiptSystemFailure(prisma, attachment, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { claimReceiptJob, runReceiptOcrOnce, runReceiptOcrTick, startReceiptOcrJob, startReceiptPgBossJob };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const PgBoss = require("pg-boss");
|
const PgBoss = require("pg-boss");
|
||||||
|
const { startReceiptPgBossJob } = require("./paymentReceiptOcrJob");
|
||||||
|
|
||||||
const { revokeExpiredDelegations } = require("./delegationRevokeJob");
|
const { revokeExpiredDelegations } = require("./delegationRevokeJob");
|
||||||
const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
|
const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
|
||||||
@@ -45,6 +46,7 @@ async function startPgBossJobs(fastify) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await boss.createQueue(JOB_OCR_TICK);
|
await boss.createQueue(JOB_OCR_TICK);
|
||||||
|
await startReceiptPgBossJob(fastify, boss);
|
||||||
await boss.createQueue(JOB_OCR_INVOICE);
|
await boss.createQueue(JOB_OCR_INVOICE);
|
||||||
await boss.createQueue(JOB_DELEGATION_REVOKE);
|
await boss.createQueue(JOB_DELEGATION_REVOKE);
|
||||||
await boss.createQueue(JOB_INVOICE_ARCHIVE);
|
await boss.createQueue(JOB_INVOICE_ARCHIVE);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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 { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail } = 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");
|
||||||
@@ -89,6 +91,51 @@ const REASON_SCHEMA = z.object({
|
|||||||
|
|
||||||
const PAYMENT_ORDER_SCHEMA = REASON_SCHEMA.partial();
|
const PAYMENT_ORDER_SCHEMA = REASON_SCHEMA.partial();
|
||||||
|
|
||||||
|
const RECEIPT_MANUAL_SCHEMA = z.object({
|
||||||
|
expectedVersion: z.number().int().min(0).max(2147483646),
|
||||||
|
reason: z.string().trim().min(1).max(500).optional(),
|
||||||
|
pages: z.array(z.object({
|
||||||
|
pageNumber: z.number().int().min(1).max(20),
|
||||||
|
itemId: UUID_SCHEMA,
|
||||||
|
fields: z.array(z.object({
|
||||||
|
Name: z.string().max(64), Value: z.string().max(256),
|
||||||
|
}).strict()).min(1).max(200).optional(),
|
||||||
|
}).strict()).min(1).max(20),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const RECEIPT_MANUAL_REVIEW_SCHEMA = z.object({
|
||||||
|
expectedVersion: z.number().int().min(0).max(2147483647),
|
||||||
|
decision: z.enum(["approve", "reject"]),
|
||||||
|
reason: z.string().trim().min(1).max(500),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
function mapManualReceiptError(error) {
|
||||||
|
if (!(error instanceof ReceiptVerificationError)) throw error;
|
||||||
|
if (error.code === "receipt_key_unavailable") {
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_key_unavailable", {}, 409);
|
||||||
|
}
|
||||||
|
if (error.code === "receipt_key_rotation_required") {
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_key_rotation_required", {}, 409);
|
||||||
|
}
|
||||||
|
if (["receipt_bank_unsupported", "receipt_business_unsupported"].includes(error.code)) {
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_snapshot_unsupported", {}, 409);
|
||||||
|
}
|
||||||
|
if (error.code === "receipt_review_identity_unresolved") {
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_identity_unresolved", {}, 409);
|
||||||
|
}
|
||||||
|
if (["receipt_review_permission_required", "receipt_review_identity_conflict"].includes(error.code)) {
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_independent_review_required", {}, 403);
|
||||||
|
}
|
||||||
|
if (error.code === "receipt_transaction_reused") {
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_transaction_reused", {}, 409);
|
||||||
|
}
|
||||||
|
if (["receipt_source_changed", "receipt_job_superseded", "receipt_unavailable",
|
||||||
|
"receipt_manual_not_available", "receipt_manual_not_reviewable", "receipt_batch_not_editable"].includes(error.code)) {
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_manual_state_changed", {}, 409);
|
||||||
|
}
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_manual_verification_required", {}, 409);
|
||||||
|
}
|
||||||
|
|
||||||
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
|
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
|
||||||
mode: z.enum(["download", "preview"]).default("download"),
|
mode: z.enum(["download", "preview"]).default("download"),
|
||||||
reason: z.string().max(500).optional(),
|
reason: z.string().max(500).optional(),
|
||||||
@@ -123,9 +170,14 @@ function normalizeNullableString(value) {
|
|||||||
return trimmed || null;
|
return trimmed || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapReceiptAttachment(item) {
|
function mapReceiptAttachment(item, batch) {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
ocrState: item.receipt_ocr_state ?? "legacy",
|
||||||
|
ocrVersion: item.receipt_ocr_version ?? 0,
|
||||||
|
ocrStartedAt: item.receipt_ocr_started_at?.toISOString?.() ?? null,
|
||||||
|
ocrRetryAvailable: batch?.entity_type === "reimbursement" && requiresBankReceipt(batch) &&
|
||||||
|
canRetryReceiptRecognition(item),
|
||||||
originalName: item.original_name,
|
originalName: item.original_name,
|
||||||
mimeType: item.mime_type,
|
mimeType: item.mime_type,
|
||||||
fileSize: item.file_size,
|
fileSize: item.file_size,
|
||||||
@@ -1770,6 +1822,26 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.get("/payment-batches/:id/receipt-readiness", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
ensureBatchManagePermission(fastify, request);
|
||||||
|
await ensureFeatureEnabled(fastify);
|
||||||
|
const batchId = parseId(request.params?.id);
|
||||||
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
|
||||||
|
const batch = await loadBatchWithItems(tx, batchId);
|
||||||
|
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||||
|
if (!["reviewed", "failed"].includes(batch.status)) return { ready: false };
|
||||||
|
try {
|
||||||
|
if (batch.entity_type === "reimbursement") await claimVerifiedReceipts(tx, fastify.config, batch, { readOnly: true });
|
||||||
|
return { ready: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ReceiptVerificationError)) throw error;
|
||||||
|
return { ready: false };
|
||||||
|
}
|
||||||
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, maxWait: 5000, timeout: 10000 });
|
||||||
|
return sendData(reply, request, { item: result });
|
||||||
|
});
|
||||||
|
|
||||||
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
ensureBatchReadPermission(fastify, request);
|
ensureBatchReadPermission(fastify, request);
|
||||||
await ensureFeatureEnabled(fastify);
|
await ensureFeatureEnabled(fastify);
|
||||||
@@ -1783,6 +1855,9 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
original_name: true,
|
original_name: true,
|
||||||
|
receipt_ocr_state: true,
|
||||||
|
receipt_ocr_version: true,
|
||||||
|
receipt_ocr_started_at: true,
|
||||||
mime_type: true,
|
mime_type: true,
|
||||||
file_size: true,
|
file_size: true,
|
||||||
file_sha256: true,
|
file_sha256: true,
|
||||||
@@ -1807,7 +1882,7 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return sendData(reply, request, { items: items.map(mapReceiptAttachment) });
|
return sendData(reply, request, { items: items.map((item) => mapReceiptAttachment(item, batch)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
@@ -1857,6 +1932,7 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
file_size: file.fileSize,
|
file_size: file.fileSize,
|
||||||
file_sha256: file.fileSha256,
|
file_sha256: file.fileSha256,
|
||||||
uploaded_by: request.employee.id,
|
uploaded_by: request.employee.id,
|
||||||
|
receipt_ocr_state: lockedBatch.entity_type === "reimbursement" && requiresBankReceipt(lockedBatch) ? "queued" : "legacy",
|
||||||
...governanceCreateData({}, {
|
...governanceCreateData({}, {
|
||||||
governanceStatus: "approved",
|
governanceStatus: "approved",
|
||||||
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
|
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
|
||||||
@@ -1867,6 +1943,9 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
original_name: true,
|
original_name: true,
|
||||||
|
receipt_ocr_state: true,
|
||||||
|
receipt_ocr_version: true,
|
||||||
|
receipt_ocr_started_at: true,
|
||||||
mime_type: true,
|
mime_type: true,
|
||||||
file_size: true,
|
file_size: true,
|
||||||
file_sha256: true,
|
file_sha256: true,
|
||||||
@@ -1905,7 +1984,7 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return created;
|
return created.map((item) => mapReceiptAttachment(item, lockedBatch));
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1918,7 +1997,7 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendData(reply, request, { items: createdItems.map(mapReceiptAttachment) });
|
return sendData(reply, request, { items: createdItems });
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
@@ -2106,6 +2185,95 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
throw buildBusinessValidationError("payment_batch.receipt_void_required", { receiptAttachment: "void_required" }, 409);
|
throw buildBusinessValidationError("payment_batch.receipt_void_required", { receiptAttachment: "void_required" }, 409);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
ensureBatchReadPermission(fastify, request);
|
||||||
|
await ensureFeatureEnabled(fastify);
|
||||||
|
const batchId = parseId(request.params?.id);
|
||||||
|
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
||||||
|
try {
|
||||||
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
|
||||||
|
const batch = await loadBatchWithItems(tx, batchId);
|
||||||
|
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||||
|
return readManualReceiptDetail(tx, fastify.config, { batchId, attachmentId, reviewer: request.employee,
|
||||||
|
hasReviewPermission: fastify.hasPermission(request.employee, "payment.batch.review") });
|
||||||
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, maxWait: 5000, timeout: 10000 });
|
||||||
|
return sendData(reply, request, { item: result });
|
||||||
|
} catch (error) {
|
||||||
|
mapManualReceiptError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: fastify.authenticate }, 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(RECEIPT_MANUAL_SCHEMA, request.body);
|
||||||
|
try {
|
||||||
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const batch = await lockBatchWithItems(tx, batchId);
|
||||||
|
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||||
|
return saveManualReceiptProposal(tx, fastify.config, request, { ...body, batchId, attachmentId });
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
return sendData(reply, request, { item: result });
|
||||||
|
} catch (error) {
|
||||||
|
mapManualReceiptError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification/:proposalId/review", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
ensureBatchReviewPermission(fastify, request);
|
||||||
|
await ensureFeatureEnabled(fastify);
|
||||||
|
const batchId = parseId(request.params?.id);
|
||||||
|
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
||||||
|
const proposalId = parseId(request.params?.proposalId, "proposalId");
|
||||||
|
const body = parseBody(RECEIPT_MANUAL_REVIEW_SCHEMA, request.body);
|
||||||
|
try {
|
||||||
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const batch = await lockBatchWithItems(tx, batchId);
|
||||||
|
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||||
|
return reviewManualReceiptProposal(tx, fastify.config, request, { ...body, batchId, attachmentId, proposalId },
|
||||||
|
fastify.hasPermission(request.employee, "payment.batch.review"));
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
return sendData(reply, request, { item: result });
|
||||||
|
} catch (error) {
|
||||||
|
mapManualReceiptError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/ocr-retry", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
ensureBatchManagePermission(fastify, request);
|
||||||
|
await ensureFeatureEnabled(fastify);
|
||||||
|
const batchId = parseId(request.params?.id);
|
||||||
|
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
||||||
|
const { expectedVersion } = parseBody(z.object({ expectedVersion: z.number().int().min(0).max(2147483646) }).strict(), request.body);
|
||||||
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const batch = await lockBatchWithItems(tx, batchId);
|
||||||
|
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||||
|
if (batch.entity_type !== "reimbursement" || !requiresBankReceipt(batch)) {
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_retry_unavailable", {}, 409);
|
||||||
|
}
|
||||||
|
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_not_editable", {}, 409);
|
||||||
|
}
|
||||||
|
let queued;
|
||||||
|
try {
|
||||||
|
queued = await queueReceiptRetry(tx, { batchId, attachmentId, expectedVersion });
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ReceiptVerificationError)) throw error;
|
||||||
|
throw buildBusinessValidationError("payment_batch.receipt_retry_unavailable", {}, 409);
|
||||||
|
}
|
||||||
|
await tx.auditLog.create({ data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id, action: "PAYMENT_BATCH_RECEIPT_OCR_REQUEUED",
|
||||||
|
targetType: "payment_batch", targetId: batchId,
|
||||||
|
extra: { attachmentId, version: queued.receipt_ocr_version },
|
||||||
|
}) });
|
||||||
|
return { id: queued.id, ocrState: queued.receipt_ocr_state, ocrVersion: queued.receipt_ocr_version };
|
||||||
|
}, { timeout: 10000 });
|
||||||
|
return sendData(reply, request, { item: result });
|
||||||
|
});
|
||||||
|
|
||||||
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/void", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/void", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
ensureBatchManagePermission(fastify, request);
|
ensureBatchManagePermission(fastify, request);
|
||||||
await ensureFeatureEnabled(fastify);
|
await ensureFeatureEnabled(fastify);
|
||||||
@@ -2346,6 +2514,13 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
);
|
);
|
||||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||||
await ensureBankReceiptForExecutableBatch(tx, batch);
|
await ensureBankReceiptForExecutableBatch(tx, batch);
|
||||||
|
if (batch.entity_type === "reimbursement" && requiresBankReceipt(batch)) {
|
||||||
|
try {
|
||||||
|
await claimVerifiedReceipts(tx, fastify.config, batch);
|
||||||
|
} catch (error) {
|
||||||
|
mapManualReceiptError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const finalItemStatuses = new Map(batch.items.map((item) => [item.id, item.status]));
|
const finalItemStatuses = new Map(batch.items.map((item) => [item.id, item.status]));
|
||||||
let attemptSuccessCount = 0;
|
let attemptSuccessCount = 0;
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
const { ReceiptVerificationError, batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
|
||||||
|
const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("./paymentReceiptManualReview");
|
||||||
|
const { encryptStringOrThrow, decryptStringOrThrow } = require("./crypto");
|
||||||
|
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||||
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
|
const { normalizeBankSlip } = require("./paymentReceiptMatching");
|
||||||
|
const { maskBankAccount } = require("./crypto");
|
||||||
|
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
|
||||||
|
|
||||||
|
function requireFact(condition, code) {
|
||||||
|
if (!condition) throw new ReceiptVerificationError(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockContext(tx, { batchId, attachmentId, expectedVersion }) {
|
||||||
|
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id = ${batchId}::uuid FOR UPDATE`;
|
||||||
|
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
|
||||||
|
requireFact(batch?.entity_type === "reimbursement" && ["reviewed", "failed"].includes(batch.status),
|
||||||
|
"receipt_batch_not_editable");
|
||||||
|
await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
|
||||||
|
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id = ${attachmentId}::uuid FOR UPDATE`;
|
||||||
|
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
|
||||||
|
requireFact(attachment?.payment_batch_id === batchId && !attachment.archived_at &&
|
||||||
|
attachment.governance_status === "approved", "receipt_unavailable");
|
||||||
|
requireFact(Number.isInteger(expectedVersion) && expectedVersion >= 0 && expectedVersion <= 2147483647 &&
|
||||||
|
attachment.receipt_ocr_version === expectedVersion, "receipt_job_superseded");
|
||||||
|
return { batch, attachment };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureUnused(tx, config, proposal) {
|
||||||
|
const current = proposal.recognition.pages.map((page) => transactionFingerprint(config, page));
|
||||||
|
requireFact(await tx.paymentReceiptClaim.count({ where: { key_id: { not: current[0].key_id } } }) === 0,
|
||||||
|
"receipt_key_rotation_required");
|
||||||
|
requireFact(await tx.paymentReceiptClaim.count({ where: { OR: [
|
||||||
|
{ fingerprint: { in: [...proposal.originalFingerprints, ...current.map((entry) => entry.fingerprint)] } },
|
||||||
|
{ item_id: { in: proposal.associations.map((entry) => entry.itemId) } },
|
||||||
|
] } }) === 0, "receipt_transaction_reused");
|
||||||
|
requireFact(await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: current[0].key_id } } }) === 0,
|
||||||
|
"receipt_key_rotation_required");
|
||||||
|
requireFact(await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint: {
|
||||||
|
in: [...proposal.originalFingerprints, ...current.map((entry) => entry.fingerprint)],
|
||||||
|
} } }) === 0, "receipt_transaction_reused");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApprovedManualReceiptEvidence(tx, config, batch, attachmentIds, readOnly = false) {
|
||||||
|
const proposals = await tx.paymentReceiptManualProposal.findMany({
|
||||||
|
where: { attachment_id: { in: attachmentIds }, status: { in: ["approved", "association_ready"] } },
|
||||||
|
include: { attachment: true, original_verification: true },
|
||||||
|
});
|
||||||
|
const evidence = [];
|
||||||
|
for (const saved of proposals) {
|
||||||
|
const attachment = saved.attachment;
|
||||||
|
if (attachment.archived_at || attachment.governance_status !== "approved" ||
|
||||||
|
attachment.receipt_ocr_version !== saved.source_version || attachment.file_sha256 !== saved.source_sha256 ||
|
||||||
|
!["manual_approved", "association_ready"].includes(attachment.receipt_ocr_state)) continue;
|
||||||
|
requireFact(attachment.payment_batch_id === batch.id, "receipt_batch_mismatch");
|
||||||
|
const original = saved.original_verification;
|
||||||
|
requireFact(original.attachment_id === attachment.id && original.source_sha256 === saved.source_sha256 &&
|
||||||
|
original.source_version + 1 === saved.source_version, "receipt_source_changed");
|
||||||
|
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
|
||||||
|
const payload = JSON.parse(decryptStringOrThrow(config, saved.payload_encrypted));
|
||||||
|
const proposal = buildManualReceiptProposal(config, {
|
||||||
|
original: recognition, pages: safePages(payload.pages), candidates: batchCandidates(config, batch),
|
||||||
|
});
|
||||||
|
if (proposal.requiresIndependentReview) {
|
||||||
|
requireFact(saved.status === "approved" && saved.reviewed_by && saved.reviewed_at, "receipt_review_permission_required");
|
||||||
|
if (!readOnly) {
|
||||||
|
await tx.$queryRaw`SELECT id FROM employees WHERE id = ${saved.reviewed_by}::uuid FOR SHARE`;
|
||||||
|
await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id = ${saved.reviewed_by}::uuid FOR SHARE`;
|
||||||
|
}
|
||||||
|
const employee = await tx.employee.findUnique({ where: { id: saved.reviewed_by }, include: { roles: true } });
|
||||||
|
requireFact(employee?.status === "active" && !employee.archived_at, "receipt_review_permission_required");
|
||||||
|
const roleCodes = employee.roles.map((role) => role.role_code);
|
||||||
|
const sources = await tx.reimbursementForm.findMany({
|
||||||
|
where: { id: { in: batch.items.map((item) => item.entity_id) } },
|
||||||
|
select: { id: true, owner_id: true, submitter_id: true },
|
||||||
|
});
|
||||||
|
requireFact(sources.length === new Set(batch.items.map((item) => item.entity_id)).size,
|
||||||
|
"receipt_review_identity_unresolved");
|
||||||
|
assertIndependentReceiptReviewer({ reviewer: { id: employee.id, roleCodes },
|
||||||
|
hasReviewPermission: hasPermission(resolvePermissionsForRoles(roleCodes), "payment.batch.review"),
|
||||||
|
makerId: batch.created_by, proposedBy: saved.proposed_by,
|
||||||
|
participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) });
|
||||||
|
} else {
|
||||||
|
requireFact(saved.status === "association_ready", "receipt_match_changed");
|
||||||
|
}
|
||||||
|
evidence.push({ id: original.id, manual_proposal_id: saved.id, matches: proposal.recognition.pages.map((page) => {
|
||||||
|
const identity = transactionFingerprint(config, page);
|
||||||
|
const previous = recognition.pages.find((entry) => entry.pageNumber === page.pageNumber);
|
||||||
|
const aliases = [identity.fingerprint];
|
||||||
|
if (previous?.payerAccount && previous.reference) aliases.push(transactionFingerprint(config, previous).fingerprint);
|
||||||
|
return { ...identity, page_number: page.pageNumber,
|
||||||
|
item_id: proposal.associations.find((entry) => entry.pageNumber === page.pageNumber).itemId,
|
||||||
|
aliases: [...new Set(aliases)].sort() };
|
||||||
|
}) });
|
||||||
|
}
|
||||||
|
return evidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePages(pages) {
|
||||||
|
requireFact(Array.isArray(pages) && pages.length > 0 && pages.length <= 20, "receipt_pages_unresolved");
|
||||||
|
return pages.map((page) => {
|
||||||
|
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 &&
|
||||||
|
typeof field.Value === "string" && field.Value.length <= 256), "receipt_manual_fields_invalid");
|
||||||
|
return { pageNumber: page.pageNumber, itemId: page.itemId,
|
||||||
|
fields: page.fields.map(({ Name, Value }) => ({ Name, Value })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePageFields(pages, original) {
|
||||||
|
requireFact(Array.isArray(pages), "receipt_pages_unresolved");
|
||||||
|
return pages.map((page) => {
|
||||||
|
const source = original.pages?.find((entry) => entry.pageNumber === page?.pageNumber);
|
||||||
|
requireFact(source || page?.fields, "receipt_manual_fields_invalid");
|
||||||
|
const minor = source?.amount;
|
||||||
|
const amount = typeof minor === "string" && /^\d+$/.test(minor) ?
|
||||||
|
`${BigInt(minor) / 100n}.${(BigInt(minor) % 100n).toString().padStart(2, "0")}` : "";
|
||||||
|
const values = {
|
||||||
|
小写金额: amount, 币别: source?.currency ?? "", 付款人账号: source?.payerAccount ?? "",
|
||||||
|
收款人账号: source?.payeeAccount ?? "", 凭证号: source?.reference ?? "",
|
||||||
|
单据状态: source?.transactionStatus === "success" ? "交易成功" : "",
|
||||||
|
};
|
||||||
|
if (page.fields !== undefined) {
|
||||||
|
requireFact(Array.isArray(page.fields) && page.fields.length <= 7 &&
|
||||||
|
new Set(page.fields.map((field) => field?.Name)).size === page.fields.length &&
|
||||||
|
page.fields.every((field) => Object.hasOwn(values, field?.Name) && typeof field.Value === "string" &&
|
||||||
|
field.Value.length <= 256), "receipt_manual_fields_invalid");
|
||||||
|
for (const field of page.fields) values[field.Name] = field.Value;
|
||||||
|
}
|
||||||
|
return { pageNumber: page.pageNumber, itemId: page.itemId,
|
||||||
|
fields: Object.entries(values).map(([Name, Value]) => ({ Name, Value })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageSummary(page) {
|
||||||
|
const amount = typeof page.amount === "string" && /^\d+$/.test(page.amount) ?
|
||||||
|
`${BigInt(page.amount) / 100n}.${(BigInt(page.amount) % 100n).toString().padStart(2, "0")}` : null;
|
||||||
|
return { pageNumber: page.pageNumber, amount, currency: page.currency ?? null,
|
||||||
|
payerAccountMasked: page.payerAccount ? maskBankAccount(page.payerAccount) : null,
|
||||||
|
payeeAccountMasked: page.payeeAccount ? maskBankAccount(page.payeeAccount) : null,
|
||||||
|
transactionStatus: page.transactionStatus ?? null, reference: page.reference ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readManualReceiptDetail(tx, config, { batchId, attachmentId, reviewer, hasReviewPermission }) {
|
||||||
|
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
|
||||||
|
requireFact(attachment?.payment_batch_id === batchId && !attachment.archived_at &&
|
||||||
|
attachment.governance_status === "approved", "receipt_unavailable");
|
||||||
|
const proposal = await tx.paymentReceiptManualProposal.findUnique({
|
||||||
|
where: { attachment_id_source_version: { attachment_id: attachmentId, source_version: attachment.receipt_ocr_version } },
|
||||||
|
include: { original_verification: true },
|
||||||
|
});
|
||||||
|
const original = proposal?.original_verification ?? await tx.paymentReceiptVerification.findFirst({
|
||||||
|
where: { attachment_id: attachmentId }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
if (!original) return { version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
|
||||||
|
pagesExpected: 0, pages: [], proposal: null };
|
||||||
|
requireFact(original.attachment_id === attachmentId && original.source_sha256 === attachment.file_sha256 &&
|
||||||
|
original.source_version + (proposal ? 1 : 0) === attachment.receipt_ocr_version &&
|
||||||
|
(!proposal || proposal.source_sha256 === attachment.file_sha256), "receipt_source_changed");
|
||||||
|
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
|
||||||
|
const payload = proposal ? JSON.parse(decryptStringOrThrow(config, proposal.payload_encrypted)) : null;
|
||||||
|
let canReview = false;
|
||||||
|
if (proposal?.status === "pending_review" && reviewer) {
|
||||||
|
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
|
||||||
|
const sources = await tx.reimbursementForm.findMany({
|
||||||
|
where: { id: { in: batch.items.map((item) => item.entity_id) } },
|
||||||
|
select: { id: true, owner_id: true, submitter_id: true },
|
||||||
|
});
|
||||||
|
if (["reviewed", "failed"].includes(batch.status) &&
|
||||||
|
sources.length === new Set(batch.items.map((item) => item.entity_id)).size) {
|
||||||
|
try {
|
||||||
|
assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId: batch.created_by,
|
||||||
|
proposedBy: proposal.proposed_by,
|
||||||
|
participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) });
|
||||||
|
canReview = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ReceiptVerificationError)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
canReview,
|
||||||
|
version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
|
||||||
|
pagesExpected: recognition.pagesExpected, pages: recognition.pages.map(pageSummary),
|
||||||
|
proposal: proposal ? { id: proposal.id, status: proposal.status, proposedBy: proposal.proposed_by,
|
||||||
|
reviewedBy: proposal.reviewed_by, reason: payload.reason, changedFields: payload.changedFields,
|
||||||
|
pages: payload.pages.map((page) => ({
|
||||||
|
...pageSummary({ pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) }), itemId: page.itemId,
|
||||||
|
})) } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(proposal) {
|
||||||
|
return { id: proposal.id, status: proposal.status, version: proposal.source_version };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function audit(tx, request, batchId, action, proposal) {
|
||||||
|
await tx.auditLog.create({ data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id, action, targetType: "payment_batch", targetId: batchId,
|
||||||
|
extra: { proposalId: proposal.id, attachmentId: proposal.attachment_id,
|
||||||
|
version: proposal.source_version, status: proposal.status },
|
||||||
|
}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveManualReceiptProposal(tx, config, request, input) {
|
||||||
|
const { batch, attachment } = await lockContext(tx, input);
|
||||||
|
requireFact(attachment.receipt_ocr_version < 2147483647, "receipt_job_superseded");
|
||||||
|
requireFact(["needs_review", "ocr_failed"].includes(attachment.receipt_ocr_state),
|
||||||
|
"receipt_manual_not_available");
|
||||||
|
const original = await tx.paymentReceiptVerification.findFirst({
|
||||||
|
where: { attachment_id: attachment.id }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
requireFact(original && original.source_version === attachment.receipt_ocr_version &&
|
||||||
|
original.source_sha256 === attachment.file_sha256, "receipt_source_changed");
|
||||||
|
const originalRecognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
|
||||||
|
const pages = safePages(resolvePageFields(input.pages, originalRecognition));
|
||||||
|
const proposal = buildManualReceiptProposal(config, {
|
||||||
|
original: originalRecognition,
|
||||||
|
pages, candidates: batchCandidates(config, batch),
|
||||||
|
});
|
||||||
|
requireFact(proposal.associations.every((association) => batch.items.some((item) =>
|
||||||
|
item.id === association.itemId && ["pending", "failed"].includes(item.status))), "receipt_item_unavailable");
|
||||||
|
await ensureUnused(tx, config, proposal);
|
||||||
|
requireFact(!proposal.requiresIndependentReview || (typeof input.reason === "string" &&
|
||||||
|
input.reason.trim().length > 0 && input.reason.length <= 500), "receipt_manual_reason_required");
|
||||||
|
const status = proposal.requiresIndependentReview ? "pending_review" : "association_ready";
|
||||||
|
const saved = await tx.paymentReceiptManualProposal.create({ data: {
|
||||||
|
attachment_id: attachment.id, original_verification_id: original.id,
|
||||||
|
source_sha256: attachment.file_sha256, source_version: attachment.receipt_ocr_version + 1,
|
||||||
|
status, proposed_by: request.employee.id,
|
||||||
|
payload_encrypted: encryptStringOrThrow(config, JSON.stringify({
|
||||||
|
pages, reason: proposal.requiresIndependentReview ? input.reason.trim() : "明细关联",
|
||||||
|
reasonSource: proposal.requiresIndependentReview ? "user" : "system", changedFields: proposal.changedFields,
|
||||||
|
})),
|
||||||
|
} });
|
||||||
|
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: {
|
||||||
|
receipt_ocr_version: { increment: 1 }, receipt_ocr_state: status, receipt_ocr_started_at: null,
|
||||||
|
} });
|
||||||
|
await audit(tx, request, batch.id, "PAYMENT_RECEIPT_MANUAL_PROPOSED", saved);
|
||||||
|
return summarize(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviewManualReceiptProposal(tx, config, request, input, hasReviewPermission) {
|
||||||
|
const { batch, attachment } = await lockContext(tx, input);
|
||||||
|
const saved = await tx.paymentReceiptManualProposal.findUnique({ where: { id: input.proposalId },
|
||||||
|
include: { original_verification: true } });
|
||||||
|
requireFact(saved?.attachment_id === attachment.id && saved.source_version === attachment.receipt_ocr_version &&
|
||||||
|
saved.source_sha256 === attachment.file_sha256, "receipt_source_changed");
|
||||||
|
requireFact(saved.status === "pending_review" && attachment.receipt_ocr_state === "pending_review",
|
||||||
|
"receipt_manual_not_reviewable");
|
||||||
|
const sources = await tx.reimbursementForm.findMany({
|
||||||
|
where: { id: { in: batch.items.map((item) => item.entity_id) } },
|
||||||
|
select: { id: true, owner_id: true, submitter_id: true },
|
||||||
|
});
|
||||||
|
requireFact(sources.length === new Set(batch.items.map((item) => item.entity_id)).size,
|
||||||
|
"receipt_review_identity_unresolved");
|
||||||
|
assertIndependentReceiptReviewer({ reviewer: request.employee, hasReviewPermission,
|
||||||
|
makerId: batch.created_by, proposedBy: saved.proposed_by,
|
||||||
|
participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) });
|
||||||
|
requireFact(["approve", "reject"].includes(input.decision), "receipt_manual_decision_invalid");
|
||||||
|
requireFact(typeof input.reason === "string" && input.reason.trim().length > 0 &&
|
||||||
|
input.reason.length <= 500, "receipt_manual_reason_required");
|
||||||
|
if (input.decision === "approve") {
|
||||||
|
const original = saved.original_verification;
|
||||||
|
requireFact(original.attachment_id === attachment.id && original.source_sha256 === saved.source_sha256 &&
|
||||||
|
original.source_version + 1 === saved.source_version, "receipt_source_changed");
|
||||||
|
const payload = JSON.parse(decryptStringOrThrow(config, saved.payload_encrypted));
|
||||||
|
const proposal = buildManualReceiptProposal(config, {
|
||||||
|
original: JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted)),
|
||||||
|
pages: safePages(payload.pages), candidates: batchCandidates(config, batch),
|
||||||
|
});
|
||||||
|
requireFact(proposal.associations.every((association) => batch.items.some((item) =>
|
||||||
|
item.id === association.itemId && ["pending", "failed"].includes(item.status))), "receipt_item_unavailable");
|
||||||
|
await ensureUnused(tx, config, proposal);
|
||||||
|
}
|
||||||
|
const status = input.decision === "approve" ? "approved" : "rejected";
|
||||||
|
const reviewed = await tx.paymentReceiptManualProposal.update({ where: { id: saved.id }, data: {
|
||||||
|
status, reviewed_by: request.employee.id, reviewed_at: new Date(),
|
||||||
|
review_note_encrypted: encryptStringOrThrow(config, input.reason.trim()),
|
||||||
|
} });
|
||||||
|
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: {
|
||||||
|
receipt_ocr_state: status === "approved" ? "manual_approved" : "manual_rejected",
|
||||||
|
} });
|
||||||
|
await audit(tx, request, batch.id, "PAYMENT_RECEIPT_MANUAL_REVIEWED", reviewed);
|
||||||
|
return summarize(reviewed);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail, loadApprovedManualReceiptEvidence };
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching");
|
||||||
|
const { ReceiptVerificationError, transactionFingerprint } = require("./paymentReceiptVerification");
|
||||||
|
|
||||||
|
const FIELDS = ["amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference"];
|
||||||
|
const BLOCKED_ISSUES = ["transaction_failed", "transaction_pending"];
|
||||||
|
|
||||||
|
function requireFact(condition, code) {
|
||||||
|
if (!condition) throw new ReceiptVerificationError(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId, proposedBy, participants }) {
|
||||||
|
requireFact(reviewer?.id && hasReviewPermission === true &&
|
||||||
|
Array.isArray(reviewer.roleCodes) &&
|
||||||
|
reviewer.roleCodes.some((role) => ["manager", "ceo"].includes(role)),
|
||||||
|
"receipt_review_permission_required");
|
||||||
|
requireFact(typeof makerId === "string" && makerId.length > 0 &&
|
||||||
|
typeof proposedBy === "string" && proposedBy.length > 0 &&
|
||||||
|
Array.isArray(participants) && participants.length > 0 &&
|
||||||
|
participants.every((entry) => typeof entry?.ownerId === "string" && entry.ownerId.length > 0 &&
|
||||||
|
typeof entry.submitterId === "string" && entry.submitterId.length > 0),
|
||||||
|
"receipt_review_identity_unresolved");
|
||||||
|
requireFact(![makerId, proposedBy, ...participants.flatMap((entry) => [entry.ownerId, entry.submitterId])]
|
||||||
|
.includes(reviewer.id), "receipt_review_identity_conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManualReceiptProposal(config, { original, pages, candidates }) {
|
||||||
|
requireFact(original && Number.isInteger(original.pagesExpected) &&
|
||||||
|
original.pagesExpected > 0 && original.pagesExpected <= 20, "receipt_pages_unresolved");
|
||||||
|
requireFact(Array.isArray(pages) && pages.length === original.pagesExpected &&
|
||||||
|
pages.every((page, index) => page?.pageNumber === index + 1), "receipt_pages_unresolved");
|
||||||
|
requireFact(Array.isArray(candidates) && candidates.length > 0 &&
|
||||||
|
candidates.every((candidate) => typeof candidate.id === "string" && candidate.id.length > 0) &&
|
||||||
|
new Set(candidates.map((candidate) => candidate.id)).size === candidates.length,
|
||||||
|
"receipt_candidates_invalid");
|
||||||
|
requireFact(Array.isArray(original.pages) &&
|
||||||
|
original.pages.every((page) => Number.isInteger(page.pageNumber) &&
|
||||||
|
page.pageNumber > 0 && page.pageNumber <= original.pagesExpected) &&
|
||||||
|
new Set(original.pages.map((page) => page.pageNumber)).size === original.pages.length,
|
||||||
|
"receipt_original_invalid");
|
||||||
|
const originalFingerprints = original.pages
|
||||||
|
.filter((page) => page.payerAccount && page.reference)
|
||||||
|
.map((page) => transactionFingerprint(config, page).fingerprint);
|
||||||
|
requireFact(new Set(originalFingerprints).size === originalFingerprints.length,
|
||||||
|
"receipt_transaction_reused");
|
||||||
|
const normalizedPages = [];
|
||||||
|
const associations = [];
|
||||||
|
const changedFields = [];
|
||||||
|
const fingerprints = new Set();
|
||||||
|
let requiresIndependentReview = original.status !== "recognized" ||
|
||||||
|
original.pagesProcessed !== original.pagesExpected ||
|
||||||
|
original.pages.length !== original.pagesExpected;
|
||||||
|
for (const page of pages) {
|
||||||
|
const previous = original.pages.find((entry) => entry.pageNumber === page.pageNumber);
|
||||||
|
requireFact(!previous || (!["failed", "pending"].includes(previous.transactionStatus) &&
|
||||||
|
!(previous.issues ?? []).some((issue) => BLOCKED_ISSUES.includes(issue))),
|
||||||
|
"receipt_transaction_not_successful");
|
||||||
|
const normalized = { pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) };
|
||||||
|
const candidate = candidates.find((entry) => entry.id === page.itemId);
|
||||||
|
requireFact(candidate, "receipt_item_unavailable");
|
||||||
|
const match = matchBankSlip(normalized, [candidate]);
|
||||||
|
requireFact(match.status === "matched", "receipt_manual_match_required");
|
||||||
|
const changed = FIELDS.filter((field) => previous?.[field] !== normalized[field]);
|
||||||
|
if (changed.length || previous?.issues?.length) requiresIndependentReview = true;
|
||||||
|
const { fingerprint } = transactionFingerprint(config, normalized);
|
||||||
|
requireFact(!fingerprints.has(fingerprint), "receipt_transaction_reused");
|
||||||
|
fingerprints.add(fingerprint);
|
||||||
|
normalizedPages.push(normalized);
|
||||||
|
associations.push({ pageNumber: page.pageNumber, itemId: candidate.id });
|
||||||
|
changedFields.push({ pageNumber: page.pageNumber, fields: changed });
|
||||||
|
}
|
||||||
|
requireFact(new Set(associations.map((entry) => entry.itemId)).size === associations.length,
|
||||||
|
"receipt_multiple_transactions");
|
||||||
|
return {
|
||||||
|
requiresIndependentReview, changedFields, associations, originalFingerprints, bankVerified: false,
|
||||||
|
recognition: { status: "recognized", pagesExpected: pages.length,
|
||||||
|
pagesProcessed: pages.length, pages: normalizedPages },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildManualReceiptProposal, assertIndependentReceiptReviewer };
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
const FIELD_ALIASES = {
|
||||||
|
amount: ["小写金额", "交易金额", "转账金额", "付款金额", "金额"],
|
||||||
|
currency: ["币别", "币种"],
|
||||||
|
payerAccount: ["付款人账号", "付款账号"],
|
||||||
|
payeeAccount: ["收款人账号", "收款账号"],
|
||||||
|
transactionStatus: ["单据状态", "交易状态"],
|
||||||
|
reference: ["流水号", "交易流水号", "凭证号", "凭证号码"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return typeof value === "string" ? value.normalize("NFKC").trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReceiptMinorUnits(value) {
|
||||||
|
const text = normalizeText(value).replace(/^[¥¥]\s*/, "").replace(/\s*元$/, "");
|
||||||
|
if (!/^(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d{1,2})?$/.test(text)) return null;
|
||||||
|
const [whole, fraction = ""] = text.replace(/,/g, "").split(".");
|
||||||
|
if (whole.length > 12) return null;
|
||||||
|
return (BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"))).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAccount(value) {
|
||||||
|
const text = normalizeText(value).replace(/\s/g, "");
|
||||||
|
return /^\d{6,40}$/.test(text) ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCurrency(value) {
|
||||||
|
const text = normalizeText(value).toUpperCase();
|
||||||
|
if (["人民币", "人民币元", "RMB", "CNY"].includes(text)) return "CNY";
|
||||||
|
return /^[A-Z]{3}$/.test(text) ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value) {
|
||||||
|
const text = normalizeText(value);
|
||||||
|
if (["交易成功", "转账成功", "付款成功", "支付成功"].includes(text)) return "success";
|
||||||
|
if (["交易失败", "转账失败", "付款失败", "支付失败", "已撤销", "已冲正", "已退汇"].includes(text)) return "failed";
|
||||||
|
if (["已受理", "受理成功", "处理中", "待处理"].includes(text)) return "pending";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBankSlip(fields) {
|
||||||
|
const result = {
|
||||||
|
amount: null, currency: null, payerAccount: null, payeeAccount: null,
|
||||||
|
transactionStatus: null, reference: null, issues: [],
|
||||||
|
};
|
||||||
|
if (!Array.isArray(fields) || fields.length === 0 || fields.length > 200) {
|
||||||
|
result.issues.push("response_invalid");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const parsers = {
|
||||||
|
amount: parseReceiptMinorUnits, currency: normalizeCurrency,
|
||||||
|
payerAccount: normalizeAccount, payeeAccount: normalizeAccount,
|
||||||
|
transactionStatus: normalizeStatus, reference: (value) => normalizeText(value) || null,
|
||||||
|
};
|
||||||
|
for (const [key, aliases] of Object.entries(FIELD_ALIASES)) {
|
||||||
|
let entries = fields.filter((field) => aliases.includes(normalizeText(field?.Name)) && normalizeText(field?.Value));
|
||||||
|
if (key === "reference" && entries.length) {
|
||||||
|
const preferred = aliases.find((alias) => entries.some((field) => normalizeText(field.Name) === alias));
|
||||||
|
entries = entries.filter((field) => normalizeText(field.Name) === preferred);
|
||||||
|
}
|
||||||
|
const names = entries.map((field) => normalizeText(field.Name));
|
||||||
|
const values = entries.map((field) => parsers[key](field.Value));
|
||||||
|
if (key === "transactionStatus") {
|
||||||
|
if (values.includes("failed")) result.issues.push("transaction_failed");
|
||||||
|
if (values.includes("pending")) result.issues.push("transaction_pending");
|
||||||
|
}
|
||||||
|
if (new Set(names).size !== names.length || new Set(values).size > 1) {
|
||||||
|
result.issues.push(`${key}_ambiguous`);
|
||||||
|
} else if (values.length && values[0] !== null) {
|
||||||
|
result[key] = values[0];
|
||||||
|
} else {
|
||||||
|
result.issues.push(`${key}_unresolved`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchBankSlip(receipt, candidates) {
|
||||||
|
const finish = (status, reasons, itemId = null) => ({ status, itemId, reasons, bankVerified: false });
|
||||||
|
if (receipt.transactionStatus === "failed") return finish("mismatch", ["transaction_failed"]);
|
||||||
|
const unresolved = [...receipt.issues];
|
||||||
|
if (receipt.transactionStatus !== "success") unresolved.push("transaction_not_confirmed");
|
||||||
|
if (unresolved.length) return finish("needs_review", [...new Set(unresolved)]);
|
||||||
|
if (!Array.isArray(candidates) || !candidates.length) return finish("needs_review", ["candidates_missing"]);
|
||||||
|
const comparisons = candidates.map((candidate) => {
|
||||||
|
const expected = {
|
||||||
|
amount: parseReceiptMinorUnits(candidate.amount), currency: normalizeCurrency(candidate.currency),
|
||||||
|
payerAccount: normalizeAccount(candidate.payerAccount), payeeAccount: normalizeAccount(candidate.payeeAccount),
|
||||||
|
};
|
||||||
|
const invalid = !candidate.id || Object.values(expected).some((value) => value === null);
|
||||||
|
const reasons = [];
|
||||||
|
if (expected.amount !== receipt.amount) reasons.push("amount_mismatch");
|
||||||
|
if (expected.currency !== receipt.currency) reasons.push("currency_mismatch");
|
||||||
|
if (expected.payerAccount !== receipt.payerAccount) reasons.push("payer_account_mismatch");
|
||||||
|
if (expected.payeeAccount !== receipt.payeeAccount) reasons.push("payee_account_mismatch");
|
||||||
|
return { id: candidate.id, invalid, reasons };
|
||||||
|
});
|
||||||
|
if (comparisons.some((comparison) => comparison.invalid)) return finish("needs_review", ["candidate_invalid"]);
|
||||||
|
const matches = comparisons.filter((comparison) => comparison.reasons.length === 0);
|
||||||
|
if (matches.length === 1) return finish("matched", [], matches[0].id);
|
||||||
|
if (matches.length > 1) return finish("needs_review", ["multiple_candidates"]);
|
||||||
|
return finish("mismatch", [...new Set(comparisons.flatMap((comparison) => comparison.reasons))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeBankSlip, matchBankSlip, parseReceiptMinorUnits };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const { PDFDocument } = require("pdf-lib");
|
||||||
|
const sharp = require("sharp");
|
||||||
|
const { normalizeBankSlip } = require("./paymentReceiptMatching");
|
||||||
|
|
||||||
|
function createReceiptOcrClient(config) {
|
||||||
|
if (config?.ocrProvider && config.ocrProvider !== "tencent") return null;
|
||||||
|
const Tencent = require("tencentcloud-sdk-nodejs");
|
||||||
|
if (!config.tencentSecretId || !config.tencentSecretKey) return null;
|
||||||
|
return new Tencent.ocr.v20181119.Client({
|
||||||
|
credential: { secretId: config.tencentSecretId, secretKey: config.tencentSecretKey },
|
||||||
|
region: config.tencentOcrRegion,
|
||||||
|
profile: { httpProfile: { endpoint: "ocr.tencentcloudapi.com", reqTimeout: 30 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recognizePaymentReceipt({ buffer, mimeType, config, createClient = createReceiptOcrClient }) {
|
||||||
|
const result = { status: "needs_review", pagesExpected: 0, pagesProcessed: 0, pages: [], reason: null };
|
||||||
|
if (!Buffer.isBuffer(buffer) || !buffer.length || buffer.length > 5 * 1024 * 1024) {
|
||||||
|
return { ...result, reason: "receipt_size_invalid" };
|
||||||
|
}
|
||||||
|
let payload = buffer;
|
||||||
|
const isPdf = mimeType === "application/pdf";
|
||||||
|
try {
|
||||||
|
if (isPdf) {
|
||||||
|
if (buffer.subarray(0, 5).toString() !== "%PDF-") throw new Error("invalid_pdf");
|
||||||
|
const document = await PDFDocument.load(buffer);
|
||||||
|
result.pagesExpected = document.getPageCount();
|
||||||
|
if (result.pagesExpected < 1 || result.pagesExpected > 20) return { ...result, reason: "receipt_page_limit" };
|
||||||
|
} else {
|
||||||
|
if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
|
||||||
|
return { ...result, reason: "receipt_format_unsupported" };
|
||||||
|
}
|
||||||
|
const image = sharp(buffer, { limitInputPixels: 40000000 });
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
const expectedFormat = { "image/png": "png", "image/jpeg": "jpeg", "image/webp": "webp" }[mimeType];
|
||||||
|
if (metadata.format !== expectedFormat || (metadata.pages ?? 1) !== 1) {
|
||||||
|
return { ...result, reason: "receipt_image_invalid" };
|
||||||
|
}
|
||||||
|
payload = await image.rotate().png().toBuffer();
|
||||||
|
result.pagesExpected = 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ...result, reason: "receipt_decode_failed" };
|
||||||
|
}
|
||||||
|
const encoded = payload.toString("base64");
|
||||||
|
if (encoded.length > 7 * 1024 * 1024) return { ...result, reason: "receipt_encoded_size_limit" };
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = createClient(config);
|
||||||
|
if (!client || typeof client.BankSlipOCR !== "function") {
|
||||||
|
return { ...result, status: "ocr_failed", reason: "receipt_ocr_unavailable" };
|
||||||
|
}
|
||||||
|
for (let pageNumber = 1; pageNumber <= result.pagesExpected; pageNumber += 1) {
|
||||||
|
const response = await client.BankSlipOCR({
|
||||||
|
ImageBase64: encoded, IsPdf: isPdf, ...(isPdf ? { PdfPageNumber: pageNumber } : {}),
|
||||||
|
});
|
||||||
|
result.pages.push({ pageNumber, ...normalizeBankSlip(response?.BankSlipInfos) });
|
||||||
|
result.pagesProcessed += 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ...result, status: "ocr_failed", reason: "receipt_ocr_request_failed" };
|
||||||
|
}
|
||||||
|
return { ...result, status: result.pages.every((page) => page.issues.length === 0) ? "recognized" : "needs_review" };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { recognizePaymentReceipt };
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
const { createHmac, createHash } = require("node:crypto");
|
||||||
|
const { encryptStringOrThrow, decryptStringOrThrow, resolveFieldEncryptionKey } = require("./crypto");
|
||||||
|
const { matchBankSlip } = require("./paymentReceiptMatching");
|
||||||
|
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||||
|
const { resolveItemPaymentChannel } = require("./paymentProcessingProgress");
|
||||||
|
|
||||||
|
class ReceiptVerificationError extends Error {
|
||||||
|
constructor(code) {
|
||||||
|
super(code);
|
||||||
|
this.name = "ReceiptVerificationError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFact(condition, code) {
|
||||||
|
if (!condition) throw new ReceiptVerificationError(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiptKey(config) {
|
||||||
|
const key = resolveFieldEncryptionKey(config);
|
||||||
|
requireFact(key, "receipt_key_unavailable");
|
||||||
|
return { key, keyId: createHash("sha256").update(key).digest("hex") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function transactionFingerprint(config, receipt) {
|
||||||
|
const { key, keyId } = receiptKey(config);
|
||||||
|
requireFact(receipt.payerAccount && receipt.reference, "receipt_identity_unresolved");
|
||||||
|
const fingerprint = createHmac("sha256", key)
|
||||||
|
.update(JSON.stringify(["ccb-receipt-v1", receipt.payerAccount, receipt.reference])).digest("hex");
|
||||||
|
return { fingerprint, key_id: keyId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchCandidates(config, batch) {
|
||||||
|
receiptKey(config);
|
||||||
|
requireFact(batch.entity_type === "reimbursement", "receipt_business_unsupported");
|
||||||
|
const payer = batch.payment_account_snapshot_json;
|
||||||
|
requireFact(payer?.bankInstitutionCode === "CCB", "receipt_bank_unsupported");
|
||||||
|
const payerAccount = decryptStringOrThrow(config, payer.accountNumberEncrypted);
|
||||||
|
return batch.items.filter((item) => resolveItemPaymentChannel(batch, item) === "bank").map((item) => {
|
||||||
|
const payee = item.snapshot_json?.payee;
|
||||||
|
requireFact(payee?.bankInstitutionCode === "CCB", "receipt_bank_unsupported");
|
||||||
|
return {
|
||||||
|
id: item.id, amount: String(item.amount), currency: item.currency_code,
|
||||||
|
payerAccount, payeeAccount: decryptStringOrThrow(config, payee.bankAccountEncrypted),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVerificationData(config, batch, attachment, recognition) {
|
||||||
|
requireFact(attachment.payment_batch_id === batch.id && !attachment.archived_at &&
|
||||||
|
attachment.governance_status === "approved", "receipt_unavailable");
|
||||||
|
const candidates = batchCandidates(config, batch);
|
||||||
|
const pages = recognition.pages ?? [];
|
||||||
|
const complete = recognition.status === "recognized" && recognition.pagesExpected > 0 &&
|
||||||
|
recognition.pagesExpected <= 20 && recognition.pagesProcessed === recognition.pagesExpected &&
|
||||||
|
pages.length === recognition.pagesExpected &&
|
||||||
|
pages.every((page, index) => page.pageNumber === index + 1);
|
||||||
|
const matches = [];
|
||||||
|
if (complete) {
|
||||||
|
for (const page of pages) {
|
||||||
|
const match = matchBankSlip(page, candidates);
|
||||||
|
if (match.status !== "matched") break;
|
||||||
|
matches.push({ page_number: page.pageNumber, item_id: match.itemId, ...transactionFingerprint(config, page) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const matched = complete && matches.length === pages.length &&
|
||||||
|
new Set(matches.map((match) => match.item_id)).size === matches.length &&
|
||||||
|
new Set(matches.map((match) => match.fingerprint)).size === matches.length;
|
||||||
|
return {
|
||||||
|
attachment_id: attachment.id, source_sha256: attachment.file_sha256,
|
||||||
|
source_version: attachment.receipt_ocr_version,
|
||||||
|
status: matched ? "matched" : "needs_review",
|
||||||
|
pages_expected: recognition.pagesExpected, pages_processed: recognition.pagesProcessed,
|
||||||
|
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
|
||||||
|
status: recognition.status, pagesExpected: recognition.pagesExpected,
|
||||||
|
pagesProcessed: recognition.pagesProcessed, reason: recognition.reason,
|
||||||
|
pages: pages.map((page) => Object.fromEntries(
|
||||||
|
["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference", "issues"]
|
||||||
|
.map((key) => [key, page[key]]),
|
||||||
|
)),
|
||||||
|
})),
|
||||||
|
...(matched ? { matches: { create: matches } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {}) {
|
||||||
|
const pending = batch.items.filter((item) => ["pending", "failed"].includes(item.status) &&
|
||||||
|
resolveItemPaymentChannel(batch, item) === "bank");
|
||||||
|
if (!pending.length) return;
|
||||||
|
const { keyId } = receiptKey(config);
|
||||||
|
if (!readOnly) await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
|
||||||
|
requireFact(await tx.paymentReceiptClaim.count({ where: { key_id: { not: keyId } } }) === 0,
|
||||||
|
"receipt_key_rotation_required");
|
||||||
|
requireFact(await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: keyId } } }) === 0,
|
||||||
|
"receipt_key_rotation_required");
|
||||||
|
const attachments = readOnly ? await tx.paymentBatchReceiptAttachment.findMany({
|
||||||
|
where: { payment_batch_id: batch.id }, select: { id: true }, orderBy: { id: "asc" },
|
||||||
|
}) : await tx.$queryRaw`
|
||||||
|
SELECT id FROM payment_batch_receipt_attachments
|
||||||
|
WHERE payment_batch_id = ${batch.id}::uuid ORDER BY id FOR UPDATE
|
||||||
|
`;
|
||||||
|
const verifications = [];
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const verification = await tx.paymentReceiptVerification.findFirst({
|
||||||
|
where: { attachment_id: attachment.id },
|
||||||
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
|
include: { attachment: true, matches: true },
|
||||||
|
});
|
||||||
|
if (!verification || verification.status !== "matched" || verification.attachment.archived_at ||
|
||||||
|
verification.attachment.governance_status !== "approved" ||
|
||||||
|
verification.source_sha256 !== verification.attachment.file_sha256 ||
|
||||||
|
verification.source_version !== verification.attachment.receipt_ocr_version) continue;
|
||||||
|
requireFact(verification.attachment.payment_batch_id === batch.id, "receipt_batch_mismatch");
|
||||||
|
const recognition = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted));
|
||||||
|
const rebuilt = buildVerificationData(config, batch, verification.attachment, recognition);
|
||||||
|
requireFact(rebuilt.status === "matched", "receipt_match_changed");
|
||||||
|
requireFact(verification.pages_expected === recognition.pagesExpected &&
|
||||||
|
verification.pages_processed === recognition.pagesProcessed, "receipt_pages_changed");
|
||||||
|
const expected = rebuilt.matches.create;
|
||||||
|
requireFact(expected.length === verification.matches.length &&
|
||||||
|
expected.every((match) => verification.matches.some((saved) =>
|
||||||
|
["page_number", "item_id", "fingerprint", "key_id"].every((key) => match[key] === saved[key]))),
|
||||||
|
"receipt_match_changed");
|
||||||
|
verifications.push(verification);
|
||||||
|
}
|
||||||
|
const { loadApprovedManualReceiptEvidence } = require("./paymentReceiptManualPersistence");
|
||||||
|
verifications.push(...await loadApprovedManualReceiptEvidence(tx, config, batch, attachments.map((entry) => entry.id), readOnly));
|
||||||
|
const claims = [];
|
||||||
|
for (const item of pending) {
|
||||||
|
const options = verifications.flatMap((verification) => verification.matches
|
||||||
|
.filter((match) => match.item_id === item.id)
|
||||||
|
.map((match) => ({ ...match, aliases: match.aliases ?? [match.fingerprint],
|
||||||
|
verification_id: verification.id, manual_proposal_id: verification.manual_proposal_id ?? null })));
|
||||||
|
requireFact(options.length > 0, "receipt_verification_required");
|
||||||
|
requireFact(new Set(options.map((option) => option.fingerprint)).size === 1, "receipt_multiple_transactions");
|
||||||
|
requireFact(new Set(options.map((option) => JSON.stringify(option.aliases))).size === 1, "receipt_multiple_transactions");
|
||||||
|
const match = options[0];
|
||||||
|
requireFact(match.key_id === keyId, "receipt_key_rotation_required");
|
||||||
|
const existing = await tx.paymentReceiptClaim.findMany({
|
||||||
|
where: { OR: [{ fingerprint: { in: match.aliases } }, { item_id: item.id }] },
|
||||||
|
});
|
||||||
|
requireFact(existing.every((claim) => claim.fingerprint === match.fingerprint && claim.item_id === item.id),
|
||||||
|
"receipt_transaction_reused");
|
||||||
|
const existingAliases = await tx.paymentReceiptTransactionAlias.findMany({
|
||||||
|
where: { fingerprint: { in: match.aliases } },
|
||||||
|
});
|
||||||
|
requireFact(existingAliases.every((alias) => alias.claim_fingerprint === match.fingerprint && alias.key_id === keyId),
|
||||||
|
"receipt_transaction_reused");
|
||||||
|
claims.push({ match, exists: existing.length > 0, existingAliases });
|
||||||
|
}
|
||||||
|
requireFact(new Set(claims.map(({ match }) => match.fingerprint)).size === claims.length,
|
||||||
|
"receipt_transaction_reused");
|
||||||
|
const aliasOwners = new Map();
|
||||||
|
for (const { match } of claims) {
|
||||||
|
for (const alias of match.aliases) {
|
||||||
|
requireFact(!aliasOwners.has(alias) || aliasOwners.get(alias) === match.item_id, "receipt_transaction_reused");
|
||||||
|
aliasOwners.set(alias, match.item_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (readOnly) return;
|
||||||
|
for (const { match, exists, existingAliases } of claims) {
|
||||||
|
if (!exists) {
|
||||||
|
await tx.paymentReceiptClaim.create({ data: {
|
||||||
|
fingerprint: match.fingerprint, key_id: match.key_id,
|
||||||
|
item_id: match.item_id, verification_id: match.verification_id, manual_proposal_id: match.manual_proposal_id,
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
for (const fingerprint of match.aliases) {
|
||||||
|
if (!existingAliases.some((alias) => alias.fingerprint === fingerprint)) {
|
||||||
|
await tx.paymentReceiptTransactionAlias.create({ data: {
|
||||||
|
fingerprint, key_id: keyId, claim_fingerprint: match.fingerprint,
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeReceiptRecognition(tx, config, { batchId, attachmentId, sourceSha256, expectedVersion, recognition }) {
|
||||||
|
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id = ${batchId}::uuid FOR UPDATE`;
|
||||||
|
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
|
||||||
|
requireFact(batch && ["reviewed", "failed"].includes(batch.status), "receipt_batch_not_editable");
|
||||||
|
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id = ${attachmentId}::uuid FOR UPDATE`;
|
||||||
|
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
|
||||||
|
requireFact(attachment && attachment.file_sha256 === sourceSha256, "receipt_source_changed");
|
||||||
|
requireFact(Number.isInteger(expectedVersion) && expectedVersion >= 0 &&
|
||||||
|
attachment.receipt_ocr_version === expectedVersion, "receipt_job_superseded");
|
||||||
|
const data = buildVerificationData(config, batch, attachment, recognition);
|
||||||
|
return tx.paymentReceiptVerification.create({ data, select: { id: true, status: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRetryReceiptRecognition(attachment, now = new Date()) {
|
||||||
|
if (attachment.archived_at || attachment.governance_status !== "approved") return false;
|
||||||
|
const timedOut = attachment.receipt_ocr_state === "processing" && attachment.receipt_ocr_started_at &&
|
||||||
|
now.getTime() - new Date(attachment.receipt_ocr_started_at).getTime() >= 15 * 60 * 1000;
|
||||||
|
return Boolean(timedOut || ["legacy", "ocr_failed", "needs_review", "manual_rejected"].includes(attachment.receipt_ocr_state));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueReceiptRetry(tx, { batchId, attachmentId, expectedVersion, now = new Date() }) {
|
||||||
|
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id = ${attachmentId}::uuid FOR UPDATE`;
|
||||||
|
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
|
||||||
|
requireFact(attachment && attachment.payment_batch_id === batchId && !attachment.archived_at &&
|
||||||
|
attachment.governance_status === "approved", "receipt_unavailable");
|
||||||
|
requireFact(Number.isInteger(expectedVersion) && attachment.receipt_ocr_version === expectedVersion,
|
||||||
|
"receipt_job_superseded");
|
||||||
|
requireFact(canRetryReceiptRecognition(attachment, now), "receipt_retry_not_ready");
|
||||||
|
return tx.paymentBatchReceiptAttachment.update({
|
||||||
|
where: { id: attachmentId },
|
||||||
|
data: { receipt_ocr_state: "queued", receipt_ocr_version: { increment: 1 }, receipt_ocr_started_at: null },
|
||||||
|
select: { id: true, receipt_ocr_state: true, receipt_ocr_version: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ReceiptVerificationError, buildVerificationData, claimVerifiedReceipts, transactionFingerprint, storeReceiptRecognition, queueReceiptRetry, canRetryReceiptRecognition, batchCandidates };
|
||||||
@@ -70,6 +70,15 @@
|
|||||||
"payment_batch.invalid_identifier": "付款记录标识已失效,请刷新后重新选择。",
|
"payment_batch.invalid_identifier": "付款记录标识已失效,请刷新后重新选择。",
|
||||||
"payment_batch.invalid_input": "付款操作内容不完整或格式不正确,请核对页面必填项后重试。",
|
"payment_batch.invalid_input": "付款操作内容不完整或格式不正确,请核对页面必填项后重试。",
|
||||||
"payment_batch.bank_receipt_required": "银行付款登记前必须上传有效银行回单,请先补充回单。",
|
"payment_batch.bank_receipt_required": "银行付款登记前必须上传有效银行回单,请先补充回单。",
|
||||||
|
"payment_batch.receipt_retry_unavailable": "回单状态已变化或仍在识别,请刷新后查看;处理中超过15分钟仍未完成时可重试。",
|
||||||
|
"payment_batch.receipt_independent_review_required": "回单人工核验需要独立审批人复核;制单人、核验人及报销归属人或提交人不能自审。",
|
||||||
|
"payment_batch.receipt_transaction_reused": "回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。",
|
||||||
|
"payment_batch.receipt_manual_state_changed": "回单或核验版本已变化,请刷新后重新查看当前状态。",
|
||||||
|
"payment_batch.receipt_manual_verification_required": "回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。",
|
||||||
|
"payment_batch.receipt_key_unavailable": "回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。",
|
||||||
|
"payment_batch.receipt_key_rotation_required": "回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。",
|
||||||
|
"payment_batch.receipt_snapshot_unsupported": "付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。",
|
||||||
|
"payment_batch.receipt_identity_unresolved": "无法完整确认报销参与人身份,请管理员核实人员关系后再复核;不能自行批准。",
|
||||||
"payment_batch.department_unavailable": "当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。",
|
"payment_batch.department_unavailable": "当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。",
|
||||||
"payment_batch.source_not_found": "部分来源单据已不存在或不可用,请刷新后重新选择。",
|
"payment_batch.source_not_found": "部分来源单据已不存在或不可用,请刷新后重新选择。",
|
||||||
"payment_batch.daily_sequence_exhausted": "今日付款批次编号已用完,请联系管理员处理,勿重复提交。",
|
"payment_batch.daily_sequence_exhausted": "今日付款批次编号已用完,请联系管理员处理,勿重复提交。",
|
||||||
|
|||||||
@@ -291,6 +291,10 @@ function buildPrisma(options = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tx = {
|
const tx = {
|
||||||
|
paymentReceiptClaim: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
|
||||||
|
paymentReceiptTransactionAlias: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
|
||||||
|
paymentReceiptManualProposal: { findMany: vi.fn(async () => []) },
|
||||||
|
paymentReceiptVerification: { findFirst: vi.fn(async () => null) },
|
||||||
paymentAccount: {
|
paymentAccount: {
|
||||||
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
|
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
|
||||||
findMany: vi.fn(async ({ where }) => where.legal_entity_id.in.includes(state.paymentAccount.legal_entity_id) && state.paymentAccount.enabled && !state.paymentAccount.archived_at ? [{ ...state.paymentAccount }] : []),
|
findMany: vi.fn(async ({ where }) => where.legal_entity_id.in.includes(state.paymentAccount.legal_entity_id) && state.paymentAccount.enabled && !state.paymentAccount.archived_at ? [{ ...state.paymentAccount }] : []),
|
||||||
@@ -383,6 +387,7 @@ function buildPrisma(options = {}) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
paymentBatchReceiptAttachment: {
|
paymentBatchReceiptAttachment: {
|
||||||
|
findUnique: vi.fn(async ({ where }) => state.receiptAttachments.find((item) => item.id === where.id) ?? null),
|
||||||
count: vi.fn(async ({ where }) =>
|
count: vi.fn(async ({ where }) =>
|
||||||
state.receiptAttachments.filter((item) =>
|
state.receiptAttachments.filter((item) =>
|
||||||
item.payment_batch_id === where.payment_batch_id &&
|
item.payment_batch_id === where.payment_batch_id &&
|
||||||
@@ -417,7 +422,10 @@ function buildPrisma(options = {}) {
|
|||||||
update: vi.fn(async ({ where, data }) => {
|
update: vi.fn(async ({ where, data }) => {
|
||||||
const item = state.receiptAttachments.find((candidate) => candidate.id === where.id);
|
const item = state.receiptAttachments.find((candidate) => candidate.id === where.id);
|
||||||
if (!item) throw new Error("receipt not found");
|
if (!item) throw new Error("receipt not found");
|
||||||
|
const nextVersion = data.receipt_ocr_version?.increment == null ? null :
|
||||||
|
item.receipt_ocr_version + data.receipt_ocr_version.increment;
|
||||||
Object.assign(item, data);
|
Object.assign(item, data);
|
||||||
|
if (nextVersion !== null) item.receipt_ocr_version = nextVersion;
|
||||||
return { ...item };
|
return { ...item };
|
||||||
}),
|
}),
|
||||||
deleteMany: vi.fn(async ({ where }) => {
|
deleteMany: vi.fn(async ({ where }) => {
|
||||||
@@ -500,6 +508,9 @@ function buildPrisma(options = {}) {
|
|||||||
$executeRaw: vi.fn(async () => 0),
|
$executeRaw: vi.fn(async () => 0),
|
||||||
$queryRaw: vi.fn(async (query, ...values) => {
|
$queryRaw: vi.fn(async (query, ...values) => {
|
||||||
const text = Array.isArray(query) ? query.join(" ") : String(query?.sql ?? query ?? "");
|
const text = Array.isArray(query) ? query.join(" ") : String(query?.sql ?? query ?? "");
|
||||||
|
if (text.includes("FROM payment_batch_receipt_attachments") && text.includes("ORDER BY id")) {
|
||||||
|
return state.receiptAttachments.map((attachment) => ({ id: attachment.id }));
|
||||||
|
}
|
||||||
if (text.includes('ORDER BY "batch_no" DESC')) {
|
if (text.includes('ORDER BY "batch_no" DESC')) {
|
||||||
const pattern = values[1];
|
const pattern = values[1];
|
||||||
return typeof pattern === "string" && new RegExp(pattern).test(state.batch.batch_no)
|
return typeof pattern === "string" && new RegExp(pattern).test(state.batch.batch_no)
|
||||||
@@ -510,6 +521,33 @@ function buildPrisma(options = {}) {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options.verifiedReceipt) {
|
||||||
|
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
|
||||||
|
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
||||||
|
const { buildVerificationData } = require("../../../src/services/paymentReceiptVerification");
|
||||||
|
const payerAccount = "000011112222";
|
||||||
|
const payeeAccount = "000033334444";
|
||||||
|
state.batch.payment_account_snapshot_json = {
|
||||||
|
bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, payerAccount),
|
||||||
|
};
|
||||||
|
for (const item of state.batch.items) item.snapshot_json = { ...item.snapshot_json, payee: {
|
||||||
|
bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, payeeAccount),
|
||||||
|
} };
|
||||||
|
const attachment = state.receiptAttachments[0];
|
||||||
|
Object.assign(attachment, { receipt_ocr_version: 0, receipt_ocr_state: "matched" });
|
||||||
|
const pages = state.batch.items.filter((item) => ["pending", "failed"].includes(item.status)).map((item, index) => ({
|
||||||
|
pageNumber: index + 1, ...normalizeBankSlip(Object.entries({
|
||||||
|
小写金额: String(item.amount), 币别: item.currency_code, 付款人账号: payerAccount,
|
||||||
|
收款人账号: payeeAccount, 单据状态: "交易成功", 凭证号: `SYN-${item.id}`,
|
||||||
|
}).map(([Name, Value]) => ({ Name, Value }))),
|
||||||
|
}));
|
||||||
|
const data = buildVerificationData(config, state.batch, attachment, {
|
||||||
|
status: "recognized", pagesExpected: pages.length, pagesProcessed: pages.length, pages,
|
||||||
|
});
|
||||||
|
tx.paymentReceiptVerification.findFirst.mockResolvedValue({
|
||||||
|
id: PAYMENT_ACCOUNT_ID, ...data, attachment, matches: data.matches?.create ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
const prisma = {
|
const prisma = {
|
||||||
...tx,
|
...tx,
|
||||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
@@ -518,6 +556,246 @@ function buildPrisma(options = {}) {
|
|||||||
return { prisma, tx, state };
|
return { prisma, tx, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("manual receipt verification routes", () => {
|
||||||
|
const headers = { authorization: "Bearer synthetic-test" };
|
||||||
|
const url = `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/manual-verification`;
|
||||||
|
function setupEvidence(tx, state) {
|
||||||
|
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
|
||||||
|
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
||||||
|
const fields = Object.entries({
|
||||||
|
小写金额: "1683.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||||
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-HTTP-MANUAL",
|
||||||
|
}).map(([Name, Value]) => ({ Name, Value }));
|
||||||
|
state.batch.payment_account_snapshot_json = {
|
||||||
|
bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, "000011112222"),
|
||||||
|
};
|
||||||
|
state.batch.items[0].snapshot_json = { payee: {
|
||||||
|
bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, "000033334444"),
|
||||||
|
} };
|
||||||
|
const attachment = state.receiptAttachments[0];
|
||||||
|
Object.assign(attachment, { receipt_ocr_version: 0, receipt_ocr_state: "needs_review", governance_status: "approved" });
|
||||||
|
const original = { id: LEGAL_ENTITY_ID, attachment_id: attachment.id,
|
||||||
|
source_version: 0, source_sha256: attachment.file_sha256,
|
||||||
|
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
|
||||||
|
status: "needs_review", pagesExpected: 1, pagesProcessed: 1,
|
||||||
|
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), amount: null, issues: ["amount_unresolved"] }],
|
||||||
|
})) };
|
||||||
|
let stored;
|
||||||
|
tx.paymentReceiptVerification = { findFirst: vi.fn(async () => original) };
|
||||||
|
tx.paymentReceiptClaim = { count: vi.fn(async () => 0) };
|
||||||
|
tx.paymentReceiptTransactionAlias = { count: vi.fn(async () => 0) };
|
||||||
|
tx.paymentReceiptManualProposal = {
|
||||||
|
create: vi.fn(async ({ data }) => { stored = { id: PAYMENT_ACCOUNT_ID, ...data }; return stored; }),
|
||||||
|
findUnique: vi.fn(async () => stored ? { ...stored, original_verification: original } : null),
|
||||||
|
update: vi.fn(async ({ data }) => { Object.assign(stored, data); return stored; }),
|
||||||
|
};
|
||||||
|
return { expectedVersion: 0, reason: "合成更正", pages: [{ pageNumber: 1, itemId: ITEM_ID, fields }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("saves encrypted proposal and rejects stale click without recording payment", async () => {
|
||||||
|
const { app, tx, state, tempDir } = await buildApp();
|
||||||
|
const payload = setupEvidence(tx, state);
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", url, headers, payload });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.item).toEqual({ id: PAYMENT_ACCOUNT_ID, version: 1, status: "pending_review" });
|
||||||
|
expect(response.body).not.toContain("SYN-HTTP-MANUAL");
|
||||||
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
|
||||||
|
action: "PAYMENT_RECEIPT_MANUAL_PROPOSED",
|
||||||
|
}) }));
|
||||||
|
expect((await app.inject({ method: "POST", url, headers, payload })).statusCode).toBe(409);
|
||||||
|
expect(tx.paymentReceiptManualProposal.create).toHaveBeenCalledTimes(1);
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["amount", "state", "duplicate", "version", "fields"])("rejects invalid %s with no write", async (failure) => {
|
||||||
|
const { app, tx, state, tempDir } = await buildApp();
|
||||||
|
const payload = setupEvidence(tx, state);
|
||||||
|
if (failure === "amount") payload.pages[0].fields[0].Value = "1.00";
|
||||||
|
if (failure === "state") state.batch.status = "executed";
|
||||||
|
if (failure === "duplicate") tx.paymentReceiptClaim.count.mockResolvedValueOnce(0).mockResolvedValue(1);
|
||||||
|
if (failure === "version") payload.expectedVersion = 1;
|
||||||
|
if (failure === "fields") payload.pages[0].fields[0].unexpected = true;
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", url, headers, payload });
|
||||||
|
expect(response.statusCode).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(response.statusCode).toBeLessThan(500);
|
||||||
|
expect(tx.paymentReceiptManualProposal.create).not.toHaveBeenCalled();
|
||||||
|
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads masked facts and merges only changed fields without requiring account reentry", async () => {
|
||||||
|
const { app, tx, state, tempDir } = await buildApp();
|
||||||
|
const payload = setupEvidence(tx, state);
|
||||||
|
try {
|
||||||
|
const detail = await app.inject({ method: "GET", url, headers });
|
||||||
|
expect(detail.statusCode).toBe(200);
|
||||||
|
expect(detail.json().data.item.pagesExpected).toBe(1);
|
||||||
|
expect(detail.body).not.toContain("000011112222");
|
||||||
|
expect(detail.body).not.toContain("000033334444");
|
||||||
|
expect(tx.$executeRaw).toHaveBeenCalled();
|
||||||
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||||
|
payload.pages[0].fields = [{ Name: "小写金额", Value: "1683.00" }];
|
||||||
|
expect((await app.inject({ method: "POST", url, headers, payload })).statusCode).toBe(200);
|
||||||
|
const proposed = await app.inject({ method: "GET", url, headers });
|
||||||
|
expect(proposed.statusCode).toBe(200);
|
||||||
|
expect(proposed.json().data.item.proposal.pages[0].amount).toBe("1683.00");
|
||||||
|
expect(proposed.json().data.item.canReview).toBe(false);
|
||||||
|
expect(proposed.body).not.toContain("000011112222");
|
||||||
|
expect(proposed.body).not.toContain("payload_encrypted");
|
||||||
|
} 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) => {
|
||||||
|
const { app, tx, state, tempDir } = await buildApp();
|
||||||
|
setupEvidence(tx, state);
|
||||||
|
if (stateKind === "foreign") state.receiptAttachments[0].payment_batch_id = LEGAL_ENTITY_ID;
|
||||||
|
if (stateKind === "voided") state.receiptAttachments[0].archived_at = new Date();
|
||||||
|
if (stateKind === "stale") state.receiptAttachments[0].receipt_ocr_version = 5;
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "GET", url, headers });
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.body).not.toContain("SYN-HTTP-MANUAL");
|
||||||
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["anonymous", "ceo", "disabled", "department"])("protects proposal boundary: %s", async (boundary) => {
|
||||||
|
const options = boundary === "ceo" ? { actor: {
|
||||||
|
id: ACTOR_ID, roleCodes: ["ceo"], permissionCodes: ["payment.batch.review"],
|
||||||
|
} } : boundary === "disabled" ? { paymentBatchEnabled: false } :
|
||||||
|
boundary === "department" ? { paymentBatchAllowedDepartmentIds: ["different"] } : {};
|
||||||
|
const { app, tx, state, tempDir } = await buildApp(options);
|
||||||
|
const payload = setupEvidence(tx, state);
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", url, payload,
|
||||||
|
headers: boundary === "anonymous" ? {} : headers });
|
||||||
|
const read = await app.inject({ method: "GET", url, headers: boundary === "anonymous" ? {} : headers });
|
||||||
|
if (boundary !== "ceo") expect(read.statusCode).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(read.body).not.toContain("000011112222");
|
||||||
|
expect(response.statusCode).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(response.statusCode).toBeLessThan(500);
|
||||||
|
expect(tx.paymentReceiptManualProposal.create).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["finance", "admin"])("cannot approve a manual proposal as %s alone", async (role) => {
|
||||||
|
const { app, tx, tempDir } = await buildApp({ actor: {
|
||||||
|
id: ACTOR_ID, roleCodes: [role], permissionCodes: ["*"],
|
||||||
|
} });
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", url: `${url}/${PAYMENT_ACCOUNT_ID}/review`, headers,
|
||||||
|
payload: { expectedVersion: 1, decision: "approve", reason: "合成复核" } });
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["independent", "proposer", "owner", "submitter", "maker"])("review enforces %s identity", async (identity) => {
|
||||||
|
const actor = { id: ACTOR_ID, roleCodes: ["finance", "ceo"],
|
||||||
|
permissionCodes: ["payment.batch.manage", "payment.batch.review"] };
|
||||||
|
const { app, tx, state, tempDir } = await buildApp({ actor });
|
||||||
|
const payload = setupEvidence(tx, state);
|
||||||
|
try {
|
||||||
|
expect((await app.inject({ method: "POST", url, headers, payload })).statusCode).toBe(200);
|
||||||
|
actor.id = identity === "proposer" ? ACTOR_ID : identity === "owner" ? OWNER_ID :
|
||||||
|
identity === "submitter" ? SUBMITTER_ID : identity === "maker" ? MAKER_ID : REVIEWER_ID;
|
||||||
|
const detail = await app.inject({ method: "GET", url, headers });
|
||||||
|
expect(detail.json().data.item.canReview).toBe(identity === "independent");
|
||||||
|
const reviewUrl = `${url}/${PAYMENT_ACCOUNT_ID}/review`;
|
||||||
|
const response = await app.inject({ method: "POST", url: reviewUrl, headers,
|
||||||
|
payload: { expectedVersion: 1, decision: "approve", reason: "合成复核" } });
|
||||||
|
expect(response.statusCode).toBe(identity === "independent" ? 200 : 403);
|
||||||
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||||
|
if (identity === "independent") {
|
||||||
|
expect(response.json().data.item.status).toBe("approved");
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
|
||||||
|
action: "PAYMENT_RECEIPT_MANUAL_REVIEWED",
|
||||||
|
}) }));
|
||||||
|
} else {
|
||||||
|
expect(tx.paymentReceiptManualProposal.update).not.toHaveBeenCalled();
|
||||||
|
expect(response.json().error.details.reasonCode).toBe("payment_batch.receipt_independent_review_required");
|
||||||
|
}
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("receipt OCR retry routes", () => {
|
||||||
|
const headers = { authorization: "Bearer synthetic-test" };
|
||||||
|
it("stores cash receipt evidence without queuing and rejects explicit OCR retry", async () => {
|
||||||
|
const { app, tx, tempDir } = await buildApp({
|
||||||
|
batch: { default_payment_channel: "cash" }, receiptAttachments: [],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const multipart = buildMultipartRequest({ files: [{
|
||||||
|
filename: "synthetic-cash.pdf", contentType: "application/pdf", content: SYNTHETIC_RECEIPT_PDF,
|
||||||
|
}] });
|
||||||
|
const upload = await app.inject({ method: "POST",
|
||||||
|
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
|
||||||
|
headers: { ...headers, ...multipart.headers }, payload: multipart.payload });
|
||||||
|
expect(upload.statusCode).toBe(200);
|
||||||
|
const attachment = upload.json().data.items[0];
|
||||||
|
expect(attachment.ocrState).toBe("legacy");
|
||||||
|
expect(attachment.ocrRetryAvailable).toBe(false);
|
||||||
|
const listed = await app.inject({ method: "GET",
|
||||||
|
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers });
|
||||||
|
expect(listed.statusCode).toBe(200);
|
||||||
|
expect(listed.json().data.items[0].ocrRetryAvailable).toBe(false);
|
||||||
|
const auditCount = tx.auditLog.create.mock.calls.length;
|
||||||
|
const retry = await app.inject({ method: "POST",
|
||||||
|
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachment.id}/ocr-retry`,
|
||||||
|
headers, payload: { expectedVersion: 0 } });
|
||||||
|
expect(retry.statusCode).toBe(409);
|
||||||
|
expect(retry.json().error.details.reasonCode).toBe("payment_batch.receipt_retry_unavailable");
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledTimes(auditCount);
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
it.each(["legacy", "ocr_failed", "needs_review", "manual_rejected"])("queues %s with audit and stale click protection", async (ocrState) => {
|
||||||
|
const { app, tx, state, tempDir } = await buildApp();
|
||||||
|
Object.assign(state.receiptAttachments[0], { receipt_ocr_version: 0, receipt_ocr_state: ocrState, governance_status: "approved" });
|
||||||
|
const url = `/api/payment-batches/${BATCH_ID}/receipt-attachments/${state.receiptAttachments[0].id}/ocr-retry`;
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", url, headers, payload: { expectedVersion: 0 } });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.item).toMatchObject({ ocrState: "queued", ocrVersion: 1 });
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
|
||||||
|
action: "PAYMENT_BATCH_RECEIPT_OCR_REQUEUED",
|
||||||
|
}) }));
|
||||||
|
expect((await app.inject({ method: "POST", url, headers, payload: { expectedVersion: 0 } })).statusCode).toBe(409);
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["queued", "matched", "processing"])("does not requeue %s immediately", async (ocrState) => {
|
||||||
|
const { app, tx, state, tempDir } = await buildApp();
|
||||||
|
Object.assign(state.receiptAttachments[0], { receipt_ocr_version: 0, receipt_ocr_state: ocrState,
|
||||||
|
receipt_ocr_started_at: new Date(), governance_status: "approved" });
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", headers,
|
||||||
|
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${state.receiptAttachments[0].id}/ocr-retry`,
|
||||||
|
payload: { expectedVersion: 0 } });
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json().error.details.reasonCode).toBe("payment_batch.receipt_retry_unavailable");
|
||||||
|
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forbids retry without finance management permission", async () => {
|
||||||
|
const { app, tx, tempDir } = await buildApp({ actor: {
|
||||||
|
id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.review"], roleCodes: ["ceo"],
|
||||||
|
} });
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "POST", headers,
|
||||||
|
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/ocr-retry`,
|
||||||
|
payload: { expectedVersion: 0 } });
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function buildApp(options = {}) {
|
async function buildApp(options = {}) {
|
||||||
const { prisma, tx, state } = buildPrisma(options);
|
const { prisma, tx, state } = buildPrisma(options);
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-cashv-payment-receipts-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-cashv-payment-receipts-"));
|
||||||
@@ -650,8 +928,43 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("payment batch reimbursement routes", () => {
|
describe("payment batch reimbursement routes", () => {
|
||||||
|
it.each(["legacy", "superseded", "amount", "voided", "reused"])("blocks bank execution for %s evidence", async (condition) => {
|
||||||
|
const { app, state, tx, tempDir } = await buildApp({ verifiedReceipt: condition !== "legacy" });
|
||||||
|
if (condition === "superseded") state.receiptAttachments[0].receipt_ocr_version += 1;
|
||||||
|
if (condition === "amount") state.batch.items[0].amount = "1684.00";
|
||||||
|
if (condition === "voided") state.receiptAttachments[0].archived_at = new Date();
|
||||||
|
if (condition === "reused") tx.paymentReceiptTransactionAlias.findMany.mockResolvedValue([
|
||||||
|
{ claim_fingerprint: "different-transaction", key_id: "different-key" },
|
||||||
|
]);
|
||||||
|
const headers = { authorization: "Bearer synthetic-test" };
|
||||||
|
try {
|
||||||
|
const readiness = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-readiness`, headers });
|
||||||
|
expect(readiness.statusCode).toBe(200);
|
||||||
|
expect(readiness.json().data.item.ready).toBe(false);
|
||||||
|
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||||
|
expect(tx.paymentReceiptTransactionAlias.create).not.toHaveBeenCalled();
|
||||||
|
const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers, payload: {} });
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||||
|
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readiness is read-only and valid evidence is claimed by execute", async () => {
|
||||||
|
const { app, tx, tempDir } = await buildApp({ verifiedReceipt: true });
|
||||||
|
const headers = { authorization: "Bearer synthetic-test" };
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-readiness`, headers });
|
||||||
|
expect(response.json().data.item.ready).toBe(true);
|
||||||
|
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||||
|
expect((await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers, payload: {} })).statusCode).toBe(200);
|
||||||
|
expect(tx.paymentReceiptClaim.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tx.paymentReceiptTransactionAlias.create).toHaveBeenCalledTimes(1);
|
||||||
|
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
it("preserves internal failure summaries while refreshing safe reason metadata on an existing risk", async () => {
|
it("preserves internal failure summaries while refreshing safe reason metadata on an existing risk", async () => {
|
||||||
const { app, tx, state } = await buildApp({ reimbursement: { status: "reviewing" } });
|
const { app, tx, state } = await buildApp({ verifiedReceipt: true, reimbursement: { status: "reviewing" } });
|
||||||
const risk = {
|
const risk = {
|
||||||
id: "existing-risk", entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID,
|
id: "existing-risk", entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID,
|
||||||
risk_code: "payment_batch_execute_failed", level: "high", title: "支付批次执行失败",
|
risk_code: "payment_batch_execute_failed", level: "high", title: "支付批次执行失败",
|
||||||
@@ -788,7 +1101,7 @@ describe("payment batch reimbursement routes", () => {
|
|||||||
{ id: "notif-2", recipient_employee_id: SUBMITTER_ID },
|
{ id: "notif-2", recipient_employee_id: SUBMITTER_ID },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { app, tx, state } = await buildApp();
|
const { app, tx, state } = await buildApp({ verifiedReceipt: true });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1495,6 +1808,7 @@ describe("payment batch reimbursement routes", () => {
|
|||||||
|
|
||||||
it("uses the company-local registration date when no payment date was entered", async () => {
|
it("uses the company-local registration date when no payment date was entered", async () => {
|
||||||
const { app, state } = await buildApp({
|
const { app, state } = await buildApp({
|
||||||
|
verifiedReceipt: true,
|
||||||
batch: { default_payment_date: null },
|
batch: { default_payment_date: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1789,6 +2103,7 @@ describe("payment batch reimbursement routes", () => {
|
|||||||
|
|
||||||
it("returns a conflict without payment audit when the source was already paid", async () => {
|
it("returns a conflict without payment audit when the source was already paid", async () => {
|
||||||
const { app, state, tx } = await buildApp({
|
const { app, state, tx } = await buildApp({
|
||||||
|
verifiedReceipt: true,
|
||||||
reimbursement: {
|
reimbursement: {
|
||||||
status: "paid",
|
status: "paid",
|
||||||
payment_voucher_no: "PBX-001",
|
payment_voucher_no: "PBX-001",
|
||||||
@@ -1817,7 +2132,7 @@ describe("payment batch reimbursement routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rolls back the whole execution path on an unexpected database error", async () => {
|
it("rolls back the whole execution path on an unexpected database error", async () => {
|
||||||
const { app, state, tx } = await buildApp();
|
const { app, state, tx } = await buildApp({ verifiedReceipt: true });
|
||||||
tx.invoicePool.updateMany.mockRejectedValueOnce(new Error("synthetic_database_failure"));
|
tx.invoicePool.updateMany.mockRejectedValueOnce(new Error("synthetic_database_failure"));
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -2027,6 +2342,7 @@ describe("payment batch reimbursement routes", () => {
|
|||||||
expect(uploadResponse.json().data.items[0]).toMatchObject({
|
expect(uploadResponse.json().data.items[0]).toMatchObject({
|
||||||
originalName: "bank-receipt.pdf",
|
originalName: "bank-receipt.pdf",
|
||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
|
ocrState: "queued",
|
||||||
governanceStatus: "approved",
|
governanceStatus: "approved",
|
||||||
sensitivityLevel: "confidential",
|
sensitivityLevel: "confidential",
|
||||||
attachmentType: "receipt",
|
attachmentType: "receipt",
|
||||||
@@ -2455,6 +2771,7 @@ describe("payment batch reimbursement routes", () => {
|
|||||||
it("retries only failed items and recomputes the batch summary from every final item state", async () => {
|
it("retries only failed items and recomputes the batch summary from every final item state", async () => {
|
||||||
const baseItem = buildPaymentBatch().items[0];
|
const baseItem = buildPaymentBatch().items[0];
|
||||||
const { app, tx, state } = await buildApp({
|
const { app, tx, state } = await buildApp({
|
||||||
|
verifiedReceipt: true,
|
||||||
batch: {
|
batch: {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
execution_summary_json: { successCount: 1, failedCount: 1, attemptedCount: 2 },
|
execution_summary_json: { successCount: 1, failedCount: 1, attemptedCount: 2 },
|
||||||
@@ -2560,6 +2877,7 @@ describe("payment batch reimbursement routes", () => {
|
|||||||
|
|
||||||
it("marks batch item failed when reimbursement is not payable and does not queue follow-ups", async () => {
|
it("marks batch item failed when reimbursement is not payable and does not queue follow-ups", async () => {
|
||||||
const { app, state } = await buildApp({
|
const { app, state } = await buildApp({
|
||||||
|
verifiedReceipt: true,
|
||||||
reimbursement: {
|
reimbursement: {
|
||||||
status: "reviewing",
|
status: "reviewing",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
||||||
|
const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("../../../src/services/paymentReceiptManualReview");
|
||||||
|
|
||||||
|
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
||||||
|
|
||||||
|
function reviewFixture() {
|
||||||
|
return { reviewer: { id: "reviewer", roleCodes: ["ceo"] }, hasReviewPermission: true,
|
||||||
|
makerId: "maker", proposedBy: "operator", participants: [{ ownerId: "owner", submitterId: "submitter" }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("independent manual receipt review identity", () => {
|
||||||
|
test("independent business reviewer is eligible", () => {
|
||||||
|
expect(() => assertIndependentReceiptReviewer(reviewFixture())).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["maker", "operator", "owner", "submitter"])("cannot review as %s", (id) => {
|
||||||
|
const input = reviewFixture();
|
||||||
|
input.reviewer.id = id;
|
||||||
|
expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_identity_conflict");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([["admin"], ["finance"], ["*"]])("permission does not substitute for business role %j", (role) => {
|
||||||
|
const input = reviewFixture();
|
||||||
|
input.reviewer.roleCodes = [role];
|
||||||
|
expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_permission_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("business role does not substitute for review permission", () => {
|
||||||
|
expect(() => assertIndependentReceiptReviewer({ ...reviewFixture(), hasReviewPermission: false }))
|
||||||
|
.toThrow("receipt_review_permission_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["makerId", "proposedBy", "participants"])("missing %s fails closed", (key) => {
|
||||||
|
const input = reviewFixture();
|
||||||
|
delete input[key];
|
||||||
|
expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_identity_unresolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every source participant is checked, not only the first reimbursement", () => {
|
||||||
|
const input = reviewFixture();
|
||||||
|
input.participants.push({ ownerId: "reviewer", submitterId: "another" });
|
||||||
|
expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_identity_conflict");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const fields = Object.entries({
|
||||||
|
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||||
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-MANUAL",
|
||||||
|
}).map(([Name, Value]) => ({ Name, Value }));
|
||||||
|
return {
|
||||||
|
original: { status: "recognized", pagesExpected: 1, pagesProcessed: 1,
|
||||||
|
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields) }] },
|
||||||
|
pages: [{ pageNumber: 1, itemId: "item-2", fields }],
|
||||||
|
candidates: ["item-1", "item-2"].map((id) => ({
|
||||||
|
id, amount: "10.00", currency: "CNY", payerAccount: "000011112222", payeeAccount: "000033334444",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("manual receipt proposal is evidence preparation, not authorization", () => {
|
||||||
|
test("explicit same-amount association needs no field reentry review", () => {
|
||||||
|
const input = fixture();
|
||||||
|
const before = JSON.stringify(input);
|
||||||
|
const result = buildManualReceiptProposal(config, input);
|
||||||
|
expect(result.requiresIndependentReview).toBe(false);
|
||||||
|
expect(result.associations).toEqual([{ pageNumber: 1, itemId: "item-2" }]);
|
||||||
|
expect(result.changedFields).toEqual([{ pageNumber: 1, fields: [] }]);
|
||||||
|
expect(result.bankVerified).toBe(false);
|
||||||
|
expect(JSON.stringify(input)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["amount", "currency", "payerAccount", "payeeAccount", "reference", "transactionStatus"])(
|
||||||
|
"corrected %s cannot masquerade as an automatic match", (field) => {
|
||||||
|
const input = fixture();
|
||||||
|
input.original.pages[0][field] = null;
|
||||||
|
const result = buildManualReceiptProposal(config, input);
|
||||||
|
expect(result.requiresIndependentReview).toBe(true);
|
||||||
|
expect(result.changedFields[0].fields).toContain(field);
|
||||||
|
expect(input.original.pages[0][field]).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("OCR failure requires independent review even with fully matching manual fields", () => {
|
||||||
|
const input = fixture();
|
||||||
|
input.original = { status: "ocr_failed", pagesExpected: 1, pagesProcessed: 0, pages: [] };
|
||||||
|
expect(buildManualReceiptProposal(config, input).requiresIndependentReview).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["failed", "pending"])("cannot rewrite original %s to success", (status) => {
|
||||||
|
const input = fixture();
|
||||||
|
input.original.pages[0].transactionStatus = status;
|
||||||
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_not_successful");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["交易失败", "处理中"])("conflicting status aliases retain %s veto", (status) => {
|
||||||
|
const input = fixture();
|
||||||
|
input.original.pages[0] = { pageNumber: 1, ...normalizeBankSlip([
|
||||||
|
...input.pages[0].fields, { Name: "交易状态", Value: status },
|
||||||
|
]) };
|
||||||
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_not_successful");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([["小写金额", "11.00"], ["币别", "USD"], ["付款人账号", "111122223333"],
|
||||||
|
["收款人账号", "222233334444"], ["单据状态", "已受理"], ["凭证号", ""]])(
|
||||||
|
"%s mismatch cannot be overridden by item selection or reason", (name, value) => {
|
||||||
|
const input = fixture();
|
||||||
|
input.pages[0].fields.find((field) => field.Name === name).Value = value;
|
||||||
|
input.reason = "测试理由不能免除核验";
|
||||||
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_manual_match_required");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("association cannot select another batch's item", () => {
|
||||||
|
const input = fixture();
|
||||||
|
input.pages[0].itemId = "outside";
|
||||||
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_item_unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing PDF page cannot be waived", () => {
|
||||||
|
const input = fixture();
|
||||||
|
input.original.pagesExpected = 2;
|
||||||
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_pages_unresolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown original page count cannot be guessed from manual input", () => {
|
||||||
|
const input = fixture();
|
||||||
|
input.original.pagesExpected = null;
|
||||||
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_pages_unresolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicate transaction on two pages cannot pay two items", () => {
|
||||||
|
const input = fixture();
|
||||||
|
input.original.pagesExpected = 2;
|
||||||
|
input.pages.push({ ...input.pages[0], pageNumber: 2, itemId: "item-1" });
|
||||||
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_reused");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two transactions cannot be assigned to one reimbursement", () => {
|
||||||
|
const input = fixture();
|
||||||
|
input.original.pagesExpected = 2;
|
||||||
|
input.pages.push({ ...input.pages[0], pageNumber: 2,
|
||||||
|
fields: input.pages[0].fields.map((field) => field.Name === "凭证号" ?
|
||||||
|
{ ...field, Value: "SYN-SECOND" } : field) });
|
||||||
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_multiple_transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["凭证号", "付款人账号"])("cannot hide original duplicate by correcting %s", (name) => {
|
||||||
|
const input = fixture();
|
||||||
|
input.original.pagesExpected = 2;
|
||||||
|
input.original.pagesProcessed = 2;
|
||||||
|
input.original.pages.push({ ...input.original.pages[0], pageNumber: 2 });
|
||||||
|
input.pages.push({ ...input.pages[0], pageNumber: 2, itemId: "item-1",
|
||||||
|
fields: input.pages[0].fields.map((field) => field.Name === name ?
|
||||||
|
{ ...field, Value: name === "凭证号" ? "SYN-ALTERED" : "999900001111" } : field) });
|
||||||
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_reused");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retains original fingerprint for persistent cross-batch reuse checks", () => {
|
||||||
|
const input = fixture();
|
||||||
|
const original = buildManualReceiptProposal(config, input).originalFingerprints;
|
||||||
|
input.pages[0].fields.find((field) => field.Name === "凭证号").Value = "SYN-CORRECTED";
|
||||||
|
const changed = buildManualReceiptProposal(config, input);
|
||||||
|
expect(changed.originalFingerprints).toEqual(original);
|
||||||
|
expect(changed.requiresIndependentReview).toBe(true);
|
||||||
|
expect(original).toHaveLength(1);
|
||||||
|
expect(original[0]).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
const {
|
||||||
|
normalizeBankSlip,
|
||||||
|
matchBankSlip,
|
||||||
|
parseReceiptMinorUnits,
|
||||||
|
} = require("../../../src/services/paymentReceiptMatching");
|
||||||
|
|
||||||
|
function fields(overrides = {}) {
|
||||||
|
return Object.entries({
|
||||||
|
小写金额: "1,234.56", 币别: "人民币", 付款人账号: "000011112222",
|
||||||
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-0001",
|
||||||
|
...overrides,
|
||||||
|
}).map(([Name, Value]) => ({ Name, Value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidates() {
|
||||||
|
return [{ id: "synthetic-item", amount: "1234.56", currency: "CNY",
|
||||||
|
payerAccount: "000011112222", payeeAccount: "000033334444" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bank receipt normalization", () => {
|
||||||
|
test.each([
|
||||||
|
["1,234.56", "123456"], ["123.45", "12345"], ["0.01", "1"],
|
||||||
|
["1234", "123400"], ["¥ 1234.56 元", "123456"], ["1,23.45", null],
|
||||||
|
["123.456", null], ["-1.00", null], ["1e3", null], ["", null],
|
||||||
|
["已付123.45", null], ["1 234.56", null],
|
||||||
|
])("parses exact money without guessing %s", (input, expected) => {
|
||||||
|
expect(parseReceiptMinorUnits(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes actual provider field aliases using synthetic values", () => {
|
||||||
|
const result = normalizeBankSlip(fields());
|
||||||
|
expect(result).toMatchObject({ amount: "123456", currency: "CNY",
|
||||||
|
payerAccount: "000011112222", payeeAccount: "000033334444",
|
||||||
|
transactionStatus: "success", reference: "SYN-0001", issues: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not use fees or uppercase text as transaction amount", () => {
|
||||||
|
const result = normalizeBankSlip(fields({ 小写金额: "", 手续费: "1234.56", 大写金额: "壹仟贰佰叁拾肆元伍角陆分" }));
|
||||||
|
expect(result.amount).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("conflicting aliases are not silently resolved", () => {
|
||||||
|
const result = normalizeBankSlip(fields({ 交易金额: "999.00" }));
|
||||||
|
expect(result.amount).toBeNull();
|
||||||
|
expect(result.issues).toContain("amount_ambiguous");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated semantic fields may represent multiple receipts on one page", () => {
|
||||||
|
const result = normalizeBankSlip([...fields(), ...fields()]);
|
||||||
|
expect(result.issues).toContain("amount_ambiguous");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["已受理", "交易成功处理中", "未成功", "", "已打印"])("does not infer payment success from %s", (status) => {
|
||||||
|
expect(normalizeBankSlip(fields({ 单据状态: status })).transactionStatus).not.toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("masked accounts stay unresolved", () => {
|
||||||
|
expect(normalizeBankSlip(fields({ 收款人账号: "****4444" })).payeeAccount).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid response does not become an empty successful receipt", () => {
|
||||||
|
expect(normalizeBankSlip(null).issues).toContain("response_invalid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bank receipt candidate matching", () => {
|
||||||
|
test("matches one exact transaction but does not declare bank verification", () => {
|
||||||
|
expect(matchBankSlip(normalizeBankSlip(fields()), candidates()))
|
||||||
|
.toEqual({ status: "matched", itemId: "synthetic-item", reasons: [], bankVerified: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[{ 小写金额: "1234.55" }, "amount_mismatch"],
|
||||||
|
[{ 收款人账号: "999933334444" }, "payee_account_mismatch"],
|
||||||
|
[{ 付款人账号: "999911112222" }, "payer_account_mismatch"],
|
||||||
|
[{ 币别: "USD" }, "currency_mismatch"],
|
||||||
|
])("blocks a known mismatch", (overrides, reason) => {
|
||||||
|
const result = matchBankSlip(normalizeBankSlip(fields(overrides)), candidates());
|
||||||
|
expect(result.status).toBe("mismatch");
|
||||||
|
expect(result.reasons).toContain(reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["交易失败", "已撤销", "已冲正", "已退汇"])("blocks %s", (status) => {
|
||||||
|
expect(matchBankSlip(normalizeBankSlip(fields({ 单据状态: status })), candidates()).status).toBe("mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["已受理", "未知", ""])("requires review for status %s", (status) => {
|
||||||
|
expect(matchBankSlip(normalizeBankSlip(fields({ 单据状态: status })), candidates()).status).toBe("needs_review");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires a transaction reference for downstream duplicate protection", () => {
|
||||||
|
expect(matchBankSlip(normalizeBankSlip(fields({ 凭证号: "" })), candidates()).reasons).toContain("reference_unresolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same-account same-amount items require disambiguation", () => {
|
||||||
|
expect(matchBankSlip(normalizeBankSlip(fields()), [...candidates(), { ...candidates()[0], id: "second-item" }]).status)
|
||||||
|
.toBe("needs_review");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not match receipt against combined batch total", () => {
|
||||||
|
const items = [ { ...candidates()[0], amount: "600.00" }, { ...candidates()[0], id: "second-item", amount: "634.56" } ];
|
||||||
|
expect(matchBankSlip(normalizeBankSlip(fields()), items).status).toBe("mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not match missing expected accounts", () => {
|
||||||
|
expect(matchBankSlip(normalizeBankSlip(fields()), [{ ...candidates()[0], payerAccount: null }]).status)
|
||||||
|
.toBe("needs_review");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not return receipt values in public matching results", () => {
|
||||||
|
const result = matchBankSlip(normalizeBankSlip(fields()), candidates());
|
||||||
|
expect(JSON.stringify(result)).not.toContain("000011112222");
|
||||||
|
expect(JSON.stringify(result)).not.toContain("SYN-0001");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const { PDFDocument } = require("pdf-lib");
|
||||||
|
const sharp = require("sharp");
|
||||||
|
const { recognizePaymentReceipt } = require("../../../src/services/paymentReceiptOcr");
|
||||||
|
|
||||||
|
async function pdfBuffer(pages = 1) {
|
||||||
|
const pdf = await PDFDocument.create();
|
||||||
|
for (let page = 0; page < pages; page += 1) pdf.addPage();
|
||||||
|
return Buffer.from(await pdf.save());
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerResult() {
|
||||||
|
return { BankSlipInfos: Object.entries({
|
||||||
|
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||||
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-REFERENCE",
|
||||||
|
}).map(([Name, Value]) => ({ Name, Value })), RequestId: "not-persisted", RawSensitive: "not-persisted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dedicated payment receipt OCR", () => {
|
||||||
|
test("processes every PDF page without public URLs or invoice writes", async () => {
|
||||||
|
const BankSlipOCR = vi.fn().mockResolvedValue(providerResult());
|
||||||
|
const result = await recognizePaymentReceipt({
|
||||||
|
buffer: await pdfBuffer(2), mimeType: "application/pdf", createClient: () => ({ BankSlipOCR }),
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ status: "recognized", pagesExpected: 2, pagesProcessed: 2 });
|
||||||
|
expect(BankSlipOCR).toHaveBeenCalledTimes(2);
|
||||||
|
expect(BankSlipOCR.mock.calls.map(([request]) => request.PdfPageNumber)).toEqual([1, 2]);
|
||||||
|
expect(BankSlipOCR.mock.calls.every(([request]) => request.ImageUrl === undefined)).toBe(true);
|
||||||
|
expect(result.pages[0].amount).toBe("1000");
|
||||||
|
expect(JSON.stringify(result)).not.toContain("not-persisted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not claim completeness after a later page fails or retry automatically", async () => {
|
||||||
|
const BankSlipOCR = vi.fn().mockResolvedValueOnce(providerResult()).mockRejectedValueOnce(new Error("sensitive error"));
|
||||||
|
const result = await recognizePaymentReceipt({
|
||||||
|
buffer: await pdfBuffer(2), mimeType: "application/pdf", createClient: () => ({ BankSlipOCR }),
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ status: "ocr_failed", pagesExpected: 2, pagesProcessed: 1, reason: "receipt_ocr_request_failed" });
|
||||||
|
expect(BankSlipOCR).toHaveBeenCalledTimes(2);
|
||||||
|
expect(JSON.stringify(result)).not.toContain("sensitive error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves unknown fields as unresolved rather than success", async () => {
|
||||||
|
const result = await recognizePaymentReceipt({
|
||||||
|
buffer: await pdfBuffer(), mimeType: "application/pdf",
|
||||||
|
createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: [] }) }),
|
||||||
|
});
|
||||||
|
expect(result.status).toBe("needs_review");
|
||||||
|
expect(result.pages[0].issues).toContain("response_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid PDF before creating cloud client", async () => {
|
||||||
|
const createClient = vi.fn();
|
||||||
|
const result = await recognizePaymentReceipt({ buffer: Buffer.from("not pdf"), mimeType: "application/pdf", createClient });
|
||||||
|
expect(result.reason).toBe("receipt_decode_failed");
|
||||||
|
expect(createClient).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("limits pages before any external call", async () => {
|
||||||
|
const createClient = vi.fn();
|
||||||
|
const result = await recognizePaymentReceipt({ buffer: await pdfBuffer(21), mimeType: "application/pdf", createClient });
|
||||||
|
expect(result.reason).toBe("receipt_page_limit");
|
||||||
|
expect(createClient).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts existing WEBP uploads to supported PNG in memory", async () => {
|
||||||
|
const buffer = await sharp({ create: { width: 10, height: 10, channels: 3, background: "white" } }).webp().toBuffer();
|
||||||
|
const BankSlipOCR = vi.fn().mockResolvedValue(providerResult());
|
||||||
|
const result = await recognizePaymentReceipt({ buffer, mimeType: "image/webp", createClient: () => ({ BankSlipOCR }) });
|
||||||
|
expect(result.status).toBe("recognized");
|
||||||
|
expect(BankSlipOCR.mock.calls[0][0].IsPdf).toBe(false);
|
||||||
|
const sent = Buffer.from(BankSlipOCR.mock.calls[0][0].ImageBase64, "base64");
|
||||||
|
expect((await sharp(sent).metadata()).format).toBe("png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing credentials do not silently disable OCR", async () => {
|
||||||
|
const result = await recognizePaymentReceipt({ buffer: await pdfBuffer(), mimeType: "application/pdf", config: {} });
|
||||||
|
expect(result).toMatchObject({ status: "ocr_failed", reason: "receipt_ocr_unavailable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["off", "mock"])("provider %s never falls through to real Tencent", async (ocrProvider) => {
|
||||||
|
const result = await recognizePaymentReceipt({
|
||||||
|
buffer: await pdfBuffer(), mimeType: "application/pdf",
|
||||||
|
config: { ocrProvider, tencentSecretId: "synthetic", tencentSecretKey: "synthetic" },
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ status: "ocr_failed", reason: "receipt_ocr_unavailable" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
const { createHash } = require("node:crypto");
|
||||||
|
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||||
|
const { claimReceiptJob, runReceiptOcrOnce, startReceiptOcrJob, startReceiptPgBossJob } = require("../../../src/jobs/paymentReceiptOcrJob");
|
||||||
|
|
||||||
|
test("cron serializes ticks locally and removes timer on close", async () => {
|
||||||
|
const cron = require("node-cron");
|
||||||
|
let callback;
|
||||||
|
let onClose;
|
||||||
|
let release;
|
||||||
|
const task = { stop: vi.fn(), destroy: vi.fn() };
|
||||||
|
const schedule = vi.spyOn(cron, "schedule").mockImplementation((expression, handler) => { callback = handler; return task; });
|
||||||
|
const transaction = vi.fn(() => new Promise((resolve) => { release = resolve; }));
|
||||||
|
try {
|
||||||
|
startReceiptOcrJob({ config: { ocrPollCron: "* * * * *" }, prisma: { $transaction: transaction },
|
||||||
|
log: { warn: vi.fn() }, addHook: (name, handler) => { onClose = handler; } });
|
||||||
|
const running = callback();
|
||||||
|
await callback();
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(1);
|
||||||
|
release(null);
|
||||||
|
await running;
|
||||||
|
await onClose();
|
||||||
|
expect(task.stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(task.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
} finally { schedule.mockRestore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pg-boss uses a separate receipt queue with no automatic request retry", async () => {
|
||||||
|
const boss = { createQueue: vi.fn(), work: vi.fn(), schedule: vi.fn() };
|
||||||
|
await startReceiptPgBossJob({ config: { ocrPollCron: "* * * * *", appTimezone: "Asia/Shanghai" } }, boss);
|
||||||
|
expect(boss.createQueue).toHaveBeenCalledWith("payment.receipt.ocr", { retryLimit: 0, expireInSeconds: 900 });
|
||||||
|
expect(boss.work).toHaveBeenCalledWith("payment.receipt.ocr", { batchSize: 1 }, expect.any(Function));
|
||||||
|
expect(boss.schedule.mock.calls[0][3]).toMatchObject({ retryLimit: 0, singletonKey: "payment.receipt.ocr" });
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
||||||
|
const buffer = Buffer.from("synthetic-pdf");
|
||||||
|
const attachment = { id: "attachment", payment_batch_id: "batch", receipt_ocr_version: 1,
|
||||||
|
file_size: buffer.length, file_sha256: createHash("sha256").update(buffer).digest("hex"),
|
||||||
|
mime_type: "application/pdf", storage_path: "private-synthetic", governance_status: "approved" };
|
||||||
|
const tx = {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([{ id: "batch" }]),
|
||||||
|
paymentBatchReceiptAttachment: { update: vi.fn().mockResolvedValue(attachment), updateMany: vi.fn(),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(attachment) },
|
||||||
|
paymentBatch: { findUnique: vi.fn().mockResolvedValue({ id: "batch", status: "reviewed", 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") } } }] }) },
|
||||||
|
paymentReceiptVerification: { create: vi.fn().mockResolvedValue({ id: "verification", status: "needs_review" }) },
|
||||||
|
};
|
||||||
|
const prisma = { $transaction: vi.fn((callback) => callback(tx)) };
|
||||||
|
const readBuffer = vi.fn().mockResolvedValue(buffer);
|
||||||
|
const recognize = vi.fn().mockResolvedValue({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [] });
|
||||||
|
return { prisma, config, readBuffer, recognize, tx, attachment };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("idle workers do not read files or call OCR", async () => {
|
||||||
|
const args = fixture();
|
||||||
|
args.tx.$queryRaw.mockResolvedValue([]);
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "idle" });
|
||||||
|
expect(args.readBuffer).not.toHaveBeenCalled();
|
||||||
|
expect(args.recognize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ entity_type: "travel_order", default_payment_channel: "bank", items: [{ status: "pending" }] },
|
||||||
|
{ entity_type: "reimbursement", default_payment_channel: "cash", items: [{ status: "pending" }] },
|
||||||
|
{ entity_type: "reimbursement", items: [{ status: "executed" }] },
|
||||||
|
])("out-of-scope queued receipts never read files or call OCR: %j", async (batch) => {
|
||||||
|
const args = fixture();
|
||||||
|
args.tx.paymentBatch.findUnique.mockResolvedValue({ id: "batch", ...batch });
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "idle" });
|
||||||
|
expect(args.readBuffer).not.toHaveBeenCalled();
|
||||||
|
expect(args.recognize).not.toHaveBeenCalled();
|
||||||
|
expect(args.tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||||
|
expect(args.tx.paymentBatchReceiptAttachment.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { payment_batch_id: "batch", receipt_ocr_state: "queued" }, data: { receipt_ocr_state: "legacy" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("claim marks processing and increments version in the transaction", async () => {
|
||||||
|
const args = fixture();
|
||||||
|
await claimReceiptJob(args.prisma);
|
||||||
|
expect(args.tx.paymentBatchReceiptAttachment.update.mock.calls[0][0].data)
|
||||||
|
.toMatchObject({ receipt_ocr_state: "processing", receipt_ocr_version: { increment: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("successful processing stores a result without raw file values in response", async () => {
|
||||||
|
const args = fixture();
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "needs_review" });
|
||||||
|
expect(args.recognize).toHaveBeenCalledTimes(1);
|
||||||
|
expect(args.tx.paymentReceiptVerification.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("file mismatch prevents external transmission", async () => {
|
||||||
|
const args = fixture();
|
||||||
|
args.attachment.file_sha256 = "a".repeat(64);
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "ocr_failed" });
|
||||||
|
expect(args.recognize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provider errors are not leaked or automatically retried", async () => {
|
||||||
|
const args = fixture();
|
||||||
|
args.recognize.mockRejectedValue(new Error("sensitive-provider-value"));
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "ocr_failed" });
|
||||||
|
expect(args.recognize).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("superseded result cannot update job state", async () => {
|
||||||
|
const args = fixture();
|
||||||
|
args.tx.paymentBatchReceiptAttachment.findUnique.mockResolvedValue({ ...args.attachment, receipt_ocr_version: 2 });
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "discarded" });
|
||||||
|
expect(args.tx.paymentReceiptVerification.create).not.toHaveBeenCalled();
|
||||||
|
expect(args.tx.paymentBatchReceiptAttachment.update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["receipt_key_unavailable", "key_failed"],
|
||||||
|
["receipt_bank_unsupported", "snapshot_failed"],
|
||||||
|
["unknown", "system_failed"],
|
||||||
|
])("persistence error %s records safe terminal state %s", async (code, state) => {
|
||||||
|
const args = fixture();
|
||||||
|
args.tx.paymentReceiptVerification.create.mockRejectedValue(Object.assign(new Error("sensitive"), { code }));
|
||||||
|
args.tx.paymentBatchReceiptAttachment.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: state });
|
||||||
|
expect(args.tx.paymentBatchReceiptAttachment.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: args.attachment.id, payment_batch_id: args.attachment.payment_batch_id,
|
||||||
|
receipt_ocr_version: 1, file_sha256: args.attachment.file_sha256,
|
||||||
|
receipt_ocr_state: "processing", archived_at: null, governance_status: "approved" },
|
||||||
|
data: { receipt_ocr_state: state },
|
||||||
|
});
|
||||||
|
expect(args.recognize).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing key is persisted before any file read or cloud call", async () => {
|
||||||
|
const args = fixture();
|
||||||
|
args.config = {};
|
||||||
|
args.tx.paymentBatchReceiptAttachment.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "key_failed" });
|
||||||
|
expect(args.readBuffer).not.toHaveBeenCalled();
|
||||||
|
expect(args.recognize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("late failure cannot overwrite a changed attachment", async () => {
|
||||||
|
const args = fixture();
|
||||||
|
args.tx.paymentReceiptVerification.create.mockRejectedValue(new Error("synthetic"));
|
||||||
|
args.tx.paymentBatchReceiptAttachment.updateMany.mockResolvedValue({ count: 0 });
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "discarded" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failure recording outage is reported without claiming persistence", async () => {
|
||||||
|
const args = fixture();
|
||||||
|
args.tx.paymentReceiptVerification.create.mockRejectedValue(new Error("sensitive"));
|
||||||
|
args.tx.paymentBatchReceiptAttachment.updateMany.mockRejectedValue(new Error("sensitive-db"));
|
||||||
|
expect(await runReceiptOcrOnce(args)).toEqual({ status: "failure_recording_failed" });
|
||||||
|
expect(args.recognize).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
const { encryptStringOrThrow, decryptStringOrThrow } = require("../../../src/services/crypto");
|
||||||
|
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
||||||
|
const { buildVerificationData, claimVerifiedReceipts, transactionFingerprint, canRetryReceiptRecognition } = require("../../../src/services/paymentReceiptVerification");
|
||||||
|
|
||||||
|
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({
|
||||||
|
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||||
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-REF",
|
||||||
|
}).map(([Name, Value]) => ({ Name, Value }))) };
|
||||||
|
const batch = {
|
||||||
|
id: "batch", entity_type: "reimbursement",
|
||||||
|
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, page.payerAccount) },
|
||||||
|
items: [{ id: "item", status: "pending", amount: "10.00", currency_code: "CNY",
|
||||||
|
snapshot_json: { payee: { bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, page.payeeAccount) } } }],
|
||||||
|
};
|
||||||
|
const attachment = { id: "attachment", payment_batch_id: batch.id, file_sha256: "a".repeat(64), governance_status: "approved", archived_at: null, receipt_ocr_version: 0 };
|
||||||
|
const recognition = { status: "recognized", pagesExpected: 1, pagesProcessed: 1, pages: [page] };
|
||||||
|
const data = buildVerificationData(config, batch, attachment, recognition);
|
||||||
|
const verification = { ...data, id: "verification", attachment, matches: data.matches.create };
|
||||||
|
const tx = {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([{ id: attachment.id }]),
|
||||||
|
paymentReceiptVerification: { findFirst: vi.fn().mockResolvedValue(verification) },
|
||||||
|
paymentReceiptClaim: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]), create: vi.fn() },
|
||||||
|
paymentReceiptTransactionAlias: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]), create: vi.fn() },
|
||||||
|
paymentReceiptManualProposal: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
return { batch, attachment, recognition, data, verification, tx };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("receipt evidence and transaction claims", () => {
|
||||||
|
test("mixed historical cash items do not require bank snapshots or bank receipt claims", async () => {
|
||||||
|
const { batch, attachment, recognition, tx } = fixture();
|
||||||
|
batch.items.push({ id: "cash-item", status: "pending", amount: "10.00", currency_code: "CNY",
|
||||||
|
payment_channel_override: "cash", snapshot_json: {} });
|
||||||
|
expect(buildVerificationData(config, batch, attachment, recognition).status).toBe("matched");
|
||||||
|
await claimVerifiedReceipts(tx, config, batch);
|
||||||
|
expect(tx.paymentReceiptClaim.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tx.paymentReceiptClaim.create.mock.calls[0][0].data.item_id).toBe("item");
|
||||||
|
});
|
||||||
|
test.each([[899999, false], [900000, true], [900001, true]])("retry becomes available only at timeout %s", (age, allowed) => {
|
||||||
|
const now = new Date("2026-09-14T00:00:00Z");
|
||||||
|
const attachment = { receipt_ocr_state: "processing", governance_status: "approved",
|
||||||
|
receipt_ocr_started_at: new Date(now.getTime() - age) };
|
||||||
|
expect(canRetryReceiptRecognition(attachment, now)).toBe(allowed);
|
||||||
|
expect(canRetryReceiptRecognition({ ...attachment, archived_at: now }, now)).toBe(false);
|
||||||
|
});
|
||||||
|
test("stores only encrypted normalized evidence and keyed transaction identity", () => {
|
||||||
|
const { data, recognition } = fixture();
|
||||||
|
expect(JSON.stringify(data)).not.toContain("000011112222");
|
||||||
|
expect(JSON.stringify(data)).not.toContain("SYN-REF");
|
||||||
|
expect(JSON.parse(decryptStringOrThrow(config, data.normalized_encrypted))).toEqual(recognition);
|
||||||
|
expect(data.matches.create[0].fingerprint).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transaction identity is independent of file, amount and payee", () => {
|
||||||
|
const { recognition } = fixture();
|
||||||
|
const page = recognition.pages[0];
|
||||||
|
expect(transactionFingerprint(config, page)).toEqual(transactionFingerprint(config, { ...page, amount: "2000", payeeAccount: "999999999999" }));
|
||||||
|
expect(transactionFingerprint(config, page)).not.toEqual(transactionFingerprint(config, { ...page, payerAccount: "999999999999" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not persist extra provider response fields even encrypted", () => {
|
||||||
|
const { batch, attachment, recognition } = fixture();
|
||||||
|
const data = buildVerificationData(config, batch, attachment, { ...recognition, RawSensitive: "provider-extra" });
|
||||||
|
expect(decryptStringOrThrow(config, data.normalized_encrypted)).not.toContain("provider-extra");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not match partial pages", () => {
|
||||||
|
const { batch, attachment, recognition } = fixture();
|
||||||
|
expect(buildVerificationData(config, batch, attachment, { ...recognition, pagesExpected: 2 }).status).toBe("needs_review");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires different items and transaction identities across pages", () => {
|
||||||
|
const { batch, attachment, recognition } = fixture();
|
||||||
|
const duplicated = { ...recognition, pagesExpected: 2, pagesProcessed: 2,
|
||||||
|
pages: [recognition.pages[0], { ...recognition.pages[0], pageNumber: 2 }] };
|
||||||
|
expect(buildVerificationData(config, batch, attachment, duplicated).status).toBe("needs_review");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("claims a verified item inside supplied transaction", async () => {
|
||||||
|
const { tx, batch } = fixture();
|
||||||
|
await claimVerifiedReceipts(tx, config, batch);
|
||||||
|
expect(tx.paymentReceiptClaim.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tx.paymentReceiptClaim.create.mock.calls[0][0].data.item_id).toBe("item");
|
||||||
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("idempotent claim does not insert again", async () => {
|
||||||
|
const { tx, batch, verification } = fixture();
|
||||||
|
tx.paymentReceiptClaim.findMany.mockResolvedValue([verification.matches[0]]);
|
||||||
|
await claimVerifiedReceipts(tx, config, batch);
|
||||||
|
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cannot reuse transaction in another item or batch", async () => {
|
||||||
|
const { tx, batch, verification } = fixture();
|
||||||
|
tx.paymentReceiptClaim.findMany.mockResolvedValue([{ ...verification.matches[0], item_id: "other-item" }]);
|
||||||
|
await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_transaction_reused" });
|
||||||
|
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("key rotation cannot bypass historical duplicate detection", async () => {
|
||||||
|
const { tx, batch } = fixture();
|
||||||
|
tx.paymentReceiptClaim.count.mockResolvedValue(1);
|
||||||
|
await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_key_rotation_required" });
|
||||||
|
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["archived", "quarantined", "hash_changed", "latest_failed"])("rejects unavailable evidence: %s", async (reason) => {
|
||||||
|
const { tx, batch, verification } = fixture();
|
||||||
|
if (reason === "archived") verification.attachment.archived_at = new Date();
|
||||||
|
if (reason === "quarantined") verification.attachment.governance_status = "quarantined";
|
||||||
|
if (reason === "hash_changed") verification.attachment.file_sha256 = "b".repeat(64);
|
||||||
|
if (reason === "latest_failed") verification.status = "needs_review";
|
||||||
|
await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_verification_required" });
|
||||||
|
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-evaluates frozen amount rather than trusting persisted matched flag", async () => {
|
||||||
|
const { tx, batch } = fixture();
|
||||||
|
batch.items[0].amount = "11.00";
|
||||||
|
await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_match_changed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checks persisted fingerprint against encrypted evidence", async () => {
|
||||||
|
const { tx, batch, verification } = fixture();
|
||||||
|
verification.matches[0].fingerprint = "f".repeat(64);
|
||||||
|
await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_match_changed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not reopen already executed items", async () => {
|
||||||
|
const { tx, batch } = fixture();
|
||||||
|
batch.items[0].status = "executed";
|
||||||
|
await claimVerifiedReceipts(tx, config, batch);
|
||||||
|
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户