完善报销通知与付款进度(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 21:06:31 +08:00
父节点 ca24479a44
当前提交 aa6b9bc373
共修改 23 个文件,包含 730 行新增和 88 行删除
+111 -5
查看文件
@@ -138,9 +138,12 @@ if (!hasPermission(actor, "payment.batch.review")
workflow stages use the actual pending assignee when available. Future stages workflow stages use the actual pending assignee when available. Future stages
keep `actorName=null`; never guess a person from today's role holders. keep `actorName=null`; never guess a person from today's role holders.
- A reimbursement approved without a batch makes `payment_batch_review` the - A reimbursement approved without a batch makes `payment_batch_review` the
current stage with `detail="待财务创建付款批次"`. A draft batch says current stage with `detail="待财务安排付款"`. A draft batch says
`所在付款批次待非制单人复核`; shared-batch wording must not imply that this `待非制单人复核付款`; employee-facing wording does not expose the internal
reimbursement alone is being reviewed again. batch object or imply that this reimbursement alone is being reviewed again.
- The final stage label is `付款完成`, and its pending/current/failed detail
explicitly says it is a finance registration step rather than an approval.
Internal finance pages may continue to use the precise `付款批次` term.
- Missing workflow, batch or receipt facts degrade only their stages. Earlier - Missing workflow, batch or receipt facts degrade only their stages. Earlier
known stages remain visible; missing downstream objects are normal, not 5xx. known stages remain visible; missing downstream objects are normal, not 5xx.
- `targetHref` is returned only when the viewer already has one of the existing - `targetHref` is returned only when the viewer already has one of the existing
@@ -163,7 +166,7 @@ if (!hasPermission(actor, "payment.batch.review")
- Batch `draft|reviewed|executed|failed|cancelled` -> map to explicit current, - Batch `draft|reviewed|executed|failed|cancelled` -> map to explicit current,
completed or exception states without changing the batch. completed or exception states without changing the batch.
- Executed payment + zero active receipts -> completed payment with - Executed payment + zero active receipts -> completed payment with
`付款已完成,回单待留存`; positive count -> `付款与回单已留存`. `付款已完成,回单待留存`; positive count -> `付款和回单已留存`.
- Viewer lacks batch-read permission -> `targetHref=null`; do not return 403 for - Viewer lacks batch-read permission -> `targetHref=null`; do not return 403 for
the reimbursement detail itself. the reimbursement detail itself.
- Database/query failure -> existing 5xx behavior and server logging; do not - Database/query failure -> existing 5xx behavior and server logging; do not
@@ -171,7 +174,7 @@ if (!hasPermission(actor, "payment.batch.review")
### 5. Good/Base/Bad Cases ### 5. Good/Base/Bad Cases
- Good: `财务复核=通过` followed by current `待财务创建付款批次`, so the owner - Good: `财务复核=通过` followed by current `待财务安排付款`, so the owner
can diagnose the exact responsibility without asking three people. can diagnose the exact responsibility without asking three people.
- Base: a newly submitted reimbursement shows known submission, every real - Base: a newly submitted reimbursement shows known submission, every real
configured approval task, and the two future payment stages with no guessed configured approval task, and the two future payment stages with no guessed
@@ -210,3 +213,106 @@ const approvalStages = workflowTasks.map((task) => buildApprovalStage(task));
approvalStages.push(...buildFutureSnapshotStages(workflowSnapshot, workflowTasks)); approvalStages.push(...buildFutureSnapshotStages(workflowSnapshot, workflowTasks));
return [submittedStage, ...approvalStages, batchReviewStage, paymentStage]; return [submittedStage, ...approvalStages, batchReviewStage, paymentStage];
``` ```
## Scenario: Payment Responsibility Notifications
### 1. Scope / Trigger
- Trigger: creating a reimbursement payment batch, completing its maker-checker
review, or extending notifications to another payment responsibility handoff.
- These are action-required notifications, not new workflow approval tasks and
not public business-domain events.
### 2. Signatures
- Notification types: `payment_batch.review_requested` and
`payment_batch.execution_requested`.
- Both use `targetType=payment_batch`, `targetId=<batch id>` and
`targetPath=/payment-batches?id=<batch id>`.
- Both resolve to the existing `reimbursement` IM domain while payment batches
contain only reimbursement entities.
### 3. Contracts
- Batch creation resolves active employees whose roles include `manager|ceo`
and whose effective role permissions include `payment.batch.review`; exclude
the maker and never hard-code a person's name or employee ID.
- Review completion first targets the active maker when that employee still has
`payment.batch.manage`. If unavailable, fall back to other active
`finance|finance_mgr` employees with that permission; always exclude the
reviewer.
- Create station notification records inside the same transaction as the batch
creation/review state change. Dispatch through the shared IM dispatcher only
after commit. A dispatch failure is logged and, once a channel attempt starts,
retained as delivery evidence; it never rolls back the committed payment
action.
- Payment execution keeps the existing `reimbursement.paid` result notification
to the owner/submitter. `付款完成` remains a finance action and must not be
represented as another approval.
- PC and mobile approval histories use the shared
`client/src/lib/approvalDisplay.ts#workflowActionLabel`; `start|submit` both
render as `提交`, and pages must not keep local label maps.
### 4. Validation & Error Matrix
- No eligible non-maker reviewer -> create the batch with zero review
notifications; do not weaken maker-checker or notify the maker.
- Maker inactive or missing `payment.batch.manage` after review -> notify the
eligible finance fallback; no eligible fallback -> review remains committed
with zero execution notifications.
- Maker attempts review or a reviewed batch is reviewed again -> existing
deterministic `409 VALID_001`; zero responsibility notifications and zero IM
dispatch.
- IM disabled, recipient push disabled/unbound, or no matching channel -> station
notification remains; dispatcher returns a fixed skipped reason without
rolling back the payment action.
- Notification record creation fails inside the transaction -> the protected
payment state change fails atomically; no partial handoff is reported.
### 5. Good/Base/Bad Cases
- Good: finance creates one weekly batch, the eligible non-maker reviewer gets
one notification, then successful review returns the action to the active
maker for payment and receipt registration.
- Base: no eligible reviewer is configured; the draft batch remains visible for
diagnosis and no unqualified management broadcast is fabricated.
- Bad: notify every finance/manager account, let the maker review their own
batch, send before transaction commit, or label payment completion as an
approval.
### 6. Tests Required
- Route tests assert qualified reviewer resolution, maker exclusion, active
maker preference, inactive-maker finance fallback and reviewer exclusion.
- Negative tests assert maker conflict, repeated review and duplicate batch
creation produce no notification/dispatch.
- Dispatcher tests assert both types are IM-supported and resolve to the
reimbursement domain; dispatch failure must leave the committed batch state.
- PC/mobile E2E asserts `start` renders `提交` from the shared label map and the
five-stage employee projection ends with `付款复核` / `付款完成(非审批)`.
### 7. Wrong vs Correct
#### Wrong
```js
notifyEmployeesByRole(["manager", "finance"]);
await tx.paymentBatch.update({ data: { status: "reviewed" } });
```
#### Correct
```js
const recipients = await resolvePaymentExecutionRecipientIds(tx, {
makerId: batch.created_by,
reviewerId: actor.id,
});
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.paymentBatchExecutionRequested,
targetType: "payment_batch",
targetId: batch.id,
});
// after commit
await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] });
```
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,56 @@
# 报销通知与处理进度简化:设计
## 1. 设计目标
用最少改动恢复真实待办通知,并让员工看到职责清晰的报销全流程。工作流审批、付款批次和付款执行仍是三个独立状态源;页面只做汇总,不把它们合成一个状态机。
## 2. 通知只读诊断
按以下顺序诊断目标财务任务,任何一步都只记录布尔状态、计数、固定结果码和脱敏结论:
1. 从目标报销的最新工作流实例定位主管已完成任务与当前财务任务,不保存内部 ID 到仓库证据。
2. 以当前财务任务为 `target_id` 查询 `workflow.task_assigned` 站内通知,并确认收件人是否为预期财务人员。
3. 若通知不存在,回到主管审批后的下一任务创建/收件人解析路径;若存在,继续查询 delivery 状态与固定 `result_code`。
4. 只核对收件员工 active、`notification_push_enabled`、企微绑定是否存在,及启用渠道能否匹配 workflow 域和财务角色;不读取 userid、渠道 Secret、消息正文或响应原文。
5. 只读结论不足以授权配置修改、补发、重启或生产写入,后续动作单独申请。
## 3. 审批动作标签
- `client/src/lib/approvalDisplay.ts` 的 `workflowActionLabel()` 为唯一共享映射。
- PC `WorkflowTaskDetailPage.tsx` 和移动 `MobileApprovalsPage.tsx` 删除局部副本并导入共享函数。
- 保持未知动作的原值兜底用于诊断,但所有当前受支持动作必须有中文映射;测试覆盖 `start` 与 `submit`。
## 4. 员工进度表达
- 后端处理进度阶段键保持兼容,只把普通详情的标签调整为“付款复核”“付款完成”。
- `付款完成` 当前节点/待处理说明明确“由财务登记,非审批环节”;已完成时继续显示付款人、时间及回单是否留存。
- “查看所在付款批次”只对已有批次读取权限的人显示,普通员工不接触内部批次对象名。
- 五阶段继续使用一条竖向进度线,因为它表达全流程进展;文案区分审批与付款,不另加 Tab、折叠或第二张流程图。
## 5. 付款阶段通知
本节实施以第 2 节诊断结论为前置门:若原链路是投递配置问题,付款阶段只新增复用既有 dispatcher 的发送点;若原链路是通知创建或 side-effect flush 的代码问题,先修复并用既有主管→财务通知证明公共路径,再让两个付款发送点复用同一修复。禁止在根因未定时并行新增后再混合排障。
### 5.1 批次创建后
- 新建批次事务内创建“待付款复核”站内通知,事务提交后走现有 IM dispatcher。
- 收件人由 active 员工的真实角色权限计算:必须有 `payment.batch.review` 且角色包含 `manager` 或 `ceo`,并排除批次创建人;不得写死姓名或向所有管理角色泛发。
- 通知目标是 `payment_batch`,deeplink 进入现有付款页面的目标批次。
### 5.2 批次复核后
- 复核事务内创建“待执行付款”通知,收件人优先为仍 active 且具备 `payment.batch.manage` 的批次创建人;创建人不可用时,才解析其他具备管理权限的 active 财务人员,并排除当前复核人。
- 不把付款动作通知给普通申请人;申请人在批次执行成功后继续收到既有 `reimbursement.paid`。
### 5.3 合同与幂等
- 新增明确的付款待办通知类型,不复用“报销已付款”或“工作流待审批”伪装语义。当前付款批次只开放报销实体,因此通知映射到既有 `reimbursement` domain,复用现有渠道配置;未来若重新开放其他实体付款,再按实体拆分 domain,不在本轮新建需要生产改配的 `payment` domain。
- 通知创建与业务状态变更在同一事务内;投递失败不回滚已完成业务动作,但保留 delivery 失败证据。
- 重复创建/复核请求由既有状态锁拒绝,测试证明不会产生重复通知。
## 6. 兼容与回滚
- 不修改数据库 schema、历史审批快照、既有通知记录或付款批次状态。
- 文案改动可直接回滚;共享映射回滚不会影响服务端事实。
- 付款通知若出现收件范围错误,关闭对应通知类型/代码路径即可,不撤销已经创建或复核的付款批次。
- 本轮不合并财务复核与组批;累计 5–10 张真实单后的用户访谈是重新立项条件,不是当前发布门禁。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,49 @@
# 报销通知与处理进度简化:实施计划
## 0. 边界与前置
- [x] 获得最终规划批准后再启动任务;生产只读诊断随本计划获批,任何生产写入、补发、配置修改或重启仍需另行批准。
- [x] 不修改审批链、付款 maker-checker、数据库 schema、历史任务或真实付款数据。
- [x] 生产诊断不输出通知正文、完整账号、企微 userid、内部业务 ID、Secret 或原始响应。
## 1. 生产只读通知诊断
- [x] 定位目标报销最新工作流的当前财务任务,先查 `workflow.task_assigned` 站内通知是否存在。
- [x] 有通知则查 delivery 状态/固定结果码、推送开关、绑定存在性和渠道匹配;无通知则核对下一任务与收件人解析事实。
- [x] 形成只含布尔值、计数和错误分类的结论;需要修生产配置、补发或重启时停止并申请批准。
诊断结论(脱敏):当前财务任务及对应站内通知均已生成,收件人 active、开启推送且存在企微绑定;投递尝试为 0,生产无启用且匹配的企微通知渠道。根因位于投递配置层,不是审批推进或通知创建代码。本任务没有修改生产配置、补发、重启或部署。
## 2. 共享动作标签
- [x] PC 与移动审批页移除局部 `workflowActionLabel`,统一导入共享实现。
- [x] 增加 `start`/`submit` 中文映射回归,检查打印及其他调用方没有第三份重复映射。
## 3. 处理进度文案
- [x] 将员工详情的后两阶段改为“付款复核”“付款完成”。
- [x] 付款完成待处理说明明确“由财务登记,非审批环节”,保留权限门控的批次入口和回单完成状态。
- [x] 更新 PC/移动组件测试,断言五阶段顺序与普通员工不暴露内部批次入口。
## 4. 付款责任人通知
- [x] 以前述只读诊断结论为实施门:先证明公共通知创建/投递路径的根因与修复边界,再确定本节只新增发送点还是必须复用同一代码修复。
- [x] 增加付款复核待办与付款执行待办的通知类型、domain 和脱敏文案。
- [x] 批次创建事务内解析有复核权限的 active manager/ceo,排除创建人并创建通知;提交后复用现有 dispatcher。
- [x] 批次复核事务内通知 active 批次创建人/实际付款责任人,排除复核人;付款执行成功沿用现有申请人通知。
- [x] 覆盖无合格收件人、创建人停用、重复请求、投递失败不回滚业务和普通用户不可读批次等边界。
## 5. 验证与审查
- [x] 运行 workflow/reimbursement/payment-batch 聚焦 Vitest、付款并发与 maker-checker 回归。
- [x] 运行客户端 typecheck/build、相关 PC/移动 Playwright 和动作标签单测。
- [x] 运行 Prisma validate、task/governance/process/sensitive/diff 门禁。
- [x] 因涉及 workflow/付款职责和通知收件范围,实施后执行只读 reviewer 检查,不允许 reviewer 改代码。
- [ ] 部署与公网检查另行批准;未部署前不通知用户可以测试,企微实际到达必须真机确认。
只读审查结论:首次检查发现站内通知中心缺少 `payment_batch` 目标链接,已补 PC/移动统一直达付款页并增加回归;复查未发现阻断项。通知记录与付款动作同事务,IM 投递发生在提交后且失败不回滚;复核人解析排除制单人,付款责任人解析排除复核人;重复创建/复核由既有锁和状态校验拒绝,不重复通知。生产仍无启用且匹配的企微渠道,属于已知部署后配置门,不得表述为企微通知已恢复。
## 6. 运行观察
- [x] 运行约定为每周集中建立一次付款批次,不建设排程功能。
- [ ] 王芝玲累计处理 5–10 张真实报销后触发一次访谈,决定财务复核与加入批次是否值得另立合并任务(未来触发条件,当前尚未发生)。
@@ -0,0 +1,61 @@
# 报销通知与处理进度简化
## Goal
让报销单在 5–10 人公司的真实流程中做到两件事:新的责任人能及时收到待办通知;申请人能看懂“审批”和“付款处理”的区别,不再把四个动作误认为四轮审批。
## Background
- 马建勇已在生产完成杨俊报销的主管审批,但下一节点王芝玲没有收到预期审批通知;王芝玲此前已通过企业微信登录,不能把问题归因于“从未登录”。
- 代码在报销提交和工作流流转时会创建 `workflow.task_assigned`,主管通过后也会生成下一任务并排队通知,因此应先区分“站内通知未生成”和“已生成但企微未投递”。
- 当前员工详情展示“付款批次复核”“付款与回单”,容易被理解为两轮额外审批;实际前者是非制单人付款复核,后者是财务线下付款后的回单与状态登记,不是审批。
- 当前职责基线:马建勇负责主管审批和非制单付款复核;王芝玲负责财务复核、创建付款批次、线下付款、上传回单和登记付款完成。一个付款批次可以覆盖多张报销单。
- `client/src/lib/approvalDisplay.ts` 已把 `start` 映射为“提交”,但 PC `WorkflowTaskDetailPage.tsx` 与移动 `MobileApprovalsPage.tsx` 各自复制了缺少 `start` 的局部映射,导致显示 `start · 杨俊`。
## Scope
- 先做一次获批的生产只读诊断:只核对目标工作流任务、王芝玲对应站内通知是否存在、投递记录状态/固定错误分类、通知开关、企微绑定状态和渠道匹配状态;不读取 Secret、消息正文或原始企微 payload。
- 删除 PC/移动审批页重复的动作标签映射,统一使用共享 `workflowActionLabel()`;`start` 和 `submit` 均显示“提交”。
- 员工报销进度保留五个业务阶段,但改为:申请提交、主管审批、财务复核、付款复核、付款完成;付款完成明确为“财务登记,非审批环节”。
- 保留付款 maker-checker:付款批次创建人与复核人不得相同,当前只需一次非制单复核,不增加更多复核层级。
- 通知以“新的责任人需要行动”为准:提交后通知主管、主管通过后通知财务、创建付款批次后通知非制单复核人、付款复核后通知财务/批次创建人继续付款、付款完成后通知申请人。
- 每周集中建立一次付款批次作为当前运行约定,不在本任务建设定时排程、自动组批或强制付款日。
## Requirements
- 通知诊断必须先判断站内通知是否生成,再进入企微投递层,不能直接假设缺少通知代码或重复创建通知。
- 若本次缺失由生产配置、人员推送开关、企微绑定或渠道配置造成,先给出脱敏根因和最小修复建议;任何生产写入、配置修改、补发消息或重启均需再次批准。
- 新增付款阶段通知时必须复用现有通知、投递和审计服务;通知只给真实需要行动的人,不向所有 finance/manager 角色泛发重复消息。
- 普通员工页面不突出“付款批次”内部对象名;有付款批次权限的人员仍可通过现有权限门控入口查看批次。
- 共享动作标签是唯一事实源;PC、移动和打印页不得继续维护互相漂移的局部映射。
- 不删除主管审批、财务复核或付款复核,不放宽自审,不把付款完成伪装成审批通过。
## Acceptance Criteria
- [ ] 脱敏证据能明确回答本次财务通知属于“未生成”还是“已生成未送达”,并定位到单一失败层或明确下一步,不包含通知正文、账号、Secret 或原始 payload。
- [ ] 马建勇通过主管审批后,王芝玲能够收到对应的新待办站内通知;企微投递恢复需另有真实投递证据后才能标为通过。
- [ ] PC 与移动审批处理历史均显示“提交 · 杨俊”,不再出现原始 `start`;动作标签映射只保留一个共享实现。
- [ ] 普通报销详情依次显示“申请提交、主管审批、财务复核、付款复核、付款完成”,并明确付款完成不是审批。
- [ ] 创建付款批次后通知实际可复核人员;复核通过后通知实际付款责任人;付款完成继续通知申请人,且同一事件不会重复创建/投递。
- [ ] 付款复核仍强制非制单人且仅一轮;既有付款批次、审批快照和历史记录不迁移、不改写。
- [ ] PC/移动相关组件测试、通知路由/服务测试、付款 maker-checker 回归、前端 typecheck/build 与 Trellis 门禁通过。
## Out of Scope
- 不在本任务删除付款批次、付款复核或银行回单要求。
- 不接银行接口,不让 OA 自动执行真实付款。
- 不建设每周付款日排程、自动组批、批次自动复核或定时通知。
- 不在未获批准时修改生产通知渠道、员工推送开关、企微绑定、环境变量或服务状态。
- 不顺带重构全部审批页面或统一所有业务状态标签。
## Deferred Decision With Trigger
- 本轮不合并“财务复核通过”和“加入/创建本周付款批次”。决定性依据是王芝玲尚未实际执行过财务复核,无法判断她会随到随审还是与周付款批次一起处理;提前合并可能迫使她延迟复核或频繁建批次。
- 触发条件:王芝玲累计处理 5–10 张真实报销单后,由负责人直接询问“财务复核与加入付款批次通常一次完成,还是分开完成?”若一次完成,再立小任务评估合并;若随到随审、定期组批,则永久保持两个动作分离。
## Test Plan
- 聚焦测试动作标签、下一审批任务通知、付款批次创建/复核/执行通知和幂等性。
- 运行相关 server Vitest、付款并发/maker-checker 回归、客户端 typecheck/build 与相关 Playwright。
- 运行 task validate、Trellis governance、process contract、sensitive-content scan 与 `git diff --check`。
- 生产只读诊断和任何后续真机通知验证分开记录;没有实际企微到达证据时不得宣称通知已恢复。
@@ -0,0 +1,26 @@
{
"id": "reimbursement-notification-progress-clarity",
"name": "reimbursement-notification-progress-clarity",
"title": "报销通知与处理进度简化",
"description": "诊断报销审批通知投递,统一审批动作中文映射,并按小团队实际职责简化付款阶段展示。",
"status": "in_progress",
"dev_type": null,
"scope": "报销审批通知只读诊断、审批动作标签去重、付款阶段文案与必要通知;不削弱付款 maker-checker",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-01",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+8
查看文件
@@ -18,6 +18,10 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
return `/reimbursements/${item.targetId}`; return `/reimbursements/${item.targetId}`;
} }
if (item.targetType === "payment_batch") {
return `/payment-batches?id=${encodeSegment(item.targetId)}`;
}
if (item.targetType === "leave_request") { if (item.targetType === "leave_request") {
return `/leave-requests?highlight=${item.targetId}`; return `/leave-requests?highlight=${item.targetId}`;
} }
@@ -60,6 +64,10 @@ export function resolveMobileNotificationTargetLink(item: NotificationTarget) {
return `/mobile/reimbursements/${encodeSegment(item.targetId)}`; return `/mobile/reimbursements/${encodeSegment(item.targetId)}`;
} }
if (item.targetType === "payment_batch") {
return `/payment-batches?id=${encodeSegment(item.targetId)}`;
}
if (item.targetType === "leave_request") { if (item.targetType === "leave_request") {
return `/mobile/leave/${encodeSegment(item.targetId)}`; return `/mobile/leave/${encodeSegment(item.targetId)}`;
} }
+1 -22
查看文件
@@ -31,7 +31,7 @@ import { Spinner } from "../components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea"; import { Textarea } from "../components/ui/textarea";
import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation"; import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay"; import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay"; import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -150,27 +150,6 @@ function workflowStatusLabel(status: string) {
return status; return status;
} }
function workflowActionLabel(action: string | null | undefined) {
if (!action) {
return "-";
}
const labels: Record<string, string> = {
submit: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
remind: "催办",
overdue_remind: "超时提醒",
cancel: "取消",
skip: "跳过",
};
return labels[action] ?? action;
}
function businessStatusLabel(status: string | null | undefined) { function businessStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
draft: "草稿", draft: "草稿",
+2 -1
查看文件
@@ -17,7 +17,7 @@ const PAGE_SIZE = 20;
const NOTIFICATION_TYPE_OPTIONS = [ const NOTIFICATION_TYPE_OPTIONS = [
{ label: "全部", value: "" }, { label: "全部", value: "" },
{ label: "审批事件", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" }, { label: "审批事件", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" }, { label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid,payment_batch.review_requested,payment_batch.execution_requested" },
{ label: "请假", value: "leave.approved,leave.rejected" }, { label: "请假", value: "leave.approved,leave.rejected" },
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" }, { label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
{ label: "差旅", value: "travel_request.approved,travel_request.rejected" }, { label: "差旅", value: "travel_request.approved,travel_request.rejected" },
@@ -39,6 +39,7 @@ function formatTime(value: string) {
function notificationTypeLabel(type: string) { function notificationTypeLabel(type: string) {
if (type.startsWith("workflow.")) return "审批事件"; if (type.startsWith("workflow.")) return "审批事件";
if (type.startsWith("reimbursement.")) return "报销事件"; if (type.startsWith("reimbursement.")) return "报销事件";
if (type.startsWith("payment_batch.")) return "付款事件";
if (type.startsWith("leave.")) return "请假事件"; if (type.startsWith("leave.")) return "请假事件";
if (type.startsWith("purchase_request.")) return "采购事件"; if (type.startsWith("purchase_request.")) return "采购事件";
if (type.startsWith("travel_request.")) return "差旅事件"; if (type.startsWith("travel_request.")) return "差旅事件";
+1 -1
查看文件
@@ -17,7 +17,7 @@ const PAGE_SIZE = 20;
const NOTIFICATION_TYPE_OPTIONS = [ const NOTIFICATION_TYPE_OPTIONS = [
{ label: "全部", value: "" }, { label: "全部", value: "" },
{ label: "审批提醒", value: "workflow.task_assigned" }, { label: "审批提醒", value: "workflow.task_assigned" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" }, { label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid,payment_batch.review_requested,payment_batch.execution_requested" },
{ label: "请假", value: "leave.approved,leave.rejected" }, { label: "请假", value: "leave.approved,leave.rejected" },
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" }, { label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
{ label: "差旅", value: "travel_request.approved,travel_request.rejected" }, { label: "差旅", value: "travel_request.approved,travel_request.rejected" },
+1 -22
查看文件
@@ -32,7 +32,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea"; import { Textarea } from "../components/ui/textarea";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay"; import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat"; import { formatDisplayDateTime } from "../lib/displayFormat";
import { import {
buildPcDetailSource, buildPcDetailSource,
@@ -85,27 +85,6 @@ function workflowInstanceStatusLabel(status: string | undefined | null) {
return labels[status] ?? status; return labels[status] ?? status;
} }
function workflowActionLabel(action: string | undefined | null) {
if (!action) {
return "-";
}
const labels: Record<string, string> = {
submit: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
remind: "催办",
overdue_remind: "超时提醒",
cancel: "取消",
skip: "跳过",
};
return labels[action] ?? action;
}
function DetailField({ label, value }: { label: string; value: ReactNode }) { function DetailField({ label, value }: { label: string; value: ReactNode }) {
return ( return (
<div className="detail-fact-card"> <div className="detail-fact-card">
@@ -1,6 +1,10 @@
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { expect, test, type Browser, type Page, type Route } from "@playwright/test"; import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
import {
resolveMobileNotificationTargetLink,
resolveNotificationTargetLink,
} from "../../src/lib/notificationLinks";
const ROOT_DIR = path.resolve(process.cwd(), ".."); const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/mobile-ia-flow-remediation"); const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/mobile-ia-flow-remediation");
@@ -881,6 +885,13 @@ async function expectCommonActionsDoNotDuplicateBottomTabs(page: Page) {
} }
test.describe("MIF7 移动信息架构整改", () => { test.describe("MIF7 移动信息架构整改", () => {
test("付款责任通知在 PC 与移动通知中心都可直达付款批次", () => {
const target = { targetType: "payment_batch", targetId: "batch/dg7" };
expect(resolveNotificationTargetLink(target)).toBe("/payment-batches?id=batch%2Fdg7");
expect(resolveMobileNotificationTargetLink(target)).toBe("/payment-batches?id=batch%2Fdg7");
});
test.skip(process.env.E2E_MODE === "live", "MIF7 本地专项使用 mock 数据验证信息架构。"); test.skip(process.env.E2E_MODE === "live", "MIF7 本地专项使用 mock 数据验证信息架构。");
test("首页常用入口不重复底部 Tab,员工进度页按状态分标签", async ({ browser }) => { test("首页常用入口不重复底部 Tab,员工进度页按状态分标签", async ({ browser }) => {
@@ -1108,7 +1119,7 @@ test.describe("MIF7 移动信息架构整改", () => {
[ [
"mobile-reimbursement-items", "mobile-reimbursement-items",
"mobile-reimbursement-attachments", "mobile-reimbursement-attachments",
"mobile-reimbursement-approval-history", "mobile-reimbursement-processing-progress",
].map((testId) => document.querySelector(`[data-testid='${testId}']`)?.getBoundingClientRect().top ?? -1), ].map((testId) => document.querySelector(`[data-testid='${testId}']`)?.getBoundingClientRect().top ?? -1),
); );
expect(sectionOrder[0]).toBeLessThan(sectionOrder[1]); expect(sectionOrder[0]).toBeLessThan(sectionOrder[1]);
@@ -155,8 +155,8 @@ const reimbursementDetail = {
{ key: "manager_approval", label: "主管审批", state: "current", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "处理中", comment: null, detail: "待主管审批处理", 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: "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: "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_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 }, { key: "payment_and_receipt", label: "付款完成", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款复核后由财务登记,非审批环节", targetHref: null },
], ],
}, },
approvalHistory: [ approvalHistory: [
@@ -1101,11 +1101,12 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
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-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")).toContainText("付款批次复核"); await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/); await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/);
await page.goto("/workflow/tasks/task-wave3"); await page.goto("/workflow/tasks/task-wave3");
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05"); await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
await expect(page.getByText(`提交 · ${visualMe.name}`)).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/reimbursements/reim-wave3"); await page.goto("/mobile/reimbursements/reim-wave3");
@@ -1119,7 +1120,7 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
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(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-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款与回单"); await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款完成");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0); await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0); await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
@@ -203,8 +203,8 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
{ key: "submitted", label: "申请提交", state: "current", actorName: "测试员工", occurredAt: null, result: "待提交", comment: null, detail: "待申请人提交", targetHref: null }, { 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: "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: "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_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 }, { key: "payment_and_receipt", label: "付款完成", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款复核后由财务登记,非审批环节", targetHref: null },
], ],
}, },
approvalHistory: [], approvalHistory: [],
@@ -71,8 +71,9 @@ test("E2E 报销处理进度:提交后按真实 workflow 展示职责进度",
await expect(reimbursementDetail.workflowProgress).toContainText("申请提交"); await expect(reimbursementDetail.workflowProgress).toContainText("申请提交");
await expect(reimbursementDetail.workflowProgress).toContainText("审批"); await expect(reimbursementDetail.workflowProgress).toContainText("审批");
await expect(reimbursementDetail.workflowProgress).not.toContainText("审批节点1"); await expect(reimbursementDetail.workflowProgress).not.toContainText("审批节点1");
await expect(reimbursementDetail.workflowProgress).toContainText("付款批次复核"); await expect(reimbursementDetail.workflowProgress).toContainText("付款复核");
await expect(reimbursementDetail.workflowProgress).toContainText("付款与回单"); await expect(reimbursementDetail.workflowProgress).toContainText("付款完成");
await expect(reimbursementDetail.workflowProgress).toContainText("非审批环节");
await expect(reimbursementDetail.workflowProgress).not.toContainText(/第 \d+ 步/); await expect(reimbursementDetail.workflowProgress).not.toContainText(/第 \d+ 步/);
await logout(page, EMPLOYEE.name); await logout(page, EMPLOYEE.name);
+3 -1
查看文件
@@ -1,7 +1,7 @@
# 领域事件清单(P19 / P19+IM-1 / P19+ERP-3 / P19+AI-1 / P20+TRAVEL-1 / P20+TRAVEL-2 / P20+TRAVEL-3) # 领域事件清单(P19 / P19+IM-1 / P19+ERP-3 / P19+AI-1 / P20+TRAVEL-1 / P20+TRAVEL-2 / P20+TRAVEL-3)
**文档状态**:技术参考和历史证据,不是当前 MVP 需求事实源。 **文档状态**:技术参考和历史证据,不是当前 MVP 需求事实源。
**快照日期**:2026-03-10 **快照日期**:2026-08-01
**目标**:冻结 `hyb-oa` 当前采购、资产与差旅域的标准领域事件名,并补充 `P19+IM-1` 已实际消费的 IM 通知类型清单,以及 `P19+ERP-1 / ERP-3 / AI-1 / TRAVEL-1` 已实际落地的消费映射,作为后续 ERP / IM / AI / TRAVEL 适配的事实源。 **目标**:冻结 `hyb-oa` 当前采购、资产与差旅域的标准领域事件名,并补充 `P19+IM-1` 已实际消费的 IM 通知类型清单,以及 `P19+ERP-1 / ERP-3 / AI-1 / TRAVEL-1` 已实际落地的消费映射,作为后续 ERP / IM / AI / TRAVEL 适配的事实源。
**不得误读**:本文用于追溯 Outbox、IM、ERP、AI、TRAVEL 等历史集成边界;当前 10 人以下公司 MVP 范围以 [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)、模块规约和最新 `issues/*.csv` 为准。 **不得误读**:本文用于追溯 Outbox、IM、ERP、AI、TRAVEL 等历史集成边界;当前 10 人以下公司 MVP 范围以 [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)、模块规约和最新 `issues/*.csv` 为准。
@@ -85,6 +85,8 @@
| `reimbursement.rejected` | 报销驳回 | `reimbursement_form` | `/reimbursements/:id` | 结果通知 | | `reimbursement.rejected` | 报销驳回 | `reimbursement_form` | `/reimbursements/:id` | 结果通知 |
| `reimbursement.returned` | 报销退回 | `reimbursement_form` | `/reimbursements/:id` | 结果通知 | | `reimbursement.returned` | 报销退回 | `reimbursement_form` | `/reimbursements/:id` | 结果通知 |
| `reimbursement.paid` | 报销付款完成 | `reimbursement_form` | `/reimbursements/:id` | 财务结果通知 | | `reimbursement.paid` | 报销付款完成 | `reimbursement_form` | `/reimbursements/:id` | 财务结果通知 |
| `payment_batch.review_requested` | 报销付款批次创建,责任转给非制单复核人 | `payment_batch` | `/payment-batches?id=<id>` | 待办通知;复用 `reimbursement` 通知域 |
| `payment_batch.execution_requested` | 付款复核通过,责任转回实际付款人 | `payment_batch` | `/payment-batches?id=<id>` | 待办通知;优先通知仍可付款的制单人 |
| `leave.approved` | 请假审批通过 | `leave_request` | `/leave-requests` | 结果通知 | | `leave.approved` | 请假审批通过 | `leave_request` | `/leave-requests` | 结果通知 |
| `leave.rejected` | 请假驳回 | `leave_request` | `/leave-requests` | 结果通知 | | `leave.rejected` | 请假驳回 | `leave_request` | `/leave-requests` | 结果通知 |
| `purchase_request.approved` | 采购审批通过 | `purchase_request` | `/purchase-requests` | 结果通知 | | `purchase_request.approved` | 采购审批通过 | `purchase_request` | `/purchase-requests` | 结果通知 |
@@ -17,6 +17,8 @@ const NOTIFICATION_TYPES = Object.freeze({
reimbursementRejected: 'reimbursement.rejected', reimbursementRejected: 'reimbursement.rejected',
reimbursementReturned: 'reimbursement.returned', reimbursementReturned: 'reimbursement.returned',
reimbursementPaid: 'reimbursement.paid', reimbursementPaid: 'reimbursement.paid',
paymentBatchReviewRequested: 'payment_batch.review_requested',
paymentBatchExecutionRequested: 'payment_batch.execution_requested',
leaveApproved: 'leave.approved', leaveApproved: 'leave.approved',
leaveRejected: 'leave.rejected', leaveRejected: 'leave.rejected',
attendanceCorrectionApproved: 'attendance_correction.approved', attendanceCorrectionApproved: 'attendance_correction.approved',
@@ -39,6 +41,8 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
NOTIFICATION_TYPES.reimbursementRejected, NOTIFICATION_TYPES.reimbursementRejected,
NOTIFICATION_TYPES.reimbursementReturned, NOTIFICATION_TYPES.reimbursementReturned,
NOTIFICATION_TYPES.reimbursementPaid, NOTIFICATION_TYPES.reimbursementPaid,
NOTIFICATION_TYPES.paymentBatchReviewRequested,
NOTIFICATION_TYPES.paymentBatchExecutionRequested,
NOTIFICATION_TYPES.leaveApproved, NOTIFICATION_TYPES.leaveApproved,
NOTIFICATION_TYPES.leaveRejected, NOTIFICATION_TYPES.leaveRejected,
NOTIFICATION_TYPES.attendanceCorrectionApproved, NOTIFICATION_TYPES.attendanceCorrectionApproved,
@@ -61,6 +65,8 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
[NOTIFICATION_TYPES.reimbursementRejected]: NOTIFICATION_DOMAIN_SCOPES.reimbursement, [NOTIFICATION_TYPES.reimbursementRejected]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.reimbursementReturned]: NOTIFICATION_DOMAIN_SCOPES.reimbursement, [NOTIFICATION_TYPES.reimbursementReturned]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.reimbursementPaid]: NOTIFICATION_DOMAIN_SCOPES.reimbursement, [NOTIFICATION_TYPES.reimbursementPaid]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.paymentBatchReviewRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.paymentBatchExecutionRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.leaveApproved]: NOTIFICATION_DOMAIN_SCOPES.leave, [NOTIFICATION_TYPES.leaveApproved]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave, [NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.attendanceCorrectionApproved]: NOTIFICATION_DOMAIN_SCOPES.attendance, [NOTIFICATION_TYPES.attendanceCorrectionApproved]: NOTIFICATION_DOMAIN_SCOPES.attendance,
+150 -4
查看文件
@@ -11,6 +11,7 @@ const { parseDateOnlyString } = require("../utils/date");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { createNotificationsWithRecords } = require("../services/notifications"); const { createNotificationsWithRecords } = require("../services/notifications");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
const { sendNotificationEmailSafe } = require("../services/email"); const { sendNotificationEmailSafe } = require("../services/email");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
@@ -247,6 +248,118 @@ function ensureBatchReviewPermission(fastify, request) {
} }
} }
function roleCodesOf(employee) {
return (employee?.roles ?? []).map((role) => role.role_code).filter(Boolean);
}
function employeeHasRolePermission(employee, permissionCode) {
return hasPermission(resolvePermissionsForRoles(roleCodesOf(employee)), permissionCode);
}
async function resolvePaymentReviewRecipientIds(tx, makerId) {
const candidates = await tx.employee.findMany({
where: {
status: "active",
id: { not: makerId },
roles: { some: { role_code: { in: ["manager", "ceo"] } } },
},
orderBy: [{ employee_no: "asc" }, { id: "asc" }],
select: {
id: true,
roles: { select: { role_code: true } },
},
});
return candidates
.filter((employee) => employeeHasRolePermission(employee, "payment.batch.review"))
.map((employee) => employee.id);
}
async function resolvePaymentExecutionRecipientIds(tx, { makerId, reviewerId }) {
const candidates = await tx.employee.findMany({
where: {
status: "active",
id: { not: reviewerId },
OR: [
{ id: makerId },
{ roles: { some: { role_code: { in: ["finance", "finance_mgr"] } } } },
],
},
orderBy: [{ employee_no: "asc" }, { id: "asc" }],
select: {
id: true,
roles: { select: { role_code: true } },
},
});
const eligible = candidates.filter((employee) =>
employee.id !== reviewerId && employeeHasRolePermission(employee, "payment.batch.manage"),
);
const activeMaker = eligible.find((employee) => employee.id === makerId);
if (activeMaker) {
return [activeMaker.id];
}
return eligible
.filter((employee) => roleCodesOf(employee).some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)))
.map((employee) => employee.id);
}
async function createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type,
title,
body,
batchId,
actorId,
}) {
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds,
type,
title,
body,
targetType: "payment_batch",
targetId: batchId,
});
return {
notifications,
type,
title,
body,
targetType: "payment_batch",
targetId: batchId,
targetPath: `/payment-batches?id=${encodeURIComponent(batchId)}`,
actorId,
};
}
async function dispatchPaymentResponsibilityNotification(fastify, request, task) {
if (!task?.notifications?.length) {
return;
}
try {
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: [task],
});
} catch (error) {
fastify.log.error(
{
err: error,
requestId: request.id,
notificationType: task.type,
targetType: task.targetType,
targetId: task.targetId,
},
"Failed to dispatch payment responsibility notification",
);
}
}
function buildBatchNo() { function buildBatchNo() {
const now = new Date(); const now = new Date();
const date = now.toISOString().slice(0, 10).replace(/-/g, ""); const date = now.toISOString().slice(0, 10).replace(/-/g, "");
@@ -1057,7 +1170,7 @@ async function paymentBatchesRoutes(fastify) {
} }
const entityIds = Array.from(new Set(body.entityIds)).sort(); const entityIds = Array.from(new Set(body.entityIds)).sort();
const created = await fastify.prisma.$transaction(async (tx) => { const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
await lockPaymentEntities(tx, body.entityType, entityIds); await lockPaymentEntities(tx, body.entityType, entityIds);
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds); const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds);
if (snapshots.length !== entityIds.length) { if (snapshots.length !== entityIds.length) {
@@ -1130,9 +1243,24 @@ async function paymentBatchesRoutes(fastify) {
}), }),
}); });
return loadBatchWithItems(tx, batch.id); const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id);
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.paymentBatchReviewRequested,
title: "待付款复核",
body: "付款安排已创建,请完成付款复核。",
batchId: batch.id,
actorId: request.employee.id,
});
return {
batch: await loadBatchWithItems(tx, batch.id),
notificationTask: nextNotificationTask,
};
}); });
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } }); return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } });
}); });
@@ -1590,7 +1718,7 @@ async function paymentBatchesRoutes(fastify) {
const batchId = parseId(request.params?.id); const batchId = parseId(request.params?.id);
const body = parseBody(REVIEW_SCHEMA, request.body); const body = parseBody(REVIEW_SCHEMA, request.body);
const result = await fastify.prisma.$transaction(async (tx) => { const { batch: result, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId); const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
const batchRisk = summarizeBatchRisk(batch); const batchRisk = summarizeBatchRisk(batch);
@@ -1659,9 +1787,27 @@ async function paymentBatchesRoutes(fastify) {
}), }),
}); });
return reviewed; const recipientEmployeeIds = await resolvePaymentExecutionRecipientIds(tx, {
makerId: batch.created_by,
reviewerId: request.employee.id,
});
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.paymentBatchExecutionRequested,
title: "待完成付款",
body: "付款复核已通过,请完成付款并登记回单。",
batchId,
actorId: request.employee.id,
});
return {
batch: reviewed,
notificationTask: nextNotificationTask,
};
}); });
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return sendData(reply, request, { item: mapBatch(result, []) }); return sendData(reply, request, { item: mapBatch(result, []) });
}); });
+16 -13
查看文件
@@ -333,8 +333,8 @@ const PROCESSING_STAGE_DEFINITIONS = Object.freeze([
{ key: "submitted", label: "申请提交" }, { key: "submitted", label: "申请提交" },
{ key: "manager_approval", label: "主管审批" }, { key: "manager_approval", label: "主管审批" },
{ key: "finance_review", label: "财务复核" }, { key: "finance_review", label: "财务复核" },
{ key: "payment_batch_review", label: "付款批次复核" }, { key: "payment_batch_review", label: "付款复核" },
{ key: "payment_and_receipt", label: "付款与回单" }, { key: "payment_and_receipt", label: "付款完成" },
]); ]);
function isoDateTimeOrNull(value) { function isoDateTimeOrNull(value) {
@@ -511,31 +511,34 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
if (!batch) { if (!batch) {
Object.assign(batchReviewStage, reimbursement.status === "approved" Object.assign(batchReviewStage, reimbursement.status === "approved"
? { state: "current", result: "待建批次", detail: "待财务创建付款批次" } ? { state: "current", result: "待安排", detail: "待财务安排付款" }
: { detail: "待财务创建付款批次" }); : { detail: "待财务安排付款" });
paymentStage.detail = "待付款批次复核后处理"; paymentStage.detail = "待付款复核后由财务登记,非审批环节";
} else if (batch.status === "draft") { } else if (batch.status === "draft") {
Object.assign(batchReviewStage, { Object.assign(batchReviewStage, {
state: "current", state: "current",
result: "待复核", result: "待复核",
detail: "所在付款批次待非制单人复核", detail: "待非制单人复核付款",
targetHref: batchTargetHref,
});
Object.assign(paymentStage, {
detail: "待付款复核通过后由财务登记,非审批环节",
targetHref: batchTargetHref, targetHref: batchTargetHref,
}); });
Object.assign(paymentStage, { detail: "待所在付款批次复核通过", targetHref: batchTargetHref });
} else if (["reviewed", "executed", "failed"].includes(batch.status)) { } else if (["reviewed", "executed", "failed"].includes(batch.status)) {
Object.assign(batchReviewStage, { Object.assign(batchReviewStage, {
state: "completed", state: "completed",
actorName: paymentActorNames.get(batch.reviewed_by) ?? null, actorName: paymentActorNames.get(batch.reviewed_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.reviewed_at), occurredAt: isoDateTimeOrNull(batch.reviewed_at),
result: "通过", result: "通过",
detail: batch.status === "reviewed" ? "所在付款批次已复核,待执行付款" : null, detail: batch.status === "reviewed" ? "付款复核已通过" : null,
targetHref: batchTargetHref, targetHref: batchTargetHref,
}); });
if (batch.status === "reviewed") { if (batch.status === "reviewed") {
Object.assign(paymentStage, { Object.assign(paymentStage, {
state: "current", state: "current",
result: "待付款", result: "待付款",
detail: "所在付款批次已复核,待执行付款", detail: "待财务完成付款并登记回单,非审批环节",
targetHref: batchTargetHref, targetHref: batchTargetHref,
}); });
} else if (batch.status === "failed") { } else if (batch.status === "failed") {
@@ -544,7 +547,7 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
actorName: paymentActorNames.get(batch.executed_by) ?? null, actorName: paymentActorNames.get(batch.executed_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.executed_at), occurredAt: isoDateTimeOrNull(batch.executed_at),
result: "付款失败", result: "付款失败",
detail: "所在付款批次执行失败,待财务重新处理", detail: "付款失败,待财务重新处理,非审批环节",
targetHref: batchTargetHref, targetHref: batchTargetHref,
}); });
} }
@@ -555,11 +558,11 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
occurredAt: isoDateTimeOrNull(batch.cancelled_at), occurredAt: isoDateTimeOrNull(batch.cancelled_at),
result: "批次已取消", result: "批次已取消",
comment: batch.cancelled_reason ?? null, comment: batch.cancelled_reason ?? null,
detail: "所在付款批次已取消,待财务重新创建", detail: "付款安排已取消,待财务重新处理",
targetHref: batchTargetHref, targetHref: batchTargetHref,
}); });
Object.assign(paymentStage, { Object.assign(paymentStage, {
detail: "待财务重新创建付款批次", detail: "待财务重新安排付款,非审批环节",
targetHref: batchTargetHref, targetHref: batchTargetHref,
}); });
} }
@@ -571,7 +574,7 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
actorName: reimbursement.paid_by_employee?.name ?? paymentActorNames.get(batch?.executed_by) ?? null, actorName: reimbursement.paid_by_employee?.name ?? paymentActorNames.get(batch?.executed_by) ?? null,
occurredAt: isoDateTimeOrNull(reimbursement.paid_at ?? batch?.executed_at), occurredAt: isoDateTimeOrNull(reimbursement.paid_at ?? batch?.executed_at),
result: "已付款", result: "已付款",
detail: receiptCount > 0 ? "付款与回单已留存" : "付款已完成,回单待留存", detail: receiptCount > 0 ? "付款和回单已留存" : "付款已完成,回单待留存",
targetHref: batchTargetHref, targetHref: batchTargetHref,
}); });
} }
@@ -20,4 +20,21 @@ describe("notification types", () => {
NOTIFICATION_DOMAIN_SCOPES.workflow, NOTIFICATION_DOMAIN_SCOPES.workflow,
); );
}); });
it("routes payment responsibility notifications through the reimbursement IM domain", () => {
expect(IM_SUPPORTED_NOTIFICATION_TYPES).toEqual(
expect.arrayContaining([
NOTIFICATION_TYPES.paymentBatchReviewRequested,
NOTIFICATION_TYPES.paymentBatchExecutionRequested,
]),
);
expect(resolveNotificationDomainScope(
NOTIFICATION_TYPES.paymentBatchReviewRequested,
"payment_batch",
)).toBe(NOTIFICATION_DOMAIN_SCOPES.reimbursement);
expect(resolveNotificationDomainScope(
NOTIFICATION_TYPES.paymentBatchExecutionRequested,
"payment_batch",
)).toBe(NOTIFICATION_DOMAIN_SCOPES.reimbursement);
});
}); });
@@ -211,6 +211,10 @@ function buildPrisma(options = {}) {
}, },
receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments, receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments,
reservedEntityIds: options.reservedEntityIds ?? [], reservedEntityIds: options.reservedEntityIds ?? [],
employees: options.employees ?? [
{ id: MAKER_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
],
invoice: { invoice: {
linked_reimbursement_id: REIMBURSEMENT_ID, linked_reimbursement_id: REIMBURSEMENT_ID,
status: "bound", status: "bound",
@@ -421,6 +425,9 @@ function buildPrisma(options = {}) {
auditLog: { auditLog: {
create: vi.fn(async () => ({ id: "audit-1" })), create: vi.fn(async () => ({ id: "audit-1" })),
}, },
employee: {
findMany: vi.fn(async () => state.employees.map((employee) => ({ ...employee }))),
},
$queryRaw: vi.fn(async () => [{ id: BATCH_ID }]), $queryRaw: vi.fn(async () => [{ id: BATCH_ID }]),
}; };
@@ -657,6 +664,33 @@ describe("payment batch reimbursement routes", () => {
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(response.json().error.details.review).toBe("maker_checker_conflict"); expect(response.json().error.details.review).toBe("maker_checker_conflict");
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
await app.close();
});
it("rejects repeated payment review without creating another responsibility notification", async () => {
const { app } = await buildApp({
actor: {
id: REVIEWER_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.review"],
roleCodes: ["manager"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/review`,
headers: { authorization: "Bearer test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.review).toBe("batch_not_reviewable");
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
await app.close(); await app.close();
}); });
@@ -756,6 +790,9 @@ describe("payment batch reimbursement routes", () => {
}); });
it("allows one manager review for a high-amount batch and does not require a second reviewer", async () => { it("allows one manager review for a high-amount batch and does not require a second reviewer", async () => {
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-execute-1", recipient_employee_id: MAKER_ID },
]);
const { app } = await buildApp({ const { app } = await buildApp({
actor: { actor: {
id: REVIEWER_ID, id: REVIEWER_ID,
@@ -783,7 +820,7 @@ describe("payment batch reimbursement routes", () => {
payload: { comment: "已核对高额付款对象和金额" }, payload: { comment: "已核对高额付款对象和金额" },
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode, response.body).toBe(200);
expect(response.json().data.item).toMatchObject({ expect(response.json().data.item).toMatchObject({
status: "reviewed", status: "reviewed",
highRisk: true, highRisk: true,
@@ -794,6 +831,65 @@ describe("payment batch reimbursement routes", () => {
reviewerPermission: "payment.batch.review", reviewerPermission: "payment.batch.review",
}, },
}); });
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
recipientEmployeeIds: [MAKER_ID],
type: "payment_batch.execution_requested",
targetType: "payment_batch",
targetId: BATCH_ID,
}),
);
expect(flushNotificationDispatchTasksMock).toHaveBeenCalledWith(
expect.objectContaining({
tasks: [expect.objectContaining({
type: "payment_batch.execution_requested",
targetPath: `/payment-batches?id=${BATCH_ID}`,
})],
}),
);
await app.close();
});
it("falls back to another active finance payment manager after review when the maker is unavailable", async () => {
const fallbackFinanceId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-execute-fallback", recipient_employee_id: fallbackFinanceId },
]);
const { app } = await buildApp({
actor: {
id: REVIEWER_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.review"],
roleCodes: ["manager"],
},
batch: {
status: "draft",
reviewed_at: null,
reviewed_by: null,
},
employees: [
{ id: fallbackFinanceId, employee_no: "FIN002", status: "active", roles: [{ role_code: "finance" }] },
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
],
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/review`,
headers: { authorization: "Bearer test" },
payload: {},
});
expect(response.statusCode, response.body).toBe(200);
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
recipientEmployeeIds: [fallbackFinanceId],
type: "payment_batch.execution_requested",
}),
);
await app.close(); await app.close();
}); });
@@ -898,6 +994,98 @@ describe("payment batch reimbursement routes", () => {
await app.close(); await app.close();
}); });
it("notifies active non-maker payment reviewers after creating a reimbursement batch", async () => {
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-review-1", recipient_employee_id: REVIEWER_ID },
]);
const { app, tx } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
name: "本周付款",
entityIds: [REIMBURSEMENT_ID],
},
});
expect(response.statusCode).toBe(201);
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
tx,
expect.objectContaining({
recipientEmployeeIds: [REVIEWER_ID],
type: "payment_batch.review_requested",
targetType: "payment_batch",
targetId: BATCH_ID,
}),
);
expect(flushNotificationDispatchTasksMock).toHaveBeenCalledWith(
expect.objectContaining({
tasks: [expect.objectContaining({
type: "payment_batch.review_requested",
targetPath: `/payment-batches?id=${BATCH_ID}`,
})],
}),
);
await app.close();
});
it("creates no payment review notification when no eligible non-maker reviewer exists", async () => {
const { app } = await buildApp({
employees: [
{ id: ACTOR_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
],
});
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
name: "无人复核批次",
entityIds: [REIMBURSEMENT_ID],
},
});
expect(response.statusCode).toBe(201);
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ recipientEmployeeIds: [] }),
);
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
await app.close();
});
it("keeps a created batch when payment review notification delivery fails", async () => {
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-review-failed", recipient_employee_id: REVIEWER_ID },
]);
flushNotificationDispatchTasksMock.mockRejectedValueOnce(new Error("synthetic dispatch failure"));
const { app, state } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
name: "投递失败不回滚",
entityIds: [REIMBURSEMENT_ID],
},
});
expect(response.statusCode).toBe(201);
expect(state.batch.status).toBe("draft");
expect(state.batch.items).toHaveLength(1);
await app.close();
});
it("adds a non-identifying random suffix to concurrent-style batch numbers", async () => { it("adds a non-identifying random suffix to concurrent-style batch numbers", async () => {
const { app, tx } = await buildApp(); const { app, tx } = await buildApp();
const payload = { const payload = {
@@ -322,7 +322,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }), expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }),
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", 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: "approval_finance-task", label: "财务审批", state: "current", actorName: "财务专员", result: "处理中" }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务创建付款批次" }), expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务安排付款" }),
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }), expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]); ]);
@@ -598,11 +598,11 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
}); });
it.each([ it.each([
["draft", "current", "pending", "待所在付款批次复核通过"], ["draft", "current", "pending", "待付款复核通过后由财务登记,非审批环节"],
["reviewed", "completed", "current", "所在付款批次已复核,待执行付款"], ["reviewed", "completed", "current", "待财务完成付款并登记回单,非审批环节"],
["executed", "completed", "completed", "付款与回单已留存"], ["executed", "completed", "completed", "付款和回单已留存"],
["failed", "completed", "exception", "所在付款批次执行失败,待财务重新处理"], ["failed", "completed", "exception", "付款失败,待财务重新处理,非审批环节"],
["cancelled", "exception", "pending", "待财务重新创建付款批次"], ["cancelled", "exception", "pending", "待财务重新安排付款,非审批环节"],
])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => { ])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => {
const paymentBatchItem = { const paymentBatchItem = {
id: "batch-item-1", id: "batch-item-1",
@@ -670,7 +670,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[3]).toMatchObject({ expect(response.json().data.processingProgress.stages[3]).toMatchObject({
detail: "所在付款批次待非制单人复核", detail: "待非制单人复核付款",
actorName: null, actorName: null,
targetHref: "/payment-batches?id=batch-1", targetHref: "/payment-batches?id=batch-1",
}); });
@@ -691,8 +691,8 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
expect(response.json().data.processingProgress.stages[3]).toMatchObject({ expect(response.json().data.processingProgress.stages[3]).toMatchObject({
state: "current", state: "current",
actorName: null, actorName: null,
result: "待建批次", result: "待安排",
detail: "待财务创建付款批次", detail: "待财务安排付款",
}); });
await app.close(); await app.close();