# 设计: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 或产品设计事实源。