110 行
6.5 KiB
Markdown
110 行
6.5 KiB
Markdown
# 设计: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 规范校核不替代该部署门。
|