补齐审批退回结果通知 [09-04-approval-return-notification-gap]

这个提交包含在:
2026-09-04 16:22:27 +08:00
父节点 a6ffb7fc3c
当前提交 2e69fe4edb
共修改 19 个文件,包含 857 行新增和 14 行删除
+62
查看文件
@@ -550,6 +550,68 @@ reimbursement UI renders only declared capabilities
legacy reject endpoint and rejected history remain compatible but undiscoverable from the new UI legacy reject endpoint and rejected history remain compatible but undiscoverable from the new UI
``` ```
## Scenario: Return Outcome Notifications
### 1. Scope / Trigger
- Trigger: adding a workflow entity that supports return, or changing return outcome notifications, recipients, copy, deep links or IM routing.
- These are runtime notification projections of a completed return, not new public business-domain events or another workflow state transition.
### 2. Signatures
- Runtime types: `reimbursement.returned`, `leave.returned`, `purchase_request.returned` and `travel_request.returned`.
- Domain scopes: reimbursement -> `reimbursement`, leave -> `leave`, purchase request -> `procurement`, travel request -> `travel`.
- Attendance correction has no return action and therefore no returned notification type.
### 3. Contracts
- Every returned type must be registered together in `NOTIFICATION_TYPES`, `IM_SUPPORTED_NOTIFICATION_TYPES` and `NOTIFICATION_TYPE_TO_DOMAIN_SCOPE`. A type missing from any one registry is incomplete even if station notification creation still works.
- The workflow outcome mapping is the only entity-to-returned-type switch. Station notifications, email tasks and IM dispatch tasks consume that same projection; do not add channel-local entity branches.
- Return copy says the request was returned and can be modified and resubmitted. It must not reuse rejected wording while the business object is restored to `draft`.
- Reuse each entity's existing applicant/submitter resolution and deep link. Duplicate employee IDs produce one station notification and one recipient entry.
- Business state, workflow task/history and leave/budget release remain authoritative. Notification construction or dispatch failure is logged without sensitive provider payloads and never rolls back a completed return.
- Production channel routing is external configuration. Shipping support for a new type does not authorize adding it to a production channel allowlist; that write requires a separate approval and audited management-API update.
### 4. Validation & Error Matrix
- Missing explicit `allowedTypes` value or domain scope in a configured whitelist -> channel does not match; empty arrays retain the existing meaning of no restriction for that dimension.
- Station notification creation throws after the return state/history writes -> return still succeeds and commits; no email or IM work is fabricated from the failed projection.
- IM provider or Outbox dispatch fails after commit -> business return remains committed and delivery/outbox evidence follows the existing dispatcher contract.
- No natural production return sample -> code and routing readiness may pass, but real enterprise-WeChat delivery stays blocked rather than being inferred from synthetic tests.
### 5. Good/Base/Bad Cases
- Good: a returned purchase request creates one `purchase_request.returned` station notification and projects the same copy and target into email/IM work; the applicant sees that the draft can be modified and resubmitted.
- Base: production routing does not yet allow the new type, so the station notification exists while IM records a deterministic routing skip; the completed return remains unchanged.
- Bad: register a returned type only in `NOTIFICATION_TYPES`, write a channel-specific entity switch, reuse rejected copy, or let a provider failure change the workflow response.
### 6. Tests Required
- Table-driven registry test covering all three registries for the four supported returned types and proving the IM-supported registry has no duplicates.
- Outcome matrix for reimbursement, leave, purchase, travel and attendance correction.
- Side-effect tests for station notification, email task, IM dispatch task, recipient deduplication, copy, target identity and deep link for leave/purchase/travel.
- Route-level failure test proving notification creation failure does not undo task completion, business `draft` state or return history.
- IM routing allow/reject coverage and management API schema coverage for all newly supported values.
### 7. Wrong vs Correct
#### Wrong
```js
const NOTIFICATION_TYPES = Object.freeze({ leaveReturned: "leave.returned" });
// Missing IM support/domain registration; station works but IM silently skips it.
```
#### Correct
```js
const NOTIFICATION_TYPES = Object.freeze({ leaveReturned: "leave.returned" });
const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([NOTIFICATION_TYPES.leaveReturned]);
const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
[NOTIFICATION_TYPES.leaveReturned]: NOTIFICATION_DOMAIN_SCOPES.leave,
});
```
## Scenario: Shared Payment Processing Projection ## Scenario: Shared Payment Processing Projection
### 1. Scope / Trigger ### 1. Scope / Trigger
@@ -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,141 @@
# 技术设计:审批退回结果通知缺失治理
## 1. 决策
沿用既有统一 workflow outcome 通知链,只补齐缺失的运行时通知类型和生产路由,不为三类实体复制 dispatcher:
| entityType | outcome | notificationType | domainScope |
| --- | --- | --- | --- |
| `reimbursement` | `returned` | `reimbursement.returned` | `reimbursement` |
| `leave` | `returned` | `leave.returned` | `leave` |
| `purchase_request` | `returned` | `purchase_request.returned` | `procurement` |
| `travel_request` | `returned` | `travel_request.returned` | `travel` |
| `attendance_correction` | `returned` | 无 | 无 |
三种新值是当前通知系统的稳定 notification type,同时补入通知事实源;本任务不额外创建一套业务 Outbox 领域事件。
## 2. 数据流
```text
POST /workflow/tasks/:taskId/return
-> 完成 task / 终止 instance / 业务对象回 draft / 释放额度或预算 / 写 history
-> resolveWorkflowOutcomeNotificationType(entityType, returned)
-> loadWorkflowNotifyContext + buildWorkflowEntityMeta + buildReturnedCopy
-> createNotificationsWithRecords(站内通知)
-> emailTasks[](既有安全邮件路径,非本任务正式渠道)
-> dispatchTasks[]
-> flushNotificationDispatchTasks
-> notification.im Outbox
-> channel routing: supported type AND allowedTypes AND domainScopes AND roleCodes
-> 企业微信个人应用消息 / delivery
```
类型映射是三条渠道共同的唯一开关。禁止在站内、邮件和 IM 分支分别新增实体判断。
## 3. 收件人、文案与深链
复用 `loadWorkflowNotifyContext`:
- leave:owner。
- purchase_request:applicant 与 submitter,统一去重。
- travel_request:applicant 与 submitter,统一去重。
复用 `buildWorkflowEntityMeta` 与 `buildReturnedCopy`,不新建三套 copy builder。统一退回结果行明确“已退回,可修改后重新提交”,与四类实体回到 draft 的实际能力一致,不复用“驳回”语义。结果通知保留各实体的现有摘要字段;审批意见缺失时沿用“无”的既有兜底。
深链保持现状:请假 `/leave-requests`、采购 `/purchase-requests`、差旅 `/travel-requests?id=<id>`。本任务不改变页面路由或新增前端定位能力。
## 4. 一致性与失败语义
站内通知记录仍在事务内尝试创建,但 outcome 通知块沿用现有 try/catch:通知构造或记录失败只写脱敏错误,不回滚业务退回。事务提交后:
- 邮件通过 `sendNotificationEmailSafe` best-effort 调用;WF8 仍冻结真实邮件验收。
- IM 通过统一 dispatcher 选择合格用户和匹配渠道并记录 delivery/Outbox;无匹配渠道产生固定 skip reason。
- provider 错误不得把完整地址、外部 userId、Secret 或原始响应写入日志和验收材料。
不改变业务优先于通知副作用的现有合同。
## 5. 代码边界
预期产品改动集中于:
- `server/src/constants/notificationTypes.js`:新增三种类型、IM 支持表和 domain scope 映射。
- `server/src/routes/workflow.js`:扩展 returned outcome 映射。
原则上不改:
- `workflowCopy.js`:已有通用文案和实体摘要。
- `imEnqueue.js`、`imRouting.js`、`notificationDispatcher.js`:已有类型、领域、角色交集逻辑。
- Prisma schema、数据库表、return 路由的业务状态与释放分支。
- client 全部代码。
若测试暴露上述既有公共 helper 无法承载三类实体,必须先回到规划说明原因,不得顺手分叉实现。
## 6. 测试设计
### 6.1 纯函数合同
- 每个新类型在 `NOTIFICATION_TYPES`、`IM_SUPPORTED_NOTIFICATION_TYPES` 和 domain scope 映射三处同时存在;以 `reimbursement.returned` 作为完整参照,漏掉任一注册表都会失败。
- `IM_SUPPORTED_NOTIFICATION_TYPES` 精确包含新值且无重复,`resolveNotificationDomainScope` 返回对应领域。
- returned outcome 五类矩阵准确,attendance 保持 null。
- `buildReturnedCopy` 对 reimbursement/leave/purchase/travel 使用正确实体摘要和“可修改后重新提交”能力文案。
### 6.2 路由与 side-effect 合同
优先通过可测试 helper 或现有 route harness 覆盖一次真实 return 调用,而不是只测映射函数:
- 三类实体分别写一份站内通知和对应 email/dispatch task。
- applicant/submitter 重合时去重。
- 目标类型、ID、路径和意见准确。
- IM routing 的显式 allowedTypes/domain/role 白名单缺目标值时拒绝,全部匹配时入队;空数组继续表示该维度不限制。
- 通知/派发失败后业务状态、workflow history 和额度/预算释放结果保持正确。
### 6.3 回归合同
- 报销 returned 继续使用旧类型。
- attendance 不出现 return 通知。
- approved/rejected 和 task assigned/reminded/overdue 类型不变。
- return/reject 状态机和 allowedActions 不变。
## 7. 生产发布与配置
采用两个独立门:
### 阶段 A:代码发布
1. 完成代码、测试、只读 review 和本地门禁。
2. 获得 commit、push、部署授权。
3. 部署支持新类型的候选,核 `/ready`、日志、现有审批/通知 smoke。
4. 只读确认生产渠道 routing 尚未变化。此时站内链路已具备能力,但企业微信三种新类型仍未放行。
### 阶段 B:生产 routing 放行
1. 另行取得生产配置写入授权。
2. 通过管理 API 读取脱敏配置,保存原 `roleCodes/domainScopes/allowedTypes` 快照。
3. 计算当前全部 supported type 的有效匹配结果;目标变更集合必须严格等于三种新 returned。
4. 通过 `PATCH /notifications/channels/:id` 做加法更新,保留其他配置与 Secret 状态。
5. 读回并核对 `NOTIFICATION_CHANNEL_UPDATED` 审计记录。
6. 再次计算有效匹配矩阵,确认没有额外类型或角色被放行。
管理 API schema 依赖新枚举,因此严禁在阶段 A 前写配置。
## 8. 真实送达验收
通知渠道测试接口发送的是通用测试消息,不经过 returned 类型解析和业务 return 路径,不能证明本缺陷已端到端修复。
有效证据只能来自:
- 经另行批准创建并退回的受控生产测试单;或
- 自然发生的真实请假、采购、差旅 return。
每类样本需核对站内通知、企业微信 delivery、真机消息、点击回 OA 和业务状态。缺少样本时标记 `BLOCKED`,不得用单测、合成 API 或通道测试冒充 PASS。
## 9. 回滚
- 代码阶段无 migration/schema/seed/flag;可回滚应用构建。
- routing 已更新时,先用管理 API 恢复原 routing 快照,核读回与审计,再回滚应用。
- 不删除已生成的 Notification、NotificationDelivery、IntegrationOutbox 或 audit 历史。
- 通知失败不需要回滚业务退回;只修复 routing/provider 后按明确授权决定是否重新触发或补发。
## 10. 存量处理
规划时生产无三类 return 历史,因此不设计补发器。若发布前复核发现新样本,只做脱敏清单与状态分类,不自动通知。过期事件是否补发由用户另行判断。
@@ -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,92 @@
# 实施计划:审批退回结果通知缺失治理
## 实施顺序
1. 在通知类型常量中新增 `leave.returned`、`purchase_request.returned`、`travel_request.returned`,加入 IM 支持表和领域映射。
2. 扩展 workflow returned outcome 映射;不改 return 路由事务、状态、释放、allowedActions 或其他 outcome。
3. 补齐纯函数测试:逐个断言三个新类型在类型对象、IM 支持清单、领域映射三处注册完整,并覆盖五类 returned 矩阵及四类“可修改后重新提交”文案/摘要/deeplink。
4. 补齐 route/side-effect 测试:三类站内通知、收件人去重、email task、IM dispatch task、失败不回滚;若现有 harness 不足,只提取最小可测试编排 helper,不改变运行时职责。
5. 补齐 IM routing 和通知渠道 schema 测试:三种新类型的允许/拒绝矩阵,既有类型不漂移。
6. 回归报销 returned、所有 approved/rejected、请假额度释放、采购/差旅预算释放、补卡无 return 和通知失败容错。
7. 更新 `docs/领域事件清单.md`、`docs/集成边界.md` 与 `.trellis/spec/hyb-oa/approval-workflow.md`,只沉淀稳定 notification type 和副作用合同。
8. 完成本地质量门禁与只读 review。此任务涉及 workflow 和生产路由,reviewer 不得改代码。
9. 分别取得 commit、push、部署授权后发布代码候选;部署前不修改生产 routing。
10. 代码生产验收通过后,另行请求生产配置写入授权;按脱敏快照、有效矩阵 diff、管理 API PATCH、读回、审计顺序放行 routing。
11. 只有出现自然 return 或取得受控生产数据写入授权后才做真实两渠道验收;否则明确记录 `BLOCKED`。
## 风险文件
- `server/src/constants/notificationTypes.js`:共享通知枚举与路由白名单,任何遗漏都会造成站内有通知但 IM 被静默跳过。
- `server/src/routes/workflow.js`:五类审批共用,必须只扩展 returned 类型映射。
- `server/src/routes/notifications.js`:管理 API schema 由支持清单生成,需证明新 routing 可写且旧配置兼容。
- `server/src/services/notifications/imRouting.js`:原则上不改;测试需证明三维过滤仍是 AND 关系。
- `docs/领域事件清单.md` 与 `docs/集成边界.md`:必须区分运行时 notification type 与领域 Outbox 事件,不能误写成新增消息总线事件。
## 预期验证命令
```bash
cd server && npm run prisma:validate
cd server && node --check src/constants/notificationTypes.js
cd server && node --check src/routes/workflow.js
cd server && npm test -- tests/unit/constants/notificationTypes.test.js tests/unit/routes/workflow.travel.test.js
cd server && npm test -- tests/unit/services/imRouting.test.js tests/unit/services/imEnqueue.test.js tests/unit/services/notificationDispatcher.test.js
cd server && npm test -- tests/unit/routes/notifications.test.js
cd server && npm test -- tests/unit/routes/workflow.permissions.test.js tests/unit/routes/workflow.batch.test.js
node scripts/qa/check-process-contract.mjs --baseline --json
node scripts/qa/audit-docs.mjs --check-registry
node scripts/qa/check-trellis-governance.mjs
python3 ./.trellis/scripts/task.py validate 09-04-approval-return-notification-gap
git diff --check
```
实施时若新增聚焦 route test 文件,将其追加到命令,不用扩大为无关全仓测试来掩盖缺口。
## Review 清单
1. 三种新类型是否在 `NOTIFICATION_TYPES`、`IM_SUPPORTED_NOTIFICATION_TYPES`、领域映射三处注册齐全,且三种渠道全部由 `resolveWorkflowOutcomeNotificationType` 的同一返回值驱动。
2. 三类收件人与 deeplink 是否复用现有实体元数据且正确去重。
3. 通知异常是否仍不回滚状态、history、假期或预算释放。
4. 报销 returned、其他 outcome、补卡和 allowedActions 是否零变化。
5. 生产 routing 变更是否被留在部署后独立授权阶段,代码或脚本中没有静默改生产配置。
6. 日志、测试 fixture、task 与证据中是否无真实人员、单据、通知正文、外部 ID、Secret 或原始 payload。
7. 退回通知是否明确“可修改后重新提交”,且没有复用驳回措辞。
## 提交与部署边界
- commit message 使用中文并引用 `09-04-approval-return-notification-gap`。
- 实施完成后先交 diff 与只读 review 结果;用户批准后才能提交。
- commit、push、应用部署、生产 routing 写入、受控生产单据创建/退回分别授权,不合并推定。
- 代码阶段无 migration、schema、seed、flag 或生产数据改动。
## 生产验收清单
### 代码候选
1. provenance、工作区与候选 SHA 一致;部署前现有 routing 只读快照未变化。
2. `/ready` 返回候选短 SHA;服务端日志无新类型/领域解析错误。
3. 报销 returned、approved/rejected、待办及五类审批 smoke 正常。
4. 生产渠道配置在独立授权前仍不放行三种新类型。
### routing 配置
1. 变更前保存脱敏 routing 快照和有效允许矩阵。
2. 管理 API PATCH 只增加三个 type 与所需 domain;其他配置不变。
3. 变更后读回值、有效矩阵与审计记录一致,新增有效能力严格等于目标三种 returned。
4. 回滚演练至少完成静态命令与快照核对;不得为了演练反复写生产。
### 真实事件
1. 目标用户站内只收到一份“已退回”通知,意见、目标类型和目标 ID 正确。
2. 企业微信 delivery 成功,真机收到消息并可点击回到有权限的 OA 页面。
3. 通知结果与业务对象 draft、workflow returned/history 以及假期/预算释放一致。
4. 无真实样本或授权时记录 `BLOCKED`,不标记 PASS。
## 本地实施结果(2026-09-04)
- 已完成三个 returned 通知类型、IM 支持清单和领域映射的同步注册;管理 API schema 测试保留既有 `workflow.task_assigned` 覆盖并新增三个 returned 值。
- 已由统一 workflow outcome 投影生成三类站内通知、email task 与 IM dispatch task;退回正文统一明确“已退回,可修改后重新提交”。
- 已增加 route 级容错回归:站内通知创建抛错时,接口仍成功,task 为 `completed/returned`、业务对象回 `draft`、return history 保留。
- 产品状态机、allowedActions、假期/预算释放分支、Prisma schema、前端和生产 routing 均未修改。
- 聚焦通知测试 53/53、全部 workflow route 测试 49/49、假期/预算服务测试 14/14 通过;Prisma validate、Node syntax、process contract、Trellis governance、task validate、docs registry 检查和 `git diff --check` 通过。
- 全量后端测试为 1384/1387 通过;三条失败均来自当前环境缺少受控 BIFF8 生成依赖 `xlwt`,集中在付款指令导出测试,与本任务改动文件和通知/workflow 路径无关。聚焦重跑全部通过,未修改或弱化这三条断言。
- AC9–AC11 尚未执行:未 commit、未 push、未部署、未写生产 routing,也未创建或退回生产测试单。真实企业微信送达继续保持未验证。
@@ -0,0 +1,107 @@
# 审批退回结果通知缺失治理
## Goal
修复请假、采购和差旅申请被审批人退回后申请人收不到结果通知的静默失败,使申请人能够及时知道单据已回到草稿、查看退回意见并继续修改;同时确保通知失败不改变已经完成的审批结果。
## Background
审批退回路由已经对报销、请假、采购和差旅执行同一通知入口,但 returned 的通知类型映射只覆盖报销。请假、采购和差旅因此在创建站内通知前被跳过;问题不在 dispatcher,也不需要重写退回事务。
生产只读核验确认 `reimbursement.returned` 已产生真实站内通知,并至少有一条成功外部投递;当前生产没有请假、采购或差旅 return 历史,也没有符合“退回超过 7 天、仍为草稿且未重提”的存量单据。因此本任务不含历史补发。
生产唯一启用的 IM 渠道使用显式通知类型与领域白名单,目前未放行请假、采购和差旅域。代码修复与生产渠道 routing 必须分阶段执行;只部署代码不能宣称企业微信通知已修复。
详细证据见 `research/return-notification-gap.md`。
## Scope
- 新增请假、采购和差旅退回的运行时通知类型及领域映射。
- 接通既有站内通知、邮件任务和 IM Outbox 流程,复用当前收件人、文案和 deeplink 规则。
- 为三类实体增加精确的结果通知与路由回归测试,并保护现有审批动作和通知类型。
- 更新稳定通知事实源和审批工作流规约。
- 在代码发布并取得独立生产写入授权后,以受控方式扩充生产 IM 渠道 routing,并保留可执行回滚快照。
- 无真实事件样本时,明确区分“代码/配置已就绪”和“真实企业微信送达已验证”。
## Requirements
### R1:补齐 returned 通知类型合同
- 新增 `leave.returned`、`purchase_request.returned`、`travel_request.returned`。
- 三种类型必须进入 IM 支持清单,并分别映射到 `leave`、`procurement`、`travel` domain scope。
- 报销 `reimbursement.returned` 以及所有 approved/rejected 类型保持原值和原行为;补卡仍不提供 return。
### R2:三类退回统一生成结果通知
- 请假、采购和差旅 return 成功后必须为既有申请人/提交人规则解析出的目标用户创建站内通知。
- 通知标题使用“已退回”,正文明确“已退回,可修改后重新提交”并包含审批意见;四类可退回实体共用同一能力口径,不沿用“驳回”措辞或新增实体文案分叉。
- targetType、targetId 与 deeplink 必须沿用实体元数据:请假进入请假列表,采购进入采购列表,差旅进入带目标 ID 的详情入口。
- 采购和差旅的申请人、提交人为同一人时只生成一份通知。
### R3:渠道副作用与业务事务解耦
- 站内通知、email task 与 IM dispatch task 必须由同一次 outcome 投影产生,不能三处分别判断 returned 类型。
- 通知创建、邮件或 IM 派发失败不得回滚已完成的退回、草稿回写、假期释放、预算释放或审批历史。
- 邮件只验证任务构造和既有安全发送路径;按 WF8 冻结口径,不启用 provider,也不把真实邮件送达作为本任务硬门禁。
### R4:生产 IM routing 受控放行
- 生产 routing 变更必须晚于支持新枚举的代码部署,并单独取得生产写入授权。
- 变更只允许新增三种 returned 类型所需的 `allowedTypes` 与 domain scopes;mode、timeout、roleCodes、模板、Secret 状态和其他允许类型不得漂移。
- 写入前必须保存脱敏 routing 快照并计算有效路由差异;若除目标三种通知外还有其他类型被新放行,必须中止。
- 只能通过带权限校验和审计记录的通知渠道管理 API 更新,不得直接手工修改数据库。
- 读回配置与审计记录后才能认定 routing 生效;回滚时先恢复原 routing,再回滚应用版本。
### R5:无存量补发
- 当前无请假、采购或差旅 return 历史,不创建补发脚本、不重放 workflow history、不伪造通知记录。
- 若实施期间只读复核发现新的存量样本,只报告脱敏数量和状态;补发通知或联系员工必须另行授权。
- 三张历史 rejected 报销不纳入本任务,它们不是 return 通知缺口的受影响对象。
### R6:兼容与回归边界
- 不修改 return/reject 的 allowedActions、endpoint、状态、outcome、history、释放逻辑或重新提交规则。
- approved、rejected、报销 returned、待办、催办、超时和其他通知的类型、收件人、文案、路由均不得变化。
- 通知渠道 schema 在新代码发布后必须接受三个新类型;旧配置无需 migration,现有渠道在未授权修改前保持原路由效果。
## Acceptance Criteria
- [x] AC1:通知类型测试逐项证明三个新类型同时存在于 `NOTIFICATION_TYPES`、`IM_SUPPORTED_NOTIFICATION_TYPES`、`NOTIFICATION_TYPE_TO_DOMAIN_SCOPE` 三处注册表,并分别解析到 `leave`、`procurement`、`travel`;以已验证的 `reimbursement.returned` 为参照,既有类型矩阵不变。
- [x] AC2:workflow outcome 测试证明 leave/purchase/travel 的 returned 分别解析到新类型,reimbursement returned 仍为原类型,attendance returned 仍为空。
- [x] AC3:三类 return 路径分别创建目标用户站内通知,类型、标题、“已退回,可修改后重新提交”能力提示、退回意见、targetType、targetId 和 deeplink 准确;申请人与提交人相同时无重复通知。
- [x] AC4:同一结果投影同时构造 email task 与 IM dispatch task;测试证明邮件/IM 失败不回滚业务退回结果,且不泄露 provider 原始错误或收件人敏感信息。
- [x] AC5:IM 路由测试覆盖三个新类型的允许与拒绝分支;显式 domain 或 allowedTypes 白名单缺少目标值时固定返回不匹配,配置完整时只为预期渠道入队;保留空数组代表该维度不限制的既有语义。
- [x] AC6:请假假期释放、采购/差旅预算释放、四类实体状态与 history、全部 approved/rejected 结果通知和报销 returned 回归通过。
- [x] AC7:`docs/领域事件清单.md`、`docs/集成边界.md` 与审批 workflow spec 明确三种 returned 结果通知及“通知失败不回滚业务”的稳定合同。
- [x] AC8:实施完成后 Prisma validate、Node 语法检查、聚焦后端测试、process contract、docs registry、Trellis governance 与 `git diff --check` 通过;因无前端改动,不新增 UI 视觉范围。
- [ ] AC9:部署前获得独立授权;代码部署后 `/ready` 为候选 SHA,现有通知与审批 smoke 通过,生产 routing 在配置授权前没有变化。
- [ ] AC10:生产 routing 经独立授权后由管理 API 做加法更新;脱敏快照、有效路由差异、读回值和审计记录证明只新增目标三种通知能力,回滚步骤可执行。
- [ ] AC11:若取得受控生产数据写入授权或出现自然 return 样本,站内通知与企业微信真机送达、点击回 OA、delivery 记录和业务状态不回滚均通过;若没有真实样本,只能记录 `BLOCKED`,不得用单测、通道测试消息或合成 dispatcher 结果冒充真实送达 PASS。
## Test Plan
- 类型合同:三种新通知类型、domain scope、管理 API schema、既有类型负向保护。
- workflow:五类 returned 映射矩阵;请假/采购/差旅的站内、email task、IM dispatch task;收件人去重、文案和 deeplink。
- 容错:站内创建异常、邮件发送异常、IM 无匹配渠道或派发失败均不改变退回业务结果。
- 回归:报销 returned、五类 approved/rejected、请假额度释放、采购/差旅预算释放、补卡无 return。
- 生产:候选 provenance、`/ready`、配置前后脱敏矩阵、管理 API 审计、可用真实样本的两渠道送达与点击回流。
## Out of Scope
- 修改审批动作、退回/驳回语义、状态机、预算或假期规则。
- 历史通知重放、存量补发、主动联系员工或改写业务单据。
- 未经授权创建真实生产请假、采购或差旅数据。
- 新增通知表、Outbox 事件、provider、消息总线或前端通知页面改版。
- 启用或验收邮件 provider。
- 调整三张 rejected 报销及父任务 AC11。
## Risks And Deferred Items
- 生产渠道同时使用 allowedTypes 与 domainScopes。新增 domain scope 可能让已在 allowedTypes 中的其他类型同时生效,因此必须比较“有效允许矩阵”,不能只比较数组 diff。
- 代码回滚但 routing 未先恢复,会留下旧版本不认识的配置值;回滚顺序固定为 routing 后应用。
- 当前没有三类真实 return 样本。代码与 routing 可完整交付,但企业微信真实送达在自然事件或受控生产样本出现前必须保持 `BLOCKED`。
- 若未来发现历史受影响对象,是否补发要按通知时效、当前业务状态和打扰风险单独决策,不能自动重放。
## Planning Gate
本任务涉及共享 workflow、通知类型合同和生产配置,属于复杂任务。`design.md`、`implement.md` 与研究记录齐备后,必须由用户审批最终规划;批准前不得执行 `task.py start`、修改产品代码或写入生产配置。
@@ -0,0 +1,113 @@
# 审批退回通知缺口调研
## 调研结论
请假、采购和差旅的审批退回已经进入统一结果通知调用链,但运行时通知类型映射缺失,导致调用在创建站内通知之前直接返回。这是通知类型合同缺失,不是 dispatcher 接线遗漏。
生产当前没有这三类实体的历史 return 动作,也没有可归因于该缺陷的存量长期未动作单据;无需历史补发。报销退回通知在生产已有真实站内通知和成功外部投递证据。
代码修复本身不会让三种新通知自动通过生产企业微信渠道:当前唯一启用的 IM 渠道同时使用 `allowedTypes` 与 `domainScopes` 限制路由,尚未放行请假、采购和差旅域。生产 routing 必须作为独立、可回滚的配置变更,经另行授权后执行。
## 1. 根因证据
- `server/src/routes/workflow.js:1076-1100` 的 `resolveWorkflowOutcomeNotificationType` 已覆盖五类实体的 approved/rejected,但 returned 只映射 `reimbursement.returned`。
- `server/src/routes/workflow.js:1210-1265` 的 `queueWorkflowOutcomeNotifications` 在通知类型为空时直接返回;类型存在时才统一创建站内通知、加入 IM dispatch task 和 email task。
- `server/src/routes/workflow.js:2990-3155` 的 return 路由已经对报销、请假、采购和差旅统一调用 `queueWorkflowOutcomeNotifications`,因此无需新增 dispatcher 调用。
- `server/src/services/notifications/workflowCopy.js:246-253` 已有通用 `buildReturnedCopy`,能够为四类实体复用“已退回”标题、业务摘要和审批意见。
- `server/src/constants/notificationTypes.js` 尚无 `leave.returned`、`purchase_request.returned`、`travel_request.returned`,相应 IM 支持表和 domain scope 映射也不存在。
## 2. 收件人与目标路径
`loadWorkflowNotifyContext` 和 `buildWorkflowEntityMeta` 已覆盖三类目标,无需另建收件人分支:
| 实体 | 收件人来源 | targetType | targetPath |
| --- | --- | --- | --- |
| 请假 | owner | `leave_request` | `/leave-requests` |
| 采购 | applicant + submitter,去重 | `purchase_request` | `/purchase-requests` |
| 差旅 | applicant + submitter,去重 | `travel_request` | `/travel-requests?id=<id>` |
站内通知记录在业务事务内创建;邮件调用和 IM dispatcher 在事务提交后执行。通知创建或派发失败沿用既有策略:记录错误或投递失败,不回滚已经完成的退回动作。
## 3. 生产只读核验
以下结果只保留聚合统计,不记录员工、单号、审批意见、通知正文、渠道密钥或原始 payload。
### 3.1 报销退回通知真实存在
生产聚合结果:
```json
{
"reimbursementReturnedNotifications": 4,
"reimbursementReturnedDeliveries": {
"success": 1
},
"returnHistory": [
{
"entityType": "reimbursement",
"returnCount": 1
}
]
}
```
这证明 `reimbursement.returned` 不只是代码映射:生产已有站内通知实例,且至少一条外部渠道 delivery 成功。
### 3.2 当前没有存量受影响单据
生产 `workflow_history.action=return` 只有报销 1 次;请假、采购、差旅均为 0 次。
“长期未动作”只读筛选口径为:最新 return 已超过 7 天、业务对象仍为 draft、return 后没有新 workflow instance。结果为空。
因此当前不做历史重放、补发通知或联系员工。三张已知 rejected 报销走的是 reject,不属于本缺陷样本;它们继续随父任务 AC11 验证。
## 4. 生产 IM 路由约束
生产聚合检查确认:
```json
{
"enabledImChannels": 1,
"broadTypeChannels": 0,
"explicitAllowedTypes": true,
"allowsReimbursementReturned": true,
"domainAllows": {
"leave": false,
"procurement": false,
"travel": false
}
}
```
路由匹配同时要求通知类型、领域和角色条件成立。新增代码类型后,若不修改该渠道配置,三种 returned 通知会得到 `no_matching_channel`,企业微信仍不会发送。
安全变更方式:
1. 先部署支持新枚举的代码;旧代码阶段不得写入新类型。
2. 用通知渠道管理 API 读取脱敏配置并保存 routing 回滚快照。
3. 计算变更前后有效允许矩阵;若除三种新 returned 类型外还有其他类型被意外放行,立即中止。
4. 经独立生产写入授权后,通过管理 API 对 `allowedTypes` 和 `domainScopes` 做加法更新;保留 mode、timeout、角色、模板和 Secret 状态。
5. 读回配置并核对审计记录;需要回滚时先恢复 routing 快照,再回滚应用构建。
不得用手工 SQL 绕过 schema、审计或 Secret 合并规则。
## 5. 邮件边界
统一 outcome helper 会为新 returned 类型加入 email task,因此代码测试应证明任务被正确构造。按照 `docs/试运行与自动化验证手册.md` 的 WF8 口径,邮件当前冻结,不是 Go/No-Go 硬门禁;本任务不启用 SMTP、不承诺真实邮件送达。
## 6. 实施影响面
- 类型合同:`server/src/constants/notificationTypes.js`
- outcome 映射与结果通知:`server/src/routes/workflow.js`
- 文案和实体摘要:复用 `server/src/services/notifications/workflowCopy.js`
- IM 路由及管理 API 回归:`server/src/services/notifications/imRouting.js`、`server/src/routes/notifications.js`
- 测试:通知类型、workflow outcome、站内/IM/email side-effect、路由正负向、非 return 回归
- 稳定事实源:`docs/领域事件清单.md`、`docs/集成边界.md`、`.trellis/spec/hyb-oa/approval-workflow.md`
## 7. 明确不做
- 不修改退回/驳回状态机、预算或假期释放逻辑。
- 不重放历史事件,不补发旧通知,不主动联系员工。
- 不创建真实生产请假、采购或差旅单据,除非后续取得明确的生产数据写入授权。
- 不新增通知表、Outbox 类型、provider 或消息总线。
- 不把邮件渠道从冻结状态解锁。
@@ -0,0 +1,26 @@
{
"id": "approval-return-notification-gap",
"name": "approval-return-notification-gap",
"title": "审批退回结果通知缺失治理",
"description": "补齐请假、采购和差旅审批退回后的站内通知与企业微信结果通知,治理生产渠道路由并验证静默失败边界。",
"status": "in_progress",
"dev_type": null,
"scope": "trellis,server,tests,docs,production-notification-routing",
"package": null,
"priority": "P1",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-09-04",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+5 -2
查看文件
@@ -96,8 +96,9 @@
### 3.2 一期明确覆盖范围 ### 3.2 一期明确覆盖范围
- 待办类:`workflow.task_assigned` - 待办类:`workflow.task_assigned`
- 报销结果:`reimbursement.approved / rejected / returned / paid` - 报销结果:`reimbursement.approved / rejected / returned / paid`
- 请假结果:`leave.approved / rejected` - 请假结果:`leave.approved / rejected / returned`
- 采购结果:`purchase_request.approved / rejected` - 采购结果:`purchase_request.approved / rejected / returned`
- 差旅结果:`travel_request.approved / rejected / returned`
- 资产流转:`asset.assigned / returned / transferred` - 资产流转:`asset.assigned / returned / transferred`
- 盘点结果:`inventory_check.completed` - 盘点结果:`inventory_check.completed`
@@ -227,6 +228,8 @@
### 6.3 当前 IM 一期实现说明 ### 6.3 当前 IM 一期实现说明
- 运行时仍以通知类型驱动 IM 入队,而不是直接暴露新的领域事件名。 - 运行时仍以通知类型驱动 IM 入队,而不是直接暴露新的领域事件名。
- 请假、采购和差旅退回分别使用 `leave.returned`、`purchase_request.returned`、`travel_request.returned`;正文明确单据已退回且可修改后重新提交。这三项是运行时通知类型,不新增领域 Outbox 事件名。
- 支持 return 的业务对象在业务状态、审批历史和额度/预算释放完成后投影站内、邮件任务与 IM 派发任务;通知构造或派发失败只记录脱敏错误,不回滚已经完成的退回结果。
- `notification.im` 的 `dedupeKey` 固定为: - `notification.im` 的 `dedupeKey` 固定为:
- `${notificationType}:${targetType}:${targetId || targetPath}:${channelId}` - `${notificationType}:${targetType}:${targetId || targetPath}:${channelId}`
- IM 失败仅影响 Outbox 状态,不回滚业务事务。 - IM 失败仅影响 Outbox 状态,不回滚业务事务。
+5
查看文件
@@ -89,10 +89,13 @@
| `payment_batch.execution_requested` | 付款复核通过,责任转回实际付款人 | `payment_batch` | `/payment-batches?id=<id>` | 待办通知;优先通知仍可付款的制单人 | | `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` | 结果通知 |
| `leave.returned` | 请假退回修改 | `leave_request` | `/leave-requests` | 结果通知;单据回到草稿,可修改后重新提交 |
| `purchase_request.approved` | 采购审批通过 | `purchase_request` | `/purchase-requests` | 结果通知 | | `purchase_request.approved` | 采购审批通过 | `purchase_request` | `/purchase-requests` | 结果通知 |
| `purchase_request.rejected` | 采购驳回 | `purchase_request` | `/purchase-requests` | 结果通知 | | `purchase_request.rejected` | 采购驳回 | `purchase_request` | `/purchase-requests` | 结果通知 |
| `purchase_request.returned` | 采购退回修改 | `purchase_request` | `/purchase-requests` | 结果通知;单据回到草稿,可修改后重新提交 |
| `travel_request.approved` | 差旅审批通过 | `travel_request` | `/travel-requests?id=<id>` | 结果通知 | | `travel_request.approved` | 差旅审批通过 | `travel_request` | `/travel-requests?id=<id>` | 结果通知 |
| `travel_request.rejected` | 差旅审批驳回 | `travel_request` | `/travel-requests?id=<id>` | 结果通知 | | `travel_request.rejected` | 差旅审批驳回 | `travel_request` | `/travel-requests?id=<id>` | 结果通知 |
| `travel_request.returned` | 差旅退回修改 | `travel_request` | `/travel-requests?id=<id>` | 结果通知;单据回到草稿,可修改后重新提交 |
| `asset.assigned` | 资产领用成功 | `asset_card` | `/assets` | 通知新领用人 | | `asset.assigned` | 资产领用成功 | `asset_card` | `/assets` | 通知新领用人 |
| `asset.returned` | 资产归还成功 | `asset_card` | `/assets` | 通知原持有人 | | `asset.returned` | 资产归还成功 | `asset_card` | `/assets` | 通知原持有人 |
| `asset.transferred` | 资产调拨成功 | `asset_card` | `/assets` | 通知归属变更涉及人员 | | `asset.transferred` | 资产调拨成功 | `asset_card` | `/assets` | 通知归属变更涉及人员 |
@@ -107,6 +110,8 @@
这些事件名称仍保留在长期蓝图中,但不属于 `P19+IM-1` 的首批协同覆盖范围。 这些事件名称仍保留在长期蓝图中,但不属于 `P19+IM-1` 的首批协同覆盖范围。
请假、采购和差旅的 `*.returned` 是运行时通知类型,不是新增的领域 Outbox 事件名;它们与既有审批结果通知共用 `notification.im` 投影和派发链。
## 10. P19+ERP-1 / ERP-3 已实现消费映射 ## 10. P19+ERP-1 / ERP-3 已实现消费映射
- `P19+ERP-1` 没有新增新的领域事件名;ERP 一期真正落地消费的既有业务事实是 `reimbursement.paid`,运行时会先构建标准化 `ReimbursementJournalSlice`,再通过 `erp.journal.export` 写入 `IntegrationOutbox` 异步派发。 - `P19+ERP-1` 没有新增新的领域事件名;ERP 一期真正落地消费的既有业务事实是 `reimbursement.paid`,运行时会先构建标准化 `ReimbursementJournalSlice`,再通过 `erp.journal.export` 写入 `IntegrationOutbox` 异步派发。
- `ReimbursementJournalSlice.sourceEventName` 固定为 `reimbursement.paid`,用于标记该凭证切片来源于“报销付款完成”这一既有业务事实;历史 `erp.reimbursement.paid` outbox 仍继续兼容。 - `ReimbursementJournalSlice.sourceEventName` 固定为 `reimbursement.paid`,用于标记该凭证切片来源于“报销付款完成”这一既有业务事实;历史 `erp.reimbursement.paid` outbox 仍继续兼容。
+10
查看文件
@@ -21,12 +21,15 @@ const NOTIFICATION_TYPES = Object.freeze({
paymentBatchExecutionRequested: 'payment_batch.execution_requested', paymentBatchExecutionRequested: 'payment_batch.execution_requested',
leaveApproved: 'leave.approved', leaveApproved: 'leave.approved',
leaveRejected: 'leave.rejected', leaveRejected: 'leave.rejected',
leaveReturned: 'leave.returned',
attendanceCorrectionApproved: 'attendance_correction.approved', attendanceCorrectionApproved: 'attendance_correction.approved',
attendanceCorrectionRejected: 'attendance_correction.rejected', attendanceCorrectionRejected: 'attendance_correction.rejected',
purchaseRequestApproved: 'purchase_request.approved', purchaseRequestApproved: 'purchase_request.approved',
purchaseRequestRejected: 'purchase_request.rejected', purchaseRequestRejected: 'purchase_request.rejected',
purchaseRequestReturned: 'purchase_request.returned',
travelRequestApproved: 'travel_request.approved', travelRequestApproved: 'travel_request.approved',
travelRequestRejected: 'travel_request.rejected', travelRequestRejected: 'travel_request.rejected',
travelRequestReturned: 'travel_request.returned',
assetAssigned: 'asset.assigned', assetAssigned: 'asset.assigned',
assetReturned: 'asset.returned', assetReturned: 'asset.returned',
assetTransferred: 'asset.transferred', assetTransferred: 'asset.transferred',
@@ -45,12 +48,15 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
NOTIFICATION_TYPES.paymentBatchExecutionRequested, NOTIFICATION_TYPES.paymentBatchExecutionRequested,
NOTIFICATION_TYPES.leaveApproved, NOTIFICATION_TYPES.leaveApproved,
NOTIFICATION_TYPES.leaveRejected, NOTIFICATION_TYPES.leaveRejected,
NOTIFICATION_TYPES.leaveReturned,
NOTIFICATION_TYPES.attendanceCorrectionApproved, NOTIFICATION_TYPES.attendanceCorrectionApproved,
NOTIFICATION_TYPES.attendanceCorrectionRejected, NOTIFICATION_TYPES.attendanceCorrectionRejected,
NOTIFICATION_TYPES.purchaseRequestApproved, NOTIFICATION_TYPES.purchaseRequestApproved,
NOTIFICATION_TYPES.purchaseRequestRejected, NOTIFICATION_TYPES.purchaseRequestRejected,
NOTIFICATION_TYPES.purchaseRequestReturned,
NOTIFICATION_TYPES.travelRequestApproved, NOTIFICATION_TYPES.travelRequestApproved,
NOTIFICATION_TYPES.travelRequestRejected, NOTIFICATION_TYPES.travelRequestRejected,
NOTIFICATION_TYPES.travelRequestReturned,
NOTIFICATION_TYPES.assetAssigned, NOTIFICATION_TYPES.assetAssigned,
NOTIFICATION_TYPES.assetReturned, NOTIFICATION_TYPES.assetReturned,
NOTIFICATION_TYPES.assetTransferred, NOTIFICATION_TYPES.assetTransferred,
@@ -69,12 +75,15 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
[NOTIFICATION_TYPES.paymentBatchExecutionRequested]: 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.leaveReturned]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.attendanceCorrectionApproved]: NOTIFICATION_DOMAIN_SCOPES.attendance, [NOTIFICATION_TYPES.attendanceCorrectionApproved]: NOTIFICATION_DOMAIN_SCOPES.attendance,
[NOTIFICATION_TYPES.attendanceCorrectionRejected]: NOTIFICATION_DOMAIN_SCOPES.attendance, [NOTIFICATION_TYPES.attendanceCorrectionRejected]: NOTIFICATION_DOMAIN_SCOPES.attendance,
[NOTIFICATION_TYPES.purchaseRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.procurement, [NOTIFICATION_TYPES.purchaseRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.procurement,
[NOTIFICATION_TYPES.purchaseRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.procurement, [NOTIFICATION_TYPES.purchaseRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.procurement,
[NOTIFICATION_TYPES.purchaseRequestReturned]: NOTIFICATION_DOMAIN_SCOPES.procurement,
[NOTIFICATION_TYPES.travelRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.travel, [NOTIFICATION_TYPES.travelRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.travel,
[NOTIFICATION_TYPES.travelRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.travel, [NOTIFICATION_TYPES.travelRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.travel,
[NOTIFICATION_TYPES.travelRequestReturned]: NOTIFICATION_DOMAIN_SCOPES.travel,
[NOTIFICATION_TYPES.assetAssigned]: NOTIFICATION_DOMAIN_SCOPES.asset, [NOTIFICATION_TYPES.assetAssigned]: NOTIFICATION_DOMAIN_SCOPES.asset,
[NOTIFICATION_TYPES.assetReturned]: NOTIFICATION_DOMAIN_SCOPES.asset, [NOTIFICATION_TYPES.assetReturned]: NOTIFICATION_DOMAIN_SCOPES.asset,
[NOTIFICATION_TYPES.assetTransferred]: NOTIFICATION_DOMAIN_SCOPES.asset, [NOTIFICATION_TYPES.assetTransferred]: NOTIFICATION_DOMAIN_SCOPES.asset,
@@ -110,5 +119,6 @@ module.exports = {
NOTIFICATION_TYPES, NOTIFICATION_TYPES,
IM_SUPPORTED_NOTIFICATION_TYPES, IM_SUPPORTED_NOTIFICATION_TYPES,
NOTIFICATION_DOMAIN_SCOPES, NOTIFICATION_DOMAIN_SCOPES,
NOTIFICATION_TYPE_TO_DOMAIN_SCOPE,
resolveNotificationDomainScope, resolveNotificationDomainScope,
}; };
+4
查看文件
@@ -1094,6 +1094,9 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
if (outcome === 'returned') { if (outcome === 'returned') {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementReturned; if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementReturned;
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveReturned;
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestReturned;
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestReturned;
} }
return null; return null;
@@ -3334,5 +3337,6 @@ module.exports.__testables = {
isWorkflowTaskOverdue, isWorkflowTaskOverdue,
listAllowedActionsForTask, listAllowedActionsForTask,
parseTaskListQuery, parseTaskListQuery,
queueWorkflowOutcomeNotifications,
resolveWorkflowOutcomeNotificationType, resolveWorkflowOutcomeNotificationType,
}; };
@@ -247,7 +247,7 @@ function buildReturnedCopy(entityMeta, comment) {
const safeComment = String(comment ?? "").trim() || "无"; const safeComment = String(comment ?? "").trim() || "无";
return { return {
title: `【已退回】${buildSubject(entityMeta)}`, title: `【已退回】${buildSubject(entityMeta)}`,
body: buildBody(entityMeta, `结果:已退回:${safeComment}`), body: buildBody(entityMeta, `结果:已退回,可修改后重新提交:${safeComment}`),
}; };
} }
@@ -1,6 +1,7 @@
const { const {
IM_SUPPORTED_NOTIFICATION_TYPES, IM_SUPPORTED_NOTIFICATION_TYPES,
NOTIFICATION_DOMAIN_SCOPES, NOTIFICATION_DOMAIN_SCOPES,
NOTIFICATION_TYPE_TO_DOMAIN_SCOPE,
NOTIFICATION_TYPES, NOTIFICATION_TYPES,
resolveNotificationDomainScope, resolveNotificationDomainScope,
} = require("../../../src/constants/notificationTypes"); } = require("../../../src/constants/notificationTypes");
@@ -37,4 +38,20 @@ describe("notification types", () => {
"payment_batch", "payment_batch",
)).toBe(NOTIFICATION_DOMAIN_SCOPES.reimbursement); )).toBe(NOTIFICATION_DOMAIN_SCOPES.reimbursement);
}); });
it.each([
["reimbursementReturned", "reimbursement.returned", NOTIFICATION_DOMAIN_SCOPES.reimbursement],
["leaveReturned", "leave.returned", NOTIFICATION_DOMAIN_SCOPES.leave],
["purchaseRequestReturned", "purchase_request.returned", NOTIFICATION_DOMAIN_SCOPES.procurement],
["travelRequestReturned", "travel_request.returned", NOTIFICATION_DOMAIN_SCOPES.travel],
])("registers %s in the type, IM support, and domain registries", (key, value, domainScope) => {
expect(NOTIFICATION_TYPES[key]).toBe(value);
expect(IM_SUPPORTED_NOTIFICATION_TYPES).toContain(value);
expect(NOTIFICATION_TYPE_TO_DOMAIN_SCOPE[value]).toBe(domainScope);
expect(resolveNotificationDomainScope(value)).toBe(domainScope);
});
it("keeps the IM-supported notification registry unique", () => {
expect(new Set(IM_SUPPORTED_NOTIFICATION_TYPES).size).toBe(IM_SUPPORTED_NOTIFICATION_TYPES.length);
});
}); });
+18 -2
查看文件
@@ -1,6 +1,7 @@
const Fastify = require('fastify'); const Fastify = require('fastify');
const { AppError } = require('../../../src/utils/AppError'); const { AppError } = require('../../../src/utils/AppError');
const { NOTIFICATION_TYPES } = require('../../../src/constants/notificationTypes');
const { encryptStringOrThrow } = require('../../../src/services/crypto'); const { encryptStringOrThrow } = require('../../../src/services/crypto');
const notificationsRoutes = require('../../../src/routes/notifications'); const notificationsRoutes = require('../../../src/routes/notifications');
@@ -446,8 +447,13 @@ describe('notifications channel routes', () => {
timeoutMs: 800, timeoutMs: 800,
routing: { routing: {
roleCodes: ['manager'], roleCodes: ['manager'],
domainScopes: ['workflow'], domainScopes: ['workflow', 'leave', 'procurement', 'travel'],
allowedTypes: ['workflow.task_assigned'], allowedTypes: [
NOTIFICATION_TYPES.workflowTaskAssigned,
NOTIFICATION_TYPES.leaveReturned,
NOTIFICATION_TYPES.purchaseRequestReturned,
NOTIFICATION_TYPES.travelRequestReturned,
],
}, },
}, },
}, },
@@ -460,6 +466,16 @@ describe('notifications channel routes', () => {
webhookUrl: null, webhookUrl: null,
secretConfigured: false, secretConfigured: false,
secretMasked: null, secretMasked: null,
routing: {
roleCodes: ['manager'],
domainScopes: ['workflow', 'leave', 'procurement', 'travel'],
allowedTypes: [
NOTIFICATION_TYPES.workflowTaskAssigned,
NOTIFICATION_TYPES.leaveReturned,
NOTIFICATION_TYPES.purchaseRequestReturned,
NOTIFICATION_TYPES.travelRequestReturned,
],
},
}), }),
); );
expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined(); expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined();
@@ -150,6 +150,14 @@ async function buildApp(options = {}) {
create: vi.fn(async () => ({ id: "delivery-1" })), create: vi.fn(async () => ({ id: "delivery-1" })),
update: vi.fn(async () => ({})), update: vi.fn(async () => ({})),
}, },
notification: {
create: vi.fn(async ({ data }) => {
if (options.notificationCreateError) {
throw options.notificationCreateError;
}
return { id: "notification-1", ...data };
}),
},
}; };
const prisma = { const prisma = {
@@ -295,6 +303,50 @@ describe("workflow batch routes", () => {
await app.close(); await app.close();
}); });
it("keeps a return committed when outcome notification creation fails", async () => {
const { app, state, tx } = await buildApp({
notificationCreateError: new Error("synthetic notification failure"),
});
const taskId = "11111111-1111-4111-8111-111111111111";
const response = await app.inject({
method: "POST",
url: `/api/workflow/tasks/${taskId}/return`,
payload: {
comment: "材料不完整,请修改后重新提交。",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
taskId,
outcome: "returned",
reimbursementStatus: "draft",
}),
);
expect(tx.notification.create).toHaveBeenCalledTimes(1);
expect(state.tasks.get(taskId)).toEqual(
expect.objectContaining({
status: "completed",
outcome: "returned",
}),
);
expect(tx.reimbursementForm.update).toHaveBeenCalledWith({
where: { id: taskId },
data: expect.objectContaining({ status: "draft" }),
});
expect(state.history).toEqual([
expect.objectContaining({
instance_id: taskId,
task_id: taskId,
action: "return",
}),
]);
await app.close();
});
it("does not roll back successful tasks when one task fails or has unsupported entity type", async () => { it("does not roll back successful tasks when one task fails or has unsupported entity type", async () => {
const { app, state } = await buildApp({ const { app, state } = await buildApp({
updateErrorTaskId: "44444444-4444-4444-8444-444444444444", updateErrorTaskId: "44444444-4444-4444-8444-444444444444",
@@ -0,0 +1,150 @@
const { NOTIFICATION_TYPES } = require("../../../src/constants/notificationTypes");
const workflowRoutes = require("../../../src/routes/workflow");
describe("workflow returned outcome notifications", () => {
const { queueWorkflowOutcomeNotifications } = workflowRoutes.__testables;
const cases = [
{
entityType: "leave",
entityId: "leave-1",
notificationType: NOTIFICATION_TYPES.leaveReturned,
targetType: "leave_request",
targetPath: "/leave-requests",
modelName: "leaveRequest",
record: {
id: "leave-1",
owner_id: "employee-1",
start_date: new Date("2026-09-01T00:00:00.000Z"),
end_date: new Date("2026-09-02T00:00:00.000Z"),
requested_days: "2",
leave_type: { name: "年假" },
owner: { id: "employee-1", name: "测试申请人" },
},
expectedBody: "申请人:测试申请人\n假种:年假\n天数:2 天\n起止日期:2026-09-01 ~ 2026-09-02\n结果:已退回,可修改后重新提交:请补充说明",
},
{
entityType: "purchase_request",
entityId: "purchase-1",
notificationType: NOTIFICATION_TYPES.purchaseRequestReturned,
targetType: "purchase_request",
targetPath: "/purchase-requests",
modelName: "purchaseRequest",
record: {
id: "purchase-1",
request_no: "CG-TEST-001",
total_amount: "1200.00",
applicant_id: "employee-1",
submitter_id: "employee-1",
applicant: { id: "employee-1", name: "测试申请人" },
},
expectedBody: "申请人:测试申请人\n金额:1200.00\n单号:CG-TEST-001\n结果:已退回,可修改后重新提交:请补充说明",
},
{
entityType: "travel_request",
entityId: "travel-1",
notificationType: NOTIFICATION_TYPES.travelRequestReturned,
targetType: "travel_request",
targetPath: "/travel-requests?id=travel-1",
modelName: "travelRequest",
record: {
id: "travel-1",
request_no: "CL-TEST-001",
estimated_amount: "860.00",
applicant_employee_id: "employee-1",
submitter_id: "employee-1",
applicant: { id: "employee-1", name: "测试申请人" },
},
expectedBody: "申请人:测试申请人\n预估金额:860.00\n单号:CL-TEST-001\n结果:已退回,可修改后重新提交:请补充说明",
},
];
it.each(cases)("queues station, email, and IM work for $entityType returns", async (testCase) => {
const notificationCreate = vi.fn(async ({ data }) => ({ id: "notification-1", ...data }));
const tx = {
[testCase.modelName]: {
findUnique: vi.fn().mockResolvedValue(testCase.record),
},
notification: {
create: notificationCreate,
},
};
const emailTasks = [];
const dispatchTasks = [];
await queueWorkflowOutcomeNotifications({
tx,
actorId: "approver-1",
emailTasks,
dispatchTasks,
workflowInstance: {
entity_type: testCase.entityType,
entity_id: testCase.entityId,
},
outcome: "returned",
comment: "请补充说明",
});
expect(notificationCreate).toHaveBeenCalledTimes(1);
expect(notificationCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
recipient_employee_id: "employee-1",
type: testCase.notificationType,
body: testCase.expectedBody,
target_type: testCase.targetType,
target_id: testCase.entityId,
}),
});
expect(dispatchTasks).toEqual([
expect.objectContaining({
type: testCase.notificationType,
body: testCase.expectedBody,
targetType: testCase.targetType,
targetId: testCase.entityId,
targetPath: testCase.targetPath,
actorId: "approver-1",
}),
]);
expect(emailTasks).toEqual([
expect.objectContaining({
type: testCase.notificationType,
recipientEmployeeIds: ["employee-1"],
body: testCase.expectedBody,
targetPath: testCase.targetPath,
}),
]);
});
it("deduplicates purchase applicant and submitter before creating side effects", async () => {
const notificationCreate = vi.fn(async ({ data }) => ({ id: "notification-1", ...data }));
const tx = {
purchaseRequest: {
findUnique: vi.fn().mockResolvedValue({
id: "purchase-1",
request_no: "CG-TEST-001",
total_amount: "1200.00",
applicant_id: "employee-1",
submitter_id: "employee-1",
applicant: { id: "employee-1", name: "测试申请人" },
}),
},
notification: { create: notificationCreate },
};
const emailTasks = [];
const dispatchTasks = [];
await queueWorkflowOutcomeNotifications({
tx,
actorId: "approver-1",
emailTasks,
dispatchTasks,
workflowInstance: { entity_type: "purchase_request", entity_id: "purchase-1" },
outcome: "returned",
comment: "请补充说明",
});
expect(notificationCreate).toHaveBeenCalledTimes(1);
expect(emailTasks[0].recipientEmployeeIds).toEqual(["employee-1"]);
expect(dispatchTasks[0].notifications).toHaveLength(1);
});
});
@@ -85,7 +85,7 @@ describe("workflow travel helpers", () => {
}); });
expect(buildReturnedCopy(reimbursementMeta, "请补发票")).toEqual({ expect(buildReturnedCopy(reimbursementMeta, "请补发票")).toEqual({
title: "【已退回】报销单 BX-20260325-001", title: "【已退回】报销单 BX-20260325-001",
body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n结果:已退回:请补发票", body: "申请人:刘报销\n金额:880.00\n单号:BX-20260325-001\n结果:已退回,可修改后重新提交:请补发票",
}); });
expect(buildRemindedTaskCopy(reimbursementMeta)).toEqual({ expect(buildRemindedTaskCopy(reimbursementMeta)).toEqual({
title: "【催办】报销单 BX-20260325-001 待审批", title: "【催办】报销单 BX-20260325-001 待审批",
@@ -97,14 +97,20 @@ describe("workflow travel helpers", () => {
}); });
}); });
it("resolves approved and rejected notification types for travel workflows", () => { it.each([
expect(resolveWorkflowOutcomeNotificationType("travel_request", "approved")).toBe( ["reimbursement", NOTIFICATION_TYPES.reimbursementReturned],
NOTIFICATION_TYPES.travelRequestApproved, ["leave", NOTIFICATION_TYPES.leaveReturned],
); ["purchase_request", NOTIFICATION_TYPES.purchaseRequestReturned],
expect(resolveWorkflowOutcomeNotificationType("travel_request", "rejected")).toBe( ["procurement", NOTIFICATION_TYPES.purchaseRequestReturned],
NOTIFICATION_TYPES.travelRequestRejected, ["travel_request", NOTIFICATION_TYPES.travelRequestReturned],
); ])("resolves returned notification type for %s workflows", (entityType, expectedType) => {
expect(resolveWorkflowOutcomeNotificationType("travel_request", "returned")).toBeNull(); expect(resolveWorkflowOutcomeNotificationType(entityType, "returned")).toBe(expectedType);
});
it("keeps travel approved/rejected and attendance returned outcome mappings unchanged", () => {
expect(resolveWorkflowOutcomeNotificationType("travel_request", "approved")).toBe(NOTIFICATION_TYPES.travelRequestApproved);
expect(resolveWorkflowOutcomeNotificationType("travel_request", "rejected")).toBe(NOTIFICATION_TYPES.travelRequestRejected);
expect(resolveWorkflowOutcomeNotificationType("attendance_correction", "returned")).toBeNull();
}); });
it("builds pending task productivity summary with overdue buckets", () => { it("builds pending task productivity summary with overdue buckets", () => {
@@ -1,3 +1,7 @@
const {
NOTIFICATION_DOMAIN_SCOPES,
NOTIFICATION_TYPES,
} = require("../../../src/constants/notificationTypes");
const { const {
isImChannelTypeSupported, isImChannelTypeSupported,
matchesNotificationChannel, matchesNotificationChannel,
@@ -88,4 +92,37 @@ describe("IM routing", () => {
}), }),
).toBe(false); ).toBe(false);
}); });
it.each([
[NOTIFICATION_TYPES.leaveReturned, NOTIFICATION_DOMAIN_SCOPES.leave],
[NOTIFICATION_TYPES.purchaseRequestReturned, NOTIFICATION_DOMAIN_SCOPES.procurement],
[NOTIFICATION_TYPES.travelRequestReturned, NOTIFICATION_DOMAIN_SCOPES.travel],
])("requires both the allowed type and domain for %s", (notificationType, domainScope) => {
const buildChannel = (routing) => ({
enabled: true,
channel_type: "wecom",
config_json: { routing },
});
const context = {
notificationType,
domainScope,
recipientRoleCodes: ["employee"],
};
expect(matchesNotificationChannel(buildChannel({
roleCodes: [],
domainScopes: [domainScope],
allowedTypes: [notificationType],
}), context)).toBe(true);
expect(matchesNotificationChannel(buildChannel({
roleCodes: [],
domainScopes: [NOTIFICATION_DOMAIN_SCOPES.workflow],
allowedTypes: [notificationType],
}), context)).toBe(false);
expect(matchesNotificationChannel(buildChannel({
roleCodes: [],
domainScopes: [domainScope],
allowedTypes: [NOTIFICATION_TYPES.workflowTaskAssigned],
}), context)).toBe(false);
});
}); });