From f57e2236f25cad4e05ee5cd8ebc8d762880ba8ba Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 31 Aug 2026 20:20:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=A5=E9=94=80=E7=A5=A8?= =?UTF-8?q?=E6=8D=AE=E9=97=A8=E7=A6=81=E6=8F=90=E7=A4=BA=E4=B8=8E=20OCR=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=EF=BC=8808-30-b2-submit-error-ocr-fallback?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/reimbursement-invoice-ocr.md | 86 +++++-- .../task.json | 5 +- .../check.jsonl | 1 + .../design.md | 109 +++++++++ .../implement.jsonl | 1 + .../implement.md | 57 +++++ .../08-30-b2-submit-error-ocr-fallback/prd.md | 96 ++++++++ .../task.json | 34 +++ client/src/api/errorMessages.ts | 61 +++++ client/src/api/reimbursements.ts | 19 ++ client/src/lib/reimbursementInvoiceGuard.ts | 148 ++++++++++++ .../pages/MobileReimbursementDetailPage.tsx | 176 +++++++++++++- client/src/pages/ReimbursementDetailPage.tsx | 183 +++++++++++++-- .../r111-invoice-reimbursement-guard.spec.ts | 209 ++++++++++++++++- docs/报销发票附件规则.md | 6 +- server/src/routes/reimbursements.js | 215 +++++++++++++++++- server/src/services/invoiceAmountEvidence.js | 32 +++ .../unit/routes/reimbursements.detail.test.js | 48 ++++ .../reimbursements.expense-standards.test.js | 170 ++++++++++++++ .../reimbursements.invoice-item-link.test.js | 180 +++++++++++++++ .../services/invoiceAmountEvidence.test.js | 42 ++++ 21 files changed, 1829 insertions(+), 49 deletions(-) create mode 100644 .trellis/tasks/08-30-b2-submit-error-ocr-fallback/check.jsonl create mode 100644 .trellis/tasks/08-30-b2-submit-error-ocr-fallback/design.md create mode 100644 .trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.jsonl create mode 100644 .trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.md create mode 100644 .trellis/tasks/08-30-b2-submit-error-ocr-fallback/prd.md create mode 100644 .trellis/tasks/08-30-b2-submit-error-ocr-fallback/task.json create mode 100644 client/src/lib/reimbursementInvoiceGuard.ts create mode 100644 server/src/services/invoiceAmountEvidence.js create mode 100644 server/tests/unit/routes/reimbursements.invoice-item-link.test.js create mode 100644 server/tests/unit/services/invoiceAmountEvidence.test.js diff --git a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md index aea266d1..3cea0fb8 100644 --- a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md +++ b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md @@ -30,10 +30,12 @@ duplicate detection or attachment behavior. require the same own-or-`expense.view_all` guard as reimbursement detail reads before invoking the precheck service; an ordinary nonparticipant must receive 403 without service-side evaluation. -- 取消或放宽必填校验时,必须继续审计该校验之后的代码是否依赖它提供的 - 非空、类型或状态保证,并为生产真实存在的空值路径补回归测试。不得只 - 删除前置错误分支;可空字段的 `.trim()`、`.length`、字符串拼接和传给 - 非空参数的调用都应逐处确认。 +- 增加、取消或放宽强制校验时,必须同时核对既有降级合同、兼容模式和该 + 校验之后的代码假设。取消或放宽时审计下游是否依赖它提供的非空、类型 + 或状态保证,并为生产真实存在的空值路径补回归测试;增加时确认不会把 + `off`、历史数据或其他已承诺可用的降级路径变成不可提交。不得只修改 + 前置错误分支;可空字段的 `.trim()`、`.length`、字符串拼接和传给非空 + 参数的调用都应逐处确认。 ## Scenario: OCR Disabled Without Mock Data @@ -994,6 +996,10 @@ await selectApprovalRule(reimbursement.total_amount); - Reimbursement detail returns `invoiceGuardEnabled` for the form department. - `POST /api/invoice-pool/:id/bind` requires `linkedItemId` while the guard is enabled; `POST /api/reimbursements/:id/submit` enforces the final gate. +- `PATCH /api/reimbursements/:id/invoices/:invoiceId/item-link` atomically links + one bound-but-unlinked invoice to an item in the same editable form. +- Detail invoice projection adds `amountEvidenceStatus`; form detail and submit + success add the redacted manual-review summary without exposing OCR payloads. ### 3. Contracts @@ -1017,16 +1023,36 @@ await selectApprovalRule(reimbursement.total_amount); - With B2 on, binding requires an existing item in the same reimbursement and may not reach the legacy auto-create branch. If an upload waits for OCR, keep the user-selected item ID with the pending invoice and send it when OCR later - makes the invoice bindable. Submit blocks any bound invoice with no item - link, each required-invoice item with no amount-bearing bound invoice, and - each declared amount greater than its linked invoice total. -- Sum and compare money with `Prisma.Decimal`. Invoice-free items are excluded. - A lower declared amount remains allowed for finance to reconcile. + makes the invoice bindable. Submit separates PDF presence from amount + evidence: a required item without a bound PDF blocks as missing evidence; + a bound PDF with attempted OCR but no amount blocks as recognition failure. +- Classify one invoice from the explicit minimum fields `total_amount`, + `ocr_attempts`, request/completion timestamps and the bounded OCR error fact. + Non-null amount is `verified`; null amount with no execution fact is + `manual_review`; null amount with any execution fact is `ocr_failed`. + Never infer execution from the historical non-null `ocr_provider` default. +- Sum and compare money with `Prisma.Decimal` only when every bound invoice for + the required item is `verified`. If any invoice is `manual_review`, allow + submission and mark the item/form for finance review; a mixed verified plus + manual-review set must not be compared using only the known partial sum. + Invoice-free items are excluded. A lower declared amount remains allowed for + finance to reconcile. +- A bound invoice with `linked_item_id=null` remains visible and recoverable. + The narrow item-link write locks invoice and form, reuses the owner/submitter + participant check, accepts only `draft|rejected`, verifies both objects belong + to the form, updates only `linked_item_id`, and writes audit in one + transaction. Failure and concurrent state drift produce zero writes. +- PC and mobile map structured guard details to a safe Chinese summary plus + item/invoice-local recovery text. IDs locate current DTO objects only and are + never rendered; dynamic summaries use alert semantics and toast is not the + sole feedback. Finance read-only detail keeps manual-review markers visible. - With B2 off, retain legacy auto-match/create compatibility. Keep the legacy whole-form invoice gate only when at least one item requires an invoice; allowance-only forms remain invoice-free because that is an always-active B1 policy. Turning the flag off never deletes or unbinds relationships already - written while it was on. + written while it was on. The manual-review projection and submit-success + notice remain active for linked no-OCR PDFs even when B2 blocking is off; + the flag controls blocking, not whether finance is told to inspect originals. - Form maintenance and invoice bind/unbind remain owner-or-submitter writes; `expense.view_all` and admin roles remain read-only for another employee's reimbursement. @@ -1040,11 +1066,23 @@ await selectApprovalRule(reimbursement.total_amount); `required_when_invoice_guard_enabled`; an item outside the form remains `item_not_in_reimbursement` with zero writes. - Guard-on submit with unlinked bound invoices -> `invoice_item_guard_failed` - plus `unlinkedInvoiceCount`. -- Required item without an amount-bearing bound invoice -> same error plus + plus `unlinkedInvoiceCount` and locating `unlinkedInvoiceIds`. +- Required item without any bound PDF -> same error plus `missingInvoiceItemIds`. +- Bound PDF with an OCR execution fact but no amount -> same error plus + `amountUnavailableItems`; tell the user to retry recognition or contact + finance, not to upload a duplicate PDF. +- Bound PDF with no OCR execution fact and no amount -> submit succeeds with + `invoiceAmountManualReviewRequired=true`; approval detail keeps the same + manual-review warning until comparable evidence exists. +- Mixed verified and manual-review invoices -> submit succeeds for manual + review without comparing the declared amount to the known partial sum. - Declared amount above linked total -> same error plus entries containing only - `itemId`, canonical `declaredAmount` and `invoiceTotal`. + `itemId`, canonical `declaredAmount` and `invoiceTotal`, but only when every + linked invoice amount is verified. +- Item-link by a nonparticipant, on a non-editable form, across forms, for an + already-linked/non-bound invoice or after a concurrent state change -> reject + with zero link/audit writes. - Invoice-free allowance-only form with no invoice -> may submit with the guard either on or off when all other existing checks pass. - Env hard-off or DB force-off -> B2 item-level blocks are skipped; the legacy @@ -1054,6 +1092,11 @@ await selectApprovalRule(reimbursement.total_amount); - Good: one required item explicitly links several same-purpose invoices whose total covers the declaration; submit passes with the guard on. +- Good: five required items each link one verified invoice and submit normally; + one off-mode PDF with no OCR execution submits with an explicit finance + manual-review marker. +- Good: a CNY 1,000 item linked to one verified CNY 600 invoice and one + manual-review invoice does not produce a false CNY 400 overage. - Good: a positive meal allowance-only form has no invoice and passes with the guard either on or off. - Base: guard off preserves an older draft and its auto-created link; later @@ -1064,6 +1107,9 @@ await selectApprovalRule(reimbursement.total_amount); item while explicit selection is required, use JavaScript floating-point sums for money, let repeated init erase finance account mappings, or 删除前置 必填校验后继续对可空值直接调用 `.trim()`。 +- Bad: call a PDF with failed OCR “missing invoice”, use only a known partial + amount in a mixed set, expose UUIDs/raw details, or implement recovery as + client-side unbind followed by bind. ### 6. Tests Required @@ -1075,14 +1121,20 @@ await selectApprovalRule(reimbursement.total_amount); disabled legacy types, default-off flag and account-code preservation. - Item/submit route tests 覆盖 assets 创建 omitted→null、编辑 null+omitted→null、 有值+omitted→保留、显式清空、切换非 assets 清空和无依据提交,并继续覆盖 - invoice-free zero invoice、missing link、unlinked bound invoice、 - declared-over-invoice 与 participant/nonparticipant side effects。 + invoice-free zero invoice、missing PDF、unlinked bound invoice、OCR failed、 + manual review、verified+manual mixed evidence、five-item/five-invoice normal + submit、declared-over-invoice 与 participant/nonparticipant side effects。 - Bind tests cover flag on/off, missing/foreign/successful explicit links and prove the guard-on path does not create a reimbursement item. +- Atomic item-link tests cover success, participant ownership, editable status, + invoice/form/item ownership, existing links, non-bound state and concurrent + zero-write behavior including audit. - PC/mobile Playwright covers asset 登记导向说明、批准依据输入/展示均不存在且 保存 payload omission、CNY 2,000 warning、allowance-only submit projection、 disabled add action until every - selected invoice has an item, and pending-OCR binding retaining that item. + selected invoice has an item, pending-OCR binding retaining that item, four + safe guard messages with local recovery actions, alert semantics, atomic + unlinked-invoice recovery and finance manual-review visibility. - Run full backend Vitest with the pinned BIFF8 Python dependencies, Prisma validate/generate check, frontend typecheck/build, focused Playwright, process/docs/Trellis governance and `git diff --check`. @@ -1109,7 +1161,7 @@ asset_prior_approval_basis: targetApprovalBasis.trim(); #### Correct ```js -if (item.expense_type.requires_invoice) { +if (item.expense_type.requires_invoice && linkedInvoices.every(hasVerifiedAmount)) { const total = linkedInvoices.reduce( (sum, invoice) => sum.plus(invoice.total_amount), new Prisma.Decimal(0), diff --git a/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/task.json b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/task.json index 76a49b22..ea827f98 100644 --- a/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/task.json +++ b/.trellis/tasks/08-29-reimbursement-expense-policy-invoice-guard/task.json @@ -21,7 +21,8 @@ "children": [ "08-30-reimbursement-expense-policy-invoice-guard-deploy", "08-30-reimbursement-assets-prior-approval-correction", - "08-30-reimbursement-employee-guide-b2-sync" + "08-30-reimbursement-employee-guide-b2-sync", + "08-30-b2-submit-error-ocr-fallback" ], "parent": null, "relatedFiles": [ @@ -39,4 +40,4 @@ ], "notes": "本地实施和只读专项复核已完成;B2 env/DB 双闸仍默认关闭,未部署、未应用生产 migration、未写生产配置。2,000 元/条明细的选类提示阈值已批准。会计科目映射由财务负责人在 B1 生产启用前提供,目标回填日期 2026-09-05;未取得正式科目表时保持 null,不由开发人员猜测。生产只读审计确认 0014/0016/0017/0018 均需退回修正;顺序改为先部署 B1、隔离验证并启用 B2,再由真实财务统一退回。B2 不校验日期粒度,0014 必须按 5 个发票日期拆分并由财务人工复核。部署合同见子任务 08-30-reimbursement-expense-policy-invoice-guard-deploy。B3 财务一页式及行内费用标准提示恢复仍为后续任务。", "meta": {} -} +} \ No newline at end of file diff --git a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/check.jsonl b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/design.md b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/design.md new file mode 100644 index 00000000..42ef4295 --- /dev/null +++ b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/design.md @@ -0,0 +1,109 @@ +# 设计:B2 提交错误可解释性与 OCR 降级合同 + +## 设计边界 + +本轮修复提交门禁的结构化反馈、OCR 金额降级语义和未归属发票的最小恢复动作。保持现有 B2 feature flag、发票/明细关系、审批状态机和数据库 schema,不进行详情页整体重构。 + +## 服务端金额证据模型 + +新增纯函数统一判定单张 bound 发票的金额证据,避免提交门禁、详情 DTO 和测试各自推断。函数只接收显式最小字段集(`totalAmount`、`ocrAttempts`、`ocrLastRequestedAt`、`ocrCompletedAt`、`ocrLastError`),不得接收整个 Prisma invoice 对象: + +| 状态 | 证据 | 提交语义 | +| --- | --- | --- | +| `verified` | `total_amount != null` | 可参与 `Prisma.Decimal` 合计 | +| `manual_review` | 金额为空,且没有 OCR 请求、尝试或完成事实 | off/历史降级 PDF;允许提交,人工核对 | +| `ocr_failed` | 金额为空,且存在 OCR 请求、尝试、完成或失败事实 | 金额识别失败;单独阻断 | + +判定不得只读取 `ocr_provider`:该列有历史非空默认值,不能证明实际调用过 OCR。提交查询需补入最小 OCR 执行字段;不得返回 OCR 原始响应或票面敏感字段。 + +每条需票明细按以下顺序检查: + +1. 没有 bound PDF:加入 `missingInvoiceItemIds`。 +2. 有 `ocr_failed` 发票:加入 `amountUnavailableItems`,不继续金额比较。 +3. 有 `manual_review` 发票:加入 `manualReviewItemIds`,允许继续提交,不用不完整金额做超额判断。 +4. 全部为 `verified`:用 `Prisma.Decimal` 合计;申报金额更高时加入 `amountExceededItems`。 + +整单仍先检查 `bound + linked_item_id=null`。两项 `requires_invoice=false` 的补助不进入上述判断。 + +## 结构化错误合同 + +保持 `400 VALID_001` 与 `details.items=invoice_item_guard_failed`,兼容保留现有字段并补充可定位字段: + +```ts +type InvoiceItemGuardDetails = { + items: "invoice_item_guard_failed"; + unlinkedInvoiceCount: number; + unlinkedInvoiceIds: string[]; + missingInvoiceItemIds: string[]; + amountUnavailableItems: Array<{ itemId: string; invoiceIds: string[] }>; + amountExceededItems: Array<{ + itemId: string; + declaredAmount: string; + invoiceTotal: string; + }>; +}; +``` + +ID 只用于客户端把服务端结论映射到当前详情中的明细/发票,不直接渲染。通用 `mapApiErrorMessage` 负责安全摘要;报销详情页使用窄化解析器生成页面内问题模型,未知或畸形 details 回退到通用中文错误,不回显原始对象。 + +成功提交如存在 `manualReviewItemIds`,提交响应返回脱敏的人工核对摘要;详情响应根据发票金额证据计算: + +- 整单 `invoiceAmountManualReviewRequired`; +- 每张发票 `amountEvidenceStatus`; +- 每条明细可由关联 ID 聚合出人工核对提示。 + +这些字段是运行时投影,不新增持久化列。降级票据以后补齐金额时,人工核对标记自然消失;审批详情在未补齐前持续可见。 + +## PC 与移动端反馈 + +客户端共享一个解析与映射模块,PC/移动不各写一套字符串判断: + +- 提交区域显示汇总,例如“还有 2 项票据问题,请按下方提示处理”。 +- 对应明细卡片显示“尚未关联正式发票”“票面金额未识别,请重试识别或联系财务”“申报金额高于关联发票合计”等原因。 +- 对应发票卡片显示“未选择报销明细”,并提供恢复动作。 +- 降级 PDF 显示“票面金额需财务查看原件核对”,不使用成功色或“校验通过”。 +- 多项错误的摘要使用 `role=alert`/`aria-live`,并可将焦点移动到首个问题区域;卡片不能只靠红色表达状态。 + +这符合长表单的最小反馈合同:顶部摘要帮助发现问题,就近错误说明原因和恢复路径,toast 只提示提交未完成。 + +## 未归属发票原子恢复 + +新增窄用途接口,例如: + +```http +PATCH /api/invoice-pool/:id/reimbursement-item +{ "reimbursementId": "...", "linkedItemId": "..." } +``` + +事务内: + +1. `FOR UPDATE` 锁发票;只接受 `status=bound`、已绑定目标报销且当前 `linked_item_id=null`。 +2. 锁报销单;复用 `ensureReimbursementParticipant` 的 owner/submitter 参与人合同,且仅 `draft/rejected` 可写。 +3. 校验目标明细属于同一报销单。 +4. 只更新 `linked_item_id`/`updated_by`,写审计记录;不把发票改为 `available`,不重跑查重,不创建明细。 + +PC 和移动卡片在未归属时显示明细下拉框和“确认关联”。成功后失效并刷新详情;409/403 等并发或权限错误留在卡片内。其他已有关联发票本轮不显示改绑入口。 + +## 兼容性与事实源 + +- B2 flag 开启时使用新判断;flag 关闭时保留既有兼容路径,不删除关联关系。 +- `OCR_PROVIDER=tencent` 的正常金额路径不变;`mock` 仍禁止用于正式票据。 +- off/未执行 OCR 的 PDF 不要求补录金额;文档与 spec 更新为“关联存在”和“金额证据”两层检查。 +- 不把发票日期写成明细发生日期权威值。 +- 不新增文档;只更新 `docs/报销发票附件规则.md` 与现有 reimbursement spec。 + +## 发布、验收与回滚 + +- 无 migration、无 seed、无 flag 变更;部署仍需独立授权并记录现有 migration/init 健康结果。 +- 公网使用合成草稿覆盖:未归属恢复、缺票、金额识别失败、超票面、off/未 OCR 人工核对和正常提交;不得改真人单据做 smoke。 +- 部署后核 `/ready`、bundle marker、B2 仍为 `force_on + global`,并确认四张真人单据状态未被部署改变。 +- 应用异常时回滚到本轮部署前生产候选;本轮无 schema/数据迁移,已存在票据关系无需补偿。 + +## 风险控制 + +- 最大产品风险是把“金额未知”误报成“缺票”或“校验通过”;用三态金额证据和独立 UI 文案锁定。 +- 最大一致性风险是前端用移除/添加模拟改绑;用单事务窄接口消除临时可用窗口。 +- 最大兼容风险是旧客户端不认识新增 fields;保留现有错误 code/字段,新增字段只做加法。 +- 最大隐私风险是把 UUID 或原始 details 渲染给员工;解析器只输出由当前详情投影出的序号、类型和说明摘要。 +- 最大可访问性风险是只用颜色或 toast;测试必须覆盖文本、告警语义和问题附近的恢复按钮。 +- 部署前告警样式与恢复动作须由具备项目 `frontend-design` skill 的会话完成视觉裁决;当前会话的 UI/UX 规范校核不替代该部署门。 diff --git a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.jsonl b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.md b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.md new file mode 100644 index 00000000..bf574fae --- /dev/null +++ b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.md @@ -0,0 +1,57 @@ +# 实施清单:B2 提交错误可解释性与 OCR 降级合同 + +## 1. 规划与证据冻结 + +- [x] 记录生产 `OCR_PROVIDER=tencent`,确认不回退 B2 flag。 +- [x] 用生产只读数据复现 0014 当前三类 B2 判断与时间线,记录历史失败 details 不可恢复的证据边界。 +- [x] PRD、design、implement 经用户审查并明确批准;批准前不运行 `task.py start`、不改业务代码。 + +## 2. 服务端门禁与 DTO + +- [x] 抽取并独立测试只接收最小字段集的金额证据纯函数:verified / manual_review / ocr_failed;三个分支各含边界用例,不依赖 Prisma 记录形状。 +- [x] 提交查询补齐最小 OCR 执行字段,将“无关联 PDF”“金额识别失败”“降级人工核对”“完整金额超额”分支按设计顺序实现。 +- [x] 金额完整时继续用 `Prisma.Decimal`;混合降级票据时不做不完整金额比较,并把人工核对摘要返回成功响应。 +- [x] 保持 `invoice_item_guard_failed`、既有字段与 flag off 语义,新增 `unlinkedInvoiceIds`、`amountUnavailableItems` 等定位字段。 +- [x] 详情 DTO 增加发票金额证据状态和整单人工核对投影,不返回 OCR 原始结果或敏感票面字段。 + +## 3. 未归属发票恢复接口 + +- [x] 新增 bound 未归属发票关联本单明细的单事务接口;锁发票/报销、复用 `ensureReimbursementParticipant` 校验 owner/submitter,并校验状态和目标明细归属。 +- [x] 只更新 `linked_item_id` 并留审计,不执行 unbind/bind、不短暂释放发票、不创建明细。 +- [x] 覆盖不存在、非 bound、已有归属、跨单明细、非参与人、非草稿/驳回及并发状态变化,失败保持零写入。 + +## 4. PC 与移动端 + +- [x] 增加共享的 guard details 窄化解析器和安全摘要;未知 details 回退通用中文,不展示 UUID/原始 payload。 +- [x] PC/移动提交失败同时显示可访问摘要和明细/票据就近错误;toast 仅作补充。 +- [x] 未归属票据卡片显示显著状态、明细选择和原子“确认关联”动作;成功刷新详情,失败错误留在卡片。 +- [x] 金额识别失败与降级人工核对使用不同文案;后者在提交前/成功后及审批只读详情持续可见,不宣称自动校验通过。 +- [x] 不调整报销事由编辑、处理进度、关联项目顺序、自动生成明细或 PC sticky 操作布局。 + +## 5. 文档与规约 + +- [x] 更新 `docs/报销发票附件规则.md`:明确关联 PDF 与金额证据分层、off 人工核对、腾讯金额失败和混合票据行为。 +- [x] 更新 `.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md` 的提交合同、错误矩阵、测试矩阵和“取消/增加校验需核对降级合同”通用规则。 +- [x] 不新建文档,不改员工手册中与本轮无关的填报粒度和日期口径。 + +## 6. 测试矩阵 + +- [x] 后端:未归属发票返回可定位 ID;需票明细无 bound PDF;Tencent/已尝试 OCR 但金额为空;完整金额超额;正常金额通过。 +- [x] 后端:off/未执行 OCR 且金额为空允许提交并标人工核对;有金额 + 降级无金额进入人工核对;有金额 + 识别失败阻断;免票补助无票通过。 +- [x] 后端:0014 类 5 明细/5 金额有效发票逐条关联时正常提交;B2 flag off 兼容路径不变。 +- [x] 后端:原子重新归属接口的成功、权限、状态、跨单、已有归属和并发零写入矩阵。 +- [x] 前端单元/组件:四类中文摘要、畸形 details 回退、ID 到明细/发票可读标签映射、无内部 ID 泄露、ARIA 告警语义。 +- [x] PC/移动 E2E:错误就近呈现、未归属卡片原子选明细、人工核对提示、正常提交;使用合成数据,不保存真实票据截图。 +- [x] 运行 `cd server && npm run prisma:validate`、聚焦 Vitest,必要时全量相关路由回归。 +- [x] 运行 `cd client && npm run typecheck && npm run build` 及聚焦 Playwright。 +- [x] 运行 `node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-trellis-governance.mjs` 和 `git diff --check`。 + +## 7. 只读复核、提交与部署门 + +- [x] 实施后执行只读专项 review,重点检查权限/状态机、金额证据三态、混合票据、API 兼容和 PC/移动数据流;reviewer 不改代码。 +- [ ] 用户复核 diff 与测试结论后,以中文 commit 引用 `08-30-b2-submit-error-ocr-fallback`;push 与部署分别取得授权。 +- [ ] 部署前由具备项目 `frontend-design` skill 的会话复核告警样式和恢复动作;部署后用合成草稿完成公网验收,确认 `/ready`、bundle marker、B2 `force_on + global` 和真人单据状态;验收前不通知员工可以测试。 + +## 8. 回滚 + +- [ ] 应用或公网验收失败时停止员工测试,回滚到部署前生产候选;本轮无 migration/seed/flag 写入,不修改或清理既有票据关联数据。 diff --git a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/prd.md b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/prd.md new file mode 100644 index 00000000..2957d405 --- /dev/null +++ b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/prd.md @@ -0,0 +1,96 @@ +# B2 提交错误可解释性与 OCR 降级合同核验 + +## Goal + +修复 B2 票据明细门禁“只返回参数校验失败、员工不知道改哪里”的缺陷,并恢复既有 OCR 降级合同:有真实 OCR 时保持金额硬校验;未实际执行 OCR 的 PDF 允许随报销提交,由财务明确执行人工票面核对。 + +## Background + +- 生产公网构建为 `0f3ecfabb7c8`,B2 已全局开启。2026-08-30 通过受控生产只读门禁确认唯一 server 运行容器为 `OCR_PROVIDER=tencent`;未读取其他环境变量,因此不触发 B2 flag 回退。 +- 0014 的员工截图发生在重新整理票据期间,当时页面只显示“参数校验失败”。后续生产只读复现确认:该单现有 5 条需票明细、5 张 bound 发票,每条明细恰好关联 1 张金额有效发票;未归属数、缺有效票明细数和超票面明细数均为 0。发票在截图后完成重新关联,单据已于当晚成功提交并进入 `reviewing`。 +- 0014 的历史失败响应无法事后精确恢复:`AppError.details` 只返回浏览器,服务端日志不记录该结构,失败事务也不留数据库快照。现有证据只能确认失败发生在“票据重新关联尚未完成”阶段,不能再断言当时一定是 `unlinkedInvoiceCount` 或 `missingInvoiceItemIds`。 +- `server/src/routes/reimbursements.js` 的 B2 提交门禁返回 `details.items=invoice_item_guard_failed`,并携带 `unlinkedInvoiceCount`、`missingInvoiceItemIds` 和 `amountExceededItems`。 +- `client/src/api/errorMessages.ts` 没有映射 `invoice_item_guard_failed`;PC 与移动详情页又把统一 `actionError` 放在概况区或操作区,因此员工既看不到具体原因,也容易把错误归因到不相关字段。 +- 已绑定但 `linked_item_id=null` 的发票并非完全不可见:PC 和移动材料区都会显示“明细:未关联”。真正缺陷是状态不突出、提交错误没有定位到该票据,且页面没有直接重新指定明细的动作;该发票也不会出现在“可添加发票”列表中,员工只能先移除再重新添加。 +- `.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md` 与 `docs/报销发票附件规则.md` 规定:`OCR_PROVIDER=off` 时 PDF 不执行 OCR、不生成金额,但仍可绑定明细并提交,由财务查看原件人工核对。当前 B2 以 `total_amount != null` 定义“有效发票”,与该合同冲突;腾讯模式下个别 OCR 金额缺失也会被错误描述成“缺票”。 +- 明细发生日期是实际费用发生或消费日期,不等同于发票日期。任何后续从票生成明细的设计只能把发票日期作为待确认建议值。 + +## Scope + +本 task 覆盖 R1-R4:冻结生产诊断证据、修复 B2 结构化错误与 PC/移动就近反馈、为未归属 bound 发票提供原子关联动作、恢复未执行 OCR 票据的人工核对降级合同。R5 所列详情页整体信息结构和自动生成明细能力明确延期。 + +## Requirements + +### R1 生产证据与应急边界 + +- 保留生产只读结论:运行 provider 为 `tencent`,不回退 B2 flag。 +- 记录 0014 当前三类 B2 检查均通过、已成功提交,以及历史失败分支不可恢复的原因;不得要求员工再开浏览器开发者工具补证据。 +- 若后续受控检查发现正式环境实际运行 `off`,仍须先单独取得生产写入授权才可回退 flag;本 task 的规划或实施批准不自动授权生产 flag、账号、报销或票据写入。 +- 正式环境若为 `mock`,停止真实票据使用并按既有禁用 mock 合同单独处置,不把 mock 结果作为票面证据。 + +### R2 三类 B2 错误可解释性 + +- 为 `invoice_item_guard_failed` 增加稳定中文反馈,分别覆盖:已绑定但未归属明细的发票、需票明细没有关联 PDF、明细金额高于可自动比较的关联票面合计。 +- 将“已关联 PDF 但 OCR 未取得金额”从“缺少发票”中拆出,给出独立原因和恢复路径;不得让员工以为需要重复上传一张已经存在的票。 +- 错误反馈使用明细序号、费用类型、说明摘要或页面内发票序号,不向普通用户展示数据库 UUID、API 路径、原始 details 或“发票池”等内部术语。 +- PC 与移动端都提供提交失败摘要,并在对应明细或发票卡片附近显示原因和下一步;动态错误使用可访问的告警语义,toast 只能补充,不能是唯一反馈。 +- 服务端仍是提交权威;前端预检查不得替代服务端结构化错误和并发后的最终反馈。 + +### R3 未归属发票的可恢复操作 + +- 对 `bound + linked_item_id=null` 的发票显示显著“未选择报销明细”错误态,并直接提供“选择明细”动作。 +- 重新指定明细必须是服务端单事务操作:锁定发票和报销单、校验操作人、草稿/驳回状态、同一报销单内目标明细后,只更新 `linked_item_id`。不得在前端串联“移除 → 重新添加”,避免票据短暂回到可用池或被并发占用。 +- 操作成功后刷新详情和提交检查;操作失败时错误留在对应票据卡片,不清空现有绑定关系。 +- 本轮只处理未归属 bound 发票的恢复,不扩展为所有已关联发票的任意改绑工作台。 + +### R4 OCR 金额证据与降级合同 + +- 将“是否有关联 PDF”和“是否取得可比较票面金额”分开判断。需票明细没有任何 bound PDF 时才属于缺票。 +- 金额证据按实际记录判定,不只看数据库默认的 `ocr_provider`: + - `total_amount` 非空:该张发票具有可自动比较金额; + - PDF 未发生 OCR 请求/尝试且金额为空:视为 off/历史降级票据,进入财务人工票面核对; + - 已发生 OCR 请求/尝试但金额仍为空:视为金额识别失败,提交时单独阻断并提示重试识别或联系财务。 +- 同一明细的所有关联发票都有金额时,使用 `Prisma.Decimal` 求和并执行“申报金额不得高于票面合计”的硬阻断。 +- 同一明细只要包含未执行 OCR 的降级票据,自动金额合计即不完整:允许提交,但必须在员工提交反馈和审批详情中明确标记“需财务人工核对票面”,不得宣称金额自动校验通过。 +- “有金额 + 降级无金额”的混合场景同样进入人工核对,不用已知部分金额制造错误的超额结论;这不是绕过校验,而是把无法完成的自动判断显式移交财务。 +- 不要求员工人工补录票面金额,不新增数据库迁移;既有金额、绑定关系和 flag off 回滚语义保持兼容。 + +### R5 后续 UX 边界 + +- “从我的票据选择并生成/补全明细”、明细与票据合并展示、草稿页信息结构、处理进度和 PC sticky 操作栏属于后续“报销详情页信息结构与操作重排”任务,不扩入本 P0。 +- 本 P0 只增加结构化错误、金额核对状态和未归属票据的最小恢复动作,不重写整个详情页。 +- 后续从发票生成明细时,发票日期仅可作为待员工确认的建议值;实际发生日期必须允许修改并保留“不一定等于发票日期”的业务口径。 + +## Acceptance Criteria + +- [x] 记录当前生产 server 的实际 `OCR_PROVIDER=tencent`,证据只包含 provider 值和观察时间,不包含其他 env。 +- [x] 记录 0014 当前 5 条需票明细、5 张金额有效 bound 发票、三类 B2 计数均为 0,且已成功提交进入 `reviewing`;明确历史失败只能定位到重新关联未完成阶段,不能伪造精确 details。 +- [x] 明确当前不触发 B2 flag 回退;未经独立授权不执行任何生产写入。 +- [x] 未归属、缺关联 PDF、金额识别失败、申报超票面四类问题在 PC 和移动端显示自然中文,并定位到对应明细/票据,不暴露内部 ID。 +- [x] 未归属 bound 发票可在票据卡片上原子选择本单明细,不需要先移除再重新添加。 +- [x] 未执行 OCR 且金额为空的关联 PDF 可随需票明细提交,并在提交反馈与审批详情明确标记财务人工核对;已尝试 OCR 但金额缺失仍被单独阻断。 +- [x] 所有关联票据金额完整时继续硬拦申报超额;混合“有金额 + 降级无金额”进入人工核对而非错误的部分金额比较。 +- [x] 两项免票补助、Tencent 正常金额路径、B2 flag off、既有关联关系和 owner/submitter 写权限保持兼容。 +- [x] 后端聚焦测试、前端错误解析与 PC/移动测试、typecheck/build、Prisma 校验及 Trellis/governance 门禁通过。 +- [ ] 面向员工的改动只有部署和公网合成验收通过后才告知可测试;生产部署、flag 写入和真人数据修改分别取得明确授权。 + +## Test Plan + +- 后端聚焦测试覆盖未归属、无关联 PDF、金额识别失败、完整金额超额、off/未执行 OCR 人工核对、混合金额证据、免票补助、flag off 兼容和 0014 类正常通过。 +- 原子关联接口覆盖成功、权限、状态、跨单、已有归属与并发零写入。 +- 前端单元/组件及 PC/移动 E2E 覆盖安全中文映射、就近定位、原子恢复、人工核对提示、可访问告警和内部 ID 不泄露。 +- 运行 Prisma validate、相关 Vitest、前端 typecheck/build、聚焦 Playwright、流程/文档/Trellis governance 与 `git diff --check`;部署后只用合成草稿做公网验收。 + +## Out of Scope + +- 报销详情页整体信息架构、个人票据入口、从票自动生成明细、处理进度重排、PC 主操作布局和财务一页式审批。 +- OCR provider 采购、腾讯凭据轮换、人工票面金额录入、数据库 provider 枚举或其他 schema migration。 +- 修改 0014/0016/0017/0018 的明细、发票、金额、状态或审批流程。 +- 将未归属恢复动作扩展成通用的已关联发票批量改绑能力。 + +## Confirmed Production Evidence + +- 运行 provider:`tencent`;不回退 B2。 +- 0014 当前:`status=reviewing`、5 条明细均需票、5 张 bound 发票、未归属 0、空金额 0、缺票明细 0、超票面明细 0。 +- 时间线:明细先完成重建;截图发生在重新关联完成之前;5 张发票随后完成关联,单据成功提交并创建待审批 workflow。 +- 历史失败的精确 `error.details` 已不可恢复,因为该响应未写日志且失败事务无快照;后续不再向员工索要 F12 证据。 diff --git a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/task.json b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/task.json new file mode 100644 index 00000000..f0aafadb --- /dev/null +++ b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/task.json @@ -0,0 +1,34 @@ +{ + "id": "b2-submit-error-ocr-fallback", + "name": "b2-submit-error-ocr-fallback", + "title": "B2 提交错误可解释性与 OCR 降级合同核验", + "description": "先只读确认生产 OCR 状态和真实提交阻断;必要时经独立授权应急回退 B2,再修复三类提交错误呈现及 OCR off 降级合同。", + "status": "in_progress", + "dev_type": "fullstack", + "scope": "server reimbursement submit/invoice linking, client PC/mobile error recovery, reimbursement OCR facts/spec", + "package": "hyb-oa", + "priority": "P0", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-30", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-29-reimbursement-expense-policy-invoice-guard", + "relatedFiles": [ + "server/src/routes/reimbursements.js", + "server/src/routes/invoicePool.js", + "client/src/api/errorMessages.ts", + "client/src/pages/ReimbursementDetailPage.tsx", + "client/src/pages/MobileReimbursementDetailPage.tsx", + "docs/报销发票附件规则.md", + ".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md" + ], + "notes": "生产只读确认 OCR_PROVIDER=tencent;0014 已完成关联并成功提交,历史失败 details 不可事后恢复。", + "meta": {} +} \ No newline at end of file diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 9e81bb75..d45f2e4e 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -10,6 +10,35 @@ function readStringList(value: unknown): string[] { .filter((item) => item.length > 0); } +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function countGuardAmountUnavailableItems(value: unknown): number { + return Array.isArray(value) + ? value.filter((item) => ( + isRecord(item) + && typeof item.itemId === "string" + && item.itemId.length > 0 + && readStringList(item.invoiceIds).length > 0 + )).length + : 0; +} + +function countGuardAmountExceededItems(value: unknown): number { + return Array.isArray(value) + ? value.filter((item) => ( + isRecord(item) + && typeof item.itemId === "string" + && item.itemId.length > 0 + && typeof item.declaredAmount === "string" + && item.declaredAmount.length > 0 + && typeof item.invoiceTotal === "string" + && item.invoiceTotal.length > 0 + )).length + : 0; +} + const FEATURE_FLAG_DISABLED_MESSAGES: Record = { portal_home_v1_disabled: "工作台首页当前未启用,请联系管理员检查环境开关与功能开关配置。", announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。", @@ -131,6 +160,38 @@ export function mapApiErrorMessage( return "所有报销明细金额都必须大于 0,请修改后再提交。"; } + if (details?.items === "invoice_item_guard_failed") { + const messages: string[] = []; + const rawUnlinkedCount = details.unlinkedInvoiceCount; + const unlinkedCount = typeof rawUnlinkedCount === "number" + && Number.isInteger(rawUnlinkedCount) + && rawUnlinkedCount >= 0 + ? rawUnlinkedCount + : 0; + const missingCount = readStringList(details.missingInvoiceItemIds).length; + const unavailableCount = countGuardAmountUnavailableItems(details.amountUnavailableItems); + const exceededCount = countGuardAmountExceededItems(details.amountExceededItems); + if (unlinkedCount > 0) messages.push(`${unlinkedCount} 张发票未选择报销明细`); + if (missingCount > 0) messages.push(`${missingCount} 条需票明细尚未关联发票`); + if (unavailableCount > 0) messages.push(`${unavailableCount} 条明细的发票金额未识别`); + if (exceededCount > 0) messages.push(`${exceededCount} 条明细的申报金额高于发票合计`); + return messages.length + ? `票据检查未通过:${messages.join(";")}。请按下方提示处理。` + : "票据检查未通过,请核对每条明细与发票的关联和金额后重试。"; + } + + if (details?.invoice === "invalid_unlinked_item_state") { + return "这张发票的关联状态已变化,请刷新后重试。"; + } + + if (details?.reimbursement === "invalid_status_for_invoice_item_link") { + return "当前报销单状态已变化,不能再关联发票,请刷新页面。"; + } + + if (details?.linkedItemId === "item_not_in_reimbursement") { + return "所选明细已不存在或不属于本单,请刷新后重新选择。"; + } + const passwordErrors = readStringList(details?.password); if (passwordErrors.length) { return `新密码不符合要求:${passwordErrors.join(";")}。`; diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 8beba107..82f8bc67 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -194,6 +194,7 @@ export type ReimbursementDetail = { mimeType: string | null; fileSize: number | null; linkedItemId: string | null; + amountEvidenceStatus: "verified" | "manual_review" | "ocr_failed"; }>; attachments: Array<{ id: string; @@ -225,6 +226,7 @@ export type ReimbursementDetail = { approvalHistory: ReimbursementApprovalHistoryItem[]; riskFlags: RiskFlag[]; invoiceGuardEnabled?: boolean; + invoiceAmountManualReviewRequired?: boolean; items: ReimbursementItem[]; }; @@ -391,6 +393,8 @@ export type ReimbursementSubmitResponse = { precheckDecision: "pass" | "warn" | "manual_review"; standardWarnings: ExpenseStandardCheck[]; openedRiskFlagCount: number; + invoiceAmountManualReviewRequired: boolean; + manualReviewItemCount: number; }; function toQueryString(query: Record) { @@ -472,6 +476,21 @@ export async function submitReimbursement(reimbursementId: string) { }); } +export async function linkReimbursementInvoiceItem( + reimbursementId: string, + invoiceId: string, + linkedItemId: string, +) { + return apiRequestData<{ + reimbursementId: string; + invoiceId: string; + linkedItemId: string; + }>(`/api/reimbursements/${reimbursementId}/invoices/${invoiceId}/item-link`, { + method: "PATCH", + body: JSON.stringify({ linkedItemId }), + }); +} + export async function withdrawReimbursement(reimbursementId: string, comment?: string) { return apiRequestData>(`/api/reimbursements/${reimbursementId}/withdraw`, { method: "POST", diff --git a/client/src/lib/reimbursementInvoiceGuard.ts b/client/src/lib/reimbursementInvoiceGuard.ts new file mode 100644 index 00000000..d4bf78ce --- /dev/null +++ b/client/src/lib/reimbursementInvoiceGuard.ts @@ -0,0 +1,148 @@ +import { isApiError } from "../api/types"; + +export type InvoiceGuardAmountUnavailableItem = { + itemId: string; + invoiceIds: string[]; +}; + +export type InvoiceGuardAmountExceededItem = { + itemId: string; + declaredAmount: string; + invoiceTotal: string; +}; + +export type ReimbursementInvoiceGuardIssues = { + unlinkedInvoiceCount: number; + unlinkedInvoiceIds: string[]; + missingInvoiceItemIds: string[]; + amountUnavailableItems: InvoiceGuardAmountUnavailableItem[]; + amountExceededItems: InvoiceGuardAmountExceededItem[]; +}; + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string" && item.length > 0) + : []; +} + +function readNonNegativeInteger(value: unknown): number { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0; +} + +export function parseReimbursementInvoiceGuardIssues( + error: unknown, +): ReimbursementInvoiceGuardIssues | null { + if (!isApiError(error) || error.details?.items !== "invoice_item_guard_failed") { + return null; + } + + const amountUnavailableItems = Array.isArray(error.details.amountUnavailableItems) + ? error.details.amountUnavailableItems.flatMap((value) => { + if (!value || typeof value !== "object") return []; + const itemId = "itemId" in value && typeof value.itemId === "string" ? value.itemId : ""; + if (!itemId) return []; + return [{ + itemId, + invoiceIds: readStringArray("invoiceIds" in value ? value.invoiceIds : undefined), + }]; + }) + : []; + + const amountExceededItems = Array.isArray(error.details.amountExceededItems) + ? error.details.amountExceededItems.flatMap((value) => { + if (!value || typeof value !== "object") return []; + const itemId = "itemId" in value && typeof value.itemId === "string" ? value.itemId : ""; + const declaredAmount = "declaredAmount" in value && typeof value.declaredAmount === "string" + ? value.declaredAmount + : ""; + const invoiceTotal = "invoiceTotal" in value && typeof value.invoiceTotal === "string" + ? value.invoiceTotal + : ""; + if (!itemId || !declaredAmount || !invoiceTotal) return []; + return [{ itemId, declaredAmount, invoiceTotal }]; + }) + : []; + + return { + unlinkedInvoiceCount: readNonNegativeInteger(error.details.unlinkedInvoiceCount), + unlinkedInvoiceIds: readStringArray(error.details.unlinkedInvoiceIds), + missingInvoiceItemIds: readStringArray(error.details.missingInvoiceItemIds), + amountUnavailableItems, + amountExceededItems, + }; +} + +export function invoiceGuardIssueCount(issues: ReimbursementInvoiceGuardIssues): number { + return issues.unlinkedInvoiceCount + + issues.missingInvoiceItemIds.length + + issues.amountUnavailableItems.length + + issues.amountExceededItems.length; +} + +export function resolveInvoiceGuardIssuesAfterLink( + issues: ReimbursementInvoiceGuardIssues | null, + invoiceId: string, + linkedItemId: string, +): ReimbursementInvoiceGuardIssues | null { + if (!issues) return null; + const locatedInvoice = issues.unlinkedInvoiceIds.includes(invoiceId); + const unlinkedInvoiceIds = issues.unlinkedInvoiceIds.filter((id) => id !== invoiceId); + const shouldDecreaseCount = locatedInvoice || issues.unlinkedInvoiceIds.length === 0; + const next = { + ...issues, + unlinkedInvoiceCount: shouldDecreaseCount + ? Math.max(0, issues.unlinkedInvoiceCount - 1) + : issues.unlinkedInvoiceCount, + unlinkedInvoiceIds, + missingInvoiceItemIds: issues.missingInvoiceItemIds.filter((id) => id !== linkedItemId), + }; + return invoiceGuardIssueCount(next) > 0 ? next : null; +} + +export function invoiceGuardItemMessages( + issues: ReimbursementInvoiceGuardIssues | null, + itemId: string, +): string[] { + if (!issues) return []; + const messages: string[] = []; + if (issues.missingInvoiceItemIds.includes(itemId)) { + messages.push("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。"); + } + if (issues.amountUnavailableItems.some((item) => item.itemId === itemId)) { + messages.push("关联发票的票面金额未识别,请重试识别或联系财务处理。"); + } + const exceeded = issues.amountExceededItems.find((item) => item.itemId === itemId); + if (exceeded) { + messages.push( + `申报金额 ¥${exceeded.declaredAmount} 高于关联发票合计 ¥${exceeded.invoiceTotal},请补充发票或调整申报金额。`, + ); + } + return messages; +} + +export function invoiceGuardInvoiceMessages( + issues: ReimbursementInvoiceGuardIssues | null, + invoiceId: string, + linkedItemId: string | null, +): string[] { + if (!issues) return []; + const messages: string[] = []; + const isUnlinked = issues.unlinkedInvoiceIds.includes(invoiceId) + || (issues.unlinkedInvoiceIds.length === 0 && issues.unlinkedInvoiceCount > 0 && !linkedItemId); + if (isUnlinked) { + messages.push("这张发票还没有选择报销明细,请在下方完成关联。"); + } + if (issues.amountUnavailableItems.some((item) => item.invoiceIds.includes(invoiceId))) { + messages.push("这张发票未识别出票面金额,请重试识别或联系财务处理。"); + } + return messages; +} + +export function focusFirstInvoiceGuardIssue() { + window.requestAnimationFrame(() => { + const target = document.querySelector('[data-reimbursement-guard-issue="true"]'); + if (!target) return; + target.focus({ preventScroll: true }); + target.scrollIntoView({ behavior: "smooth", block: "center" }); + }); +} diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index 3ba5ce9b..5c3913e1 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -29,6 +29,7 @@ import { submitReimbursement, updateReimbursementItem, uploadReimbursementAttachments, + linkReimbursementInvoiceItem, type ReimbursementItem, type ReimbursementStatus, } from "../api/reimbursements"; @@ -81,6 +82,15 @@ import { cn } from "../lib/utils"; import { isWecomWebView } from "../lib/runtimeContext"; import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor"; +import { + focusFirstInvoiceGuardIssue, + invoiceGuardInvoiceMessages, + invoiceGuardIssueCount, + invoiceGuardItemMessages, + parseReimbursementInvoiceGuardIssues, + resolveInvoiceGuardIssuesAfterLink, + type ReimbursementInvoiceGuardIssues, +} from "../lib/reimbursementInvoiceGuard"; type ItemFormState = { mode: "create" | "edit"; @@ -167,6 +177,9 @@ export function MobileReimbursementDetailPage() { const [itemForm, setItemForm] = useState(null); const [itemError, setItemError] = useState(""); const [actionError, setActionError] = useState(""); + const [invoiceGuardIssues, setInvoiceGuardIssues] = useState(null); + const [invoiceItemSelections, setInvoiceItemSelections] = useState>({}); + const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState>({}); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState(""); const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false); @@ -369,6 +382,7 @@ export function MobileReimbursementDetailPage() { await invalidateDetail(); setItemForm(null); setItemError(""); + setInvoiceGuardIssues(null); toast.success("明细已保存"); }, onError: (error) => { @@ -408,15 +422,23 @@ export function MobileReimbursementDetailPage() { const submitMutation = useMutation({ mutationFn: () => submitReimbursement(reimbursementId), - onSuccess: async () => { + onSuccess: async (data) => { await invalidateDetail(); setSubmitConfirmPrecheck(null); setActionError(""); - toast.success("已提交审批"); + setInvoiceGuardIssues(null); + toast.success( + data.invoiceAmountManualReviewRequired + ? "已提交,票面金额将由财务核对" + : "已提交审批", + ); }, onError: (error) => { const message = resolveErrorMessage(error); + const issues = parseReimbursementInvoiceGuardIssues(error); setActionError(message); + setInvoiceGuardIssues(issues); + if (issues) focusFirstInvoiceGuardIssue(); toast.error(message); }, }); @@ -464,6 +486,7 @@ export function MobileReimbursementDetailPage() { setPendingOcrLinkedItemIds((previous) => Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))), ); + if (succeededIds.length) setInvoiceGuardIssues(null); if (!failed.length) { setInvoiceActionError(""); @@ -531,6 +554,7 @@ export function MobileReimbursementDetailPage() { onSuccess: async () => { await invalidateDetail(); setInvoiceActionError(""); + setInvoiceGuardIssues(null); toast.success("发票已移除"); }, onError: (error) => { @@ -540,6 +564,35 @@ export function MobileReimbursementDetailPage() { }, }); + const linkInvoiceItemMutation = useMutation({ + mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) => + linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId), + onSuccess: async (_result, variables) => { + await invalidateDetail(); + setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink( + previous, + variables.invoiceId, + variables.linkedItemId, + )); + setInvoiceItemSelections((previous) => { + const next = { ...previous }; + delete next[variables.invoiceId]; + return next; + }); + setInvoiceItemLinkErrors((previous) => { + const next = { ...previous }; + delete next[variables.invoiceId]; + return next; + }); + toast.success("发票已关联到报销明细"); + }, + onError: (error, variables) => { + const message = resolveErrorMessage(error, "关联明细失败,请刷新后重试"); + setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message })); + toast.error(message); + }, + }); + const deleteAvailableInvoiceMutation = useMutation({ mutationFn: (invoiceId: string) => archiveInvoice(invoiceId), onSuccess: async (_result, invoiceId) => { @@ -805,6 +858,7 @@ export function MobileReimbursementDetailPage() { try { setActionError(""); + setInvoiceGuardIssues(null); const precheck = await precheckReimbursement(reimbursementId); const message = buildPrecheckDialogMessage(precheck); if (precheck.decision === "block") { @@ -1023,6 +1077,20 @@ export function MobileReimbursementDetailPage() { + {invoiceGuardIssues ? ( +
+

票据检查未通过

+

+ 共 {invoiceGuardIssueCount(invoiceGuardIssues)} 项需要处理,请按下方明细和发票提示修改。 +

+
+ ) : null} +

报销明细

@@ -1114,7 +1182,15 @@ export function MobileReimbursementDetailPage() {
{detail.items.length ? ( detail.items.map((item) => ( -
+
{item.expenseType?.name ?? "费用"}
@@ -1126,6 +1202,15 @@ export function MobileReimbursementDetailPage() { {shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (

该金额通常应选择办公电子设备。

) : null} + {invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => ( +

+ {message} +

+ ))} {canMaintainReimbursement ? (
+ {detail.invoiceAmountManualReviewRequired ? ( +

+ 本单包含未执行 OCR 的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。 +

+ ) : null} + {detail.invoices.length || (detail.attachments ?? []).length ? (

打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。

@@ -1273,8 +1364,21 @@ export function MobileReimbursementDetailPage() { const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement ? "已关联" : resolveInvoiceNextStepText(invoice); + const guardMessages = invoiceGuardInvoiceMessages( + invoiceGuardIssues, + invoice.id, + invoice.linkedItemId, + ); return ( -
+
+ {guardMessages.map((message) => ( +

+ {message} +

+ ))} + {invoice.amountEvidenceStatus === "manual_review" ? ( +

+ 票面金额未自动识别,需由财务查看原件核对。 +

+ ) : null} + {invoice.amountEvidenceStatus === "ocr_failed" ? ( +

+ OCR 已处理但未取得票面金额,提交前需要重试识别或联系财务。 +

+ ) : null} + {canMaintainReimbursement && !invoice.linkedItemId ? ( +
+ + + + {invoiceItemLinkErrors[invoice.id] ? ( +

+ {invoiceItemLinkErrors[invoice.id]} +

+ ) : null} +
+ ) : null} {canUnbindInvoices ? ( - {canUnbindInvoices ? ( - + {(guardMessages.length + || invoice.amountEvidenceStatus === "manual_review" + || invoice.amountEvidenceStatus === "ocr_failed" + || canUnbindInvoices) ? ( +
+ {guardMessages.map((message) => ( +

+ {message} +

+ ))} + {invoice.amountEvidenceStatus === "manual_review" ? ( +

+ 票面金额未自动识别,需由财务查看原件核对。 +

+ ) : null} + {invoice.amountEvidenceStatus === "ocr_failed" ? ( +

+ OCR 已处理但未取得票面金额,提交前需要重试识别或联系财务。 +

+ ) : null} + {canMaintainReimbursement && !invoice.linkedItemId ? ( +
+ + + + {invoiceItemLinkErrors[invoice.id] ? ( +

+ {invoiceItemLinkErrors[invoice.id]} +

+ ) : null} +
+ ) : null} + {canUnbindInvoices ? ( + + ) : null} +
) : null}
); diff --git a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts index c0ac8f0d..39edfdf9 100644 --- a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts +++ b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts @@ -107,6 +107,7 @@ function buildInvoice() { mimeType: "application/pdf", fileSize: 4096, linkedItemId: "item-r111" as string | null, + amountEvidenceStatus: "verified" as "verified" | "manual_review" | "ocr_failed", }; } @@ -188,7 +189,11 @@ function buildReimbursementState() { bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>, itemSaveRequests: [] as Array>, submitRequestCount: 0, + submitGuardDetails: null as null | Record, + invoiceItemLinkRequests: [] as Array<{ invoiceId: string; linkedItemId?: string }>, + invoiceItemLinkError: null as null | Record, invoiceGuardEnabled: false, + invoiceAmountManualReviewRequired: false, detailItems: null as null | Array>, }; } @@ -237,6 +242,7 @@ function buildReimbursementDetail(state: ReturnType ( + invoice.id === invoiceId ? { ...invoice, linkedItemId: payload.linkedItemId ?? null } : invoice + )); + await fulfillJson(route, { + reimbursementId, + invoiceId, + linkedItemId: payload.linkedItemId, + }); return; } @@ -855,6 +915,153 @@ test.describe("R111 国内报销发票与附件双端体验", () => { await expect.poll(() => state.submitRequestCount).toBe(1); }); + test("B2 提交错误在 PC/移动就近呈现,未归属发票可原子选择明细", async ({ page }) => { + const state = buildReimbursementState(); + state.invoiceGuardEnabled = true; + state.detailItems = [ + { + id: "item-missing-r111", + expenseTypeId: "expense-type-r111", + expenseDate: "2026-05-08", + amount: "200.00", + description: "缺少发票的明细", + expenseType: { ...buildExpenseTypes().items[0] }, + }, + { + id: "item-ocr-r111", + expenseTypeId: "expense-type-r111", + expenseDate: "2026-05-08", + amount: "300.00", + description: "金额识别失败的明细", + expenseType: { ...buildExpenseTypes().items[0] }, + }, + { + id: "item-over-r111", + expenseTypeId: "expense-type-r111", + expenseDate: "2026-05-08", + amount: "1000.00", + description: "金额超过发票的明细", + expenseType: { ...buildExpenseTypes().items[0] }, + }, + { + id: "item-manual-r111", + expenseTypeId: "expense-type-r111", + expenseDate: "2026-05-08", + amount: "400.00", + description: "人工核对票面金额的明细", + expenseType: { ...buildExpenseTypes().items[0] }, + }, + ]; + const ocrFailedInvoice = { + ...buildInvoice(), + id: "77777777-7777-4777-8777-777777777777", + invoiceNumber: "OCR-FAILED", + originalName: "金额未识别.pdf", + totalAmount: null, + linkedItemId: "item-ocr-r111", + amountEvidenceStatus: "ocr_failed" as const, + }; + const amountOverInvoice = { + ...buildInvoice(), + id: "88888888-8888-4888-8888-888888888888", + invoiceNumber: "AMOUNT-OVER", + originalName: "金额不足.pdf", + totalAmount: "600.00", + linkedItemId: "item-over-r111", + }; + const manualReviewInvoice = { + ...buildInvoice(), + id: "99999999-9999-4999-8999-999999999999", + invoiceNumber: "MANUAL-REVIEW", + originalName: "待人工核对.pdf", + totalAmount: null, + linkedItemId: "item-manual-r111", + amountEvidenceStatus: "manual_review" as const, + }; + state.invoices = [buildUnlinkedInvoice(), ocrFailedInvoice, amountOverInvoice, manualReviewInvoice]; + state.invoiceAmountManualReviewRequired = true; + state.submitGuardDetails = { + items: "invoice_item_guard_failed", + unlinkedInvoiceCount: 1, + unlinkedInvoiceIds: [unlinkedInvoiceId], + missingInvoiceItemIds: ["item-missing-r111"], + amountUnavailableItems: [{ + itemId: "item-ocr-r111", + invoiceIds: [ocrFailedInvoice.id], + }], + amountExceededItems: [{ + itemId: "item-over-r111", + declaredAmount: "1000.00", + invoiceTotal: "600.00", + }], + }; + + await mockApis(page, state); + await login(page); + await page.setViewportSize({ width: 1440, height: 960 }); + await page.goto(`/reimbursements/${reimbursementId}`); + await page.getByTestId("reimbursement-submit").click(); + + await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细"); + await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible(); + await expect(page.getByText("关联发票的票面金额未识别,请重试识别或联系财务处理。")).toBeVisible(); + await expect( + page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"), + ).toBeVisible(); + await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible(); + await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible(); + await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0); + await expect(page.getByText("item-missing-r111", { exact: false })).toHaveCount(0); + await page.getByLabel("选择报销明细").selectOption("item-missing-r111"); + state.invoiceItemLinkError = { invoice: "invalid_unlinked_item_state" }; + await page.getByRole("button", { name: "确认关联" }).click(); + await expect( + page.getByTestId("reimbursement-invoices").getByText("这张发票的关联状态已变化,请刷新后重试。"), + ).toBeVisible(); + expect(state.invoices.find((invoice) => invoice.id === unlinkedInvoiceId)?.linkedItemId).toBeNull(); + state.invoiceItemLinkError = null; + await page.getByRole("button", { name: "确认关联" }).click(); + await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{ + invoiceId: unlinkedInvoiceId, + linkedItemId: "item-missing-r111", + }, { + invoiceId: unlinkedInvoiceId, + linkedItemId: "item-missing-r111", + }]); + await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toHaveCount(0); + await expect( + page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"), + ).toBeVisible(); + + const structuredGuardDetails = state.submitGuardDetails; + state.submitGuardDetails = { + items: "invoice_item_guard_failed", + unlinkedInvoiceCount: "invalid-count", + unlinkedInvoiceIds: [123], + missingInvoiceItemIds: [{ id: "internal-only" }], + amountUnavailableItems: [{ itemId: null, invoiceIds: [unlinkedInvoiceId] }], + amountExceededItems: [{ itemId: "broken", declaredAmount: null, invoiceTotal: "600.00" }], + }; + await page.getByTestId("reimbursement-submit").click(); + await expect(page.getByTestId("reimbursement-action-error").first()).toContainText( + "票据检查未通过,请核对每条明细与发票的关联和金额后重试。", + ); + await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0); + state.submitGuardDetails = structuredGuardDetails; + + state.invoices = state.invoices.map((invoice) => ( + invoice.id === unlinkedInvoiceId ? { ...invoice, linkedItemId: null } : invoice + )); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`/mobile/reimbursements/${reimbursementId}`); + await page.getByTestId("reimbursement-submit").click(); + await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 4 项需要处理"); + await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible(); + await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible(); + await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible(); + await expect(page.getByLabel("选择报销明细")).toBeVisible(); + }); + test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => { const state = buildReimbursementState(); const mobile = await newWecomPage(browser); diff --git a/docs/报销发票附件规则.md b/docs/报销发票附件规则.md index 24aaace0..988c9d71 100644 --- a/docs/报销发票附件规则.md +++ b/docs/报销发票附件规则.md @@ -51,6 +51,9 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文 - 未配置真实 OCR 时使用 `OCR_PROVIDER=off`:PDF 直接进入 `available`,不入 OCR 队列、不生成发票代码、号码、日期、购销方或金额。员工仍可把票据添加到已有明细并提交,财务必须查看 PDF 原件人工核对。 - off 模式下文件 SHA-256 查重继续有效,依赖发票代码和号码的查重暂不可用。对当前小团队可短期接受;强制人工补录或接入腾讯 OCR 应另行规划,不能在紧急报销中临时增加门槛。 - 既有 `InvoicePool.ocr_provider` 为非空枚举且默认 `tencent`,所以 off 模式的新记录仍可能带该历史默认标记。判定是否实际调用 OCR 必须同时看 provider 配置、`ocr_attempts`、请求/完成时间和 OCR 结果,不能只看 `ocr_provider`。 +- 报销提交时必须把“已关联正式 PDF”和“具有可自动比较的票面金额”分开判断。金额已识别的票据可参加自动合计;没有任何 OCR 请求、尝试、完成或错误事实且金额为空的票据视为未执行 OCR,允许提交并持续标记为“需财务查看原件人工核对”。 +- 已发生 OCR 请求、尝试、完成或错误事实但金额仍为空,属于金额识别失败,不得伪装成“缺少发票”或直接放行;员工应重试识别或联系财务处理。 +- 同一明细只要包含未执行 OCR 的无金额票据,自动金额合计即不完整。即使同时存在有金额票据,也不得只用已知部分判断申报超额;整条明细转为财务人工核对。只有全部关联票据都有金额时,系统才执行申报金额与票面合计的自动比较。 ## 样票脱敏和授权 @@ -95,6 +98,7 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文 - 发票状态为可添加。 - 发票未被其他报销单、差旅单或归档流程占用。 - `linkedItemId` 若传入,必须属于当前报销单。 +- 草稿或驳回单中,已添加但尚未选择明细的发票可以直接选择本单明细。该动作必须由服务端在一个事务内校验报销参与人、单据状态、发票归属和明细归属,只更新明细关联;不得要求员工先移除再重新添加,也不得让发票短暂回到可添加状态。 明细匹配规则: @@ -153,7 +157,7 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文 - 确实无法提供支付截图时,员工必须上传情况说明;财务可以退回补充,也可以在有其他充分证据时例外接受并留痕。不得伪造、补造或借用他人支付凭证。 - OCR 只辅助识别发票字段、匹配和查重,不判断支付真实性、费用合理性或发票法律状态。财务复核时必须同时查看明细金额、PDF 原件和个人支付证明;三者不一致时退回更正或补充说明。 - 每张有效发票应关联到一条明确费用明细。一条明细只表达同一费用类型和同一笔业务支出;不同类型或彼此独立的支出应拆分,采购物品还应是一件或一批同类物品。连续跨天或跨期的同一笔业务支出(如多晚住宿、月度通讯费、按服务期计费)可以作为一条明细,并在说明中写清起止日期。 -- 票据—明细门禁启用后,需票明细没有有效发票或申报金额高于关联票面合计时阻断提交;两项包干补助不参加票面比较。关闭门禁不会删除或解绑已经建立的关联关系。 +- 票据—明细门禁启用后,已添加发票未选择明细、需票明细没有关联正式 PDF、已尝试 OCR 但票面金额仍无法识别,或在全部关联票据金额完整时申报金额高于票面合计,均阻断提交。未执行 OCR 的无金额 PDF 允许提交,但员工提交反馈和审批详情必须明确提示财务查看原件人工核对;两项包干补助不参加票据与票面比较。关闭门禁不会删除或解绑已经建立的关联关系。 - 财务付款凭证号、付款日期、付款渠道和付款回单属于审批通过后的财务处理,不是员工提交报销时填写的个人支付证明。 ### 差旅补助规定 diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index ce9a958f..0b09367d 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -33,6 +33,10 @@ const { resolveEntityPrecheck, } = require("../services/feeControl"); const { issueDocumentNumber } = require("../services/documentSequence"); +const { + INVOICE_AMOUNT_EVIDENCE_STATUS, + resolveInvoiceAmountEvidence, +} = require("../services/invoiceAmountEvidence"); const { PAYMENT_PROCESSING_STAGE_DEFINITIONS, buildPaymentProcessingSemantics, @@ -103,6 +107,9 @@ const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREV const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z .object({ projectId: z.string().uuid().nullable() }) .strict(); +const REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA = z + .object({ linkedItemId: z.string().uuid() }) + .strict(); function resolveSignedFileOrigin(config, request) { const configured = String(config.appOrigin ?? "") @@ -2467,6 +2474,10 @@ async function reimbursementRoutes(fastify) { mime_type: true, file_size: true, linked_item_id: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_completed_at: true, ocr_result: { select: { result_json: true } }, }, }, @@ -2657,6 +2668,28 @@ async function reimbursementRoutes(fastify) { departmentId: reimbursement.department_id ?? null, strictDepartment: true, }); + const requiredInvoiceItemIds = new Set( + reimbursement.items + .filter((item) => item.expense_type?.requires_invoice) + .map((item) => item.id), + ); + const invoiceAmountEvidenceById = new Map( + reimbursement.invoice_pool_items.map((invoice) => [ + invoice.id, + resolveInvoiceAmountEvidence({ + totalAmount: invoice.total_amount, + ocrAttempts: invoice.ocr_attempts, + ocrLastRequestedAt: invoice.ocr_last_requested_at, + ocrCompletedAt: invoice.ocr_completed_at, + ocrLastError: invoice.ocr_last_error, + }), + ]), + ); + const invoiceAmountManualReviewRequired = reimbursement.invoice_pool_items.some( + (invoice) => + requiredInvoiceItemIds.has(invoice.linked_item_id) && + invoiceAmountEvidenceById.get(invoice.id) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview, + ); return sendData(reply, request, { id: reimbursement.id, @@ -2736,6 +2769,7 @@ async function reimbursementRoutes(fastify) { mimeType: item.mime_type ?? null, fileSize: item.file_size ?? null, linkedItemId: item.linked_item_id ?? null, + amountEvidenceStatus: invoiceAmountEvidenceById.get(item.id), })), pendingWorkflow: pendingInstance ? { @@ -2755,6 +2789,7 @@ async function reimbursementRoutes(fastify) { approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows), riskFlags: riskFlags.map(buildPersistedRiskFlag), invoiceGuardEnabled: invoiceGuardGate.enabled, + invoiceAmountManualReviewRequired, }); }, ); @@ -3941,6 +3976,117 @@ async function reimbursementRoutes(fastify) { }, ); + fastify.patch( + "/reimbursements/:id/invoices/:invoiceId/item-link", + { + preHandler: fastify.requirePermission("expense.edit_own"), + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + const parsedInvoiceId = UUID_SCHEMA.safeParse(request.params?.invoiceId); + if (!parsedInvoiceId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { invoiceId: "invalid_uuid" }, + }); + } + + const parsedBody = REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA.safeParse(request.body ?? {}); + if (!parsedBody.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsedBody.error.flatten(), + }); + } + + const invoiceId = parsedInvoiceId.data; + const linkedItemId = parsedBody.data.linkedItemId; + const response = await fastify.prisma.$transaction(async (tx) => { + const invoiceRows = await tx.$queryRaw` + SELECT id, status, linked_reimbursement_id, linked_item_id + FROM "invoice_pool" + WHERE id = ${invoiceId}::uuid + FOR UPDATE + `; + const invoice = Array.isArray(invoiceRows) ? invoiceRows[0] ?? null : null; + + const reimbursementRows = await tx.$queryRaw` + SELECT id, owner_id, submitter_id, status + FROM "reimbursement_forms" + WHERE id = ${reimbursementId}::uuid + FOR UPDATE + `; + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + + const reimbursement = reimbursementRows[0]; + ensureReimbursementParticipant(request, reimbursement); + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { reimbursement: "invalid_status_for_invoice_item_link" }, + }); + } + if (!invoice) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + if ( + invoice.status !== "bound" || + invoice.linked_reimbursement_id !== reimbursementId || + invoice.linked_item_id != null + ) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { invoice: "invalid_unlinked_item_state" }, + }); + } + + const linkedItem = await tx.reimbursementItem.findFirst({ + where: { id: linkedItemId, reimbursement_id: reimbursementId }, + select: { id: true }, + }); + if (!linkedItem) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { linkedItemId: "item_not_in_reimbursement" }, + }); + } + + await tx.invoicePool.update({ + where: { id: invoiceId }, + data: { + linked_item_id: linkedItem.id, + updated_by: request.employee.id, + }, + }); + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "REIMBURSEMENT_INVOICE_ITEM_LINKED", + targetType: "invoice_pool", + targetId: invoiceId, + extra: { reimbursementId, linkedItemId: linkedItem.id }, + }), + }); + + return { invoiceId, reimbursementId, linkedItemId: linkedItem.id }; + }); + + return sendData(reply, request, response); + }, + ); + fastify.post( "/reimbursements/:id/precheck", { @@ -4016,7 +4162,14 @@ async function reimbursementRoutes(fastify) { }, invoice_pool_items: { where: { status: "bound" }, - select: { id: true, total_amount: true }, + select: { + id: true, + total_amount: true, + ocr_attempts: true, + ocr_last_requested_at: true, + ocr_last_error: true, + ocr_completed_at: true, + }, }, }, }); @@ -4046,26 +4199,61 @@ async function reimbursementRoutes(fastify) { departmentId: reimbursement.department_id ?? null, strictDepartment: true, }); + const manualReviewItemIds = reimbursementItems + .filter((item) => item.expense_type.requires_invoice) + .filter((item) => item.invoice_pool_items.some((invoice) => ( + resolveInvoiceAmountEvidence({ + totalAmount: invoice.total_amount, + ocrAttempts: invoice.ocr_attempts, + ocrLastRequestedAt: invoice.ocr_last_requested_at, + ocrCompletedAt: invoice.ocr_completed_at, + ocrLastError: invoice.ocr_last_error, + }) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview + ))) + .map((item) => item.id); if (invoiceGuardGate.enabled) { - const unlinkedBoundInvoiceCount = await tx.invoicePool.count({ + const unlinkedBoundInvoices = await tx.invoicePool.findMany({ where: { linked_reimbursement_id: reimbursementId, linked_item_id: null, status: "bound", }, + select: { id: true }, }); const missingInvoiceItemIds = []; + const amountUnavailableItems = []; const amountExceededItems = []; for (const item of reimbursementItems) { if (!item.expense_type.requires_invoice) continue; - const validInvoices = item.invoice_pool_items.filter( - (invoice) => invoice.total_amount != null, - ); - if (!validInvoices.length) { + if (!item.invoice_pool_items.length) { missingInvoiceItemIds.push(item.id); continue; } - const invoiceTotal = validInvoices.reduce( + + const evidenceByInvoice = item.invoice_pool_items.map((invoice) => ({ + invoice, + status: resolveInvoiceAmountEvidence({ + totalAmount: invoice.total_amount, + ocrAttempts: invoice.ocr_attempts, + ocrLastRequestedAt: invoice.ocr_last_requested_at, + ocrCompletedAt: invoice.ocr_completed_at, + ocrLastError: invoice.ocr_last_error, + }), + })); + const failedInvoiceIds = evidenceByInvoice + .filter(({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed) + .map(({ invoice }) => invoice.id); + if (failedInvoiceIds.length) { + amountUnavailableItems.push({ itemId: item.id, invoiceIds: failedInvoiceIds }); + continue; + } + if (evidenceByInvoice.some( + ({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview, + )) { + continue; + } + + const invoiceTotal = item.invoice_pool_items.reduce( (sum, invoice) => sum.plus(invoice.total_amount), new Prisma.Decimal(0), ); @@ -4077,15 +4265,22 @@ async function reimbursementRoutes(fastify) { }); } } - if (unlinkedBoundInvoiceCount || missingInvoiceItemIds.length || amountExceededItems.length) { + if ( + unlinkedBoundInvoices.length || + missingInvoiceItemIds.length || + amountUnavailableItems.length || + amountExceededItems.length + ) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { items: "invoice_item_guard_failed", - unlinkedInvoiceCount: unlinkedBoundInvoiceCount, + unlinkedInvoiceCount: unlinkedBoundInvoices.length, + unlinkedInvoiceIds: unlinkedBoundInvoices.map((invoice) => invoice.id), missingInvoiceItemIds, + amountUnavailableItems, amountExceededItems, }, }); @@ -4420,6 +4615,8 @@ async function reimbursementRoutes(fastify) { precheckDecision: authoritativePrecheck.decision, standardWarnings, openedRiskFlagCount: openedRiskFlags.length, + invoiceAmountManualReviewRequired: manualReviewItemIds.length > 0, + manualReviewItemCount: manualReviewItemIds.length, }, sideEffectsContext: { reimbursementId, diff --git a/server/src/services/invoiceAmountEvidence.js b/server/src/services/invoiceAmountEvidence.js new file mode 100644 index 00000000..4629a515 --- /dev/null +++ b/server/src/services/invoiceAmountEvidence.js @@ -0,0 +1,32 @@ +const INVOICE_AMOUNT_EVIDENCE_STATUS = Object.freeze({ + verified: "verified", + manualReview: "manual_review", + ocrFailed: "ocr_failed", +}); + +function resolveInvoiceAmountEvidence({ + totalAmount, + ocrAttempts, + ocrLastRequestedAt, + ocrCompletedAt, + ocrLastError, +}) { + if (totalAmount != null) { + return INVOICE_AMOUNT_EVIDENCE_STATUS.verified; + } + + const hasOcrExecutionEvidence = + Number(ocrAttempts ?? 0) > 0 || + ocrLastRequestedAt != null || + ocrCompletedAt != null || + (typeof ocrLastError === "string" && ocrLastError.trim().length > 0); + + return hasOcrExecutionEvidence + ? INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed + : INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview; +} + +module.exports = { + INVOICE_AMOUNT_EVIDENCE_STATUS, + resolveInvoiceAmountEvidence, +}; diff --git a/server/tests/unit/routes/reimbursements.detail.test.js b/server/tests/unit/routes/reimbursements.detail.test.js index 1d08db9c..786decd1 100644 --- a/server/tests/unit/routes/reimbursements.detail.test.js +++ b/server/tests/unit/routes/reimbursements.detail.test.js @@ -41,6 +41,7 @@ function buildReimbursementRecord(overrides = {}) { name: "打车费", category: "travel", enabled: true, + requires_invoice: true, }, }, ], @@ -56,6 +57,10 @@ function buildReimbursementRecord(overrides = {}) { total_amount: "188.80", original_name: "receipt.pdf", linked_item_id: "item-1", + ocr_attempts: 1, + ocr_last_requested_at: new Date("2026-04-09T01:00:00.000Z"), + ocr_last_error: null, + ocr_completed_at: new Date("2026-04-09T01:00:01.000Z"), }, ], workflow_instances: [ @@ -358,8 +363,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { totalAmount: "188.80", originalName: "receipt.pdf", linkedItemId: "item-1", + amountEvidenceStatus: "verified", }, ], + invoiceAmountManualReviewRequired: false, approvalHistory: [ { id: "history-start", @@ -394,6 +401,47 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { await app.close(); }); + it("keeps the manual amount-review marker visible to a finance detail reader", async () => { + const reimbursement = buildReimbursementRecord({ + invoice_pool_items: [{ + id: "invoice-off", + status: "bound", + invoice_code: null, + invoice_number: null, + invoice_date: null, + seller_name: null, + total_amount: null, + original_name: "manual-review.pdf", + linked_item_id: "item-1", + ocr_attempts: 0, + ocr_last_requested_at: null, + ocr_last_error: null, + ocr_completed_at: null, + }], + }); + const { app } = await buildApp({ + employeeId: "finance-reader", + permissionCodes: ["expense.view_all"], + reimbursement, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/reimbursements/${REIMBURSEMENT_ID}`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode, response.body).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ + invoiceAmountManualReviewRequired: true, + invoices: [expect.objectContaining({ + id: "invoice-off", + amountEvidenceStatus: "manual_review", + })], + })); + await app.close(); + }); + it("returns null submittedAt and empty approvalHistory when reimbursement has never been submitted", async () => { const reimbursement = buildReimbursementRecord({ status: "draft", diff --git a/server/tests/unit/routes/reimbursements.expense-standards.test.js b/server/tests/unit/routes/reimbursements.expense-standards.test.js index 4d869f73..d9eddf95 100644 --- a/server/tests/unit/routes/reimbursements.expense-standards.test.js +++ b/server/tests/unit/routes/reimbursements.expense-standards.test.js @@ -102,6 +102,14 @@ async function buildApp(options = {}) { ? (options.unlinkedBoundInvoiceCount ?? 0) : (options.boundInvoiceCount ?? 1) )), + findMany: vi.fn(async ({ where } = {}) => ( + where?.linked_item_id === null + ? Array.from( + { length: options.unlinkedBoundInvoiceCount ?? 0 }, + (_unused, index) => ({ id: `unlinked-invoice-${index + 1}` }), + ) + : [] + )), }, reimbursementItem: { findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)), @@ -409,6 +417,35 @@ describe("reimbursements routes expense standard integration", () => { await app.close(); }); + it("keeps the finance manual-review success marker when the B2 guard is off", async () => { + resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); + const { app } = await buildApp({ + invoiceGuardEnabled: false, + boundInvoiceCount: 1, + reimbursementItems: [{ + id: "manual-review-guard-off", + amount: "180.00", + invoice_pool_items: [{ + id: "off-invoice-guard-off", + total_amount: null, + ocr_attempts: 0, + ocr_last_requested_at: null, + ocr_last_error: null, + ocr_completed_at: null, + }], + }], + }); + + const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); + + expect(response.statusCode, response.body).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ + invoiceAmountManualReviewRequired: true, + manualReviewItemCount: 1, + })); + await app.close(); + }); + it("blocks required-invoice items without linked invoices when the guard is on", async () => { const { app, tx } = await buildApp({ invoiceGuardEnabled: true, @@ -441,6 +478,112 @@ describe("reimbursements routes expense standard integration", () => { await app.close(); }); + it("identifies unlinked invoices without exposing only a count", async () => { + const { app } = await buildApp({ + invoiceGuardEnabled: true, + unlinkedBoundInvoiceCount: 2, + }); + + const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); + + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual(expect.objectContaining({ + unlinkedInvoiceCount: 2, + unlinkedInvoiceIds: ["unlinked-invoice-1", "unlinked-invoice-2"], + })); + await app.close(); + }); + + it("blocks a linked invoice whose OCR ran without producing an amount", async () => { + const { app, tx } = await buildApp({ + invoiceGuardEnabled: true, + reimbursementItems: [{ + id: "ocr-failed-item", + amount: "180.00", + invoice_pool_items: [{ + id: "ocr-failed-invoice", + total_amount: null, + ocr_attempts: 1, + ocr_last_requested_at: new Date("2026-08-30T10:00:00.000Z"), + ocr_last_error: "amount_missing", + ocr_completed_at: new Date("2026-08-30T10:00:01.000Z"), + }], + }], + }); + + const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); + + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual(expect.objectContaining({ + missingInvoiceItemIds: [], + amountUnavailableItems: [{ + itemId: "ocr-failed-item", + invoiceIds: ["ocr-failed-invoice"], + }], + })); + expect(tx.workflowInstance.create).not.toHaveBeenCalled(); + await app.close(); + }); + + it("allows a linked PDF with no OCR execution evidence and marks finance manual review", async () => { + resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); + const { app } = await buildApp({ + invoiceGuardEnabled: true, + reimbursementItems: [{ + id: "manual-review-item", + amount: "180.00", + invoice_pool_items: [{ + id: "off-invoice", + total_amount: null, + ocr_attempts: 0, + ocr_last_requested_at: null, + ocr_last_error: null, + ocr_completed_at: null, + }], + }], + }); + + const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); + + expect(response.statusCode, response.body).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ + invoiceAmountManualReviewRequired: true, + manualReviewItemCount: 1, + })); + await app.close(); + }); + + it("does not compare a declared amount against only the known part of mixed evidence", async () => { + resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); + const { app } = await buildApp({ + invoiceGuardEnabled: true, + reimbursementItems: [{ + id: "mixed-evidence-item", + amount: "1000.00", + invoice_pool_items: [ + { id: "known-invoice", total_amount: "600.00" }, + { + id: "off-invoice", + total_amount: null, + ocr_attempts: 0, + ocr_last_requested_at: null, + ocr_last_error: null, + ocr_completed_at: null, + }, + ], + }], + }); + + const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); + + expect(response.statusCode, response.body).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ + invoiceAmountManualReviewRequired: true, + manualReviewItemCount: 1, + })); + await app.close(); + }); + it("blocks declared amounts above the linked invoice total when the guard is on", async () => { const { app, tx } = await buildApp({ invoiceGuardEnabled: true, @@ -462,6 +605,33 @@ describe("reimbursements routes expense standard integration", () => { await app.close(); }); + it("allows five required items when each has one verified bound invoice", async () => { + resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); + const reimbursementItems = Array.from({ length: 5 }, (_unused, index) => ({ + id: `verified-item-${index + 1}`, + amount: "100.00", + invoice_pool_items: [{ + id: `verified-invoice-${index + 1}`, + total_amount: "100.00", + }], + })); + const { app, tx } = await buildApp({ + invoiceGuardEnabled: true, + reimbursementItems, + authoritativeTotal: "500.00", + }); + + const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); + + expect(response.statusCode, response.body).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ + invoiceAmountManualReviewRequired: false, + manualReviewItemCount: 0, + })); + expect(tx.workflowInstance.create).toHaveBeenCalledTimes(1); + await app.close(); + }); + it("recomputes a stale total before selecting the approval rule", async () => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); const { app, tx } = await buildApp({ authoritativeTotal: "320.00" }); diff --git a/server/tests/unit/routes/reimbursements.invoice-item-link.test.js b/server/tests/unit/routes/reimbursements.invoice-item-link.test.js new file mode 100644 index 00000000..bb5a8560 --- /dev/null +++ b/server/tests/unit/routes/reimbursements.invoice-item-link.test.js @@ -0,0 +1,180 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const reimbursementRoutes = require("../../../src/routes/reimbursements"); + +const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_REIMBURSEMENT_ID = "22222222-2222-4222-8222-222222222222"; +const INVOICE_ID = "33333333-3333-4333-8333-333333333333"; +const ITEM_ID = "44444444-4444-4444-8444-444444444444"; +const ACTOR_ID = "55555555-5555-4555-8555-555555555555"; +const OTHER_EMPLOYEE_ID = "66666666-6666-4666-8666-666666666666"; + +function sqlText(strings) { + return Array.isArray(strings) ? strings.join("?") : String(strings); +} + +async function buildApp(options = {}) { + const actor = options.actor ?? { + id: ACTOR_ID, + permissionCodes: ["expense.edit_own"], + roleCodes: [], + }; + const invoice = { + id: INVOICE_ID, + status: "bound", + linked_reimbursement_id: REIMBURSEMENT_ID, + linked_item_id: null, + ...options.invoice, + }; + const reimbursement = { + id: REIMBURSEMENT_ID, + owner_id: ACTOR_ID, + submitter_id: ACTOR_ID, + status: "draft", + ...options.reimbursement, + }; + const tx = { + $queryRaw: vi.fn(async (strings) => { + const sql = sqlText(strings); + if (sql.includes('FROM "invoice_pool"')) return options.invoiceMissing ? [] : [invoice]; + if (sql.includes('FROM "reimbursement_forms"')) return options.reimbursementMissing ? [] : [reimbursement]; + return []; + }), + reimbursementItem: { + findFirst: vi.fn(async () => options.itemMissing ? null : { id: ITEM_ID }), + }, + invoicePool: { + update: vi.fn(async ({ data }) => ({ ...invoice, ...data })), + }, + auditLog: { + create: vi.fn(async () => ({ id: "audit-1" })), + }, + }; + const prisma = { + $transaction: vi.fn(async (callback) => callback(tx)), + }; + const app = Fastify(); + app.decorate("prisma", prisma); + app.decorate("config", { + appTimezone: "Asia/Shanghai", + reimbursementInvoiceGuardEnabled: true, + }); + app.decorate("hasPermission", (employee, permissionCode) => ( + employee.permissionCodes.includes("*") || employee.permissionCodes.includes(permissionCode) + )); + app.decorate("requirePermission", (permissionCode) => async (request) => { + request.employee = actor; + if (!app.hasPermission(actor, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + app.decorate("authenticate", async (request) => { + request.employee = actor; + }); + app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ + requestId: request.id, + error: { + code: error.code ?? "SYS_001", + message: error.message, + details: error.details ?? {}, + }, + })); + + await app.register(reimbursementRoutes, { prefix: "/api" }); + return { app, prisma, tx }; +} + +function requestPayload() { + return { + method: "PATCH", + url: `/api/reimbursements/${REIMBURSEMENT_ID}/invoices/${INVOICE_ID}/item-link`, + payload: { linkedItemId: ITEM_ID }, + }; +} + +describe("PATCH reimbursement invoice item link", () => { + it("atomically links an unassigned bound invoice to an item in the same reimbursement", async () => { + const { app, tx } = await buildApp(); + + const response = await app.inject(requestPayload()); + + expect(response.statusCode, response.body).toBe(200); + expect(response.json().data).toEqual({ + invoiceId: INVOICE_ID, + reimbursementId: REIMBURSEMENT_ID, + linkedItemId: ITEM_ID, + }); + expect(tx.reimbursementItem.findFirst).toHaveBeenCalledWith({ + where: { id: ITEM_ID, reimbursement_id: REIMBURSEMENT_ID }, + select: { id: true }, + }); + expect(tx.invoicePool.update).toHaveBeenCalledWith({ + where: { id: INVOICE_ID }, + data: { linked_item_id: ITEM_ID, updated_by: ACTOR_ID }, + }); + expect(tx.auditLog.create).toHaveBeenCalledTimes(1); + await app.close(); + }); + + it.each([ + ["submitter participant", { owner_id: OTHER_EMPLOYEE_ID, submitter_id: ACTOR_ID, status: "draft" }], + ["rejected form", { owner_id: ACTOR_ID, submitter_id: ACTOR_ID, status: "rejected" }], + ])("allows %s to restore the item link", async (_label, reimbursement) => { + const { app, tx } = await buildApp({ reimbursement }); + + const response = await app.inject(requestPayload()); + + expect(response.statusCode, response.body).toBe(200); + expect(tx.invoicePool.update).toHaveBeenCalledTimes(1); + expect(tx.auditLog.create).toHaveBeenCalledTimes(1); + await app.close(); + }); + + it.each([ + ["nonparticipant", { reimbursement: { owner_id: OTHER_EMPLOYEE_ID, submitter_id: OTHER_EMPLOYEE_ID } }, 403], + ["nonparticipant with a foreign invoice", { + reimbursement: { owner_id: OTHER_EMPLOYEE_ID, submitter_id: OTHER_EMPLOYEE_ID }, + invoice: { linked_reimbursement_id: OTHER_REIMBURSEMENT_ID }, + }, 403], + ["submitted reimbursement", { reimbursement: { status: "reviewing" } }, 409], + ["item from another reimbursement", { itemMissing: true }, 409], + ["invoice already assigned", { invoice: { linked_item_id: ITEM_ID } }, 409], + ["invoice belongs to another reimbursement", { invoice: { linked_reimbursement_id: OTHER_REIMBURSEMENT_ID } }, 409], + ["invoice is no longer bound", { invoice: { status: "available" } }, 409], + ])("rejects %s with zero writes", async (_label, options, expectedStatus) => { + const { app, tx } = await buildApp(options); + + const response = await app.inject(requestPayload()); + + expect(response.statusCode).toBe(expectedStatus); + expect(tx.invoicePool.update).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + await app.close(); + }); + + it("returns not found for a missing invoice only after validating the reimbursement participant", async () => { + const { app, tx } = await buildApp({ invoiceMissing: true }); + + const response = await app.inject(requestPayload()); + + expect(response.statusCode).toBe(404); + expect(tx.$queryRaw).toHaveBeenCalledTimes(2); + expect(tx.invoicePool.update).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + await app.close(); + }); + + it("rejects an actor without the own-expense edit permission before reading rows", async () => { + const { app, tx } = await buildApp({ + actor: { id: ACTOR_ID, permissionCodes: [], roleCodes: [] }, + }); + + const response = await app.inject(requestPayload()); + + expect(response.statusCode).toBe(403); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + expect(tx.invoicePool.update).not.toHaveBeenCalled(); + await app.close(); + }); +}); diff --git a/server/tests/unit/services/invoiceAmountEvidence.test.js b/server/tests/unit/services/invoiceAmountEvidence.test.js new file mode 100644 index 00000000..9bff1627 --- /dev/null +++ b/server/tests/unit/services/invoiceAmountEvidence.test.js @@ -0,0 +1,42 @@ +const { + INVOICE_AMOUNT_EVIDENCE_STATUS, + resolveInvoiceAmountEvidence, +} = require("../../../src/services/invoiceAmountEvidence"); + +describe("resolveInvoiceAmountEvidence", () => { + it("treats a present amount, including zero, as verified", () => { + expect(resolveInvoiceAmountEvidence({ + totalAmount: "0.00", + ocrAttempts: 0, + ocrLastRequestedAt: null, + ocrCompletedAt: null, + ocrLastError: null, + })).toBe(INVOICE_AMOUNT_EVIDENCE_STATUS.verified); + }); + + it("requires manual review when no amount and no OCR execution evidence exist", () => { + expect(resolveInvoiceAmountEvidence({ + totalAmount: null, + ocrAttempts: 0, + ocrLastRequestedAt: null, + ocrCompletedAt: null, + ocrLastError: null, + })).toBe(INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview); + }); + + it.each([ + ["attempt count", { ocrAttempts: 1 }], + ["request time", { ocrLastRequestedAt: new Date("2026-08-30T10:00:00.000Z") }], + ["completion time", { ocrCompletedAt: new Date("2026-08-30T10:00:01.000Z") }], + ["last error", { ocrLastError: "amount_missing" }], + ])("treats missing amount with %s as OCR failed", (_label, evidence) => { + expect(resolveInvoiceAmountEvidence({ + totalAmount: null, + ocrAttempts: 0, + ocrLastRequestedAt: null, + ocrCompletedAt: null, + ocrLastError: null, + ...evidence, + })).toBe(INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed); + }); +});