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

这个提交包含在:
2026-08-31 20:20:20 +08:00
父节点 8b19538584
当前提交 f57e2236f2
共修改 21 个文件,包含 1829 行新增和 49 行删除
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,109 @@
# 设计:B2 提交错误可解释性与 OCR 降级合同
## 设计边界
本轮修复提交门禁的结构化反馈、OCR 金额降级语义和未归属发票的最小恢复动作。保持现有 B2 feature flag、发票/明细关系、审批状态机和数据库 schema,不进行详情页整体重构。
## 服务端金额证据模型
新增纯函数统一判定单张 bound 发票的金额证据,避免提交门禁、详情 DTO 和测试各自推断。函数只接收显式最小字段集(`totalAmount`、`ocrAttempts`、`ocrLastRequestedAt`、`ocrCompletedAt`、`ocrLastError`),不得接收整个 Prisma invoice 对象:
| 状态 | 证据 | 提交语义 |
| --- | --- | --- |
| `verified` | `total_amount != null` | 可参与 `Prisma.Decimal` 合计 |
| `manual_review` | 金额为空,且没有 OCR 请求、尝试或完成事实 | off/历史降级 PDF;允许提交,人工核对 |
| `ocr_failed` | 金额为空,且存在 OCR 请求、尝试、完成或失败事实 | 金额识别失败;单独阻断 |
判定不得只读取 `ocr_provider`:该列有历史非空默认值,不能证明实际调用过 OCR。提交查询需补入最小 OCR 执行字段;不得返回 OCR 原始响应或票面敏感字段。
每条需票明细按以下顺序检查:
1. 没有 bound PDF:加入 `missingInvoiceItemIds`。
2. 有 `ocr_failed` 发票:加入 `amountUnavailableItems`,不继续金额比较。
3. 有 `manual_review` 发票:加入 `manualReviewItemIds`,允许继续提交,不用不完整金额做超额判断。
4. 全部为 `verified`:用 `Prisma.Decimal` 合计;申报金额更高时加入 `amountExceededItems`。
整单仍先检查 `bound + linked_item_id=null`。两项 `requires_invoice=false` 的补助不进入上述判断。
## 结构化错误合同
保持 `400 VALID_001` 与 `details.items=invoice_item_guard_failed`,兼容保留现有字段并补充可定位字段:
```ts
type InvoiceItemGuardDetails = {
items: "invoice_item_guard_failed";
unlinkedInvoiceCount: number;
unlinkedInvoiceIds: string[];
missingInvoiceItemIds: string[];
amountUnavailableItems: Array<{ itemId: string; invoiceIds: string[] }>;
amountExceededItems: Array<{
itemId: string;
declaredAmount: string;
invoiceTotal: string;
}>;
};
```
ID 只用于客户端把服务端结论映射到当前详情中的明细/发票,不直接渲染。通用 `mapApiErrorMessage` 负责安全摘要;报销详情页使用窄化解析器生成页面内问题模型,未知或畸形 details 回退到通用中文错误,不回显原始对象。
成功提交如存在 `manualReviewItemIds`,提交响应返回脱敏的人工核对摘要;详情响应根据发票金额证据计算:
- 整单 `invoiceAmountManualReviewRequired`;
- 每张发票 `amountEvidenceStatus`;
- 每条明细可由关联 ID 聚合出人工核对提示。
这些字段是运行时投影,不新增持久化列。降级票据以后补齐金额时,人工核对标记自然消失;审批详情在未补齐前持续可见。
## PC 与移动端反馈
客户端共享一个解析与映射模块,PC/移动不各写一套字符串判断:
- 提交区域显示汇总,例如“还有 2 项票据问题,请按下方提示处理”。
- 对应明细卡片显示“尚未关联正式发票”“票面金额未识别,请重试识别或联系财务”“申报金额高于关联发票合计”等原因。
- 对应发票卡片显示“未选择报销明细”,并提供恢复动作。
- 降级 PDF 显示“票面金额需财务查看原件核对”,不使用成功色或“校验通过”。
- 多项错误的摘要使用 `role=alert`/`aria-live`,并可将焦点移动到首个问题区域;卡片不能只靠红色表达状态。
这符合长表单的最小反馈合同:顶部摘要帮助发现问题,就近错误说明原因和恢复路径,toast 只提示提交未完成。
## 未归属发票原子恢复
新增窄用途接口,例如:
```http
PATCH /api/invoice-pool/:id/reimbursement-item
{ "reimbursementId": "...", "linkedItemId": "..." }
```
事务内:
1. `FOR UPDATE` 锁发票;只接受 `status=bound`、已绑定目标报销且当前 `linked_item_id=null`。
2. 锁报销单;复用 `ensureReimbursementParticipant` 的 owner/submitter 参与人合同,且仅 `draft/rejected` 可写。
3. 校验目标明细属于同一报销单。
4. 只更新 `linked_item_id`/`updated_by`,写审计记录;不把发票改为 `available`,不重跑查重,不创建明细。
PC 和移动卡片在未归属时显示明细下拉框和“确认关联”。成功后失效并刷新详情;409/403 等并发或权限错误留在卡片内。其他已有关联发票本轮不显示改绑入口。
## 兼容性与事实源
- B2 flag 开启时使用新判断;flag 关闭时保留既有兼容路径,不删除关联关系。
- `OCR_PROVIDER=tencent` 的正常金额路径不变;`mock` 仍禁止用于正式票据。
- off/未执行 OCR 的 PDF 不要求补录金额;文档与 spec 更新为“关联存在”和“金额证据”两层检查。
- 不把发票日期写成明细发生日期权威值。
- 不新增文档;只更新 `docs/报销发票附件规则.md` 与现有 reimbursement spec。
## 发布、验收与回滚
- 无 migration、无 seed、无 flag 变更;部署仍需独立授权并记录现有 migration/init 健康结果。
- 公网使用合成草稿覆盖:未归属恢复、缺票、金额识别失败、超票面、off/未 OCR 人工核对和正常提交;不得改真人单据做 smoke。
- 部署后核 `/ready`、bundle marker、B2 仍为 `force_on + global`,并确认四张真人单据状态未被部署改变。
- 应用异常时回滚到本轮部署前生产候选;本轮无 schema/数据迁移,已存在票据关系无需补偿。
## 风险控制
- 最大产品风险是把“金额未知”误报成“缺票”或“校验通过”;用三态金额证据和独立 UI 文案锁定。
- 最大一致性风险是前端用移除/添加模拟改绑;用单事务窄接口消除临时可用窗口。
- 最大兼容风险是旧客户端不认识新增 fields;保留现有错误 code/字段,新增字段只做加法。
- 最大隐私风险是把 UUID 或原始 details 渲染给员工;解析器只输出由当前详情投影出的序号、类型和说明摘要。
- 最大可访问性风险是只用颜色或 toast;测试必须覆盖文本、告警语义和问题附近的恢复按钮。
- 部署前告警样式与恢复动作须由具备项目 `frontend-design` skill 的会话完成视觉裁决;当前会话的 UI/UX 规范校核不替代该部署门。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,57 @@
# 实施清单:B2 提交错误可解释性与 OCR 降级合同
## 1. 规划与证据冻结
- [x] 记录生产 `OCR_PROVIDER=tencent`,确认不回退 B2 flag。
- [x] 用生产只读数据复现 0014 当前三类 B2 判断与时间线,记录历史失败 details 不可恢复的证据边界。
- [x] PRD、design、implement 经用户审查并明确批准;批准前不运行 `task.py start`、不改业务代码。
## 2. 服务端门禁与 DTO
- [x] 抽取并独立测试只接收最小字段集的金额证据纯函数:verified / manual_review / ocr_failed;三个分支各含边界用例,不依赖 Prisma 记录形状。
- [x] 提交查询补齐最小 OCR 执行字段,将“无关联 PDF”“金额识别失败”“降级人工核对”“完整金额超额”分支按设计顺序实现。
- [x] 金额完整时继续用 `Prisma.Decimal`;混合降级票据时不做不完整金额比较,并把人工核对摘要返回成功响应。
- [x] 保持 `invoice_item_guard_failed`、既有字段与 flag off 语义,新增 `unlinkedInvoiceIds`、`amountUnavailableItems` 等定位字段。
- [x] 详情 DTO 增加发票金额证据状态和整单人工核对投影,不返回 OCR 原始结果或敏感票面字段。
## 3. 未归属发票恢复接口
- [x] 新增 bound 未归属发票关联本单明细的单事务接口;锁发票/报销、复用 `ensureReimbursementParticipant` 校验 owner/submitter,并校验状态和目标明细归属。
- [x] 只更新 `linked_item_id` 并留审计,不执行 unbind/bind、不短暂释放发票、不创建明细。
- [x] 覆盖不存在、非 bound、已有归属、跨单明细、非参与人、非草稿/驳回及并发状态变化,失败保持零写入。
## 4. PC 与移动端
- [x] 增加共享的 guard details 窄化解析器和安全摘要;未知 details 回退通用中文,不展示 UUID/原始 payload。
- [x] PC/移动提交失败同时显示可访问摘要和明细/票据就近错误;toast 仅作补充。
- [x] 未归属票据卡片显示显著状态、明细选择和原子“确认关联”动作;成功刷新详情,失败错误留在卡片。
- [x] 金额识别失败与降级人工核对使用不同文案;后者在提交前/成功后及审批只读详情持续可见,不宣称自动校验通过。
- [x] 不调整报销事由编辑、处理进度、关联项目顺序、自动生成明细或 PC sticky 操作布局。
## 5. 文档与规约
- [x] 更新 `docs/报销发票附件规则.md`:明确关联 PDF 与金额证据分层、off 人工核对、腾讯金额失败和混合票据行为。
- [x] 更新 `.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md` 的提交合同、错误矩阵、测试矩阵和“取消/增加校验需核对降级合同”通用规则。
- [x] 不新建文档,不改员工手册中与本轮无关的填报粒度和日期口径。
## 6. 测试矩阵
- [x] 后端:未归属发票返回可定位 ID;需票明细无 bound PDF;Tencent/已尝试 OCR 但金额为空;完整金额超额;正常金额通过。
- [x] 后端:off/未执行 OCR 且金额为空允许提交并标人工核对;有金额 + 降级无金额进入人工核对;有金额 + 识别失败阻断;免票补助无票通过。
- [x] 后端:0014 类 5 明细/5 金额有效发票逐条关联时正常提交;B2 flag off 兼容路径不变。
- [x] 后端:原子重新归属接口的成功、权限、状态、跨单、已有归属和并发零写入矩阵。
- [x] 前端单元/组件:四类中文摘要、畸形 details 回退、ID 到明细/发票可读标签映射、无内部 ID 泄露、ARIA 告警语义。
- [x] PC/移动 E2E:错误就近呈现、未归属卡片原子选明细、人工核对提示、正常提交;使用合成数据,不保存真实票据截图。
- [x] 运行 `cd server && npm run prisma:validate`、聚焦 Vitest,必要时全量相关路由回归。
- [x] 运行 `cd client && npm run typecheck && npm run build` 及聚焦 Playwright。
- [x] 运行 `node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-trellis-governance.mjs` 和 `git diff --check`。
## 7. 只读复核、提交与部署门
- [x] 实施后执行只读专项 review,重点检查权限/状态机、金额证据三态、混合票据、API 兼容和 PC/移动数据流;reviewer 不改代码。
- [ ] 用户复核 diff 与测试结论后,以中文 commit 引用 `08-30-b2-submit-error-ocr-fallback`;push 与部署分别取得授权。
- [ ] 部署前由具备项目 `frontend-design` skill 的会话复核告警样式和恢复动作;部署后用合成草稿完成公网验收,确认 `/ready`、bundle marker、B2 `force_on + global` 和真人单据状态;验收前不通知员工可以测试。
## 8. 回滚
- [ ] 应用或公网验收失败时停止员工测试,回滚到部署前生产候选;本轮无 migration/seed/flag 写入,不修改或清理既有票据关联数据。
@@ -0,0 +1,96 @@
# B2 提交错误可解释性与 OCR 降级合同核验
## Goal
修复 B2 票据明细门禁“只返回参数校验失败、员工不知道改哪里”的缺陷,并恢复既有 OCR 降级合同:有真实 OCR 时保持金额硬校验;未实际执行 OCR 的 PDF 允许随报销提交,由财务明确执行人工票面核对。
## Background
- 生产公网构建为 `0f3ecfabb7c8`,B2 已全局开启。2026-08-30 通过受控生产只读门禁确认唯一 server 运行容器为 `OCR_PROVIDER=tencent`;未读取其他环境变量,因此不触发 B2 flag 回退。
- 0014 的员工截图发生在重新整理票据期间,当时页面只显示“参数校验失败”。后续生产只读复现确认:该单现有 5 条需票明细、5 张 bound 发票,每条明细恰好关联 1 张金额有效发票;未归属数、缺有效票明细数和超票面明细数均为 0。发票在截图后完成重新关联,单据已于当晚成功提交并进入 `reviewing`。
- 0014 的历史失败响应无法事后精确恢复:`AppError.details` 只返回浏览器,服务端日志不记录该结构,失败事务也不留数据库快照。现有证据只能确认失败发生在“票据重新关联尚未完成”阶段,不能再断言当时一定是 `unlinkedInvoiceCount` 或 `missingInvoiceItemIds`。
- `server/src/routes/reimbursements.js` 的 B2 提交门禁返回 `details.items=invoice_item_guard_failed`,并携带 `unlinkedInvoiceCount`、`missingInvoiceItemIds` 和 `amountExceededItems`。
- `client/src/api/errorMessages.ts` 没有映射 `invoice_item_guard_failed`;PC 与移动详情页又把统一 `actionError` 放在概况区或操作区,因此员工既看不到具体原因,也容易把错误归因到不相关字段。
- 已绑定但 `linked_item_id=null` 的发票并非完全不可见:PC 和移动材料区都会显示“明细:未关联”。真正缺陷是状态不突出、提交错误没有定位到该票据,且页面没有直接重新指定明细的动作;该发票也不会出现在“可添加发票”列表中,员工只能先移除再重新添加。
- `.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md` 与 `docs/报销发票附件规则.md` 规定:`OCR_PROVIDER=off` 时 PDF 不执行 OCR、不生成金额,但仍可绑定明细并提交,由财务查看原件人工核对。当前 B2 以 `total_amount != null` 定义“有效发票”,与该合同冲突;腾讯模式下个别 OCR 金额缺失也会被错误描述成“缺票”。
- 明细发生日期是实际费用发生或消费日期,不等同于发票日期。任何后续从票生成明细的设计只能把发票日期作为待确认建议值。
## Scope
本 task 覆盖 R1-R4:冻结生产诊断证据、修复 B2 结构化错误与 PC/移动就近反馈、为未归属 bound 发票提供原子关联动作、恢复未执行 OCR 票据的人工核对降级合同。R5 所列详情页整体信息结构和自动生成明细能力明确延期。
## Requirements
### R1 生产证据与应急边界
- 保留生产只读结论:运行 provider 为 `tencent`,不回退 B2 flag。
- 记录 0014 当前三类 B2 检查均通过、已成功提交,以及历史失败分支不可恢复的原因;不得要求员工再开浏览器开发者工具补证据。
- 若后续受控检查发现正式环境实际运行 `off`,仍须先单独取得生产写入授权才可回退 flag;本 task 的规划或实施批准不自动授权生产 flag、账号、报销或票据写入。
- 正式环境若为 `mock`,停止真实票据使用并按既有禁用 mock 合同单独处置,不把 mock 结果作为票面证据。
### R2 三类 B2 错误可解释性
- 为 `invoice_item_guard_failed` 增加稳定中文反馈,分别覆盖:已绑定但未归属明细的发票、需票明细没有关联 PDF、明细金额高于可自动比较的关联票面合计。
- 将“已关联 PDF 但 OCR 未取得金额”从“缺少发票”中拆出,给出独立原因和恢复路径;不得让员工以为需要重复上传一张已经存在的票。
- 错误反馈使用明细序号、费用类型、说明摘要或页面内发票序号,不向普通用户展示数据库 UUID、API 路径、原始 details 或“发票池”等内部术语。
- PC 与移动端都提供提交失败摘要,并在对应明细或发票卡片附近显示原因和下一步;动态错误使用可访问的告警语义,toast 只能补充,不能是唯一反馈。
- 服务端仍是提交权威;前端预检查不得替代服务端结构化错误和并发后的最终反馈。
### R3 未归属发票的可恢复操作
- 对 `bound + linked_item_id=null` 的发票显示显著“未选择报销明细”错误态,并直接提供“选择明细”动作。
- 重新指定明细必须是服务端单事务操作:锁定发票和报销单、校验操作人、草稿/驳回状态、同一报销单内目标明细后,只更新 `linked_item_id`。不得在前端串联“移除 → 重新添加”,避免票据短暂回到可用池或被并发占用。
- 操作成功后刷新详情和提交检查;操作失败时错误留在对应票据卡片,不清空现有绑定关系。
- 本轮只处理未归属 bound 发票的恢复,不扩展为所有已关联发票的任意改绑工作台。
### R4 OCR 金额证据与降级合同
- 将“是否有关联 PDF”和“是否取得可比较票面金额”分开判断。需票明细没有任何 bound PDF 时才属于缺票。
- 金额证据按实际记录判定,不只看数据库默认的 `ocr_provider`:
- `total_amount` 非空:该张发票具有可自动比较金额;
- PDF 未发生 OCR 请求/尝试且金额为空:视为 off/历史降级票据,进入财务人工票面核对;
- 已发生 OCR 请求/尝试但金额仍为空:视为金额识别失败,提交时单独阻断并提示重试识别或联系财务。
- 同一明细的所有关联发票都有金额时,使用 `Prisma.Decimal` 求和并执行“申报金额不得高于票面合计”的硬阻断。
- 同一明细只要包含未执行 OCR 的降级票据,自动金额合计即不完整:允许提交,但必须在员工提交反馈和审批详情中明确标记“需财务人工核对票面”,不得宣称金额自动校验通过。
- “有金额 + 降级无金额”的混合场景同样进入人工核对,不用已知部分金额制造错误的超额结论;这不是绕过校验,而是把无法完成的自动判断显式移交财务。
- 不要求员工人工补录票面金额,不新增数据库迁移;既有金额、绑定关系和 flag off 回滚语义保持兼容。
### R5 后续 UX 边界
- “从我的票据选择并生成/补全明细”、明细与票据合并展示、草稿页信息结构、处理进度和 PC sticky 操作栏属于后续“报销详情页信息结构与操作重排”任务,不扩入本 P0。
- 本 P0 只增加结构化错误、金额核对状态和未归属票据的最小恢复动作,不重写整个详情页。
- 后续从发票生成明细时,发票日期仅可作为待员工确认的建议值;实际发生日期必须允许修改并保留“不一定等于发票日期”的业务口径。
## Acceptance Criteria
- [x] 记录当前生产 server 的实际 `OCR_PROVIDER=tencent`,证据只包含 provider 值和观察时间,不包含其他 env。
- [x] 记录 0014 当前 5 条需票明细、5 张金额有效 bound 发票、三类 B2 计数均为 0,且已成功提交进入 `reviewing`;明确历史失败只能定位到重新关联未完成阶段,不能伪造精确 details。
- [x] 明确当前不触发 B2 flag 回退;未经独立授权不执行任何生产写入。
- [x] 未归属、缺关联 PDF、金额识别失败、申报超票面四类问题在 PC 和移动端显示自然中文,并定位到对应明细/票据,不暴露内部 ID。
- [x] 未归属 bound 发票可在票据卡片上原子选择本单明细,不需要先移除再重新添加。
- [x] 未执行 OCR 且金额为空的关联 PDF 可随需票明细提交,并在提交反馈与审批详情明确标记财务人工核对;已尝试 OCR 但金额缺失仍被单独阻断。
- [x] 所有关联票据金额完整时继续硬拦申报超额;混合“有金额 + 降级无金额”进入人工核对而非错误的部分金额比较。
- [x] 两项免票补助、Tencent 正常金额路径、B2 flag off、既有关联关系和 owner/submitter 写权限保持兼容。
- [x] 后端聚焦测试、前端错误解析与 PC/移动测试、typecheck/build、Prisma 校验及 Trellis/governance 门禁通过。
- [ ] 面向员工的改动只有部署和公网合成验收通过后才告知可测试;生产部署、flag 写入和真人数据修改分别取得明确授权。
## Test Plan
- 后端聚焦测试覆盖未归属、无关联 PDF、金额识别失败、完整金额超额、off/未执行 OCR 人工核对、混合金额证据、免票补助、flag off 兼容和 0014 类正常通过。
- 原子关联接口覆盖成功、权限、状态、跨单、已有归属与并发零写入。
- 前端单元/组件及 PC/移动 E2E 覆盖安全中文映射、就近定位、原子恢复、人工核对提示、可访问告警和内部 ID 不泄露。
- 运行 Prisma validate、相关 Vitest、前端 typecheck/build、聚焦 Playwright、流程/文档/Trellis governance 与 `git diff --check`;部署后只用合成草稿做公网验收。
## Out of Scope
- 报销详情页整体信息架构、个人票据入口、从票自动生成明细、处理进度重排、PC 主操作布局和财务一页式审批。
- OCR provider 采购、腾讯凭据轮换、人工票面金额录入、数据库 provider 枚举或其他 schema migration。
- 修改 0014/0016/0017/0018 的明细、发票、金额、状态或审批流程。
- 将未归属恢复动作扩展成通用的已关联发票批量改绑能力。
## Confirmed Production Evidence
- 运行 provider:`tencent`;不回退 B2。
- 0014 当前:`status=reviewing`、5 条明细均需票、5 张 bound 发票、未归属 0、空金额 0、缺票明细 0、超票面明细 0。
- 时间线:明细先完成重建;截图发生在重新关联完成之前;5 张发票随后完成关联,单据成功提交并创建待审批 workflow。
- 历史失败的精确 `error.details` 已不可恢复,因为该响应未写日志且失败事务无快照;后续不再向员工索要 F12 证据。
@@ -0,0 +1,34 @@
{
"id": "b2-submit-error-ocr-fallback",
"name": "b2-submit-error-ocr-fallback",
"title": "B2 提交错误可解释性与 OCR 降级合同核验",
"description": "先只读确认生产 OCR 状态和真实提交阻断;必要时经独立授权应急回退 B2,再修复三类提交错误呈现及 OCR off 降级合同。",
"status": "in_progress",
"dev_type": "fullstack",
"scope": "server reimbursement submit/invoice linking, client PC/mobile error recovery, reimbursement OCR facts/spec",
"package": "hyb-oa",
"priority": "P0",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-30",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-29-reimbursement-expense-policy-invoice-guard",
"relatedFiles": [
"server/src/routes/reimbursements.js",
"server/src/routes/invoicePool.js",
"client/src/api/errorMessages.ts",
"client/src/pages/ReimbursementDetailPage.tsx",
"client/src/pages/MobileReimbursementDetailPage.tsx",
"docs/报销发票附件规则.md",
".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md"
],
"notes": "生产只读确认 OCR_PROVIDER=tencent;0014 已完成关联并成功提交,历史失败 details 不可事后恢复。",
"meta": {}
}