From cf6754819c09dc42c13913117d05419f2ff60a04 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 8 Mar 2026 13:14:30 +0800 Subject: [PATCH] =?UTF-8?q?docs/test(P19IM1-5,P19IM1-6):=20=E6=94=B6?= =?UTF-8?q?=E5=8F=A3IM=E5=8D=8F=E5=90=8C=E6=96=87=E6=A1=A3=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E4=B8=8E=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/domain-events.md | 63 ++- docs/feature-matrix.md | 16 +- docs/integration-boundaries.md | 125 +++-- ...8-10-p19-im-1-l5-collaboration-adapter.csv | 16 +- scripts/validate-feature-matrix.py | 1 + server/scripts/regression-all.sh | 6 +- server/scripts/regression-im-notifier.sh | 529 ++++++++++++++++++ 7 files changed, 675 insertions(+), 81 deletions(-) create mode 100755 server/scripts/regression-im-notifier.sh diff --git a/docs/domain-events.md b/docs/domain-events.md index e658e881..e07de1ce 100644 --- a/docs/domain-events.md +++ b/docs/domain-events.md @@ -1,18 +1,20 @@ -# 领域事件清单(P19 冻结版) +# 领域事件清单(P19 / P19+IM-1) -**快照日期**:2026-03-07 -**目标**:冻结 `hyb-oa` 当前采购与资产域对外发布的标准事件名称、触发时机与最小载荷,为未来 ERP / IM / AI 适配提供统一事实源。 +**快照日期**:2026-03-08 +**目标**:冻结 `hyb-oa` 当前采购与资产域的标准领域事件名,并补充 `P19+IM-1` 已实际消费的 IM 通知类型清单,作为后续 ERP / IM / AI 适配的事实源。 ## 1. 使用原则 -- 本文档只定义**领域事件名与边界**,不代表当前已实现事件总线。 -- 未来若落地 Outbox / webhook / MQ,事件名与载荷应保持向后兼容。 -- 事件仅表达“已经发生的领域事实”,不表达待办命令。 +- 本文档同时维护两类事实: + - 领域事件名(长期稳定边界) + - IM 一期已消费的通知类型(当前运行时实现) +- 未来若继续扩展 Outbox / webhook / MQ,领域事件名与最小载荷必须保持向后兼容。 +- 领域事件只表达“已经发生的业务事实”,不表达待办命令。 -## 2. 标准事件清单 +## 2. 标准领域事件清单 | 事件名 | 触发时机 | 领域对象 | 最小载荷字段 | 推荐消费者 | 备注 | | --- | --- | --- | --- | --- | --- | -| `purchase_request.submitted` | 采购申请提交成功后 | `purchase_request` | `purchaseRequestId`, `requestNo`, `applicantId`, `departmentId`, `legalEntityId`, `costCenterId`, `supplierId`, `budgetPoolId`, `totalAmount`, `status` | IM 通知、ERP 预处理、AI 摘要 | 仅表示进入 `reviewing` | +| `purchase_request.submitted` | 采购申请提交成功后 | `purchase_request` | `purchaseRequestId`, `requestNo`, `applicantId`, `departmentId`, `legalEntityId`, `costCenterId`, `supplierId`, `budgetPoolId`, `totalAmount`, `status` | IM、ERP、AI | 表示进入 `reviewing` | | `purchase_request.approved` | 采购申请审批通过后 | `purchase_request` | `purchaseRequestId`, `requestNo`, `approvedBy`, `legalEntityId`, `costCenterId`, `supplierId`, `budgetPoolId`, `totalAmount`, `status` | ERP、IM、AI | 对应预算 `confirm` 已完成 | | `purchase_request.rejected` | 采购申请审批驳回后 | `purchase_request` | `purchaseRequestId`, `requestNo`, `actorId`, `reason`, `status` | IM、AI | 若存在预算预留,应已释放 | | `purchase_request.withdrawn` | 申请人撤回采购申请后 | `purchase_request` | `purchaseRequestId`, `requestNo`, `actorId`, `status` | IM、AI、ERP 补偿 | 表示由 `reviewing` 回到 `draft` | @@ -20,7 +22,7 @@ | `asset.assigned` | 资产领用成功后 | `asset_card` | `assetId`, `assetNo`, `employeeId`, `assignedAt`, `location`, `status` | IM、AI | 对应新增 open assignment | | `asset.returned` | 资产归还成功后 | `asset_card` | `assetId`, `assetNo`, `employeeId`, `returnedAt`, `location`, `status` | IM、AI | 对应关闭 open assignment | | `asset.transferred` | 资产调拨成功后 | `asset_card` | `assetId`, `assetNo`, `fromEmployeeId`, `toEmployeeId`, `fromLocation`, `toLocation`, `transferredAt`, `status` | ERP、IM、AI | 可只改位置,不一定改归属人 | -| `inventory_check.completed` | 盘点任务完成后 | `inventory_check` | `inventoryCheckId`, `checkNo`, `scopeType`, `scopeRefId`, `completedAt`, `itemCount`, `summary` | ERP、IM、AI | `summary` 建议包含 matched/missing/extra/damaged 计数 | +| `inventory_check.completed` | 盘点任务完成后 | `inventory_check` | `inventoryCheckId`, `checkNo`, `scopeType`, `scopeRefId`, `completedAt`, `itemCount`, `summary` | ERP、IM、AI | `summary` 建议包含 matched / missing / extra / damaged 计数 | ## 3. 事件名与运行时对象映射 @@ -32,13 +34,13 @@ ## 4. 最小 Envelope 建议 -未来若落地事件投递,建议统一使用如下 envelope: +未来若继续扩展事件投递,建议统一使用如下 envelope: ```json { "eventName": "purchase_request.submitted", "eventId": "uuid", - "occurredAt": "2026-03-07T23:22:00+08:00", + "occurredAt": "2026-03-08T12:18:10+08:00", "actorId": "user-id", "entityType": "purchase_request", "entityId": "entity-id", @@ -52,12 +54,39 @@ - 新增字段必须保持向后兼容;删除字段需要单独版本升级。 - 载荷字段命名应与 HTTP camelCase 输出保持一致,不直接暴露数据库 snake_case 列名。 -## 6. 明确不纳入 P19 的事件 -- `purchase_request.archived`:待未来需要对接采购归档通知时再冻结 -- `inventory_check.started`:当前不作为外部关键事实源 -- `asset.repaired` / `asset.retired`:待未来维修/报废专项后再定义 +## 6. 明确不纳入当前事件主表的事项 +- `purchase_request.archived`:待未来采购归档通知专项再冻结 +- `inventory_check.started`:当前不是对外关键事实源 +- `asset.repaired` / `asset.retired`:待未来维修 / 报废专项后再定义 -## 7. 推荐的首批消费场景 -- IM:采购提交、采购审批结果、资产领用归还、盘点完成提醒 +## 7. 推荐消费场景 +- IM:采购审批结果、报销结果、资产领用归还、盘点完成提醒 - ERP:采购审批通过摘要、资产卡片新增摘要、盘点完成摘要 - AI:审批摘要、异常提示、主数据重复建议、盘点结果总结 + +## 8. P19+IM-1 已消费通知类型 + +> 说明:IM 一期运行时并未新增新的领域事件名,而是基于现有业务事实统一沉淀为通知类型,再通过 `notification.im` Outbox 异步派发。 + +| 通知类型 | 当前来源事实 | 默认 targetType | 默认 targetPath | 说明 | +| --- | --- | --- | --- | --- | +| `workflow.task_assigned` | workflow 新待办 / 转审待办 | `workflow_task` | `/workflow/tasks/:taskId` | 当前唯一待办类通知 | +| `reimbursement.approved` | 报销审批通过 | `reimbursement_form` | `/reimbursements/:id` | 结果通知 | +| `reimbursement.rejected` | 报销驳回 | `reimbursement_form` | `/reimbursements/:id` | 结果通知 | +| `reimbursement.returned` | 报销退回 | `reimbursement_form` | `/reimbursements/:id` | 结果通知 | +| `reimbursement.paid` | 报销付款完成 | `reimbursement_form` | `/reimbursements/:id` | 财务结果通知 | +| `leave.approved` | 请假审批通过 | `leave_request` | `/leave-requests` | 结果通知 | +| `leave.rejected` | 请假驳回 | `leave_request` | `/leave-requests` | 结果通知 | +| `purchase_request.approved` | 采购审批通过 | `purchase_request` | `/purchase-requests` | 结果通知 | +| `purchase_request.rejected` | 采购驳回 | `purchase_request` | `/purchase-requests` | 结果通知 | +| `asset.assigned` | 资产领用成功 | `asset_card` | `/assets` | 通知新领用人 | +| `asset.returned` | 资产归还成功 | `asset_card` | `/assets` | 通知原持有人 | +| `asset.transferred` | 资产调拨成功 | `asset_card` | `/assets` | 通知归属变更涉及人员 | +| `inventory_check.completed` | 盘点完成 | `inventory_check` | `/inventory-checks` | 通知创建人与 `finance_mgr` 角色 | + +## 9. 当前仍未接入 IM 的冻结事件 +- `purchase_request.submitted` +- `purchase_request.withdrawn` +- `inventory_check.started` + +这些事件名称仍保留在长期蓝图中,但不属于 `P19+IM-1` 的首批协同覆盖范围。 diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index 744a5133..69219126 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -1,8 +1,8 @@ -# hyb-oa 实现状态矩阵(P5-P19) +# hyb-oa 实现状态矩阵(P5-P19+IM-1) -**快照日期**:2026-03-07 -**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6` + `P16C-0-P16C-6` + `R9-0-R9-3` + `G2-0-G2-6` + `P17A-0-P17A-6` + `P17B-0-P17B-6` + `P18A-0-P18A-6` + `P18B-0-P18B-6` + `P19-0-P19-6`,共 192 项。 -**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv` + `issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv` + `issues/2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv` + `issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv` + `issues/2026-03-07_23-22-00-p19-l4-integration-readiness.csv`(执行合同) +**快照日期**:2026-03-08 +**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6` + `P16C-0-P16C-6` + `R9-0-R9-3` + `G2-0-G2-6` + `P17A-0-P17A-6` + `P17B-0-P17B-6` + `P18A-0-P18A-6` + `P18B-0-P18B-6` + `P19-0-P19-6` + `P19IM1-0-P19IM1-6`,共 199 项。 +**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv` + `issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv` + `issues/2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv` + `issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv` + `issues/2026-03-07_23-22-00-p19-l4-integration-readiness.csv` + `issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv`(执行合同) **说明**: - 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。 @@ -222,4 +222,10 @@ | P19-5 | 文档与矩阵同步 | N/A | 2026-03-07_23-22-00-p19-l4-integration-readiness.csv | 2026-03-07_23-22-00-p19-l4-integration-readiness.csv | 2026-03-07 | DONE | validate-feature-matrix | Feature Matrix 标题、范围、事实源与 P19 条目同步到 `P5-P19` | | P19-6 | P19蓝图验收收口 | N/A | 2026-03-07_23-22-00-p19-l4-integration-readiness.csv | 2026-03-07_23-22-00-p19-l4-integration-readiness.csv | 2026-03-07 | DONE | validate-issues-csv + validate-feature-matrix | P19 仅交付文档蓝图且不包含运行时代码改动,校验通过后收口 | - +| P19IM1-0 | P19IM1执行合同建档 | N/A | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08 | DONE | CSV 校验 | 企微协同适配一期执行合同建档,冻结仅做群机器人 + 深链 + Outbox 派发 | +| P19IM1-1 | 通知渠道CRUD与路由配置 | N/A | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08 | DONE | server:test + node --check | `NotificationChannel` 创建更新查询与角色 / 业务域 / 类型路由校验落地,不新增 Prisma 迁移 | +| P19IM1-2 | Outbox化IM派发与企微适配器 | N/A | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08 | DONE | server:test + regression-im-notifier | `notification.im` 真实走 `IntegrationOutbox`,支持企微 `mock` / `wecom_webhook` 并与邮件拆分 | +| P19IM1-3 | 业务事件接入与通知类型规范化 | N/A | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08 | DONE | server:test + regression-all | 报销 / 请假 / 采购 / 资产 / 盘点的首批待办与结果通知统一接入 IM 入队 | +| P19IM1-4 | 前端渠道管理页与导航入口 | N/A | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08 | DONE | client:typecheck/build | 新增 `NotificationChannelsPage`、`/system/notification-channels` 路由与菜单入口,支持测试发送 | +| P19IM1-5 | OpenAPI文档与矩阵同步 | N/A | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08 | DONE | validate-feature-matrix | 集成边界、领域事件与 Feature Matrix 更新到 `P5-P19+IM-1` 并纳入新合同事实源 | +| P19IM1-6 | P19IM1回归与验收收口 | N/A | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | 2026-03-08 | DONE | server:test + regression-all + client:typecheck/build | 企微 mock / webhook、渠道后台、业务入队与回归脚本全部通过,且站内通知 / 邮件零回归 | diff --git a/docs/integration-boundaries.md b/docs/integration-boundaries.md index 7dcb72f8..e81b85e3 100644 --- a/docs/integration-boundaries.md +++ b/docs/integration-boundaries.md @@ -1,25 +1,25 @@ -# 集成边界冻结(P19) +# 集成边界冻结(P19 / P19+IM-1) -**快照日期**:2026-03-07 -**当前基线**:`P18B` 已完成并进入 `docs/feature-matrix.md`。 -**目标**:冻结 `hyb-oa` 在 ERP/财务、IM/协同、AI 与 MCP 使用上的边界,作为后续专项实施的约束,不直接引入任何运行时代码。 +**快照日期**:2026-03-08 +**当前基线**:`P19+IM-1` 已完成,当前运行时已落地企微群机器人 + 深链 + Outbox 异步派发的一期协同适配。 +**目标**:在保持核心域稳定的前提下,冻结 ERP / IM / AI / MCP 的长期边界,并记录 IM 一期的已实现范围与后续禁入规则。 ## 1. 总体原则 ### 1.1 只通过边界集成 -- 外部系统只允许通过两种形式接入: - - 领域事件(推荐,后续可落到 Outbox) +- 外部系统只允许通过两种方式接入: + - 领域事件 / Outbox 异步投递 - 显式 API 适配层 - 禁止外部系统直写核心业务表。 - 禁止为了对接外部系统,在核心事务表中提前加入 ERP 私有字段、IM 私有字段或 AI 中间状态字段。 ### 1.2 核心域优先 -- `purchase_request`、`asset_card`、`inventory_check` 等领域模型继续由业务服务自己维护。 +- `purchase_request`、`asset_card`、`inventory_check` 等领域模型继续由业务服务自身维护。 - 集成层只能消费既有领域事实,不能反向篡改领域判定逻辑。 -- 若未来需要双向同步,必须先定义“系统主责方(source of truth)”,再设计幂等回写接口。 +- 若未来需要双向同步,必须先定义系统主责方(source of truth),再设计幂等回写接口。 ### 1.3 回滚优先 -- 所有未来集成必须支持“关闭适配器后核心域仍可独立运行”。 +- 所有集成必须支持“关闭适配器后核心域仍可独立运行”。 - 外部调用失败只能影响外围联动,不得阻断核心单据保存、审批和查询。 - 对外推送必须具备可重试、可补偿、可审计能力。 @@ -27,54 +27,79 @@ ### 2.1 允许进入适配层的对象 - 主数据:`LegalEntity`、`CostCenter`、`Supplier` -- 采购:`purchase_request` 的审批结果、预算占用确认结果、PO/合同归档摘要 +- 采购:`purchase_request` 审批结果、预算占用确认结果、PO / 合同归档摘要 - 资产:`asset_card` 基础卡片信息、归属变更摘要、盘点完成摘要 ### 2.2 推荐适配方式 - **主数据同步**: - 由 `hyb-oa` 输出标准化读模型或事件 - - ERP 适配器负责字段映射与批量/增量同步 -- **财务凭证/单据接口**: - - 先生成中间格式(例如 JSON/CSV/显式 API payload) + - ERP 适配器负责字段映射与批量 / 增量同步 +- **财务凭证 / 单据接口**: + - 先生成中间格式(JSON / CSV / 显式 API payload) - 再由独立适配器调用外部 ERP - **失败处理**: - 在适配层重试 - - 失败记录在适配层日志/事件投递表,不回写核心事务状态为“失败” + - 失败记录在适配层日志 / Outbox 中,不回写核心事务状态为“失败” ### 2.3 明确禁止事项 - 禁止在 `PurchaseRequest`、`AssetCard`、`BudgetPool` 等核心表中直接加入 ERP 专有主键、分录号、凭证表字段。 - 禁止应用直接连写 ERP 主表。 - 禁止把 ERP 状态机复制到 `hyb-oa` 核心路由中。 -### 2.4 建议的后续专项 +### 2.4 建议后续专项 - `P19+ERP-1`:主数据同步协议 -- `P19+ERP-2`:采购审批结果导出/回写协议 +- `P19+ERP-2`:采购审批结果导出 / 回写协议 - `P19+ERP-3`:资产台账摘要导出协议 ## 3. IM / 协同适配边界 -### 3.1 允许能力 -- 飞书 / 企微消息通知 -- 待办提醒 -- 深链跳转(例如采购审批详情、资产盘点详情) -- 审批摘要的只读推送 +### 3.1 一期已实现能力(P19+IM-1) +- 已复用 `IntegrationOutbox` 与派发作业,实现 `notification.im` 异步派发。 +- 已新增 `NotificationChannel` 的创建 / 更新 / 列表 / 测试接口与后台页面。 +- 已接入企微群机器人两种模式: + - `mock` + - `wecom_webhook` +- 已支持三维路由过滤: + - `roleCodes` + - `domainScopes` + - `allowedTypes` +- 已支持 `APP_ORIGIN + targetPath` 生成深链;缺少 `APP_ORIGIN` 时允许发送但不阻断业务。 +- 站内通知、邮件、IM 三通道并存;业务事务内只负责写通知与 Outbox,真正派发在作业中完成。 -### 3.2 边界约束 -- IM 适配器只消费领域事件,不直接读取或修改数据库。 -- IM 适配器可以构造消息文案、跳转链接、卡片摘要,但不能替代工作流审批判定。 -- 任何“点击消息即审批”的能力,必须仍然回到 `hyb-oa` 的鉴权与工作流接口执行。 +### 3.2 一期明确覆盖范围 +- 待办类:`workflow.task_assigned` +- 报销结果:`reimbursement.approved / rejected / returned / paid` +- 请假结果:`leave.approved / rejected` +- 采购结果:`purchase_request.approved / rejected` +- 资产流转:`asset.assigned / returned / transferred` +- 盘点结果:`inventory_check.completed` -### 3.3 禁止事项 -- 禁止把 IM 用户标识写入核心审批流状态字段。 -- 禁止在 IM 侧持久化领域真相状态并反向覆盖业务库。 +### 3.3 一期明确不做 +- 飞书、钉钉真实适配器 +- 员工 IM OpenID / UserID / UnionID 主数据 +- 点对点个人消息 +- IM 卡片回调审批 +- 新的 Outbox 表或消息总线 +- 默认全员广播频道 + +### 3.4 运行时回滚策略 +- 设置 `IM_NOTIFIER_ENABLED=false` +- 在管理页中禁用全部通知渠道 +- 保留 `integration_outbox` 历史记录,不回删 +- 即使 IM 全部关闭,站内通知与邮件仍继续工作 + +### 3.5 后续专项建议 +- `P19+IM-2`:飞书 / 钉钉适配器 +- `P19+IM-3`:个人直达与 IM 身份映射 +- `P19+IM-4`:消息卡片交互与回调审批(仍必须回到 OA 鉴权) ## 4. AI 增强边界 ### 4.1 允许能力 - 审批摘要生成 -- 采购/资产异常提示 +- 采购 / 资产异常提示 - 主数据重名与重复建议 -- 文档/制度/流程问答检索 +- 文档 / 制度 / 流程问答检索 ### 4.2 强制约束 - AI 只做建议,不直接写库。 @@ -86,10 +111,10 @@ - 禁止 AI 自动创建、审批、归档业务单据。 - 禁止 AI 直接修改预算、工作流、资产归属等核心状态。 -## 5. 领域事件交付规范 +## 5. 事件与通知交付规范 ### 5.1 事件最小结构 -后续若引入事件投递,建议统一包含: +后续若继续扩展领域事件 / Outbox,建议统一包含: - `eventName` - `eventId` - `occurredAt` @@ -101,18 +126,24 @@ ### 5.2 幂等要求 - 同一 `eventId` 必须可安全重放。 -- 消费端必须以 `eventId` 或 `entityId + status/version` 方式做幂等。 +- 消费端必须以 `eventId` 或 `entityId + status / version` 方式做幂等。 - 不允许消费端以“收到多次事件”为理由修改核心业务状态。 +### 5.3 当前 IM 一期实现说明 +- 运行时仍以通知类型驱动 IM 入队,而不是直接暴露新的领域事件名。 +- `notification.im` 的 `dedupeKey` 固定为: + - `${notificationType}:${targetType}:${targetId || targetPath}:${channelId}` +- IM 失败仅影响 Outbox 状态,不回滚业务事务。 + ## 6. MCP 基线 ### 6.1 当前结论 -- `P19` 阶段 **不启用 MCP**。 -- 当前仓库中 **不创建** `docs/mcp-tools.md`,因为尚未进入实际 MCP 使用场景。 +- `P19+IM-1` 阶段 **不启用 MCP**。 +- 当前仓库中 **仍不创建** `docs/mcp-tools.md`,因为尚未进入真实 MCP 使用场景。 ### 6.2 首次启用前置条件 若未来需要启用 MCP,固定顺序如下: -1. 明确真实用途(数据库只读排障、GitHub PR/Issue/CI 协作等) +1. 明确真实用途(数据库只读排障、GitHub PR / Issue / CI 协作等) 2. 新增 `docs/mcp-tools.md` 3. 使用 `mcp-tools-catalog` 生成工具目录 4. 在执行合同中记录启用原因、风险与禁用边界 @@ -122,17 +153,13 @@ - `github MCP`:PR / Issue / CI 协作 ### 6.4 禁止事项 -- 禁止在没有 `docs/mcp-tools.md` 的前提下默认启用 MCP -- 禁止用 MCP 绕过仓库内已存在的本地验证命令 -- 禁止将 MCP 用作写生产数据的通道 +- 禁止在没有 `docs/mcp-tools.md` 的前提下默认启用 MCP。 +- 禁止用 MCP 绕过仓库内已存在的本地验证命令。 +- 禁止将 MCP 用作写生产数据的通道。 -## 7. 本阶段明确不做 -- 不实现 Outbox 表 -- 不新增对外 webhook -- 不接入 ERP、飞书、企微或大模型 SDK -- 不修改任何路由、Prisma 模型、前端页面和工作流逻辑 - -## 8. 下一步专项建议 -- 若优先打通 ERP,请先开“主数据同步协议”专项 -- 若优先做协同体验,请先开“IM 待办通知”专项 -- 若优先做智能化,请先开“审批摘要与异常提示”专项 +## 7. 当前阶段明确不做 +- 不新增新的集成总线表结构 +- 不接入 ERP 真实写回逻辑 +- 不接入飞书 / 钉钉真实生产推送 +- 不新增 AI 写库动作 +- 不引入绕过工作流与权限体系的外部回调路径 diff --git a/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv b/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv index 3dfc3ed5..a25105fa 100644 --- a/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv +++ b/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv @@ -1,8 +1,8 @@ -ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes -P19IM1-0,P19IM1执行合同建档,创建并校验P19IM1企微协同适配执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py /home/shunli/Projects/hyb-oa/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv,manual,TODO,TODO,TODO,/home/shunli/Projects/hyb-oa/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv,[],P19IM1仅交付企微群机器人+深链+按角色业务域路由的IM一期能力 -P19IM1-1,通知渠道CRUD与路由配置,补齐通知渠道创建更新查询与路由配置校验能力,可维护wecom渠道并保存routing与template配置,cd server && npm run test,manual,TODO,TODO,TODO,server/src/routes/notifications.js | server/src/services/notifications | server/tests,"[""P19IM1-0""]",不新增Prisma迁移 继续复用NotificationChannel的config_json与template_json -P19IM1-2,Outbox化IM派发与企微适配器,实现notification.im真实渠道加载 Outbox派发与wecom webhook/mock发送,业务通知写入IntegrationOutbox并由job派发成功或失败重试,cd server && npm run test,manual,TODO,TODO,TODO,server/src/services/integrations | server/src/jobs/integrationOutboxJob.js | server/tests,"[""P19IM1-1""]",IM从sendNotificationEmailSafe中拆出避免双发 保留邮件独立发送 -P19IM1-3,业务事件接入与通知类型规范化,新增通知类型常量并将报销请假采购资产盘点的首批事件接入IM入队,workflow.task_assigned与首批结果类通知均可按规则入队,cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh,manual,TODO,TODO,TODO,server/src/constants/notificationTypes.js | server/src/routes/workflow.js | server/src/routes/reimbursements.js | server/src/routes/purchaseRequests.js | server/src/routes/assetMovements.js | server/src/routes/inventoryChecks.js,"[""P19IM1-2""]",只覆盖待办+结果+盘点 不接auth和withdrawn等二期通知 -P19IM1-4,前端渠道管理页与导航入口,新增通知渠道管理页面 路由 菜单与测试发送交互,finance_mgr可进入后台维护wecom渠道并执行测试发送,cd client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,client/src/pages/NotificationChannelsPage.tsx | client/src/api/notificationChannels.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx,"[""P19IM1-3""]",旧api/integrations.ts中的testNotificationChannel保留兼容但前端切换到新模块 -P19IM1-5,OpenAPI文档与矩阵同步,同步P19IM1的OpenAPI 文档 边界说明与Feature Matrix事实源,Feature Matrix更新到P5-P19+IM-1且事实源包含新合同,python3 scripts/validate-feature-matrix.py,manual,TODO,TODO,TODO,docs/integration-boundaries.md | docs/domain-events.md | docs/feature-matrix.md | scripts/validate-feature-matrix.py | /home/shunli/Projects/hyb-oa/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv,"[""P19IM1-4""]",矩阵总数固定更新为199并追加P19IM1-0到P19IM1-6 -P19IM1-6,P19IM1回归与验收收口,完成渠道CRUD IM Outbox 业务入队 前端页面与全量回归验证,企微mock/webhook可用且站内通知邮件IM三通道不互相回归,"cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build",manual,TODO,TODO,TODO,/home/shunli/Projects/hyb-oa/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | server/scripts/regression-im-notifier.sh | client/src/pages/NotificationChannelsPage.tsx,"[""P19IM1-5""]",需新增IM专项回归脚本并确认关闭IM后业务仍可正常提交 +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +P19IM1-0,P19IM1执行合同建档,创建并校验P19IM1企微协同适配执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py /home/shunli/Projects/hyb-oa/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv,manual,DONE,DONE,DONE,/home/shunli/Projects/hyb-oa/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv,[],P19IM1仅交付企微群机器人+深链+按角色业务域路由的IM一期能力 +P19IM1-1,通知渠道CRUD与路由配置,补齐通知渠道创建更新查询与路由配置校验能力,可维护wecom渠道并保存routing与template配置,cd server && npm run test,manual,DONE,DONE,DONE,server/src/routes/notifications.js | server/src/services/notifications | server/tests,"[""P19IM1-0""]",不新增Prisma迁移 继续复用NotificationChannel的config_json与template_json +P19IM1-2,Outbox化IM派发与企微适配器,实现notification.im真实渠道加载 Outbox派发与wecom webhook/mock发送,业务通知写入IntegrationOutbox并由job派发成功或失败重试,cd server && npm run test,manual,DONE,DONE,DONE,server/src/services/integrations | server/src/jobs/integrationOutboxJob.js | server/tests,"[""P19IM1-1""]",IM从sendNotificationEmailSafe中拆出避免双发 保留邮件独立发送 +P19IM1-3,业务事件接入与通知类型规范化,新增通知类型常量并将报销请假采购资产盘点的首批事件接入IM入队,workflow.task_assigned与首批结果类通知均可按规则入队,cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh,manual,DONE,DONE,DONE,server/src/constants/notificationTypes.js | server/src/routes/workflow.js | server/src/routes/reimbursements.js | server/src/routes/purchaseRequests.js | server/src/routes/assetMovements.js | server/src/routes/inventoryChecks.js,"[""P19IM1-2""]",只覆盖待办+结果+盘点 不接auth和withdrawn等二期通知 +P19IM1-4,前端渠道管理页与导航入口,新增通知渠道管理页面 路由 菜单与测试发送交互,finance_mgr可进入后台维护wecom渠道并执行测试发送,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/NotificationChannelsPage.tsx | client/src/api/notificationChannels.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx,"[""P19IM1-3""]",旧api/integrations.ts中的testNotificationChannel保留兼容但前端切换到新模块 +P19IM1-5,OpenAPI文档与矩阵同步,同步P19IM1的OpenAPI 文档 边界说明与Feature Matrix事实源,Feature Matrix更新到P5-P19+IM-1且事实源包含新合同,python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,docs/integration-boundaries.md | docs/domain-events.md | docs/feature-matrix.md | scripts/validate-feature-matrix.py | /home/shunli/Projects/hyb-oa/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv,"[""P19IM1-4""]",矩阵总数固定更新为199并追加P19IM1-0到P19IM1-6 +P19IM1-6,P19IM1回归与验收收口,完成渠道CRUD IM Outbox 业务入队 前端页面与全量回归验证,企微mock/webhook可用且站内通知邮件IM三通道不互相回归,cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,/home/shunli/Projects/hyb-oa/issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv | server/scripts/regression-im-notifier.sh | client/src/pages/NotificationChannelsPage.tsx,"[""P19IM1-5""]",需新增IM专项回归脚本并确认关闭IM后业务仍可正常提交 diff --git a/scripts/validate-feature-matrix.py b/scripts/validate-feature-matrix.py index 6cd55575..877a6137 100755 --- a/scripts/validate-feature-matrix.py +++ b/scripts/validate-feature-matrix.py @@ -38,6 +38,7 @@ CSV_FACT_SOURCES = [ ROOT / "issues" / "2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv", ROOT / "issues" / "2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv", ROOT / "issues" / "2026-03-07_23-22-00-p19-l4-integration-readiness.csv", + ROOT / "issues" / "2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv", ] ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$") diff --git a/server/scripts/regression-all.sh b/server/scripts/regression-all.sh index f1e8833e..c1b84261 100755 --- a/server/scripts/regression-all.sh +++ b/server/scripts/regression-all.sh @@ -15,6 +15,7 @@ RUN_VERIFICATION_REGRESSION="${RUN_VERIFICATION_REGRESSION:-0}" RUN_DASHBOARD_REGRESSION="${RUN_DASHBOARD_REGRESSION:-0}" RUN_BUDGET_REGRESSION="${RUN_BUDGET_REGRESSION:-0}" RUN_INTEGRATIONS_REGRESSION="${RUN_INTEGRATIONS_REGRESSION:-0}" +RUN_IM_NOTIFIER_REGRESSION="${RUN_IM_NOTIFIER_REGRESSION:-1}" RUN_FEATURE_FLAGS_REGRESSION="${RUN_FEATURE_FLAGS_REGRESSION:-0}" JSON_REPORT_PATH="" @@ -57,7 +58,7 @@ if [[ -n "$JSON_REPORT_PATH" ]]; then trap rr_cleanup EXIT fi -TOTAL_STEPS=14 +TOTAL_STEPS=15 PASSED_STEPS=0 FAILED_STEPS=0 SKIPPED_STEPS=0 @@ -134,7 +135,8 @@ run_step 10 "verification" "Verification Regression" "$RUN_VERIFICATION_REGRESSI run_step 11 "dashboard" "Dashboard Regression" "$RUN_DASHBOARD_REGRESSION" '"$SCRIPT_DIR"/regression-dashboard.sh' "RUN_DASHBOARD_REGRESSION=$RUN_DASHBOARD_REGRESSION" run_step 12 "budget" "Budget Regression" "$RUN_BUDGET_REGRESSION" '"$SCRIPT_DIR"/regression-budget.sh' "RUN_BUDGET_REGRESSION=$RUN_BUDGET_REGRESSION" run_step 13 "integrations" "Integrations Regression" "$RUN_INTEGRATIONS_REGRESSION" '"$SCRIPT_DIR"/regression-integrations.sh' "RUN_INTEGRATIONS_REGRESSION=$RUN_INTEGRATIONS_REGRESSION" -run_step 14 "feature_flags" "Feature Flags Regression" "$RUN_FEATURE_FLAGS_REGRESSION" '"$SCRIPT_DIR"/regression-feature-flags.sh' "RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION" +run_step 14 "im_notifier" "IM Notifier Regression" "$RUN_IM_NOTIFIER_REGRESSION" '"$SCRIPT_DIR"/regression-im-notifier.sh' "RUN_IM_NOTIFIER_REGRESSION=$RUN_IM_NOTIFIER_REGRESSION" +run_step 15 "feature_flags" "Feature Flags Regression" "$RUN_FEATURE_FLAGS_REGRESSION" '"$SCRIPT_DIR"/regression-feature-flags.sh' "RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION" RUN_ENDED_MS="$(rr_now_ms)" OVERALL_DURATION_MS="$((RUN_ENDED_MS - RUN_STARTED_MS))" diff --git a/server/scripts/regression-im-notifier.sh b/server/scripts/regression-im-notifier.sh new file mode 100755 index 00000000..2bce257f --- /dev/null +++ b/server/scripts/regression-im-notifier.sh @@ -0,0 +1,529 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" +DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-im-notifier}" +EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" +EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}" +FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}" +FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}" +RUN_SUFFIX="$(date +%s)-$RANDOM" + +if ! command -v curl >/dev/null 2>&1; then + echo "[ERROR] curl not found" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "[ERROR] jq not found" >&2 + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "[ERROR] node not found" >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +if [[ -z "${DATABASE_URL:-}" ]]; then + echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +assert_status() { + local actual="$1" + local expected="$2" + local name="$3" + local body_file="${4:-}" + + if [[ "$actual" != "$expected" ]]; then + echo "[ERROR] $name expected HTTP $expected but got $actual" >&2 + [[ -n "$body_file" && -f "$body_file" ]] && cat "$body_file" >&2 + exit 1 + fi +} + +assert_jq() { + local file_path="$1" + local jq_expr="$2" + local name="$3" + + if ! jq -e "$jq_expr" "$file_path" >/dev/null; then + echo "[ERROR] $name assertion failed: $jq_expr" >&2 + cat "$file_path" >&2 + exit 1 + fi +} + +login_user() { + local identifier="$1" + local password="$2" + local out_file="$3" + + local status + status="$(curl -sS -o "$out_file" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/login" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: $DEVICE_FINGERPRINT" \ + -d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")" + assert_status "$status" "200" "login $identifier" "$out_file" + jq -er '.data.accessToken' "$out_file" +} + +api_json_auth() { + local method="$1" + local url="$2" + local token="$3" + local out_file="$4" + local body="${5:-}" + + if [[ -n "$body" ]]; then + curl -sS -o "$out_file" -w "%{http_code}" \ + -X "$method" "$url" \ + -H "authorization: Bearer $token" \ + -H "content-type: application/json" \ + -d "$body" + else + curl -sS -o "$out_file" -w "%{http_code}" \ + -X "$method" "$url" \ + -H "authorization: Bearer $token" + fi +} + +resolve_fixture_ids() { + DATABASE_URL="$DATABASE_URL" node - <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); +const { resolveApplicableBudgetPoolByContext } = require('./src/services/budgetPool'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const employee = await prisma.employee.findUnique({ + where: { email: process.env.SEED_EMPLOYEE_EMAIL ?? 'employee@hyinda.com' }, + select: { department_id: true, legal_entity_id: true }, + }); + const costCenter = await prisma.costCenter.findFirst({ + where: { + enabled: true, + archived_at: null, + legal_entity_id: employee.legal_entity_id, + }, + orderBy: { created_at: 'asc' }, + select: { id: true }, + }); + const supplier = await prisma.supplier.findFirst({ + where: { + enabled: true, + archived_at: null, + OR: [{ legal_entity_id: employee.legal_entity_id }, { legal_entity_id: null }], + }, + orderBy: { created_at: 'asc' }, + select: { id: true }, + }); + const documentCategory = await prisma.documentCategory.findFirst({ + where: { + enabled: true, + archived_at: null, + entity_type: 'purchase_request', + }, + orderBy: { created_at: 'asc' }, + select: { id: true }, + }); + const budgetPool = await resolveApplicableBudgetPoolByContext(prisma, { + departmentId: employee.department_id, + legalEntityId: employee.legal_entity_id, + costCenterId: costCenter?.id ?? null, + effectiveDate: new Date(), + }); + + process.stdout.write([ + employee.legal_entity_id ?? '', + costCenter?.id ?? '', + supplier?.id ?? '', + documentCategory?.id ?? '', + budgetPool?.id ?? '', + ].join('\n')); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +create_channel() { + local token="$1" + local name="$2" + local mode="$3" + local webhook_url="$4" + local out_file="$5" + + local body + body="$(jq -nc \ + --arg name "$name" \ + --arg mode "$mode" \ + --arg webhookUrl "$webhook_url" \ + '{ + channelType:"wecom", + name:$name, + enabled:true, + configJson:{ + mode:$mode, + webhookUrl:(if $webhookUrl == "" then null else $webhookUrl end), + secret:null, + timeoutMs:800, + routing:{ + roleCodes:["manager","ceo","admin"], + domainScopes:["workflow"], + allowedTypes:["workflow.task_assigned"] + } + }, + templateJson:{ + title:"{{title}}", + body:"{{body}}\n\n查看详情:{{detailUrl}}", + footer:"来自海音达工作台", + deepLinkLabel:"查看详情" + } + }')" + + local status + status="$(api_json_auth POST "$BASE_URL/api/notifications/channels" "$token" "$out_file" "$body")" + assert_status "$status" "200" "create channel $name" "$out_file" + jq -er '.data.channel.id' "$out_file" +} + +patch_channel_enabled() { + local token="$1" + local channel_id="$2" + local enabled="$3" + local out_file="$4" + + local status + status="$(api_json_auth PATCH "$BASE_URL/api/notifications/channels/$channel_id" "$token" "$out_file" "{\"enabled\":$enabled}")" + assert_status "$status" "200" "patch channel $channel_id enabled=$enabled" "$out_file" +} + +create_and_submit_purchase() { + local token="$1" + local label="$2" + local out_prefix="$3" + local legal_entity_id="$4" + local cost_center_id="$5" + local supplier_id="$6" + local document_category_id="$7" + local budget_pool_id="$8" + + local create_status + create_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests" "$token" "$TMP_DIR/${out_prefix}-create.json" '{}')" + assert_status "$create_status" "200" "create purchase request $label" "$TMP_DIR/${out_prefix}-create.json" + + local purchase_id + purchase_id="$(jq -er '.data.purchaseRequest.id' "$TMP_DIR/${out_prefix}-create.json")" + + local patch_body + if [[ -n "$budget_pool_id" ]]; then + patch_body="$(jq -nc \ + --arg title "$label" \ + --arg le "$legal_entity_id" \ + --arg cc "$cost_center_id" \ + --arg sp "$supplier_id" \ + --arg dc "$document_category_id" \ + --arg bp "$budget_pool_id" \ + '{ + title:$title, + legalEntityId:$le, + costCenterId:$cc, + supplierId:$sp, + documentCategoryId:$dc, + budgetPoolId:$bp, + items:[{description:"IM notifier regression", specification:"mock event", quantity:"1.00", unitPrice:"88.00", isAssetCandidate:false}] + }')" + else + patch_body="$(jq -nc \ + --arg title "$label" \ + --arg le "$legal_entity_id" \ + --arg cc "$cost_center_id" \ + --arg sp "$supplier_id" \ + --arg dc "$document_category_id" \ + '{ + title:$title, + legalEntityId:$le, + costCenterId:$cc, + supplierId:$sp, + documentCategoryId:$dc, + items:[{description:"IM notifier regression", specification:"mock event", quantity:"1.00", unitPrice:"88.00", isAssetCandidate:false}] + }')" + fi + + local patch_status + patch_status="$(api_json_auth PATCH "$BASE_URL/api/purchase-requests/$purchase_id" "$token" "$TMP_DIR/${out_prefix}-patch.json" "$patch_body")" + assert_status "$patch_status" "200" "patch purchase request $label" "$TMP_DIR/${out_prefix}-patch.json" + + local submit_status + submit_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$purchase_id/submit" "$token" "$TMP_DIR/${out_prefix}-submit.json" '{}')" + assert_status "$submit_status" "200" "submit purchase request $label" "$TMP_DIR/${out_prefix}-submit.json" + assert_jq "$TMP_DIR/${out_prefix}-submit.json" '.data.status == "reviewing" and (.data.currentTaskId | length > 0)' "purchase submit reviewing $label" + + printf '%s\n' "$purchase_id" +} + +query_im_outboxes_for_entity() { + local entity_id="$1" + local channel_ids_csv="$2" + DATABASE_URL="$DATABASE_URL" ENTITY_ID="$entity_id" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const entityId = process.env.ENTITY_ID; + const channelIds = (process.env.CHANNEL_IDS || '').split(',').filter(Boolean); + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const items = await prisma.integrationOutbox.findMany({ + where: { event_type: 'notification.im' }, + orderBy: { created_at: 'desc' }, + take: 200, + select: { + id: true, + status: true, + last_error: true, + payload: true, + }, + }); + + const filtered = items.filter((item) => { + const payload = item.payload && typeof item.payload === 'object' ? item.payload : null; + return payload + && payload.entityId === entityId + && payload.notificationType === 'workflow.task_assigned' + && channelIds.includes(payload.channelId); + }).map((item) => ({ + id: item.id, + status: item.status, + lastError: item.last_error, + channelId: item.payload.channelId, + code: item.payload.notificationType, + })); + + process.stdout.write(JSON.stringify(filtered)); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +enqueue_manual_im_outbox() { + local channel_id="$1" + DATABASE_URL="$DATABASE_URL" CHANNEL_ID="$channel_id" RUN_SUFFIX="$RUN_SUFFIX" node - <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); +const { enqueueIntegrationEvent } = require('./src/services/integrations/outbox'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const outbox = await enqueueIntegrationEvent(prisma, { + eventType: 'notification.im', + dedupeKey: `manual-im-disabled-${process.env.RUN_SUFFIX}`, + maxAttempts: 5, + actorId: null, + payload: { + channelId: process.env.CHANNEL_ID, + channelType: 'wecom', + notificationType: 'workflow.task_assigned', + domainScope: 'workflow', + title: '【待办】IM disabled regression', + body: 'IM disabled regression body', + targetType: 'workflow_task', + targetId: `manual-task-${process.env.RUN_SUFFIX}`, + targetPath: '/workflow/tasks/manual-task', + detailUrl: 'https://app.example.com/workflow/tasks/manual-task', + recipientEmployeeIds: [], + recipientRoleCodes: ['manager'], + entityType: 'purchase_request', + entityId: `manual-pr-${process.env.RUN_SUFFIX}`, + }, + }); + process.stdout.write(outbox.id); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +query_outbox_by_id() { + local outbox_id="$1" + DATABASE_URL="$DATABASE_URL" OUTBOX_ID="$outbox_id" node - <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const row = await prisma.integrationOutbox.findUnique({ + where: { id: process.env.OUTBOX_ID }, + select: { + id: true, + status: true, + last_error: true, + attempts: true, + }, + }); + process.stdout.write(JSON.stringify(row)); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +dispatch_outboxes_by_ids() { + local outbox_ids_csv="$1" + local im_enabled="$2" + DATABASE_URL="$DATABASE_URL" OUTBOX_IDS="$outbox_ids_csv" IM_NOTIFIER_ENABLED="$im_enabled" node - <<'NODE' +require('dotenv/config'); + +const { createPrismaClient } = require('./src/prismaClient'); +const { getConfig } = require('./src/config'); +const { dispatchOutboxEvent } = require('./src/services/integrations/dispatcher'); +const { + markOutboxFailed, + markOutboxProcessing, + markOutboxSucceeded, +} = require('./src/services/integrations/outbox'); + +(async () => { + const ids = (process.env.OUTBOX_IDS || '').split(',').filter(Boolean); + const config = getConfig(); + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + for (const id of ids) { + const item = await prisma.integrationOutbox.findUnique({ + where: { id }, + select: { + id: true, + event_type: true, + payload: true, + attempts: true, + max_attempts: true, + }, + }); + if (!item) { + throw new Error(`outbox_not_found:${id}`); + } + + await markOutboxProcessing(prisma, item.id, null); + try { + await dispatchOutboxEvent({ + prisma, + config, + eventType: item.event_type, + payload: item.payload, + }); + await markOutboxSucceeded(prisma, item.id, null); + } catch (error) { + const retryable = typeof error?.retryable === 'boolean' ? error.retryable : true; + await markOutboxFailed(prisma, config, item, error, null, { retryable }); + console.error({ + err: error, + retryable, + outboxId: item.id, + eventType: item.event_type, + channelId: item.payload?.channelId ?? null, + channelType: item.payload?.channelType ?? null, + notificationType: item.payload?.notificationType ?? null, + }, 'Integration outbox dispatch failed'); + } + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +echo "[1/7] login employee + finance_mgr" +EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/employee-login.json")" +FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")" + +mapfile -t FIXTURE_IDS < <(resolve_fixture_ids) +LEGAL_ENTITY_ID="${FIXTURE_IDS[0]}" +COST_CENTER_ID="${FIXTURE_IDS[1]}" +SUPPLIER_ID="${FIXTURE_IDS[2]}" +DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}" +BUDGET_POOL_ID="${FIXTURE_IDS[4]}" + +echo "[2/7] create mock + failing wecom channels" +MOCK_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Mock ${RUN_SUFFIX}" "mock" "" "$TMP_DIR/create-mock-channel.json")" +FAIL_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Failing ${RUN_SUFFIX}" "wecom_webhook" "http://127.0.0.1:9/wecom" "$TMP_DIR/create-failing-channel.json")" + +echo "[3/7] submit purchase request and verify IM outbox written" +PURCHASE_ID_A="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression A ${RUN_SUFFIX}" "purchase-a" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")" +query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a.json" +assert_jq "$TMP_DIR/outboxes-a.json" 'length == 2 and all(.[]; .status == "pending")' "submit purchase enqueues two IM outboxes" + +echo "[4/7] run outbox job => mock succeeds, failing webhook does not block business transaction" +OUTBOX_IDS_A="$(jq -r "map(.id) | join(\",\")" "$TMP_DIR/outboxes-a.json")" +dispatch_outboxes_by_ids "$OUTBOX_IDS_A" "true" > "$TMP_DIR/outbox-job-1.log" +query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a-after-job.json" +assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(.status) | sort == ["failed","succeeded"]' "outbox success and failure statuses" +assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(select(.status == "failed"))[0].lastError | type == "string" and length > 0' "failing webhook captured error" + +echo "[5/7] disable channels and verify business submission safely skips IM enqueue" +patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "false" "$TMP_DIR/disable-mock-channel.json" +patch_channel_enabled "$FINANCE_MGR_TOKEN" "$FAIL_CHANNEL_ID" "false" "$TMP_DIR/disable-fail-channel.json" +PURCHASE_ID_B="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression B ${RUN_SUFFIX}" "purchase-b" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")" +query_im_outboxes_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-b.json" +assert_jq "$TMP_DIR/outboxes-b.json" 'length == 0' "disabled channels should not enqueue IM outbox" + +echo "[6/7] verify IM_NOTIFIER_ENABLED=false marks outbox as skipped success" +patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "true" "$TMP_DIR/re-enable-mock-channel.json" +MANUAL_OUTBOX_ID="$(enqueue_manual_im_outbox "$MOCK_CHANNEL_ID")" +dispatch_outboxes_by_ids "$MANUAL_OUTBOX_ID" "false" > "$TMP_DIR/outbox-job-im-disabled.log" +query_outbox_by_id "$MANUAL_OUTBOX_ID" > "$TMP_DIR/manual-outbox.json" +assert_jq "$TMP_DIR/manual-outbox.json" '.status == "succeeded" and .attempts == 0' "im disabled outbox skipped as succeeded" + +echo "[7/7] verify channel test API still works for saved channel" +test_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$MOCK_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/channel-test.json" '{}')" +assert_status "$test_status" "200" "test channel by id" "$TMP_DIR/channel-test.json" +assert_jq "$TMP_DIR/channel-test.json" '.data.testResult.ok == true and .data.testResult.code == "mock_sent"' "test channel endpoint mock sent" + +echo "[OK] IM notifier regression passed"