比较提交
6
次代码提交
0f3ecfabb7
...
f39b43cce3
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
f39b43cce3 | ||
|
|
8aed72d1bf | ||
|
|
fff360f362 | ||
|
|
f57e2236f2 | ||
|
|
8b19538584 | ||
|
|
8e3425e7de |
@@ -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),
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"subtasks": [],
|
||||
"children": [
|
||||
"08-30-reimbursement-expense-policy-invoice-guard-deploy",
|
||||
"08-30-reimbursement-assets-prior-approval-correction"
|
||||
"08-30-reimbursement-assets-prior-approval-correction",
|
||||
"08-30-reimbursement-employee-guide-b2-sync",
|
||||
"08-30-b2-submit-error-ocr-fallback"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
@@ -38,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;测试必须覆盖文本、告警语义和问题附近的恢复按钮。
|
||||
- 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 或产品设计事实源。
|
||||
@@ -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 不改代码。
|
||||
- [x] 用户明确批准本轮无障碍机械修正后直接提交并回填元数据;业务提交使用中文并引用 `08-30-b2-submit-error-ocr-fallback`。新候选须经用户最终 diff 复核后才可 push,部署仍需独立授权。
|
||||
- [x] 部署前视觉门:确认 `frontend-design` 当前不可用后,经用户批准以 `ui-ux-pro-max` + Playwright 真实渲染替代。PC/390px 手机/667px 横屏无横向溢出,移动选择框与确认按钮均为 44px,sticky 底栏贴合视口且不遮挡问题卡片,提交失败后焦点落到首个具体问题;检查发现 `red-600` / `red-50` 对比度约 4.42:1,已将 PC 与移动报销详情页内所有同类错误块统一为 `red-700` / `red-50`(约 5.92:1),并为共享操作错误补齐 `role=alert` / `aria-live=assertive`,复跑通过。该替代门不冒充 `frontend-design`。全局错误语义色 token 化与视觉裁决 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": "8aed72d",
|
||||
"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 不可事后恢复。frontend-design 当前不可用,本轮经批准采用 ui-ux-pro-max + Playwright 替代门;全局错误语义色 token 化及视觉裁决 skill 引用修订作为后续治理债处理。",
|
||||
"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 @@
|
||||
{"_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,63 @@
|
||||
# 同步员工报销填报指南与 B2 规则
|
||||
|
||||
## Goal
|
||||
|
||||
让员工侧主手册准确反映已经全局生效的票据—明细门禁和人工填报粒度要求,避免员工按旧文案误以为“金额超票面后写说明即可提交”,同时允许住宿、月度通讯费、服务期费用等连续跨天或跨期支出作为同一笔业务支出填一条明细。
|
||||
|
||||
## Background
|
||||
|
||||
- `docs/员工快速上手.md:41-68` 是当前员工使用说明;第 6 节已有 PDF-only、图片走附件、支付截图、补助和差旅专项规则,不应重复另建“报销填报说明”。
|
||||
- `docs/员工快速上手.md:65` 仍写“原则上不得超过有效发票金额,差异必须说明”。B2 全局生效后,需要发票的明细金额高于关联发票票面合计会被硬阻断,仅写说明不能提交。
|
||||
- B2 只校验发票是否关联、需票明细是否有金额有效的关联发票、明细金额是否高于关联票面合计;它不校验费用类型、发生日期或业务支出粒度。明细拆分属于员工填报要求和财务人工复核事项,不得宣称系统会自动提醒或阻断。
|
||||
- 发生日期是实际费用发生或消费日期,不是报销提交日期,也不能一律由发票日期替代;与发票日期不一致时应说明原因。
|
||||
- 连续跨天或跨期的同一笔业务支出(如多晚住宿、月度通讯费、按服务期计费)可以作为一条明细,并在说明中写清起止日期。
|
||||
- 只改员工手册会产生事实源冲突:`docs/报销发票附件规则.md:151` 仍保留“原则上不得超过、差异说明”,`:155` 仍把“一条明细”绝对限定为同一发生日期;`:156` 又已经记录 B2 超票面硬阻断。
|
||||
|
||||
## Scope
|
||||
|
||||
- 员工侧主手册中的通用报销填报规则与差旅指南第 8 条。
|
||||
- 报销发票附件事实源中与金额硬门禁、连续跨期粒度直接冲突的两条现行口径。
|
||||
- Trellis task 元数据与文档一致性验证。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 员工手册增加最小通用规则
|
||||
|
||||
- 在 `docs/员工快速上手.md` 第 6 节、现有“差旅报销一页指南”之前增加“通用报销填报规则”。
|
||||
- 只补现有差旅段落没有覆盖的内容:同一费用类型和同一笔业务支出的明细粒度、连续跨期例外、实际发生日期口径、逐票关联、需票明细的金额硬门禁,以及退回后“先移除发票—再改明细—最后重新添加并选择明细”的顺序。
|
||||
- 图片、截图和拍照材料不得从正式发票入口上传;属于支付证明、订单或情况说明时,通过“上传附件”提交。不得写成图片材料一律无效。
|
||||
- 明确粒度要求由员工遵守、财务复核,B2 不自动比对日期或判断拆分是否正确。
|
||||
|
||||
### R2 纠正差旅指南第 8 条
|
||||
|
||||
- 把 `docs/员工快速上手.md:65` 改为:需要发票的实报实销明细金额不得高于关联发票票面合计,超出时系统阻止提交;优惠券、多人合并开票、部分自费等申报金额低于票面的情况仍需说明。
|
||||
- 保留已退款、公司直接支付、他人承担金额不得重复报销,以及缺支付截图时由财务决定是否接受其他证据、不得补造凭证的现有口径。
|
||||
|
||||
### R3 最小同步主事实源
|
||||
|
||||
- `docs/报销发票附件规则.md` 是产品/财务事实源而非员工手册,不复制员工操作说明。
|
||||
- 其 `:151/:155` 与本任务已确认的新口径冲突;同一 task 内只做两处最小事实纠偏,避免员工手册与主事实源互相矛盾:金额规则改为需票明细超关联票面硬阻断,粒度规则补充连续跨天/跨期同一业务支出的例外及起止日期说明。
|
||||
- 不修改 `docs/trialrun/R7用户手册与FAQ.md`;该文档仍是草案,不是当前员工主说明。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 不新建说明文档;员工通用规则落在现有 `docs/员工快速上手.md` 第 6 节且不重复现有差旅指南。
|
||||
- [x] 通用规则正确区分实际发生日期、发票日期和报销提交日期,并明确连续跨天/跨期的同一业务支出可以一条明细记录起止日期。
|
||||
- [x] 文档明确 B2 的三项系统门禁及其日期/粒度非目标;员工不会被告知“系统会自动检查拆分是否正确”。
|
||||
- [x] 需要发票的明细金额高于关联票面合计被表述为硬阻断;说明仅用于允许提交的票面高于申报金额等差异场景。
|
||||
- [x] 退回修改顺序、正式 PDF 入口和图片附件路径表述准确。
|
||||
- [x] `docs/员工快速上手.md` 与 `docs/报销发票附件规则.md` 不保留互相矛盾的生效规则。
|
||||
- [x] `node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-trellis-governance.mjs` 和 `git diff --check` 全部通过。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不修改应用代码、自动化测试或运行配置,不执行线上写入或发布。
|
||||
- 不新建报销说明文档,不改 `docs/trialrun/R7用户手册与FAQ.md`。
|
||||
- 不处理费用类型命名、会计科目映射、固定资产政策或四张真人报销的退回操作。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 逐条对照 B2 已生效合同,检查员工手册与报销事实源的金额、关联、免票和粒度边界不存在矛盾。
|
||||
- 运行 `node scripts/qa/audit-docs.mjs --check-registry`。
|
||||
- 运行 `node scripts/qa/check-trellis-governance.mjs`。
|
||||
- 运行 `git diff --check`。
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "reimbursement-employee-guide-b2-sync",
|
||||
"name": "reimbursement-employee-guide-b2-sync",
|
||||
"title": "同步员工报销填报指南与 B2 规则",
|
||||
"description": "同步员工快速上手中的通用报销填报规则与已全局生效的 B2 门禁,纠正差旅金额旧口径,并处理连续跨期支出的明细粒度表达。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": "employee reimbursement guide; reimbursement documentation consistency",
|
||||
"package": "docs",
|
||||
"priority": "P1",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-30",
|
||||
"completedAt": "2026-08-30",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "08-29-reimbursement-expense-policy-invoice-guard",
|
||||
"relatedFiles": [
|
||||
"docs/员工快速上手.md",
|
||||
"docs/报销发票附件规则.md"
|
||||
],
|
||||
"notes": "文档专项目标,不改应用、不执行线上发布或写入;四张真人报销的退回与通知不依赖本任务。",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -10,6 +10,35 @@ function readStringList(value: unknown): string[] {
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function countGuardAmountUnavailableItems(value: unknown): number {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => (
|
||||
isRecord(item)
|
||||
&& typeof item.itemId === "string"
|
||||
&& item.itemId.length > 0
|
||||
&& readStringList(item.invoiceIds).length > 0
|
||||
)).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
function countGuardAmountExceededItems(value: unknown): number {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => (
|
||||
isRecord(item)
|
||||
&& typeof item.itemId === "string"
|
||||
&& item.itemId.length > 0
|
||||
&& typeof item.declaredAmount === "string"
|
||||
&& item.declaredAmount.length > 0
|
||||
&& typeof item.invoiceTotal === "string"
|
||||
&& item.invoiceTotal.length > 0
|
||||
)).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
const FEATURE_FLAG_DISABLED_MESSAGES: Record<string, string> = {
|
||||
portal_home_v1_disabled: "工作台首页当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
@@ -131,6 +160,38 @@ export function mapApiErrorMessage(
|
||||
return "所有报销明细金额都必须大于 0,请修改后再提交。";
|
||||
}
|
||||
|
||||
if (details?.items === "invoice_item_guard_failed") {
|
||||
const messages: string[] = [];
|
||||
const rawUnlinkedCount = details.unlinkedInvoiceCount;
|
||||
const unlinkedCount = typeof rawUnlinkedCount === "number"
|
||||
&& Number.isInteger(rawUnlinkedCount)
|
||||
&& rawUnlinkedCount >= 0
|
||||
? rawUnlinkedCount
|
||||
: 0;
|
||||
const missingCount = readStringList(details.missingInvoiceItemIds).length;
|
||||
const unavailableCount = countGuardAmountUnavailableItems(details.amountUnavailableItems);
|
||||
const exceededCount = countGuardAmountExceededItems(details.amountExceededItems);
|
||||
if (unlinkedCount > 0) messages.push(`${unlinkedCount} 张发票未选择报销明细`);
|
||||
if (missingCount > 0) messages.push(`${missingCount} 条需票明细尚未关联发票`);
|
||||
if (unavailableCount > 0) messages.push(`${unavailableCount} 条明细的发票金额未识别`);
|
||||
if (exceededCount > 0) messages.push(`${exceededCount} 条明细的申报金额高于发票合计`);
|
||||
return messages.length
|
||||
? `票据检查未通过:${messages.join(";")}。请按下方提示处理。`
|
||||
: "票据检查未通过,请核对每条明细与发票的关联和金额后重试。";
|
||||
}
|
||||
|
||||
if (details?.invoice === "invalid_unlinked_item_state") {
|
||||
return "这张发票的关联状态已变化,请刷新后重试。";
|
||||
}
|
||||
|
||||
if (details?.reimbursement === "invalid_status_for_invoice_item_link") {
|
||||
return "当前报销单状态已变化,不能再关联发票,请刷新页面。";
|
||||
}
|
||||
|
||||
if (details?.linkedItemId === "item_not_in_reimbursement") {
|
||||
return "所选明细已不存在或不属于本单,请刷新后重新选择。";
|
||||
}
|
||||
|
||||
const passwordErrors = readStringList(details?.password);
|
||||
if (passwordErrors.length) {
|
||||
return `新密码不符合要求:${passwordErrors.join(";")}。`;
|
||||
|
||||
@@ -194,6 +194,7 @@ export type ReimbursementDetail = {
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
linkedItemId: string | null;
|
||||
amountEvidenceStatus: "verified" | "manual_review" | "ocr_failed";
|
||||
}>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
@@ -225,6 +226,7 @@ export type ReimbursementDetail = {
|
||||
approvalHistory: ReimbursementApprovalHistoryItem[];
|
||||
riskFlags: RiskFlag[];
|
||||
invoiceGuardEnabled?: boolean;
|
||||
invoiceAmountManualReviewRequired?: boolean;
|
||||
items: ReimbursementItem[];
|
||||
};
|
||||
|
||||
@@ -391,6 +393,8 @@ export type ReimbursementSubmitResponse = {
|
||||
precheckDecision: "pass" | "warn" | "manual_review";
|
||||
standardWarnings: ExpenseStandardCheck[];
|
||||
openedRiskFlagCount: number;
|
||||
invoiceAmountManualReviewRequired: boolean;
|
||||
manualReviewItemCount: number;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||
@@ -472,6 +476,21 @@ export async function submitReimbursement(reimbursementId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function linkReimbursementInvoiceItem(
|
||||
reimbursementId: string,
|
||||
invoiceId: string,
|
||||
linkedItemId: string,
|
||||
) {
|
||||
return apiRequestData<{
|
||||
reimbursementId: string;
|
||||
invoiceId: string;
|
||||
linkedItemId: string;
|
||||
}>(`/api/reimbursements/${reimbursementId}/invoices/${invoiceId}/item-link`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ linkedItemId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function withdrawReimbursement(reimbursementId: string, comment?: string) {
|
||||
return apiRequestData<Record<string, unknown>>(`/api/reimbursements/${reimbursementId}/withdraw`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { isApiError } from "../api/types";
|
||||
|
||||
export type InvoiceGuardAmountUnavailableItem = {
|
||||
itemId: string;
|
||||
invoiceIds: string[];
|
||||
};
|
||||
|
||||
export type InvoiceGuardAmountExceededItem = {
|
||||
itemId: string;
|
||||
declaredAmount: string;
|
||||
invoiceTotal: string;
|
||||
};
|
||||
|
||||
export type ReimbursementInvoiceGuardIssues = {
|
||||
unlinkedInvoiceCount: number;
|
||||
unlinkedInvoiceIds: string[];
|
||||
missingInvoiceItemIds: string[];
|
||||
amountUnavailableItems: InvoiceGuardAmountUnavailableItem[];
|
||||
amountExceededItems: InvoiceGuardAmountExceededItem[];
|
||||
};
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string" && item.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
function readNonNegativeInteger(value: unknown): number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
export function parseReimbursementInvoiceGuardIssues(
|
||||
error: unknown,
|
||||
): ReimbursementInvoiceGuardIssues | null {
|
||||
if (!isApiError(error) || error.details?.items !== "invoice_item_guard_failed") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amountUnavailableItems = Array.isArray(error.details.amountUnavailableItems)
|
||||
? error.details.amountUnavailableItems.flatMap((value) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const itemId = "itemId" in value && typeof value.itemId === "string" ? value.itemId : "";
|
||||
if (!itemId) return [];
|
||||
return [{
|
||||
itemId,
|
||||
invoiceIds: readStringArray("invoiceIds" in value ? value.invoiceIds : undefined),
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
|
||||
const amountExceededItems = Array.isArray(error.details.amountExceededItems)
|
||||
? error.details.amountExceededItems.flatMap((value) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const itemId = "itemId" in value && typeof value.itemId === "string" ? value.itemId : "";
|
||||
const declaredAmount = "declaredAmount" in value && typeof value.declaredAmount === "string"
|
||||
? value.declaredAmount
|
||||
: "";
|
||||
const invoiceTotal = "invoiceTotal" in value && typeof value.invoiceTotal === "string"
|
||||
? value.invoiceTotal
|
||||
: "";
|
||||
if (!itemId || !declaredAmount || !invoiceTotal) return [];
|
||||
return [{ itemId, declaredAmount, invoiceTotal }];
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
unlinkedInvoiceCount: readNonNegativeInteger(error.details.unlinkedInvoiceCount),
|
||||
unlinkedInvoiceIds: readStringArray(error.details.unlinkedInvoiceIds),
|
||||
missingInvoiceItemIds: readStringArray(error.details.missingInvoiceItemIds),
|
||||
amountUnavailableItems,
|
||||
amountExceededItems,
|
||||
};
|
||||
}
|
||||
|
||||
export function invoiceGuardIssueCount(issues: ReimbursementInvoiceGuardIssues): number {
|
||||
return issues.unlinkedInvoiceCount
|
||||
+ issues.missingInvoiceItemIds.length
|
||||
+ issues.amountUnavailableItems.length
|
||||
+ issues.amountExceededItems.length;
|
||||
}
|
||||
|
||||
export function resolveInvoiceGuardIssuesAfterLink(
|
||||
issues: ReimbursementInvoiceGuardIssues | null,
|
||||
invoiceId: string,
|
||||
linkedItemId: string,
|
||||
): ReimbursementInvoiceGuardIssues | null {
|
||||
if (!issues) return null;
|
||||
const locatedInvoice = issues.unlinkedInvoiceIds.includes(invoiceId);
|
||||
const unlinkedInvoiceIds = issues.unlinkedInvoiceIds.filter((id) => id !== invoiceId);
|
||||
const shouldDecreaseCount = locatedInvoice || issues.unlinkedInvoiceIds.length === 0;
|
||||
const next = {
|
||||
...issues,
|
||||
unlinkedInvoiceCount: shouldDecreaseCount
|
||||
? Math.max(0, issues.unlinkedInvoiceCount - 1)
|
||||
: issues.unlinkedInvoiceCount,
|
||||
unlinkedInvoiceIds,
|
||||
missingInvoiceItemIds: issues.missingInvoiceItemIds.filter((id) => id !== linkedItemId),
|
||||
};
|
||||
return invoiceGuardIssueCount(next) > 0 ? next : null;
|
||||
}
|
||||
|
||||
export function invoiceGuardItemMessages(
|
||||
issues: ReimbursementInvoiceGuardIssues | null,
|
||||
itemId: string,
|
||||
): string[] {
|
||||
if (!issues) return [];
|
||||
const messages: string[] = [];
|
||||
if (issues.missingInvoiceItemIds.includes(itemId)) {
|
||||
messages.push("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。");
|
||||
}
|
||||
if (issues.amountUnavailableItems.some((item) => item.itemId === itemId)) {
|
||||
messages.push("关联发票的票面金额未识别,请重试识别或联系财务处理。");
|
||||
}
|
||||
const exceeded = issues.amountExceededItems.find((item) => item.itemId === itemId);
|
||||
if (exceeded) {
|
||||
messages.push(
|
||||
`申报金额 ¥${exceeded.declaredAmount} 高于关联发票合计 ¥${exceeded.invoiceTotal},请补充发票或调整申报金额。`,
|
||||
);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function invoiceGuardInvoiceMessages(
|
||||
issues: ReimbursementInvoiceGuardIssues | null,
|
||||
invoiceId: string,
|
||||
linkedItemId: string | null,
|
||||
): string[] {
|
||||
if (!issues) return [];
|
||||
const messages: string[] = [];
|
||||
const isUnlinked = issues.unlinkedInvoiceIds.includes(invoiceId)
|
||||
|| (issues.unlinkedInvoiceIds.length === 0 && issues.unlinkedInvoiceCount > 0 && !linkedItemId);
|
||||
if (isUnlinked) {
|
||||
messages.push("这张发票还没有选择报销明细,请在下方完成关联。");
|
||||
}
|
||||
if (issues.amountUnavailableItems.some((item) => item.invoiceIds.includes(invoiceId))) {
|
||||
messages.push("这张发票未识别出票面金额,请重试识别或联系财务处理。");
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function focusFirstInvoiceGuardIssue() {
|
||||
window.requestAnimationFrame(() => {
|
||||
const target = document.querySelector<HTMLElement>('[data-reimbursement-guard-issue="true"]');
|
||||
if (!target) return;
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
submitReimbursement,
|
||||
updateReimbursementItem,
|
||||
uploadReimbursementAttachments,
|
||||
linkReimbursementInvoiceItem,
|
||||
type ReimbursementItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
@@ -81,6 +82,15 @@ import { cn } from "../lib/utils";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
import {
|
||||
focusFirstInvoiceGuardIssue,
|
||||
invoiceGuardInvoiceMessages,
|
||||
invoiceGuardIssueCount,
|
||||
invoiceGuardItemMessages,
|
||||
parseReimbursementInvoiceGuardIssues,
|
||||
resolveInvoiceGuardIssuesAfterLink,
|
||||
type ReimbursementInvoiceGuardIssues,
|
||||
} from "../lib/reimbursementInvoiceGuard";
|
||||
|
||||
type ItemFormState = {
|
||||
mode: "create" | "edit";
|
||||
@@ -167,6 +177,9 @@ export function MobileReimbursementDetailPage() {
|
||||
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
|
||||
const [itemError, setItemError] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
|
||||
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
|
||||
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
|
||||
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
|
||||
@@ -369,6 +382,7 @@ export function MobileReimbursementDetailPage() {
|
||||
await invalidateDetail();
|
||||
setItemForm(null);
|
||||
setItemError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success("明细已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -408,15 +422,23 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: () => submitReimbursement(reimbursementId),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (data) => {
|
||||
await invalidateDetail();
|
||||
setSubmitConfirmPrecheck(null);
|
||||
setActionError("");
|
||||
toast.success("已提交审批");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success(
|
||||
data.invoiceAmountManualReviewRequired
|
||||
? "已提交,票面金额将由财务核对"
|
||||
: "已提交审批",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error);
|
||||
const issues = parseReimbursementInvoiceGuardIssues(error);
|
||||
setActionError(message);
|
||||
setInvoiceGuardIssues(issues);
|
||||
if (issues) focusFirstInvoiceGuardIssue();
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
@@ -464,6 +486,7 @@ export function MobileReimbursementDetailPage() {
|
||||
setPendingOcrLinkedItemIds((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||||
);
|
||||
if (succeededIds.length) setInvoiceGuardIssues(null);
|
||||
|
||||
if (!failed.length) {
|
||||
setInvoiceActionError("");
|
||||
@@ -531,6 +554,7 @@ export function MobileReimbursementDetailPage() {
|
||||
onSuccess: async () => {
|
||||
await invalidateDetail();
|
||||
setInvoiceActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success("发票已移除");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -540,6 +564,35 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const linkInvoiceItemMutation = useMutation({
|
||||
mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) =>
|
||||
linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId),
|
||||
onSuccess: async (_result, variables) => {
|
||||
await invalidateDetail();
|
||||
setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink(
|
||||
previous,
|
||||
variables.invoiceId,
|
||||
variables.linkedItemId,
|
||||
));
|
||||
setInvoiceItemSelections((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
setInvoiceItemLinkErrors((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
toast.success("发票已关联到报销明细");
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
const message = resolveErrorMessage(error, "关联明细失败,请刷新后重试");
|
||||
setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message }));
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAvailableInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceId: string) => archiveInvoice(invoiceId),
|
||||
onSuccess: async (_result, invoiceId) => {
|
||||
@@ -805,6 +858,7 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
try {
|
||||
setActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
const precheck = await precheckReimbursement(reimbursementId);
|
||||
const message = buildPrecheckDialogMessage(precheck);
|
||||
if (precheck.decision === "block") {
|
||||
@@ -871,7 +925,7 @@ export function MobileReimbursementDetailPage() {
|
||||
if (!reimbursementId) {
|
||||
return (
|
||||
<PageScaffold>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-600">缺少报销单 ID。</div>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">缺少报销单 ID。</div>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
@@ -889,7 +943,7 @@ export function MobileReimbursementDetailPage() {
|
||||
if (detailQuery.isError || !detail) {
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-600">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{resolveErrorMessage(detailQuery.error)}
|
||||
</div>
|
||||
<Button asChild variant="secondary">
|
||||
@@ -1023,6 +1077,20 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{invoiceGuardIssues ? (
|
||||
<section
|
||||
className="mobile-card border-red-300 bg-red-50"
|
||||
data-testid="mobile-reimbursement-invoice-guard-summary"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-red-800">票据检查未通过</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-red-700">
|
||||
共 {invoiceGuardIssueCount(invoiceGuardIssues)} 项需要处理,请按下方明细和发票提示修改。
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
||||
@@ -1034,7 +1102,7 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
|
||||
{itemError ? (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{itemError}</div>
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{itemError}</div>
|
||||
) : null}
|
||||
|
||||
{itemForm ? (
|
||||
@@ -1114,7 +1182,15 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.items.length ? (
|
||||
detail.items.map((item) => (
|
||||
<article key={item.id} className="mobile-list-card">
|
||||
<article
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"mobile-list-card",
|
||||
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length && "border-red-300 bg-red-50/60",
|
||||
)}
|
||||
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
|
||||
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div>
|
||||
@@ -1126,6 +1202,15 @@ export function MobileReimbursementDetailPage() {
|
||||
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
||||
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||||
) : null}
|
||||
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
|
||||
<p
|
||||
key={message}
|
||||
className="mt-2 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{canMaintainReimbursement ? (
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
|
||||
@@ -1172,6 +1257,12 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.invoiceAmountManualReviewRequired ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
|
||||
本单包含未执行 OCR 的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
|
||||
@@ -1220,7 +1311,7 @@ export function MobileReimbursementDetailPage() {
|
||||
) : null}
|
||||
|
||||
{invoiceActionError ? (
|
||||
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{invoiceActionError}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -1273,8 +1364,21 @@ export function MobileReimbursementDetailPage() {
|
||||
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
|
||||
? "已关联"
|
||||
: resolveInvoiceNextStepText(invoice);
|
||||
const guardMessages = invoiceGuardInvoiceMessages(
|
||||
invoiceGuardIssues,
|
||||
invoice.id,
|
||||
invoice.linkedItemId,
|
||||
);
|
||||
return (
|
||||
<article key={invoice.id} className="mobile-list-card">
|
||||
<article
|
||||
key={invoice.id}
|
||||
className={cn(
|
||||
"mobile-list-card",
|
||||
guardMessages.length && "border-red-300 bg-red-50/60",
|
||||
)}
|
||||
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
|
||||
tabIndex={guardMessages.length ? -1 : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
@@ -1298,6 +1402,64 @@ export function MobileReimbursementDetailPage() {
|
||||
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
|
||||
</span>
|
||||
</button>
|
||||
{guardMessages.map((message) => (
|
||||
<p
|
||||
key={message}
|
||||
className="mt-3 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{invoice.amountEvidenceStatus === "manual_review" ? (
|
||||
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800">
|
||||
票面金额未自动识别,需由财务查看原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
{invoice.amountEvidenceStatus === "ocr_failed" ? (
|
||||
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm leading-5 text-red-700">
|
||||
OCR 已处理但未取得票面金额,提交前需要重试识别或联系财务。
|
||||
</p>
|
||||
) : null}
|
||||
{canMaintainReimbursement && !invoice.linkedItemId ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<label className="block text-sm font-medium text-zinc-700" htmlFor={`mobile-invoice-item-link-${invoice.id}`}>
|
||||
选择报销明细
|
||||
</label>
|
||||
<select
|
||||
id={`mobile-invoice-item-link-${invoice.id}`}
|
||||
className="select-std min-h-11 w-full"
|
||||
value={invoiceItemSelections[invoice.id] ?? ""}
|
||||
onChange={(event) => setInvoiceItemSelections((previous) => ({
|
||||
...previous,
|
||||
[invoice.id]: event.target.value,
|
||||
}))}
|
||||
>
|
||||
<option value="">请选择明细</option>
|
||||
{detail.items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className="min-h-11 w-full"
|
||||
type="button"
|
||||
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
|
||||
onClick={() => linkInvoiceItemMutation.mutate({
|
||||
invoiceId: invoice.id,
|
||||
linkedItemId: invoiceItemSelections[invoice.id],
|
||||
})}
|
||||
>
|
||||
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
|
||||
</Button>
|
||||
{invoiceItemLinkErrors[invoice.id] ? (
|
||||
<p className="text-sm leading-5 text-red-700" role="alert">
|
||||
{invoiceItemLinkErrors[invoice.id]}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
@@ -1362,7 +1524,11 @@ export function MobileReimbursementDetailPage() {
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
|
||||
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
||||
{actionError ? (
|
||||
<div className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
<div
|
||||
className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1428,7 +1594,7 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
|
||||
{invoiceActionError ? (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{invoiceActionError}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1540,7 +1706,7 @@ export function MobileReimbursementDetailPage() {
|
||||
>
|
||||
{invoiceLibraryLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{invoiceLibraryError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{resolveErrorMessage(invoiceLibraryError)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
uploadReimbursementAttachments,
|
||||
withdrawReimbursement,
|
||||
linkReimbursementProject,
|
||||
linkReimbursementInvoiceItem,
|
||||
type ReimbursementItem,
|
||||
} from "../api/reimbursements";
|
||||
import { remindWorkflowTask } from "../api/workflow";
|
||||
@@ -95,6 +96,14 @@ import {
|
||||
type ItemEditorSaveData,
|
||||
} from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import {
|
||||
focusFirstInvoiceGuardIssue,
|
||||
invoiceGuardInvoiceMessages,
|
||||
invoiceGuardItemMessages,
|
||||
parseReimbursementInvoiceGuardIssues,
|
||||
resolveInvoiceGuardIssuesAfterLink,
|
||||
type ReimbursementInvoiceGuardIssues,
|
||||
} from "../lib/reimbursementInvoiceGuard";
|
||||
|
||||
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||||
type SelectedInvoiceMap = Record<string, string>;
|
||||
@@ -111,6 +120,9 @@ export function ReimbursementDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
|
||||
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
|
||||
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
||||
const [itemError, setItemError] = useState("");
|
||||
@@ -239,22 +251,34 @@ export function ReimbursementDetailPage() {
|
||||
return withdrawReimbursement(reimbursementId, params.comment);
|
||||
}
|
||||
},
|
||||
onSuccess: async (_data, params) => {
|
||||
onSuccess: async (data, params) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||
]);
|
||||
setActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
if (params.action === "submit") {
|
||||
setSubmitConfirmPrecheck(null);
|
||||
}
|
||||
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回" };
|
||||
const requiresManualReview = params.action === "submit"
|
||||
&& data != null
|
||||
&& typeof data === "object"
|
||||
&& "invoiceAmountManualReviewRequired" in data
|
||||
&& data.invoiceAmountManualReviewRequired === true;
|
||||
const actionMessages: Record<string, string> = {
|
||||
submit: requiresManualReview ? "已提交,票面金额将由财务核对" : "已提交",
|
||||
withdraw: "已撤回",
|
||||
};
|
||||
toast.success(actionMessages[params.action] ?? "操作成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "操作失败,请稍后重试");
|
||||
const issues = parseReimbursementInvoiceGuardIssues(error);
|
||||
setActionError(message);
|
||||
setInvoiceGuardIssues(issues);
|
||||
if (issues) focusFirstInvoiceGuardIssue();
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
@@ -306,6 +330,7 @@ export function ReimbursementDetailPage() {
|
||||
]);
|
||||
setItemEditor(null);
|
||||
setItemError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success("明细已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -417,6 +442,7 @@ export function ReimbursementDetailPage() {
|
||||
setPendingOcrLinkedItemIds((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||||
);
|
||||
if (succeededIds.length) setInvoiceGuardIssues(null);
|
||||
|
||||
if (!failedResults.length) {
|
||||
setInvoiceActionError("");
|
||||
@@ -497,6 +523,7 @@ export function ReimbursementDetailPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
|
||||
]);
|
||||
setInvoiceActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
toast.success("发票已移除");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -506,6 +533,35 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const linkInvoiceItemMutation = useMutation({
|
||||
mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) =>
|
||||
linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId),
|
||||
onSuccess: async (_result, variables) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
|
||||
setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink(
|
||||
previous,
|
||||
variables.invoiceId,
|
||||
variables.linkedItemId,
|
||||
));
|
||||
setInvoiceItemSelections((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
setInvoiceItemLinkErrors((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
toast.success("发票已关联到报销明细");
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
const message = buildActionErrorMessage(error, "关联明细失败,请刷新后重试");
|
||||
setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message }));
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadInvoiceMutation = useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
const ownerEmployeeId = detailQuery.data?.ownerId;
|
||||
@@ -849,6 +905,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
try {
|
||||
setActionError("");
|
||||
setInvoiceGuardIssues(null);
|
||||
if (!hasCompleteTravelSummary(detail)) {
|
||||
const message = "请先补全出差事由、地点和起止日期";
|
||||
setActionError(message);
|
||||
@@ -1157,8 +1214,10 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
data-testid="reimbursement-action-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
@@ -1169,7 +1228,7 @@ export function ReimbursementDetailPage() {
|
||||
</p>
|
||||
) : null}
|
||||
{hasBlockingPrecheck ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
请修改超标明细后提交。
|
||||
</p>
|
||||
) : null}
|
||||
@@ -1216,7 +1275,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
{itemError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
data-testid="reimbursement-item-error"
|
||||
>
|
||||
{itemError}
|
||||
@@ -1243,9 +1302,13 @@ export function ReimbursementDetailPage() {
|
||||
detail.items.map((item) => (
|
||||
<TableRow
|
||||
data-testid="reimbursement-item-row"
|
||||
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
|
||||
key={item.id}
|
||||
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
|
||||
className={
|
||||
blockStandardChecks.some((check) => check.itemId === item.id)
|
||||
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length
|
||||
? "bg-red-50/70"
|
||||
: blockStandardChecks.some((check) => check.itemId === item.id)
|
||||
? "bg-red-50/70"
|
||||
: warnStandardChecks.some((check) => check.itemId === item.id)
|
||||
? "bg-amber-50/70"
|
||||
@@ -1271,6 +1334,15 @@ export function ReimbursementDetailPage() {
|
||||
{check.exceeded ? `,超出 ${formatMoney(check.exceededAmount)}` : ""}
|
||||
</div>
|
||||
))}
|
||||
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
|
||||
<div
|
||||
key={message}
|
||||
className="mt-2 rounded-md border border-red-200 bg-red-50 px-2 py-1.5 text-xs leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.description ?? "-"}</div>
|
||||
@@ -1399,20 +1471,36 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
{invoiceActionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceActionError}
|
||||
>
|
||||
{invoiceActionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{detail.invoiceAmountManualReviewRequired ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
|
||||
本单包含未执行 OCR 的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
|
||||
{detail.invoices.map((invoice) => {
|
||||
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
|
||||
? "已关联"
|
||||
: resolveInvoiceNextStepText(invoice);
|
||||
const guardMessages = invoiceGuardInvoiceMessages(
|
||||
invoiceGuardIssues,
|
||||
invoice.id,
|
||||
invoice.linkedItemId,
|
||||
);
|
||||
return (
|
||||
<div className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center" key={invoice.id}>
|
||||
<div
|
||||
className={`detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center ${guardMessages.length ? "border-red-300 bg-red-50/60" : ""}`}
|
||||
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
|
||||
key={invoice.id}
|
||||
tabIndex={guardMessages.length ? -1 : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
@@ -1432,16 +1520,83 @@ export function ReimbursementDetailPage() {
|
||||
<span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span>
|
||||
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
|
||||
</button>
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
{(guardMessages.length
|
||||
|| invoice.amountEvidenceStatus === "manual_review"
|
||||
|| invoice.amountEvidenceStatus === "ocr_failed"
|
||||
|| canUnbindInvoices) ? (
|
||||
<div className="w-full space-y-2 md:w-72">
|
||||
{guardMessages.map((message) => (
|
||||
<p
|
||||
key={message}
|
||||
className="rounded-md border border-red-200 bg-white px-3 py-2 text-xs leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{invoice.amountEvidenceStatus === "manual_review" ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
|
||||
票面金额未自动识别,需由财务查看原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
{invoice.amountEvidenceStatus === "ocr_failed" ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs leading-5 text-red-700">
|
||||
OCR 已处理但未取得票面金额,提交前需要重试识别或联系财务。
|
||||
</p>
|
||||
) : null}
|
||||
{canMaintainReimbursement && !invoice.linkedItemId ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-zinc-700" htmlFor={`invoice-item-link-${invoice.id}`}>
|
||||
选择报销明细
|
||||
</label>
|
||||
<select
|
||||
id={`invoice-item-link-${invoice.id}`}
|
||||
className="select-std w-full"
|
||||
value={invoiceItemSelections[invoice.id] ?? ""}
|
||||
onChange={(event) => setInvoiceItemSelections((previous) => ({
|
||||
...previous,
|
||||
[invoice.id]: event.target.value,
|
||||
}))}
|
||||
>
|
||||
<option value="">请选择明细</option>
|
||||
{detail.items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
|
||||
onClick={() => linkInvoiceItemMutation.mutate({
|
||||
invoiceId: invoice.id,
|
||||
linkedItemId: invoiceItemSelections[invoice.id],
|
||||
})}
|
||||
>
|
||||
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
|
||||
</Button>
|
||||
{invoiceItemLinkErrors[invoice.id] ? (
|
||||
<p className="text-xs leading-5 text-red-700" role="alert">
|
||||
{invoiceItemLinkErrors[invoice.id]}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -1607,7 +1762,7 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
|
||||
{invoiceActionError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{invoiceActionError}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -1616,7 +1771,7 @@ export function ReimbursementDetailPage() {
|
||||
{bindableInvoicesQuery.isLoading ? (
|
||||
<p className="text-sm text-zinc-500">正在加载可添加发票...</p>
|
||||
) : bindableInvoicesQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可添加发票失败")}
|
||||
</p>
|
||||
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
|
||||
|
||||
@@ -40,8 +40,10 @@ export function ReimbursementActions({
|
||||
<div className="space-y-4">
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
data-testid="reimbursement-action-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
|
||||
@@ -107,6 +107,7 @@ function buildInvoice() {
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 4096,
|
||||
linkedItemId: "item-r111" as string | null,
|
||||
amountEvidenceStatus: "verified" as "verified" | "manual_review" | "ocr_failed",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +189,11 @@ function buildReimbursementState() {
|
||||
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
|
||||
itemSaveRequests: [] as Array<Record<string, unknown>>,
|
||||
submitRequestCount: 0,
|
||||
submitGuardDetails: null as null | Record<string, unknown>,
|
||||
invoiceItemLinkRequests: [] as Array<{ invoiceId: string; linkedItemId?: string }>,
|
||||
invoiceItemLinkError: null as null | Record<string, unknown>,
|
||||
invoiceGuardEnabled: false,
|
||||
invoiceAmountManualReviewRequired: false,
|
||||
detailItems: null as null | Array<Record<string, unknown>>,
|
||||
};
|
||||
}
|
||||
@@ -237,6 +242,7 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
|
||||
approvalHistory: [],
|
||||
riskFlags: [],
|
||||
invoiceGuardEnabled: state.invoiceGuardEnabled,
|
||||
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
|
||||
items: state.detailItems ?? [
|
||||
{
|
||||
id: "item-r111",
|
||||
@@ -421,7 +427,61 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
|
||||
if (pathName === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
|
||||
state.submitRequestCount += 1;
|
||||
await fulfillJson(route, { id: reimbursementId, status: "reviewing" });
|
||||
if (state.submitGuardDetails) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-r111-submit-guard",
|
||||
error: {
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
details: state.submitGuardDetails,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
reimbursementId,
|
||||
status: "reviewing",
|
||||
invoiceAmountManualReviewRequired: state.invoiceAmountManualReviewRequired,
|
||||
manualReviewItemCount: state.invoiceAmountManualReviewRequired ? 1 : 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pathName.startsWith(`/api/reimbursements/${reimbursementId}/invoices/`)
|
||||
&& pathName.endsWith("/item-link")
|
||||
&& request.method() === "PATCH"
|
||||
) {
|
||||
const invoiceId = pathName.split("/").at(-2) ?? "";
|
||||
const payload = request.postDataJSON() as { linkedItemId?: string };
|
||||
state.invoiceItemLinkRequests.push({ invoiceId, linkedItemId: payload.linkedItemId });
|
||||
if (state.invoiceItemLinkError) {
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-r111-item-link",
|
||||
error: {
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
details: state.invoiceItemLinkError,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.invoices = state.invoices.map((invoice) => (
|
||||
invoice.id === invoiceId ? { ...invoice, linkedItemId: payload.linkedItemId ?? null } : invoice
|
||||
));
|
||||
await fulfillJson(route, {
|
||||
reimbursementId,
|
||||
invoiceId,
|
||||
linkedItemId: payload.linkedItemId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -855,6 +915,153 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await expect.poll(() => state.submitRequestCount).toBe(1);
|
||||
});
|
||||
|
||||
test("B2 提交错误在 PC/移动就近呈现,未归属发票可原子选择明细", async ({ page }) => {
|
||||
const state = buildReimbursementState();
|
||||
state.invoiceGuardEnabled = true;
|
||||
state.detailItems = [
|
||||
{
|
||||
id: "item-missing-r111",
|
||||
expenseTypeId: "expense-type-r111",
|
||||
expenseDate: "2026-05-08",
|
||||
amount: "200.00",
|
||||
description: "缺少发票的明细",
|
||||
expenseType: { ...buildExpenseTypes().items[0] },
|
||||
},
|
||||
{
|
||||
id: "item-ocr-r111",
|
||||
expenseTypeId: "expense-type-r111",
|
||||
expenseDate: "2026-05-08",
|
||||
amount: "300.00",
|
||||
description: "金额识别失败的明细",
|
||||
expenseType: { ...buildExpenseTypes().items[0] },
|
||||
},
|
||||
{
|
||||
id: "item-over-r111",
|
||||
expenseTypeId: "expense-type-r111",
|
||||
expenseDate: "2026-05-08",
|
||||
amount: "1000.00",
|
||||
description: "金额超过发票的明细",
|
||||
expenseType: { ...buildExpenseTypes().items[0] },
|
||||
},
|
||||
{
|
||||
id: "item-manual-r111",
|
||||
expenseTypeId: "expense-type-r111",
|
||||
expenseDate: "2026-05-08",
|
||||
amount: "400.00",
|
||||
description: "人工核对票面金额的明细",
|
||||
expenseType: { ...buildExpenseTypes().items[0] },
|
||||
},
|
||||
];
|
||||
const ocrFailedInvoice = {
|
||||
...buildInvoice(),
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
invoiceNumber: "OCR-FAILED",
|
||||
originalName: "金额未识别.pdf",
|
||||
totalAmount: null,
|
||||
linkedItemId: "item-ocr-r111",
|
||||
amountEvidenceStatus: "ocr_failed" as const,
|
||||
};
|
||||
const amountOverInvoice = {
|
||||
...buildInvoice(),
|
||||
id: "88888888-8888-4888-8888-888888888888",
|
||||
invoiceNumber: "AMOUNT-OVER",
|
||||
originalName: "金额不足.pdf",
|
||||
totalAmount: "600.00",
|
||||
linkedItemId: "item-over-r111",
|
||||
};
|
||||
const manualReviewInvoice = {
|
||||
...buildInvoice(),
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
invoiceNumber: "MANUAL-REVIEW",
|
||||
originalName: "待人工核对.pdf",
|
||||
totalAmount: null,
|
||||
linkedItemId: "item-manual-r111",
|
||||
amountEvidenceStatus: "manual_review" as const,
|
||||
};
|
||||
state.invoices = [buildUnlinkedInvoice(), ocrFailedInvoice, amountOverInvoice, manualReviewInvoice];
|
||||
state.invoiceAmountManualReviewRequired = true;
|
||||
state.submitGuardDetails = {
|
||||
items: "invoice_item_guard_failed",
|
||||
unlinkedInvoiceCount: 1,
|
||||
unlinkedInvoiceIds: [unlinkedInvoiceId],
|
||||
missingInvoiceItemIds: ["item-missing-r111"],
|
||||
amountUnavailableItems: [{
|
||||
itemId: "item-ocr-r111",
|
||||
invoiceIds: [ocrFailedInvoice.id],
|
||||
}],
|
||||
amountExceededItems: [{
|
||||
itemId: "item-over-r111",
|
||||
declaredAmount: "1000.00",
|
||||
invoiceTotal: "600.00",
|
||||
}],
|
||||
};
|
||||
|
||||
await mockApis(page, state);
|
||||
await login(page);
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await page.getByTestId("reimbursement-submit").click();
|
||||
|
||||
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
|
||||
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
|
||||
await expect(page.getByText("关联发票的票面金额未识别,请重试识别或联系财务处理。")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
|
||||
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
|
||||
await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0);
|
||||
await expect(page.getByText("item-missing-r111", { exact: false })).toHaveCount(0);
|
||||
await page.getByLabel("选择报销明细").selectOption("item-missing-r111");
|
||||
state.invoiceItemLinkError = { invoice: "invalid_unlinked_item_state" };
|
||||
await page.getByRole("button", { name: "确认关联" }).click();
|
||||
await expect(
|
||||
page.getByTestId("reimbursement-invoices").getByText("这张发票的关联状态已变化,请刷新后重试。"),
|
||||
).toBeVisible();
|
||||
expect(state.invoices.find((invoice) => invoice.id === unlinkedInvoiceId)?.linkedItemId).toBeNull();
|
||||
state.invoiceItemLinkError = null;
|
||||
await page.getByRole("button", { name: "确认关联" }).click();
|
||||
await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{
|
||||
invoiceId: unlinkedInvoiceId,
|
||||
linkedItemId: "item-missing-r111",
|
||||
}, {
|
||||
invoiceId: unlinkedInvoiceId,
|
||||
linkedItemId: "item-missing-r111",
|
||||
}]);
|
||||
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
|
||||
).toBeVisible();
|
||||
|
||||
const structuredGuardDetails = state.submitGuardDetails;
|
||||
state.submitGuardDetails = {
|
||||
items: "invoice_item_guard_failed",
|
||||
unlinkedInvoiceCount: "invalid-count",
|
||||
unlinkedInvoiceIds: [123],
|
||||
missingInvoiceItemIds: [{ id: "internal-only" }],
|
||||
amountUnavailableItems: [{ itemId: null, invoiceIds: [unlinkedInvoiceId] }],
|
||||
amountExceededItems: [{ itemId: "broken", declaredAmount: null, invoiceTotal: "600.00" }],
|
||||
};
|
||||
await page.getByTestId("reimbursement-submit").click();
|
||||
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText(
|
||||
"票据检查未通过,请核对每条明细与发票的关联和金额后重试。",
|
||||
);
|
||||
await expect(page.getByText(unlinkedInvoiceId, { exact: false })).toHaveCount(0);
|
||||
state.submitGuardDetails = structuredGuardDetails;
|
||||
|
||||
state.invoices = state.invoices.map((invoice) => (
|
||||
invoice.id === unlinkedInvoiceId ? { ...invoice, linkedItemId: null } : invoice
|
||||
));
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||
await page.getByTestId("reimbursement-submit").click();
|
||||
await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 4 项需要处理");
|
||||
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
|
||||
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
|
||||
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
|
||||
await expect(page.getByLabel("选择报销明细")).toBeVisible();
|
||||
});
|
||||
|
||||
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
|
||||
const state = buildReimbursementState();
|
||||
const mobile = await newWecomPage(browser);
|
||||
|
||||
+10
-1
@@ -49,6 +49,15 @@
|
||||
- OAOPS1 期间请用真实业务数据完成报销、采购/费用、合同台账和项目关联;遇到提交、审批、付款、导出或权限阻断时,记录页面、步骤和影响等级,让管理员当天建修复合同或暂停对应流程。
|
||||
- OALAUNCH1 收口期内,请把真实业务问题按“阻断 / 非阻断 / 建议”告诉管理员;不要只在聊天里说“系统有问题”。阻断包括无法提交、无法审批、付款/AP 无法闭环、越权可见、合同或资产敏感信息泄露、备份恢复或导出证据缺失。
|
||||
|
||||
### 通用报销填报规则
|
||||
|
||||
- 一条明细应表达同一费用类型和同一笔业务支出。不同类型或彼此独立的支出必须分开填写;连续跨天或跨期的同一笔支出(如多晚住宿、月度通讯费、按服务期计费)可以填写一条明细,并在说明中写清起止日期。
|
||||
- 发生日期填写实际费用发生或消费日期,不是提交报销的日期,也不一定等于发票日期;与发票日期不一致时,在说明中写清原因。
|
||||
- 上述明细粒度和日期口径是员工填报要求,由财务复核。系统不会自动比对日期,也不会判断明细拆分是否正确。
|
||||
- 每张有效发票都必须关联到一条明细。正式发票入口只接受官方 PDF;图片、截图和拍照材料不能从该入口上传,支付证明、订单或情况说明等材料应通过“上传附件 / 补充材料”提交。
|
||||
- 提交时,系统会检查已添加的发票是否都已关联明细、需要发票的明细是否有票面金额有效的关联发票,以及明细金额是否高于关联发票的票面合计。金额超出时系统会阻止提交,仅在说明中解释差额不能通过;申报金额低于票面合计(如使用优惠券、多人合并开票或部分自费)时仍需说明原因。
|
||||
- 报销被退回后需要调整明细时,请先在“报销材料”中逐张移除发票,再拆分、删除或修改明细,最后重新添加发票并为每张发票选择对应明细。不要先删除仍挂有发票的明细,否则发票可能变成无法重新选择的未归属状态。
|
||||
|
||||
### 差旅报销一页指南
|
||||
|
||||
1. 从企业微信工作台进入海音达工作台,打开 `报销`,新建报销单。
|
||||
@@ -62,7 +71,7 @@
|
||||
5. 支付截图应看得出商户、金额、支付时间和支付成功状态;银行卡号、支付账号、订单号等与复核无关的信息先遮挡。
|
||||
6. 补助按自然日计算:出发日、返回日各计 1 天,当天往返计 1 天;因个人原因延长的天数不计。公司或其他单位已直接承担餐食或车辆费用的,对应补助不得重复领取。
|
||||
7. 伙食补助和市内交通补助均为包干:领取伙食补助后个人餐费不得另报;领取市内交通补助后,出差期间公交、地铁、出租车、网约车等不得另报。城际交通、住宿及其他经批准的必要差旅费用凭有效材料实报实销。
|
||||
8. 实报实销明细填写个人实际支付的合规金额,原则上不得超过有效发票金额。优惠券、多人合并开票、部分自费或发票金额与实付金额不一致时,必须说明;已退款、公司直接支付或他人承担的金额不能再次报销。无法提供支付截图时上传情况说明,由财务决定是否例外接受,不得补造凭证。
|
||||
8. 需要发票的实报实销明细填写个人实际支付的合规金额,不得高于关联发票的票面合计,超出时系统会阻止提交。使用优惠券、多人合并开票或部分自费等导致申报金额低于票面合计时,仍需在说明中写清原因;已退款、公司直接支付或他人承担的金额不能再次报销。无法提供支付截图时上传情况说明,由财务决定是否接受其他证据,不得补造凭证。
|
||||
9. 提交前检查各项明细、对应 PDF、火车票和住宿费支付证明以及补助算式。提交后由直属主管审批、财务复核;实际付款和付款回单由财务处理,员工不预填。
|
||||
|
||||
OCR 会辅助识别发票字段和查重,但不代表系统已经确认费用真实合规。财务仍会同时核对明细金额、PDF 原件和个人支付证明;三者不一致时会退回补充或更正。
|
||||
|
||||
+7
-3
@@ -51,6 +51,9 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
|
||||
- 未配置真实 OCR 时使用 `OCR_PROVIDER=off`:PDF 直接进入 `available`,不入 OCR 队列、不生成发票代码、号码、日期、购销方或金额。员工仍可把票据添加到已有明细并提交,财务必须查看 PDF 原件人工核对。
|
||||
- off 模式下文件 SHA-256 查重继续有效,依赖发票代码和号码的查重暂不可用。对当前小团队可短期接受;强制人工补录或接入腾讯 OCR 应另行规划,不能在紧急报销中临时增加门槛。
|
||||
- 既有 `InvoicePool.ocr_provider` 为非空枚举且默认 `tencent`,所以 off 模式的新记录仍可能带该历史默认标记。判定是否实际调用 OCR 必须同时看 provider 配置、`ocr_attempts`、请求/完成时间和 OCR 结果,不能只看 `ocr_provider`。
|
||||
- 报销提交时必须把“已关联正式 PDF”和“具有可自动比较的票面金额”分开判断。金额已识别的票据可参加自动合计;没有任何 OCR 请求、尝试、完成或错误事实且金额为空的票据视为未执行 OCR,允许提交并持续标记为“需财务查看原件人工核对”。
|
||||
- 已发生 OCR 请求、尝试、完成或错误事实但金额仍为空,属于金额识别失败,不得伪装成“缺少发票”或直接放行;员工应重试识别或联系财务处理。
|
||||
- 同一明细只要包含未执行 OCR 的无金额票据,自动金额合计即不完整。即使同时存在有金额票据,也不得只用已知部分判断申报超额;整条明细转为财务人工核对。只有全部关联票据都有金额时,系统才执行申报金额与票面合计的自动比较。
|
||||
|
||||
## 样票脱敏和授权
|
||||
|
||||
@@ -95,6 +98,7 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
|
||||
- 发票状态为可添加。
|
||||
- 发票未被其他报销单、差旅单或归档流程占用。
|
||||
- `linkedItemId` 若传入,必须属于当前报销单。
|
||||
- 草稿或驳回单中,已添加但尚未选择明细的发票可以直接选择本单明细。该动作必须由服务端在一个事务内校验报销参与人、单据状态、发票归属和明细归属,只更新明细关联;不得要求员工先移除再重新添加,也不得让发票短暂回到可添加状态。
|
||||
|
||||
明细匹配规则:
|
||||
|
||||
@@ -148,12 +152,12 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
|
||||
- 火车电子票据、住宿电子普通发票等正式票据必须使用官方 PDF,从对应明细的发票入口上传并绑定;发票截图、支付截图或拍照图片不得冒充正式发票。
|
||||
- 火车票和住宿费均必须提供个人支付截图。支付截图属于整张报销单的补充附件,不绑定到发票记录;文件名或附件说明必须能对应到具体费用明细。
|
||||
- 支付截图保留商户、金额、支付时间和支付成功状态即可;银行卡号、支付账号、订单号等与审批无关的信息应先遮挡,不要求员工提供完整支付账户或银行流水。
|
||||
- 明细金额以员工个人实际支付的合规金额为准,原则上不得超过有效发票金额。优惠券、多人合并开票、部分自费、票面金额与实付金额不一致等情况必须在明细说明中写明差异。
|
||||
- 明细金额以员工个人实际支付的合规金额为准;需要发票的明细金额不得高于该明细关联发票的票面合计,超出时系统阻断提交。优惠券、多人合并开票、部分自费等导致申报金额低于票面合计时,必须在明细说明中写明差异。
|
||||
- 已退款、公司直接支付或由他人承担的金额不得重复报销。发生部分退款时,只能申报扣除退款后的个人实际承担金额,并附情况说明。
|
||||
- 确实无法提供支付截图时,员工必须上传情况说明;财务可以退回补充,也可以在有其他充分证据时例外接受并留痕。不得伪造、补造或借用他人支付凭证。
|
||||
- OCR 只辅助识别发票字段、匹配和查重,不判断支付真实性、费用合理性或发票法律状态。财务复核时必须同时查看明细金额、PDF 原件和个人支付证明;三者不一致时退回更正或补充说明。
|
||||
- 每张有效发票应关联到一条明确费用明细。一条明细只表达同一费用类型、同一发生日期和同一业务支出;采购物品还应是一件或一批同类物品,不同日期、不同支出或需要分别判断设备提示的多件物品应拆分。
|
||||
- 票据—明细门禁启用后,需票明细没有有效发票或申报金额高于关联票面合计时阻断提交;两项包干补助不参加票面比较。关闭门禁不会删除或解绑已经建立的关联关系。
|
||||
- 每张有效发票应关联到一条明确费用明细。一条明细只表达同一费用类型和同一笔业务支出;不同类型或彼此独立的支出应拆分,采购物品还应是一件或一批同类物品。连续跨天或跨期的同一笔业务支出(如多晚住宿、月度通讯费、按服务期计费)可以作为一条明细,并在说明中写清起止日期。
|
||||
- 票据—明细门禁启用后,已添加发票未选择明细、需票明细没有关联正式 PDF、已尝试 OCR 但票面金额仍无法识别,或在全部关联票据金额完整时申报金额高于票面合计,均阻断提交。未执行 OCR 的无金额 PDF 允许提交,但员工提交反馈和审批详情必须明确提示财务查看原件人工核对;两项包干补助不参加票据与票面比较。关闭门禁不会删除或解绑已经建立的关联关系。
|
||||
- 财务付款凭证号、付款日期、付款渠道和付款回单属于审批通过后的财务处理,不是员工提交报销时填写的个人支付证明。
|
||||
|
||||
### 差旅补助规定
|
||||
|
||||
@@ -33,6 +33,10 @@ const {
|
||||
resolveEntityPrecheck,
|
||||
} = require("../services/feeControl");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const {
|
||||
INVOICE_AMOUNT_EVIDENCE_STATUS,
|
||||
resolveInvoiceAmountEvidence,
|
||||
} = require("../services/invoiceAmountEvidence");
|
||||
const {
|
||||
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
|
||||
buildPaymentProcessingSemantics,
|
||||
@@ -103,6 +107,9 @@ const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREV
|
||||
const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z
|
||||
.object({ projectId: z.string().uuid().nullable() })
|
||||
.strict();
|
||||
const REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA = z
|
||||
.object({ linkedItemId: z.string().uuid() })
|
||||
.strict();
|
||||
|
||||
function resolveSignedFileOrigin(config, request) {
|
||||
const configured = String(config.appOrigin ?? "")
|
||||
@@ -2467,6 +2474,10 @@ async function reimbursementRoutes(fastify) {
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
linked_item_id: true,
|
||||
ocr_attempts: true,
|
||||
ocr_last_requested_at: true,
|
||||
ocr_last_error: true,
|
||||
ocr_completed_at: true,
|
||||
ocr_result: { select: { result_json: true } },
|
||||
},
|
||||
},
|
||||
@@ -2657,6 +2668,28 @@ async function reimbursementRoutes(fastify) {
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
const requiredInvoiceItemIds = new Set(
|
||||
reimbursement.items
|
||||
.filter((item) => item.expense_type?.requires_invoice)
|
||||
.map((item) => item.id),
|
||||
);
|
||||
const invoiceAmountEvidenceById = new Map(
|
||||
reimbursement.invoice_pool_items.map((invoice) => [
|
||||
invoice.id,
|
||||
resolveInvoiceAmountEvidence({
|
||||
totalAmount: invoice.total_amount,
|
||||
ocrAttempts: invoice.ocr_attempts,
|
||||
ocrLastRequestedAt: invoice.ocr_last_requested_at,
|
||||
ocrCompletedAt: invoice.ocr_completed_at,
|
||||
ocrLastError: invoice.ocr_last_error,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const invoiceAmountManualReviewRequired = reimbursement.invoice_pool_items.some(
|
||||
(invoice) =>
|
||||
requiredInvoiceItemIds.has(invoice.linked_item_id) &&
|
||||
invoiceAmountEvidenceById.get(invoice.id) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview,
|
||||
);
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: reimbursement.id,
|
||||
@@ -2736,6 +2769,7 @@ async function reimbursementRoutes(fastify) {
|
||||
mimeType: item.mime_type ?? null,
|
||||
fileSize: item.file_size ?? null,
|
||||
linkedItemId: item.linked_item_id ?? null,
|
||||
amountEvidenceStatus: invoiceAmountEvidenceById.get(item.id),
|
||||
})),
|
||||
pendingWorkflow: pendingInstance
|
||||
? {
|
||||
@@ -2755,6 +2789,7 @@ async function reimbursementRoutes(fastify) {
|
||||
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
|
||||
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
||||
invoiceGuardEnabled: invoiceGuardGate.enabled,
|
||||
invoiceAmountManualReviewRequired,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -3941,6 +3976,117 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/reimbursements/:id/invoices/:invoiceId/item-link",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedInvoiceId = UUID_SCHEMA.safeParse(request.params?.invoiceId);
|
||||
if (!parsedInvoiceId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { invoiceId: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsedBody.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const invoiceId = parsedInvoiceId.data;
|
||||
const linkedItemId = parsedBody.data.linkedItemId;
|
||||
const response = await fastify.prisma.$transaction(async (tx) => {
|
||||
const invoiceRows = await tx.$queryRaw`
|
||||
SELECT id, status, linked_reimbursement_id, linked_item_id
|
||||
FROM "invoice_pool"
|
||||
WHERE id = ${invoiceId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
const invoice = Array.isArray(invoiceRows) ? invoiceRows[0] ?? null : null;
|
||||
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { reimbursement: "invalid_status_for_invoice_item_link" },
|
||||
});
|
||||
}
|
||||
if (!invoice) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (
|
||||
invoice.status !== "bound" ||
|
||||
invoice.linked_reimbursement_id !== reimbursementId ||
|
||||
invoice.linked_item_id != null
|
||||
) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { invoice: "invalid_unlinked_item_state" },
|
||||
});
|
||||
}
|
||||
|
||||
const linkedItem = await tx.reimbursementItem.findFirst({
|
||||
where: { id: linkedItemId, reimbursement_id: reimbursementId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!linkedItem) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { linkedItemId: "item_not_in_reimbursement" },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.invoicePool.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
linked_item_id: linkedItem.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_INVOICE_ITEM_LINKED",
|
||||
targetType: "invoice_pool",
|
||||
targetId: invoiceId,
|
||||
extra: { reimbursementId, linkedItemId: linkedItem.id },
|
||||
}),
|
||||
});
|
||||
|
||||
return { invoiceId, reimbursementId, linkedItemId: linkedItem.id };
|
||||
});
|
||||
|
||||
return sendData(reply, request, response);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/precheck",
|
||||
{
|
||||
@@ -4016,7 +4162,14 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
invoice_pool_items: {
|
||||
where: { status: "bound" },
|
||||
select: { id: true, total_amount: true },
|
||||
select: {
|
||||
id: true,
|
||||
total_amount: true,
|
||||
ocr_attempts: true,
|
||||
ocr_last_requested_at: true,
|
||||
ocr_last_error: true,
|
||||
ocr_completed_at: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -4046,26 +4199,61 @@ async function reimbursementRoutes(fastify) {
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
const manualReviewItemIds = reimbursementItems
|
||||
.filter((item) => item.expense_type.requires_invoice)
|
||||
.filter((item) => item.invoice_pool_items.some((invoice) => (
|
||||
resolveInvoiceAmountEvidence({
|
||||
totalAmount: invoice.total_amount,
|
||||
ocrAttempts: invoice.ocr_attempts,
|
||||
ocrLastRequestedAt: invoice.ocr_last_requested_at,
|
||||
ocrCompletedAt: invoice.ocr_completed_at,
|
||||
ocrLastError: invoice.ocr_last_error,
|
||||
}) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview
|
||||
)))
|
||||
.map((item) => item.id);
|
||||
if (invoiceGuardGate.enabled) {
|
||||
const unlinkedBoundInvoiceCount = await tx.invoicePool.count({
|
||||
const unlinkedBoundInvoices = await tx.invoicePool.findMany({
|
||||
where: {
|
||||
linked_reimbursement_id: reimbursementId,
|
||||
linked_item_id: null,
|
||||
status: "bound",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const missingInvoiceItemIds = [];
|
||||
const amountUnavailableItems = [];
|
||||
const amountExceededItems = [];
|
||||
for (const item of reimbursementItems) {
|
||||
if (!item.expense_type.requires_invoice) continue;
|
||||
const validInvoices = item.invoice_pool_items.filter(
|
||||
(invoice) => invoice.total_amount != null,
|
||||
);
|
||||
if (!validInvoices.length) {
|
||||
if (!item.invoice_pool_items.length) {
|
||||
missingInvoiceItemIds.push(item.id);
|
||||
continue;
|
||||
}
|
||||
const invoiceTotal = validInvoices.reduce(
|
||||
|
||||
const evidenceByInvoice = item.invoice_pool_items.map((invoice) => ({
|
||||
invoice,
|
||||
status: resolveInvoiceAmountEvidence({
|
||||
totalAmount: invoice.total_amount,
|
||||
ocrAttempts: invoice.ocr_attempts,
|
||||
ocrLastRequestedAt: invoice.ocr_last_requested_at,
|
||||
ocrCompletedAt: invoice.ocr_completed_at,
|
||||
ocrLastError: invoice.ocr_last_error,
|
||||
}),
|
||||
}));
|
||||
const failedInvoiceIds = evidenceByInvoice
|
||||
.filter(({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed)
|
||||
.map(({ invoice }) => invoice.id);
|
||||
if (failedInvoiceIds.length) {
|
||||
amountUnavailableItems.push({ itemId: item.id, invoiceIds: failedInvoiceIds });
|
||||
continue;
|
||||
}
|
||||
if (evidenceByInvoice.some(
|
||||
({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const invoiceTotal = item.invoice_pool_items.reduce(
|
||||
(sum, invoice) => sum.plus(invoice.total_amount),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
@@ -4077,15 +4265,22 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (unlinkedBoundInvoiceCount || missingInvoiceItemIds.length || amountExceededItems.length) {
|
||||
if (
|
||||
unlinkedBoundInvoices.length ||
|
||||
missingInvoiceItemIds.length ||
|
||||
amountUnavailableItems.length ||
|
||||
amountExceededItems.length
|
||||
) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: {
|
||||
items: "invoice_item_guard_failed",
|
||||
unlinkedInvoiceCount: unlinkedBoundInvoiceCount,
|
||||
unlinkedInvoiceCount: unlinkedBoundInvoices.length,
|
||||
unlinkedInvoiceIds: unlinkedBoundInvoices.map((invoice) => invoice.id),
|
||||
missingInvoiceItemIds,
|
||||
amountUnavailableItems,
|
||||
amountExceededItems,
|
||||
},
|
||||
});
|
||||
@@ -4420,6 +4615,8 @@ async function reimbursementRoutes(fastify) {
|
||||
precheckDecision: authoritativePrecheck.decision,
|
||||
standardWarnings,
|
||||
openedRiskFlagCount: openedRiskFlags.length,
|
||||
invoiceAmountManualReviewRequired: manualReviewItemIds.length > 0,
|
||||
manualReviewItemCount: manualReviewItemIds.length,
|
||||
},
|
||||
sideEffectsContext: {
|
||||
reimbursementId,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const INVOICE_AMOUNT_EVIDENCE_STATUS = Object.freeze({
|
||||
verified: "verified",
|
||||
manualReview: "manual_review",
|
||||
ocrFailed: "ocr_failed",
|
||||
});
|
||||
|
||||
function resolveInvoiceAmountEvidence({
|
||||
totalAmount,
|
||||
ocrAttempts,
|
||||
ocrLastRequestedAt,
|
||||
ocrCompletedAt,
|
||||
ocrLastError,
|
||||
}) {
|
||||
if (totalAmount != null) {
|
||||
return INVOICE_AMOUNT_EVIDENCE_STATUS.verified;
|
||||
}
|
||||
|
||||
const hasOcrExecutionEvidence =
|
||||
Number(ocrAttempts ?? 0) > 0 ||
|
||||
ocrLastRequestedAt != null ||
|
||||
ocrCompletedAt != null ||
|
||||
(typeof ocrLastError === "string" && ocrLastError.trim().length > 0);
|
||||
|
||||
return hasOcrExecutionEvidence
|
||||
? INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed
|
||||
: INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INVOICE_AMOUNT_EVIDENCE_STATUS,
|
||||
resolveInvoiceAmountEvidence,
|
||||
};
|
||||
@@ -41,6 +41,7 @@ function buildReimbursementRecord(overrides = {}) {
|
||||
name: "打车费",
|
||||
category: "travel",
|
||||
enabled: true,
|
||||
requires_invoice: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -56,6 +57,10 @@ function buildReimbursementRecord(overrides = {}) {
|
||||
total_amount: "188.80",
|
||||
original_name: "receipt.pdf",
|
||||
linked_item_id: "item-1",
|
||||
ocr_attempts: 1,
|
||||
ocr_last_requested_at: new Date("2026-04-09T01:00:00.000Z"),
|
||||
ocr_last_error: null,
|
||||
ocr_completed_at: new Date("2026-04-09T01:00:01.000Z"),
|
||||
},
|
||||
],
|
||||
workflow_instances: [
|
||||
@@ -358,8 +363,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||
totalAmount: "188.80",
|
||||
originalName: "receipt.pdf",
|
||||
linkedItemId: "item-1",
|
||||
amountEvidenceStatus: "verified",
|
||||
},
|
||||
],
|
||||
invoiceAmountManualReviewRequired: false,
|
||||
approvalHistory: [
|
||||
{
|
||||
id: "history-start",
|
||||
@@ -394,6 +401,47 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps the manual amount-review marker visible to a finance detail reader", async () => {
|
||||
const reimbursement = buildReimbursementRecord({
|
||||
invoice_pool_items: [{
|
||||
id: "invoice-off",
|
||||
status: "bound",
|
||||
invoice_code: null,
|
||||
invoice_number: null,
|
||||
invoice_date: null,
|
||||
seller_name: null,
|
||||
total_amount: null,
|
||||
original_name: "manual-review.pdf",
|
||||
linked_item_id: "item-1",
|
||||
ocr_attempts: 0,
|
||||
ocr_last_requested_at: null,
|
||||
ocr_last_error: null,
|
||||
ocr_completed_at: null,
|
||||
}],
|
||||
});
|
||||
const { app } = await buildApp({
|
||||
employeeId: "finance-reader",
|
||||
permissionCodes: ["expense.view_all"],
|
||||
reimbursement,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({
|
||||
invoiceAmountManualReviewRequired: true,
|
||||
invoices: [expect.objectContaining({
|
||||
id: "invoice-off",
|
||||
amountEvidenceStatus: "manual_review",
|
||||
})],
|
||||
}));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns null submittedAt and empty approvalHistory when reimbursement has never been submitted", async () => {
|
||||
const reimbursement = buildReimbursementRecord({
|
||||
status: "draft",
|
||||
|
||||
@@ -102,6 +102,14 @@ async function buildApp(options = {}) {
|
||||
? (options.unlinkedBoundInvoiceCount ?? 0)
|
||||
: (options.boundInvoiceCount ?? 1)
|
||||
)),
|
||||
findMany: vi.fn(async ({ where } = {}) => (
|
||||
where?.linked_item_id === null
|
||||
? Array.from(
|
||||
{ length: options.unlinkedBoundInvoiceCount ?? 0 },
|
||||
(_unused, index) => ({ id: `unlinked-invoice-${index + 1}` }),
|
||||
)
|
||||
: []
|
||||
)),
|
||||
},
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
||||
@@ -409,6 +417,35 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps the finance manual-review success marker when the B2 guard is off", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app } = await buildApp({
|
||||
invoiceGuardEnabled: false,
|
||||
boundInvoiceCount: 1,
|
||||
reimbursementItems: [{
|
||||
id: "manual-review-guard-off",
|
||||
amount: "180.00",
|
||||
invoice_pool_items: [{
|
||||
id: "off-invoice-guard-off",
|
||||
total_amount: null,
|
||||
ocr_attempts: 0,
|
||||
ocr_last_requested_at: null,
|
||||
ocr_last_error: null,
|
||||
ocr_completed_at: null,
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({
|
||||
invoiceAmountManualReviewRequired: true,
|
||||
manualReviewItemCount: 1,
|
||||
}));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("blocks required-invoice items without linked invoices when the guard is on", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
@@ -441,6 +478,112 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("identifies unlinked invoices without exposing only a count", async () => {
|
||||
const { app } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
unlinkedBoundInvoiceCount: 2,
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||
unlinkedInvoiceCount: 2,
|
||||
unlinkedInvoiceIds: ["unlinked-invoice-1", "unlinked-invoice-2"],
|
||||
}));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("blocks a linked invoice whose OCR ran without producing an amount", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
reimbursementItems: [{
|
||||
id: "ocr-failed-item",
|
||||
amount: "180.00",
|
||||
invoice_pool_items: [{
|
||||
id: "ocr-failed-invoice",
|
||||
total_amount: null,
|
||||
ocr_attempts: 1,
|
||||
ocr_last_requested_at: new Date("2026-08-30T10:00:00.000Z"),
|
||||
ocr_last_error: "amount_missing",
|
||||
ocr_completed_at: new Date("2026-08-30T10:00:01.000Z"),
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||
missingInvoiceItemIds: [],
|
||||
amountUnavailableItems: [{
|
||||
itemId: "ocr-failed-item",
|
||||
invoiceIds: ["ocr-failed-invoice"],
|
||||
}],
|
||||
}));
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows a linked PDF with no OCR execution evidence and marks finance manual review", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
reimbursementItems: [{
|
||||
id: "manual-review-item",
|
||||
amount: "180.00",
|
||||
invoice_pool_items: [{
|
||||
id: "off-invoice",
|
||||
total_amount: null,
|
||||
ocr_attempts: 0,
|
||||
ocr_last_requested_at: null,
|
||||
ocr_last_error: null,
|
||||
ocr_completed_at: null,
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({
|
||||
invoiceAmountManualReviewRequired: true,
|
||||
manualReviewItemCount: 1,
|
||||
}));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not compare a declared amount against only the known part of mixed evidence", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
reimbursementItems: [{
|
||||
id: "mixed-evidence-item",
|
||||
amount: "1000.00",
|
||||
invoice_pool_items: [
|
||||
{ id: "known-invoice", total_amount: "600.00" },
|
||||
{
|
||||
id: "off-invoice",
|
||||
total_amount: null,
|
||||
ocr_attempts: 0,
|
||||
ocr_last_requested_at: null,
|
||||
ocr_last_error: null,
|
||||
ocr_completed_at: null,
|
||||
},
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({
|
||||
invoiceAmountManualReviewRequired: true,
|
||||
manualReviewItemCount: 1,
|
||||
}));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("blocks declared amounts above the linked invoice total when the guard is on", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
@@ -462,6 +605,33 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows five required items when each has one verified bound invoice", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const reimbursementItems = Array.from({ length: 5 }, (_unused, index) => ({
|
||||
id: `verified-item-${index + 1}`,
|
||||
amount: "100.00",
|
||||
invoice_pool_items: [{
|
||||
id: `verified-invoice-${index + 1}`,
|
||||
total_amount: "100.00",
|
||||
}],
|
||||
}));
|
||||
const { app, tx } = await buildApp({
|
||||
invoiceGuardEnabled: true,
|
||||
reimbursementItems,
|
||||
authoritativeTotal: "500.00",
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({
|
||||
invoiceAmountManualReviewRequired: false,
|
||||
manualReviewItemCount: 0,
|
||||
}));
|
||||
expect(tx.workflowInstance.create).toHaveBeenCalledTimes(1);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("recomputes a stale total before selecting the approval rule", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
||||
|
||||
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_REIMBURSEMENT_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const INVOICE_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const ITEM_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const ACTOR_ID = "55555555-5555-4555-8555-555555555555";
|
||||
const OTHER_EMPLOYEE_ID = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
function sqlText(strings) {
|
||||
return Array.isArray(strings) ? strings.join("?") : String(strings);
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: ["expense.edit_own"],
|
||||
roleCodes: [],
|
||||
};
|
||||
const invoice = {
|
||||
id: INVOICE_ID,
|
||||
status: "bound",
|
||||
linked_reimbursement_id: REIMBURSEMENT_ID,
|
||||
linked_item_id: null,
|
||||
...options.invoice,
|
||||
};
|
||||
const reimbursement = {
|
||||
id: REIMBURSEMENT_ID,
|
||||
owner_id: ACTOR_ID,
|
||||
submitter_id: ACTOR_ID,
|
||||
status: "draft",
|
||||
...options.reimbursement,
|
||||
};
|
||||
const tx = {
|
||||
$queryRaw: vi.fn(async (strings) => {
|
||||
const sql = sqlText(strings);
|
||||
if (sql.includes('FROM "invoice_pool"')) return options.invoiceMissing ? [] : [invoice];
|
||||
if (sql.includes('FROM "reimbursement_forms"')) return options.reimbursementMissing ? [] : [reimbursement];
|
||||
return [];
|
||||
}),
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn(async () => options.itemMissing ? null : { id: ITEM_ID }),
|
||||
},
|
||||
invoicePool: {
|
||||
update: vi.fn(async ({ data }) => ({ ...invoice, ...data })),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(async () => ({ id: "audit-1" })),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
};
|
||||
const app = Fastify();
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
reimbursementInvoiceGuardEnabled: true,
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => (
|
||||
employee.permissionCodes.includes("*") || employee.permissionCodes.includes(permissionCode)
|
||||
));
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
if (!app.hasPermission(actor, permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code ?? "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? {},
|
||||
},
|
||||
}));
|
||||
|
||||
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||
return { app, prisma, tx };
|
||||
}
|
||||
|
||||
function requestPayload() {
|
||||
return {
|
||||
method: "PATCH",
|
||||
url: `/api/reimbursements/${REIMBURSEMENT_ID}/invoices/${INVOICE_ID}/item-link`,
|
||||
payload: { linkedItemId: ITEM_ID },
|
||||
};
|
||||
}
|
||||
|
||||
describe("PATCH reimbursement invoice item link", () => {
|
||||
it("atomically links an unassigned bound invoice to an item in the same reimbursement", async () => {
|
||||
const { app, tx } = await buildApp();
|
||||
|
||||
const response = await app.inject(requestPayload());
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
invoiceId: INVOICE_ID,
|
||||
reimbursementId: REIMBURSEMENT_ID,
|
||||
linkedItemId: ITEM_ID,
|
||||
});
|
||||
expect(tx.reimbursementItem.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: ITEM_ID, reimbursement_id: REIMBURSEMENT_ID },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(tx.invoicePool.update).toHaveBeenCalledWith({
|
||||
where: { id: INVOICE_ID },
|
||||
data: { linked_item_id: ITEM_ID, updated_by: ACTOR_ID },
|
||||
});
|
||||
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["submitter participant", { owner_id: OTHER_EMPLOYEE_ID, submitter_id: ACTOR_ID, status: "draft" }],
|
||||
["rejected form", { owner_id: ACTOR_ID, submitter_id: ACTOR_ID, status: "rejected" }],
|
||||
])("allows %s to restore the item link", async (_label, reimbursement) => {
|
||||
const { app, tx } = await buildApp({ reimbursement });
|
||||
|
||||
const response = await app.inject(requestPayload());
|
||||
|
||||
expect(response.statusCode, response.body).toBe(200);
|
||||
expect(tx.invoicePool.update).toHaveBeenCalledTimes(1);
|
||||
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["nonparticipant", { reimbursement: { owner_id: OTHER_EMPLOYEE_ID, submitter_id: OTHER_EMPLOYEE_ID } }, 403],
|
||||
["nonparticipant with a foreign invoice", {
|
||||
reimbursement: { owner_id: OTHER_EMPLOYEE_ID, submitter_id: OTHER_EMPLOYEE_ID },
|
||||
invoice: { linked_reimbursement_id: OTHER_REIMBURSEMENT_ID },
|
||||
}, 403],
|
||||
["submitted reimbursement", { reimbursement: { status: "reviewing" } }, 409],
|
||||
["item from another reimbursement", { itemMissing: true }, 409],
|
||||
["invoice already assigned", { invoice: { linked_item_id: ITEM_ID } }, 409],
|
||||
["invoice belongs to another reimbursement", { invoice: { linked_reimbursement_id: OTHER_REIMBURSEMENT_ID } }, 409],
|
||||
["invoice is no longer bound", { invoice: { status: "available" } }, 409],
|
||||
])("rejects %s with zero writes", async (_label, options, expectedStatus) => {
|
||||
const { app, tx } = await buildApp(options);
|
||||
|
||||
const response = await app.inject(requestPayload());
|
||||
|
||||
expect(response.statusCode).toBe(expectedStatus);
|
||||
expect(tx.invoicePool.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns not found for a missing invoice only after validating the reimbursement participant", async () => {
|
||||
const { app, tx } = await buildApp({ invoiceMissing: true });
|
||||
|
||||
const response = await app.inject(requestPayload());
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(tx.invoicePool.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects an actor without the own-expense edit permission before reading rows", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: { id: ACTOR_ID, permissionCodes: [], roleCodes: [] },
|
||||
});
|
||||
|
||||
const response = await app.inject(requestPayload());
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(tx.invoicePool.update).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const {
|
||||
INVOICE_AMOUNT_EVIDENCE_STATUS,
|
||||
resolveInvoiceAmountEvidence,
|
||||
} = require("../../../src/services/invoiceAmountEvidence");
|
||||
|
||||
describe("resolveInvoiceAmountEvidence", () => {
|
||||
it("treats a present amount, including zero, as verified", () => {
|
||||
expect(resolveInvoiceAmountEvidence({
|
||||
totalAmount: "0.00",
|
||||
ocrAttempts: 0,
|
||||
ocrLastRequestedAt: null,
|
||||
ocrCompletedAt: null,
|
||||
ocrLastError: null,
|
||||
})).toBe(INVOICE_AMOUNT_EVIDENCE_STATUS.verified);
|
||||
});
|
||||
|
||||
it("requires manual review when no amount and no OCR execution evidence exist", () => {
|
||||
expect(resolveInvoiceAmountEvidence({
|
||||
totalAmount: null,
|
||||
ocrAttempts: 0,
|
||||
ocrLastRequestedAt: null,
|
||||
ocrCompletedAt: null,
|
||||
ocrLastError: null,
|
||||
})).toBe(INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["attempt count", { ocrAttempts: 1 }],
|
||||
["request time", { ocrLastRequestedAt: new Date("2026-08-30T10:00:00.000Z") }],
|
||||
["completion time", { ocrCompletedAt: new Date("2026-08-30T10:00:01.000Z") }],
|
||||
["last error", { ocrLastError: "amount_missing" }],
|
||||
])("treats missing amount with %s as OCR failed", (_label, evidence) => {
|
||||
expect(resolveInvoiceAmountEvidence({
|
||||
totalAmount: null,
|
||||
ocrAttempts: 0,
|
||||
ocrLastRequestedAt: null,
|
||||
ocrCompletedAt: null,
|
||||
ocrLastError: null,
|
||||
...evidence,
|
||||
})).toBe(INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户