From 2e69fe4edb6e72e7cfd64780b56660f3060be332 Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 4 Sep 2026 16:22:27 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=A1=E6=89=B9=E9=80=80?= =?UTF-8?q?=E5=9B=9E=E7=BB=93=E6=9E=9C=E9=80=9A=E7=9F=A5=20[09-04-approval?= =?UTF-8?q?-return-notification-gap]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/approval-workflow.md | 62 ++++++++ .../check.jsonl | 1 + .../design.md | 141 ++++++++++++++++ .../implement.jsonl | 1 + .../implement.md | 92 +++++++++++ .../prd.md | 107 +++++++++++++ .../research/return-notification-gap.md | 113 +++++++++++++ .../task.json | 26 +++ docs/集成边界.md | 7 +- docs/领域事件清单.md | 5 + server/src/constants/notificationTypes.js | 10 ++ server/src/routes/workflow.js | 4 + .../services/notifications/workflowCopy.js | 2 +- .../unit/constants/notificationTypes.test.js | 17 ++ .../tests/unit/routes/notifications.test.js | 20 ++- .../tests/unit/routes/workflow.batch.test.js | 52 ++++++ .../workflow.return-notifications.test.js | 150 ++++++++++++++++++ .../tests/unit/routes/workflow.travel.test.js | 24 +-- server/tests/unit/services/imRouting.test.js | 37 +++++ 19 files changed, 857 insertions(+), 14 deletions(-) create mode 100644 .trellis/tasks/09-04-approval-return-notification-gap/check.jsonl create mode 100644 .trellis/tasks/09-04-approval-return-notification-gap/design.md create mode 100644 .trellis/tasks/09-04-approval-return-notification-gap/implement.jsonl create mode 100644 .trellis/tasks/09-04-approval-return-notification-gap/implement.md create mode 100644 .trellis/tasks/09-04-approval-return-notification-gap/prd.md create mode 100644 .trellis/tasks/09-04-approval-return-notification-gap/research/return-notification-gap.md create mode 100644 .trellis/tasks/09-04-approval-return-notification-gap/task.json create mode 100644 server/tests/unit/routes/workflow.return-notifications.test.js diff --git a/.trellis/spec/hyb-oa/approval-workflow.md b/.trellis/spec/hyb-oa/approval-workflow.md index 6482daa0..4110da7a 100644 --- a/.trellis/spec/hyb-oa/approval-workflow.md +++ b/.trellis/spec/hyb-oa/approval-workflow.md @@ -550,6 +550,68 @@ reimbursement UI renders only declared capabilities 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 ### 1. Scope / Trigger diff --git a/.trellis/tasks/09-04-approval-return-notification-gap/check.jsonl b/.trellis/tasks/09-04-approval-return-notification-gap/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-04-approval-return-notification-gap/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/09-04-approval-return-notification-gap/design.md b/.trellis/tasks/09-04-approval-return-notification-gap/design.md new file mode 100644 index 00000000..e103beb3 --- /dev/null +++ b/.trellis/tasks/09-04-approval-return-notification-gap/design.md @@ -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=`。本任务不改变页面路由或新增前端定位能力。 + +## 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 历史,因此不设计补发器。若发布前复核发现新样本,只做脱敏清单与状态分类,不自动通知。过期事件是否补发由用户另行判断。 diff --git a/.trellis/tasks/09-04-approval-return-notification-gap/implement.jsonl b/.trellis/tasks/09-04-approval-return-notification-gap/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/09-04-approval-return-notification-gap/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/09-04-approval-return-notification-gap/implement.md b/.trellis/tasks/09-04-approval-return-notification-gap/implement.md new file mode 100644 index 00000000..c89a28ac --- /dev/null +++ b/.trellis/tasks/09-04-approval-return-notification-gap/implement.md @@ -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,也未创建或退回生产测试单。真实企业微信送达继续保持未验证。 diff --git a/.trellis/tasks/09-04-approval-return-notification-gap/prd.md b/.trellis/tasks/09-04-approval-return-notification-gap/prd.md new file mode 100644 index 00000000..37d91466 --- /dev/null +++ b/.trellis/tasks/09-04-approval-return-notification-gap/prd.md @@ -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`、修改产品代码或写入生产配置。 diff --git a/.trellis/tasks/09-04-approval-return-notification-gap/research/return-notification-gap.md b/.trellis/tasks/09-04-approval-return-notification-gap/research/return-notification-gap.md new file mode 100644 index 00000000..537f6c03 --- /dev/null +++ b/.trellis/tasks/09-04-approval-return-notification-gap/research/return-notification-gap.md @@ -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=` | + +站内通知记录在业务事务内创建;邮件调用和 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 或消息总线。 +- 不把邮件渠道从冻结状态解锁。 diff --git a/.trellis/tasks/09-04-approval-return-notification-gap/task.json b/.trellis/tasks/09-04-approval-return-notification-gap/task.json new file mode 100644 index 00000000..54a4a5f7 --- /dev/null +++ b/.trellis/tasks/09-04-approval-return-notification-gap/task.json @@ -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": {} +} \ No newline at end of file diff --git a/docs/集成边界.md b/docs/集成边界.md index c0ffd5de..0dc35b15 100644 --- a/docs/集成边界.md +++ b/docs/集成边界.md @@ -96,8 +96,9 @@ ### 3.2 一期明确覆盖范围 - 待办类:`workflow.task_assigned` - 报销结果:`reimbursement.approved / rejected / returned / paid` -- 请假结果:`leave.approved / rejected` -- 采购结果:`purchase_request.approved / rejected` +- 请假结果:`leave.approved / rejected / returned` +- 采购结果:`purchase_request.approved / rejected / returned` +- 差旅结果:`travel_request.approved / rejected / returned` - 资产流转:`asset.assigned / returned / transferred` - 盘点结果:`inventory_check.completed` @@ -227,6 +228,8 @@ ### 6.3 当前 IM 一期实现说明 - 运行时仍以通知类型驱动 IM 入队,而不是直接暴露新的领域事件名。 +- 请假、采购和差旅退回分别使用 `leave.returned`、`purchase_request.returned`、`travel_request.returned`;正文明确单据已退回且可修改后重新提交。这三项是运行时通知类型,不新增领域 Outbox 事件名。 +- 支持 return 的业务对象在业务状态、审批历史和额度/预算释放完成后投影站内、邮件任务与 IM 派发任务;通知构造或派发失败只记录脱敏错误,不回滚已经完成的退回结果。 - `notification.im` 的 `dedupeKey` 固定为: - `${notificationType}:${targetType}:${targetId || targetPath}:${channelId}` - IM 失败仅影响 Outbox 状态,不回滚业务事务。 diff --git a/docs/领域事件清单.md b/docs/领域事件清单.md index 3aa796bb..0469941b 100644 --- a/docs/领域事件清单.md +++ b/docs/领域事件清单.md @@ -89,10 +89,13 @@ | `payment_batch.execution_requested` | 付款复核通过,责任转回实际付款人 | `payment_batch` | `/payment-batches?id=` | 待办通知;优先通知仍可付款的制单人 | | `leave.approved` | 请假审批通过 | `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.rejected` | 采购驳回 | `purchase_request` | `/purchase-requests` | 结果通知 | +| `purchase_request.returned` | 采购退回修改 | `purchase_request` | `/purchase-requests` | 结果通知;单据回到草稿,可修改后重新提交 | | `travel_request.approved` | 差旅审批通过 | `travel_request` | `/travel-requests?id=` | 结果通知 | | `travel_request.rejected` | 差旅审批驳回 | `travel_request` | `/travel-requests?id=` | 结果通知 | +| `travel_request.returned` | 差旅退回修改 | `travel_request` | `/travel-requests?id=` | 结果通知;单据回到草稿,可修改后重新提交 | | `asset.assigned` | 资产领用成功 | `asset_card` | `/assets` | 通知新领用人 | | `asset.returned` | 资产归还成功 | `asset_card` | `/assets` | 通知原持有人 | | `asset.transferred` | 资产调拨成功 | `asset_card` | `/assets` | 通知归属变更涉及人员 | @@ -107,6 +110,8 @@ 这些事件名称仍保留在长期蓝图中,但不属于 `P19+IM-1` 的首批协同覆盖范围。 +请假、采购和差旅的 `*.returned` 是运行时通知类型,不是新增的领域 Outbox 事件名;它们与既有审批结果通知共用 `notification.im` 投影和派发链。 + ## 10. P19+ERP-1 / ERP-3 已实现消费映射 - `P19+ERP-1` 没有新增新的领域事件名;ERP 一期真正落地消费的既有业务事实是 `reimbursement.paid`,运行时会先构建标准化 `ReimbursementJournalSlice`,再通过 `erp.journal.export` 写入 `IntegrationOutbox` 异步派发。 - `ReimbursementJournalSlice.sourceEventName` 固定为 `reimbursement.paid`,用于标记该凭证切片来源于“报销付款完成”这一既有业务事实;历史 `erp.reimbursement.paid` outbox 仍继续兼容。 diff --git a/server/src/constants/notificationTypes.js b/server/src/constants/notificationTypes.js index d100bc8c..23ee0097 100644 --- a/server/src/constants/notificationTypes.js +++ b/server/src/constants/notificationTypes.js @@ -21,12 +21,15 @@ const NOTIFICATION_TYPES = Object.freeze({ paymentBatchExecutionRequested: 'payment_batch.execution_requested', leaveApproved: 'leave.approved', leaveRejected: 'leave.rejected', + leaveReturned: 'leave.returned', attendanceCorrectionApproved: 'attendance_correction.approved', attendanceCorrectionRejected: 'attendance_correction.rejected', purchaseRequestApproved: 'purchase_request.approved', purchaseRequestRejected: 'purchase_request.rejected', + purchaseRequestReturned: 'purchase_request.returned', travelRequestApproved: 'travel_request.approved', travelRequestRejected: 'travel_request.rejected', + travelRequestReturned: 'travel_request.returned', assetAssigned: 'asset.assigned', assetReturned: 'asset.returned', assetTransferred: 'asset.transferred', @@ -45,12 +48,15 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([ NOTIFICATION_TYPES.paymentBatchExecutionRequested, NOTIFICATION_TYPES.leaveApproved, NOTIFICATION_TYPES.leaveRejected, + NOTIFICATION_TYPES.leaveReturned, NOTIFICATION_TYPES.attendanceCorrectionApproved, NOTIFICATION_TYPES.attendanceCorrectionRejected, NOTIFICATION_TYPES.purchaseRequestApproved, NOTIFICATION_TYPES.purchaseRequestRejected, + NOTIFICATION_TYPES.purchaseRequestReturned, NOTIFICATION_TYPES.travelRequestApproved, NOTIFICATION_TYPES.travelRequestRejected, + NOTIFICATION_TYPES.travelRequestReturned, NOTIFICATION_TYPES.assetAssigned, NOTIFICATION_TYPES.assetReturned, NOTIFICATION_TYPES.assetTransferred, @@ -69,12 +75,15 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({ [NOTIFICATION_TYPES.paymentBatchExecutionRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement, [NOTIFICATION_TYPES.leaveApproved]: 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.attendanceCorrectionRejected]: NOTIFICATION_DOMAIN_SCOPES.attendance, [NOTIFICATION_TYPES.purchaseRequestApproved]: 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.travelRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.travel, + [NOTIFICATION_TYPES.travelRequestReturned]: NOTIFICATION_DOMAIN_SCOPES.travel, [NOTIFICATION_TYPES.assetAssigned]: NOTIFICATION_DOMAIN_SCOPES.asset, [NOTIFICATION_TYPES.assetReturned]: NOTIFICATION_DOMAIN_SCOPES.asset, [NOTIFICATION_TYPES.assetTransferred]: NOTIFICATION_DOMAIN_SCOPES.asset, @@ -110,5 +119,6 @@ module.exports = { NOTIFICATION_TYPES, IM_SUPPORTED_NOTIFICATION_TYPES, NOTIFICATION_DOMAIN_SCOPES, + NOTIFICATION_TYPE_TO_DOMAIN_SCOPE, resolveNotificationDomainScope, }; diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 2a259f6c..3690d269 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -1094,6 +1094,9 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) { if (outcome === 'returned') { 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; @@ -3334,5 +3337,6 @@ module.exports.__testables = { isWorkflowTaskOverdue, listAllowedActionsForTask, parseTaskListQuery, + queueWorkflowOutcomeNotifications, resolveWorkflowOutcomeNotificationType, }; diff --git a/server/src/services/notifications/workflowCopy.js b/server/src/services/notifications/workflowCopy.js index 91ef4b91..a91638ee 100644 --- a/server/src/services/notifications/workflowCopy.js +++ b/server/src/services/notifications/workflowCopy.js @@ -247,7 +247,7 @@ function buildReturnedCopy(entityMeta, comment) { const safeComment = String(comment ?? "").trim() || "无"; return { title: `【已退回】${buildSubject(entityMeta)}`, - body: buildBody(entityMeta, `结果:已退回:${safeComment}`), + body: buildBody(entityMeta, `结果:已退回,可修改后重新提交:${safeComment}`), }; } diff --git a/server/tests/unit/constants/notificationTypes.test.js b/server/tests/unit/constants/notificationTypes.test.js index 66ffd658..1c063f90 100644 --- a/server/tests/unit/constants/notificationTypes.test.js +++ b/server/tests/unit/constants/notificationTypes.test.js @@ -1,6 +1,7 @@ const { IM_SUPPORTED_NOTIFICATION_TYPES, NOTIFICATION_DOMAIN_SCOPES, + NOTIFICATION_TYPE_TO_DOMAIN_SCOPE, NOTIFICATION_TYPES, resolveNotificationDomainScope, } = require("../../../src/constants/notificationTypes"); @@ -37,4 +38,20 @@ describe("notification types", () => { "payment_batch", )).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); + }); }); diff --git a/server/tests/unit/routes/notifications.test.js b/server/tests/unit/routes/notifications.test.js index 805a2040..3ffc0a34 100644 --- a/server/tests/unit/routes/notifications.test.js +++ b/server/tests/unit/routes/notifications.test.js @@ -1,6 +1,7 @@ const Fastify = require('fastify'); const { AppError } = require('../../../src/utils/AppError'); +const { NOTIFICATION_TYPES } = require('../../../src/constants/notificationTypes'); const { encryptStringOrThrow } = require('../../../src/services/crypto'); const notificationsRoutes = require('../../../src/routes/notifications'); @@ -446,8 +447,13 @@ describe('notifications channel routes', () => { timeoutMs: 800, routing: { roleCodes: ['manager'], - domainScopes: ['workflow'], - allowedTypes: ['workflow.task_assigned'], + domainScopes: ['workflow', 'leave', 'procurement', 'travel'], + allowedTypes: [ + NOTIFICATION_TYPES.workflowTaskAssigned, + NOTIFICATION_TYPES.leaveReturned, + NOTIFICATION_TYPES.purchaseRequestReturned, + NOTIFICATION_TYPES.travelRequestReturned, + ], }, }, }, @@ -460,6 +466,16 @@ describe('notifications channel routes', () => { webhookUrl: null, secretConfigured: false, 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(); diff --git a/server/tests/unit/routes/workflow.batch.test.js b/server/tests/unit/routes/workflow.batch.test.js index 0d38ff30..359c5857 100644 --- a/server/tests/unit/routes/workflow.batch.test.js +++ b/server/tests/unit/routes/workflow.batch.test.js @@ -150,6 +150,14 @@ async function buildApp(options = {}) { create: vi.fn(async () => ({ id: "delivery-1" })), update: vi.fn(async () => ({})), }, + notification: { + create: vi.fn(async ({ data }) => { + if (options.notificationCreateError) { + throw options.notificationCreateError; + } + return { id: "notification-1", ...data }; + }), + }, }; const prisma = { @@ -295,6 +303,50 @@ describe("workflow batch routes", () => { 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 () => { const { app, state } = await buildApp({ updateErrorTaskId: "44444444-4444-4444-8444-444444444444", diff --git a/server/tests/unit/routes/workflow.return-notifications.test.js b/server/tests/unit/routes/workflow.return-notifications.test.js new file mode 100644 index 00000000..d2b31aba --- /dev/null +++ b/server/tests/unit/routes/workflow.return-notifications.test.js @@ -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); + }); +}); diff --git a/server/tests/unit/routes/workflow.travel.test.js b/server/tests/unit/routes/workflow.travel.test.js index f6660f52..6f5f99e0 100644 --- a/server/tests/unit/routes/workflow.travel.test.js +++ b/server/tests/unit/routes/workflow.travel.test.js @@ -85,7 +85,7 @@ describe("workflow travel helpers", () => { }); expect(buildReturnedCopy(reimbursementMeta, "请补发票")).toEqual({ 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({ title: "【催办】报销单 BX-20260325-001 待审批", @@ -97,14 +97,20 @@ describe("workflow travel helpers", () => { }); }); - it("resolves approved and rejected notification types for travel workflows", () => { - expect(resolveWorkflowOutcomeNotificationType("travel_request", "approved")).toBe( - NOTIFICATION_TYPES.travelRequestApproved, - ); - expect(resolveWorkflowOutcomeNotificationType("travel_request", "rejected")).toBe( - NOTIFICATION_TYPES.travelRequestRejected, - ); - expect(resolveWorkflowOutcomeNotificationType("travel_request", "returned")).toBeNull(); + it.each([ + ["reimbursement", NOTIFICATION_TYPES.reimbursementReturned], + ["leave", NOTIFICATION_TYPES.leaveReturned], + ["purchase_request", NOTIFICATION_TYPES.purchaseRequestReturned], + ["procurement", NOTIFICATION_TYPES.purchaseRequestReturned], + ["travel_request", NOTIFICATION_TYPES.travelRequestReturned], + ])("resolves returned notification type for %s workflows", (entityType, expectedType) => { + 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", () => { diff --git a/server/tests/unit/services/imRouting.test.js b/server/tests/unit/services/imRouting.test.js index e1aac3e7..e859db71 100644 --- a/server/tests/unit/services/imRouting.test.js +++ b/server/tests/unit/services/imRouting.test.js @@ -1,3 +1,7 @@ +const { + NOTIFICATION_DOMAIN_SCOPES, + NOTIFICATION_TYPES, +} = require("../../../src/constants/notificationTypes"); const { isImChannelTypeSupported, matchesNotificationChannel, @@ -88,4 +92,37 @@ describe("IM routing", () => { }), ).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); + }); });