7.4 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 原始响应或票面敏感字段。
每条需票明细按以下顺序检查:
- 没有 bound PDF:加入
missingInvoiceItemIds。 - 有
ocr_failed发票:加入amountUnavailableItems,不继续金额比较。 - 有
manual_review发票:加入manualReviewItemIds,允许继续提交,不用不完整金额做超额判断。 - 全部为
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": "..." }
事务内:
FOR UPDATE锁发票;只接受status=bound、已绑定目标报销且当前linked_item_id=null。- 锁报销单;复用
ensureReimbursementParticipant的 owner/submitter 参与人合同,且仅draft/rejected可写。 - 校验目标明细属于同一报销单。
- 只更新
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 断言防止同类回归。
风险控制
- 最大产品风险是把“金额未知”误报成“缺票”或“校验通过”;用三态金额证据和独立 UI 文案锁定。
- 最大一致性风险是前端用移除/添加模拟改绑;用单事务窄接口消除临时可用窗口。
- 最大兼容风险是旧客户端不认识新增 fields;保留现有错误 code/字段,新增字段只做加法。
- 最大隐私风险是把 UUID 或原始 details 渲染给员工;解析器只输出由当前详情投影出的序号、类型和说明摘要。
- 最大可访问性风险是只用颜色或 toast;测试必须覆盖文本、告警语义和问题附近的恢复按钮。
- 2026-08-31 核验确认当前会话、用户级、平台级和项目级均无可调用的
frontend-designskill。经用户明确批准,本轮以可用的ui-ux-pro-max规则库和 Playwright 真实渲染作为替代部署门,并明确记录其不等同于frontend-design:普通文字对比度不低于 4.5:1,错误不只靠颜色表达,错误聚焦到具体明细/发票,移动触控目标不小于 44px,PC/390px 手机/667px 横屏无横向溢出,sticky 主动作不遮挡内容。frontend-design引用悬空属于后续治理债,本 task 不顺带修改 AGENTS 或产品设计事实源。