修复非参与人报销只读边界(08-28-reimbursement-nonparticipant-readonly-projection)

这个提交包含在:
2026-08-29 08:22:23 +08:00
父节点 174cb7acde
当前提交 e8d676634f
共修改 24 个文件,包含 891 行新增和 62 行删除
@@ -14,6 +14,22 @@ duplicate detection or attachment behavior.
- Reimbursement flow tests should cover upload/OCR, duplicate handling, - Reimbursement flow tests should cover upload/OCR, duplicate handling,
submission, approval, payment batch, receipt attachment and AP export when the submission, approval, payment batch, receipt attachment and AP export when the
Trellis task scope touches those areas. Trellis task scope touches those areas.
- Reimbursement object maintenance is limited to the form owner or submitter
while the status permits editing. `expense.view_all`, an admin role and an
approval assignment grant read or workflow-specific abilities only; they do
not authorize editing another employee's form, items, attachments, project
link, submission, or reimbursement invoice binding/unbinding.
- PC and mobile reimbursement details must project the same boundary. A
nonparticipant remains able to read the detail and permitted originals or
merged materials, but sees a clearly labeled read-only view with no owner
maintenance actions. Bound-invoice helper text must describe facts (for
example `已关联` / `未关联`) rather than advertise removal or internal
auto-matching behavior to a viewer who cannot perform it.
- Reimbursement precheck exposes budget, expense-standard and risk information,
so it is an object read even though it uses POST. Resolve the target form and
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.
## Scenario: OCR Disabled Without Mock Data ## Scenario: OCR Disabled Without Mock Data
@@ -0,0 +1,35 @@
# 设计:报销详情修复部署与公网验证
## 当前依赖
部署候选必须包含 Task A 和 `08-28-reimbursement-nonparticipant-readonly-projection`。后者未提交前不冻结 SHA、不运行来源发布、不连接生产。
## 发布流
```text
冻结并发布候选 SHA
-> 生产身份与 existing 部署合同只读 preflight
-> 期望 migration 清单核对
-> deploy-existing.sh(SYNC_ENV=0)
-> init + migrate deploy + post migration status
-> /ready + runtime/external smoke + bundle marker
-> 备份合同 refresh
-> 角色化公网检查和材料预览
-> 归档部署子任务与 Task A
```
- 正式入口使用仓库现有 wrapper,不手工 rsync、不 source 或覆盖远端 `.env`。
- 本轮代码不包含新 schema,但 init 和 pre/post migration status 仍是必需证据。
- `pdf-v2` 缓存仅在进程内,应用重启后自然失效,无缓存迁移或清理动作。
- 真实材料检查只记录页数、页序、比例和成功/失败,不保存截图、原件或票面字段。
## 固定回滚
- 切换前失败:停止,不进入回滚,因为生产应用尚未改变。
- 切换后任一必需门禁失败:停止新版本验证,使用同一 existing 部署入口发布固定提交 `f178aa0`,保持 `SYNC_ENV=0`,不回退或 reset migration。
- 回滚后必须确认 `/ready` 和 bundle marker 为 `f178aa0` 的短 SHA,并复核 migration status、runtime/external smoke、DB/附件挂载与公网入口。
- 回滚来源同样必须干净、已发布且远端可验证;不得以 unpublished override 绕过正式 wrapper。
## 授权边界
任务规划批准不等于部署批准。推送候选、连接生产、应用切换和合成 owner 撤回写入均需最终部署方案通过后的单独明确授权;真实报销永不作为撤回测试对象。
@@ -0,0 +1,35 @@
# 实施清单:报销详情修复部署与公网验证
## 1. 依赖与候选
- [ ] P0 非参与人只读投影修复已实施、只读复核 PASS 并提交。
- [ ] 将 Task A 与 P0 修复的最终线性提交冻结为候选 SHA,复核完整提交范围和上游发布影响。
- [ ] 用户已审查最终候选、回滚和公网验证清单,并另行明确批准推送与部署。
## 2. 部署前门禁
- [ ] 工作区干净,候选与 `f178aa0` 均已发布且来源可远端验证。
- [ ] 生产 identity、部署合同、DB/附件 existing 路径、运行容器和回滚窗口只读 preflight 全部通过。
- [ ] 明确期望 pending migrations;本轮若仍无新增 migration,期望值写为空但不跳过 status。
## 3. 正式发布
- [ ] 使用 `deploy-existing.sh`、`DEPLOY_DATA_MODE=existing`、`SYNC_ENV=0` 执行发布。
- [ ] init、migrate deploy、post migration status、runtime/external smoke、`/ready`、bundle marker 和备份合同 refresh 全部通过。
## 4. 公网验证
- [ ] `/ready` 与前端 marker 等于候选短 SHA,迁移状态正常。
- [ ] 财务/管理员非参与人无报销人维护入口;owner 视角维护入口正确。
- [ ] 仅在最终授权包含合成测试写入且存在专用合成单据时验证 owner 撤回成功;否则停止,不操作真实单据。
- [ ] 材料合并预览可打开,页序和比例正常;不保存真实票据截图或原件。
## 5. 失败与回滚
- [ ] 切换前失败直接停止;切换后任一必需门禁失败,使用同一入口回滚固定提交 `f178aa0`。
- [ ] 回滚不 reset migration、不覆盖环境或数据目录;回滚后复核 build、status、smoke、挂载和公网入口。
## 6. 收口
- [ ] 公网全部通过后才报告用户可测试,回填脱敏证据和实际部署 SHA。
- [ ] 先归档部署子任务,再按真实完成证据归档 Task A;任何未完成门禁保持未勾选。
@@ -0,0 +1,53 @@
# 报销详情修复部署与公网验证
## Goal
在 Task A 与非参与人只读投影修复都完成后,冻结新的已发布候选,执行受控生产部署、显式迁移状态校验、角色化公网检查和固定回滚验证。
## Current Planning Gate
- 原候选 `174cb7a` 只包含撤回投影与 EXIF 尺寸修复,未覆盖已确认的非参与人草稿/驳回维护动作和服务端解绑资格问题,因此不再作为最终部署基线。
- 本任务保持 `planning`,阻塞于 `08-28-reimbursement-nonparticipant-readonly-projection` 的实施、只读权限复核和工作提交。
- 阻塞解除后再补齐正式 Requirements、Acceptance Criteria、`design.md` 与 `implement.md`,并把新的候选 SHA、回滚到 `f178aa0` 的触发条件/动作、迁移 pre/post status、公网角色和材料预览检查写死后交付审查。
- 当前未经批准不得推送、部署、写生产或归档 Task A。
## Scope
- 在 P0 只读投影修复完成并提交后,冻结包含两轮报销修复的候选 SHA;候选必须进入远端上游并通过来源校验。
- 使用现有 `deploy/trialrun/deploy-existing.sh` 完成生产只读 preflight、existing 模式部署、init、迁移状态复核、运行时 smoke、构建标识和备份合同刷新。
- 部署后核验财务/管理员非参与人只读视图、owner 维护/撤回合同和材料合并预览;不把真实票据内容写入证据。
- 固定回滚基线为 `f178aa0`;部署切换后的任一必需门禁失败时执行同一正式入口回滚应用代码,不回退数据库 migration。
## Requirements
- 部署前必须显式复核候选提交范围、上游包含关系、生产身份、existing 数据目录、待迁移清单和回滚窗口;来源未发布、工作区不干净或身份不唯一时停止。
- 即使候选没有新增 migration,init 后仍必须显式运行并记录 Prisma migration status;不得因“无 schema 变更”跳过。
- `SYNC_ENV=0`,不覆盖生产环境文件;不 reset 数据库、不覆盖 DB/附件目录、不清理 `pdf-v2` 进程内缓存,应用重启自然失效。
- 公网 `/ready` 和前端 bundle marker 必须等于最终候选短 SHA;迁移、runtime/external smoke、备份合同刷新和角色化检查全部为必需门禁。
- 非参与人只读检查不得写业务数据。owner 撤回成功只能使用专用合成单据并在最终部署授权中单列生产测试写入;没有合格合成单据时该项保持阻断,不得操作真实报销。
- 切换前门禁失败只中止部署;切换后必需门禁失败则回滚到 `f178aa0`,再复核该基线 `/ready`、bundle、migration status、runtime smoke 和数据挂载连续性。
- 公网检查全部通过前不告知用户可以测试;通过后先归档部署子任务,再按真实完成证据归档 Task A。
## Acceptance Criteria
- [ ] 非参与人只读投影修复已实施、复核和提交,新的部署候选 SHA 已冻结。
- [ ] 完整部署 PRD、设计、实施和回滚清单经用户审查批准。
- [ ] 最终候选已发布且 `/ready`、bundle marker、迁移状态、runtime/external smoke 和备份合同刷新全部通过。
- [ ] 财务/管理员非参与人无报销人维护动作,owner 既有维护合同正常,材料合并预览可打开且页序和比例正常。
- [ ] 任一必需门禁失败时已按固定动作回滚到 `f178aa0` 并完成回滚后复核,未临场选择其他基线。
- [ ] 公网检查通过后才报告可测试并归档相关任务。
## Out of Scope
- 在部署任务内继续修改报销业务代码、权限、状态机、schema、migration 或生产配置。
- 处理 `BX-20260828-0017` 票面差额、退回、付款、重传或任何其他真实单据写入。
- Task B 产品改造、r102 测试债、企业微信写入、真实付款、数据库 reset 或附件清理。
- 为 `pdf-v2` 执行缓存迁移或清理;缓存仅存在进程内。
## Test Plan
- 部署前:候选范围/上游来源、`check-existing-deployment.sh`、production identity/preflight、期望待迁移清单和回滚窗口。
- 部署入口:`deploy-existing.sh` 的 existing 模式、`SYNC_ENV=0`、init、pre/post Prisma status、runtime/external smoke、`/ready`、bundle marker 和合同 refresh。
- 公网角色:财务/管理员非参与人只读、owner 维护/撤回、材料合并预览与原件降级;只保留脱敏结论。
- 回滚演练:从受控干净来源发布 `f178aa0`,复核构建标识、迁移状态、运行 smoke、挂载和公网入口。
- 治理:task validate、Trellis governance、敏感检查和 `git diff --check`。
@@ -0,0 +1,26 @@
{
"id": "reimbursement-detail-material-preview-deploy",
"name": "reimbursement-detail-material-preview-deploy",
"title": "报销详情修复部署与公网验证",
"description": "在报销详情两轮权限修复完成后,以新的已发布候选执行受控部署、迁移状态校验、固定回滚和公网角色验证。",
"status": "planning",
"dev_type": null,
"scope": "production deployment preflight; migration status; public role and material-preview verification; fixed rollback",
"package": null,
"priority": "P0",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-28",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-28-reimbursement-detail-material-preview-fixes",
"relatedFiles": [],
"notes": "规划暂停:用户已确认 174cb7a 不是最终部署候选。必须等待 08-28-reimbursement-nonparticipant-readonly-projection 实施、只读复核和提交后,重新冻结候选 SHA 并完成 PRD 审查;当前不得推送或部署。",
"meta": {}
}
@@ -18,7 +18,10 @@
"commit": "46bfa7e", "commit": "46bfa7e",
"pr_url": null, "pr_url": null,
"subtasks": [], "subtasks": [],
"children": [], "children": [
"08-28-reimbursement-detail-material-preview-deploy",
"08-28-reimbursement-nonparticipant-readonly-projection"
],
"parent": null, "parent": null,
"relatedFiles": [], "relatedFiles": [],
"notes": "任务范围内聚焦验证通过。完整 r102 为 5/6,唯一失败是既有用例仍查找已不存在的‘编辑’按钮;本任务新增的管理员撤回不可见断言通过,不把该历史失败静默视为绿色基线。", "notes": "任务范围内聚焦验证通过。完整 r102 为 5/6,唯一失败是既有用例仍查找已不存在的‘编辑’按钮;本任务新增的管理员撤回不可见断言通过,不把该历史失败静默视为绿色基线。",
@@ -0,0 +1,74 @@
# 设计:非参与人报销详情只读投影修复
## 设计边界
本任务修正“访问资格”和“维护资格”被混用的问题,不新建报销详情页面、不重构审批工作台,也不改变角色/权限码。详情读取继续支持 owner/submitter、当前审批职责和 `expense.view_all`;报销人自助写入必须额外通过对象参与资格。
## 资格模型
```text
可查看详情
= owner/submitter 的 expense.view_own
OR 当前审批/职责访问
OR expense.view_all
可维护报销
= 当前用户是 owner/submitter
AND 当前状态允许该动作
AND 具备对应自助权限码
职责动作
= workflow 审批/退回、财务付款、付款批次等专用接口
!= 报销人自助维护
```
- 前端 PC 与移动端都从同一参与资格推导 `canMaintainReimbursement`,所有新增、编辑、删除、绑定、解绑、提交、撤回、催办和项目维护入口使用该事实。
- 只读视图不渲染禁用的维护按钮;它保留可访问的事实信息和材料查看动作,并用“只读查看”标明当前上下文。
- owner 与 submitter 都是参与人,支持代填/代提交的现有合同;其他角色不因 admin 或全量查看权限获得自助维护资格。
## 服务端数据流
```text
请求权限码通过
-> 锁定/读取目标报销
-> 校验 actor.id == owner_id OR submitter_id
-> 校验动作状态
-> 执行业务写入与副作用
```
- `reimbursements.js` 将 `ensureApplicantOrAdmin` 收紧为无 admin 豁免的参与人检查,统一用于报销头、删除、附件、明细和提交;`link-project` 补 `expense.edit_own`,从可查看检查改为参与人写检查,并增加 `draft/rejected` 状态门。
- `invoicePool.js` 的 reimbursement bind/unbind 分支移除 `expense.view_all` 写入豁免,只保留 owner/submitter。travel order 分支继续按 `travel.payment.manage`,不受本任务影响。
- 对象资格失败统一返回 `403 PERM_001`,并尽量使用稳定的 `only_owner_or_submitter` 详情码;测试断言事务内没有 update/create/delete、文件落盘、审计、workflow 或通知副作用。
- `ensureOwnOrCanViewAll` 继续用于详情、附件原件和材料预览等读取接口,并补到会返回预算、费用标准和风险结果的 precheck;这些读取接口不改为参与人专属。
- `expense.finance.pay` 和 workflow 路由是显式职责动作,保持原授权和状态机。
## 前端角色化呈现
### 参与人维护视图
- draft/rejected:保留报销头、明细、项目、发票、附件、提交和删除等现有动作。
- reviewing:保留撤回和催办;不恢复草稿编辑动作。
- 其他状态:沿用现有只读和付款结果呈现。
### 非参与人只读视图
- 概况区标明“只读查看”,下一步只描述状态事实,不给报销人操作指令。
- 明细、材料对应情况、处理进度、风险、原件和合并预览继续可见。
- 发票卡显示实际关联事实:`关联明细:...`、`已关联`;没有 `linkedItemId` 时明确为 `未关联`,不推断自动生成或匹配结果。`已关联` 适用于所有当前不可维护视角,不只限于非参与人。
- 不渲染任何维护控件,避免出现按钮后再依赖 403 纠错。
- PC 与移动端使用同一文案和资格含义;交互继续使用现有语义化 Button/Link、焦点样式和移动触控尺寸,不引入新的视觉系统。
## 兼容性与回滚
- 无 schema、migration、API payload 或角色定义变化;读取 DTO 不变。
- 权限收紧会移除管理员/财务通过通用详情维护他人报销的旧行为,这是本任务的预期破坏性修正。历史数据不迁移,已经发生的审计记录不改写。
- 回滚仅恢复本任务前后端代码;回滚会重新开放已确认的不安全资格,因此只有新版本造成更高等级阻断且获得明确回滚批准时才执行。
- 本任务提交后仍不部署;部署子任务必须冻结新的候选 SHA,不能继续使用 `174cb7a`。
## 关键权衡
- 复用同一详情页面但切换角色化模式,避免复制 PC/移动两套详情和数据查询;资格合同通过共享事实和回归保证,不用 URL 区分安全边界。
- 只隐藏动作不足以修复问题,因此同步收紧服务端对象资格。
- 不给 admin 保留隐式后门。若未来确有代维护需要,应新建设计明确、带原因与审计的管理动作,而不是复用员工自助接口。
- admin 豁免关闭后,离职/长期缺席员工的卡住草稿暂不提供代维护路径;真实需求由 Task B 的专用纠错动作或独立运营修复任务承接。
- 本轮保留票据和明细事实展示,为财务核验提供上下文;一页式审批的信息结构和金额门禁留给 Task B。
@@ -0,0 +1,54 @@
# 实施清单:非参与人报销详情只读投影修复
## 1. 实施前门禁
- [x] 用户已审查并明确批准本 PRD、设计和实施清单;运行 `task.py start` 后再修改业务代码。
- [x] 加载 `trellis-before-dev`,重读 frontend/backend/approval/reimbursement 规约和当前任务材料。
- [x] 冻结工作区与基线;确认部署任务仍为 planning,未推送、未部署、未访问或写入生产。
## 2. 服务端对象资格
- [x] 将报销头、删除、附件、明细和提交路由的 `ensureApplicantOrAdmin` 收紧为 owner/submitter 参与人检查,保留原状态与权限码门禁。
- [x] 为项目关联补 `expense.edit_own`,从 `expense.view_all` 可写收紧为 owner/submitter 可写,并增加 `draft/rejected` 状态门;不改变现有 feature flag 和 payload。
- [x] 为 precheck 补 owner/submitter 或 `expense.view_all` 对象级读守卫;普通非参与人 403 且 precheck 服务零调用,合法查看者保持成功。
- [x] 将 reimbursement 发票 bind/unbind 分支从 `isOwn || expense.view_all` 收紧为 owner/submitter;保持 travel order 分支不变。
- [x] 统一 403 详情码并检查失败顺序,确保资格失败发生在数据、文件、审计、workflow 和通知副作用之前。
## 3. PC 与移动端动作投影
- [x] 建立统一的参与人/可维护资格事实,PC 和移动端都使用“参与人 AND 状态”控制维护动作。
- [x] PC 收紧主要动作、明细增改删、项目选择、票据添加/移除、附件上传/删除、提交、删除草稿、撤回和催办。
- [x] 移动端收紧 sticky submit、明细增改删、票据添加/移除、附件上传/删除、删除草稿、撤回和催办。
- [x] 保留 owner/submitter 既有动作、财务/审批专用流程、材料查看和打印。
## 4. 只读文案与信息
- [x] 非参与人显示“只读查看”上下文,并把 draft/rejected 的下一步改为等待报销人处理的事实文案。
- [x] `linkedItemId` 为空改为“关联明细:未关联”,无法解析改为“关联明细:无法识别”;移除“自动生成或匹配明细”。
- [x] 当前不可维护视角的 bound 发票显示“已关联”,不渲染“草稿或驳回状态可移除”;参与人可维护视角保留必要操作说明。
- [x] 检查 PC/移动无禁用的伪操作、无内部权限码或实现术语,继续使用语义化按钮/链接和现有焦点/触控合同。
## 5. 回归测试
- [x] 服务端合成矩阵按 S1/S2/S3 分档覆盖:admin、finance/`expense.view_all` 非参与人对 form/items/attachments/submit/project/invoice bind-unbind 的 403 与零副作用;approved/paid 项目关联为 409 且零写入。
- [x] 服务端保留 owner/submitter 成功、GET/material preview 读取、workflow/finance pay 和 travel invoice 分支基线。
- [x] Playwright 覆盖 PC/移动 nonparticipant draft 的无维护动作和文案,并保留 participant 主流程;rejected/reviewing 由相同资格投影与既有撤回/催办测试覆盖。
- [x] 运行前端 typecheck/build、后端 node check/Prisma/聚焦测试及相关报销回归。
## 6. 复核、规约与提交
- [x] 按 `trellis-check` 复核跨层资格流,修复发现后重跑聚焦与全局门禁。
- [x] 因涉及权限模型,实施完成后启动独立只读 review,不让 reviewer 修改代码;结论为 PASS,无权限阻断。
- [x] 将“全量查看不等于对象维护、非参与人报销详情默认只读”的稳定合同更新到报销规约,避免再次 fail-open。
- [x] 运行 task validate、process contract、Trellis governance、敏感检查和 `git diff --check`。
- [ ] 用户批准提交后,使用中文并引用 `08-28-reimbursement-nonparticipant-readonly-projection`;不推送、不部署、不归档。
- [ ] 提交完成后更新部署子任务候选依赖并重新编写其完整 PRD,交用户审查部署方案。
## 7. 实施发现与验证记录
- `link-project` 原实现调用未定义的 `parseBody`,feature flag 打开后会在写入前返回 500,因此其错误的 `expense.view_all` 写授权历史上不可实际利用。本轮在同一路由范围内改为专用 Zod 解析函数,测试覆盖 draft 参与人 200、非参与人 403、approved/paid 409。
- r102 完整文件仍为 5/6:唯一失败保持在 `spec.ts:412`,根因仍是 travel category 夹具使 `showDescription=false`,与本轮 diff 无关;本轮新增的管理员 PC/移动只读投影用例通过。
- 后端全量 Vitest 为 1303/1306;3 个失败均在既有付款单 BIFF8 生成链路,错误为 `payment_order_generator_unavailable`,本轮 diff 未触及付款批次或生成器。当前任务相关的聚焦与扩展套件均通过。
- 独立只读权限复核结论 PASS:非参与人写入守卫位于副作用前,读取、workflow、财务付款和差旅专用分支未收紧。非阻断建议是后续补附件 DELETE 单独用例及 rejected/reviewing UI 状态矩阵。
- 提交前复核发现 precheck 缺少对象级读授权;本轮决定直接修复。路由现在先读取报销并复用 `ensureOwnOrCanViewAll`,普通非参与人 403 且 precheck 服务零调用,owner/submitter 与 `expense.view_all` 保持成功。父 task `task.json` 末字节已再次确认为 `0a`。
- 本轮没有 schema 或 migration 变化,没有访问生产、推送、部署或归档;部署任务继续处于 planning 并阻塞于本任务复核与提交决定。
@@ -0,0 +1,100 @@
# 非参与人报销详情只读投影修复
## Goal
把报销详情按对象参与资格收敛为两种明确模式:owner/submitter 在允许编辑的状态维护自己的报销;财务、审批人、管理员和其他因职责或 `expense.view_all` 获得访问权的非参与人只查看、核验材料和执行职责内的独立业务动作,不得复用报销人的维护入口或直接变更他人报销数据。
## Background
- `ReimbursementDetailPage.tsx:725` 和 `MobileReimbursementDetailPage.tsx:206` 只按 `draft/rejected` 判断可编辑状态。PC 的新增/编辑/删除明细、项目关联、附件上传/删除和主要提交动作,以及移动端对应动作,没有全部叠加 owner/submitter 资格。
- PC 与移动端的 `canUnbindInvoices` 都把 `expense.view_all` 当成他人报销的解绑资格;`invoicePool.js:1082` 的服务端解绑合同也允许该权限直接解除他人报销与发票的关联,因此不是单纯的前端误显。
- `ensureApplicantOrAdmin` 允许管理员修改、删除、提交他人草稿,新增/修改/删除他人明细和附件;`link-project` 与发票 bind 也把全量查看权限当成写入资格。全量查看、系统管理或当前审批职责不等于报销人自助维护资格。
- 发票卡片在所有视角共用 `resolveInvoiceNextStepText`,向非参与人显示“草稿或驳回状态可移除”;`linkedItemId` 为空时又显示技术性兜底“自动生成或匹配明细”。这些文案不是只读核验事实。
- 现有事实源要求页面按当前任务角色收敛,审批人和财务只看到职责内动作;报销材料仍允许授权查看原件和合并预览。Task A 已修复审批中撤回入口,但没有覆盖更广的草稿/驳回维护动作和服务端对象资格。
## Defect Severity
- **S1 真实写入能力**:reimbursement 发票 unbind/bind 把 `expense.view_all` 当成对象写资格;`ensureApplicantOrAdmin` 允许 admin 修改、删除、提交他人报销头、明细和附件。`link-project` 的授权设计同样错误(仅认证、复用读守卫且没有状态门),但原实现还调用了未定义的 `parseBody`,因此 feature flag 打开后会在写入前 500,历史上该写入洞不可实际利用;本轮仍须同时修复可用性和授权边界。测试必须断言 403/409 及零副作用,不能只检查按钮不可见。
- **S2 仅显示或前后端不一致**:PC 多处明细/附件动作和移动端对应动作只按 editable status 渲染,普通财务可能看到按钮但被部分服务端路由拒绝。测试必须断言 PC/移动 DOM 中不存在无资格动作,并保留直接 API 负向回归。
- **S3 已有正确边界**:PC/移动报销头编辑器已经同时检查 editable status 和参与人资格;实施只统一资格事实并证明基线,不重复改写已经正确的组件合同。
## Scope
- 统一 PC、移动端的报销参与人和可维护资格计算,修正所有报销人自助维护动作的投影。
- 收紧报销与发票路由中把 admin 或 `expense.view_all` 当成他人报销维护资格的服务端分支。
- 为报销 precheck 补对象级读取授权,避免普通员工横向读取他人预算、费用标准和风险结果。
- 为非参与人提供明确的只读查看语义和事实型票据文案,同时保留授权材料查看、打印、审批和财务专属流程。
- 使用合成身份和数据覆盖 owner/submitter、财务、管理员及普通非参与查看者,不读取或修改真实报销。
## Requirements
### R1 服务端对象资格必须 fail closed
- 报销 owner 或 submitter 统称参与人。只有参与人可通过报销人自助接口修改报销头、删除草稿、上传/删除附件、新增/修改/删除明细、提交报销、关联项目,以及为该报销绑定或解绑发票;状态和对应权限码仍需同时满足。
- `POST /reimbursements/:id/link-project` 必须补 `expense.edit_own` 权限门,只允许参与人在 `draft/rejected` 状态写入;非参与人返回 403,已通过、已支付等非编辑状态返回 409。不得继续使用 `ensureOwnOrCanViewAll` 读守卫授权写入。
- admin、财务、审批人及任何仅持 `expense.view_all` 的非参与人调用上述接口时必须稳定返回 `403 PERM_001`,并在读取目标对象后、任何业务写入、文件持久化、审计或通知副作用前失败。
- `expense.view_all` 继续允许授权查看报销详情、发票/附件原件和材料合并预览;不得把读权限降级为无权查看。
- `POST /reimbursements/:id/precheck` 虽不写业务数据,但会返回预算、费用标准、审批规则和风险结果,必须先读取目标报销并通过 owner/submitter 或 `expense.view_all` 对象级读守卫;普通非参与人返回 403,且不得调用 precheck 服务。审批页既有 `standardViolations` 快照继续保留。
- 显式职责动作保持独立:workflow 审批/退回、财务付款及付款批次接口不改;差旅订单 bind/unbind 分支不改。若未来需要财务纠正票据归属,必须在 Task B 设计专用、可审计的业务动作,不能继续借用报销人解绑接口。
- 不改变状态机、权限码集合、角色定义、数据库 schema 或迁移。
### R2 PC 与移动端统一维护资格
- 页面使用“参与人 AND 状态允许编辑”计算统一的 `canMaintainReimbursement`,不得再把 `draft/rejected` 或 `expense.view_all` 单独当成维护资格。
- 非参与人不得看到或触发:新增/编辑/删除明细、修改报销日期/事由/差旅摘要、关联项目下拉框、添加/移除发票、上传/删除附件、提交报销、删除草稿、撤回、催办等报销人自助动作。
- owner/submitter 在 draft/rejected 状态继续看到并可使用既有维护动作;owner/submitter 在 reviewing 状态继续使用 Task A 已修复的撤回和催办合同,其他状态行为不变。
- 只读查看者可继续查看报销概要、明细事实、处理进度、材料原件/合并预览、风险与材料对应情况,以及权限允许的打印入口;页面不通过禁用按钮暗示不可用动作,而是完全不渲染无资格动作。
- PC 和移动端必须使用同一资格语义,不能只修截图对应的 PC 页面。
### R3 非参与人使用事实型查看文案
- 同一详情路由可以复用,但非参与人应显示明确的“只读查看”上下文,不伪装成报销人维护页,也不新建一套重复详情页面。
- 非参与人的“下一步”按当前业务状态表达观察事实:草稿为“等待报销人完善”,驳回为“等待报销人修改后重提”,审批中显示当前处理事实,已通过为“等待财务处理”,已支付为“查看付款结果”。不得提示其新增明细、添加发票或提交审批。
- 发票存在真实 `linkedItemId` 时显示 `关联明细:<费用类型 · 明细说明>`;为空时显示 `关联明细:未关联`,关联对象无法解析时显示 `关联明细:无法识别`。不得再显示“自动生成或匹配明细”。
- bound 发票在当前不可维护视角(包括非参与人,以及 owner/submitter 查看 approved/paid 等非编辑状态)使用事实状态“已关联”,且不显示“草稿或驳回状态可移除”等操作提示。只有参与人的可维护视角可保留必要的移除说明。
- 材料对应、票面金额、日期和风险提示继续作为财务/审批核验信息展示;本轮不重构为 Task B 的一页式审批工作台。
### R4 回归与可部署基线
- 前端回归至少覆盖 PC 和移动端的非参与管理员/财务查看他人 draft、rejected、reviewing 报销,断言全部维护动作不存在、只读文案正确、原件和合并预览入口仍可用。
- owner/submitter 合成场景继续覆盖 draft/rejected 维护动作和 reviewing 撤回,防止把页面整体改成只读。
- 服务端对每类维护接口至少覆盖一个非参与高权限身份的 `403` 和零副作用,并保留参与人成功基线;发票 bind/unbind 同时保留差旅分支现有合同。
- 权限与审批规则改动实施后必须完成独立只读复核;未经复核和新工作提交,不更新部署候选、不推送、不部署。
## Acceptance Criteria
- [ ] 非参与管理员或财务查看他人 draft/rejected 报销时,PC 和移动端不存在明细、项目、票据、附件、提交、删除、撤回和催办等报销人维护动作。
- [ ] 同一非参与人仍能查看报销概要、明细事实、处理进度、材料原件和合并预览;reviewing 审批/财务职责入口仍在原工作流页面正常使用。
- [ ] 非参与人看到“只读查看”上下文;未关联票据显示“关联明细:未关联”,bound 状态显示“已关联”,页面不存在“自动生成或匹配明细”和“草稿或驳回状态可移除”。
- [ ] 非参与人直接调用报销头修改/删除、附件增删、明细增改删、提交、项目关联、发票 bind/unbind 均返回 `403 PERM_001`,且对应数据、文件、审计、workflow 和通知零副作用。
- [ ] 普通非参与人调用 precheck 返回 `403 PERM_001` 且 precheck 服务零调用;owner/submitter 与 `expense.view_all` 查看者保持成功读取基线。
- [ ] owner/submitter 在 approved/paid 等非编辑状态调用 `link-project` 返回 409 且项目归属不变;draft/rejected 且具备 `expense.edit_own` 时保持成功基线。
- [ ] owner/submitter 在既有允许状态仍可维护、提交、撤回和催办自己的报销;现有状态校验、材料查看、打印、财务付款、workflow 审批和差旅票据分支不变。
- [ ] 无 schema、迁移、权限码或角色定义变更;前后端聚焦测试、前端 typecheck/build、相关 Playwright、Trellis 治理、敏感检查和 `git diff --check` 通过。
- [ ] 只读权限复核结论为 PASS 后才形成新工作提交;部署子任务随后重新冻结包含 Task A 与本任务的候选 SHA,原 `174cb7a` 不再作为最终部署基线。
## Out of Scope
- Task B 的财务一页式审批、费用口径、`requires_invoice`、票据—明细强制关联、金额差异门禁和多票拆分。
- 为财务或管理员新增“代员工修改”“纠正票据归属”或其他管理修复入口。
- 修改 workflow 审批/退回、付款批次、财务付款、材料查看授权、打印权限、发票 OCR 或材料合并算法。
- 处理 `BX-20260828-0017` 的真实单据、票面差额、退回、付款或附件重传。
- 修复 r102 差旅夹具测试债;该项仍按独立任务处理。
- 推送、部署、公网验证、生产配置、生产数据写入和任务归档。
## Risks and Constraints
- 服务端移除 admin/`expense.view_all` 写入豁免属于真实权限收紧,必须用直接接口测试证明读取能力仍在、写入统一拒绝。
- 不能只隐藏前端动作;否则旧客户端或直接 API 仍可变更他人报销。
- 不能把所有非参与人操作一刀切:workflow 审批、财务付款和授权材料查看是职责动作,必须与报销人维护动作区分。
- 移除 admin 通用写入豁免后,员工离职或长期缺席造成的卡住草稿暂时没有代维护入口;若真实发生,应在 Task B 的专用可审计纠错动作或独立运营修复任务中重新规划,本任务不以保留通用后门兜底。
- 同一页面角色化比复制新页面更低风险,但 PC/移动端必须共享资格事实和文案合同,避免再次漂移。
## Test Plan
- 服务端语法与基础:`cd server && node --check src/routes/reimbursements.js && node --check src/routes/invoicePool.js && npm run prisma:validate`。
- 服务端聚焦:新增或扩展报销参与人资格矩阵测试,覆盖 form/items/attachments/submit/project/invoice bind-unbind 的非参与高权限 403、零副作用和参与人成功基线。
- 前端:`cd client && npm run typecheck && npm run build`。
- Playwright:扩展 `r102-reimbursement-main-flow.spec.ts` 或新增聚焦 spec,使用阻断 Service Worker 的合成 PC/移动身份覆盖 participant 与 nonparticipant 的 draft/rejected/reviewing 投影及只读票据文案。
- 全局回归:运行相关报销、材料预览和系统动作确认用例;完整 r102 的既有差旅夹具失败必须如实单列,不得把它当成本任务新失败或默认绿色基线。
- 治理:`node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/check-trellis-governance.mjs`、`node scripts/qa/check-sensitive-content.mjs`、`git diff --check`。
@@ -0,0 +1,32 @@
{
"id": "reimbursement-nonparticipant-readonly-projection",
"name": "reimbursement-nonparticipant-readonly-projection",
"title": "非参与人报销详情只读投影修复",
"description": "收紧非 owner/submitter 查看报销详情时的 PC、移动端操作投影与服务端变更资格,并改为面向审批/浏览者的只读票据文案。",
"status": "in_progress",
"dev_type": null,
"scope": "PC/mobile reimbursement read-only projection; reimbursement and invoice-pool object authorization; role-aware invoice copy",
"package": null,
"priority": "P0",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-28",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-28-reimbursement-detail-material-preview-fixes",
"relatedFiles": [
"client/src/pages/ReimbursementDetailPage.tsx",
"client/src/pages/MobileReimbursementDetailPage.tsx",
"client/src/lib/invoiceStatus.ts",
"server/src/routes/reimbursements.js",
"server/src/routes/invoicePool.js"
],
"notes": "阻断 08-28-reimbursement-detail-material-preview-deploy:部署候选不再固定为 174cb7a,须待本任务实施、复核和提交后更新为包含两轮修复的新基线。",
"meta": {}
}
@@ -198,12 +198,12 @@ export function MobileReimbursementDetailPage() {
const detail = detailQuery.data; const detail = detailQuery.data;
const canUploadInvoice = hasPermission(permissions, "invoice.upload"); const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canSubmitExpense = hasPermission(permissions, "expense.submit"); const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]); const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id)); const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
const canEdit = isEditableStatus(detail?.status); const canEdit = isEditableStatus(detail?.status);
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null; const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
const canRemindPendingWorkflow = Boolean( const canRemindPendingWorkflow = Boolean(
detail?.status === "reviewing" && detail?.status === "reviewing" &&
@@ -211,14 +211,14 @@ export function MobileReimbursementDetailPage() {
isParticipant && isParticipant &&
detail?.pendingWorkflow?.task?.assignee_id !== me?.id, detail?.pendingWorkflow?.task?.assignee_id !== me?.id,
); );
const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice); const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense)); const canUnbindInvoices = canMaintainReimbursement;
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense)); const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && isParticipant);
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense)); const canManageAttachments = canMaintainReimbursement;
const selectedInvoiceEntries = Object.entries(selectedInvoices); const selectedInvoiceEntries = Object.entries(selectedInvoices);
const reimbursementPrecheckQuery = useQuery({ const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(reimbursementId && canSubmitExpense && detail), enabled: Boolean(reimbursementId && canSubmitExpense && canMaintainReimbursement),
queryKey: ["reimbursements", reimbursementId, "precheck"], queryKey: ["reimbursements", reimbursementId, "precheck"],
queryFn: () => precheckReimbursement(reimbursementId), queryFn: () => precheckReimbursement(reimbursementId),
}); });
@@ -834,10 +834,10 @@ export function MobileReimbursementDetailPage() {
const resolveLinkedItemLabel = (linkedItemId: string | null) => { const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) { if (!linkedItemId) {
return "自动生成或匹配明细"; return "未关联";
} }
const item = detail?.items.find((candidate) => candidate.id === linkedItemId); const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
return item ? itemLabel(item) : "明细"; return item ? itemLabel(item) : "无法识别";
}; };
const expenseTypes = expenseTypesQuery.data?.items ?? []; const expenseTypes = expenseTypesQuery.data?.items ?? [];
@@ -874,7 +874,7 @@ export function MobileReimbursementDetailPage() {
} }
const displayCode = resolveReimbursementDisplayCode(detail); const displayCode = resolveReimbursementDisplayCode(detail);
const showSubmitAction = Boolean(canEdit && canSubmitExpense); const showSubmitAction = Boolean(canMaintainReimbursement && canSubmitExpense);
const showStickyActions = showSubmitAction; const showStickyActions = showSubmitAction;
const showSecondaryActions = Boolean(actionError || canDeleteDraft); const showSecondaryActions = Boolean(actionError || canDeleteDraft);
@@ -903,6 +903,16 @@ export function MobileReimbursementDetailPage() {
<div className="mobile-section-header mobile-section-header--top"> <div className="mobile-section-header mobile-section-header--top">
<div> <div>
<h2 className="mobile-section-title">报销概况</h2> <h2 className="mobile-section-title">报销概况</h2>
{!isParticipant ? (
<p className="mt-1 text-xs text-zinc-500">
只读查看
{detail.status === "draft"
? " · 等待报销人完善"
: detail.status === "rejected"
? " · 等待报销人修改后重提"
: ""}
</p>
) : null}
</div> </div>
</div> </div>
<div data-testid="mobile-reimbursement-summary"> <div data-testid="mobile-reimbursement-summary">
@@ -990,7 +1000,7 @@ export function MobileReimbursementDetailPage() {
<section className="mobile-card" data-testid="mobile-reimbursement-items"> <section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2> <h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
{canEdit ? ( {canMaintainReimbursement ? (
<Button size="sm" variant="secondary" type="button" onClick={startCreateItem}> <Button size="sm" variant="secondary" type="button" onClick={startCreateItem}>
新增 新增
</Button> </Button>
@@ -1076,7 +1086,7 @@ export function MobileReimbursementDetailPage() {
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div> <div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div>
</div> </div>
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null} {item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
{canEdit ? ( {canMaintainReimbursement ? (
<div className="mobile-two-action-grid mt-3"> <div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}> <Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
编辑 编辑
@@ -1220,7 +1230,9 @@ export function MobileReimbursementDetailPage() {
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2"> <div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
{detail.invoices.map((invoice) => { {detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice); const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
return ( return (
<article key={invoice.id} className="mobile-list-card"> <article key={invoice.id} className="mobile-list-card">
<button <button
+37 -16
查看文件
@@ -158,6 +158,12 @@ export function ReimbursementDetailPage() {
}); });
const projectsQuery = useQuery({ const projectsQuery = useQuery({
enabled: Boolean(
detailQuery.data &&
me?.id &&
[detailQuery.data.ownerId, detailQuery.data.submitterId].includes(me.id) &&
["draft", "rejected"].includes(detailQuery.data.status),
),
queryKey: ["projects", "reimbursement-link"], queryKey: ["projects", "reimbursement-link"],
queryFn: () => fetchProjects({ mine: true }), queryFn: () => fetchProjects({ mine: true }),
}); });
@@ -165,12 +171,18 @@ export function ReimbursementDetailPage() {
const canSubmitExpense = hasPermission(permissions, "expense.submit"); const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canUploadInvoice = hasPermission(permissions, "invoice.upload"); const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own"); const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]); const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const reimbursementPrecheckQuery = useQuery({ const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(reimbursementId) && canSubmitExpense && Boolean(detailQuery.data), enabled: Boolean(
reimbursementId &&
canSubmitExpense &&
detailQuery.data &&
me?.id &&
[detailQuery.data.ownerId, detailQuery.data.submitterId].includes(me.id) &&
["draft", "rejected"].includes(detailQuery.data.status),
),
queryKey: ["reimbursements", reimbursementId, "precheck"], queryKey: ["reimbursements", reimbursementId, "precheck"],
queryFn: () => precheckReimbursement(reimbursementId), queryFn: () => precheckReimbursement(reimbursementId),
}); });
@@ -724,6 +736,7 @@ export function ReimbursementDetailPage() {
const budgetHint = detail.budgetHint; const budgetHint = detail.budgetHint;
const canEditItems = ["draft", "rejected"].includes(detail.status); const canEditItems = ["draft", "rejected"].includes(detail.status);
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? ""); const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
const canMaintainReimbursement = canEditItems && isReimbursementParticipant;
const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null; const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null;
const canRemindPendingWorkflow = Boolean( const canRemindPendingWorkflow = Boolean(
detail.status === "reviewing" && detail.status === "reviewing" &&
@@ -736,16 +749,16 @@ export function ReimbursementDetailPage() {
isReimbursementParticipant && isReimbursementParticipant &&
canUploadInvoice && canUploadInvoice &&
canViewOwnInvoice; canViewOwnInvoice;
const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense); const canUnbindInvoices = canMaintainReimbursement;
const canDeleteReimbursement = const canDeleteReimbursement =
detail.status === "draft" && detail.status === "draft" &&
canDeleteExpense && canDeleteExpense &&
(isReimbursementParticipant || canViewAllExpense); isReimbursementParticipant;
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? []; const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block"); const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn"); const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。"; const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : ""; const submitInvoiceNotice = canMaintainReimbursement && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
const selectedInvoiceEntries = Object.entries(selectedInvoices); const selectedInvoiceEntries = Object.entries(selectedInvoices);
function handleInvoicePdfSelection(files: FileList | null) { function handleInvoicePdfSelection(files: FileList | null) {
@@ -886,12 +899,12 @@ export function ReimbursementDetailPage() {
const resolveLinkedItemLabel = (linkedItemId: string | null) => { const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) { if (!linkedItemId) {
return "自动生成或匹配明细"; return "未关联";
} }
const linkedItem = detail.items.find((item) => item.id === linkedItemId); const linkedItem = detail.items.find((item) => item.id === linkedItemId);
if (!linkedItem) { if (!linkedItem) {
return "未识别明细"; return "无法识别";
} }
return `${linkedItem.expenseType?.name ?? "未识别费用类型"} · ${linkedItem.description ?? "未填写说明"}`; return `${linkedItem.expenseType?.name ?? "未识别费用类型"} · ${linkedItem.description ?? "未填写说明"}`;
@@ -909,6 +922,9 @@ export function ReimbursementDetailPage() {
: detail.status === "paid" ? "已完成" : "待处理"; : detail.status === "paid" ? "已完成" : "待处理";
const nextStepText = (() => { const nextStepText = (() => {
if (["draft", "rejected"].includes(detail.status)) { if (["draft", "rejected"].includes(detail.status)) {
if (!isReimbursementParticipant) {
return detail.status === "rejected" ? "等待报销人修改后重提" : "等待报销人完善";
}
if (!itemCount) { if (!itemCount) {
return "补报销明细"; return "补报销明细";
} }
@@ -933,7 +949,7 @@ export function ReimbursementDetailPage() {
})(); })();
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length); const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
const primaryAction = (() => { const primaryAction = (() => {
if (["draft", "rejected"].includes(detail.status) && canEditItems) { if (canMaintainReimbursement) {
if (!itemCount) { if (!itemCount) {
return ( return (
<Button <Button
@@ -1021,6 +1037,9 @@ export function ReimbursementDetailPage() {
meta={( meta={(
<> <>
<StatusBadge status={detail.status} /> <StatusBadge status={detail.status} />
{!isReimbursementParticipant ? (
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">只读查看</span>
) : null}
</> </>
)} )}
/> />
@@ -1078,7 +1097,7 @@ export function ReimbursementDetailPage() {
editable={Boolean(canEditItems && isReimbursementParticipant)} editable={Boolean(canEditItems && isReimbursementParticipant)}
embedded embedded
/> />
{canEditItems || detail.project || detail.linkedTravelRequest ? ( {canMaintainReimbursement || detail.project || detail.linkedTravelRequest ? (
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm" data-testid="reimbursement-related-records"> <div className="mt-4 border-t border-zinc-200 pt-4 text-sm" data-testid="reimbursement-related-records">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<span className="font-medium text-zinc-900">关联事项</span> <span className="font-medium text-zinc-900">关联事项</span>
@@ -1088,7 +1107,7 @@ export function ReimbursementDetailPage() {
</Link> </Link>
) : null} ) : null}
</div> </div>
{canEditItems ? ( {canMaintainReimbursement ? (
<select <select
className="select-std mt-3 max-w-xl" className="select-std mt-3 max-w-xl"
value={detail.projectId ?? ""} value={detail.projectId ?? ""}
@@ -1155,7 +1174,7 @@ export function ReimbursementDetailPage() {
<SectionPanel title="报销明细"> <SectionPanel title="报销明细">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items"> <div className="space-y-4" data-testid="reimbursement-items">
{canEditItems ? ( {canMaintainReimbursement ? (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
data-testid="reimbursement-item-add" data-testid="reimbursement-item-add"
@@ -1178,7 +1197,7 @@ export function ReimbursementDetailPage() {
</p> </p>
) : null} ) : null}
{reimbursementPrecheckQuery.isLoading && canEditItems ? ( {reimbursementPrecheckQuery.isLoading && canMaintainReimbursement ? (
<p className="text-sm text-zinc-500">正在刷新费用标准校验结果...</p> <p className="text-sm text-zinc-500">正在刷新费用标准校验结果...</p>
) : null} ) : null}
@@ -1241,7 +1260,7 @@ export function ReimbursementDetailPage() {
))} ))}
</TableCell> </TableCell>
<TableCell> <TableCell>
{canEditItems ? ( {canMaintainReimbursement ? (
<div className="flex min-w-36 items-center gap-2"> <div className="flex min-w-36 items-center gap-2">
<Button <Button
data-testid="reimbursement-item-edit" data-testid="reimbursement-item-edit"
@@ -1358,7 +1377,9 @@ export function ReimbursementDetailPage() {
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}> <div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => { {detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice); const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
return ( 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" key={invoice.id}>
<button <button
@@ -1397,7 +1418,7 @@ export function ReimbursementDetailPage() {
</div> </div>
<div className="space-y-4" data-testid="reimbursement-attachments"> <div className="space-y-4" data-testid="reimbursement-attachments">
{canEditItems ? ( {canMaintainReimbursement ? (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-zinc-500">支持 PDF/JPG/PNG/WebP,单次最多 5 个。</p> <p className="text-xs text-zinc-500">支持 PDF/JPG/PNG/WebP,单次最多 5 个。</p>
<div className="flex flex-col gap-2 md:flex-row md:items-center"> <div className="flex flex-col gap-2 md:flex-row md:items-center">
@@ -1461,7 +1482,7 @@ export function ReimbursementDetailPage() {
</span> </span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" /> <ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button> </button>
{canEditItems ? ( {canMaintainReimbursement ? (
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
@@ -452,7 +452,7 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("报销合计 ¥168.30"); await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("报销合计 ¥168.30");
}); });
test("管理员查看他人报销时隐藏撤回并可在移动端切换全部报销", async ({ page }) => { test("管理员查看他人报销时保持只读并可在移动端切换全部报销", async ({ page }) => {
const adminMe = { const adminMe = {
...me, ...me,
id: "admin-r102", id: "admin-r102",
@@ -475,11 +475,25 @@ test.describe("R102 reimbursement main flow", () => {
...reimbursementDetail(), ...reimbursementDetail(),
id: otherReimbursementId, id: otherReimbursementId,
formNumber: "BX-20260731-0002", formNumber: "BX-20260731-0002",
status: "reviewing", status: "draft",
ownerId: "employee-other-r102", ownerId: "employee-other-r102",
submitterId: "employee-other-r102", submitterId: "employee-other-r102",
owner: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" }, owner: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
submitter: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" }, submitter: { id: "employee-other-r102", employee_no: "E103", name: "其他员工" },
invoices: [{
id: "invoice-other-r102",
status: "bound",
invoiceCode: "SYNTHETIC",
invoiceNumber: "R102-OTHER",
invoiceDate: "2026-07-31",
sellerName: "合成供应方",
buyerName: "合成购买方",
totalAmount: "168.30",
originalName: "合成发票.pdf",
mimeType: "application/pdf",
fileSize: 1024,
linkedItemId: null,
}],
}); });
}); });
await page.route((url) => url.pathname === "/api/reimbursements", async (route) => { await page.route((url) => url.pathname === "/api/reimbursements", async (route) => {
@@ -515,6 +529,13 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByTestId("reimbursement-overview")).toBeVisible(); await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
await expect(page.getByTestId("reimbursement-withdraw")).toHaveCount(0); await expect(page.getByTestId("reimbursement-withdraw")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-withdraw-comment")).toHaveCount(0); await expect(page.getByTestId("reimbursement-withdraw-comment")).toHaveCount(0);
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByText("等待报销人完善")).toBeVisible();
await expect(page.getByTestId("reimbursement-item-add")).toHaveCount(0);
await expect(page.getByRole("combobox", { name: "关联项目" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-invoices")).toContainText("明细:未关联");
await expect(page.getByTestId("reimbursement-invoices")).toContainText("已关联");
await expect(page.getByText("草稿或驳回状态可移除")).toHaveCount(0);
await page.setViewportSize({ width: 375, height: 812 }); await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/mobile/reimbursements?status=reviewing"); await page.goto("/mobile/reimbursements?status=reviewing");
@@ -543,6 +564,11 @@ test.describe("R102 reimbursement main flow", () => {
await page.getByText("其他员工").click(); await page.getByText("其他员工").click();
await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`)); await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`));
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-items").getByRole("button", { name: "新增" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-invoices")).toContainText("明细:未关联");
await expect(page.getByTestId("reimbursement-invoices")).toContainText("已关联");
await expect(page.getByRole("button", { name: "移除" })).toHaveCount(0);
const routeState = await page.evaluate(() => window.history.state?.usr ?? null); const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
expect(routeState?.from).toContain("status=reviewing"); expect(routeState?.from).toContain("status=reviewing");
expect(routeState?.from).toContain("scope=all"); expect(routeState?.from).toContain("scope=all");
+2 -4
查看文件
@@ -1079,8 +1079,7 @@ async function invoicePoolRoutes(fastify) {
const isOwn = const isOwn =
reimbursement.owner_id === request.employee.id || reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id; reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (!isOwn) {
if (!isOwn && !canViewAll) {
throw new AppError({ throw new AppError({
code: "PERM_001", code: "PERM_001",
message: "无权限", message: "无权限",
@@ -1591,8 +1590,7 @@ async function invoicePoolRoutes(fastify) {
const isOwn = const isOwn =
reimbursement.owner_id === request.employee.id || reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id; reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (!isOwn) {
if (!isOwn && !canViewAll) {
throw new AppError({ throw new AppError({
code: "PERM_001", code: "PERM_001",
message: "无权限", message: "无权限",
+46 -18
查看文件
@@ -100,6 +100,9 @@ const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
.object({ reason: z.string().trim().max(200).optional() }) .object({ reason: z.string().trim().max(200).optional() })
.strict(); .strict();
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA; const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z
.object({ projectId: z.string().uuid().nullable() })
.strict();
function resolveSignedFileOrigin(config, request) { function resolveSignedFileOrigin(config, request) {
const configured = String(config.appOrigin ?? "") const configured = String(config.appOrigin ?? "")
@@ -583,16 +586,12 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
}; };
} }
function ensureApplicantOrAdmin(request, reimbursement) { function ensureReimbursementParticipant(request, reimbursement) {
if (request.employee?.roleCodes?.includes("admin")) { const isParticipant =
return;
}
const isApplicant =
reimbursement.owner_id === request.employee.id || reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id; reimbursement.submitter_id === request.employee.id;
if (!isApplicant) { if (!isParticipant) {
throw new AppError({ throw new AppError({
code: "PERM_001", code: "PERM_001",
message: "无权限", message: "无权限",
@@ -680,6 +679,19 @@ function parseReimbursementUpdateBody(body) {
return parsed.data; return parsed.data;
} }
function parseReimbursementProjectLinkBody(body) {
const parsed = REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseReimbursementAttachmentDeleteBody(body) { function parseReimbursementAttachmentDeleteBody(body) {
const parsed = REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {}); const parsed = REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) { if (!parsed.success) {
@@ -2758,7 +2770,7 @@ async function reimbursementRoutes(fastify) {
} }
const reimbursement = reimbursementRows[0]; const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement); ensureReimbursementParticipant(request, reimbursement);
await ensureCutoverAllowed({ await ensureCutoverAllowed({
prisma: tx, prisma: tx,
config: fastify.config, config: fastify.config,
@@ -2861,7 +2873,7 @@ async function reimbursementRoutes(fastify) {
} }
const reimbursement = reimbursementRows[0]; const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement); ensureReimbursementParticipant(request, reimbursement);
if (reimbursement.status !== "draft") { if (reimbursement.status !== "draft") {
throw new AppError({ throw new AppError({
@@ -2928,7 +2940,7 @@ async function reimbursementRoutes(fastify) {
}); });
} }
ensureApplicantOrAdmin(request, reimbursement); ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) { if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({ throw new AppError({
code: "VALID_001", code: "VALID_001",
@@ -3140,7 +3152,7 @@ async function reimbursementRoutes(fastify) {
} }
const reimbursement = reimbursementRows[0]; const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement); ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) { if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({ throw new AppError({
code: "VALID_001", code: "VALID_001",
@@ -3617,7 +3629,7 @@ async function reimbursementRoutes(fastify) {
} }
const reimbursement = reimbursementRows[0]; const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement); ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) { if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({ throw new AppError({
@@ -3745,7 +3757,7 @@ async function reimbursementRoutes(fastify) {
} }
const reimbursement = reimbursementRows[0]; const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement); ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) { if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({ throw new AppError({
@@ -3866,7 +3878,7 @@ async function reimbursementRoutes(fastify) {
} }
const reimbursement = reimbursementRows[0]; const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement); ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) { if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({ throw new AppError({
@@ -3910,6 +3922,14 @@ async function reimbursementRoutes(fastify) {
}, },
async (request, reply) => { async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id); 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 },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, reimbursement);
const precheck = await resolveEntityPrecheck({ const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma, prisma: fastify.prisma,
config: fastify.config, config: fastify.config,
@@ -3948,7 +3968,7 @@ async function reimbursementRoutes(fastify) {
} }
const reimbursement = reimbursementRows[0]; const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement); ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) { if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({ throw new AppError({
@@ -4486,7 +4506,7 @@ async function reimbursementRoutes(fastify) {
fastify.post( fastify.post(
"/reimbursements/:id/link-project", "/reimbursements/:id/link-project",
{ {
preHandler: fastify.authenticate, preHandler: fastify.requirePermission("expense.edit_own"),
}, },
async (request, reply) => { async (request, reply) => {
const gate = await evaluateFeatureFlag({ const gate = await evaluateFeatureFlag({
@@ -4500,7 +4520,7 @@ async function reimbursementRoutes(fastify) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason } }); throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason } });
} }
const reimbursementId = parseReimbursementId(request.params?.id); const reimbursementId = parseReimbursementId(request.params?.id);
const body = parseBody(z.object({ projectId: z.string().uuid().nullable() }).strict(), request.body); const body = parseReimbursementProjectLinkBody(request.body);
const current = await fastify.prisma.reimbursementForm.findUnique({ const current = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId }, where: { id: reimbursementId },
select: { id: true, owner_id: true, submitter_id: true, project_id: true, status: true }, select: { id: true, owner_id: true, submitter_id: true, project_id: true, status: true },
@@ -4508,7 +4528,15 @@ async function reimbursementRoutes(fastify) {
if (!current) { if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
} }
ensureOwnOrCanViewAll(fastify, request, current); ensureReimbursementParticipant(request, current);
if (!["draft", "rejected"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_project_link" },
});
}
const updated = await fastify.prisma.reimbursementForm.update({ const updated = await fastify.prisma.reimbursementForm.update({
where: { id: reimbursementId }, where: { id: reimbursementId },
data: { project_id: body.projectId ?? null, updated_by: request.employee.id }, data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
@@ -87,7 +87,7 @@ function buildPrisma({
return { prisma, state }; return { prisma, state };
} }
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] }) { async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], employeeId = "actor-1" }) {
const app = Fastify(); const app = Fastify();
app.decorate("prisma", prisma); app.decorate("prisma", prisma);
@@ -101,7 +101,7 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] })
if (!request.headers.authorization) { if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
} }
request.employee = { id: "actor-1", permissionCodes }; request.employee = { id: employeeId, permissionCodes };
}); });
app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode)); app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode));
app.decorate("requirePermission", (permissionCode) => async (request) => { app.decorate("requirePermission", (permissionCode) => async (request) => {
@@ -133,6 +133,84 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] })
} }
describe("invoicePool travel-order bind routes", () => { describe("invoicePool travel-order bind routes", () => {
it("rejects a view-all nonparticipant before binding a reimbursement invoice", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({
invoice: {
id: "11111111-1111-4111-8111-111111111111",
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
travel_order_id: null,
total_amount: "1683.00",
},
reimbursement: {
id: reimbursementId,
owner_id: "actor-1",
submitter_id: "actor-1",
status: "draft",
reimbursement_date: new Date("2026-05-08T00:00:00.000Z"),
},
});
const app = await buildApp({
prisma,
employeeId: "finance-viewer",
permissionCodes: ["invoice.upload", "expense.view_all"],
});
const response = await app.inject({
method: "POST",
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind",
headers: { authorization: "Bearer test" },
payload: { reimbursementId },
});
expect(response.statusCode).toBe(403);
expect(state.invoice.status).toBe("available");
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("rejects a view-all nonparticipant before unbinding a reimbursement invoice", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({
invoice: {
id: "11111111-1111-4111-8111-111111111111",
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: "item-1",
travel_order_id: null,
total_amount: "1683.00",
},
reimbursement: {
id: reimbursementId,
owner_id: "actor-1",
submitter_id: "actor-1",
status: "draft",
},
});
const app = await buildApp({
prisma,
employeeId: "finance-viewer",
permissionCodes: ["expense.view_all"],
});
const response = await app.inject({
method: "POST",
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/unbind",
headers: { authorization: "Bearer test" },
payload: {},
});
expect(response.statusCode).toBe(403);
expect(state.invoice.status).toBe("bound");
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("adds invoice to reimbursement and auto-creates an item when no detail matches", async () => { it("adds invoice to reimbursement and auto-creates an item when no detail matches", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({ const { prisma, state } = buildPrisma({
@@ -44,7 +44,13 @@ function buildMultipartRequest({ files, fields = {} }) {
}; };
} }
async function buildApp({ reimbursementStatus = "draft", materialPreviewStore = null } = {}) { async function buildApp({
reimbursementStatus = "draft",
materialPreviewStore = null,
employeeId = ACTOR_ID,
roleCodes = ["employee"],
permissionCodes = ["expense.edit_own", "expense.view_own"],
} = {}) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r111-reimbursement-attachments-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r111-reimbursement-attachments-"));
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const createdAttachments = []; const createdAttachments = [];
@@ -160,9 +166,9 @@ async function buildApp({ reimbursementStatus = "draft", materialPreviewStore =
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
} }
request.employee = { request.employee = {
id: ACTOR_ID, id: employeeId,
roleCodes: ["employee"], roleCodes,
permissionCodes: ["expense.edit_own", "expense.view_own"], permissionCodes,
}; };
}); });
app.decorate("hasPermission", (employee, permissionCode) => { app.decorate("hasPermission", (employee, permissionCode) => {
@@ -202,6 +208,38 @@ async function buildApp({ reimbursementStatus = "draft", materialPreviewStore =
} }
describe("reimbursement attachment routes", () => { describe("reimbursement attachment routes", () => {
it("rejects an admin view-all nonparticipant before attachment side effects", async () => {
const { app, prisma, tempDir, reimbursementId, createdAttachments } = await buildApp({
employeeId: "finance-viewer",
roleCodes: ["admin"],
permissionCodes: ["expense.edit_own", "expense.view_all"],
});
try {
const requestPayload = buildMultipartRequest({
files: [{
filename: "synthetic.pdf",
contentType: "application/pdf",
content: "%PDF synthetic",
}],
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${reimbursementId}/attachments`,
headers: { authorization: "Bearer test", ...requestPayload.headers },
payload: requestPayload.payload,
});
expect(response.statusCode).toBe(403);
expect(createdAttachments).toHaveLength(0);
expect(prisma.reimbursementAttachment.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("uploads five attachment materials with the production global one-file baseline", async () => { it("uploads five attachment materials with the production global one-file baseline", async () => {
const { app, prisma, tempDir, reimbursementId, createdAttachments } = await buildApp(); const { app, prisma, tempDir, reimbursementId, createdAttachments } = await buildApp();
@@ -177,7 +177,26 @@ async function buildApp({
paymentActors = [], paymentActors = [],
} = {}) { } = {}) {
const app = Fastify(); const app = Fastify();
const tx = {
$queryRaw: vi.fn(async () => [reimbursement]),
reimbursementForm: {
update: vi.fn(),
delete: vi.fn(),
},
invoicePool: {
updateMany: vi.fn(),
},
reimbursementItem: {
create: vi.fn(),
update: vi.fn(),
deleteMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
};
const prisma = { const prisma = {
$transaction: vi.fn(async (callback) => callback(tx)),
reimbursementForm: { reimbursementForm: {
findUnique: vi.fn(async ({ where }) => { findUnique: vi.fn(async ({ where }) => {
if (where.id !== REIMBURSEMENT_ID) { if (where.id !== REIMBURSEMENT_ID) {
@@ -185,6 +204,22 @@ async function buildApp({
} }
return reimbursement; return reimbursement;
}), }),
update: vi.fn(async ({ data }) => ({
id: REIMBURSEMENT_ID,
project_id: data.project_id ?? null,
project: data.project_id
? { id: data.project_id, project_no: "P-001", name: "合成项目" }
: null,
})),
},
featureFlag: {
findUnique: vi.fn(async () => ({
flag_key: "project_linking_v1",
override_mode: "inherit",
rollout_type: "global",
department_ids: [],
version: 1,
})),
}, },
workflowInstance: { workflowInstance: {
findFirst: vi.fn(async () => latestWorkflowInstance), findFirst: vi.fn(async () => latestWorkflowInstance),
@@ -210,6 +245,7 @@ async function buildApp({
app.decorate("config", { app.decorate("config", {
budgetControlMode: "warn", budgetControlMode: "warn",
appTimezone: "Asia/Shanghai", appTimezone: "Asia/Shanghai",
projectLinkingEnabled: true,
}); });
app.decorate("authenticate", async (request) => { app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) { if (!request.headers.authorization) {
@@ -255,7 +291,7 @@ async function buildApp({
}); });
await app.register(reimbursementRoutes, { prefix: "/api" }); await app.register(reimbursementRoutes, { prefix: "/api" });
return { app, prisma }; return { app, prisma, tx };
} }
describe("GET /api/reimbursements/:id detail enhancements", () => { describe("GET /api/reimbursements/:id detail enhancements", () => {
@@ -886,3 +922,101 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
await app.close(); await app.close();
}); });
}); });
describe("POST /api/reimbursements/:id/link-project authorization", () => {
const projectId = "66666666-6666-4666-8666-666666666666";
it("rejects a view-all nonparticipant without changing the project", async () => {
const { app, prisma } = await buildApp({
employeeId: "finance-viewer",
permissionCodes: ["expense.view_all", "expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`,
headers: { authorization: "Bearer test" },
payload: { projectId },
});
expect(response.statusCode, response.body).toBe(403);
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
await app.close();
});
it.each(["approved", "paid"])("rejects participant project changes in %s status", async (status) => {
const { app, prisma } = await buildApp({
employeeId: OWNER_ID,
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`,
headers: { authorization: "Bearer test" },
payload: { projectId },
});
expect(response.statusCode, response.body).toBe(409);
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
await app.close();
});
it("allows a draft participant to change the project", async () => {
const { app, prisma } = await buildApp({
employeeId: SUBMITTER_ID,
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`,
headers: { authorization: "Bearer test" },
payload: { projectId },
});
expect(response.statusCode, response.body).toBe(200);
expect(prisma.reimbursementForm.update).toHaveBeenCalledTimes(1);
await app.close();
});
});
describe("reimbursement maintenance authorization", () => {
const itemId = "77777777-7777-4777-8777-777777777777";
const expenseTypeId = "88888888-8888-4888-8888-888888888888";
it.each([
["PATCH", `/api/reimbursements/${REIMBURSEMENT_ID}`, { description: "合成修改" }, "expense.edit_own"],
["DELETE", `/api/reimbursements/${REIMBURSEMENT_ID}`, undefined, "expense.delete_own"],
["POST", `/api/reimbursements/${REIMBURSEMENT_ID}/items`, {
expenseTypeId,
expenseDate: "2026-04-10",
amount: "10.00",
description: "合成明细",
}, "expense.edit_own"],
["PATCH", `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, { amount: "20.00" }, "expense.edit_own"],
["DELETE", `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, undefined, "expense.edit_own"],
])("rejects a view-all nonparticipant for %s %s before mutations", async (method, url, payload, permission) => {
const { app, tx } = await buildApp({
employeeId: "finance-viewer",
permissionCodes: [permission, "expense.view_all"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method,
url,
headers: { authorization: "Bearer test" },
...(payload ? { payload } : {}),
});
expect(response.statusCode, response.body).toBe(403);
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.reimbursementForm.delete).not.toHaveBeenCalled();
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
expect(tx.reimbursementItem.create).not.toHaveBeenCalled();
expect(tx.reimbursementItem.update).not.toHaveBeenCalled();
expect(tx.reimbursementItem.deleteMany).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
});
@@ -53,7 +53,7 @@ async function buildApp(options = {}) {
id: EMPLOYEE_ID, id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID, department_id: DEPARTMENT_ID,
roleCodes: [], roleCodes: [],
permissionCodes: ["expense.submit"], permissionCodes: ["expense.submit", "expense.view_own"],
}; };
const reimbursement = options.reimbursement ?? { const reimbursement = options.reimbursement ?? {
id: REIMBURSEMENT_ID, id: REIMBURSEMENT_ID,
@@ -176,6 +176,8 @@ async function buildApp(options = {}) {
reimbursementForm: { reimbursementForm: {
findUnique: vi.fn(async () => ({ findUnique: vi.fn(async () => ({
id: reimbursement.id, id: reimbursement.id,
owner_id: reimbursement.owner_id,
submitter_id: reimbursement.submitter_id,
form_number: reimbursement.form_number, form_number: reimbursement.form_number,
total_amount: reimbursement.total_amount, total_amount: reimbursement.total_amount,
owner: { owner: {
@@ -239,6 +241,29 @@ describe("reimbursements routes expense standard integration", () => {
resolveRecipientsForAssigneeMock.mockResolvedValue([]); resolveRecipientsForAssigneeMock.mockResolvedValue([]);
}); });
it("rejects an admin view-all nonparticipant before creating submission side effects", async () => {
const { app, prisma, tx } = await buildApp({
actor: {
id: "finance-viewer",
department_id: DEPARTMENT_ID,
roleCodes: ["admin"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
});
expect(response.statusCode).toBe(403);
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
expect(tx.workflowTask.create).not.toHaveBeenCalled();
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(prisma.reimbursementForm.findUnique).not.toHaveBeenCalled();
await app.close();
});
it("blocks travel reimbursement submit until the structured summary is complete", async () => { it("blocks travel reimbursement submit until the structured summary is complete", async () => {
const { app, tx } = await buildApp({ travelItem: true }); const { app, tx } = await buildApp({ travelItem: true });
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
@@ -347,6 +372,47 @@ describe("reimbursements routes expense standard integration", () => {
await app.close(); await app.close();
}); });
it("rejects an ordinary nonparticipant before resolving reimbursement precheck", async () => {
const { app } = await buildApp({
actor: {
id: "ordinary-nonparticipant",
department_id: DEPARTMENT_ID,
roleCodes: ["employee"],
permissionCodes: ["expense.submit"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`,
});
expect(response.statusCode).toBe(403);
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
await app.close();
});
it("allows a view-all nonparticipant to read reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
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(200);
expect(resolveEntityPrecheckMock).toHaveBeenCalledTimes(1);
await app.close();
});
it("blocks submit with 400 and standardViolations when block standard is exceeded", async () => { it("blocks submit with 400 and standardViolations when block standard is exceeded", async () => {
resolveEntityPrecheckMock.mockResolvedValue( resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({ buildPrecheck({
@@ -103,7 +103,7 @@ describe("paymentProcessingProgress", () => {
); );
const reimbursementAssembler = reimbursementsSource.slice( const reimbursementAssembler = reimbursementsSource.slice(
reimbursementsSource.indexOf("function buildProcessingProgress("), reimbursementsSource.indexOf("function buildProcessingProgress("),
reimbursementsSource.indexOf("function ensureApplicantOrAdmin("), reimbursementsSource.indexOf("function ensureReimbursementParticipant("),
); );
const paymentBatchAssembler = paymentBatchesSource.slice( const paymentBatchAssembler = paymentBatchesSource.slice(
paymentBatchesSource.indexOf("function buildPaymentBatchProcessingProgress("), paymentBatchesSource.indexOf("function buildPaymentBatchProcessingProgress("),