From aa6b9bc373f423a7177b7d009d75b608fd70e1d4 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 1 Aug 2026 21:06:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8A=A5=E9=94=80=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=B8=8E=E4=BB=98=E6=AC=BE=E8=BF=9B=E5=BA=A6=EF=BC=88?= =?UTF-8?q?08-01-reimbursement-notification-progress-clarity=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/approval-workflow.md | 116 ++++++++++- .../check.jsonl | 1 + .../design.md | 56 ++++++ .../implement.jsonl | 1 + .../implement.md | 49 +++++ .../prd.md | 61 ++++++ .../task.json | 26 +++ client/src/lib/notificationLinks.ts | 8 + client/src/pages/MobileApprovalsPage.tsx | 23 +-- client/src/pages/MobileNotificationsPage.tsx | 3 +- client/src/pages/NotificationsPage.tsx | 2 +- client/src/pages/WorkflowTaskDetailPage.tsx | 23 +-- .../e2e/dg7-mobile-information-dedupe.spec.ts | 13 +- .../e2e/pc-detail-mobile-visual-wave3.spec.ts | 9 +- .../r111-invoice-reimbursement-guard.spec.ts | 4 +- .../reimbursement-workflow-progress.spec.ts | 5 +- docs/领域事件清单.md | 4 +- server/src/constants/notificationTypes.js | 6 + server/src/routes/paymentBatches.js | 154 +++++++++++++- server/src/routes/reimbursements.js | 29 +-- .../unit/constants/notificationTypes.test.js | 17 ++ .../paymentBatches.reimbursement.test.js | 190 +++++++++++++++++- .../unit/routes/reimbursements.detail.test.js | 18 +- 23 files changed, 730 insertions(+), 88 deletions(-) create mode 100644 .trellis/tasks/08-01-reimbursement-notification-progress-clarity/check.jsonl create mode 100644 .trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md create mode 100644 .trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.jsonl create mode 100644 .trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md create mode 100644 .trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md create mode 100644 .trellis/tasks/08-01-reimbursement-notification-progress-clarity/task.json diff --git a/.trellis/spec/hyb-oa/approval-workflow.md b/.trellis/spec/hyb-oa/approval-workflow.md index 2de92646..3870e0e9 100644 --- a/.trellis/spec/hyb-oa/approval-workflow.md +++ b/.trellis/spec/hyb-oa/approval-workflow.md @@ -138,9 +138,12 @@ if (!hasPermission(actor, "payment.batch.review") 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. + current stage with `detail="待财务安排付款"`. A draft batch says + `待非制单人复核付款`; employee-facing wording does not expose the internal + 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 known stages remain visible; missing downstream objects are normal, not 5xx. - `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, completed or exception states without changing the batch. - Executed payment + zero active receipts -> completed payment with - `付款已完成,回单待留存`; positive count -> `付款与回单已留存`. + `付款已完成,回单待留存`; 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 @@ -171,7 +174,7 @@ if (!hasPermission(actor, "payment.batch.review") ### 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. - Base: a newly submitted reimbursement shows known submission, every real 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)); 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=` and + `targetPath=/payment-batches?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)] }); +``` diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/check.jsonl b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md new file mode 100644 index 00000000..dbf0ca0d --- /dev/null +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md @@ -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 张真实单后的用户访谈是重新立项条件,不是当前发布门禁。 diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.jsonl b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md new file mode 100644 index 00000000..f8b8130e --- /dev/null +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md @@ -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 张真实报销后触发一次访谈,决定财务复核与加入批次是否值得另立合并任务(未来触发条件,当前尚未发生)。 diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md new file mode 100644 index 00000000..d1f72357 --- /dev/null +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md @@ -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`。 +- 生产只读诊断和任何后续真机通知验证分开记录;没有实际企微到达证据时不得宣称通知已恢复。 diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/task.json b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/task.json new file mode 100644 index 00000000..61371fdb --- /dev/null +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/task.json @@ -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": {} +} \ No newline at end of file diff --git a/client/src/lib/notificationLinks.ts b/client/src/lib/notificationLinks.ts index 2c19b81f..17c57e15 100644 --- a/client/src/lib/notificationLinks.ts +++ b/client/src/lib/notificationLinks.ts @@ -18,6 +18,10 @@ export function resolveNotificationTargetLink(item: NotificationTarget) { return `/reimbursements/${item.targetId}`; } + if (item.targetType === "payment_batch") { + return `/payment-batches?id=${encodeSegment(item.targetId)}`; + } + if (item.targetType === "leave_request") { return `/leave-requests?highlight=${item.targetId}`; } @@ -60,6 +64,10 @@ export function resolveMobileNotificationTargetLink(item: NotificationTarget) { return `/mobile/reimbursements/${encodeSegment(item.targetId)}`; } + if (item.targetType === "payment_batch") { + return `/payment-batches?id=${encodeSegment(item.targetId)}`; + } + if (item.targetType === "leave_request") { return `/mobile/leave/${encodeSegment(item.targetId)}`; } diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index e163d755..fcaccf4d 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -31,7 +31,7 @@ import { Spinner } from "../components/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { Textarea } from "../components/ui/textarea"; 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 { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { cn } from "../lib/utils"; @@ -150,27 +150,6 @@ function workflowStatusLabel(status: string) { return status; } -function workflowActionLabel(action: string | null | undefined) { - if (!action) { - return "-"; - } - - const labels: Record = { - submit: "提交", - approve: "通过", - reject: "驳回", - return: "退回", - transfer: "转审", - withdraw: "撤回", - remind: "催办", - overdue_remind: "超时提醒", - cancel: "取消", - skip: "跳过", - }; - - return labels[action] ?? action; -} - function businessStatusLabel(status: string | null | undefined) { const labels: Record = { draft: "草稿", diff --git a/client/src/pages/MobileNotificationsPage.tsx b/client/src/pages/MobileNotificationsPage.tsx index 1005230c..df9a014b 100644 --- a/client/src/pages/MobileNotificationsPage.tsx +++ b/client/src/pages/MobileNotificationsPage.tsx @@ -17,7 +17,7 @@ const PAGE_SIZE = 20; const NOTIFICATION_TYPE_OPTIONS = [ { label: "全部", value: "" }, { 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: "purchase_request.approved,purchase_request.rejected" }, { label: "差旅", value: "travel_request.approved,travel_request.rejected" }, @@ -39,6 +39,7 @@ function formatTime(value: string) { function notificationTypeLabel(type: string) { if (type.startsWith("workflow.")) return "审批事件"; if (type.startsWith("reimbursement.")) return "报销事件"; + if (type.startsWith("payment_batch.")) return "付款事件"; if (type.startsWith("leave.")) return "请假事件"; if (type.startsWith("purchase_request.")) return "采购事件"; if (type.startsWith("travel_request.")) return "差旅事件"; diff --git a/client/src/pages/NotificationsPage.tsx b/client/src/pages/NotificationsPage.tsx index 48201414..638d3f7c 100644 --- a/client/src/pages/NotificationsPage.tsx +++ b/client/src/pages/NotificationsPage.tsx @@ -17,7 +17,7 @@ const PAGE_SIZE = 20; const NOTIFICATION_TYPE_OPTIONS = [ { label: "全部", value: "" }, { 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: "purchase_request.approved,purchase_request.rejected" }, { label: "差旅", value: "travel_request.approved,travel_request.rejected" }, diff --git a/client/src/pages/WorkflowTaskDetailPage.tsx b/client/src/pages/WorkflowTaskDetailPage.tsx index 283480e2..c0c8fdbe 100644 --- a/client/src/pages/WorkflowTaskDetailPage.tsx +++ b/client/src/pages/WorkflowTaskDetailPage.tsx @@ -32,7 +32,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Button } from "../components/ui/button"; import { Textarea } from "../components/ui/textarea"; -import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay"; +import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay"; import { formatDisplayDateTime } from "../lib/displayFormat"; import { buildPcDetailSource, @@ -85,27 +85,6 @@ function workflowInstanceStatusLabel(status: string | undefined | null) { return labels[status] ?? status; } -function workflowActionLabel(action: string | undefined | null) { - if (!action) { - return "-"; - } - - const labels: Record = { - submit: "提交", - approve: "通过", - reject: "驳回", - return: "退回", - transfer: "转审", - withdraw: "撤回", - remind: "催办", - overdue_remind: "超时提醒", - cancel: "取消", - skip: "跳过", - }; - - return labels[action] ?? action; -} - function DetailField({ label, value }: { label: string; value: ReactNode }) { return (
diff --git a/client/tests/e2e/dg7-mobile-information-dedupe.spec.ts b/client/tests/e2e/dg7-mobile-information-dedupe.spec.ts index 63e66c69..fae0b5ff 100644 --- a/client/tests/e2e/dg7-mobile-information-dedupe.spec.ts +++ b/client/tests/e2e/dg7-mobile-information-dedupe.spec.ts @@ -1,6 +1,10 @@ import { mkdirSync } from "node:fs"; import path from "node:path"; 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 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("付款责任通知在 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("首页常用入口不重复底部 Tab,员工进度页按状态分标签", async ({ browser }) => { @@ -1108,7 +1119,7 @@ test.describe("MIF7 移动信息架构整改", () => { [ "mobile-reimbursement-items", "mobile-reimbursement-attachments", - "mobile-reimbursement-approval-history", + "mobile-reimbursement-processing-progress", ].map((testId) => document.querySelector(`[data-testid='${testId}']`)?.getBoundingClientRect().top ?? -1), ); expect(sectionOrder[0]).toBeLessThan(sectionOrder[1]); diff --git a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts index c92c3799..62d03dc8 100644 --- a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts +++ b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts @@ -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: "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 }, + { 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: [ @@ -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(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")).not.toContainText(/第 \d+ 步/); await page.goto("/workflow/tasks/task-wave3"); 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.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(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.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0); await expectNoHorizontalOverflow(page); diff --git a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts index 267691e5..158a9cd7 100644 --- a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts +++ b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts @@ -203,8 +203,8 @@ function buildReimbursementDetail(state: ReturnType` | 待办通知;复用 `reimbursement` 通知域 | +| `payment_batch.execution_requested` | 付款复核通过,责任转回实际付款人 | `payment_batch` | `/payment-batches?id=` | 待办通知;优先通知仍可付款的制单人 | | `leave.approved` | 请假审批通过 | `leave_request` | `/leave-requests` | 结果通知 | | `leave.rejected` | 请假驳回 | `leave_request` | `/leave-requests` | 结果通知 | | `purchase_request.approved` | 采购审批通过 | `purchase_request` | `/purchase-requests` | 结果通知 | diff --git a/server/src/constants/notificationTypes.js b/server/src/constants/notificationTypes.js index dbd8081a..d100bc8c 100644 --- a/server/src/constants/notificationTypes.js +++ b/server/src/constants/notificationTypes.js @@ -17,6 +17,8 @@ const NOTIFICATION_TYPES = Object.freeze({ reimbursementRejected: 'reimbursement.rejected', reimbursementReturned: 'reimbursement.returned', reimbursementPaid: 'reimbursement.paid', + paymentBatchReviewRequested: 'payment_batch.review_requested', + paymentBatchExecutionRequested: 'payment_batch.execution_requested', leaveApproved: 'leave.approved', leaveRejected: 'leave.rejected', attendanceCorrectionApproved: 'attendance_correction.approved', @@ -39,6 +41,8 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([ NOTIFICATION_TYPES.reimbursementRejected, NOTIFICATION_TYPES.reimbursementReturned, NOTIFICATION_TYPES.reimbursementPaid, + NOTIFICATION_TYPES.paymentBatchReviewRequested, + NOTIFICATION_TYPES.paymentBatchExecutionRequested, NOTIFICATION_TYPES.leaveApproved, NOTIFICATION_TYPES.leaveRejected, NOTIFICATION_TYPES.attendanceCorrectionApproved, @@ -61,6 +65,8 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({ [NOTIFICATION_TYPES.reimbursementRejected]: NOTIFICATION_DOMAIN_SCOPES.reimbursement, [NOTIFICATION_TYPES.reimbursementReturned]: 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.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave, [NOTIFICATION_TYPES.attendanceCorrectionApproved]: NOTIFICATION_DOMAIN_SCOPES.attendance, diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index af9b6832..cbae1aa0 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -11,6 +11,7 @@ const { parseDateOnlyString } = require("../utils/date"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { createNotificationsWithRecords } = require("../services/notifications"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); +const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions"); const { sendNotificationEmailSafe } = require("../services/email"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); 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() { const now = new Date(); 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 created = await fastify.prisma.$transaction(async (tx) => { + const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => { await lockPaymentEntities(tx, body.entityType, entityIds); const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds); 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, []) } }); }); @@ -1590,7 +1718,7 @@ async function paymentBatchesRoutes(fastify) { const batchId = parseId(request.params?.id); 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); await ensureBatchSourceDepartmentsEnabled(fastify, tx, 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, []) }); }); diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 17933be6..2bb51d00 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -333,8 +333,8 @@ 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: "付款与回单" }, + { key: "payment_batch_review", label: "付款复核" }, + { key: "payment_and_receipt", label: "付款完成" }, ]); function isoDateTimeOrNull(value) { @@ -511,31 +511,34 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch if (!batch) { Object.assign(batchReviewStage, reimbursement.status === "approved" - ? { state: "current", result: "待建批次", detail: "待财务创建付款批次" } - : { detail: "待财务创建付款批次" }); - paymentStage.detail = "待付款批次复核后处理"; + ? { state: "current", result: "待安排", detail: "待财务安排付款" } + : { detail: "待财务安排付款" }); + paymentStage.detail = "待付款复核后由财务登记,非审批环节"; } else if (batch.status === "draft") { Object.assign(batchReviewStage, { state: "current", result: "待复核", - detail: "所在付款批次待非制单人复核", + detail: "待非制单人复核付款", + targetHref: batchTargetHref, + }); + Object.assign(paymentStage, { + 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, + detail: batch.status === "reviewed" ? "付款复核已通过" : null, targetHref: batchTargetHref, }); if (batch.status === "reviewed") { Object.assign(paymentStage, { state: "current", result: "待付款", - detail: "所在付款批次已复核,待执行付款", + detail: "待财务完成付款并登记回单,非审批环节", targetHref: batchTargetHref, }); } else if (batch.status === "failed") { @@ -544,7 +547,7 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch actorName: paymentActorNames.get(batch.executed_by) ?? null, occurredAt: isoDateTimeOrNull(batch.executed_at), result: "付款失败", - detail: "所在付款批次执行失败,待财务重新处理", + detail: "付款失败,待财务重新处理,非审批环节", targetHref: batchTargetHref, }); } @@ -555,11 +558,11 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch occurredAt: isoDateTimeOrNull(batch.cancelled_at), result: "批次已取消", comment: batch.cancelled_reason ?? null, - detail: "所在付款批次已取消,待财务重新创建", + detail: "付款安排已取消,待财务重新处理", targetHref: batchTargetHref, }); Object.assign(paymentStage, { - detail: "待财务重新创建付款批次", + detail: "待财务重新安排付款,非审批环节", targetHref: batchTargetHref, }); } @@ -571,7 +574,7 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch actorName: reimbursement.paid_by_employee?.name ?? paymentActorNames.get(batch?.executed_by) ?? null, occurredAt: isoDateTimeOrNull(reimbursement.paid_at ?? batch?.executed_at), result: "已付款", - detail: receiptCount > 0 ? "付款与回单已留存" : "付款已完成,回单待留存", + detail: receiptCount > 0 ? "付款和回单已留存" : "付款已完成,回单待留存", targetHref: batchTargetHref, }); } diff --git a/server/tests/unit/constants/notificationTypes.test.js b/server/tests/unit/constants/notificationTypes.test.js index af2832be..66ffd658 100644 --- a/server/tests/unit/constants/notificationTypes.test.js +++ b/server/tests/unit/constants/notificationTypes.test.js @@ -20,4 +20,21 @@ describe("notification types", () => { 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); + }); }); diff --git a/server/tests/unit/routes/paymentBatches.reimbursement.test.js b/server/tests/unit/routes/paymentBatches.reimbursement.test.js index 937af55e..a51eb826 100644 --- a/server/tests/unit/routes/paymentBatches.reimbursement.test.js +++ b/server/tests/unit/routes/paymentBatches.reimbursement.test.js @@ -211,6 +211,10 @@ function buildPrisma(options = {}) { }, receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments, 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: { linked_reimbursement_id: REIMBURSEMENT_ID, status: "bound", @@ -421,6 +425,9 @@ function buildPrisma(options = {}) { auditLog: { create: vi.fn(async () => ({ id: "audit-1" })), }, + employee: { + findMany: vi.fn(async () => state.employees.map((employee) => ({ ...employee }))), + }, $queryRaw: vi.fn(async () => [{ id: BATCH_ID }]), }; @@ -657,6 +664,33 @@ describe("payment batch reimbursement routes", () => { expect(response.statusCode).toBe(409); 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(); }); @@ -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 () => { + createNotificationsWithRecordsMock.mockResolvedValueOnce([ + { id: "notif-execute-1", recipient_employee_id: MAKER_ID }, + ]); const { app } = await buildApp({ actor: { id: REVIEWER_ID, @@ -783,7 +820,7 @@ describe("payment batch reimbursement routes", () => { payload: { comment: "已核对高额付款对象和金额" }, }); - expect(response.statusCode).toBe(200); + expect(response.statusCode, response.body).toBe(200); expect(response.json().data.item).toMatchObject({ status: "reviewed", highRisk: true, @@ -794,6 +831,65 @@ describe("payment batch reimbursement routes", () => { 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(); }); @@ -898,6 +994,98 @@ describe("payment batch reimbursement routes", () => { 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 () => { const { app, tx } = await buildApp(); const payload = { diff --git a/server/tests/unit/routes/reimbursements.detail.test.js b/server/tests/unit/routes/reimbursements.detail.test.js index 3bdf0282..89d58ca9 100644 --- a/server/tests/unit/routes/reimbursements.detail.test.js +++ b/server/tests/unit/routes/reimbursements.detail.test.js @@ -322,7 +322,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { 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_batch_review", state: "pending", actorName: null, detail: "待财务安排付款" }), expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }), ]); @@ -598,11 +598,11 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { }); it.each([ - ["draft", "current", "pending", "待所在付款批次复核通过"], - ["reviewed", "completed", "current", "所在付款批次已复核,待执行付款"], - ["executed", "completed", "completed", "付款与回单已留存"], - ["failed", "completed", "exception", "所在付款批次执行失败,待财务重新处理"], - ["cancelled", "exception", "pending", "待财务重新创建付款批次"], + ["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", @@ -670,7 +670,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => { }); expect(response.statusCode).toBe(200); expect(response.json().data.processingProgress.stages[3]).toMatchObject({ - detail: "所在付款批次待非制单人复核", + detail: "待非制单人复核付款", actorName: null, 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({ state: "current", actorName: null, - result: "待建批次", - detail: "待财务创建付款批次", + result: "待安排", + detail: "待财务安排付款", }); await app.close();