文件
hyb-oa/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/design.md
T

8.0 KiB

设计: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,兼容保留现有字段并补充可定位字段:

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 只提示提交未完成。

未归属发票原子恢复

新增窄用途接口,例如:

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/数据迁移,已存在票据关系无需补偿。
  • 2026-08-31 首次发布在 init、应用切换、/ready 和 runtime smoke 通过后,被公网文案扫描拦截:4 处用户文案含内部术语 OCR。应用已按合同回滚至 0f3ecfa。根因不是公网扫描器,而是本地 check-ui-design-rules.mjs --strict 未进入本 task Test Plan;热修后把它提升为所有用户可见前端文案变更的提交前标准门禁,并以四词 E2E 断言防止同类回归。
  • 2026-09-01 第二次发布通过全部静态门禁、init、切换、版本和公网文案扫描,但合成草稿发现 manual_review 确认弹窗内触发 B2 后,提交 mutation 的 onError 未清空 submitConfirmPrecheck,模态遮罩持续拦截票据卡片的“确认关联”;应用再次按合同回滚至 0f3ecfa。PC/移动修复均在错误回调先关闭确认弹窗,再延后一帧调用既有问题聚焦函数;回归必须覆盖 manual_review 和 warn 两种进入同一弹窗的路径,不能从 pass 预检直接调用提交来替代。

风险控制

  • 最大产品风险是把“金额未知”误报成“缺票”或“校验通过”;用三态金额证据和独立 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 或产品设计事实源。