比较提交

...
6 次代码提交
共修改 17 个文件,包含 1423 行新增和 362 行删除
+117
查看文件
@@ -93,3 +93,120 @@ if (!hasPermission(actor, "payment.batch.review")
throw forbidden();
}
```
## Scenario: Reimbursement End-To-End Processing Projection
### 1. Scope / Trigger
- Trigger: showing one reimbursement's progress across approval workflow,
payment-batch review, payment execution and receipt retention.
- This is a read-only projection for diagnosis. It must not merge the workflow
and payment state machines or create synthetic workflow tasks.
### 2. Signatures
- Detail API: `GET /api/reimbursements/:id`.
- Response: `processingProgress.stages[]` with `key`, `label`, `state`,
nullable `actorName`, `occurredAt`, `result`, `comment`, `detail` and
permission-gated `targetHref`.
- Fixed stage keys: `submitted`, `payment_batch_review` and
`payment_and_receipt`. Each persisted workflow task contributes one unique
approval-stage key derived from task identity; clients treat keys as opaque
strings and never infer responsibility from their value.
### 3. Contracts
- Always return submission first, then one stage per real workflow task in the
query's stable `created_at,id` order. Because the engine creates only the
current task, append not-yet-materialized future steps from the immutable
`workflow_steps_snapshot` after the current step, then return payment-batch
review and payment/receipt. The current two-approval production rule therefore
returns five stages from initial submission onward; configured extra or
repeated-role approvals increase the array length instead of being discarded,
merged or temporarily hidden.
- Never collect workflow tasks into a `Map` keyed by semantic responsibility:
two finance, manager or CEO tasks are distinct historical facts and later
tasks must not overwrite earlier ones. The UI renders `stages` with a plain
ordered `map` and contains no fixed-length or positional responsibility logic.
- Approval labels use a readable non-generic `step_name` first. Empty,
ordinal (`步骤2`, `审批节点1`, `step 3`), raw technical identifier or generic
(`审批`, `审核`, `复核`) names fall back through role mapping
(`manager -> 主管审批`, `finance|finance_mgr -> 财务复核`,
`ceo -> 负责人复核`) and finally `审批`. UI labels never expose step IDs,
role codes or sequence numbers.
- Completed stages use the actual completer/actor and completion time. Current
workflow stages use the actual pending assignee when available. Future stages
keep `actorName=null`; never guess a person from today's role holders.
- A reimbursement approved without a batch makes `payment_batch_review` the
current stage with `detail="待财务创建付款批次"`. A draft batch says
`所在付款批次待非制单人复核`; shared-batch wording must not imply that this
reimbursement alone is being reviewed again.
- Missing workflow, batch or receipt facts degrade only their stages. Earlier
known stages remain visible; missing downstream objects are normal, not 5xx.
- `targetHref` is returned only when the viewer already has one of the existing
payment-batch read permissions. The projection never returns batch peers,
aggregate amount, other materials or bank receipt contents.
### 4. Validation & Error Matrix
- No workflow instance -> submission/applicant state remains readable and the
approval segment falls back to pending `主管审批` / `财务复核` placeholders.
- Workflow instance with no task yet -> build pending approval stages from its
immutable snapshot. Current task exists but later tasks do not -> append those
future snapshot stages with `actorName=null`; never infer future employees.
- A third unknown task -> one additional approval stage in its real position;
it must not disappear before the payment stages.
- Two tasks with the same role -> two unique ordered stages with their own
actor, state, time and comment; neither may overwrite the other.
- Workflow approved + no batch -> payment-batch stage is current, future actor
remains null.
- Batch `draft|reviewed|executed|failed|cancelled` -> map to explicit current,
completed or exception states without changing the batch.
- Executed payment + zero active receipts -> completed payment with
`付款已完成,回单待留存`; positive count -> `付款与回单已留存`.
- Viewer lacks batch-read permission -> `targetHref=null`; do not return 403 for
the reimbursement detail itself.
- Database/query failure -> existing 5xx behavior and server logging; do not
fabricate a completed or pending business fact.
### 5. Good/Base/Bad Cases
- Good: `财务复核=通过` followed by current `待财务创建付款批次`, so the owner
can diagnose the exact responsibility without asking three people.
- Base: a newly submitted reimbursement shows known submission, every real
configured approval task, and the two future payment stages with no guessed
future names.
- Bad: classify tasks into manager/finance keys and overwrite duplicates, skip
the third task, display an expected checker before batch creation, label a
shared batch as `您的报销待复核`, or hide the whole timeline because no receipt
exists.
### 6. Tests Required
- Route tests for no workflow, empty-task instance, a current task plus future
snapshot-only steps, pending/completed/returned/rejected tasks, a third
unclassified task, two same-role tasks, all five batch statuses, receipt count
zero/positive and paid-without-batch legacy data.
- Assert future stages have null actors and ordinary applicants receive no
batch link or batch-adjacent business data.
- PC/mobile tests assert variable-length ordered rendering, no `第 N 步` or raw
ordinal label, comments on exception nodes, four-block order and no loss of
earlier stages when a downstream source is absent.
### 7. Wrong vs Correct
#### Wrong
```js
for (const task of workflowTasks) {
stageByRole.set(classify(task), buildStage(task));
}
```
#### Correct
```js
const approvalStages = workflowTasks.map((task) => buildApprovalStage(task));
approvalStages.push(...buildFutureSnapshotStages(workflowSnapshot, workflowTasks));
return [submittedStage, ...approvalStages, batchReviewStage, paymentStage];
```
@@ -691,6 +691,9 @@ await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, siz
- `PATCH /api/reimbursements/:id` accepts nullable camel-case counterparts.
- Detail response includes `travelSummary: { isTravel, purpose, location,
startDate, endDate }`.
- A privileged correction of an already submitted reimbursement writes an
`AuditLog` in the same transaction. Its `extra.changes` is an object keyed by
changed field name, with `{ before, after }` scalar snapshots.
### 3. Contracts
@@ -702,6 +705,17 @@ await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, siz
min/max travel-item dates; location remains `未填写` and is never guessed.
- Submit requires all four fields only for travel reimbursements; ordinary
reimbursements remain unaffected.
- An administrator correcting an already submitted amount, expense date,
travel-summary date or other core declared financial fact must record the
exact normalized `before` and `after` values in the restricted database audit
entry. Dates use `YYYY-MM-DD`; decimal amounts use canonical strings. The
audit also records a sanitized reason and operation/task reference.
- The repository-visible Trellis task, docs and generated evidence record only
changed field names, invariant results and counts. They must not copy the
real purpose, location, amount, ticket content or audit payload.
- Do not put attachment bytes, OCR/provider payloads or unrelated personal data
into `extra.changes`. Existing `system.audit.view` authorization remains the
access boundary for the database audit.
### 4. Validation & Error Matrix
@@ -710,18 +724,33 @@ await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, siz
- Travel submit with missing fields -> `400` with
`travelSummary: incomplete` and stable `missingFields`.
- Edit while not draft/rejected -> existing `409 invalid_edit_status`.
- Privileged correction changes a core declared fact but lacks a field-level
`before` or `after` snapshot -> abort the transaction; a field-name-only
audit is insufficient.
- A historical correction is later found without its original snapshot -> do
not invent or reconstruct an audit value from memory. Record the evidence
gap and enforce the complete audit contract on future corrections.
### 5. Good/Base/Bad Cases
- Good: travel item plus complete purpose/location/date range submits normally.
- Base: office-supply reimbursement has no travel summary and is not blocked.
- Good: an authorized date correction atomically updates the row and writes
`extra.changes.expenseDate = { before, after }` using synthetic or real
database values visible only to authorized auditors.
- Bad: infer a destination from invoice OCR, attachment name or free text.
- Bad: overwrite a submitted expense date and audit only
`changedFields: ["expenseDate"]`, leaving the original fact unrecoverable.
### 6. Tests Required
- Prisma validation and all migrations against an empty PostgreSQL database.
- Route tests for detail fallback, complete/incomplete travel submit, date order
and non-travel compatibility; frontend typecheck/build and PC/mobile UI tests.
- Privileged-correction tests use synthetic data and assert one transaction,
exact normalized `before/after`, sanitized reason, unchanged amount/material/
workflow invariants, rollback on audit failure and no real values in generated
repository evidence.
### 7. Wrong vs Correct
@@ -731,12 +760,26 @@ await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, siz
travelLocation = invoice.ocrResult.sellerName;
```
```js
extra = { changedFields: ["expenseDate"] }; // original declared fact is lost
```
#### Correct
```js
travelLocation = reimbursement.travel_location ?? null;
```
```js
extra = {
changes: {
expenseDate: { before: "2026-01-02", after: "2026-01-03" },
},
reason: "authorized_correction",
operationRef: "TASK-SYNTHETIC",
};
```
## Scenario: Payment Batch Receipt Attention Count
### 1. Scope / Trigger
@@ -161,8 +161,8 @@
- 后端继续用 ISO 8601 UTC 字符串传输时间,前端不得用 `slice(0, 19)` 假装本地时间。PC 报销详情、移动报销详情和 PC 审批任务详情复用 `formatDisplayDateTime`;共享函数显式使用 `Asia/Shanghai`,使公司内部 OA 不依赖浏览器所在时区。
- 不新增页面级时间工具。合成 UTC 样例通过页面文本断言验证上海时间,覆盖附件上传时间和审批任务时间。
- PC 将“当前状态与下一步”和“业务摘要”收敛为单一首屏概况,保留总额、状态、编号、日期、报销人、说明与真正可执行入口;删除解释同一事实的二级标题和辅助句。
- PC 的明细、发票、附件各保留一个可扫描标题;附件上传限制只在可编辑时出现。关联事项仅在已有项目/差旅关联或可编辑时展示,预算/风险区域仅在存在真实提示时展示。
- PC 将“当前状态与下一步”和“业务摘要”收敛为单一首屏概况,保留总额、状态、编号、日期、报销人、说明与真正可执行入口;差旅概要并入该单头,删除解释同一事实的二级标题和辅助句。
- PC 与移动只保留单头概况、费用明细、报销材料、处理进度四个可扫描标题。附件上传限制只在可编辑时出现;关联事项作为单头行、预算/风险作为就近提示、付款作为进度节点次级事实,均不形成永久空区域。
- 移动端头部已经包含状态,因此概况卡不重复状态;只保留金额、日期、报销人和说明。只读状态不显示编辑教学或空“操作”卡,编辑态仍保留新增、上传、删除和底部提交能力。
- 采用当前设计系统和现有组件,不引入新颜色、字体、动效、布局系统或后端字段;新版 `ui-ux-pro-max` 仅用于内容优先、密度和渐进披露判断。
@@ -206,7 +206,7 @@
## 12. 报销明细与材料的 KISS 对齐
- 交互采用“一个入口、一次通览、语义保留”:费用明细只展示费用事实,不再渲染关联发票;`linkedItemId`、票据/附件类型、对象权限、审计和生命周期均不改变。付款回单仍属于付款阶段材料,不提前混入本次提交材料。
- 报销材料只读态首屏显示“发票 N · 补充附件 M”和一个明确的“查看材料合并预览”按钮,不为每份文件重复暴露技术操作。草稿/驳回编辑态仍保留发票添加/移除和附件上传/删除所需列表,数据变更动作与只读通览分离。
- 报销材料首屏显示“发票 N · 补充附件 M”和一个明确的“查看材料合并预览”按钮,下方始终显示有序原件列表。每份材料只使用一个语义化整行按钮打开原件,不再叠加“查看”按钮;草稿/驳回编辑态在同一行外保留发票移除和附件删除动作,只读审批人不显示数据变更动作。
- 服务端新增报销级合并 PDF grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 原页全部复制,JPG/PNG/WebP 经 Sharp 规范化后各嵌入一页。企微调用一次 `previewFile({ url, name, size })`,PC/普通浏览器打开同一短时 PDF URL;不引入 PDF.js,不在客户端渲染 PDF。
- 预览顺序固定为发票 `created_at, id`、补充附件 `created_at, id`、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。有序材料集合哈希覆盖材料类型、ID、文件哈希/大小/MIME,缓存键由报销单 ID、集合哈希和合并器版本组成。
- 合并在 Node `worker_threads` 中执行,不启用 Compose worker profile。worker 限制材料数、源文件总字节、PDF 总页数、图片像素、输出字节和执行时间;`pdf-lib` 不使用 `ignoreEncryption`,加密、损坏或不可复制 PDF 导致整包失败。任一材料失败都不能跳过,客户端展开现有逐份原件路径。
@@ -246,10 +246,47 @@
- 差旅单判定使用“任一明细的 `expenseType.category === travel`”或存在关联差旅申请,不新建另一套报销类型字典。
- 关联差旅申请时,概要可从已批准申请的 purpose、segments 城市、start/end date 读取;普通差旅报销使用自身四字段。历史数据只读降级为“description 作事由、明细最小/最大日期作起止日”,地点缺失如实显示,禁止从文件名、OCR 金额或自由文本猜地点。
- 四字段只允许申请人/受权代办在 `draft/rejected` 更改。审批中单不原地改业务事实;当前真实单如需完整地点,必须由主管退回后申请人补齐并重新提交。
- 详情页把“差旅概要”置于金额/报销人概况之后、费用明细之前;只有差旅单显示,不让普通费用报销承担空字段。
- 详情页把差旅概要并入金额/报销人所在的单头概况;只有差旅单显示差旅行,不让普通费用报销承担空字段或独立空卡。
- 共享 `ReimbursementTravelSummary` 以“可编辑且概要不完整”作为初始填写态条件,直接呈现四个可见标签输入框;保存成功后切回摘要态,保存失败停留在表单并显示现有错误反馈。完整概要仍由用户点击“编辑”进入表单,不引入新的步骤、弹窗或报销类型。
### 13.4 验证与回退
### 13.4 四块式 KISS 交互边界
- 材料列表与合并预览复用现有单份签名 URL、企微 `previewFile`、PC 打开方式、下载理由、权限和审计;本轮只移除只读态的列表可见性条件,不增加后端接口。
- 列表顺序沿用详情 API 的发票、补充附件顺序;不按金额、文件名或关联明细重新排序。稳定 ID 作为 React key,整行按钮保留可见焦点和至少 44px 触控高度。
- PC/移动沿用现有详情路由和返回路径,只重排当前内容为四块纯滚动;不做 Tab、折叠、抽屉、普通/差旅二选一、费用日期交叉校验、补助自动计算或审批页重构。
- 真实风险以内联提示贴近单头/明细,关联项目或差旅申请以单头一行链接呈现;草稿/驳回动作留在页面底部命令区,均不计入稳定内容块。
### 13.5 五阶段处理进度 DTO
`GET /api/reimbursements/:id` 新增只读 `processingProgress`:
```text
processingProgress.stages[] = {
key,
label,
state, // completed | current | pending | exception
actorName?,
occurredAt?,
result?,
comment?,
detail?,
targetHref?
}
```
- 服务端固定生成“申请提交”以及“付款批次复核、付款与回单”首尾阶段;中间审批段先按查询的 `created_at,id` 顺序逐项生成真实 task,每个 task 拥有独立 stage key,不再用职责 Map 合并;再从提交时 `workflow_steps_snapshot` 的当前节点之后补齐尚未创建 task 的未来 pending 节点。当前两步审批规则从刚提交起就返回五个阶段,新增或重复角色节点会自然增加时间轴节点,不得丢失、覆盖或因延迟建任务而暂时消失。
- 审批标题优先使用非空且不属于“步骤/节点/step + 序号”的 `step_name`;否则把 `manager` 映射为“主管审批”、`finance|finance_mgr` 映射为“财务复核”、`ceo` 映射为“负责人复核”,其余统一为“审批”。标题和详情均不暴露原始 step ID、角色码或序号。
- workflow 实例已创建但 `workflow_tasks` 暂为空时,中间审批段按提交快照生成无姓名 pending 节点;workflow 整体不存在时才回退为“主管审批、财务复核”默认占位,避免申请刚提交时出现空白进度。
- workflow task 提供实际 assignee/completer/outcome,history 提供动作、意见和时间。已完成取实际 completer/actor,当前取真实 pending assignee;未来 task 尚未创建时 `actorName=null`,严禁按当前角色持有人猜姓名。
- 付款汇总读取与当前报销关联的最新 `PaymentBatchItem` 及其 batch,只选择状态、制单/复核/执行人员与时间和未归档回单计数,不返回批次其他报销、金额或材料。只有具备现有付款批次读取权限时才返回可跳转的 batch href。
- 没有批次时付款复核 detail 为“待财务创建付款批次”;draft 为“所在付款批次待非制单人复核”;reviewed 为“所在付款批次已复核,待执行付款”;failed/cancelled 如实表达失败或待重新创建。不得写成“您的报销待复核”。
- `paid` 后最后阶段完成;回单计数为 0 显示“付款已完成,回单待留存”,大于 0 显示“付款与回单已留存”。回单是最后阶段次级状态,不增加第六块。
- workflow、batch、receipt 三个来源分别 try/normalize;合法缺失不是错误,缺失来源只让对应及下游阶段保持 pending。数据库或映射异常仍按现有 5xx 处理并记录服务端错误,不用伪造“已完成”。
- PC 与移动复用一个时间轴组件。已完成无意见节点压成单行;当前、exception、returned/rejected 或有 comment 的节点展开。语义状态同时使用图标/文字/线条,不只依赖颜色。
### 13.6 验证与回退
- 后端覆盖迁移、PATCH/GET/submit 字段合同、日期倒置、非差旅报销不误拦截、关联差旅申请摘要与旧单降级。
- 前端覆盖 PC/移动概要顺序、草稿编辑、审批中只读、按钮归位、加载反馈、JPEG 类型、企微有序 URL 和 375px/横屏。
- 后端补充进度 DTO 的 pending/approved/paid、退回/驳回、batch 五种状态、回单有无、三个来源独立缺失、未来节点无姓名和付款链接权限测试;另覆盖第三个未知职责节点、两个同角色节点互不覆盖、未来快照节点尚无 task、实例存在但 tasks 为空四条边界。
- 前端覆盖 PC/移动四块顺序、单头差旅填写/只读、材料列表与合并入口、变长审批节点紧凑时间轴、加载反馈、企微原件预览和 375px/横屏;组件只按数组 `map` 渲染,不按固定长度或位置解释职责。
- 若 JPEG/并发修复未通过目标镜像与合成火车票验收,回滚应用代码到现有 PNG 串行路径,保留原件降级;不删生产数据或回滚整库。
@@ -207,6 +207,44 @@
- [x] 复用既有 unit 内的受控合同参数重装 automatic backup 三项与 WARCH3;4 个 service 构建身份均匹配 `da35591`,4 个 timer 均 enabled/active。手工触发 automatic backup daily 和源端 watchdog,结果均为 `success/0`。
- [x] 本次发布未修改真实报销状态、审批任务、权限、付款批次、银行回单或业务材料;企微真机合并 PDF 体验和真实报销后半链继续由对应真人验收。
## 6.15 首笔历史差旅概要受控补录
- [x] 写入前只读锁定唯一目标:当前真实报销仍为审批中、当前审批任务尚未处理、四个差旅概要字段均为空;5 条 travel 明细中只有 1 条出发火车票日期早于申请人确认的实际行程,且正确日期已由业务方明确确认。
- [x] 以单一 Serializable 数据库事务补录用户确认的事由、地点和起止日期,并只把上述唯一错误火车票明细日期改为确认后的实际乘车日期;未改金额、费用类型、说明、票据、附件、审批状态、工作流实例或待办。
- [x] 同事务新增 1 条管理员代录审计,只记录“字段于提交后上线、原提交未采集、业务方确认后代录”的原因、被修正字段名和受控 task 标识;未把真实事由、地点、日期、票据或原始请求写入 task 证据或审计 extra。
- [x] 写后只读断言通过:差旅概要四项完整、5 条 travel 明细日期全部位于确认区间、金额与 3 张发票和 3 个附件保持不变、工作流与当前待办仍为原 pending 状态、目标审计恰好新增 1 条;事务结果为 `applied`,未触发回滚。
- [x] 用户刷新当前真实报销后确认页面日期显示正确;该结果只证明日期展示,不代替差旅概要其他字段、材料列表或真实审批结果。
- [ ] 补录不代表审批通过;马建勇仍须按业务事实完成主管审批,后续财务、付款批次、银行回单和最终复核继续遵守既有流程。
## 6.16 四块式详情与五阶段处理进度
- [x] PC 与移动端移除只读态材料列表的权限/失败条件,所有有权查看当前报销的人始终看到发票与补充附件列表;合并预览保留在列表上方。
- [x] 单份材料继续使用现有整行按钮和单份预览路径;只读审批人没有移除、删除或管理动作,编辑态既有动作保持不变。
- [x] PC/移动详情重排为“单头概况 → 费用明细 → 报销材料 → 处理进度”四块;差旅概要并入单头,关联/风险/付款/操作按数据与权限就近显示,不增加 Tab 或折叠。
- [x] 共享差旅概要组件在“草稿/驳回可编辑且字段不完整”时直接进入四字段填写态;完整概要保持单头内摘要 + 编辑,不新增报销类型或字段合同。
- [x] 后端详情新增五阶段 `processingProgress`,从 latest workflow snapshot/tasks/history、关联 PaymentBatchItem/batch、报销付款字段和回单计数生成;不改 schema、workflow 或 PaymentBatch 状态机。
- [x] 固化未来节点无姓名、共享批次措辞、maker-checker 各状态、回单次级状态、付款链接权限和三源独立缺失降级;返回中不包含批次其他单据、金额或材料。
- [x] PC/移动复用紧凑纵向时间轴:已完成无意见节点单行,当前/异常/有意见节点展开;不显示“第 N 步”、内部 step ID、角色码或权限码。
- [x] 更新后端聚焦测试与 PC/移动 Playwright,覆盖五阶段、退回/驳回、batch 状态、回单、来源缺失、未来无姓名、材料单份/合并、编辑权限、四块顺序、44px 触控和横屏无溢出。
- [x] 运行 Prisma validate、touched-file `node --check`、server 聚焦/必要全量、client typecheck/build、聚焦 Playwright、task/Trellis/process/docs/sensitive/diff 门禁;只读 review 跨 workflow/付款状态映射与权限,不让 reviewer 改代码。
- [ ] 实现完成后分别申请提交、推送和生产部署批准;部署与公网检查通过前不通知真人测试,不执行真实审批、付款或回单写入。
## 6.17 审批节点数据驱动修正
- [x] 移除 `classifyApprovalTask` + 阶段键 Map 覆盖路径;固定首尾业务阶段,中间按 `workflow_tasks` 的真实稳定顺序逐任务生成唯一节点,并按提交快照补齐尚未创建 task 的未来审批节点。
- [x] 节点标题按“可读 step name → assignee role 职责映射 → 审批”降级,过滤“步骤/节点/step + 序号”,不显示 step ID、角色码或序号。
- [x] workflow 整体不存在时保留主管/财务 pending 占位,实例 tasks 暂为空时按快照展示;前端 stage key 改为可扩展字符串并继续纯 `map` 渲染,不增加固定位置判断。
- [x] 新增第三个未知职责节点、两个同角色节点、未来快照尚无 task、空 task 实例单元测试,并复跑 `reimbursement-workflow-progress.spec.ts` 及既有四块/企微聚焦回归。
- [x] 重新运行后端聚焦与全量、Prisma、前端 typecheck/build、Trellis/process/docs/sensitive/diff 门禁;更新稳定审批投影规约后申请提交,随后按本轮授权推送、部署和公网检查。
验证记录:
- 服务端聚焦 16 项通过;最终全量 171 个文件、1129 项通过,Prisma validate 与 touched route `node --check` 通过。
- 前端 typecheck/生产 build 通过;Wave3 + R111 聚焦 2/2 通过,Wave3 同时覆盖 PC/移动 6 节点变长时间轴。
- `reimbursement-workflow-progress.spec.ts` 首轮在临时库首次改密页停止;隔离处理种子账号后,依次暴露手工服务未启 jobs、共享 Page Object 仍等待已不展示的文件名、盲取差旅费用类型却未填概要、旧断言硬编码主管/财务五阶段四个既有测试漂移。逐项收紧测试编排与断言后,最终真实临时 PostgreSQL + server + Mock OCR 路径 1/1 通过;应用认证、OCR、差旅和 workflow 生产合同未为测试放宽。
- workflow 实际只创建当前 task 的核查促使 DTO 继续用提交快照补齐未来 pending 审批节点;单元测试覆盖第三节点、同角色双节点、未来 snapshot-only 节点和空 tasks 实例。
- 本轮唯一明确命名的临时测试库已关闭服务并删除;未读取或写入生产业务数据,未执行真实审批、付款或回单。
## 7. 完成条件
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
@@ -288,3 +326,9 @@
- 数据合同验证:Prisma schema 通过;63 个 migration 在临时空 PostgreSQL 16 数据库完整 apply;新迁移只增加四个 nullable 差旅概要列,未回填或改写真实审批中数据。
- 最终质量:server 全量 171 个文件、1114 项通过;client typecheck/生产 build 通过;task、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过。文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。
- 发布后运维对齐:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `8a9279e`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。
- 四块式详情实现:PC/移动均收敛为“单头概况 → 报销明细 → 报销材料 → 处理进度”;差旅概要、关联事项和真实风险并入单头,材料列表对只读审批人常显,单份与合并预览沿用既有权限和审计路径。
- 五阶段只读投影:详情 API 固定返回申请提交、主管审批、财务复核、付款批次复核、付款与回单;付款批次未创建时可明确显示“待财务创建付款批次”,未来处理人不预填,普通申请人不获得批次跳转或批次其他业务信息。
- 五阶段验证:后端最新聚焦 12 项覆盖草稿、审批中、退回/驳回、批次 draft/reviewed/executed/failed/cancelled、回单有无、无批次历史付款和批次链接权限;服务端全量基线 171 个文件、1123 项通过。Prisma validate 与 touched route `node --check` 通过。
- 双端验证:client typecheck/生产 build 通过;R111 PC/企微 2/2、Wave3 只读详情 1/1 及移动视觉聚焦 2/2 通过,覆盖材料常显、只读无删除、四块顺序、五阶段无“第 N 步”、44px 触控和无横向溢出。视觉复核确认 PC 主内容不再因空侧栏过窄。
- 只读 review:按 workflow task、PaymentBatchItem/batch、回单计数三源逐项复核映射与权限;修正“workflow 已通过但尚未建批次”原本无当前节点的问题,使其明确落在“待财务创建付款批次”。确认未来 checker 不预填、普通申请人无批次链接、共享批次不暴露其他单据/金额/材料;review 未修改状态机、权限矩阵或生产数据。
- 最终治理:task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。本步骤未部署、未推送、未修改生产配置、真实报销状态、付款批次、回单或业务数据。
@@ -38,7 +38,7 @@
- 当前小团队取消报销金额分层,所有金额统一按“直属主管 → finance”审批;不得保留无人持有的 `finance_mgr` 节点,也不得因马建勇同时是主管和 CEO 而让同一人重复审批。
- 真实首笔报销暴露审批可读性阻断:待办列表卡片能够进入审批任务详情,但任务详情只显示单号、申请人和金额摘要,没有通往费用明细、发票和附件的业务详情入口。移动端必须在审批任务详情区提供明确的“查看完整报销单”入口,复用既有 `/mobile/reimbursements/:id` 路由和 `state.from` 回跳约定;PC 审批任务详情同步提供报销单入口。
- 报销详情当前把后端 ISO UTC 时间直接截取为字符串,附件和审批记录会在中国时区显示早 8 小时;报销链路的日期时间必须复用共享格式化能力并固定按 `Asia/Shanghai` 展示,不修改数据库时间或 API ISO 合同。
- PC 与移动报销详情应按 KISS 原则收敛为“概况 → 明细 → 发票 → 附件 → 审批记录”的业务主线:删除重复状态、重复标题和面向只读用户的操作教学;没有实际内容或动作的辅助区块不渲染,同时保留编辑态必要控件、风险提示和审计信息。
- PC 与移动报销详情应按 KISS 原则收敛为四个稳定内容块:“单头概况 → 费用明细 → 报销材料 → 处理进度”。差旅事由、地点和起止日期并入单头,不保留独立差旅卡;项目/差旅申请关联、真实风险和付款事实仅在存在时作为相邻行或节点次级信息显示。页面纯纵向滚动,不增加 Tab、折叠或抽屉,同时保留编辑态必要控件、风险提示和审计信息。
- 生产真机验收确认企微 WebView 可继续运行部署前的旧前端包:公网资源已包含“查看完整报销单”,但存活页面仍只显示报销日期。页面从后台恢复到前台时必须用现有前后端构建标识做一次轻量比对;不一致时只显示用户可控的“发现新版本,点击刷新”,点击后清理旧 PWA 缓存并重载,不自动打断当前页面操作,不扩展为完整更新管理系统。
- 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
@@ -50,6 +50,10 @@
- 生产只读计时确认当前图片包串行处理约 1.8 秒,且火车票栅格图在企微中视觉上近似空白。新主路径不得继续现场栅格化 PDF;合并由 Node `worker_threads` 执行并设置材料数、源文件字节、总页数、输出大小和超时上限,避免阻塞单一 server 的事件循环。不启用 Compose worker profile,不引入 PDF.js。
- 合并产物必须可重复生成:材料查询使用显式排序键,缓存键包含报销单 ID、有序材料集合哈希与合并器版本;PDF 元数据和保存选项固定。同样输入独立生成两次必须字节完全相同。签发返回的 `fileSize` 与实际 GET 返回的缓存字节严格一致;缓存缺失或进程重启不能临时拼出另一份字节冒充原签发产物,只能拒绝并让客户端重新签发或降级原件。
- 当前真实报销为“空白报销”创建且未关联差旅申请,现有数据模型没有独立的出差事由、地点和起止日期,因此详情页无法显示完整差旅概要。轻量差旅报销必须直接持久化这四项可选业务字段;存在任一差旅费用明细时,草稿/驳回态提交前必须补齐。既有审批中单据不自动伪造或回填地点;可以只读显示报销说明和明细日期的明确降级值,需完整补齐时由主管退回后申请人修改。
- KISS 复核确认只读审批人仍需要知道本单有哪些原始材料。PC 与移动端“报销材料”区必须始终显示有序材料列表,整行点击只预览该份原件;“查看材料合并预览”保留为列表上方的一次通览动作。只读审批人不得看到移除、删除或票据池管理动作,费用明细仍不重复展示发票。
- 草稿/驳回报销出现差旅类明细且四项差旅概要不完整时,PC 与移动端应直接显示带标签的填写表单,不先展示四个“未填写”再要求用户点击编辑;概要完整后恢复紧凑只读摘要并保留编辑入口。不新增“普通/差旅”选择,不改变现有字段、差旅判定、提交校验或差旅申请关联规则。
- “处理进度”必须使用移动端友好的纵向时间轴。申请提交、付款批次复核、付款与回单是固定业务阶段;中间审批段按当前 workflow 实例的真实任务顺序逐项展开。当前生产规则仍自然形成“申请提交、主管审批、财务复核、付款批次复核、付款与回单”五阶段,但未来新增或重复角色审批节点时不得丢失、覆盖或错误提前显示付款。节点名使用职责而非序号;已完成节点显示实际处理人/时间/结果,当前节点显示实际待办人(可取得时),未来节点不得预填姓名。退回、驳回和有内容的意见必须展开,普通已完成节点压成一行。
- 处理进度是跨状态机只读投影,不把付款批次塞入 workflow。服务端从 workflow 快照/任务/历史、报销付款字段、当前关联付款批次和回单计数生成安全 DTO;已发生/当前审批逐条使用真实 task,尚未创建的未来审批按提交快照补 pending 占位,workflow 实例存在但任务尚未落库时也按快照展示,不因延迟建任务而出现空白;仅在 workflow 整体缺失时使用主管/财务默认占位。任一来源缺失时仅把对应下游节点降级为“待处理”,前面已完成节点继续返回。审批节点标题按“非序号化 step name → assignee role/快照规则职责映射 → 审批”降级,永远不向界面显示原始 step ID、角色码或“步骤 N”。付款批次为共享对象,节点文案必须写“所在付款批次…”,不能暗示单张报销自身再次被审批。
## Scope
@@ -93,6 +97,12 @@
- [x] 合并在 `worker_threads` 中受限执行;同样输入生成两次字节完全相同,签发 `fileSize` 等于 GET 实际字节,缓存丢失不返回漂移产物。
- [x] 用无业务数据的多页 PDF 与 JPG/PNG/WebP 合成样本验证页数、顺序、25 页边界和企微一次 `previewFile`;加密、损坏或不可复制 PDF 必须整包失败并回退逐份原件。
- [x] 存在差旅费用明细时,PC/移动详情首部均显示“出差事由、出差地点、起止日期”的轻量概要;草稿/驳回态可编辑且提交校验完整,审批中历史单不伪造缺失字段。
- [x] PC 与移动端只读报销详情始终显示有序材料列表;列表上方保留合并预览,点击单行仅预览对应原件,审批人没有编辑/解绑/删除动作。
- [x] 草稿/驳回差旅报销的概要不完整时直接进入四字段填写态;完整后显示紧凑摘要与编辑入口,普通报销不显示差旅字段。
- [x] PC 与移动报销详情只保留四个稳定内容块;差旅概要并入单头,关联、风险、付款和操作均按数据/权限条件呈现,不形成永久空版块,不增加 Tab 或折叠。
- [x] “处理进度”按五阶段纵向显示,不出现“第 N 步”;当前责任、实际处理人、时间、结果及必要意见清楚,未来节点无姓名,付款批次共享语义准确。
- [x] workflow、付款批次或回单任一来源缺失时进度 DTO 和页面均安全降级,已知节点不丢失;批次 draft/reviewed/executed/failed/cancelled 与回单有/无均有合成回归。
- [x] workflow 中第三个未知职责节点和多个同角色节点均按真实顺序完整显示且互不覆盖;实例存在但任务为空时审批段仍显示安全占位,前端不假设固定节点数。
## Out of Scope
@@ -117,3 +127,7 @@
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
- 用合成普通增值税发票、数电发票、电子火车票、多页 PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;合并 PDF 包含全部材料并保持顺序;异常/加密 PDF、超限和合并失败可安全降级;编辑态管理动作不回归。
- 用合成申请人、审批人和管理员权限覆盖 PC/移动材料区:列表始终可见且顺序稳定,语义化整行按钮可单份预览,合并预览仍只调用一次,只有可编辑参与人看到移除/删除动作。
- 用合成普通与差旅草稿覆盖 PC/移动概要:无差旅明细时不显示;差旅概要不完整时直接显示四个有标签输入框;保存中、成功和失败有反馈;完整后回到摘要态。
- 后端用合成 workflow task/history、付款批次 item 和回单计数覆盖五阶段只读 DTO:当前/完成/未来节点、退回/驳回意见、maker-checker、共享批次措辞、每个来源单独缺失和权限安全摘要;不写生产业务数据。
- 前端用同一 DTO 覆盖 PC/企微纵向时间轴:已完成节点单行、当前/异常节点展开、无“第 N 步”、未来无姓名、付款批次共享措辞和四块式纯滚动布局。
@@ -0,0 +1,94 @@
# 国内报销详情页 KISS 调研
## 1. 调研问题
面向 10 人以下国内科技公司,报销详情页是否需要把概况、差旅概要、费用明细、材料、关联事项、风险、审批、付款和操作拆成多个常驻区域;如果不需要,最小但完整的信息结构是什么。
## 2. 公开资料结论
- 合思公开产品资料覆盖申请、消费、报销、对账、记账、归档、报表,并强调票据识别、费用可追踪和审核;这些是全链路能力域,不等于单据详情页需要常驻同等数量的版块。
- 汇联易公开产品资料强调费用管理、合规规则、发票管理、智能审核、会计与流程能力;其复杂预算、风险和核算能力面向中大型企业,不应直接成为本项目小团队详情页的默认结构。
- 每刻报销公开产品资料强调出差申请、费用、发票、审核、支付和归档,并明确其相对传统 OA 的优势来自预算、智能审核、ERP 和银企集成;本项目当前没有这些真实配置或集成,不能为了“像费控系统”而展示空区域。
- 人人都是产品经理的费控设计案例把差旅移动流程概括为事项申请、发票采集、场景报销、领导审批和财务审核,并指出不同费用类型应使用动态单据模板和附件清单。可复用原则是“按业务事实动态展示”,不是固定堆叠所有能力域。
## 3. 项目现状
- 移动报销详情当前至少有:报销概况、独立差旅概要、报销明细、报销材料、审批、操作。
- PC 详情除上述主线外,还可能出现关联事项、预算与审核提示、付款状态和更多操作;多数已按数据条件隐藏,但差旅概要仍形成独立卡片。
- 当前公司费用标准和预算没有形成真实常用数据;风险区没有内容时本来就不显示。审批动作在审批任务页,普通业务详情不应复制审批动作。
- 当前真实单据规模小,适合单页纵向滚动;Tab、折叠和二级抽屉只会增加企微 WebView 的状态与返回验证成本。
## 4. 推荐结构
只保留四个稳定内容块,PC 与移动端信息顺序一致:
1. **单头概况**:单号、状态、申请人、部门、总额、说明;差旅单把事由、地点和起止日期并入同一块,普通报销自然不显示差旅行。
2. **费用明细**:费用类型、发生日期、金额和说明;编辑态在这里保留新增、编辑和删除。
3. **报销材料**:计数与合并预览在上,下面始终显示可单份打开的发票/补充附件列表;只读审批人没有移除或删除动作。
4. **审批与处理记录**:提交、当前节点和历史。付款事实仅在实际存在时作为本块内的处理结果显示,不新建永久空版块。
状态性内容不形成新的常驻区域:
- 真实风险或校验异常以内联提示放到单头或对应明细附近;无风险不显示。
- 项目或差旅申请关联作为单头的一行链接;无关联不显示。
- 草稿/驳回操作继续使用页面底部命令区或移动固定操作条,不算内容块。
- 打印、返回列表等低频动作收进“更多”,不占主阅读流。
## 5. 明确不做
- 不做 Tab、折叠、底部抽屉或左右分页。
- 不保留独立“差旅概要”卡片。
- 不新增常驻预算、合规、风险、财务或付款版块。
- 不把审批按钮搬进报销业务详情,不改变审批任务返回链路。
- 不新增普通/差旅类型,不改变现有数据字段、权限、状态机或 API。
## 6. 来源
- 合思官网,合思费控与全链路产品说明:<https://www.ekuaibao.com/>(访问:2026-08-01)。
- 汇联易官网,全面费用管理产品功能:<https://www.huilianyi.com/products/helios>(访问:2026-08-01)。
- 每刻报销官网,企业差旅及费用管理平台:<https://www.maycur.com/mkbx>(访问:2026-08-01)。
- 人人都是产品经理,《费控报销系统,如何设计?》:<https://www.woshipm.com/pd/5292016.html>(访问:2026-08-01)。
以上公开资料只用于提炼信息与流程共性,不把供应商宣传中的预算、AI、ERP、银企直连或大型集团能力视为本项目需求。
## 7. 审批与付款进度补充调研
### 7.1 国内产品共性
- 企业微信官方“获取审批申请详情”数据合同把审批流程表达为多个审批节点;节点含审批人、节点/审批人状态、操作时间和审批意见,备注另行记录。可复用原则是按节点展示职责、处理人、状态、时间和必要意见,而不是显示“节点 1 / 节点 2”。
- 飞书、钉钉等国内审批产品同样把实例详情、任务/节点和操作记录分开建模。公开资料没有要求统一视觉,但移动端纵向时间轴符合用户既有审批心智,也适合本项目企业微信 WebView。
- 国内费控产品通常把费用审批与支付、回单、归档串成端到端业务链;实现上审批 workflow 与付款对象仍是不同状态机。详情页可以给用户一条连续进度,但不能把付款批次伪造成 workflow 节点。
企业微信官方资料:<https://developer.work.weixin.qq.com/document/path/91983>(访问:2026-08-01)。
### 7.2 本项目真实状态机
- 报销 workflow 快照当前固定为“主管审批 → 财务复核”,最终把报销状态置为 `approved`。
- 付款另由 `PaymentBatch` 承担:财务创建批次(maker)→ 非制单人复核批次(checker)→ 执行付款;执行后报销进入 `paid`,银行回单可随后留存。
- 当前 PC `workflowProgress` 显示“第 N 步”,且只在 workflow pending 时返回;移动端只显示当前节点和历史。报销详情没有付款批次安全摘要,因此还不能诚实展示付款复核、执行和回单状态。
### 7.3 推荐的端到端纵向进度
第四个稳定内容块命名为“处理进度”,使用一条纵向时间轴,按职责显示五个业务阶段:
1. 申请提交
2. 主管审批
3. 财务复核
4. 付款批次复核
5. 付款与回单
显示规则:
- 节点名称使用业务职责,不显示“第 1 步 / 第 2 步”。
- 已处理节点显示实际处理人、时间和结果;当前节点显示实际待办处理人(可取得时)和“处理中”;未来节点只显示职责与“待处理”,不根据当前角色持有人猜姓名。
- 审批意见、退回/驳回原因仅在有内容时显示;退回/驳回原因不得隐藏。
- 付款批次未创建时,付款复核节点显示“待财务创建付款批次”;批次 draft 显示“待非制单人复核”;reviewed 显示“复核通过,待执行付款”;failed/cancelled 如实显示异常或重新处理。
- 报销进入 `paid` 后,最后节点显示付款时间/处理人;回单状态作为该节点的次级状态,不额外增加第六块内容。
- 前端时间轴消费统一的只读进度 DTO;服务端分别读取 workflow 快照/历史、当前 task、报销付款字段和当前关联付款批次摘要,再做安全映射。不得修改 workflow 或 PaymentBatch 状态机。
### 7.4 KISS 边界
- 不画 BPMN、分支图或横向步骤条。
- 不展示内部 step ID、角色码、权限码或状态机枚举。
- 不在报销详情复制审批、付款或回单操作;需要处理时仍跳到当前审批任务或付款批次详情。
- 不硬编码自然人姓名;历史用实际 actor,当前用实际 assignee,未来只写职责。
+17
查看文件
@@ -112,6 +112,22 @@ export type ReimbursementWorkflowProgress = {
}>;
};
export type ReimbursementProcessingStage = {
key: string;
label: string;
state: "completed" | "current" | "pending" | "exception";
actorName: string | null;
occurredAt: string | null;
result: string | null;
comment: string | null;
detail: string | null;
targetHref: string | null;
};
export type ReimbursementProcessingProgress = {
stages: ReimbursementProcessingStage[];
};
export type ReimbursementApprovalHistoryItem = {
id: string;
stepName: string | null;
@@ -215,6 +231,7 @@ export type ReimbursementDetail = {
} | null;
} | null;
workflowProgress: ReimbursementWorkflowProgress | null;
processingProgress: ReimbursementProcessingProgress;
approvalHistory: ReimbursementApprovalHistoryItem[];
riskFlags: RiskFlag[];
items: ReimbursementItem[];
@@ -0,0 +1,82 @@
import { AlertCircle, Check, Circle, Clock, ExternalLink } from "lucide-react";
import { Link } from "react-router-dom";
import type { ReimbursementProcessingProgress as ProcessingProgress } from "../../api/reimbursements";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import { cn } from "../../lib/utils";
type Props = {
progress: ProcessingProgress;
mobile?: boolean;
};
const STATE_TEXT = {
completed: "已完成",
current: "处理中",
pending: "待处理",
exception: "需处理",
} as const;
export function ReimbursementProcessingProgress({ progress, mobile = false }: Props) {
return (
<ol
className="space-y-0"
data-testid={mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress"}
aria-label="报销处理进度"
>
{progress.stages.map((stage, index) => {
const expanded = stage.state === "current" || stage.state === "exception" || Boolean(stage.comment);
const Icon = stage.state === "completed"
? Check
: stage.state === "current"
? Clock
: stage.state === "exception"
? AlertCircle
: Circle;
const facts = [stage.actorName, stage.occurredAt ? formatDisplayDateTime(stage.occurredAt) : null, stage.result]
.filter(Boolean);
return (
<li className="relative flex gap-3" data-state={stage.state} key={stage.key}>
{index < progress.stages.length - 1 ? (
<span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-zinc-200" aria-hidden="true" />
) : null}
<span
className={cn(
"relative z-10 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full border bg-white",
stage.state === "completed" && "border-emerald-300 text-emerald-700",
stage.state === "current" && "border-blue-300 bg-blue-50 text-blue-700",
stage.state === "exception" && "border-red-300 bg-red-50 text-red-700",
stage.state === "pending" && "border-zinc-300 text-zinc-400",
)}
aria-hidden="true"
>
<Icon className="size-4" />
</span>
<div className={cn("min-w-0 flex-1 pb-5", mobile ? "pt-1" : "pt-1.5")}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="font-semibold text-zinc-900">{stage.label}</span>
{facts.length ? <span className="text-zinc-500">· {facts.join(" · ")}</span> : null}
{!stage.result ? <span className="text-xs text-zinc-500">· {STATE_TEXT[stage.state]}</span> : null}
</div>
{expanded && stage.detail ? <p className="mt-1 text-sm text-zinc-600">{stage.detail}</p> : null}
{stage.comment ? (
<blockquote className="mt-2 rounded-md border-l-2 border-zinc-300 bg-zinc-50 px-3 py-2 text-sm leading-5 text-zinc-700">
{stage.comment}
</blockquote>
) : null}
{stage.targetHref ? (
<Link
className="mt-2 inline-flex min-h-11 items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
to={stage.targetHref}
>
查看所在付款批次
<ExternalLink aria-hidden="true" className="size-4" />
</Link>
) : null}
</div>
</li>
);
})}
</ol>
);
}
@@ -7,10 +7,10 @@ import { isTravelReimbursement } from "../../lib/reimbursementTravel";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean };
type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean; embedded?: boolean };
const emptyForm: ReimbursementTravelSummaryPayload = { travelPurpose: null, travelLocation: null, travelStartDate: null, travelEndDate: null };
export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false }: Props) {
export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false, embedded = false }: Props) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(emptyForm);
@@ -22,7 +22,10 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
travelStartDate: detail.travelSummary?.startDate ?? null,
travelEndDate: detail.travelSummary?.endDate ?? null,
});
}, [detail.travelSummary]);
if (editable && !detail.travelSummary?.purpose?.trim()) setEditing(true);
else if (editable && !detail.travelSummary?.location?.trim()) setEditing(true);
else if (editable && (!detail.travelSummary?.startDate || !detail.travelSummary?.endDate)) setEditing(true);
}, [detail.travelSummary, editable]);
const mutation = useMutation({
mutationFn: () => updateReimbursementTravelSummary(reimbursementId, form),
onSuccess: async () => {
@@ -34,10 +37,11 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
});
if (!isTravel) return null;
const summary = detail.travelSummary ?? { purpose: null, location: null, startDate: null, endDate: null };
const Container = embedded ? "div" : "section";
return (
<section className={mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
<Container className={embedded ? "mt-4 border-t border-zinc-200 pt-4" : mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">差旅概要</h2>
<h3 className="text-sm font-semibold text-zinc-900">出差信息</h3>
{editable && !editing ? <Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>编辑</Button> : null}
</div>
{editing ? (
@@ -61,6 +65,6 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
<div><dt className="text-xs text-zinc-500">结束日期</dt><dd className="mt-1 text-zinc-900">{summary.endDate || "未填写"}</dd></div>
</dl>
)}
</section>
</Container>
);
}
@@ -40,9 +40,11 @@ import { AttachmentGovernanceBadges } from "../components/common/AttachmentGover
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
@@ -132,20 +134,6 @@ function formatDate(value: string | null | undefined) {
return value || "未填写";
}
function workflowActionLabel(action: string) {
const labels: Record<string, string> = {
start: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
remind: "催办",
overdue_remind: "超时提醒",
};
return labels[action] ?? action;
}
function matchInvoiceSearch(
invoice: {
invoiceCode?: string | null;
@@ -933,17 +921,40 @@ export function MobileReimbursementDetailPage() {
<div className="text-xs text-zinc-500">报销人</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.owner?.name ?? "-"}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">部门</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.department?.name ?? "-"}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">申请人</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.submitter?.name ?? "-"}</div>
</div>
</div>
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEdit && isParticipant)}
mobile
embedded
/>
{detail.project || detail.linkedTravelRequest ? (
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm text-zinc-600">
{detail.project ? <p>项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
{detail.linkedTravelRequest ? (
<Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}>
差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}
</Link>
) : null}
</div>
) : null}
{detail.riskFlags.length ? (
<div className="mt-4 border-t border-zinc-200 pt-4">
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
</div>
) : null}
</section>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEdit && isParticipant)}
mobile
/>
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -1164,7 +1175,6 @@ export function MobileReimbursementDetailPage() {
) : null}
{detail.invoices.length || (detail.attachments ?? []).length ? (
canOpenInvoiceDialog || canManageAttachments || Boolean(materialPreviewError) ? (
<div className="mt-3 space-y-2">
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
{detail.invoices.map((invoice) => {
@@ -1247,7 +1257,6 @@ export function MobileReimbursementDetailPage() {
))}
</div>
</div>
) : null
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无报销材料
@@ -1255,14 +1264,10 @@ export function MobileReimbursementDetailPage() {
)}
</section>
{detail.status === "reviewing" || detail.approvalHistory.length ? (
<section className="mobile-card" data-testid="mobile-reimbursement-approval-history">
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">审批</h2>
<p className="mt-1 text-xs text-zinc-500">
当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
</p>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
@@ -1276,27 +1281,10 @@ export function MobileReimbursementDetailPage() {
</Button>
) : null}
</div>
{detail.approvalHistory.length ? (
<div className="mt-3 space-y-2">
{detail.approvalHistory.map((item) => (
<article key={item.id} className="mobile-list-card">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-900">{workflowActionLabel(item.action)}</div>
<div className="shrink-0 text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.comment}</p> : null}
</article>
))}
</div>
) : (
<p className="mt-3 text-sm text-zinc-500">暂无审批记录。</p>
)}
<div className="mt-3">
<ReimbursementProcessingProgress progress={detail.processingProgress ?? { stages: [] }} mobile />
</div>
</section>
) : null}
{showSecondaryActions ? (
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
+65 -284
查看文件
@@ -47,6 +47,7 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import {
@@ -58,7 +59,6 @@ import {
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
@@ -98,42 +98,6 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
return fallbackMessage;
}
function workflowActionLabel(action: string) {
const labels: Record<string, string> = {
start: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
remind: "催办",
overdue_remind: "超时提醒",
};
return labels[action] ?? action;
}
function budgetControlModeLabel(controlMode: string | null | undefined) {
const labels: Record<string, string> = {
block: "超额阻断",
warn: "超额提醒",
manual_review: "人工复核",
};
return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理";
}
function materialCompletenessLabel(count: number, noun: string) {
return count > 0 ? `${count} ${noun}` : `缺${noun}`;
}
function paymentChannelLabel(value: string | null | undefined) {
if (value === "bank") return "银行转账";
if (value === "alipay") return "支付宝";
if (value === "wechat") return "微信";
if (value === "cash") return "现金";
if (value === "other") return "其他";
return value || "-";
}
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
@@ -792,16 +756,8 @@ export function ReimbursementDetailPage() {
}
const detail = detailQuery.data;
const payment = detail.payment ?? {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
};
const displayCode = resolveReimbursementDisplayCode(detail);
const budgetHint = detail.budgetHint;
const canPay = hasPermission(permissions, "expense.finance.pay");
const canEditItems = ["draft", "rejected"].includes(detail.status);
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null;
@@ -992,21 +948,13 @@ export function ReimbursementDetailPage() {
const itemCount = detail.items.length;
const invoiceCount = detail.invoices.length;
const hasBlockingPrecheck = blockStandardChecks.length > 0;
const currentHandlerText = (() => {
if (detail.status === "reviewing") {
return detail.pendingWorkflow?.task?.step_name ?? "当前审批节点";
}
if (detail.status === "approved") {
return canPay ? "财务处理" : "等待财务";
}
if (detail.status === "paid") {
return payment.paidBy?.name ?? "财务已处理";
}
if (["draft", "rejected"].includes(detail.status)) {
return detail.owner?.name ?? "报销人";
}
return "-";
})();
const processingProgress = detail.processingProgress ?? { stages: [] };
const currentProcessingStage = processingProgress.stages.find((stage) =>
stage.state === "current" || stage.state === "exception",
);
const currentHandlerText = currentProcessingStage
? `${currentProcessingStage.label}${currentProcessingStage.actorName ? ` · ${currentProcessingStage.actorName}` : ""}`
: detail.status === "paid" ? "已完成" : "待处理";
const nextStepText = (() => {
if (["draft", "rejected"].includes(detail.status)) {
if (!itemCount) {
@@ -1021,9 +969,7 @@ export function ReimbursementDetailPage() {
return "提交审批";
}
if (detail.status === "reviewing") {
return detail.workflowProgress
? `查看第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步`
: "查看审批进度";
return currentProcessingStage?.detail ?? "查看处理进度";
}
if (detail.status === "approved") {
return "等待财务处理";
@@ -1033,9 +979,7 @@ export function ReimbursementDetailPage() {
}
return "查看记录";
})();
const showRelatedRecords = canEditItems || Boolean(detail.project || detail.linkedTravelRequest);
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
const showApprovalRecords = Boolean(detail.workflowProgress || detail.approvalHistory.length);
const primaryAction = (() => {
if (["draft", "rejected"].includes(detail.status) && canEditItems) {
if (!itemCount) {
@@ -1085,7 +1029,7 @@ export function ReimbursementDetailPage() {
if (detail.status === "approved") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-payment-panel">看付款状态</a>
<a href="#reimbursement-processing-progress-panel">看处理进度</a>
</Button>
);
}
@@ -1093,7 +1037,7 @@ export function ReimbursementDetailPage() {
if (detail.status === "paid") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-payment-panel">查看付款结果</a>
<a href="#reimbursement-processing-progress-panel">查看付款结果</a>
</Button>
);
}
@@ -1155,7 +1099,7 @@ export function ReimbursementDetailPage() {
)}
/>
<div className="detail-workspace-grid">
<div className="mx-auto w-full max-w-5xl space-y-5">
<div className="space-y-5">
<SectionPanel title="报销概况">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
@@ -1175,9 +1119,9 @@ export function ReimbursementDetailPage() {
<div className="detail-fact-card__value">{currentHandlerText}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">材料</div>
<div className="detail-fact-card__label">部门</div>
<div className="detail-fact-card__value">
{materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")}
{detail.department?.name ?? "-"}
</div>
</div>
<div className="detail-fact-card">
@@ -1200,6 +1144,42 @@ export function ReimbursementDetailPage() {
{detail.description}
</div>
) : null}
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEditItems && isReimbursementParticipant)}
embedded
/>
{canEditItems || detail.project || detail.linkedTravelRequest ? (
<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">
<span className="font-medium text-zinc-900">关联事项</span>
{detail.linkedTravelRequest ? (
<Link to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`} className="text-primary hover:underline">
差旅申请 {detail.linkedTravelRequest.requestNo ?? "未编号"}
</Link>
) : null}
</div>
{canEditItems ? (
<select
className="select-std mt-3 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
aria-label="关联项目"
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>{item.projectNo} · {item.name}</option>
))}
</select>
) : detail.project ? (
<Link className="mt-2 inline-block text-primary hover:underline" to={`/projects/${detail.project.id}`}>
项目 {detail.project.projectNo} · {detail.project.name}
</Link>
) : null}
</div>
) : null}
</div>
{actionError ? (
@@ -1220,45 +1200,19 @@ export function ReimbursementDetailPage() {
请修改超标明细后提交。
</p>
) : null}
{showReviewHints ? (
<div className="space-y-3" data-testid="reimbursement-review-hints">
{budgetHint ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%
{budgetHint.exceedsThreshold ? ",已达到预警阈值。" : "。"}
</p>
) : null}
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
</div>
) : null}
</div>
{["approved", "paid"].includes(detail.status) ? (
<div id="reimbursement-payment-panel" className="detail-command-panel mt-4" data-testid="reimbursement-payment-status">
<div className="flex items-center justify-between gap-3">
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
detail.status === "paid"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}`}
>
{detail.status === "paid" ? "已付款" : "未付款"}
</span>
</div>
<div className="detail-fact-grid mt-3 xl:grid-cols-4">
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款日期</div>
<div className="detail-fact-card__value">{payment.paymentDate ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款凭证号</div>
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款渠道</div>
<div className="detail-fact-card__value">{paymentChannelLabel(payment.paymentChannel)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">操作人 / 时间</div>
<div className="detail-fact-card__value">
{payment.paidBy?.name ?? "-"} · {formatDisplayDateTime(payment.paidAt)}
</div>
</div>
</div>
</div>
) : null}
{showSecondaryDetail ? (
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
<div className="detail-command-panel">
@@ -1291,12 +1245,6 @@ export function ReimbursementDetailPage() {
) : null}
</SectionPanel>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEditItems && isReimbursementParticipant)}
/>
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
@@ -1489,7 +1437,6 @@ export function ReimbursementDetailPage() {
</p>
) : null}
{canOpenInvoiceBindDialog || materialPreviewError ? (
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
@@ -1529,7 +1476,6 @@ export function ReimbursementDetailPage() {
);
})}
</div>
) : null}
<div className="space-y-4" data-testid="reimbursement-attachments">
{canEditItems ? (
@@ -1575,7 +1521,7 @@ export function ReimbursementDetailPage() {
</div>
) : null}
{(detail.attachments ?? []).length && (canEditItems || Boolean(materialPreviewError)) ? (
{(detail.attachments ?? []).length ? (
<div className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
@@ -1617,179 +1563,14 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
{showRelatedRecords ? (
<SectionPanel title="关联事项">
<div className="space-y-4" data-testid="reimbursement-related-records">
<div className="detail-command-panel">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
</div>
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
查看项目
</Link>
) : null}
</div>
{canEditItems ? (
<select
className="select-std mt-4 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}
</option>
))}
</select>
) : (
<div className="mt-3 text-sm text-zinc-600">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}
</div>
)}
</div>
{detail.linkedTravelRequest ? (
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
</h4>
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
</div>
<Link
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
className="text-sm text-primary hover:underline"
>
查看申请
</Link>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">日期范围</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">预估金额</div>
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">申请编号</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
</div>
</div>
</div>
</div>
) : null}
<SectionPanel title="处理进度">
<div id="reimbursement-processing-progress-panel">
<ReimbursementProcessingProgress progress={processingProgress} />
</div>
</SectionPanel>
) : null}
{showReviewHints ? (
<SectionPanel title="预算与审核提示">
<div className="space-y-4">
{budgetHint ? (
<div className="detail-command-panel text-sm" data-testid="reimbursement-budget-hint">
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
<div className="mt-3">预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
<div>
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%)
</div>
<div>控制方式:{budgetControlModeLabel(budgetHint.controlMode)}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断。
</p>
) : budgetHint.exceedsThreshold ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
</p>
) : (
<p className="mt-3 text-zinc-500">当前预算充足,可继续提交。</p>
)}
</div>
) : null}
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
</div>
</SectionPanel>
) : null}
{showApprovalRecords ? (
<SectionPanel title="审批与处理记录">
<div id="reimbursement-workflow-progress-panel" className="space-y-4">
{detail.workflowProgress ? (
<div className="detail-command-panel">
<div className="mb-3 text-sm text-zinc-500">
当前位于第 {detail.workflowProgress.currentStepIndex + 1} / {detail.workflowProgress.totalSteps} 步。
</div>
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "detail-timeline-item detail-timeline-item--current"
: step.status === "completed"
? "detail-timeline-item detail-timeline-item--done"
: "detail-timeline-item"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
</div>
))}
</div>
</div>
) : null}
{detail.approvalHistory.length ? (
<div className="detail-command-panel">
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
<div className="detail-timeline mt-3" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
</div>
))}
</div>
</div>
) : null}
</div>
</SectionPanel>
) : null}
</div>
<div className="space-y-5">
{detail.status === "approved" && canPay ? (
<DetailSideMeta title="付款">
<Button asChild variant="secondary" size="sm">
<Link to="/payment-batches">进入付款批次</Link>
</Button>
</DetailSideMeta>
) : null}
<ReimbursementActions
status={detail.status}
showSubmitAction={false}
@@ -79,6 +79,8 @@ export class ReimbursementDetailPageObject {
}
async uploadInvoicePdf(fileName: string, buffer: Buffer) {
const invoiceCards = this.invoices.locator(":scope > div");
const invoiceCountBeforeUpload = await invoiceCards.count();
await this.invoiceAddBtn.click();
await expect(this.invoiceDialog).toBeVisible();
@@ -101,7 +103,7 @@ export class ReimbursementDetailPageObject {
throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`);
}
await expect(this.invoices).toContainText(fileName, { timeout: 90_000 });
await expect(invoiceCards).toHaveCount(invoiceCountBeforeUpload + 1, { timeout: 90_000 });
await this.page.keyboard.press("Escape");
await expect(this.invoiceDialog).toHaveCount(0);
@@ -149,6 +149,16 @@ const reimbursementDetail = {
{ id: "step-2", name: "财务复核", status: "pending" },
],
},
processingProgress: {
stages: [
{ key: "submitted", label: "申请提交", state: "completed", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "已提交", comment: null, detail: null, targetHref: null },
{ key: "manager_approval", label: "主管审批", state: "current", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "处理中", comment: null, detail: "待主管审批处理", targetHref: null },
{ key: "approval_extra-review", label: "负责人复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
{ key: "finance_review", label: "财务复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
{ key: "payment_batch_review", label: "付款批次复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务创建付款批次", targetHref: null },
{ key: "payment_and_receipt", label: "付款与回单", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款批次复核后处理", targetHref: null },
],
},
approvalHistory: [
{
id: "history-wave3-1",
@@ -1084,6 +1094,12 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款批次复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/);
await page.goto("/workflow/tasks/task-wave3");
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
@@ -1093,7 +1109,12 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("负责人复核");
await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款与回单");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
@@ -198,6 +198,15 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
processingProgress: {
stages: [
{ key: "submitted", label: "申请提交", state: "current", actorName: "测试员工", occurredAt: null, result: "待提交", comment: null, detail: "待申请人提交", targetHref: null },
{ key: "manager_approval", label: "主管审批", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
{ key: "finance_review", label: "财务复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
{ key: "payment_batch_review", label: "付款批次复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务创建付款批次", targetHref: null },
{ key: "payment_and_receipt", label: "付款与回单", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款批次复核后处理", targetHref: null },
],
},
approvalHistory: [],
riskFlags: [],
items: [
@@ -624,6 +633,14 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect(page.getByText("预算与审核提示")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
const pcSectionOrder = await page.locator("[data-testid='reimbursement-overview'], [data-testid='reimbursement-items'], [data-testid='reimbursement-materials'], [data-testid='reimbursement-workflow-progress']")
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(pcSectionOrder).toEqual([
"reimbursement-overview",
"reimbursement-items",
"reimbursement-materials",
"reimbursement-workflow-progress",
]);
await expect(page.getByText("ERP")).toHaveCount(0);
await page.getByTestId("reimbursement-invoice-add").click();
@@ -768,6 +785,14 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("电子火车票 · 12345679");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("无代码");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("未识别销售方");
const mobileSectionOrder = await mobile.page.locator("[data-testid='mobile-reimbursement-detail-focus'], [data-testid='mobile-reimbursement-items'], [data-testid='mobile-reimbursement-materials'], [data-testid='mobile-reimbursement-processing-section']")
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(mobileSectionOrder).toEqual([
"mobile-reimbursement-detail-focus",
"mobile-reimbursement-items",
"mobile-reimbursement-materials",
"mobile-reimbursement-processing-section",
]);
const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
await expect(mobileItem).not.toContainText("电子火车票");
@@ -6,8 +6,8 @@ import { openNavLink } from "./helpers/navigation";
async function firstEnabledExpenseTypeId(api: APIRequestContext) {
const response = await api.get("/api/expense-types?enabled=true");
const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean }> } };
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0];
const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean; category?: string | null }> } };
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false && candidate.category !== "travel");
if (!response.ok() || !item?.id) {
throw new Error(`无法读取费用类型:HTTP ${response.status()}`);
}
@@ -29,7 +29,7 @@ async function createReimbursementItem(api: APIRequestContext, reimbursementId:
}
}
test("E2E 报销审批进度:提交后详情页展示当前步骤进度", async ({ page, reimbursementDetail }) => {
test("E2E 报销处理进度:提交后按真实 workflow 展示职责进度", async ({ page, reimbursementDetail }) => {
const apiClient = await createAuthenticatedApiClient(EMPLOYEE);
await login(page, EMPLOYEE);
@@ -67,8 +67,13 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn
await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress).toContainText("当前节点");
await expect(reimbursementDetail.workflowProgress).toContainText(/第 1 步|第 2 步|第 3 步/);
await expect(reimbursementDetail.workflowProgress.locator(":scope > li")).toHaveCount(4);
await expect(reimbursementDetail.workflowProgress).toContainText("申请提交");
await expect(reimbursementDetail.workflowProgress).toContainText("审批");
await expect(reimbursementDetail.workflowProgress).not.toContainText("审批节点1");
await expect(reimbursementDetail.workflowProgress).toContainText("付款批次复核");
await expect(reimbursementDetail.workflowProgress).toContainText("付款与回单");
await expect(reimbursementDetail.workflowProgress).not.toContainText(/第 \d+ 步/);
await logout(page, EMPLOYEE.name);
} finally {
+331 -7
查看文件
@@ -329,6 +329,265 @@ function buildApprovalHistory(workflowInstance, historyItems = []) {
}));
}
const PROCESSING_STAGE_DEFINITIONS = Object.freeze([
{ key: "submitted", label: "申请提交" },
{ key: "manager_approval", label: "主管审批" },
{ key: "finance_review", label: "财务复核" },
{ key: "payment_batch_review", label: "付款批次复核" },
{ key: "payment_and_receipt", label: "付款与回单" },
]);
function isoDateTimeOrNull(value) {
return value?.toISOString?.() ?? null;
}
function isOrdinalApprovalLabel(value) {
const label = String(value ?? "").trim();
if (!label) return true;
const ordinal = "(?:\\d+|[一二三四五六七八九十百]+)";
return new RegExp(`^(?:第\\s*)?${ordinal}\\s*(?:步|步骤|节点)$`, "i").test(label)
|| new RegExp(`^(?:审批)?(?:步骤?|节点|step)\\s*[-_#::]?\\s*${ordinal}$`, "i").test(label);
}
function resolveApprovalStageLabel(task) {
const stepName = String(task?.step_name ?? "").trim();
const stepId = String(task?.step_id ?? "").trim();
const isTechnicalIdentifier = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+$/i.test(stepName);
const isGenericLabel = ["审批", "审核", "复核"].includes(stepName);
if (stepName && stepName !== stepId && !isOrdinalApprovalLabel(stepName) && !isTechnicalIdentifier && !isGenericLabel) {
return stepName;
}
const role = String(task?.assignee_role ?? "").toLowerCase();
if (role === "manager") return "主管审批";
if (["finance", "finance_mgr"].includes(role)) return "财务复核";
if (role === "ceo") return "负责人复核";
return "审批";
}
function resolveSnapshotApprovalRole(step) {
if (step?.assignee_rule?.type === "department_manager") return "manager";
if (step?.assignee_rule?.type === "role") return step.assignee_rule.value ?? null;
return null;
}
function buildPendingSnapshotApprovalStage(step, index) {
return {
key: `approval_pending_${index + 1}`,
label: resolveApprovalStageLabel({
step_id: step?.id,
step_name: step?.name,
assignee_role: resolveSnapshotApprovalRole(step),
}),
state: "pending",
actorName: null,
occurredAt: null,
result: null,
comment: null,
detail: "待处理",
targetHref: null,
};
}
function buildApprovalProcessingStage(task, fallback) {
if (!task) {
return fallback;
}
const actorName = task.completer_employee?.name ?? task.assignee_employee?.name ?? null;
if (task.outcome === "rejected" || task.status === "rejected") {
return {
...fallback,
state: "exception",
actorName,
occurredAt: isoDateTimeOrNull(task.completed_at),
result: "驳回",
comment: task.comment ?? null,
detail: "申请已驳回,待申请人处理",
};
}
if (task.outcome === "returned") {
return {
...fallback,
state: "exception",
actorName,
occurredAt: isoDateTimeOrNull(task.completed_at),
result: "退回",
comment: task.comment ?? null,
detail: "申请已退回,待申请人补充后重新提交",
};
}
if (task.status === "completed" || task.outcome === "approved") {
return {
...fallback,
state: "completed",
actorName,
occurredAt: isoDateTimeOrNull(task.completed_at),
result: "通过",
comment: task.comment ?? null,
detail: null,
};
}
if (task.status === "pending") {
return {
...fallback,
state: "current",
actorName: task.assignee_employee?.name ?? null,
occurredAt: isoDateTimeOrNull(task.created_at),
result: "处理中",
comment: null,
detail: `待${fallback.label}处理`,
};
}
return fallback;
}
function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatchItem, paymentActorNames, canReadPaymentBatch }) {
const stageByKey = new Map(PROCESSING_STAGE_DEFINITIONS.map((stage) => [stage.key, {
...stage,
state: "pending",
actorName: null,
occurredAt: null,
result: null,
comment: null,
detail: "待处理",
targetHref: null,
}]));
const submittedStage = stageByKey.get("submitted");
if (workflowInstance) {
Object.assign(submittedStage, {
state: "completed",
actorName: reimbursement.submitter?.name ?? reimbursement.owner?.name ?? null,
occurredAt: isoDateTimeOrNull(workflowInstance.started_at ?? workflowInstance.created_at),
result: "已提交",
detail: null,
});
} else if (["draft", "rejected"].includes(reimbursement.status)) {
Object.assign(submittedStage, {
state: "current",
actorName: reimbursement.owner?.name ?? null,
result: "待提交",
detail: "待申请人提交",
});
}
const approvalStages = [];
const workflowTasks = workflowInstance?.workflow_tasks ?? [];
for (const [index, task] of workflowTasks.entries()) {
const fallback = {
key: `approval_${task.id ?? index + 1}`,
label: resolveApprovalStageLabel(task),
state: "pending",
actorName: null,
occurredAt: null,
result: null,
comment: null,
detail: "待处理",
targetHref: null,
};
approvalStages.push(buildApprovalProcessingStage(task, fallback));
}
if (workflowInstance) {
const snapshotSteps = getWorkflowStepsSnapshot(workflowInstance);
const currentStepIndex = snapshotSteps.findIndex((step) => step.id === workflowInstance.current_step_id);
const materializedStepIds = new Set(workflowTasks.map((task) => task.step_id).filter(Boolean));
const pendingStartIndex = workflowTasks.length === 0 ? 0 : currentStepIndex + 1;
for (let index = Math.max(pendingStartIndex, 0); index < snapshotSteps.length; index += 1) {
const step = snapshotSteps[index];
if (!materializedStepIds.has(step.id)) {
approvalStages.push(buildPendingSnapshotApprovalStage(step, index));
}
}
}
const batch = paymentBatchItem?.payment_batch ?? null;
const batchTargetHref = batch && canReadPaymentBatch
? `/payment-batches?id=${encodeURIComponent(batch.id)}`
: null;
const batchReviewStage = stageByKey.get("payment_batch_review");
const paymentStage = stageByKey.get("payment_and_receipt");
if (!batch) {
Object.assign(batchReviewStage, reimbursement.status === "approved"
? { state: "current", result: "待建批次", detail: "待财务创建付款批次" }
: { detail: "待财务创建付款批次" });
paymentStage.detail = "待付款批次复核后处理";
} else if (batch.status === "draft") {
Object.assign(batchReviewStage, {
state: "current",
result: "待复核",
detail: "所在付款批次待非制单人复核",
targetHref: batchTargetHref,
});
Object.assign(paymentStage, { detail: "待所在付款批次复核通过", targetHref: batchTargetHref });
} else if (["reviewed", "executed", "failed"].includes(batch.status)) {
Object.assign(batchReviewStage, {
state: "completed",
actorName: paymentActorNames.get(batch.reviewed_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.reviewed_at),
result: "通过",
detail: batch.status === "reviewed" ? "所在付款批次已复核,待执行付款" : null,
targetHref: batchTargetHref,
});
if (batch.status === "reviewed") {
Object.assign(paymentStage, {
state: "current",
result: "待付款",
detail: "所在付款批次已复核,待执行付款",
targetHref: batchTargetHref,
});
} else if (batch.status === "failed") {
Object.assign(paymentStage, {
state: "exception",
actorName: paymentActorNames.get(batch.executed_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.executed_at),
result: "付款失败",
detail: "所在付款批次执行失败,待财务重新处理",
targetHref: batchTargetHref,
});
}
} else if (batch.status === "cancelled") {
Object.assign(batchReviewStage, {
state: "exception",
actorName: paymentActorNames.get(batch.cancelled_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.cancelled_at),
result: "批次已取消",
comment: batch.cancelled_reason ?? null,
detail: "所在付款批次已取消,待财务重新创建",
targetHref: batchTargetHref,
});
Object.assign(paymentStage, {
detail: "待财务重新创建付款批次",
targetHref: batchTargetHref,
});
}
if (batch?.status === "executed" || reimbursement.status === "paid") {
const receiptCount = batch?._count?.receipt_attachments ?? 0;
Object.assign(paymentStage, {
state: "completed",
actorName: reimbursement.paid_by_employee?.name ?? paymentActorNames.get(batch?.executed_by) ?? null,
occurredAt: isoDateTimeOrNull(reimbursement.paid_at ?? batch?.executed_at),
result: "已付款",
detail: receiptCount > 0 ? "付款与回单已留存" : "付款已完成,回单待留存",
targetHref: batchTargetHref,
});
}
return {
stages: [
stageByKey.get("submitted"),
...(approvalStages.length > 0
? approvalStages
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
stageByKey.get("payment_batch_review"),
stageByKey.get("payment_and_receipt"),
],
};
}
function ensureApplicantOrAdmin(request, reimbursement) {
if (request.employee?.roleCodes?.includes("admin")) {
return;
@@ -2255,6 +2514,9 @@ async function reimbursementRoutes(fastify) {
select: {
id: true,
current_step_id: true,
current_status: true,
started_at: true,
completed_at: true,
created_at: true,
context_data: true,
workflow_definition: {
@@ -2262,6 +2524,23 @@ async function reimbursementRoutes(fastify) {
definition_json: true,
},
},
workflow_tasks: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
step_id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
status: true,
outcome: true,
comment: true,
created_at: true,
completed_at: true,
assignee_employee: { select: { name: true } },
completer_employee: { select: { name: true } },
},
},
},
});
const workflowHistory = pendingInstance
@@ -2297,6 +2576,48 @@ async function reimbursementRoutes(fastify) {
},
})
: [];
const paymentBatchItem = await fastify.prisma.paymentBatchItem.findFirst({
where: {
entity_id: reimbursementId,
payment_batch: { entity_type: "reimbursement" },
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
select: {
id: true,
payment_batch: {
select: {
id: true,
status: true,
created_by: true,
reviewed_at: true,
reviewed_by: true,
executed_at: true,
executed_by: true,
cancelled_at: true,
cancelled_by: true,
cancelled_reason: true,
_count: {
select: { receipt_attachments: { where: { archived_at: null } } },
},
},
},
},
});
const paymentActorIds = Array.from(new Set([
paymentBatchItem?.payment_batch?.created_by,
paymentBatchItem?.payment_batch?.reviewed_by,
paymentBatchItem?.payment_batch?.executed_by,
paymentBatchItem?.payment_batch?.cancelled_by,
].filter(Boolean)));
const paymentActors = paymentActorIds.length
? await fastify.prisma.employee.findMany({
where: { id: { in: paymentActorIds } },
select: { id: true, name: true },
})
: [];
const paymentActorNames = new Map(paymentActors.map((actor) => [actor.id, actor.name]));
const canReadPaymentBatch = ["payment.batch.manage", "risk.review.manage", "payment.batch.review"]
.some((permissionCode) => fastify.hasPermission(request.employee, permissionCode));
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "reimbursement",
@@ -2306,10 +2627,6 @@ async function reimbursementRoutes(fastify) {
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
const travelItemDates = travelItems
.map((item) => item.expense_date)
.filter(Boolean)
.sort((left, right) => left.getTime() - right.getTime());
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
return sendData(reply, request, {
@@ -2349,10 +2666,10 @@ async function reimbursementRoutes(fastify) {
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
travelSummary: {
isTravel: isTravelReimbursement,
purpose: reimbursement.travel_purpose ?? (isTravelReimbursement ? reimbursement.description : null),
purpose: reimbursement.travel_purpose ?? null,
location: reimbursement.travel_location ?? null,
startDate: toDateOnlyString(reimbursement.travel_start_date ?? travelItemDates[0] ?? null),
endDate: toDateOnlyString(reimbursement.travel_end_date ?? travelItemDates.at(-1) ?? null),
startDate: toDateOnlyString(reimbursement.travel_start_date),
endDate: toDateOnlyString(reimbursement.travel_end_date),
},
owner: reimbursement.owner,
submitter: reimbursement.submitter,
@@ -2397,6 +2714,13 @@ async function reimbursementRoutes(fastify) {
}
: null,
workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null,
processingProgress: buildProcessingProgress({
reimbursement,
workflowInstance: latestWorkflowInstance,
paymentBatchItem,
paymentActorNames,
canReadPaymentBatch,
}),
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
@@ -99,6 +99,8 @@ async function buildApp({
id: PENDING_INSTANCE_ID,
current_step_id: "finance_approve",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
current_status: "pending",
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
@@ -113,6 +115,34 @@ async function buildApp({
],
},
},
workflow_tasks: [
{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status: "completed",
outcome: "approved",
comment: null,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T12:00:00.000Z"),
assignee_employee: { name: "王经理" },
completer_employee: { name: "王经理" },
},
{
id: "finance-task",
step_id: "finance_approve",
step_name: "财务审批",
assignee_role: "finance",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-04-10T12:00:00.000Z"),
completed_at: null,
assignee_employee: { name: "财务专员" },
completer_employee: null,
},
],
},
workflowHistoryRows = [
{
@@ -143,6 +173,8 @@ async function buildApp({
workflow_task: null,
},
],
paymentBatchItem = null,
paymentActors = [],
} = {}) {
const app = Fastify();
const prisma = {
@@ -163,6 +195,12 @@ async function buildApp({
entityRiskFlag: {
findMany: vi.fn(async () => []),
},
paymentBatchItem: {
findFirst: vi.fn(async () => paymentBatchItem),
},
employee: {
findMany: vi.fn(async () => paymentActors),
},
budgetPool: {
findMany: vi.fn(async () => []),
},
@@ -240,10 +278,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
submittedAt: "2026-04-10T10:00:00.000Z",
travelSummary: {
isTravel: true,
purpose: "客户拜访报销",
purpose: null,
location: null,
startDate: "2026-04-09",
endDate: "2026-04-09",
startDate: null,
endDate: null,
},
invoices: [
{
@@ -280,6 +318,13 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
currentStepIndex: 1,
totalSteps: 2,
});
expect(response.json().data.processingProgress.stages).toEqual([
expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }),
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed", actorName: "王经理", result: "通过" }),
expect.objectContaining({ key: "approval_finance-task", label: "财务审批", state: "current", actorName: "财务专员", result: "处理中" }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务创建付款批次" }),
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]);
await app.close();
});
@@ -304,6 +349,424 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
expect(response.statusCode).toBe(200);
expect(response.json().data.submittedAt).toBe(null);
expect(response.json().data.approvalHistory).toEqual([]);
expect(response.json().data.processingProgress.stages).toEqual([
expect.objectContaining({ key: "submitted", state: "current", actorName: "张三", detail: "待申请人提交" }),
expect.objectContaining({ key: "manager_approval", state: "pending", actorName: null }),
expect.objectContaining({ key: "finance_review", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null }),
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]);
await app.close();
});
it("keeps a third approval task and uses the role label when its configured name is only an ordinal", async () => {
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "ceo_approve",
current_status: "pending",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务复核" },
{ id: "ceo_approve", name: "步骤3" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务复核" },
{ id: "ceo_approve", name: "步骤3" },
],
},
},
workflow_tasks: [
{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status: "completed",
outcome: "approved",
comment: null,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
assignee_employee: { name: "主管" },
completer_employee: { name: "主管" },
},
{
id: "finance-task",
step_id: "finance_approve",
step_name: "财务复核",
assignee_role: "finance",
status: "completed",
outcome: "approved",
comment: null,
created_at: new Date("2026-04-10T11:00:00.000Z"),
completed_at: new Date("2026-04-10T12:00:00.000Z"),
assignee_employee: { name: "财务" },
completer_employee: { name: "财务" },
},
{
id: "ceo-task",
step_id: "ceo_approve",
step_name: "步骤3",
assignee_role: "ceo",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-04-10T12:00:00.000Z"),
completed_at: null,
assignee_employee: { name: "负责人" },
completer_employee: null,
},
],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages.slice(1, 4)).toEqual([
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed" }),
expect.objectContaining({ key: "approval_finance-task", label: "财务复核", state: "completed" }),
expect.objectContaining({ key: "approval_ceo-task", label: "负责人复核", state: "current", actorName: "负责人" }),
]);
await app.close();
});
it("appends future snapshot approvals that do not have workflow tasks yet", async () => {
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "manager_approve",
current_status: "pending",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批", type: "approval", assignee_rule: { type: "department_manager" } },
{ id: "finance_approve", name: "财务复核", type: "approval", assignee_rule: { type: "role", value: "finance" } },
{ id: "ceo_approve", name: "步骤3", type: "approval", assignee_rule: { type: "role", value: "ceo" } },
],
},
workflow_definition: { definition_json: { steps: [] } },
workflow_tasks: [{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: null,
assignee_employee: { name: "主管" },
completer_employee: null,
}],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages.slice(1, 4)).toEqual([
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "current", actorName: "主管" }),
expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }),
expect.objectContaining({ key: "approval_pending_3", label: "负责人复核", state: "pending", actorName: null }),
]);
await app.close();
});
it("keeps multiple tasks with the same role as separate ordered approval stages", async () => {
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "finance_final",
current_status: "pending",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
context_data: {
workflow_steps_snapshot: [
{ id: "finance_first", name: "财务初审" },
{ id: "finance_final", name: "财务终审" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "finance_first", name: "财务初审" },
{ id: "finance_final", name: "财务终审" },
],
},
},
workflow_tasks: [
{
id: "finance-first-task",
step_id: "finance_first",
step_name: "finance_first",
assignee_role: "finance",
status: "completed",
outcome: "approved",
comment: "初审通过",
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
assignee_employee: { name: "初审人" },
completer_employee: { name: "初审人" },
},
{
id: "finance-final-task",
step_id: "finance_final",
step_name: "财务终审",
assignee_role: "finance",
status: "pending",
outcome: null,
comment: null,
created_at: new Date("2026-04-10T11:00:00.000Z"),
completed_at: null,
assignee_employee: { name: "终审人" },
completer_employee: null,
},
],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages.slice(1, 3)).toEqual([
expect.objectContaining({ key: "approval_finance-first-task", label: "财务复核", actorName: "初审人", state: "completed" }),
expect.objectContaining({ key: "approval_finance-final-task", label: "财务终审", actorName: "终审人", state: "current" }),
]);
await app.close();
});
it("falls back to pending manager and finance placeholders when an instance has no tasks yet", async () => {
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: null,
current_status: "pending",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
context_data: {
workflow_steps_snapshot: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务复核" },
],
},
workflow_definition: {
definition_json: {
steps: [
{ id: "manager_approve", name: "主管审批" },
{ id: "finance_approve", name: "财务复核" },
],
},
},
workflow_tasks: [],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages.slice(1, 3)).toEqual([
expect.objectContaining({ key: "approval_pending_1", label: "主管审批", state: "pending", actorName: null }),
expect.objectContaining({ key: "approval_pending_2", label: "财务复核", state: "pending", actorName: null }),
]);
await app.close();
});
it.each([
["draft", "current", "pending", "待所在付款批次复核通过"],
["reviewed", "completed", "current", "所在付款批次已复核,待执行付款"],
["executed", "completed", "completed", "付款与回单已留存"],
["failed", "completed", "exception", "所在付款批次执行失败,待财务重新处理"],
["cancelled", "exception", "pending", "待财务重新创建付款批次"],
])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => {
const paymentBatchItem = {
id: "batch-item-1",
payment_batch: {
id: "batch-1",
status,
created_by: "maker-1",
reviewed_at: status === "draft" || status === "cancelled" ? null : new Date("2026-04-11T08:00:00.000Z"),
reviewed_by: status === "draft" || status === "cancelled" ? null : "reviewer-1",
executed_at: ["executed", "failed"].includes(status) ? new Date("2026-04-12T08:00:00.000Z") : null,
executed_by: ["executed", "failed"].includes(status) ? "executor-1" : null,
cancelled_at: status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null,
cancelled_by: status === "cancelled" ? "reviewer-1" : null,
cancelled_reason: status === "cancelled" ? "批次资料需调整" : null,
_count: { receipt_attachments: status === "executed" ? 1 : 0 },
},
};
const { app } = await buildApp({
paymentBatchItem,
paymentActors: [
{ id: "reviewer-1", name: "实际复核人" },
{ id: "executor-1", name: "实际付款人" },
],
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
const stages = response.json().data.processingProgress.stages;
expect(stages[3]).toMatchObject({ state: reviewState, targetHref: null });
expect(stages[4]).toMatchObject({ state: paymentState, detail, targetHref: null });
if (status === "draft") expect(stages[3].actorName).toBe(null);
await app.close();
});
it("returns only a safe payment-batch link to users with existing batch-read permission", async () => {
const { app } = await buildApp({
permissionCodes: ["expense.view_own", "payment.batch.review"],
paymentBatchItem: {
id: "batch-item-1",
payment_batch: {
id: "batch-1",
status: "draft",
created_by: "maker-1",
reviewed_at: null,
reviewed_by: null,
executed_at: null,
executed_by: null,
cancelled_at: null,
cancelled_by: null,
cancelled_reason: null,
_count: { receipt_attachments: 0 },
},
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[3]).toMatchObject({
detail: "所在付款批次待非制单人复核",
actorName: null,
targetHref: "/payment-batches?id=batch-1",
});
await app.close();
});
it("marks finance batch creation as the current responsibility after workflow approval", async () => {
const { app } = await buildApp({
reimbursement: buildReimbursementRecord({ status: "approved", workflow_instances: [] }),
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[3]).toMatchObject({
state: "current",
actorName: null,
result: "待建批次",
detail: "待财务创建付款批次",
});
await app.close();
});
it("keeps a legacy paid reimbursement readable when no payment batch exists", async () => {
const { app } = await buildApp({
reimbursement: buildReimbursementRecord({
status: "paid",
paid_at: new Date("2026-04-12T08:00:00.000Z"),
paid_by_employee: { id: "finance-1", employee_no: "F001", name: "实际付款人" },
workflow_instances: [],
}),
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[4]).toMatchObject({
state: "completed",
actorName: "实际付款人",
occurredAt: "2026-04-12T08:00:00.000Z",
result: "已付款",
detail: "付款已完成,回单待留存",
});
await app.close();
});
it.each([
["returned", "退回", "请补充材料"],
["rejected", "驳回", "不符合报销要求"],
])("expands a %s workflow outcome with the actual handler and comment", async (outcome, result, comment) => {
const status = outcome === "rejected" ? "rejected" : "completed";
const { app } = await buildApp({
latestWorkflowInstance: {
id: PENDING_INSTANCE_ID,
current_step_id: "manager_approve",
current_status: outcome === "rejected" ? "rejected" : "terminated",
created_at: new Date("2026-04-10T10:00:00.000Z"),
started_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
context_data: { workflow_steps_snapshot: [{ id: "manager_approve", name: "主管审批" }] },
workflow_definition: { definition_json: { steps: [{ id: "manager_approve", name: "主管审批" }] } },
workflow_tasks: [{
id: "manager-task",
step_id: "manager_approve",
step_name: "主管审批",
assignee_role: "manager",
status,
outcome,
comment,
created_at: new Date("2026-04-10T10:00:00.000Z"),
completed_at: new Date("2026-04-10T11:00:00.000Z"),
assignee_employee: { name: "王经理" },
completer_employee: { name: "王经理" },
}],
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[1]).toMatchObject({
state: "exception",
actorName: "王经理",
result,
comment,
});
await app.close();
});