收紧报销草稿读取边界 [09-04-reimbursement-state-gap-finance-view][R2]

这个提交包含在:
2026-09-05 09:13:53 +08:00
父节点 1b120e0829
当前提交 8e0af8235e
共修改 14 个文件,包含 704 行新增和 120 行删除
@@ -686,3 +686,56 @@ await prisma.$transaction(async (tx) => {
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", id, { reason });
});
```
## Scenario: Reimbursement Draft Business-Fact Visibility
### 1. Scope / Trigger
- Trigger: changing reimbursement list/detail/material/search/export/statistics read scope or adding a new record-level read surface.
- `expense.view_all` covers submitted business facts; it does not turn another employee's never-submitted draft into a shared ledger record.
### 2. Signatures
- Shared builder: `buildReadableReimbursementWhere({ employee, scope, filters })` in `server/src/services/reimbursementVisibility.js`.
- Object check: `canReadReimbursementBusinessFact(prisma, reimbursement, employeeId)`.
- Workflow fact: `workflow_instances.entity_type='reimbursement' AND entity_id=<reimbursement-id>`; Prisma relation filters may use `workflow_instances: { some: {} }` because the relation is reimbursement-specific.
### 3. Contracts
- Owner or submitter keeps existing access to their draft. A non-draft keeps the existing role/team scope. Another employee's draft is readable only if at least one reimbursement workflow instance exists.
- Returned and withdrawn drafts remain business facts regardless of the latest workflow status or outcome. Never replace existence with a pending/latest/outcome whitelist.
- Apply the shared condition before pagination/counting to list, global search, reimbursement exports and dashboard/statistics. Raw SQL uses an indexed `EXISTS` on `entity_type + entity_id`.
- Detail, attachment file, attachment preview URL, material bundle and read-only `precheck` fail closed through the same fact check. A current or historical workflow reviewer remains independently readable through task assignment and is not granted edit permission.
### 4. Validation & Error Matrix
- Other employee + `draft` + no workflow -> excluded from collections and `403 PERM_001` on known-ID reads without revealing why.
- Other employee + `draft` + workflow exists -> existing global/team/reviewer scope applies.
- Owner/submitter draft -> existing own permission applies without requiring workflow history.
- Missing status/object or failed fact lookup -> deny; never assume a missing value is non-draft.
### 5. Good/Base/Bad Cases
- Good: finance sees a returned draft but not a colleague's unfinished first draft; the same result holds in list, search, export, statistics and materials.
- Base: a reviewer without `expense.view_all` opens the reimbursement attached to a current or historical assigned task.
- Bad: hide drafts only in React, filter every `draft`, or let `precheck` expose policy/risk facts for an otherwise unreadable draft.
### 6. Tests Required
- Table-driven global/team/own scope and owner/submitter/non-draft/returned/withdrawn/never-submitted classification.
- Route tests cover all five object-level read entries, including `precheck`, plus a reviewer non-regression.
- Structural and route tests keep list, search, both reimbursement exports, dashboard/statistics and raw SQL on the shared fact contract.
### 7. Wrong vs Correct
#### Wrong
```js
if (hasPermission(employee, "expense.view_all")) return reimbursement;
```
#### Correct
```js
const where = buildReadableReimbursementWhere({ employee, scope: "all", filters });
await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);
```
@@ -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,118 @@
# 报销状态空隙与财务处理视角:设计
## 1. 设计边界
本任务包含两个独立变更单元:
- R1 是付款运营入口与只读进度投影调整,复用现有付款候选及创建合同。
- R2 是报销读取授权收紧,使用已有 workflow 关系判断是否形成过业务事实。
两者不共享新数据库字段、feature flag 或写状态机。实现、测试和提交顺序允许分开回滚;部署候选可同时包含两项,但不得形成互相调用或互为前置的数据依赖。
## 2. R1:待组批运营队列
### 2.1 服务端合同
继续以 `GET /payment-batches/candidates` 为唯一候选来源:`approved`、`paid_at=null`、不属于任何非取消报销付款批次,并通过付款部门门禁。接口已经提供搜索、页码、页大小和 `total`,无需新增第二个候选接口或在客户端复制 eligibility。
候选查询仍要求 `payment.batch.manage`。付款批次列表继续查询已经创建的 `PaymentBatch`;“待组批”与“付款批次”是同一付款工作台的两个数据视图,不把候选伪装成批次记录。
### 2.2 页面状态与路由
付款页引入显式 URL 视图,例如:
- `/payment-batches?view=pending`:待组批。
- `/payment-batches?view=batches`:付款批次。
- `/payment-batches?id=<batch-id>`:既有批次详情,优先于 view。
报销详情深链使用 `view=pending&q=<form-number>`。页面从 URL 初始化搜索并保持返回来源;不得把内部 UUID 放入用户文案。组批管理员无 detail 参数时默认 pending,复核只读角色固定 batches。
现有“新建批次”面板中的候选搜索/选择提升为 pending 视图的主体,创建所需的最小字段和主动作沿用当前 KISS 合同。实现应只有一个候选 `map` 和一份选择状态;不能保留旧弹层或折叠区中的第二份候选 DOM。
候选行使用稳定 entity ID 作为 key,展示服务端权威字段。查询超过 300ms 显示加载反馈;请求失败就地显示可重试错误;空态只写“当前没有待组批报销”。选择后才显示批量创建主动作,避免未选择时铺设高风险动作。
### 2.3 进度语义
共享 `PAYMENT_PROCESSING_STAGES` 已有 `payment_batch_created / 批次创建`,但报销详情当前故意丢弃该阶段并在无批次时把 `payment_batch_review` 提前标 current。本任务调整报销组装层:
- 将 created 阶段对报销用户重命名/投影为“组建付款批次”,不改变付款批次详情使用的“批次创建”。
- 无 batch:created=current,result=待组批,detail=待财务组建付款批次;review=pending;payment=pending。
- 有 batch:三个付款阶段全部来自共享 `buildPaymentProcessingSemantics`,created 的时间和创建人按现有 actorSource 解析,review/payment 语义不变。
报销 DTO 阶段 key 增加 `payment_batch_created` 属向后兼容的数组扩展;旧客户端按通用 stage 渲染或忽略,不需要 schema/migration。结构测试锁定共享语义仍只有一套,禁止在客户端根据 batch status 再写矩阵。
### 2.4 查询刷新
创建批次成功后同时失效:候选列表、候选总数、付款批次列表和当前批次详情所需查询。候选资格由服务端自然消失;客户端不得乐观伪造 `approved -> batched` 状态。失败时保留选择并显示现有自然中文错误。
## 3. R2:曾进入流程的草稿可见性
### 3.1 权威判据
定义可复用的 Prisma 条件:
```text
own-or-submitted-draft =
owner_id = actor
OR submitter_id = actor
OR workflow_instances.some(entity_type = reimbursement)
```
关系本身已限定为报销实体;若 Prisma relation 不需要额外 entity type 条件,以 relation `some: {}` 为准,并由结构测试证明关联字段正确。判据只在记录 `status=draft` 时附加。非 draft 沿用原范围条件。
撤回 workflow 的 `current_status=cancelled`、退回 workflow 的 `current_status=terminated`,两者都满足“曾进入流程”;不得使用 pending/latest/outcome 过滤。
### 3.2 列表、搜索、导出和统计
提取或共享一个“当前人员可读取的报销事实”条件构建器,组合顺序为:
1. 既有角色范围:全局角色 `{}`;经理为本人/提交人/直属下属;普通 own 为本人/提交人。
2. 业务事实条件:非 draft,或本人/提交人,或存在 workflow。
3. 页面自己的状态、时间、部门、搜索等条件。
列表、全局搜索、报销 CSV/XLSX 导出、报销统计/看板使用同一事实条件。原始 SQL 统计使用 `EXISTS` 子查询表达相同合同,不能因为实现技术不同而遗漏。审批瓶颈查询本身 inner join workflow 且只查 reviewing,天然满足,但测试应证明无需额外分支。
付款候选固定只查 approved,不复用这个授权条件;项目关联、AP 等非全范围草稿入口若本就不展示 draft,保持不变。实现前逐个确认所有报销 record-level 导出入口,防止只修一条 CSV 路径。
### 3.3 详情与材料防旁路
把当前 `ensureOwnCanViewAllOrWorkflowReviewer` 的无条件 `canViewAll` 快速放行改为异步事实判断:
- 本人/提交人:按原权限放行。
- 非本人且持有 view_all:记录非 draft 直接放行;draft 必须查询至少一个 workflow instance。
- 历史/当前 workflow reviewer:继续按既有 task 关系放行;从未提交草稿不可能存在 reviewer task。
- 其他人员:拒绝。
详情、附件文件、附件 preview URL、完整材料 preview 和 `POST /reimbursements/:id/precheck` 都调用同一事实授权 helper。材料与 precheck 的现有轻量查询没有同时选择 status/workflow,可由 helper 使用 reimbursement ID 做最小 `findFirst/count`,避免为每个调用方复制状态和 workflow 查询。拒绝统一返回既有 `PERM_001`,不区分不存在、未提交或越权原因。
写接口的 owner/submitter、状态和 permission 门禁不因本任务放宽。`precheck` 虽是 POST,但只读返回预算、费用标准、审批规则与风险事实,必须使用同一读取边界;它不得因重用 helper 让 view_all 用户获得修改他人草稿的能力。当前或历史 workflow reviewer 分支继续按 task 关系独立放行,focused 测试必须证明新草稿过滤不会阻断审批人读取自己的待办报销。
### 3.4 性能与兼容
优先使用现有 relation filter 与 `workflow_instances(entity_type, reimbursement_id)` 查询路径,不新增 `submitted_at` 或 migration。分页先过滤后计数,保证 `items` 和 `total` 一致。对原始 SQL 使用 `EXISTS`,避免 join 放大金额或记录数。
生产当前仅 1 张从未提交草稿、0 张有 workflow 的草稿,发布不需要数据修复。历史 returned/withdrawn/rejected 数据不改写;列表状态中文映射、审批历史和再次提交保持兼容。
## 4. 权限与状态矩阵
| 访问者/记录 | 自己的未提交 draft | 范围内他人的未提交 draft | 范围内他人的退回/撤回 draft | 范围内他人的非 draft |
| --- | --- | --- | --- | --- |
| owner/submitter | 可见、可按原规则编辑 | 不适用 | 可见、可按原规则编辑 | 可见 |
| 全局 view_all 角色 | 可见 | 不可见 | 只读可见 | 只读可见 |
| manager | 可见 | 不可见 | 仅直属团队只读可见 | 仅直属团队只读可见 |
| workflow reviewer(无 view_all) | 按本人规则 | 不存在 reviewer task,拒绝 | 有历史任务时只读可见 | 有任务时只读可见 |
| 无关员工 | 按本人规则 | 不可见 | 不可见 | 不可见 |
## 5. UI/UX 约束
本任务使用现有 React/Tailwind 工作台设计,不引入新色板、字体或动画体系。`ui-ux-pro-max` 的适用结论落实为:异步候选查询必须有加载/错误反馈;列表使用稳定 key;导航和搜索状态写入 URL;危险批量动作仅在选择后出现;移动触控目标维持至少 44px;1280px 不产生横向滚动。
“待组批”是可执行运营队列,不是解释卡:首屏优先数量、搜索、候选和组批动作,不重复说明系统内部状态。付款批次仍是历史与后续处理对象,两者用清晰标签区分。
## 6. 发布、回滚与真实验收
- 无 migration、schema、seed、flag、生产配置或数据修正。
- R1 与 R2 使用独立提交和 focused gate。R1 回滚只恢复付款视图/进度投影;R2 回滚只恢复读取条件。任何一项回滚都不撤销已由财务人工创建的批次。
- 部署前跑 UI strict、权限/workflow focused 回归和标准 provenance 门禁。
- 部署后只读确认目标单据在待组批可搜到、未被自动修改;由财务人工点击创建后,才能形成真实付款复核对象。
- R2 部署后使用受控测试身份或既有非敏感 fixture 核验未提交他人草稿不可见、有 workflow 草稿可见;不在生产临时创建真实员工草稿来凑证据。
@@ -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,91 @@
# 报销状态空隙与财务处理视角:实施计划
## 0. 启动门
- 用户批准本版 `prd.md`、`design.md`、`implement.md` 后,才运行 `task.py start`。
- 实施前使用 `trellis-before-dev` 读取 frontend、backend、approval/workflow 和测试规约。
- 保持 inline 模式;不派 implement/check sub-agent。涉及权限和 workflow,编码完成后主会话执行只读 review。
## 1. R1 独立实现单元:待组批与付款阶段
1. 为付款页增加 URL 驱动的 `pending/batches/detail` 视图投影;锁定 manage/review 角色默认视图和直达边界。
2. 把现有候选搜索、选择、分页和最小创建表单提升为唯一“待组批”主体,删除旧的重复入口/休眠 DOM。
3. 候选标签展示服务端 `total`;实现加载、错误、空态、搜索、分页和选择后的组建动作。
4. 报销详情 approved/no-batch 下一步改为“等待财务组建付款批次”;仅 manage 权限注入带编号搜索的深链。
5. 调整报销处理进度组装,加入 `payment_batch_created` 的报销专属“组建付款批次”投影;批次存在后继续完全复用共享付款语义。
6. 创建成功刷新候选及批次查询,不做乐观业务状态写入。
7. 完成 R1 focused 测试并形成第一个独立提交;不回填最终 task commit,直到整个任务候选提交完成。
R1 重点文件预计包括:
- `client/src/pages/PaymentBatchesPage.tsx`
- `client/src/pages/ReimbursementDetailPage.tsx`
- `client/src/api/paymentBatches.ts`
- `server/src/routes/reimbursements.js`
- `server/src/services/paymentProcessingProgress.js`(仅在共享阶段接口确需调整时)
- 对应 server unit 与 client Playwright specs
## 2. R2 独立实现单元:草稿读取授权
1. 提取“既有角色范围 + 报销业务事实”的服务端条件构建器,表驱动验证 global/team/own 边界。
2. 应用于 `/reimbursements` 列表并保证 `items/total` 同源。
3. 应用于全局搜索、报销 CSV/XLSX 导出、报销统计/看板;原始 SQL 使用等价 `EXISTS` 条件并验证不放大聚合。
4. 收紧 `ensureOwnCanViewAllOrWorkflowReviewer` / `ensureOwnOrCanViewAll` 或合并为等价统一 helper,使详情、附件文件、preview URL、完整材料 preview 和 `precheck` 对他人未提交 draft fail closed。
5. 审计 helper 的所有调用方,确认写接口不因读取授权重构而放宽;保持 workflow reviewer 和本人路径,并增加审批人读取自己待办报销的负向保护。
6. 增加 returned 与 withdrawn/cancelled workflow fixture,证明两类历史 draft 仍可见、可由申请人按既有规则继续处理。
7. 完成 R2 focused 测试并形成第二个独立提交。
R2 重点文件预计包括:
- `server/src/routes/reimbursements.js`
- `server/src/routes/search.js`
- `server/src/routes/exports.js`
- `server/src/routes/dashboard.js`
- 可复用的报销 scope helper(优先放现有模块,不为单一条件新建过度抽象)
- 对应权限、详情、附件、搜索、导出与统计测试
## 3. Focused 验证
按实际测试文件补齐精确命令,最低包括:
```bash
cd server && node --check src/routes/reimbursements.js
cd server && node --check src/routes/paymentBatches.js
cd server && node --check src/routes/search.js
cd server && node --check src/routes/exports.js
cd server && node --check src/routes/dashboard.js
cd server && npm run prisma:validate
cd server && npx vitest run tests/unit/routes/reimbursements.detail.test.js tests/unit/routes/reimbursements.withdraw.test.js tests/unit/routes/paymentBatches.reimbursement.test.js
cd client && npm run typecheck
cd client && npm run build
node scripts/qa/check-ui-design-rules.mjs --strict
node scripts/qa/check-ui-design-rules.self-test.mjs
node scripts/qa/check-trellis-governance.mjs
git diff --check
```
还必须新增并执行:
- R1:候选 eligibility、manage/review 权限、视图 URL、搜索/分页/空态、创建后刷新、无批次/有批次三阶段进度矩阵。
- R2:角色 × owner/submitter/team × status × workflow-exists 表驱动矩阵;列表/详情/材料/precheck/搜索/导出/统计旁路一致性;workflow reviewer 待办读取不受误伤。
- 回归:付款批次 `draft/reviewed/executed/failed/cancelled`、maker-checker、报销退回/撤回/重提、B1/B2 和非报销 workflow 动作投影不变。
## 4. 只读 Review 门
提交前逐项只读复核:
1. 候选资格是否仍只有服务端一处来源,前端没有复制 `approved/unpaid/unbatched/gate` 判断。
2. 无 batch 与 batch draft 的阶段是否分别为“待组批”和“待付款复核”,没有同义混用。
3. 未提交他人 draft 是否在所有 record-level 旁路 fail closed,而 returned/withdrawn draft 仍按原范围可见。
4. R2 是否没有放宽任何写能力、没有改变角色授权、没有引入 schema 或历史数据修正。
5. R1/R2 是否可分别 revert 并各自构建、测试,不存在交叉 import 或数据前置。
## 5. 提交、部署与回滚
- Commit 1:中文提交信息引用 `[09-04-reimbursement-state-gap-finance-view][R1]`。
- Commit 2:中文提交信息引用 `[09-04-reimbursement-state-gap-finance-view][R2]`。
- 最终元数据提交把 `task.json.commit` 指向最后一个业务提交;push、部署继续分别获得授权。
- 部署前紧邻执行 `git status --porcelain --untracked-files=all`、UI strict 和 provenance 核验。
- 部署后检查 PRD AC11;真实创建批次仍由财务执行。
- R1 回滚:revert R1 提交;已创建批次继续按原付款状态机处理。
- R2 回滚:revert R2 提交;不做数据库恢复。若只发现某一旁路遗漏,R2 整体不得宣称完成。
@@ -0,0 +1,88 @@
# 报销状态空隙与财务处理视角
## Goal
消除报销从审批通过到付款批次创建之间、以及报销回到草稿状态之后的两个运营空隙:财务应能在付款页直接发现并处理尚未组批的已通过报销,同时“全范围”不得暴露他人从未提交的私人草稿,但必须保留对已经进入过流程的退回或撤回草稿的追踪能力。
本任务来自真实业务单据 `0014` 的首次使用反馈。两个 Requirement 共享“状态空隙必须有明确责任人和可见入口”的产品原则,但必须保持代码边界、测试证据和回滚点独立,不能因一项回滚而被迫撤销另一项。
## Scope
- R1 覆盖付款页待组批视图、报销详情下一步深链及报销处理进度中的组批/复核阶段区分。
- R2 覆盖报销列表、详情、材料、提交前检查、全局搜索、导出和报销统计的“本人草稿或已进入流程”服务端读取边界。
- 两项分别实施、分别测试、分别提交和回滚;具体行为以 Requirements 为准,排除项以 Out of Scope 为准。
## Background
- 2026-09-04 对生产做了只读核验:目标单据唯一匹配,状态为 `approved`、未付款、无任何付款批次关联,`payment_batch_v1` 为 `force_on/global`,满足现有 `/payment-batches/candidates` 候选合同;未创建批次或改动单据。
- 同次脱敏统计显示:生产当前只有 1 张 `approved` 且未付款、未被活动批次占用的报销,即本次目标单据;当前 1 张 `draft` 从未产生 workflow 实例,暂无“有历史 workflow 的 draft”。这些计数只用于评估本次行为,不作为测试 fixture 或实现常量。
- 付款首页只查询已经创建的 `PaymentBatch`;候选报销仅在财务点击“新建批次”后才加载。`待我审批` 查询 workflow task,而付款批次复核不是报销 workflow task;组批前也不存在可复核的付款批次。
- 报销详情目前把无批次的 `approved` 投影为“付款复核 / 待安排 / 待财务安排付款”,同时页面下一步仅写“等待财务处理”。批次 `draft` 才真正表示“已创建并提交付款复核”。当前投影把批次级阶段提前套在尚未组批的报销上。
- 报销列表的状态与范围相互独立。具备 `expense.view_all` 的财务、财务负责人、CEO、HR、管理员可在 `草稿 + 全范围` 查看全公司的草稿;经理可查看本人、代填及直属下属草稿。现有详情和材料读取辅助函数也对 `expense.view_all` 无条件放行。
- 报销 `return` 和申请人 `withdraw` 都会把业务状态改回 `draft`,并保留 workflow 实例;从未提交的草稿没有 workflow 实例。仓库统计查询已使用 workflow 实例创建时间作为提交事实,因此“是否存在 workflow 实例”是现有、可推导且无需 schema 变更的“是否进入过流程”判据。
## Requirements
### R1:已通过但未组批的报销必须成为财务可见、可执行的队列
1. 报销 `approved` 且未付款、未进入任何非取消付款批次、付款能力对其部门有效时,必须出现在付款页一级“待组批”视图;候选资格继续由服务端现有候选合同决定,前端不得自行复制状态或部门门禁。
2. “待组批”只对具备 `payment.batch.manage` 的人员显示和查询;仅具备 `payment.batch.review` 的复核人继续查看已创建的付款批次,不获得组批候选或创建能力。
3. 付款页在无批次详情参数时提供“待组批”和“付款批次”两个明确视图。对组批管理员默认进入“待组批”,标签显示服务端返回的候选总数;复核只读角色默认进入“付款批次”。URL 必须可表达当前视图并支持刷新、返回和报销详情深链。
4. “待组批”复用现有候选 API、搜索、分页和创建合同,显示单号、报销人、报销日期、权威总额及摘要。它不是第二套候选计算,也不得同时保留一份隐藏在“新建批次”里的重复候选列表。
5. 财务可从待组批列表选择一张或多张报销并进入现有“创建并提交复核”流程;创建成功后候选立即从待组批队列消失,付款批次视图出现新批次,其状态为“待付款复核”。不得自动组批、自动选择、自动复核或改动 maker-checker 规则。
6. 报销详情在无批次的 `approved` 状态下,把下一步明确写为“等待财务组建付款批次”。具备 `payment.batch.manage` 时提供进入付款页待组批视图的单一入口,并以当前报销编号预置搜索;其他用户只看到状态说明,不暴露付款管理能力。
7. 报销处理进度必须区分“组建付款批次”和“付款复核”:
- 无批次的 `approved`:组批阶段为当前,说明“待财务组建付款批次”;付款复核仍为待处理。
- 已有 `draft` 批次:组批阶段已完成,付款复核为当前,继续使用“待付款复核”。
- 后续 `reviewed/executed/failed/cancelled` 继续由共享付款阶段语义投影,不改变付款写状态机。
8. 列表必须有加载、错误、空态和分页反馈;空态只说明当前没有待组批报销,不把“系统正常”写成解释性噪音。PC 与现有响应式付款页保持同一数据源,移动端不新建第二套付款状态机。
### R2:全范围只展示已经成为业务事实的他人草稿
1. “业务事实”判据固定为:报销单至少存在一个 `entity_type=reimbursement` 的 workflow 实例。不得用当前 workflow 状态、最后一次 outcome、页面标签、单号是否为空或更新时间猜测。
2. 报销人为本人或提交人为本人时,仍可查看自己的全部草稿,不要求存在 workflow 实例。
3. 对他人的 `draft`:
- 从未进入流程、无 workflow 实例:从全范围列表、全局搜索、导出和按报销聚合的统计中排除,直接访问详情或材料接口返回无权限。
- 曾经进入流程、有 workflow 实例:继续按既有全范围角色边界可见,包括审批人退回和申请人撤回后回到 `draft` 的记录。
4. 非 `draft` 状态的既有可见性不变;经理仍限本人、提交人及直属下属,财务/财务负责人/CEO/HR/管理员仍限现有全局范围。本任务不新增角色、不授予 `expense.view_all`、不改变编辑权限。
5. 服务端必须统一强制该判据。前端可以隐藏不可见记录,但不得作为授权边界;详情、普通附件下载、附件预览 URL、完整材料预览和 `POST /reimbursements/:id/precheck` 必须共同收紧,避免通过已知 ID 绕过列表。`precheck` 返回的预算提示、费用标准、审批规则和风险标记同样属于受保护的报销内容。
6. 报销统计与导出不得把他人从未提交的草稿当作正式台账事实;本人草稿是否进入个人视角统计沿用既有行为。付款候选只接受 `approved`,不受本条授权筛选影响。
7. 被排除的记录对无权读取者使用既有自然中文无权限合同,不泄露“该 ID 存在但尚未提交”、workflow 计数或内部判据。
## Acceptance Criteria
- [ ] AC1(R1):以 `approved + unpaid + no active batch + gate enabled` fixture 请求待组批视图,能看到目标报销;任一条件不满足时不出现。生产只读复核能在待组批视图搜索到 `0014`,但自动化不得创建真实付款批次。
- [ ] AC2(R1):付款批次管理员默认看到“待组批”及准确总数,搜索/分页/空态/错误态可用;仅复核角色看不到待组批和创建入口,原有批次复核能力不变。
- [ ] AC3(R1):从报销详情点击入口进入可刷新、可返回的待组批 URL,并预置当前单号;无管理权限时入口为 0,但“等待财务组建付款批次”仍准确显示。
- [ ] AC4(R1):无批次时进度显示“组建付款批次”为当前且“付款复核”为待处理;创建批次后前者完成、后者当前。`draft/reviewed/executed/failed/cancelled` 付款批次的既有语义、通知、权限和写状态机回归通过。
- [ ] AC5(R1):创建成功只刷新候选与批次查询,不出现重复候选列表,不自动执行任何付款写动作;PC 1280px 无横向溢出,移动端可完成搜索、选择和进入创建流程,交互目标不小于现有 44px 约束。
- [ ] AC6(R2):表驱动服务端测试覆盖本人/提交人、全局角色、经理直属团队、无关人员 × `draft` 无 workflow、`draft` 有 returned workflow、`draft` 有 cancelled/withdrawn workflow、所有非 draft 状态;只有“他人且从未进入流程的 draft”被排除。另有负向保护证明当前或历史 workflow 审批人读取自己任务关联的报销不受新过滤误伤。
- [ ] AC7(R2):同一未提交他人草稿在列表、全局搜索、CSV/XLSX 导出和报销统计中均不可见/不计入;直接详情、普通附件、预览 URL、完整材料预览和 `precheck` 均返回无权限。不得只做前端筛选。
- [ ] AC8(R2):退回或撤回后为 `draft` 且有 workflow 实例的报销仍可由原授权范围内人员查看;既有审批历史、只读投影和申请人编辑重提/再次提交路径不变。
- [ ] AC9(独立性):R1 与 R2 分成独立业务提交或至少独立可逆提交单元、独立 focused 测试组;分别回滚任一项时另一项仍可构建并通过自己的测试,不需要 schema、migration、seed、flag 或生产数据修正。
- [ ] AC10(门禁):前后端 focused 测试、前端 typecheck/build、UI strict/self-test、Prisma validate、workflow/权限过程级回归、Trellis governance 和 `git diff --check` 通过;涉及权限和 workflow 投影,实施后必须进行只读 review。
- [ ] AC11(部署后):`/ready` 与 bundle marker 等于候选短 SHA,公网文案扫描 findings=0;真实账号只读确认 `0014` 位于待组批且未被自动修改,未提交他人草稿不再可见,退回/撤回历史草稿 fixture 仍可见。真实组批、复核和付款仍由财务人工执行。
## Test Plan
- R1 focused:付款候选资格、manage/review 权限矩阵、待组批 URL/搜索/分页/空态、创建后查询刷新,以及无批次到五种批次状态的进度投影。
- R2 focused:角色与归属 × draft/非 draft × workflow 无/有的表驱动矩阵,并覆盖列表、详情、材料、`precheck`、全局搜索、CSV/XLSX 和统计旁路;单列 workflow reviewer 待办读取的负向保护。
- 回归与门禁:付款 maker-checker、报销退回/撤回/重提、B1/B2、非报销 workflow、前端 typecheck/build、UI strict/self-test、Prisma validate、Trellis governance 和 `git diff --check`。
- 生产验收只读检查候选可见性和授权结果;不自动创建、复核或执行真实付款批次。
## Out of Scope
- 不在本任务替财务创建 `0014` 的付款批次、复核或登记付款。
- 不修改 Prisma schema,不新增 `submitted_at`,不回写历史报销或 workflow 数据。
- 不合并报销 workflow 与付款批次状态机,不把付款复核塞进“待我审批”。
- 不修改 maker-checker、付款账号、回单、网银制单、B1/B2、预算、票据或通知 routing。
- 不改变采购、差旅、请假、补卡的列表、草稿权限或工作流。
- 不借本任务调整 `expense.view_all` 的角色授予;HR/CEO 是否应继续拥有该权限另行评估。
- 不新增批量自动组批、自动付款、真实支付接口或第二套移动付款页。
## Risks and Deferred Items
- workflow 实例存在表示“曾进入流程”,因此会有意保留退回和撤回草稿的全范围可见性;若后续业务要求撤回后重新私有化,必须新增独立产品判据,不能悄悄改成 outcome 白名单。
- 当前数据库无 `submitted_at`;workflow 关系查询需要通过 focused 性能测试确认列表、搜索、导出和统计没有不可接受退化。本任务不以冗余字段或 migration 预优化。
- 全局搜索、导出和统计是列表之外的旁路;若实现评估发现某一旁路无法在本候选安全收紧,必须阻断 R2 发布或拆出明确依赖任务,不能把列表隐藏冒充权限修复完成。
- 真实付款批次创建属于财务业务动作,不作为自动化验收。部署后只证明候选可见、入口可用和数据未被自动修改。
@@ -0,0 +1,26 @@
{
"id": "reimbursement-state-gap-finance-view",
"name": "reimbursement-state-gap-finance-view",
"title": "报销状态空隙与财务处理视角",
"description": "修复已通过报销在组批前缺少财务处理入口,以及全范围读取暴露从未提交草稿的问题。",
"status": "in_progress",
"dev_type": null,
"scope": "server,client,tests,spec",
"package": "hyb-oa",
"priority": "P1",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-09-04",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+7 -19
查看文件
@@ -2,6 +2,7 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility");
const QUERY_SCHEMA = z.object({
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
@@ -40,14 +41,8 @@ function parseQuery(query) {
};
}
function canViewAll(request) {
return request.employee.roleCodes.some((code) =>
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
);
}
function buildReimbursementWhere(request, query) {
const where = {
const filters = {
...(query.dateFrom || query.dateTo
? {
reimbursement_date: {
@@ -59,18 +54,11 @@ function buildReimbursementWhere(request, query) {
...(query.departmentId ? { department_id: query.departmentId } : {}),
};
if (canViewAll(request)) {
return where;
}
return {
...where,
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
return buildReadableReimbursementWhere({
employee: request.employee,
scope: "all",
filters,
});
}
function moneyString(value) {
+11 -48
查看文件
@@ -19,6 +19,7 @@ const {
} = require("../services/employeeDirectoryFilters");
const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility");
const {
FINANCE_LEDGER_EXPORT_HEADERS,
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
@@ -189,32 +190,6 @@ function ensureHasAnyPermission(fastify, request, permissionCodes) {
});
}
function buildOwnReimbursementScopeWhere(employeeId) {
return {
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
};
}
function isGlobalViewRole(roleCodes) {
return roleCodes.some((code) =>
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
);
}
function buildViewAllScopeWhere(request) {
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
return {};
}
return {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
}
function toDateRange(query) {
const dateFrom = query.dateFrom ? new Date(`${query.dateFrom}T00:00:00.000Z`) : null;
const dateTo = query.dateTo ? new Date(`${query.dateTo}T23:59:59.999Z`) : null;
@@ -244,22 +219,11 @@ function buildDashboardWhere(request, query) {
const departmentWhere = query.departmentId ? { department_id: query.departmentId } : {};
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
return {
...dateWhere,
...departmentWhere,
};
}
return {
...dateWhere,
...departmentWhere,
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
return buildReadableReimbursementWhere({
employee: request.employee,
scope: "all",
filters: { ...dateWhere, ...departmentWhere },
});
}
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
@@ -1873,12 +1837,11 @@ async function exportsRoutes(fastify) {
});
}
const where = {
...(query.status ? { status: query.status } : {}),
...(scope === "all"
? buildViewAllScopeWhere(request)
: buildOwnReimbursementScopeWhere(request.employee.id)),
};
const where = buildReadableReimbursementWhere({
employee: request.employee,
scope,
filters: query.status ? { status: query.status } : {},
});
const format = resolveExportFormat(query);
const limit = query.limit ?? 5000;
+40 -39
查看文件
@@ -26,6 +26,11 @@ const { mapInvoiceDocumentMeta } = require("../services/invoiceDocumentMeta");
const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildReadableReimbursementWhere,
canReadReimbursementBusinessFact,
isGlobalReimbursementViewRole,
} = require("../services/reimbursementVisibility");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const {
buildPersistedRiskFlag,
@@ -245,9 +250,12 @@ const ATTACHMENT_MIME_ALLOWLIST = new Set([
"image/webp",
]);
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
async function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (canViewAll) {
if (
canViewAll &&
await canReadReimbursementBusinessFact(fastify.prisma, reimbursement, request.employee.id)
) {
return;
}
@@ -275,7 +283,11 @@ async function ensureOwnCanViewAllOrWorkflowReviewer(fastify, request, reimburse
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
const isOwn = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id;
const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own") && isOwn;
if (canViewAll || canViewOwn) return;
if (canViewOwn) return;
if (
canViewAll &&
await canReadReimbursementBusinessFact(fastify.prisma, reimbursement, request.employee.id)
) return;
const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, {
delegateEmployeeId: request.employee.id,
@@ -852,7 +864,7 @@ function buildReimbursementStatsConditions({
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "department_id")} = ${departmentId}`);
}
if (!isGlobalViewRole(request.employee.roleCodes ?? [])) {
if (!isGlobalReimbursementViewRole(request.employee.roleCodes ?? [])) {
conditions.push(
Prisma.sql`(
${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id}
@@ -862,6 +874,20 @@ function buildReimbursementStatsConditions({
);
}
conditions.push(
Prisma.sql`(
${sqlIdentifier(formAlias, "status")}::text <> 'draft'
OR ${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id}
OR ${sqlIdentifier(formAlias, "submitter_id")} = ${request.employee.id}
OR EXISTS (
SELECT 1
FROM "workflow_instances" visibility_wi
WHERE visibility_wi.entity_type = 'reimbursement'
AND visibility_wi.entity_id = ${sqlIdentifier(formAlias, "id")}
)
)`,
);
return conditions;
}
@@ -1030,32 +1056,6 @@ function parseReimbursementListQuery(query) {
};
}
function buildOwnScopeWhere(employeeId) {
return {
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
};
}
function isGlobalViewRole(roleCodes) {
return roleCodes.some((code) =>
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
);
}
function buildViewAllScopeWhere(request) {
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
return {};
}
return {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
}
function mapReimbursementSummary(item) {
return {
id: item.id,
@@ -2042,12 +2042,11 @@ async function reimbursementRoutes(fastify) {
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
const where = {
...(query.status ? { status: query.status } : {}),
...(scope === "all"
? buildViewAllScopeWhere(request)
: buildOwnScopeWhere(request.employee.id)),
};
const where = buildReadableReimbursementWhere({
employee: request.employee,
scope,
filters: query.status ? { status: query.status } : {},
});
const [items, total] = await Promise.all([
fastify.prisma.reimbursementForm.findMany({
@@ -3335,6 +3334,7 @@ async function reimbursementRoutes(fastify) {
select: {
owner_id: true,
submitter_id: true,
status: true,
},
},
},
@@ -3435,7 +3435,7 @@ async function reimbursementRoutes(fastify) {
sensitivity_level: true,
attachment_type: true,
download_policy: true,
reimbursement_form: { select: { owner_id: true, submitter_id: true } },
reimbursement_form: { select: { owner_id: true, submitter_id: true, status: true } },
},
});
if (!attachment) {
@@ -3508,6 +3508,7 @@ async function reimbursementRoutes(fastify) {
id: true,
owner_id: true,
submitter_id: true,
status: true,
invoice_pool_items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
@@ -4132,12 +4133,12 @@ async function reimbursementRoutes(fastify) {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, owner_id: true, submitter_id: true },
select: { id: true, owner_id: true, submitter_id: true, status: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, reimbursement);
await ensureOwnOrCanViewAll(fastify, request, reimbursement);
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
+11 -13
查看文件
@@ -3,6 +3,7 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility");
const QUERY_SCHEMA = z.object({
q: z.string().trim().min(2).max(100),
@@ -213,19 +214,16 @@ async function searchRoutes(fastify) {
if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) {
tasks.push(
fastify.prisma.reimbursementForm.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "expense.view_all") || isAdmin
? {}
: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] },
{
OR: [
{ form_number: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: "insensitive" } },
],
},
],
},
where: buildReadableReimbursementWhere({
employee: request.employee,
scope: fastify.hasPermission(request.employee, "expense.view_all") || isAdmin ? "all" : "own",
filters: {
OR: [
{ form_number: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: "insensitive" } },
],
},
}),
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, form_number: true, description: true, status: true, updated_at: true },
@@ -0,0 +1,92 @@
const GLOBAL_REIMBURSEMENT_VIEW_ROLES = new Set([
"admin",
"ceo",
"finance",
"finance_mgr",
"hr",
]);
function isGlobalReimbursementViewRole(roleCodes = []) {
return roleCodes.some((code) => GLOBAL_REIMBURSEMENT_VIEW_ROLES.has(code));
}
function buildOwnReimbursementScopeWhere(employeeId) {
return {
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
};
}
function buildViewAllReimbursementScopeWhere(employee) {
if (isGlobalReimbursementViewRole(employee.roleCodes ?? [])) {
return {};
}
return {
OR: [
{ owner_id: employee.id },
{ submitter_id: employee.id },
{ owner: { direct_manager_id: employee.id } },
],
};
}
function buildReimbursementBusinessFactWhere(employeeId) {
return {
OR: [
{ status: { not: "draft" } },
{ owner_id: employeeId },
{ submitter_id: employeeId },
{ workflow_instances: { some: {} } },
],
};
}
function buildReadableReimbursementWhere({ employee, scope = "all", filters = {} }) {
const scopeWhere = scope === "own"
? buildOwnReimbursementScopeWhere(employee.id)
: buildViewAllReimbursementScopeWhere(employee);
if (scope === "own") {
return { AND: [scopeWhere, filters] };
}
return {
AND: [scopeWhere, buildReimbursementBusinessFactWhere(employee.id), filters],
};
}
async function canReadReimbursementBusinessFact(prisma, reimbursement, employeeId) {
if (reimbursement.owner_id === employeeId || reimbursement.submitter_id === employeeId) {
return true;
}
let status = reimbursement.status;
if (!status) {
const current = await prisma.reimbursementForm.findUnique({
where: { id: reimbursement.id },
select: { status: true },
});
status = current?.status ?? null;
}
if (status !== "draft") {
return Boolean(status);
}
const workflowInstance = await prisma.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursement.id,
},
select: { id: true },
});
return Boolean(workflowInstance);
}
module.exports = {
buildOwnReimbursementScopeWhere,
buildReadableReimbursementWhere,
buildReimbursementBusinessFactWhere,
buildViewAllReimbursementScopeWhere,
canReadReimbursementBusinessFact,
isGlobalReimbursementViewRole,
};
@@ -208,6 +208,7 @@ async function buildApp(options = {}) {
id: reimbursement.id,
owner_id: reimbursement.owner_id,
submitter_id: reimbursement.submitter_id,
status: reimbursement.status,
form_number: reimbursement.form_number,
total_amount: reimbursement.total_amount,
owner: {
@@ -216,6 +217,9 @@ async function buildApp(options = {}) {
},
})),
},
workflowInstance: {
findFirst: vi.fn(async () => options.existingWorkflowInstance ?? null),
},
};
app.decorate("prisma", prisma);
@@ -718,7 +722,7 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("allows a view-all nonparticipant to read reimbursement precheck", async () => {
it("allows a view-all nonparticipant to read a submitted reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
actor: {
@@ -727,6 +731,59 @@ describe("reimbursements routes expense standard integration", () => {
roleCodes: ["finance"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
reimbursement: {
id: REIMBURSEMENT_ID,
owner_id: EMPLOYEE_ID,
submitter_id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
reimbursement_date: new Date("2026-04-08T00:00:00.000Z"),
status: "approved",
form_number: "BX-20260408-001",
total_amount: "180.00",
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`,
});
expect(response.statusCode).toBe(200);
expect(resolveEntityPrecheckMock).toHaveBeenCalledTimes(1);
await app.close();
});
it("rejects a view-all nonparticipant precheck for a never-submitted draft", async () => {
const { app } = await buildApp({
actor: {
id: "finance-viewer",
department_id: DEPARTMENT_ID,
roleCodes: ["finance"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`,
});
expect(response.statusCode).toBe(403);
expect(response.json().error).toMatchObject({ code: "PERM_001", message: "无权限" });
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
await app.close();
});
it("allows a view-all nonparticipant precheck for a returned draft", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
actor: {
id: "finance-viewer",
department_id: DEPARTMENT_ID,
roleCodes: ["finance"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
existingWorkflowInstance: { id: "returned-workflow" },
});
const response = await app.inject({
@@ -0,0 +1,107 @@
const {
buildReadableReimbursementWhere,
canReadReimbursementBusinessFact,
} = require("../../../src/services/reimbursementVisibility");
const fs = require("node:fs");
const path = require("node:path");
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_ID = "22222222-2222-4222-8222-222222222222";
const REIMBURSEMENT_ID = "33333333-3333-4333-8333-333333333333";
describe("reimbursement visibility", () => {
it.each([
["global finance", ["finance"], {}],
["global finance manager", ["finance_mgr"], {}],
["global ceo", ["ceo"], {}],
["global hr", ["hr"], {}],
["global admin", ["admin"], {}],
[
"manager team",
["manager"],
{
OR: [
{ owner_id: ACTOR_ID },
{ submitter_id: ACTOR_ID },
{ owner: { direct_manager_id: ACTOR_ID } },
],
},
],
])("keeps the existing %s scope while adding the business-fact filter", (_label, roleCodes, expectedScope) => {
expect(buildReadableReimbursementWhere({
employee: { id: ACTOR_ID, roleCodes },
scope: "all",
filters: { status: "draft" },
})).toEqual({
AND: [
expectedScope,
{
OR: [
{ status: { not: "draft" } },
{ owner_id: ACTOR_ID },
{ submitter_id: ACTOR_ID },
{ workflow_instances: { some: {} } },
],
},
{ status: "draft" },
],
});
});
it("keeps own scope independent from workflow history", () => {
expect(buildReadableReimbursementWhere({
employee: { id: ACTOR_ID, roleCodes: ["employee"] },
scope: "own",
filters: { status: "draft" },
})).toEqual({
AND: [
{ OR: [{ owner_id: ACTOR_ID }, { submitter_id: ACTOR_ID }] },
{ status: "draft" },
],
});
});
it("keeps every reimbursement read surface on the shared visibility contract", () => {
const routesDir = path.resolve(__dirname, "../../../src/routes");
const reimbursementsSource = fs.readFileSync(path.join(routesDir, "reimbursements.js"), "utf8");
const searchSource = fs.readFileSync(path.join(routesDir, "search.js"), "utf8");
const exportsSource = fs.readFileSync(path.join(routesDir, "exports.js"), "utf8");
const dashboardSource = fs.readFileSync(path.join(routesDir, "dashboard.js"), "utf8");
expect(reimbursementsSource).toContain("await ensureOwnOrCanViewAll(fastify, request, reimbursement)");
expect(reimbursementsSource.match(/await ensureOwnCanViewAllOrWorkflowReviewer\(fastify, request/g)).toHaveLength(4);
expect(reimbursementsSource).toContain("buildReadableReimbursementWhere({");
expect(reimbursementsSource).toContain("EXISTS (");
expect(searchSource).toContain("buildReadableReimbursementWhere({");
expect(exportsSource.match(/buildReadableReimbursementWhere\(\{/g)).toHaveLength(2);
expect(dashboardSource).toContain("buildReadableReimbursementWhere({");
});
it.each([
["owner draft", { owner_id: ACTOR_ID, submitter_id: OTHER_ID, status: "draft" }, null, true],
["submitter draft", { owner_id: OTHER_ID, submitter_id: ACTOR_ID, status: "draft" }, null, true],
["other approved", { owner_id: OTHER_ID, submitter_id: OTHER_ID, status: "approved" }, null, true],
["other returned draft", { owner_id: OTHER_ID, submitter_id: OTHER_ID, status: "draft" }, { id: "returned" }, true],
["other withdrawn draft", { owner_id: OTHER_ID, submitter_id: OTHER_ID, status: "draft" }, { id: "cancelled" }, true],
["other never-submitted draft", { owner_id: OTHER_ID, submitter_id: OTHER_ID, status: "draft" }, null, false],
])("classifies %s", async (_label, record, workflowInstance, expected) => {
const prisma = {
reimbursementForm: { findUnique: vi.fn() },
workflowInstance: { findFirst: vi.fn().mockResolvedValue(workflowInstance) },
};
await expect(canReadReimbursementBusinessFact(prisma, {
id: REIMBURSEMENT_ID,
...record,
}, ACTOR_ID)).resolves.toBe(expected);
if (record.status === "draft" && record.owner_id !== ACTOR_ID && record.submitter_id !== ACTOR_ID) {
expect(prisma.workflowInstance.findFirst).toHaveBeenCalledWith({
where: { entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID },
select: { id: true },
});
} else {
expect(prisma.workflowInstance.findFirst).not.toHaveBeenCalled();
}
});
});