修复报销票据门禁提示与 OCR 降级(08-30-b2-submit-error-ocr-fallback)
这个提交包含在:
@@ -30,10 +30,12 @@ duplicate detection or attachment behavior.
|
||||
require the same own-or-`expense.view_all` guard as reimbursement detail reads
|
||||
before invoking the precheck service; an ordinary nonparticipant must receive
|
||||
403 without service-side evaluation.
|
||||
- 取消或放宽必填校验时,必须继续审计该校验之后的代码是否依赖它提供的
|
||||
非空、类型或状态保证,并为生产真实存在的空值路径补回归测试。不得只
|
||||
删除前置错误分支;可空字段的 `.trim()`、`.length`、字符串拼接和传给
|
||||
非空参数的调用都应逐处确认。
|
||||
- 增加、取消或放宽强制校验时,必须同时核对既有降级合同、兼容模式和该
|
||||
校验之后的代码假设。取消或放宽时审计下游是否依赖它提供的非空、类型
|
||||
或状态保证,并为生产真实存在的空值路径补回归测试;增加时确认不会把
|
||||
`off`、历史数据或其他已承诺可用的降级路径变成不可提交。不得只修改
|
||||
前置错误分支;可空字段的 `.trim()`、`.length`、字符串拼接和传给非空
|
||||
参数的调用都应逐处确认。
|
||||
|
||||
## Scenario: OCR Disabled Without Mock Data
|
||||
|
||||
@@ -994,6 +996,10 @@ await selectApprovalRule(reimbursement.total_amount);
|
||||
- Reimbursement detail returns `invoiceGuardEnabled` for the form department.
|
||||
- `POST /api/invoice-pool/:id/bind` requires `linkedItemId` while the guard is
|
||||
enabled; `POST /api/reimbursements/:id/submit` enforces the final gate.
|
||||
- `PATCH /api/reimbursements/:id/invoices/:invoiceId/item-link` atomically links
|
||||
one bound-but-unlinked invoice to an item in the same editable form.
|
||||
- Detail invoice projection adds `amountEvidenceStatus`; form detail and submit
|
||||
success add the redacted manual-review summary without exposing OCR payloads.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
@@ -1017,16 +1023,36 @@ await selectApprovalRule(reimbursement.total_amount);
|
||||
- With B2 on, binding requires an existing item in the same reimbursement and
|
||||
may not reach the legacy auto-create branch. If an upload waits for OCR, keep
|
||||
the user-selected item ID with the pending invoice and send it when OCR later
|
||||
makes the invoice bindable. Submit blocks any bound invoice with no item
|
||||
link, each required-invoice item with no amount-bearing bound invoice, and
|
||||
each declared amount greater than its linked invoice total.
|
||||
- Sum and compare money with `Prisma.Decimal`. Invoice-free items are excluded.
|
||||
A lower declared amount remains allowed for finance to reconcile.
|
||||
makes the invoice bindable. Submit separates PDF presence from amount
|
||||
evidence: a required item without a bound PDF blocks as missing evidence;
|
||||
a bound PDF with attempted OCR but no amount blocks as recognition failure.
|
||||
- Classify one invoice from the explicit minimum fields `total_amount`,
|
||||
`ocr_attempts`, request/completion timestamps and the bounded OCR error fact.
|
||||
Non-null amount is `verified`; null amount with no execution fact is
|
||||
`manual_review`; null amount with any execution fact is `ocr_failed`.
|
||||
Never infer execution from the historical non-null `ocr_provider` default.
|
||||
- Sum and compare money with `Prisma.Decimal` only when every bound invoice for
|
||||
the required item is `verified`. If any invoice is `manual_review`, allow
|
||||
submission and mark the item/form for finance review; a mixed verified plus
|
||||
manual-review set must not be compared using only the known partial sum.
|
||||
Invoice-free items are excluded. A lower declared amount remains allowed for
|
||||
finance to reconcile.
|
||||
- A bound invoice with `linked_item_id=null` remains visible and recoverable.
|
||||
The narrow item-link write locks invoice and form, reuses the owner/submitter
|
||||
participant check, accepts only `draft|rejected`, verifies both objects belong
|
||||
to the form, updates only `linked_item_id`, and writes audit in one
|
||||
transaction. Failure and concurrent state drift produce zero writes.
|
||||
- PC and mobile map structured guard details to a safe Chinese summary plus
|
||||
item/invoice-local recovery text. IDs locate current DTO objects only and are
|
||||
never rendered; dynamic summaries use alert semantics and toast is not the
|
||||
sole feedback. Finance read-only detail keeps manual-review markers visible.
|
||||
- With B2 off, retain legacy auto-match/create compatibility. Keep the legacy
|
||||
whole-form invoice gate only when at least one item requires an invoice;
|
||||
allowance-only forms remain invoice-free because that is an always-active B1
|
||||
policy. Turning the flag off never deletes or unbinds relationships already
|
||||
written while it was on.
|
||||
written while it was on. The manual-review projection and submit-success
|
||||
notice remain active for linked no-OCR PDFs even when B2 blocking is off;
|
||||
the flag controls blocking, not whether finance is told to inspect originals.
|
||||
- Form maintenance and invoice bind/unbind remain owner-or-submitter writes;
|
||||
`expense.view_all` and admin roles remain read-only for another employee's
|
||||
reimbursement.
|
||||
@@ -1040,11 +1066,23 @@ await selectApprovalRule(reimbursement.total_amount);
|
||||
`required_when_invoice_guard_enabled`; an item outside the form remains
|
||||
`item_not_in_reimbursement` with zero writes.
|
||||
- Guard-on submit with unlinked bound invoices -> `invoice_item_guard_failed`
|
||||
plus `unlinkedInvoiceCount`.
|
||||
- Required item without an amount-bearing bound invoice -> same error plus
|
||||
plus `unlinkedInvoiceCount` and locating `unlinkedInvoiceIds`.
|
||||
- Required item without any bound PDF -> same error plus
|
||||
`missingInvoiceItemIds`.
|
||||
- Bound PDF with an OCR execution fact but no amount -> same error plus
|
||||
`amountUnavailableItems`; tell the user to retry recognition or contact
|
||||
finance, not to upload a duplicate PDF.
|
||||
- Bound PDF with no OCR execution fact and no amount -> submit succeeds with
|
||||
`invoiceAmountManualReviewRequired=true`; approval detail keeps the same
|
||||
manual-review warning until comparable evidence exists.
|
||||
- Mixed verified and manual-review invoices -> submit succeeds for manual
|
||||
review without comparing the declared amount to the known partial sum.
|
||||
- Declared amount above linked total -> same error plus entries containing only
|
||||
`itemId`, canonical `declaredAmount` and `invoiceTotal`.
|
||||
`itemId`, canonical `declaredAmount` and `invoiceTotal`, but only when every
|
||||
linked invoice amount is verified.
|
||||
- Item-link by a nonparticipant, on a non-editable form, across forms, for an
|
||||
already-linked/non-bound invoice or after a concurrent state change -> reject
|
||||
with zero link/audit writes.
|
||||
- Invoice-free allowance-only form with no invoice -> may submit with the guard
|
||||
either on or off when all other existing checks pass.
|
||||
- Env hard-off or DB force-off -> B2 item-level blocks are skipped; the legacy
|
||||
@@ -1054,6 +1092,11 @@ await selectApprovalRule(reimbursement.total_amount);
|
||||
|
||||
- Good: one required item explicitly links several same-purpose invoices whose
|
||||
total covers the declaration; submit passes with the guard on.
|
||||
- Good: five required items each link one verified invoice and submit normally;
|
||||
one off-mode PDF with no OCR execution submits with an explicit finance
|
||||
manual-review marker.
|
||||
- Good: a CNY 1,000 item linked to one verified CNY 600 invoice and one
|
||||
manual-review invoice does not produce a false CNY 400 overage.
|
||||
- Good: a positive meal allowance-only form has no invoice and passes with the
|
||||
guard either on or off.
|
||||
- Base: guard off preserves an older draft and its auto-created link; later
|
||||
@@ -1064,6 +1107,9 @@ await selectApprovalRule(reimbursement.total_amount);
|
||||
item while explicit selection is required, use JavaScript floating-point
|
||||
sums for money, let repeated init erase finance account mappings, or 删除前置
|
||||
必填校验后继续对可空值直接调用 `.trim()`。
|
||||
- Bad: call a PDF with failed OCR “missing invoice”, use only a known partial
|
||||
amount in a mixed set, expose UUIDs/raw details, or implement recovery as
|
||||
client-side unbind followed by bind.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
@@ -1075,14 +1121,20 @@ await selectApprovalRule(reimbursement.total_amount);
|
||||
disabled legacy types, default-off flag and account-code preservation.
|
||||
- Item/submit route tests 覆盖 assets 创建 omitted→null、编辑 null+omitted→null、
|
||||
有值+omitted→保留、显式清空、切换非 assets 清空和无依据提交,并继续覆盖
|
||||
invoice-free zero invoice、missing link、unlinked bound invoice、
|
||||
declared-over-invoice 与 participant/nonparticipant side effects。
|
||||
invoice-free zero invoice、missing PDF、unlinked bound invoice、OCR failed、
|
||||
manual review、verified+manual mixed evidence、five-item/five-invoice normal
|
||||
submit、declared-over-invoice 与 participant/nonparticipant side effects。
|
||||
- Bind tests cover flag on/off, missing/foreign/successful explicit links and
|
||||
prove the guard-on path does not create a reimbursement item.
|
||||
- Atomic item-link tests cover success, participant ownership, editable status,
|
||||
invoice/form/item ownership, existing links, non-bound state and concurrent
|
||||
zero-write behavior including audit.
|
||||
- PC/mobile Playwright covers asset 登记导向说明、批准依据输入/展示均不存在且
|
||||
保存 payload omission、CNY 2,000 warning、allowance-only submit projection、
|
||||
disabled add action until every
|
||||
selected invoice has an item, and pending-OCR binding retaining that item.
|
||||
selected invoice has an item, pending-OCR binding retaining that item, four
|
||||
safe guard messages with local recovery actions, alert semantics, atomic
|
||||
unlinked-invoice recovery and finance manual-review visibility.
|
||||
- Run full backend Vitest with the pinned BIFF8 Python dependencies, Prisma
|
||||
validate/generate check, frontend typecheck/build, focused Playwright,
|
||||
process/docs/Trellis governance and `git diff --check`.
|
||||
@@ -1109,7 +1161,7 @@ asset_prior_approval_basis: targetApprovalBasis.trim();
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
if (item.expense_type.requires_invoice) {
|
||||
if (item.expense_type.requires_invoice && linkedInvoices.every(hasVerifiedAmount)) {
|
||||
const total = linkedInvoices.reduce(
|
||||
(sum, invoice) => sum.plus(invoice.total_amount),
|
||||
new Prisma.Decimal(0),
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"children": [
|
||||
"08-30-reimbursement-expense-policy-invoice-guard-deploy",
|
||||
"08-30-reimbursement-assets-prior-approval-correction",
|
||||
"08-30-reimbursement-employee-guide-b2-sync"
|
||||
"08-30-reimbursement-employee-guide-b2-sync",
|
||||
"08-30-b2-submit-error-ocr-fallback"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
@@ -39,4 +40,4 @@
|
||||
],
|
||||
"notes": "本地实施和只读专项复核已完成;B2 env/DB 双闸仍默认关闭,未部署、未应用生产 migration、未写生产配置。2,000 元/条明细的选类提示阈值已批准。会计科目映射由财务负责人在 B1 生产启用前提供,目标回填日期 2026-09-05;未取得正式科目表时保持 null,不由开发人员猜测。生产只读审计确认 0014/0016/0017/0018 均需退回修正;顺序改为先部署 B1、隔离验证并启用 B2,再由真实财务统一退回。B2 不校验日期粒度,0014 必须按 5 个发票日期拆分并由财务人工复核。部署合同见子任务 08-30-reimbursement-expense-policy-invoice-guard-deploy。B3 财务一页式及行内费用标准提示恢复仍为后续任务。",
|
||||
"meta": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户