比较提交

...
6 次代码提交
共修改 27 个文件,包含 1951 行新增和 65 行删除
@@ -30,10 +30,12 @@ duplicate detection or attachment behavior.
require the same own-or-`expense.view_all` guard as reimbursement detail reads
before invoking the precheck service; an ordinary nonparticipant must receive
403 without service-side evaluation.
- 取消或放宽必填校验时,必须继续审计该校验之后的代码是否依赖它提供的
非空、类型或状态保证,并为生产真实存在的空值路径补回归测试。不得只
删除前置错误分支;可空字段的 `.trim()`、`.length`、字符串拼接和传给
非空参数的调用都应逐处确认。
- 增加、取消或放宽强制校验时,必须同时核对既有降级合同、兼容模式和该
校验之后的代码假设。取消或放宽时审计下游是否依赖它提供的非空、类型
或状态保证,并为生产真实存在的空值路径补回归测试;增加时确认不会把
`off`、历史数据或其他已承诺可用的降级路径变成不可提交。不得只修改
前置错误分支;可空字段的 `.trim()`、`.length`、字符串拼接和传给非空
参数的调用都应逐处确认。
## Scenario: OCR Disabled Without Mock Data
@@ -994,6 +996,10 @@ await selectApprovalRule(reimbursement.total_amount);
- Reimbursement detail returns `invoiceGuardEnabled` for the form department.
- `POST /api/invoice-pool/:id/bind` requires `linkedItemId` while the guard is
enabled; `POST /api/reimbursements/:id/submit` enforces the final gate.
- `PATCH /api/reimbursements/:id/invoices/:invoiceId/item-link` atomically links
one bound-but-unlinked invoice to an item in the same editable form.
- Detail invoice projection adds `amountEvidenceStatus`; form detail and submit
success add the redacted manual-review summary without exposing OCR payloads.
### 3. Contracts
@@ -1017,16 +1023,36 @@ await selectApprovalRule(reimbursement.total_amount);
- With B2 on, binding requires an existing item in the same reimbursement and
may not reach the legacy auto-create branch. If an upload waits for OCR, keep
the user-selected item ID with the pending invoice and send it when OCR later
makes the invoice bindable. Submit blocks any bound invoice with no item
link, each required-invoice item with no amount-bearing bound invoice, and
each declared amount greater than its linked invoice total.
- Sum and compare money with `Prisma.Decimal`. Invoice-free items are excluded.
A lower declared amount remains allowed for finance to reconcile.
makes the invoice bindable. Submit separates PDF presence from amount
evidence: a required item without a bound PDF blocks as missing evidence;
a bound PDF with attempted OCR but no amount blocks as recognition failure.
- Classify one invoice from the explicit minimum fields `total_amount`,
`ocr_attempts`, request/completion timestamps and the bounded OCR error fact.
Non-null amount is `verified`; null amount with no execution fact is
`manual_review`; null amount with any execution fact is `ocr_failed`.
Never infer execution from the historical non-null `ocr_provider` default.
- Sum and compare money with `Prisma.Decimal` only when every bound invoice for
the required item is `verified`. If any invoice is `manual_review`, allow
submission and mark the item/form for finance review; a mixed verified plus
manual-review set must not be compared using only the known partial sum.
Invoice-free items are excluded. A lower declared amount remains allowed for
finance to reconcile.
- A bound invoice with `linked_item_id=null` remains visible and recoverable.
The narrow item-link write locks invoice and form, reuses the owner/submitter
participant check, accepts only `draft|rejected`, verifies both objects belong
to the form, updates only `linked_item_id`, and writes audit in one
transaction. Failure and concurrent state drift produce zero writes.
- PC and mobile map structured guard details to a safe Chinese summary plus
item/invoice-local recovery text. IDs locate current DTO objects only and are
never rendered; dynamic summaries use alert semantics and toast is not the
sole feedback. Finance read-only detail keeps manual-review markers visible.
- With B2 off, retain legacy auto-match/create compatibility. Keep the legacy
whole-form invoice gate only when at least one item requires an invoice;
allowance-only forms remain invoice-free because that is an always-active B1
policy. Turning the flag off never deletes or unbinds relationships already
written while it was on.
written while it was on. The manual-review projection and submit-success
notice remain active for linked no-OCR PDFs even when B2 blocking is off;
the flag controls blocking, not whether finance is told to inspect originals.
- Form maintenance and invoice bind/unbind remain owner-or-submitter writes;
`expense.view_all` and admin roles remain read-only for another employee's
reimbursement.
@@ -1040,11 +1066,23 @@ await selectApprovalRule(reimbursement.total_amount);
`required_when_invoice_guard_enabled`; an item outside the form remains
`item_not_in_reimbursement` with zero writes.
- Guard-on submit with unlinked bound invoices -> `invoice_item_guard_failed`
plus `unlinkedInvoiceCount`.
- Required item without an amount-bearing bound invoice -> same error plus
plus `unlinkedInvoiceCount` and locating `unlinkedInvoiceIds`.
- Required item without any bound PDF -> same error plus
`missingInvoiceItemIds`.
- Bound PDF with an OCR execution fact but no amount -> same error plus
`amountUnavailableItems`; tell the user to retry recognition or contact
finance, not to upload a duplicate PDF.
- Bound PDF with no OCR execution fact and no amount -> submit succeeds with
`invoiceAmountManualReviewRequired=true`; approval detail keeps the same
manual-review warning until comparable evidence exists.
- Mixed verified and manual-review invoices -> submit succeeds for manual
review without comparing the declared amount to the known partial sum.
- Declared amount above linked total -> same error plus entries containing only
`itemId`, canonical `declaredAmount` and `invoiceTotal`.
`itemId`, canonical `declaredAmount` and `invoiceTotal`, but only when every
linked invoice amount is verified.
- Item-link by a nonparticipant, on a non-editable form, across forms, for an
already-linked/non-bound invoice or after a concurrent state change -> reject
with zero link/audit writes.
- Invoice-free allowance-only form with no invoice -> may submit with the guard
either on or off when all other existing checks pass.
- Env hard-off or DB force-off -> B2 item-level blocks are skipped; the legacy
@@ -1054,6 +1092,11 @@ await selectApprovalRule(reimbursement.total_amount);
- Good: one required item explicitly links several same-purpose invoices whose
total covers the declaration; submit passes with the guard on.
- Good: five required items each link one verified invoice and submit normally;
one off-mode PDF with no OCR execution submits with an explicit finance
manual-review marker.
- Good: a CNY 1,000 item linked to one verified CNY 600 invoice and one
manual-review invoice does not produce a false CNY 400 overage.
- Good: a positive meal allowance-only form has no invoice and passes with the
guard either on or off.
- Base: guard off preserves an older draft and its auto-created link; later
@@ -1064,6 +1107,9 @@ await selectApprovalRule(reimbursement.total_amount);
item while explicit selection is required, use JavaScript floating-point
sums for money, let repeated init erase finance account mappings, or 删除前置
必填校验后继续对可空值直接调用 `.trim()`。
- Bad: call a PDF with failed OCR “missing invoice”, use only a known partial
amount in a mixed set, expose UUIDs/raw details, or implement recovery as
client-side unbind followed by bind.
### 6. Tests Required
@@ -1075,14 +1121,20 @@ await selectApprovalRule(reimbursement.total_amount);
disabled legacy types, default-off flag and account-code preservation.
- Item/submit route tests 覆盖 assets 创建 omitted→null、编辑 null+omitted→null、
有值+omitted→保留、显式清空、切换非 assets 清空和无依据提交,并继续覆盖
invoice-free zero invoice、missing link、unlinked bound invoice、
declared-over-invoice 与 participant/nonparticipant side effects。
invoice-free zero invoice、missing PDF、unlinked bound invoice、OCR failed、
manual review、verified+manual mixed evidence、five-item/five-invoice normal
submit、declared-over-invoice 与 participant/nonparticipant side effects。
- Bind tests cover flag on/off, missing/foreign/successful explicit links and
prove the guard-on path does not create a reimbursement item.
- Atomic item-link tests cover success, participant ownership, editable status,
invoice/form/item ownership, existing links, non-bound state and concurrent
zero-write behavior including audit.
- PC/mobile Playwright covers asset 登记导向说明、批准依据输入/展示均不存在且
保存 payload omission、CNY 2,000 warning、allowance-only submit projection、
disabled add action until every
selected invoice has an item, and pending-OCR binding retaining that item.
selected invoice has an item, pending-OCR binding retaining that item, four
safe guard messages with local recovery actions, alert semantics, atomic
unlinked-invoice recovery and finance manual-review visibility.
- Run full backend Vitest with the pinned BIFF8 Python dependencies, Prisma
validate/generate check, frontend typecheck/build, focused Playwright,
process/docs/Trellis governance and `git diff --check`.
@@ -1109,7 +1161,7 @@ asset_prior_approval_basis: targetApprovalBasis.trim();
#### Correct
```js
if (item.expense_type.requires_invoice) {
if (item.expense_type.requires_invoice && linkedInvoices.every(hasVerifiedAmount)) {
const total = linkedInvoices.reduce(
(sum, invoice) => sum.plus(invoice.total_amount),
new Prisma.Decimal(0),
@@ -20,7 +20,9 @@
"subtasks": [],
"children": [
"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-b2-submit-error-ocr-fallback"
],
"parent": null,
"relatedFiles": [
@@ -38,4 +40,4 @@
],
"notes": "本地实施和只读专项复核已完成;B2 env/DB 双闸仍默认关闭,未部署、未应用生产 migration、未写生产配置。2,000 元/条明细的选类提示阈值已批准。会计科目映射由财务负责人在 B1 生产启用前提供,目标回填日期 2026-09-05;未取得正式科目表时保持 null,不由开发人员猜测。生产只读审计确认 0014/0016/0017/0018 均需退回修正;顺序改为先部署 B1、隔离验证并启用 B2,再由真实财务统一退回。B2 不校验日期粒度,0014 必须按 5 个发票日期拆分并由财务人工复核。部署合同见子任务 08-30-reimbursement-expense-policy-invoice-guard-deploy。B3 财务一页式及行内费用标准提示恢复仍为后续任务。",
"meta": {}
}
}
@@ -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;测试必须覆盖文本、告警语义和问题附近的恢复按钮。
- 2026-08-31 核验确认当前会话、用户级、平台级和项目级均无可调用的 `frontend-design` skill。经用户明确批准,本轮以可用的 `ui-ux-pro-max` 规则库和 Playwright 真实渲染作为替代部署门,并明确记录其不等同于 `frontend-design`:普通文字对比度不低于 4.5:1,错误不只靠颜色表达,错误聚焦到具体明细/发票,移动触控目标不小于 44px,PC/390px 手机/667px 横屏无横向溢出,sticky 主动作不遮挡内容。`frontend-design` 引用悬空属于后续治理债,本 task 不顺带修改 AGENTS 或产品设计事实源。
@@ -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 不改代码。
- [x] 用户明确批准本轮无障碍机械修正后直接提交并回填元数据;业务提交使用中文并引用 `08-30-b2-submit-error-ocr-fallback`。新候选须经用户最终 diff 复核后才可 push,部署仍需独立授权。
- [x] 部署前视觉门:确认 `frontend-design` 当前不可用后,经用户批准以 `ui-ux-pro-max` + Playwright 真实渲染替代。PC/390px 手机/667px 横屏无横向溢出,移动选择框与确认按钮均为 44px,sticky 底栏贴合视口且不遮挡问题卡片,提交失败后焦点落到首个具体问题;检查发现 `red-600` / `red-50` 对比度约 4.42:1,已将 PC 与移动报销详情页内所有同类错误块统一为 `red-700` / `red-50`(约 5.92:1),并为共享操作错误补齐 `role=alert` / `aria-live=assertive`,复跑通过。该替代门不冒充 `frontend-design`。全局错误语义色 token 化与视觉裁决 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": "8aed72d",
"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 不可事后恢复。frontend-design 当前不可用,本轮经批准采用 ui-ux-pro-max + Playwright 替代门;全局错误语义色 token 化及视觉裁决 skill 引用修订作为后续治理债处理。",
"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 @@
{"_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,63 @@
# 同步员工报销填报指南与 B2 规则
## Goal
让员工侧主手册准确反映已经全局生效的票据—明细门禁和人工填报粒度要求,避免员工按旧文案误以为“金额超票面后写说明即可提交”,同时允许住宿、月度通讯费、服务期费用等连续跨天或跨期支出作为同一笔业务支出填一条明细。
## Background
- `docs/员工快速上手.md:41-68` 是当前员工使用说明;第 6 节已有 PDF-only、图片走附件、支付截图、补助和差旅专项规则,不应重复另建“报销填报说明”。
- `docs/员工快速上手.md:65` 仍写“原则上不得超过有效发票金额,差异必须说明”。B2 全局生效后,需要发票的明细金额高于关联发票票面合计会被硬阻断,仅写说明不能提交。
- B2 只校验发票是否关联、需票明细是否有金额有效的关联发票、明细金额是否高于关联票面合计;它不校验费用类型、发生日期或业务支出粒度。明细拆分属于员工填报要求和财务人工复核事项,不得宣称系统会自动提醒或阻断。
- 发生日期是实际费用发生或消费日期,不是报销提交日期,也不能一律由发票日期替代;与发票日期不一致时应说明原因。
- 连续跨天或跨期的同一笔业务支出(如多晚住宿、月度通讯费、按服务期计费)可以作为一条明细,并在说明中写清起止日期。
- 只改员工手册会产生事实源冲突:`docs/报销发票附件规则.md:151` 仍保留“原则上不得超过、差异说明”,`:155` 仍把“一条明细”绝对限定为同一发生日期;`:156` 又已经记录 B2 超票面硬阻断。
## Scope
- 员工侧主手册中的通用报销填报规则与差旅指南第 8 条。
- 报销发票附件事实源中与金额硬门禁、连续跨期粒度直接冲突的两条现行口径。
- Trellis task 元数据与文档一致性验证。
## Requirements
### R1 员工手册增加最小通用规则
- 在 `docs/员工快速上手.md` 第 6 节、现有“差旅报销一页指南”之前增加“通用报销填报规则”。
- 只补现有差旅段落没有覆盖的内容:同一费用类型和同一笔业务支出的明细粒度、连续跨期例外、实际发生日期口径、逐票关联、需票明细的金额硬门禁,以及退回后“先移除发票—再改明细—最后重新添加并选择明细”的顺序。
- 图片、截图和拍照材料不得从正式发票入口上传;属于支付证明、订单或情况说明时,通过“上传附件”提交。不得写成图片材料一律无效。
- 明确粒度要求由员工遵守、财务复核,B2 不自动比对日期或判断拆分是否正确。
### R2 纠正差旅指南第 8 条
- 把 `docs/员工快速上手.md:65` 改为:需要发票的实报实销明细金额不得高于关联发票票面合计,超出时系统阻止提交;优惠券、多人合并开票、部分自费等申报金额低于票面的情况仍需说明。
- 保留已退款、公司直接支付、他人承担金额不得重复报销,以及缺支付截图时由财务决定是否接受其他证据、不得补造凭证的现有口径。
### R3 最小同步主事实源
- `docs/报销发票附件规则.md` 是产品/财务事实源而非员工手册,不复制员工操作说明。
- 其 `:151/:155` 与本任务已确认的新口径冲突;同一 task 内只做两处最小事实纠偏,避免员工手册与主事实源互相矛盾:金额规则改为需票明细超关联票面硬阻断,粒度规则补充连续跨天/跨期同一业务支出的例外及起止日期说明。
- 不修改 `docs/trialrun/R7用户手册与FAQ.md`;该文档仍是草案,不是当前员工主说明。
## Acceptance Criteria
- [x] 不新建说明文档;员工通用规则落在现有 `docs/员工快速上手.md` 第 6 节且不重复现有差旅指南。
- [x] 通用规则正确区分实际发生日期、发票日期和报销提交日期,并明确连续跨天/跨期的同一业务支出可以一条明细记录起止日期。
- [x] 文档明确 B2 的三项系统门禁及其日期/粒度非目标;员工不会被告知“系统会自动检查拆分是否正确”。
- [x] 需要发票的明细金额高于关联票面合计被表述为硬阻断;说明仅用于允许提交的票面高于申报金额等差异场景。
- [x] 退回修改顺序、正式 PDF 入口和图片附件路径表述准确。
- [x] `docs/员工快速上手.md` 与 `docs/报销发票附件规则.md` 不保留互相矛盾的生效规则。
- [x] `node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-trellis-governance.mjs` 和 `git diff --check` 全部通过。
## Out of Scope
- 不修改应用代码、自动化测试或运行配置,不执行线上写入或发布。
- 不新建报销说明文档,不改 `docs/trialrun/R7用户手册与FAQ.md`。
- 不处理费用类型命名、会计科目映射、固定资产政策或四张真人报销的退回操作。
## Test Plan
- 逐条对照 B2 已生效合同,检查员工手册与报销事实源的金额、关联、免票和粒度边界不存在矛盾。
- 运行 `node scripts/qa/audit-docs.mjs --check-registry`。
- 运行 `node scripts/qa/check-trellis-governance.mjs`。
- 运行 `git diff --check`。
@@ -0,0 +1,29 @@
{
"id": "reimbursement-employee-guide-b2-sync",
"name": "reimbursement-employee-guide-b2-sync",
"title": "同步员工报销填报指南与 B2 规则",
"description": "同步员工快速上手中的通用报销填报规则与已全局生效的 B2 门禁,纠正差旅金额旧口径,并处理连续跨期支出的明细粒度表达。",
"status": "completed",
"dev_type": null,
"scope": "employee reimbursement guide; reimbursement documentation consistency",
"package": "docs",
"priority": "P1",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-30",
"completedAt": "2026-08-30",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-29-reimbursement-expense-policy-invoice-guard",
"relatedFiles": [
"docs/员工快速上手.md",
"docs/报销发票附件规则.md"
],
"notes": "文档专项目标,不改应用、不执行线上发布或写入;四张真人报销的退回与通知不依赖本任务。",
"meta": {}
}
+61
查看文件
@@ -10,6 +10,35 @@ function readStringList(value: unknown): string[] {
.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> = {
portal_home_v1_disabled: "工作台首页当前未启用,请联系管理员检查环境开关与功能开关配置。",
announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
@@ -131,6 +160,38 @@ export function mapApiErrorMessage(
return "所有报销明细金额都必须大于 0,请修改后再提交。";
}
if (details?.items === "invoice_item_guard_failed") {
const messages: string[] = [];
const rawUnlinkedCount = details.unlinkedInvoiceCount;
const unlinkedCount = typeof rawUnlinkedCount === "number"
&& Number.isInteger(rawUnlinkedCount)
&& rawUnlinkedCount >= 0
? rawUnlinkedCount
: 0;
const missingCount = readStringList(details.missingInvoiceItemIds).length;
const unavailableCount = countGuardAmountUnavailableItems(details.amountUnavailableItems);
const exceededCount = countGuardAmountExceededItems(details.amountExceededItems);
if (unlinkedCount > 0) messages.push(`${unlinkedCount} 张发票未选择报销明细`);
if (missingCount > 0) messages.push(`${missingCount} 条需票明细尚未关联发票`);
if (unavailableCount > 0) messages.push(`${unavailableCount} 条明细的发票金额未识别`);
if (exceededCount > 0) messages.push(`${exceededCount} 条明细的申报金额高于发票合计`);
return messages.length
? `票据检查未通过:${messages.join(";")}。请按下方提示处理。`
: "票据检查未通过,请核对每条明细与发票的关联和金额后重试。";
}
if (details?.invoice === "invalid_unlinked_item_state") {
return "这张发票的关联状态已变化,请刷新后重试。";
}
if (details?.reimbursement === "invalid_status_for_invoice_item_link") {
return "当前报销单状态已变化,不能再关联发票,请刷新页面。";
}
if (details?.linkedItemId === "item_not_in_reimbursement") {
return "所选明细已不存在或不属于本单,请刷新后重新选择。";
}
const passwordErrors = readStringList(details?.password);
if (passwordErrors.length) {
return `新密码不符合要求:${passwordErrors.join(";")}。`;
+19
查看文件
@@ -194,6 +194,7 @@ export type ReimbursementDetail = {
mimeType: string | null;
fileSize: number | null;
linkedItemId: string | null;
amountEvidenceStatus: "verified" | "manual_review" | "ocr_failed";
}>;
attachments: Array<{
id: string;
@@ -225,6 +226,7 @@ export type ReimbursementDetail = {
approvalHistory: ReimbursementApprovalHistoryItem[];
riskFlags: RiskFlag[];
invoiceGuardEnabled?: boolean;
invoiceAmountManualReviewRequired?: boolean;
items: ReimbursementItem[];
};
@@ -391,6 +393,8 @@ export type ReimbursementSubmitResponse = {
precheckDecision: "pass" | "warn" | "manual_review";
standardWarnings: ExpenseStandardCheck[];
openedRiskFlagCount: number;
invoiceAmountManualReviewRequired: boolean;
manualReviewItemCount: number;
};
function toQueryString(query: Record<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) {
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/withdraw`, {
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,
updateReimbursementItem,
uploadReimbursementAttachments,
linkReimbursementInvoiceItem,
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
@@ -81,6 +82,15 @@ import { cn } from "../lib/utils";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
import {
focusFirstInvoiceGuardIssue,
invoiceGuardInvoiceMessages,
invoiceGuardIssueCount,
invoiceGuardItemMessages,
parseReimbursementInvoiceGuardIssues,
resolveInvoiceGuardIssuesAfterLink,
type ReimbursementInvoiceGuardIssues,
} from "../lib/reimbursementInvoiceGuard";
type ItemFormState = {
mode: "create" | "edit";
@@ -167,6 +177,9 @@ export function MobileReimbursementDetailPage() {
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
const [itemError, setItemError] = 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 [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
@@ -369,6 +382,7 @@ export function MobileReimbursementDetailPage() {
await invalidateDetail();
setItemForm(null);
setItemError("");
setInvoiceGuardIssues(null);
toast.success("明细已保存");
},
onError: (error) => {
@@ -408,15 +422,23 @@ export function MobileReimbursementDetailPage() {
const submitMutation = useMutation({
mutationFn: () => submitReimbursement(reimbursementId),
onSuccess: async () => {
onSuccess: async (data) => {
await invalidateDetail();
setSubmitConfirmPrecheck(null);
setActionError("");
toast.success("已提交审批");
setInvoiceGuardIssues(null);
toast.success(
data.invoiceAmountManualReviewRequired
? "已提交,票面金额将由财务核对"
: "已提交审批",
);
},
onError: (error) => {
const message = resolveErrorMessage(error);
const issues = parseReimbursementInvoiceGuardIssues(error);
setActionError(message);
setInvoiceGuardIssues(issues);
if (issues) focusFirstInvoiceGuardIssue();
toast.error(message);
},
});
@@ -464,6 +486,7 @@ export function MobileReimbursementDetailPage() {
setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
);
if (succeededIds.length) setInvoiceGuardIssues(null);
if (!failed.length) {
setInvoiceActionError("");
@@ -531,6 +554,7 @@ export function MobileReimbursementDetailPage() {
onSuccess: async () => {
await invalidateDetail();
setInvoiceActionError("");
setInvoiceGuardIssues(null);
toast.success("发票已移除");
},
onError: (error) => {
@@ -540,6 +564,35 @@ export function MobileReimbursementDetailPage() {
},
});
const linkInvoiceItemMutation = useMutation({
mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) =>
linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId),
onSuccess: async (_result, variables) => {
await invalidateDetail();
setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink(
previous,
variables.invoiceId,
variables.linkedItemId,
));
setInvoiceItemSelections((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
return next;
});
setInvoiceItemLinkErrors((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
return next;
});
toast.success("发票已关联到报销明细");
},
onError: (error, variables) => {
const message = resolveErrorMessage(error, "关联明细失败,请刷新后重试");
setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message }));
toast.error(message);
},
});
const deleteAvailableInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => archiveInvoice(invoiceId),
onSuccess: async (_result, invoiceId) => {
@@ -805,6 +858,7 @@ export function MobileReimbursementDetailPage() {
try {
setActionError("");
setInvoiceGuardIssues(null);
const precheck = await precheckReimbursement(reimbursementId);
const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") {
@@ -871,7 +925,7 @@ export function MobileReimbursementDetailPage() {
if (!reimbursementId) {
return (
<PageScaffold>
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-600">缺少报销单 ID。</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">缺少报销单 ID。</div>
</PageScaffold>
);
}
@@ -889,7 +943,7 @@ export function MobileReimbursementDetailPage() {
if (detailQuery.isError || !detail) {
return (
<PageScaffold className="space-y-4">
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-600">
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{resolveErrorMessage(detailQuery.error)}
</div>
<Button asChild variant="secondary">
@@ -1023,6 +1077,20 @@ export function MobileReimbursementDetailPage() {
</div>
</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">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -1034,7 +1102,7 @@ export function MobileReimbursementDetailPage() {
</div>
{itemError ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{itemError}</div>
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{itemError}</div>
) : null}
{itemForm ? (
@@ -1114,7 +1182,15 @@ export function MobileReimbursementDetailPage() {
<div className="mt-3 space-y-2">
{detail.items.length ? (
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="min-w-0">
<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) ? (
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
) : 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 ? (
<div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
@@ -1172,6 +1257,12 @@ export function MobileReimbursementDetailPage() {
</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 ? (
<div className="mt-3 space-y-2">
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
@@ -1220,7 +1311,7 @@ export function MobileReimbursementDetailPage() {
) : null}
{invoiceActionError ? (
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{invoiceActionError}
</p>
) : null}
@@ -1273,8 +1364,21 @@ export function MobileReimbursementDetailPage() {
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
const guardMessages = invoiceGuardInvoiceMessages(
invoiceGuardIssues,
invoice.id,
invoice.linkedItemId,
);
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
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"
@@ -1298,6 +1402,64 @@ export function MobileReimbursementDetailPage() {
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
</span>
</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 ? (
<Button
className="mt-3 w-full"
@@ -1362,7 +1524,11 @@ export function MobileReimbursementDetailPage() {
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
{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-700"
role="alert"
aria-live="assertive"
>
{actionError}
</div>
) : null}
@@ -1428,7 +1594,7 @@ export function MobileReimbursementDetailPage() {
</div>
{invoiceActionError ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{invoiceActionError}
</div>
) : null}
@@ -1540,7 +1706,7 @@ export function MobileReimbursementDetailPage() {
>
{invoiceLibraryLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{invoiceLibraryError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{resolveErrorMessage(invoiceLibraryError)}
</div>
) : null}
+175 -20
查看文件
@@ -22,6 +22,7 @@ import {
uploadReimbursementAttachments,
withdrawReimbursement,
linkReimbursementProject,
linkReimbursementInvoiceItem,
type ReimbursementItem,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
@@ -95,6 +96,14 @@ import {
type ItemEditorSaveData,
} from "./reimbursement-detail/ReimbursementItemEditor";
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 SelectedInvoiceMap = Record<string, string>;
@@ -111,6 +120,9 @@ export function ReimbursementDetailPage() {
const queryClient = useQueryClient();
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 [itemError, setItemError] = useState("");
@@ -239,22 +251,34 @@ export function ReimbursementDetailPage() {
return withdrawReimbursement(reimbursementId, params.comment);
}
},
onSuccess: async (_data, params) => {
onSuccess: async (data, params) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
]);
setActionError("");
setInvoiceGuardIssues(null);
if (params.action === "submit") {
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] ?? "操作成功");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "操作失败,请稍后重试");
const issues = parseReimbursementInvoiceGuardIssues(error);
setActionError(message);
setInvoiceGuardIssues(issues);
if (issues) focusFirstInvoiceGuardIssue();
toast.error(message);
},
});
@@ -306,6 +330,7 @@ export function ReimbursementDetailPage() {
]);
setItemEditor(null);
setItemError("");
setInvoiceGuardIssues(null);
toast.success("明细已保存");
},
onError: (error) => {
@@ -417,6 +442,7 @@ export function ReimbursementDetailPage() {
setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
);
if (succeededIds.length) setInvoiceGuardIssues(null);
if (!failedResults.length) {
setInvoiceActionError("");
@@ -497,6 +523,7 @@ export function ReimbursementDetailPage() {
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
]);
setInvoiceActionError("");
setInvoiceGuardIssues(null);
toast.success("发票已移除");
},
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({
mutationFn: async (files: File[]) => {
const ownerEmployeeId = detailQuery.data?.ownerId;
@@ -849,6 +905,7 @@ export function ReimbursementDetailPage() {
try {
setActionError("");
setInvoiceGuardIssues(null);
if (!hasCompleteTravelSummary(detail)) {
const message = "请先补全出差事由、地点和起止日期";
setActionError(message);
@@ -1157,8 +1214,10 @@ export function ReimbursementDetailPage() {
{actionError ? (
<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-700"
data-testid="reimbursement-action-error"
role="alert"
aria-live="assertive"
>
{actionError}
</p>
@@ -1169,7 +1228,7 @@ export function ReimbursementDetailPage() {
</p>
) : null}
{hasBlockingPrecheck ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
请修改超标明细后提交。
</p>
) : null}
@@ -1216,7 +1275,7 @@ export function ReimbursementDetailPage() {
{itemError ? (
<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-700"
data-testid="reimbursement-item-error"
>
{itemError}
@@ -1243,9 +1302,13 @@ export function ReimbursementDetailPage() {
detail.items.map((item) => (
<TableRow
data-testid="reimbursement-item-row"
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
key={item.id}
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
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"
: warnStandardChecks.some((check) => check.itemId === item.id)
? "bg-amber-50/70"
@@ -1271,6 +1334,15 @@ export function ReimbursementDetailPage() {
{check.exceeded ? `,超出 ${formatMoney(check.exceededAmount)}` : ""}
</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>
<div>{item.description ?? "-"}</div>
@@ -1399,20 +1471,36 @@ export function ReimbursementDetailPage() {
{invoiceActionError ? (
<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-700"
data-testid={TEST_IDS.reimbursement.invoiceActionError}
>
{invoiceActionError}
</p>
) : 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}>
{detail.invoices.map((invoice) => {
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
const guardMessages = invoiceGuardInvoiceMessages(
invoiceGuardIssues,
invoice.id,
invoice.linkedItemId,
);
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
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"
@@ -1432,16 +1520,83 @@ export function ReimbursementDetailPage() {
<span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{canUnbindInvoices ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
{(guardMessages.length
|| invoice.amountEvidenceStatus === "manual_review"
|| invoice.amountEvidenceStatus === "ocr_failed"
|| canUnbindInvoices) ? (
<div className="w-full space-y-2 md:w-72">
{guardMessages.map((message) => (
<p
key={message}
className="rounded-md border border-red-200 bg-white px-3 py-2 text-xs leading-5 text-red-700"
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}
</div>
);
@@ -1607,7 +1762,7 @@ export function ReimbursementDetailPage() {
</div>
{invoiceActionError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{invoiceActionError}
</p>
) : null}
@@ -1616,7 +1771,7 @@ export function ReimbursementDetailPage() {
{bindableInvoicesQuery.isLoading ? (
<p className="text-sm text-zinc-500">正在加载可添加发票...</p>
) : bindableInvoicesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可添加发票失败")}
</p>
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
@@ -40,8 +40,10 @@ export function ReimbursementActions({
<div className="space-y-4">
{actionError ? (
<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-700"
data-testid="reimbursement-action-error"
role="alert"
aria-live="assertive"
>
{actionError}
</p>
@@ -107,6 +107,7 @@ function buildInvoice() {
mimeType: "application/pdf",
fileSize: 4096,
linkedItemId: "item-r111" as string | null,
amountEvidenceStatus: "verified" as "verified" | "manual_review" | "ocr_failed",
};
}
@@ -188,7 +189,11 @@ function buildReimbursementState() {
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
itemSaveRequests: [] as Array<Record<string, unknown>>,
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,
invoiceAmountManualReviewRequired: false,
detailItems: null as null | Array<Record<string, unknown>>,
};
}
@@ -237,6 +242,7 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
approvalHistory: [],
riskFlags: [],
invoiceGuardEnabled: state.invoiceGuardEnabled,
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
items: state.detailItems ?? [
{
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") {
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;
}
@@ -855,6 +915,153 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect.poll(() => state.submitRequestCount).toBe(1);
});
test("B2 提交错误在 PC/移动就近呈现,未归属发票可原子选择明细", async ({ page }) => {
const state = buildReimbursementState();
state.invoiceGuardEnabled = true;
state.detailItems = [
{
id: "item-missing-r111",
expenseTypeId: "expense-type-r111",
expenseDate: "2026-05-08",
amount: "200.00",
description: "缺少发票的明细",
expenseType: { ...buildExpenseTypes().items[0] },
},
{
id: "item-ocr-r111",
expenseTypeId: "expense-type-r111",
expenseDate: "2026-05-08",
amount: "300.00",
description: "金额识别失败的明细",
expenseType: { ...buildExpenseTypes().items[0] },
},
{
id: "item-over-r111",
expenseTypeId: "expense-type-r111",
expenseDate: "2026-05-08",
amount: "1000.00",
description: "金额超过发票的明细",
expenseType: { ...buildExpenseTypes().items[0] },
},
{
id: "item-manual-r111",
expenseTypeId: "expense-type-r111",
expenseDate: "2026-05-08",
amount: "400.00",
description: "人工核对票面金额的明细",
expenseType: { ...buildExpenseTypes().items[0] },
},
];
const ocrFailedInvoice = {
...buildInvoice(),
id: "77777777-7777-4777-8777-777777777777",
invoiceNumber: "OCR-FAILED",
originalName: "金额未识别.pdf",
totalAmount: null,
linkedItemId: "item-ocr-r111",
amountEvidenceStatus: "ocr_failed" as const,
};
const amountOverInvoice = {
...buildInvoice(),
id: "88888888-8888-4888-8888-888888888888",
invoiceNumber: "AMOUNT-OVER",
originalName: "金额不足.pdf",
totalAmount: "600.00",
linkedItemId: "item-over-r111",
};
const manualReviewInvoice = {
...buildInvoice(),
id: "99999999-9999-4999-8999-999999999999",
invoiceNumber: "MANUAL-REVIEW",
originalName: "待人工核对.pdf",
totalAmount: null,
linkedItemId: "item-manual-r111",
amountEvidenceStatus: "manual_review" as const,
};
state.invoices = [buildUnlinkedInvoice(), ocrFailedInvoice, amountOverInvoice, manualReviewInvoice];
state.invoiceAmountManualReviewRequired = true;
state.submitGuardDetails = {
items: "invoice_item_guard_failed",
unlinkedInvoiceCount: 1,
unlinkedInvoiceIds: [unlinkedInvoiceId],
missingInvoiceItemIds: ["item-missing-r111"],
amountUnavailableItems: [{
itemId: "item-ocr-r111",
invoiceIds: [ocrFailedInvoice.id],
}],
amountExceededItems: [{
itemId: "item-over-r111",
declaredAmount: "1000.00",
invoiceTotal: "600.00",
}],
};
await mockApis(page, state);
await login(page);
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-submit").click();
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
await expect(page.getByText("关联发票的票面金额未识别,请重试识别或联系财务处理。")).toBeVisible();
await expect(
page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
).toBeVisible();
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0);
await expect(page.getByText("item-missing-r111", { exact: false })).toHaveCount(0);
await page.getByLabel("选择报销明细").selectOption("item-missing-r111");
state.invoiceItemLinkError = { invoice: "invalid_unlinked_item_state" };
await page.getByRole("button", { name: "确认关联" }).click();
await expect(
page.getByTestId("reimbursement-invoices").getByText("这张发票的关联状态已变化,请刷新后重试。"),
).toBeVisible();
expect(state.invoices.find((invoice) => invoice.id === unlinkedInvoiceId)?.linkedItemId).toBeNull();
state.invoiceItemLinkError = null;
await page.getByRole("button", { name: "确认关联" }).click();
await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{
invoiceId: unlinkedInvoiceId,
linkedItemId: "item-missing-r111",
}, {
invoiceId: unlinkedInvoiceId,
linkedItemId: "item-missing-r111",
}]);
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toHaveCount(0);
await expect(
page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
).toBeVisible();
const structuredGuardDetails = state.submitGuardDetails;
state.submitGuardDetails = {
items: "invoice_item_guard_failed",
unlinkedInvoiceCount: "invalid-count",
unlinkedInvoiceIds: [123],
missingInvoiceItemIds: [{ id: "internal-only" }],
amountUnavailableItems: [{ itemId: null, invoiceIds: [unlinkedInvoiceId] }],
amountExceededItems: [{ itemId: "broken", declaredAmount: null, invoiceTotal: "600.00" }],
};
await page.getByTestId("reimbursement-submit").click();
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText(
"票据检查未通过,请核对每条明细与发票的关联和金额后重试。",
);
await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0);
state.submitGuardDetails = structuredGuardDetails;
state.invoices = state.invoices.map((invoice) => (
invoice.id === unlinkedInvoiceId ? { ...invoice, linkedItemId: null } : invoice
));
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-submit").click();
await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 4 项需要处理");
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
await expect(page.getByLabel("选择报销明细")).toBeVisible();
});
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
const state = buildReimbursementState();
const mobile = await newWecomPage(browser);
+10 -1
查看文件
@@ -49,6 +49,15 @@
- OAOPS1 期间请用真实业务数据完成报销、采购/费用、合同台账和项目关联;遇到提交、审批、付款、导出或权限阻断时,记录页面、步骤和影响等级,让管理员当天建修复合同或暂停对应流程。
- OALAUNCH1 收口期内,请把真实业务问题按“阻断 / 非阻断 / 建议”告诉管理员;不要只在聊天里说“系统有问题”。阻断包括无法提交、无法审批、付款/AP 无法闭环、越权可见、合同或资产敏感信息泄露、备份恢复或导出证据缺失。
### 通用报销填报规则
- 一条明细应表达同一费用类型和同一笔业务支出。不同类型或彼此独立的支出必须分开填写;连续跨天或跨期的同一笔支出(如多晚住宿、月度通讯费、按服务期计费)可以填写一条明细,并在说明中写清起止日期。
- 发生日期填写实际费用发生或消费日期,不是提交报销的日期,也不一定等于发票日期;与发票日期不一致时,在说明中写清原因。
- 上述明细粒度和日期口径是员工填报要求,由财务复核。系统不会自动比对日期,也不会判断明细拆分是否正确。
- 每张有效发票都必须关联到一条明细。正式发票入口只接受官方 PDF;图片、截图和拍照材料不能从该入口上传,支付证明、订单或情况说明等材料应通过“上传附件 / 补充材料”提交。
- 提交时,系统会检查已添加的发票是否都已关联明细、需要发票的明细是否有票面金额有效的关联发票,以及明细金额是否高于关联发票的票面合计。金额超出时系统会阻止提交,仅在说明中解释差额不能通过;申报金额低于票面合计(如使用优惠券、多人合并开票或部分自费)时仍需说明原因。
- 报销被退回后需要调整明细时,请先在“报销材料”中逐张移除发票,再拆分、删除或修改明细,最后重新添加发票并为每张发票选择对应明细。不要先删除仍挂有发票的明细,否则发票可能变成无法重新选择的未归属状态。
### 差旅报销一页指南
1. 从企业微信工作台进入海音达工作台,打开 `报销`,新建报销单。
@@ -62,7 +71,7 @@
5. 支付截图应看得出商户、金额、支付时间和支付成功状态;银行卡号、支付账号、订单号等与复核无关的信息先遮挡。
6. 补助按自然日计算:出发日、返回日各计 1 天,当天往返计 1 天;因个人原因延长的天数不计。公司或其他单位已直接承担餐食或车辆费用的,对应补助不得重复领取。
7. 伙食补助和市内交通补助均为包干:领取伙食补助后个人餐费不得另报;领取市内交通补助后,出差期间公交、地铁、出租车、网约车等不得另报。城际交通、住宿及其他经批准的必要差旅费用凭有效材料实报实销。
8. 实报实销明细填写个人实际支付的合规金额,原则上不得超过有效发票金额。优惠券、多人合并开票、部分自费或发票金额与实付金额不一致时,必须说明;已退款、公司直接支付或他人承担的金额不能再次报销。无法提供支付截图时上传情况说明,由财务决定是否例外接受,不得补造凭证。
8. 需要发票的实报实销明细填写个人实际支付的合规金额,不得高于关联发票的票面合计,超出时系统会阻止提交。使用优惠券、多人合并开票或部分自费等导致申报金额低于票面合计时,仍需在说明中写清原因;已退款、公司直接支付或他人承担的金额不能再次报销。无法提供支付截图时上传情况说明,由财务决定是否接受其他证据,不得补造凭证。
9. 提交前检查各项明细、对应 PDF、火车票和住宿费支付证明以及补助算式。提交后由直属主管审批、财务复核;实际付款和付款回单由财务处理,员工不预填。
OCR 会辅助识别发票字段和查重,但不代表系统已经确认费用真实合规。财务仍会同时核对明细金额、PDF 原件和个人支付证明;三者不一致时会退回补充或更正。
+7 -3
查看文件
@@ -51,6 +51,9 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 未配置真实 OCR 时使用 `OCR_PROVIDER=off`:PDF 直接进入 `available`,不入 OCR 队列、不生成发票代码、号码、日期、购销方或金额。员工仍可把票据添加到已有明细并提交,财务必须查看 PDF 原件人工核对。
- off 模式下文件 SHA-256 查重继续有效,依赖发票代码和号码的查重暂不可用。对当前小团队可短期接受;强制人工补录或接入腾讯 OCR 应另行规划,不能在紧急报销中临时增加门槛。
- 既有 `InvoicePool.ocr_provider` 为非空枚举且默认 `tencent`,所以 off 模式的新记录仍可能带该历史默认标记。判定是否实际调用 OCR 必须同时看 provider 配置、`ocr_attempts`、请求/完成时间和 OCR 结果,不能只看 `ocr_provider`。
- 报销提交时必须把“已关联正式 PDF”和“具有可自动比较的票面金额”分开判断。金额已识别的票据可参加自动合计;没有任何 OCR 请求、尝试、完成或错误事实且金额为空的票据视为未执行 OCR,允许提交并持续标记为“需财务查看原件人工核对”。
- 已发生 OCR 请求、尝试、完成或错误事实但金额仍为空,属于金额识别失败,不得伪装成“缺少发票”或直接放行;员工应重试识别或联系财务处理。
- 同一明细只要包含未执行 OCR 的无金额票据,自动金额合计即不完整。即使同时存在有金额票据,也不得只用已知部分判断申报超额;整条明细转为财务人工核对。只有全部关联票据都有金额时,系统才执行申报金额与票面合计的自动比较。
## 样票脱敏和授权
@@ -95,6 +98,7 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 发票状态为可添加。
- 发票未被其他报销单、差旅单或归档流程占用。
- `linkedItemId` 若传入,必须属于当前报销单。
- 草稿或驳回单中,已添加但尚未选择明细的发票可以直接选择本单明细。该动作必须由服务端在一个事务内校验报销参与人、单据状态、发票归属和明细归属,只更新明细关联;不得要求员工先移除再重新添加,也不得让发票短暂回到可添加状态。
明细匹配规则:
@@ -148,12 +152,12 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 火车电子票据、住宿电子普通发票等正式票据必须使用官方 PDF,从对应明细的发票入口上传并绑定;发票截图、支付截图或拍照图片不得冒充正式发票。
- 火车票和住宿费均必须提供个人支付截图。支付截图属于整张报销单的补充附件,不绑定到发票记录;文件名或附件说明必须能对应到具体费用明细。
- 支付截图保留商户、金额、支付时间和支付成功状态即可;银行卡号、支付账号、订单号等与审批无关的信息应先遮挡,不要求员工提供完整支付账户或银行流水。
- 明细金额以员工个人实际支付的合规金额为准,原则上不得超过有效发票金额。优惠券、多人合并开票、部分自费、票面金额与实付金额不一致等情况必须在明细说明中写明差异。
- 明细金额以员工个人实际支付的合规金额为准;需要发票的明细金额不得高于该明细关联发票的票面合计,超出时系统阻断提交。优惠券、多人合并开票、部分自费等导致申报金额低于票面合计时,必须在明细说明中写明差异。
- 已退款、公司直接支付或由他人承担的金额不得重复报销。发生部分退款时,只能申报扣除退款后的个人实际承担金额,并附情况说明。
- 确实无法提供支付截图时,员工必须上传情况说明;财务可以退回补充,也可以在有其他充分证据时例外接受并留痕。不得伪造、补造或借用他人支付凭证。
- OCR 只辅助识别发票字段、匹配和查重,不判断支付真实性、费用合理性或发票法律状态。财务复核时必须同时查看明细金额、PDF 原件和个人支付证明;三者不一致时退回更正或补充说明。
- 每张有效发票应关联到一条明确费用明细。一条明细只表达同一费用类型、同一发生日期和同一业务支出;采购物品还应是一件或一批同类物品,不同日期、不同支出或需要分别判断设备提示的多件物品应拆分。
- 票据—明细门禁启用后,需票明细没有有效发票或申报金额高于关联票面合计时阻断提交;两项包干补助不参加票面比较。关闭门禁不会删除或解绑已经建立的关联关系。
- 每张有效发票应关联到一条明确费用明细。一条明细只表达同一费用类型和同一笔业务支出;不同类型或彼此独立的支出应拆分,采购物品还应是一件或一批同类物品。连续跨天或跨期的同一笔业务支出(如多晚住宿、月度通讯费、按服务期计费)可以作为一条明细,并在说明中写清起止日期。
- 票据—明细门禁启用后,已添加发票未选择明细、需票明细没有关联正式 PDF、已尝试 OCR 但票面金额仍无法识别,或在全部关联票据金额完整时申报金额高于票面合计,均阻断提交。未执行 OCR 的无金额 PDF 允许提交,但员工提交反馈和审批详情必须明确提示财务查看原件人工核对;两项包干补助不参加票据与票面比较。关闭门禁不会删除或解绑已经建立的关联关系。
- 财务付款凭证号、付款日期、付款渠道和付款回单属于审批通过后的财务处理,不是员工提交报销时填写的个人支付证明。
### 差旅补助规定
+206 -9
查看文件
@@ -33,6 +33,10 @@ const {
resolveEntityPrecheck,
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const {
INVOICE_AMOUNT_EVIDENCE_STATUS,
resolveInvoiceAmountEvidence,
} = require("../services/invoiceAmountEvidence");
const {
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
buildPaymentProcessingSemantics,
@@ -103,6 +107,9 @@ const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREV
const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z
.object({ projectId: z.string().uuid().nullable() })
.strict();
const REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA = z
.object({ linkedItemId: z.string().uuid() })
.strict();
function resolveSignedFileOrigin(config, request) {
const configured = String(config.appOrigin ?? "")
@@ -2467,6 +2474,10 @@ async function reimbursementRoutes(fastify) {
mime_type: true,
file_size: true,
linked_item_id: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
ocr_result: { select: { result_json: true } },
},
},
@@ -2657,6 +2668,28 @@ async function reimbursementRoutes(fastify) {
departmentId: reimbursement.department_id ?? null,
strictDepartment: true,
});
const requiredInvoiceItemIds = new Set(
reimbursement.items
.filter((item) => item.expense_type?.requires_invoice)
.map((item) => item.id),
);
const invoiceAmountEvidenceById = new Map(
reimbursement.invoice_pool_items.map((invoice) => [
invoice.id,
resolveInvoiceAmountEvidence({
totalAmount: invoice.total_amount,
ocrAttempts: invoice.ocr_attempts,
ocrLastRequestedAt: invoice.ocr_last_requested_at,
ocrCompletedAt: invoice.ocr_completed_at,
ocrLastError: invoice.ocr_last_error,
}),
]),
);
const invoiceAmountManualReviewRequired = reimbursement.invoice_pool_items.some(
(invoice) =>
requiredInvoiceItemIds.has(invoice.linked_item_id) &&
invoiceAmountEvidenceById.get(invoice.id) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview,
);
return sendData(reply, request, {
id: reimbursement.id,
@@ -2736,6 +2769,7 @@ async function reimbursementRoutes(fastify) {
mimeType: item.mime_type ?? null,
fileSize: item.file_size ?? null,
linkedItemId: item.linked_item_id ?? null,
amountEvidenceStatus: invoiceAmountEvidenceById.get(item.id),
})),
pendingWorkflow: pendingInstance
? {
@@ -2755,6 +2789,7 @@ async function reimbursementRoutes(fastify) {
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
invoiceGuardEnabled: invoiceGuardGate.enabled,
invoiceAmountManualReviewRequired,
});
},
);
@@ -3941,6 +3976,117 @@ async function reimbursementRoutes(fastify) {
},
);
fastify.patch(
"/reimbursements/:id/invoices/:invoiceId/item-link",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedInvoiceId = UUID_SCHEMA.safeParse(request.params?.invoiceId);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { invoiceId: "invalid_uuid" },
});
}
const parsedBody = REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
}
const invoiceId = parsedInvoiceId.data;
const linkedItemId = parsedBody.data.linkedItemId;
const response = await fastify.prisma.$transaction(async (tx) => {
const invoiceRows = await tx.$queryRaw`
SELECT id, status, linked_reimbursement_id, linked_item_id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
`;
const invoice = Array.isArray(invoiceRows) ? invoiceRows[0] ?? null : null;
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const reimbursement = reimbursementRows[0];
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_invoice_item_link" },
});
}
if (!invoice) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (
invoice.status !== "bound" ||
invoice.linked_reimbursement_id !== reimbursementId ||
invoice.linked_item_id != null
) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { invoice: "invalid_unlinked_item_state" },
});
}
const linkedItem = await tx.reimbursementItem.findFirst({
where: { id: linkedItemId, reimbursement_id: reimbursementId },
select: { id: true },
});
if (!linkedItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { linkedItemId: "item_not_in_reimbursement" },
});
}
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
linked_item_id: linkedItem.id,
updated_by: request.employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_INVOICE_ITEM_LINKED",
targetType: "invoice_pool",
targetId: invoiceId,
extra: { reimbursementId, linkedItemId: linkedItem.id },
}),
});
return { invoiceId, reimbursementId, linkedItemId: linkedItem.id };
});
return sendData(reply, request, response);
},
);
fastify.post(
"/reimbursements/:id/precheck",
{
@@ -4016,7 +4162,14 @@ async function reimbursementRoutes(fastify) {
},
invoice_pool_items: {
where: { status: "bound" },
select: { id: true, total_amount: true },
select: {
id: true,
total_amount: true,
ocr_attempts: true,
ocr_last_requested_at: true,
ocr_last_error: true,
ocr_completed_at: true,
},
},
},
});
@@ -4046,26 +4199,61 @@ async function reimbursementRoutes(fastify) {
departmentId: reimbursement.department_id ?? null,
strictDepartment: true,
});
const manualReviewItemIds = reimbursementItems
.filter((item) => item.expense_type.requires_invoice)
.filter((item) => item.invoice_pool_items.some((invoice) => (
resolveInvoiceAmountEvidence({
totalAmount: invoice.total_amount,
ocrAttempts: invoice.ocr_attempts,
ocrLastRequestedAt: invoice.ocr_last_requested_at,
ocrCompletedAt: invoice.ocr_completed_at,
ocrLastError: invoice.ocr_last_error,
}) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview
)))
.map((item) => item.id);
if (invoiceGuardGate.enabled) {
const unlinkedBoundInvoiceCount = await tx.invoicePool.count({
const unlinkedBoundInvoices = await tx.invoicePool.findMany({
where: {
linked_reimbursement_id: reimbursementId,
linked_item_id: null,
status: "bound",
},
select: { id: true },
});
const missingInvoiceItemIds = [];
const amountUnavailableItems = [];
const amountExceededItems = [];
for (const item of reimbursementItems) {
if (!item.expense_type.requires_invoice) continue;
const validInvoices = item.invoice_pool_items.filter(
(invoice) => invoice.total_amount != null,
);
if (!validInvoices.length) {
if (!item.invoice_pool_items.length) {
missingInvoiceItemIds.push(item.id);
continue;
}
const invoiceTotal = validInvoices.reduce(
const evidenceByInvoice = item.invoice_pool_items.map((invoice) => ({
invoice,
status: resolveInvoiceAmountEvidence({
totalAmount: invoice.total_amount,
ocrAttempts: invoice.ocr_attempts,
ocrLastRequestedAt: invoice.ocr_last_requested_at,
ocrCompletedAt: invoice.ocr_completed_at,
ocrLastError: invoice.ocr_last_error,
}),
}));
const failedInvoiceIds = evidenceByInvoice
.filter(({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed)
.map(({ invoice }) => invoice.id);
if (failedInvoiceIds.length) {
amountUnavailableItems.push({ itemId: item.id, invoiceIds: failedInvoiceIds });
continue;
}
if (evidenceByInvoice.some(
({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview,
)) {
continue;
}
const invoiceTotal = item.invoice_pool_items.reduce(
(sum, invoice) => sum.plus(invoice.total_amount),
new Prisma.Decimal(0),
);
@@ -4077,15 +4265,22 @@ async function reimbursementRoutes(fastify) {
});
}
}
if (unlinkedBoundInvoiceCount || missingInvoiceItemIds.length || amountExceededItems.length) {
if (
unlinkedBoundInvoices.length ||
missingInvoiceItemIds.length ||
amountUnavailableItems.length ||
amountExceededItems.length
) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: {
items: "invoice_item_guard_failed",
unlinkedInvoiceCount: unlinkedBoundInvoiceCount,
unlinkedInvoiceCount: unlinkedBoundInvoices.length,
unlinkedInvoiceIds: unlinkedBoundInvoices.map((invoice) => invoice.id),
missingInvoiceItemIds,
amountUnavailableItems,
amountExceededItems,
},
});
@@ -4420,6 +4615,8 @@ async function reimbursementRoutes(fastify) {
precheckDecision: authoritativePrecheck.decision,
standardWarnings,
openedRiskFlagCount: openedRiskFlags.length,
invoiceAmountManualReviewRequired: manualReviewItemIds.length > 0,
manualReviewItemCount: manualReviewItemIds.length,
},
sideEffectsContext: {
reimbursementId,
@@ -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: "打车费",
category: "travel",
enabled: true,
requires_invoice: true,
},
},
],
@@ -56,6 +57,10 @@ function buildReimbursementRecord(overrides = {}) {
total_amount: "188.80",
original_name: "receipt.pdf",
linked_item_id: "item-1",
ocr_attempts: 1,
ocr_last_requested_at: new Date("2026-04-09T01:00:00.000Z"),
ocr_last_error: null,
ocr_completed_at: new Date("2026-04-09T01:00:01.000Z"),
},
],
workflow_instances: [
@@ -358,8 +363,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
totalAmount: "188.80",
originalName: "receipt.pdf",
linkedItemId: "item-1",
amountEvidenceStatus: "verified",
},
],
invoiceAmountManualReviewRequired: false,
approvalHistory: [
{
id: "history-start",
@@ -394,6 +401,47 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
await app.close();
});
it("keeps the manual amount-review marker visible to a finance detail reader", async () => {
const reimbursement = buildReimbursementRecord({
invoice_pool_items: [{
id: "invoice-off",
status: "bound",
invoice_code: null,
invoice_number: null,
invoice_date: null,
seller_name: null,
total_amount: null,
original_name: "manual-review.pdf",
linked_item_id: "item-1",
ocr_attempts: 0,
ocr_last_requested_at: null,
ocr_last_error: null,
ocr_completed_at: null,
}],
});
const { app } = await buildApp({
employeeId: "finance-reader",
permissionCodes: ["expense.view_all"],
reimbursement,
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: true,
invoices: [expect.objectContaining({
id: "invoice-off",
amountEvidenceStatus: "manual_review",
})],
}));
await app.close();
});
it("returns null submittedAt and empty approvalHistory when reimbursement has never been submitted", async () => {
const reimbursement = buildReimbursementRecord({
status: "draft",
@@ -102,6 +102,14 @@ async function buildApp(options = {}) {
? (options.unlinkedBoundInvoiceCount ?? 0)
: (options.boundInvoiceCount ?? 1)
)),
findMany: vi.fn(async ({ where } = {}) => (
where?.linked_item_id === null
? Array.from(
{ length: options.unlinkedBoundInvoiceCount ?? 0 },
(_unused, index) => ({ id: `unlinked-invoice-${index + 1}` }),
)
: []
)),
},
reimbursementItem: {
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
@@ -409,6 +417,35 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("keeps the finance manual-review success marker when the B2 guard is off", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
invoiceGuardEnabled: false,
boundInvoiceCount: 1,
reimbursementItems: [{
id: "manual-review-guard-off",
amount: "180.00",
invoice_pool_items: [{
id: "off-invoice-guard-off",
total_amount: null,
ocr_attempts: 0,
ocr_last_requested_at: null,
ocr_last_error: null,
ocr_completed_at: null,
}],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: true,
manualReviewItemCount: 1,
}));
await app.close();
});
it("blocks required-invoice items without linked invoices when the guard is on", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
@@ -441,6 +478,112 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("identifies unlinked invoices without exposing only a count", async () => {
const { app } = await buildApp({
invoiceGuardEnabled: true,
unlinkedBoundInvoiceCount: 2,
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(expect.objectContaining({
unlinkedInvoiceCount: 2,
unlinkedInvoiceIds: ["unlinked-invoice-1", "unlinked-invoice-2"],
}));
await app.close();
});
it("blocks a linked invoice whose OCR ran without producing an amount", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems: [{
id: "ocr-failed-item",
amount: "180.00",
invoice_pool_items: [{
id: "ocr-failed-invoice",
total_amount: null,
ocr_attempts: 1,
ocr_last_requested_at: new Date("2026-08-30T10:00:00.000Z"),
ocr_last_error: "amount_missing",
ocr_completed_at: new Date("2026-08-30T10:00:01.000Z"),
}],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(expect.objectContaining({
missingInvoiceItemIds: [],
amountUnavailableItems: [{
itemId: "ocr-failed-item",
invoiceIds: ["ocr-failed-invoice"],
}],
}));
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("allows a linked PDF with no OCR execution evidence and marks finance manual review", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems: [{
id: "manual-review-item",
amount: "180.00",
invoice_pool_items: [{
id: "off-invoice",
total_amount: null,
ocr_attempts: 0,
ocr_last_requested_at: null,
ocr_last_error: null,
ocr_completed_at: null,
}],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: true,
manualReviewItemCount: 1,
}));
await app.close();
});
it("does not compare a declared amount against only the known part of mixed evidence", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems: [{
id: "mixed-evidence-item",
amount: "1000.00",
invoice_pool_items: [
{ id: "known-invoice", total_amount: "600.00" },
{
id: "off-invoice",
total_amount: null,
ocr_attempts: 0,
ocr_last_requested_at: null,
ocr_last_error: null,
ocr_completed_at: null,
},
],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: true,
manualReviewItemCount: 1,
}));
await app.close();
});
it("blocks declared amounts above the linked invoice total when the guard is on", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
@@ -462,6 +605,33 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("allows five required items when each has one verified bound invoice", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const reimbursementItems = Array.from({ length: 5 }, (_unused, index) => ({
id: `verified-item-${index + 1}`,
amount: "100.00",
invoice_pool_items: [{
id: `verified-invoice-${index + 1}`,
total_amount: "100.00",
}],
}));
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems,
authoritativeTotal: "500.00",
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: false,
manualReviewItemCount: 0,
}));
expect(tx.workflowInstance.create).toHaveBeenCalledTimes(1);
await app.close();
});
it("recomputes a stale total before selecting the approval rule", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
@@ -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);
});
});