diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index c7645389..4eb5717b 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -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=`; 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); +``` diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/check.jsonl b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md new file mode 100644 index 00000000..3c36b0ae --- /dev/null +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md @@ -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=`:既有批次详情,优先于 view。 + +报销详情深链使用 `view=pending&q=`。页面从 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 草稿可见;不在生产临时创建真实员工草稿来凑证据。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.jsonl b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md new file mode 100644 index 00000000..2b411c62 --- /dev/null +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md @@ -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 整体不得宣称完成。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md new file mode 100644 index 00000000..50945971 --- /dev/null +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md @@ -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 发布或拆出明确依赖任务,不能把列表隐藏冒充权限修复完成。 +- 真实付款批次创建属于财务业务动作,不作为自动化验收。部署后只证明候选可见、入口可用和数据未被自动修改。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json new file mode 100644 index 00000000..caf347d6 --- /dev/null +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json @@ -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": {} +} \ No newline at end of file diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js index 57b1ac54..5d51e7bd 100644 --- a/server/src/routes/dashboard.js +++ b/server/src/routes/dashboard.js @@ -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) { diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js index f07f3c04..4c63f2b1 100644 --- a/server/src/routes/exports.js +++ b/server/src/routes/exports.js @@ -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; diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 22a0bcae..bb6545d8 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -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, diff --git a/server/src/routes/search.js b/server/src/routes/search.js index 15b7a921..a274015b 100644 --- a/server/src/routes/search.js +++ b/server/src/routes/search.js @@ -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 }, diff --git a/server/src/services/reimbursementVisibility.js b/server/src/services/reimbursementVisibility.js new file mode 100644 index 00000000..ef87c01f --- /dev/null +++ b/server/src/services/reimbursementVisibility.js @@ -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, +}; diff --git a/server/tests/unit/routes/reimbursements.expense-standards.test.js b/server/tests/unit/routes/reimbursements.expense-standards.test.js index cdca5a06..a11f4c4c 100644 --- a/server/tests/unit/routes/reimbursements.expense-standards.test.js +++ b/server/tests/unit/routes/reimbursements.expense-standards.test.js @@ -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({ diff --git a/server/tests/unit/services/reimbursementVisibility.test.js b/server/tests/unit/services/reimbursementVisibility.test.js new file mode 100644 index 00000000..6c403392 --- /dev/null +++ b/server/tests/unit/services/reimbursementVisibility.test.js @@ -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(); + } + }); +});