修复报销票据门禁提示与 OCR 降级(08-30-b2-submit-error-ocr-fallback)

这个提交包含在:
2026-08-31 20:20:20 +08:00
父节点 8b19538584
当前提交 f57e2236f2
共修改 21 个文件,包含 1829 行新增和 49 行删除
@@ -30,10 +30,12 @@ duplicate detection or attachment behavior.
require the same own-or-`expense.view_all` guard as reimbursement detail reads require the same own-or-`expense.view_all` guard as reimbursement detail reads
before invoking the precheck service; an ordinary nonparticipant must receive before invoking the precheck service; an ordinary nonparticipant must receive
403 without service-side evaluation. 403 without service-side evaluation.
- 取消或放宽必填校验时,必须继续审计该校验之后的代码是否依赖它提供的 - 增加、取消或放宽强制校验时,必须同时核对既有降级合同、兼容模式和该
非空、类型或状态保证,并为生产真实存在的空值路径补回归测试。不得只 校验之后的代码假设。取消或放宽时审计下游是否依赖它提供的非空、类型
删除前置错误分支;可空字段的 `.trim()`、`.length`、字符串拼接和传给 或状态保证,并为生产真实存在的空值路径补回归测试;增加时确认不会把
非空参数的调用都应逐处确认。 `off`、历史数据或其他已承诺可用的降级路径变成不可提交。不得只修改
前置错误分支;可空字段的 `.trim()`、`.length`、字符串拼接和传给非空
参数的调用都应逐处确认。
## Scenario: OCR Disabled Without Mock Data ## Scenario: OCR Disabled Without Mock Data
@@ -994,6 +996,10 @@ await selectApprovalRule(reimbursement.total_amount);
- Reimbursement detail returns `invoiceGuardEnabled` for the form department. - Reimbursement detail returns `invoiceGuardEnabled` for the form department.
- `POST /api/invoice-pool/:id/bind` requires `linkedItemId` while the guard is - `POST /api/invoice-pool/:id/bind` requires `linkedItemId` while the guard is
enabled; `POST /api/reimbursements/:id/submit` enforces the final gate. 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 ### 3. Contracts
@@ -1017,16 +1023,36 @@ await selectApprovalRule(reimbursement.total_amount);
- With B2 on, binding requires an existing item in the same reimbursement and - 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 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 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 makes the invoice bindable. Submit separates PDF presence from amount
link, each required-invoice item with no amount-bearing bound invoice, and evidence: a required item without a bound PDF blocks as missing evidence;
each declared amount greater than its linked invoice total. a bound PDF with attempted OCR but no amount blocks as recognition failure.
- Sum and compare money with `Prisma.Decimal`. Invoice-free items are excluded. - Classify one invoice from the explicit minimum fields `total_amount`,
A lower declared amount remains allowed for finance to reconcile. `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 - 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; 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 allowance-only forms remain invoice-free because that is an always-active B1
policy. Turning the flag off never deletes or unbinds relationships already 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; - Form maintenance and invoice bind/unbind remain owner-or-submitter writes;
`expense.view_all` and admin roles remain read-only for another employee's `expense.view_all` and admin roles remain read-only for another employee's
reimbursement. reimbursement.
@@ -1040,11 +1066,23 @@ await selectApprovalRule(reimbursement.total_amount);
`required_when_invoice_guard_enabled`; an item outside the form remains `required_when_invoice_guard_enabled`; an item outside the form remains
`item_not_in_reimbursement` with zero writes. `item_not_in_reimbursement` with zero writes.
- Guard-on submit with unlinked bound invoices -> `invoice_item_guard_failed` - Guard-on submit with unlinked bound invoices -> `invoice_item_guard_failed`
plus `unlinkedInvoiceCount`. plus `unlinkedInvoiceCount` and locating `unlinkedInvoiceIds`.
- Required item without an amount-bearing bound invoice -> same error plus - Required item without any bound PDF -> same error plus
`missingInvoiceItemIds`. `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 - 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 - Invoice-free allowance-only form with no invoice -> may submit with the guard
either on or off when all other existing checks pass. 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 - 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 - Good: one required item explicitly links several same-purpose invoices whose
total covers the declaration; submit passes with the guard on. 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 - Good: a positive meal allowance-only form has no invoice and passes with the
guard either on or off. guard either on or off.
- Base: guard off preserves an older draft and its auto-created link; later - 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 item while explicit selection is required, use JavaScript floating-point
sums for money, let repeated init erase finance account mappings, or 删除前置 sums for money, let repeated init erase finance account mappings, or 删除前置
必填校验后继续对可空值直接调用 `.trim()`。 必填校验后继续对可空值直接调用 `.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 ### 6. Tests Required
@@ -1075,14 +1121,20 @@ await selectApprovalRule(reimbursement.total_amount);
disabled legacy types, default-off flag and account-code preservation. disabled legacy types, default-off flag and account-code preservation.
- Item/submit route tests 覆盖 assets 创建 omitted→null、编辑 null+omitted→null、 - Item/submit route tests 覆盖 assets 创建 omitted→null、编辑 null+omitted→null、
有值+omitted→保留、显式清空、切换非 assets 清空和无依据提交,并继续覆盖 有值+omitted→保留、显式清空、切换非 assets 清空和无依据提交,并继续覆盖
invoice-free zero invoice、missing link、unlinked bound invoice、 invoice-free zero invoice、missing PDF、unlinked bound invoice、OCR failed、
declared-over-invoice 与 participant/nonparticipant side effects。 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 - Bind tests cover flag on/off, missing/foreign/successful explicit links and
prove the guard-on path does not create a reimbursement item. 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 登记导向说明、批准依据输入/展示均不存在且 - PC/mobile Playwright covers asset 登记导向说明、批准依据输入/展示均不存在且
保存 payload omission、CNY 2,000 warning、allowance-only submit projection、 保存 payload omission、CNY 2,000 warning、allowance-only submit projection、
disabled add action until every 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 - Run full backend Vitest with the pinned BIFF8 Python dependencies, Prisma
validate/generate check, frontend typecheck/build, focused Playwright, validate/generate check, frontend typecheck/build, focused Playwright,
process/docs/Trellis governance and `git diff --check`. process/docs/Trellis governance and `git diff --check`.
@@ -1109,7 +1161,7 @@ asset_prior_approval_basis: targetApprovalBasis.trim();
#### Correct #### Correct
```js ```js
if (item.expense_type.requires_invoice) { if (item.expense_type.requires_invoice && linkedInvoices.every(hasVerifiedAmount)) {
const total = linkedInvoices.reduce( const total = linkedInvoices.reduce(
(sum, invoice) => sum.plus(invoice.total_amount), (sum, invoice) => sum.plus(invoice.total_amount),
new Prisma.Decimal(0), new Prisma.Decimal(0),
@@ -21,7 +21,8 @@
"children": [ "children": [
"08-30-reimbursement-expense-policy-invoice-guard-deploy", "08-30-reimbursement-expense-policy-invoice-guard-deploy",
"08-30-reimbursement-assets-prior-approval-correction", "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, "parent": null,
"relatedFiles": [ "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 财务一页式及行内费用标准提示恢复仍为后续任务。", "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": {} "meta": {}
} }
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -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 规范校核不替代该部署门。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -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 写入,不修改或清理既有票据关联数据。
@@ -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 证据。
@@ -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": {}
}
+61
查看文件
@@ -10,6 +10,35 @@ function readStringList(value: unknown): string[] {
.filter((item) => item.length > 0); .filter((item) => item.length > 0);
} }
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, string> = { const FEATURE_FLAG_DISABLED_MESSAGES: Record<string, string> = {
portal_home_v1_disabled: "工作台首页当前未启用,请联系管理员检查环境开关与功能开关配置。", portal_home_v1_disabled: "工作台首页当前未启用,请联系管理员检查环境开关与功能开关配置。",
announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。", announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
@@ -131,6 +160,38 @@ export function mapApiErrorMessage(
return "所有报销明细金额都必须大于 0,请修改后再提交。"; 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); const passwordErrors = readStringList(details?.password);
if (passwordErrors.length) { if (passwordErrors.length) {
return `新密码不符合要求:${passwordErrors.join(";")}。`; return `新密码不符合要求:${passwordErrors.join(";")}。`;
+19
查看文件
@@ -194,6 +194,7 @@ export type ReimbursementDetail = {
mimeType: string | null; mimeType: string | null;
fileSize: number | null; fileSize: number | null;
linkedItemId: string | null; linkedItemId: string | null;
amountEvidenceStatus: "verified" | "manual_review" | "ocr_failed";
}>; }>;
attachments: Array<{ attachments: Array<{
id: string; id: string;
@@ -225,6 +226,7 @@ export type ReimbursementDetail = {
approvalHistory: ReimbursementApprovalHistoryItem[]; approvalHistory: ReimbursementApprovalHistoryItem[];
riskFlags: RiskFlag[]; riskFlags: RiskFlag[];
invoiceGuardEnabled?: boolean; invoiceGuardEnabled?: boolean;
invoiceAmountManualReviewRequired?: boolean;
items: ReimbursementItem[]; items: ReimbursementItem[];
}; };
@@ -391,6 +393,8 @@ export type ReimbursementSubmitResponse = {
precheckDecision: "pass" | "warn" | "manual_review"; precheckDecision: "pass" | "warn" | "manual_review";
standardWarnings: ExpenseStandardCheck[]; standardWarnings: ExpenseStandardCheck[];
openedRiskFlagCount: number; openedRiskFlagCount: number;
invoiceAmountManualReviewRequired: boolean;
manualReviewItemCount: number;
}; };
function toQueryString(query: Record<string, string | number | undefined>) { function toQueryString(query: Record<string, string | number | undefined>) {
@@ -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) { export async function withdrawReimbursement(reimbursementId: string, comment?: string) {
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/withdraw`, { return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/withdraw`, {
method: "POST", method: "POST",
+148
查看文件
@@ -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<HTMLElement>('[data-reimbursement-guard-issue="true"]');
if (!target) return;
target.focus({ preventScroll: true });
target.scrollIntoView({ behavior: "smooth", block: "center" });
});
}
@@ -29,6 +29,7 @@ import {
submitReimbursement, submitReimbursement,
updateReimbursementItem, updateReimbursementItem,
uploadReimbursementAttachments, uploadReimbursementAttachments,
linkReimbursementInvoiceItem,
type ReimbursementItem, type ReimbursementItem,
type ReimbursementStatus, type ReimbursementStatus,
} from "../api/reimbursements"; } from "../api/reimbursements";
@@ -81,6 +82,15 @@ import { cn } from "../lib/utils";
import { isWecomWebView } from "../lib/runtimeContext"; import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor"; import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
import {
focusFirstInvoiceGuardIssue,
invoiceGuardInvoiceMessages,
invoiceGuardIssueCount,
invoiceGuardItemMessages,
parseReimbursementInvoiceGuardIssues,
resolveInvoiceGuardIssuesAfterLink,
type ReimbursementInvoiceGuardIssues,
} from "../lib/reimbursementInvoiceGuard";
type ItemFormState = { type ItemFormState = {
mode: "create" | "edit"; mode: "create" | "edit";
@@ -167,6 +177,9 @@ export function MobileReimbursementDetailPage() {
const [itemForm, setItemForm] = useState<ItemFormState | null>(null); const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
const [itemError, setItemError] = useState(""); const [itemError, setItemError] = useState("");
const [actionError, setActionError] = useState(""); const [actionError, setActionError] = useState("");
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState(""); const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false); const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
@@ -369,6 +382,7 @@ export function MobileReimbursementDetailPage() {
await invalidateDetail(); await invalidateDetail();
setItemForm(null); setItemForm(null);
setItemError(""); setItemError("");
setInvoiceGuardIssues(null);
toast.success("明细已保存"); toast.success("明细已保存");
}, },
onError: (error) => { onError: (error) => {
@@ -408,15 +422,23 @@ export function MobileReimbursementDetailPage() {
const submitMutation = useMutation({ const submitMutation = useMutation({
mutationFn: () => submitReimbursement(reimbursementId), mutationFn: () => submitReimbursement(reimbursementId),
onSuccess: async () => { onSuccess: async (data) => {
await invalidateDetail(); await invalidateDetail();
setSubmitConfirmPrecheck(null); setSubmitConfirmPrecheck(null);
setActionError(""); setActionError("");
toast.success("已提交审批"); setInvoiceGuardIssues(null);
toast.success(
data.invoiceAmountManualReviewRequired
? "已提交,票面金额将由财务核对"
: "已提交审批",
);
}, },
onError: (error) => { onError: (error) => {
const message = resolveErrorMessage(error); const message = resolveErrorMessage(error);
const issues = parseReimbursementInvoiceGuardIssues(error);
setActionError(message); setActionError(message);
setInvoiceGuardIssues(issues);
if (issues) focusFirstInvoiceGuardIssue();
toast.error(message); toast.error(message);
}, },
}); });
@@ -464,6 +486,7 @@ export function MobileReimbursementDetailPage() {
setPendingOcrLinkedItemIds((previous) => setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))), Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
); );
if (succeededIds.length) setInvoiceGuardIssues(null);
if (!failed.length) { if (!failed.length) {
setInvoiceActionError(""); setInvoiceActionError("");
@@ -531,6 +554,7 @@ export function MobileReimbursementDetailPage() {
onSuccess: async () => { onSuccess: async () => {
await invalidateDetail(); await invalidateDetail();
setInvoiceActionError(""); setInvoiceActionError("");
setInvoiceGuardIssues(null);
toast.success("发票已移除"); toast.success("发票已移除");
}, },
onError: (error) => { 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({ const deleteAvailableInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => archiveInvoice(invoiceId), mutationFn: (invoiceId: string) => archiveInvoice(invoiceId),
onSuccess: async (_result, invoiceId) => { onSuccess: async (_result, invoiceId) => {
@@ -805,6 +858,7 @@ export function MobileReimbursementDetailPage() {
try { try {
setActionError(""); setActionError("");
setInvoiceGuardIssues(null);
const precheck = await precheckReimbursement(reimbursementId); const precheck = await precheckReimbursement(reimbursementId);
const message = buildPrecheckDialogMessage(precheck); const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") { if (precheck.decision === "block") {
@@ -1023,6 +1077,20 @@ export function MobileReimbursementDetailPage() {
</div> </div>
</section> </section>
{invoiceGuardIssues ? (
<section
className="mobile-card border-red-300 bg-red-50"
data-testid="mobile-reimbursement-invoice-guard-summary"
role="alert"
aria-live="assertive"
>
<h2 className="text-base font-semibold text-red-800">票据检查未通过</h2>
<p className="mt-2 text-sm leading-6 text-red-700">
共 {invoiceGuardIssueCount(invoiceGuardIssues)} 项需要处理,请按下方明细和发票提示修改。
</p>
</section>
) : null}
<section className="mobile-card" data-testid="mobile-reimbursement-items"> <section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2> <h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -1114,7 +1182,15 @@ export function MobileReimbursementDetailPage() {
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{detail.items.length ? ( {detail.items.length ? (
detail.items.map((item) => ( detail.items.map((item) => (
<article key={item.id} className="mobile-list-card"> <article
key={item.id}
className={cn(
"mobile-list-card",
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length && "border-red-300 bg-red-50/60",
)}
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div> <div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div>
@@ -1126,6 +1202,15 @@ export function MobileReimbursementDetailPage() {
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? ( {shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p> <p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
) : null} ) : null}
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
<p
key={message}
className="mt-2 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{canMaintainReimbursement ? ( {canMaintainReimbursement ? (
<div className="mobile-two-action-grid mt-3"> <div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}> <Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
@@ -1172,6 +1257,12 @@ export function MobileReimbursementDetailPage() {
</div> </div>
</div> </div>
{detail.invoiceAmountManualReviewRequired ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
本单包含未执行 OCR 的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
</p>
) : null}
{detail.invoices.length || (detail.attachments ?? []).length ? ( {detail.invoices.length || (detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p> <p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
@@ -1273,8 +1364,21 @@ export function MobileReimbursementDetailPage() {
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联" ? "已关联"
: resolveInvoiceNextStepText(invoice); : resolveInvoiceNextStepText(invoice);
const guardMessages = invoiceGuardInvoiceMessages(
invoiceGuardIssues,
invoice.id,
invoice.linkedItemId,
);
return ( return (
<article key={invoice.id} className="mobile-list-card"> <article
key={invoice.id}
className={cn(
"mobile-list-card",
guardMessages.length && "border-red-300 bg-red-50/60",
)}
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
tabIndex={guardMessages.length ? -1 : undefined}
>
<button <button
type="button" type="button"
className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60" className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
@@ -1298,6 +1402,64 @@ export function MobileReimbursementDetailPage() {
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" /> <ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
</span> </span>
</button> </button>
{guardMessages.map((message) => (
<p
key={message}
className="mt-3 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{invoice.amountEvidenceStatus === "manual_review" ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800">
票面金额未自动识别,需由财务查看原件核对。
</p>
) : null}
{invoice.amountEvidenceStatus === "ocr_failed" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm leading-5 text-red-700">
OCR 已处理但未取得票面金额,提交前需要重试识别或联系财务。
</p>
) : null}
{canMaintainReimbursement && !invoice.linkedItemId ? (
<div className="mt-3 space-y-2">
<label className="block text-sm font-medium text-zinc-700" htmlFor={`mobile-invoice-item-link-${invoice.id}`}>
选择报销明细
</label>
<select
id={`mobile-invoice-item-link-${invoice.id}`}
className="select-std min-h-11 w-full"
value={invoiceItemSelections[invoice.id] ?? ""}
onChange={(event) => setInvoiceItemSelections((previous) => ({
...previous,
[invoice.id]: event.target.value,
}))}
>
<option value="">请选择明细</option>
{detail.items.map((item) => (
<option key={item.id} value={item.id}>
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
</option>
))}
</select>
<Button
className="min-h-11 w-full"
type="button"
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
onClick={() => linkInvoiceItemMutation.mutate({
invoiceId: invoice.id,
linkedItemId: invoiceItemSelections[invoice.id],
})}
>
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
</Button>
{invoiceItemLinkErrors[invoice.id] ? (
<p className="text-sm leading-5 text-red-700" role="alert">
{invoiceItemLinkErrors[invoice.id]}
</p>
) : null}
</div>
) : null}
{canUnbindInvoices ? ( {canUnbindInvoices ? (
<Button <Button
className="mt-3 w-full" className="mt-3 w-full"
@@ -1362,7 +1524,11 @@ export function MobileReimbursementDetailPage() {
<section className="mobile-card" data-testid="mobile-reimbursement-actions"> <section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2> <h2 className="text-base font-semibold text-zinc-900">操作</h2>
{actionError ? ( {actionError ? (
<div className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"> <div
className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
role="alert"
aria-live="assertive"
>
{actionError} {actionError}
</div> </div>
) : null} ) : null}
+169 -14
查看文件
@@ -22,6 +22,7 @@ import {
uploadReimbursementAttachments, uploadReimbursementAttachments,
withdrawReimbursement, withdrawReimbursement,
linkReimbursementProject, linkReimbursementProject,
linkReimbursementInvoiceItem,
type ReimbursementItem, type ReimbursementItem,
} from "../api/reimbursements"; } from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow"; import { remindWorkflowTask } from "../api/workflow";
@@ -95,6 +96,14 @@ import {
type ItemEditorSaveData, type ItemEditorSaveData,
} from "./reimbursement-detail/ReimbursementItemEditor"; } from "./reimbursement-detail/ReimbursementItemEditor";
import { buildPrecheckDialogMessage } from "../lib/precheck"; import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
focusFirstInvoiceGuardIssue,
invoiceGuardInvoiceMessages,
invoiceGuardItemMessages,
parseReimbursementInvoiceGuardIssues,
resolveInvoiceGuardIssuesAfterLink,
type ReimbursementInvoiceGuardIssues,
} from "../lib/reimbursementInvoiceGuard";
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null; type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
type SelectedInvoiceMap = Record<string, string>; type SelectedInvoiceMap = Record<string, string>;
@@ -111,6 +120,9 @@ export function ReimbursementDetailPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [actionError, setActionError] = useState(""); const [actionError, setActionError] = useState("");
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null); const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
const [itemError, setItemError] = useState(""); const [itemError, setItemError] = useState("");
@@ -239,22 +251,34 @@ export function ReimbursementDetailPage() {
return withdrawReimbursement(reimbursementId, params.comment); return withdrawReimbursement(reimbursementId, params.comment);
} }
}, },
onSuccess: async (_data, params) => { onSuccess: async (data, params) => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
]); ]);
setActionError(""); setActionError("");
setInvoiceGuardIssues(null);
if (params.action === "submit") { if (params.action === "submit") {
setSubmitConfirmPrecheck(null); setSubmitConfirmPrecheck(null);
} }
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回" }; const requiresManualReview = params.action === "submit"
&& data != null
&& typeof data === "object"
&& "invoiceAmountManualReviewRequired" in data
&& data.invoiceAmountManualReviewRequired === true;
const actionMessages: Record<string, string> = {
submit: requiresManualReview ? "已提交,票面金额将由财务核对" : "已提交",
withdraw: "已撤回",
};
toast.success(actionMessages[params.action] ?? "操作成功"); toast.success(actionMessages[params.action] ?? "操作成功");
}, },
onError: (error) => { onError: (error) => {
const message = buildActionErrorMessage(error, "操作失败,请稍后重试"); const message = buildActionErrorMessage(error, "操作失败,请稍后重试");
const issues = parseReimbursementInvoiceGuardIssues(error);
setActionError(message); setActionError(message);
setInvoiceGuardIssues(issues);
if (issues) focusFirstInvoiceGuardIssue();
toast.error(message); toast.error(message);
}, },
}); });
@@ -306,6 +330,7 @@ export function ReimbursementDetailPage() {
]); ]);
setItemEditor(null); setItemEditor(null);
setItemError(""); setItemError("");
setInvoiceGuardIssues(null);
toast.success("明细已保存"); toast.success("明细已保存");
}, },
onError: (error) => { onError: (error) => {
@@ -417,6 +442,7 @@ export function ReimbursementDetailPage() {
setPendingOcrLinkedItemIds((previous) => setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))), Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
); );
if (succeededIds.length) setInvoiceGuardIssues(null);
if (!failedResults.length) { if (!failedResults.length) {
setInvoiceActionError(""); setInvoiceActionError("");
@@ -497,6 +523,7 @@ export function ReimbursementDetailPage() {
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }), queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
]); ]);
setInvoiceActionError(""); setInvoiceActionError("");
setInvoiceGuardIssues(null);
toast.success("发票已移除"); toast.success("发票已移除");
}, },
onError: (error) => { onError: (error) => {
@@ -506,6 +533,35 @@ export function ReimbursementDetailPage() {
}, },
}); });
const linkInvoiceItemMutation = useMutation({
mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) =>
linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId),
onSuccess: async (_result, variables) => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
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 = buildActionErrorMessage(error, "关联明细失败,请刷新后重试");
setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message }));
toast.error(message);
},
});
const uploadInvoiceMutation = useMutation({ const uploadInvoiceMutation = useMutation({
mutationFn: async (files: File[]) => { mutationFn: async (files: File[]) => {
const ownerEmployeeId = detailQuery.data?.ownerId; const ownerEmployeeId = detailQuery.data?.ownerId;
@@ -849,6 +905,7 @@ export function ReimbursementDetailPage() {
try { try {
setActionError(""); setActionError("");
setInvoiceGuardIssues(null);
if (!hasCompleteTravelSummary(detail)) { if (!hasCompleteTravelSummary(detail)) {
const message = "请先补全出差事由、地点和起止日期"; const message = "请先补全出差事由、地点和起止日期";
setActionError(message); setActionError(message);
@@ -1159,6 +1216,8 @@ export function ReimbursementDetailPage() {
<p <p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
data-testid="reimbursement-action-error" data-testid="reimbursement-action-error"
role="alert"
aria-live="assertive"
> >
{actionError} {actionError}
</p> </p>
@@ -1243,9 +1302,13 @@ export function ReimbursementDetailPage() {
detail.items.map((item) => ( detail.items.map((item) => (
<TableRow <TableRow
data-testid="reimbursement-item-row" data-testid="reimbursement-item-row"
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
key={item.id} key={item.id}
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
className={ className={
blockStandardChecks.some((check) => check.itemId === item.id) invoiceGuardItemMessages(invoiceGuardIssues, item.id).length
? "bg-red-50/70"
: blockStandardChecks.some((check) => check.itemId === item.id)
? "bg-red-50/70" ? "bg-red-50/70"
: warnStandardChecks.some((check) => check.itemId === item.id) : warnStandardChecks.some((check) => check.itemId === item.id)
? "bg-amber-50/70" ? "bg-amber-50/70"
@@ -1271,6 +1334,15 @@ export function ReimbursementDetailPage() {
{check.exceeded ? `,超出 ${formatMoney(check.exceededAmount)}` : ""} {check.exceeded ? `,超出 ${formatMoney(check.exceededAmount)}` : ""}
</div> </div>
))} ))}
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
<div
key={message}
className="mt-2 rounded-md border border-red-200 bg-red-50 px-2 py-1.5 text-xs leading-5 text-red-700"
role="alert"
>
{message}
</div>
))}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div>{item.description ?? "-"}</div> <div>{item.description ?? "-"}</div>
@@ -1406,13 +1478,29 @@ export function ReimbursementDetailPage() {
</p> </p>
) : null} ) : null}
{detail.invoiceAmountManualReviewRequired ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
本单包含未执行 OCR 的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
</p>
) : null}
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}> <div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => { {detail.invoices.map((invoice) => {
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联" ? "已关联"
: resolveInvoiceNextStepText(invoice); : resolveInvoiceNextStepText(invoice);
const guardMessages = invoiceGuardInvoiceMessages(
invoiceGuardIssues,
invoice.id,
invoice.linkedItemId,
);
return ( return (
<div className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center" key={invoice.id}> <div
className={`detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center ${guardMessages.length ? "border-red-300 bg-red-50/60" : ""}`}
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
key={invoice.id}
tabIndex={guardMessages.length ? -1 : undefined}
>
<button <button
type="button" type="button"
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60" className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
@@ -1432,16 +1520,83 @@ export function ReimbursementDetailPage() {
<span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span> <span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" /> <ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button> </button>
{canUnbindInvoices ? ( {(guardMessages.length
<Button || invoice.amountEvidenceStatus === "manual_review"
type="button" || invoice.amountEvidenceStatus === "ocr_failed"
variant="secondary" || canUnbindInvoices) ? (
size="sm" <div className="w-full space-y-2 md:w-72">
disabled={unbindInvoiceMutation.isPending} {guardMessages.map((message) => (
onClick={() => unbindInvoiceMutation.mutate(invoice.id)} <p
> key={message}
移除 className="rounded-md border border-red-200 bg-white px-3 py-2 text-xs leading-5 text-red-700"
</Button> role="alert"
>
{message}
</p>
))}
{invoice.amountEvidenceStatus === "manual_review" ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
票面金额未自动识别,需由财务查看原件核对。
</p>
) : null}
{invoice.amountEvidenceStatus === "ocr_failed" ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs leading-5 text-red-700">
OCR 已处理但未取得票面金额,提交前需要重试识别或联系财务。
</p>
) : null}
{canMaintainReimbursement && !invoice.linkedItemId ? (
<div className="space-y-2">
<label className="block text-xs font-medium text-zinc-700" htmlFor={`invoice-item-link-${invoice.id}`}>
选择报销明细
</label>
<select
id={`invoice-item-link-${invoice.id}`}
className="select-std w-full"
value={invoiceItemSelections[invoice.id] ?? ""}
onChange={(event) => setInvoiceItemSelections((previous) => ({
...previous,
[invoice.id]: event.target.value,
}))}
>
<option value="">请选择明细</option>
{detail.items.map((item) => (
<option key={item.id} value={item.id}>
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
</option>
))}
</select>
<Button
className="w-full"
type="button"
size="sm"
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
onClick={() => linkInvoiceItemMutation.mutate({
invoiceId: invoice.id,
linkedItemId: invoiceItemSelections[invoice.id],
})}
>
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
</Button>
{invoiceItemLinkErrors[invoice.id] ? (
<p className="text-xs leading-5 text-red-700" role="alert">
{invoiceItemLinkErrors[invoice.id]}
</p>
) : null}
</div>
) : null}
{canUnbindInvoices ? (
<Button
className="w-full"
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
</div>
) : null} ) : null}
</div> </div>
); );
@@ -107,6 +107,7 @@ function buildInvoice() {
mimeType: "application/pdf", mimeType: "application/pdf",
fileSize: 4096, fileSize: 4096,
linkedItemId: "item-r111" as string | null, 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 }>, bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
itemSaveRequests: [] as Array<Record<string, unknown>>, itemSaveRequests: [] as Array<Record<string, unknown>>,
submitRequestCount: 0, submitRequestCount: 0,
submitGuardDetails: null as null | Record<string, unknown>,
invoiceItemLinkRequests: [] as Array<{ invoiceId: string; linkedItemId?: string }>,
invoiceItemLinkError: null as null | Record<string, unknown>,
invoiceGuardEnabled: false, invoiceGuardEnabled: false,
invoiceAmountManualReviewRequired: false,
detailItems: null as null | Array<Record<string, unknown>>, detailItems: null as null | Array<Record<string, unknown>>,
}; };
} }
@@ -237,6 +242,7 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
approvalHistory: [], approvalHistory: [],
riskFlags: [], riskFlags: [],
invoiceGuardEnabled: state.invoiceGuardEnabled, invoiceGuardEnabled: state.invoiceGuardEnabled,
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
items: state.detailItems ?? [ items: state.detailItems ?? [
{ {
id: "item-r111", id: "item-r111",
@@ -421,7 +427,61 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
if (pathName === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") { if (pathName === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
state.submitRequestCount += 1; state.submitRequestCount += 1;
await fulfillJson(route, { id: reimbursementId, status: "reviewing" }); if (state.submitGuardDetails) {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
requestId: "mock-r111-submit-guard",
error: {
code: "VALID_001",
message: "参数校验失败",
details: state.submitGuardDetails,
},
}),
});
return;
}
await fulfillJson(route, {
reimbursementId,
status: "reviewing",
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
manualReviewItemCount: state.invoiceAmountManualReviewRequired ? 1 : 0,
});
return;
}
if (
pathName.startsWith(`/api/reimbursements/${reimbursementId}/invoices/`)
&& pathName.endsWith("/item-link")
&& request.method() === "PATCH"
) {
const invoiceId = pathName.split("/").at(-2) ?? "";
const payload = request.postDataJSON() as { linkedItemId?: string };
state.invoiceItemLinkRequests.push({ invoiceId, linkedItemId: payload.linkedItemId });
if (state.invoiceItemLinkError) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
requestId: "mock-r111-item-link",
error: {
code: "VALID_001",
message: "参数校验失败",
details: state.invoiceItemLinkError,
},
}),
});
return;
}
state.invoices = state.invoices.map((invoice) => (
invoice.id === invoiceId ? { ...invoice, linkedItemId: payload.linkedItemId ?? null } : invoice
));
await fulfillJson(route, {
reimbursementId,
invoiceId,
linkedItemId: payload.linkedItemId,
});
return; return;
} }
@@ -855,6 +915,153 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect.poll(() => state.submitRequestCount).toBe(1); 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 }) => { test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
const state = buildReimbursementState(); const state = buildReimbursementState();
const mobile = await newWecomPage(browser); const mobile = await newWecomPage(browser);
+5 -1
查看文件
@@ -51,6 +51,9 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 未配置真实 OCR 时使用 `OCR_PROVIDER=off`:PDF 直接进入 `available`,不入 OCR 队列、不生成发票代码、号码、日期、购销方或金额。员工仍可把票据添加到已有明细并提交,财务必须查看 PDF 原件人工核对。 - 未配置真实 OCR 时使用 `OCR_PROVIDER=off`:PDF 直接进入 `available`,不入 OCR 队列、不生成发票代码、号码、日期、购销方或金额。员工仍可把票据添加到已有明细并提交,财务必须查看 PDF 原件人工核对。
- off 模式下文件 SHA-256 查重继续有效,依赖发票代码和号码的查重暂不可用。对当前小团队可短期接受;强制人工补录或接入腾讯 OCR 应另行规划,不能在紧急报销中临时增加门槛。 - off 模式下文件 SHA-256 查重继续有效,依赖发票代码和号码的查重暂不可用。对当前小团队可短期接受;强制人工补录或接入腾讯 OCR 应另行规划,不能在紧急报销中临时增加门槛。
- 既有 `InvoicePool.ocr_provider` 为非空枚举且默认 `tencent`,所以 off 模式的新记录仍可能带该历史默认标记。判定是否实际调用 OCR 必须同时看 provider 配置、`ocr_attempts`、请求/完成时间和 OCR 结果,不能只看 `ocr_provider`。 - 既有 `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` 若传入,必须属于当前报销单。 - `linkedItemId` 若传入,必须属于当前报销单。
- 草稿或驳回单中,已添加但尚未选择明细的发票可以直接选择本单明细。该动作必须由服务端在一个事务内校验报销参与人、单据状态、发票归属和明细归属,只更新明细关联;不得要求员工先移除再重新添加,也不得让发票短暂回到可添加状态。
明细匹配规则: 明细匹配规则:
@@ -153,7 +157,7 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 确实无法提供支付截图时,员工必须上传情况说明;财务可以退回补充,也可以在有其他充分证据时例外接受并留痕。不得伪造、补造或借用他人支付凭证。 - 确实无法提供支付截图时,员工必须上传情况说明;财务可以退回补充,也可以在有其他充分证据时例外接受并留痕。不得伪造、补造或借用他人支付凭证。
- OCR 只辅助识别发票字段、匹配和查重,不判断支付真实性、费用合理性或发票法律状态。财务复核时必须同时查看明细金额、PDF 原件和个人支付证明;三者不一致时退回更正或补充说明。 - OCR 只辅助识别发票字段、匹配和查重,不判断支付真实性、费用合理性或发票法律状态。财务复核时必须同时查看明细金额、PDF 原件和个人支付证明;三者不一致时退回更正或补充说明。
- 每张有效发票应关联到一条明确费用明细。一条明细只表达同一费用类型和同一笔业务支出;不同类型或彼此独立的支出应拆分,采购物品还应是一件或一批同类物品。连续跨天或跨期的同一笔业务支出(如多晚住宿、月度通讯费、按服务期计费)可以作为一条明细,并在说明中写清起止日期。 - 每张有效发票应关联到一条明确费用明细。一条明细只表达同一费用类型和同一笔业务支出;不同类型或彼此独立的支出应拆分,采购物品还应是一件或一批同类物品。连续跨天或跨期的同一笔业务支出(如多晚住宿、月度通讯费、按服务期计费)可以作为一条明细,并在说明中写清起止日期。
- 票据—明细门禁启用后,需票明细没有有效发票或申报金额高于关联票面合计时阻断提交;两项包干补助不参加票面比较。关闭门禁不会删除或解绑已经建立的关联关系。 - 票据—明细门禁启用后,已添加发票未选择明细、需票明细没有关联正式 PDF、已尝试 OCR 但票面金额仍无法识别,或在全部关联票据金额完整时申报金额高于票面合计,均阻断提交。未执行 OCR 的无金额 PDF 允许提交,但员工提交反馈和审批详情必须明确提示财务查看原件人工核对;两项包干补助不参加票据与票面比较。关闭门禁不会删除或解绑已经建立的关联关系。
- 财务付款凭证号、付款日期、付款渠道和付款回单属于审批通过后的财务处理,不是员工提交报销时填写的个人支付证明。 - 财务付款凭证号、付款日期、付款渠道和付款回单属于审批通过后的财务处理,不是员工提交报销时填写的个人支付证明。
### 差旅补助规定 ### 差旅补助规定
+206 -9
查看文件
@@ -33,6 +33,10 @@ const {
resolveEntityPrecheck, resolveEntityPrecheck,
} = require("../services/feeControl"); } = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence"); const { issueDocumentNumber } = require("../services/documentSequence");
const {
INVOICE_AMOUNT_EVIDENCE_STATUS,
resolveInvoiceAmountEvidence,
} = require("../services/invoiceAmountEvidence");
const { const {
PAYMENT_PROCESSING_STAGE_DEFINITIONS, PAYMENT_PROCESSING_STAGE_DEFINITIONS,
buildPaymentProcessingSemantics, buildPaymentProcessingSemantics,
@@ -103,6 +107,9 @@ const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREV
const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z
.object({ projectId: z.string().uuid().nullable() }) .object({ projectId: z.string().uuid().nullable() })
.strict(); .strict();
const REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA = z
.object({ linkedItemId: z.string().uuid() })
.strict();
function resolveSignedFileOrigin(config, request) { function resolveSignedFileOrigin(config, request) {
const configured = String(config.appOrigin ?? "") const configured = String(config.appOrigin ?? "")
@@ -2467,6 +2474,10 @@ async function reimbursementRoutes(fastify) {
mime_type: true, mime_type: true,
file_size: true, file_size: true,
linked_item_id: 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 } }, ocr_result: { select: { result_json: true } },
}, },
}, },
@@ -2657,6 +2668,28 @@ async function reimbursementRoutes(fastify) {
departmentId: reimbursement.department_id ?? null, departmentId: reimbursement.department_id ?? null,
strictDepartment: true, 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, { return sendData(reply, request, {
id: reimbursement.id, id: reimbursement.id,
@@ -2736,6 +2769,7 @@ async function reimbursementRoutes(fastify) {
mimeType: item.mime_type ?? null, mimeType: item.mime_type ?? null,
fileSize: item.file_size ?? null, fileSize: item.file_size ?? null,
linkedItemId: item.linked_item_id ?? null, linkedItemId: item.linked_item_id ?? null,
amountEvidenceStatus: invoiceAmountEvidenceById.get(item.id),
})), })),
pendingWorkflow: pendingInstance pendingWorkflow: pendingInstance
? { ? {
@@ -2755,6 +2789,7 @@ async function reimbursementRoutes(fastify) {
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows), approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
riskFlags: riskFlags.map(buildPersistedRiskFlag), riskFlags: riskFlags.map(buildPersistedRiskFlag),
invoiceGuardEnabled: invoiceGuardGate.enabled, 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( fastify.post(
"/reimbursements/:id/precheck", "/reimbursements/:id/precheck",
{ {
@@ -4016,7 +4162,14 @@ async function reimbursementRoutes(fastify) {
}, },
invoice_pool_items: { invoice_pool_items: {
where: { status: "bound" }, 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, departmentId: reimbursement.department_id ?? null,
strictDepartment: true, 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) { if (invoiceGuardGate.enabled) {
const unlinkedBoundInvoiceCount = await tx.invoicePool.count({ const unlinkedBoundInvoices = await tx.invoicePool.findMany({
where: { where: {
linked_reimbursement_id: reimbursementId, linked_reimbursement_id: reimbursementId,
linked_item_id: null, linked_item_id: null,
status: "bound", status: "bound",
}, },
select: { id: true },
}); });
const missingInvoiceItemIds = []; const missingInvoiceItemIds = [];
const amountUnavailableItems = [];
const amountExceededItems = []; const amountExceededItems = [];
for (const item of reimbursementItems) { for (const item of reimbursementItems) {
if (!item.expense_type.requires_invoice) continue; if (!item.expense_type.requires_invoice) continue;
const validInvoices = item.invoice_pool_items.filter( if (!item.invoice_pool_items.length) {
(invoice) => invoice.total_amount != null,
);
if (!validInvoices.length) {
missingInvoiceItemIds.push(item.id); missingInvoiceItemIds.push(item.id);
continue; 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), (sum, invoice) => sum.plus(invoice.total_amount),
new Prisma.Decimal(0), 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({ throw new AppError({
code: "VALID_001", code: "VALID_001",
message: "参数校验失败", message: "参数校验失败",
statusCode: 400, statusCode: 400,
details: { details: {
items: "invoice_item_guard_failed", items: "invoice_item_guard_failed",
unlinkedInvoiceCount: unlinkedBoundInvoiceCount, unlinkedInvoiceCount: unlinkedBoundInvoices.length,
unlinkedInvoiceIds: unlinkedBoundInvoices.map((invoice) => invoice.id),
missingInvoiceItemIds, missingInvoiceItemIds,
amountUnavailableItems,
amountExceededItems, amountExceededItems,
}, },
}); });
@@ -4420,6 +4615,8 @@ async function reimbursementRoutes(fastify) {
precheckDecision: authoritativePrecheck.decision, precheckDecision: authoritativePrecheck.decision,
standardWarnings, standardWarnings,
openedRiskFlagCount: openedRiskFlags.length, openedRiskFlagCount: openedRiskFlags.length,
invoiceAmountManualReviewRequired: manualReviewItemIds.length > 0,
manualReviewItemCount: manualReviewItemIds.length,
}, },
sideEffectsContext: { sideEffectsContext: {
reimbursementId, reimbursementId,
@@ -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,
};
@@ -41,6 +41,7 @@ function buildReimbursementRecord(overrides = {}) {
name: "打车费", name: "打车费",
category: "travel", category: "travel",
enabled: true, enabled: true,
requires_invoice: true,
}, },
}, },
], ],
@@ -56,6 +57,10 @@ function buildReimbursementRecord(overrides = {}) {
total_amount: "188.80", total_amount: "188.80",
original_name: "receipt.pdf", original_name: "receipt.pdf",
linked_item_id: "item-1", 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: [ workflow_instances: [
@@ -358,8 +363,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
totalAmount: "188.80", totalAmount: "188.80",
originalName: "receipt.pdf", originalName: "receipt.pdf",
linkedItemId: "item-1", linkedItemId: "item-1",
amountEvidenceStatus: "verified",
}, },
], ],
invoiceAmountManualReviewRequired: false,
approvalHistory: [ approvalHistory: [
{ {
id: "history-start", id: "history-start",
@@ -394,6 +401,47 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
await app.close(); 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 () => { it("returns null submittedAt and empty approvalHistory when reimbursement has never been submitted", async () => {
const reimbursement = buildReimbursementRecord({ const reimbursement = buildReimbursementRecord({
status: "draft", status: "draft",
@@ -102,6 +102,14 @@ async function buildApp(options = {}) {
? (options.unlinkedBoundInvoiceCount ?? 0) ? (options.unlinkedBoundInvoiceCount ?? 0)
: (options.boundInvoiceCount ?? 1) : (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: { reimbursementItem: {
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)), findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
@@ -409,6 +417,35 @@ describe("reimbursements routes expense standard integration", () => {
await app.close(); 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 () => { it("blocks required-invoice items without linked invoices when the guard is on", async () => {
const { app, tx } = await buildApp({ const { app, tx } = await buildApp({
invoiceGuardEnabled: true, invoiceGuardEnabled: true,
@@ -441,6 +478,112 @@ describe("reimbursements routes expense standard integration", () => {
await app.close(); 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 () => { it("blocks declared amounts above the linked invoice total when the guard is on", async () => {
const { app, tx } = await buildApp({ const { app, tx } = await buildApp({
invoiceGuardEnabled: true, invoiceGuardEnabled: true,
@@ -462,6 +605,33 @@ describe("reimbursements routes expense standard integration", () => {
await app.close(); 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 () => { it("recomputes a stale total before selecting the approval rule", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" }); const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
@@ -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();
});
});
@@ -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);
});
});