From f3936cec61c4da98ee96c926f5637ec59c4b6fff Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Tue, 13 Oct 2026 17:29:55 +0800 Subject: [PATCH] u --- code/g3soft-erp/.gitignore | 1 + code/g3soft-erp/README.md | 35 + code/g3soft-erp/docs/工作流迁移-交接说明.md | 261 +++ code/g3soft-erp/server/README.md | 83 +- code/g3soft-erp/server/log-viewer.html | 414 +++++ .../exception/GlobalExceptionHandler.java | 95 +- .../g3soft/erp/common/util/RequestParams.java | 79 + .../cn/g3soft/erp/config/JwtAuthFilter.java | 13 + .../cn/g3soft/erp/config/LogProperties.java | 68 + .../erp/config/RequestLoggingFilter.java | 140 ++ .../erp/controller/WorkflowController.java | 104 ++ .../controller/WorkflowRuntimeController.java | 159 ++ .../database/org/OrgDatabaseConfigLoader.java | 27 + .../workflow/WorkflowActorResolver.java | 325 ++++ .../workflow/WorkflowConditionEvaluator.java | 198 ++ .../workflow/WorkflowConfigException.java | 17 + .../workflow/WorkflowConfigService.java | 1026 +++++++++++ .../workflow/WorkflowDelegationService.java | 80 + .../erp/service/workflow/WorkflowDraft.java | 224 +++ .../workflow/WorkflowExpirySweepService.java | 153 ++ .../service/workflow/WorkflowInstanceRow.java | 71 + .../erp/service/workflow/WorkflowJson.java | 82 + .../workflow/WorkflowRuntimeQueryService.java | 522 ++++++ .../workflow/WorkflowRuntimeService.java | 1616 +++++++++++++++++ .../erp/service/workflow/WorkflowSql.java | 97 + .../erp/service/workflow/WorkflowTx.java | 111 ++ .../service/workflow/WorkflowValidator.java | 402 ++++ .../src/main/resources/application.yaml | 31 + .../src/main/resources/logback-spring.xml | 94 + .../GlobalExceptionHandlerTests.java | 8 +- .../erp/common/util/RequestParamsTests.java | 78 + .../g3soft/erp/config/LogPropertiesTests.java | 35 + .../WorkflowConditionEvaluatorTests.java | 135 ++ .../workflow/WorkflowValidatorTests.java | 285 +++ code/g3soft-erp/server/start.cmd | 1 + .../workflow_audit_tables.sql | 69 + .../sql/10_workflow/01_fms_workflow.sql | 805 ++++++++ .../10_workflow/02_fms_workflow_org_ext.sql | 144 ++ .../10_fms_workflow_module.sql | 505 ++++++ .../20_registration/11_fms_workflow_menu.sql | 97 + .../20_registration/12_fms_workflow_power.sql | 96 + .../01_fms_workflow_index_patch.sql | 41 + .../02_fms_workflow_unique_index_patch.sql | 81 + .../sql/90_mock/01_fms_workflow_mock.sql | 275 +++ .../90_mock/02_fms_workflow_runtime_mock.sql | 741 ++++++++ code/g3soft-erp/sql/README.md | 85 + code/g3soft-erp/web/src/App.vue | 13 + .../components/PreferencesDrawer.vue | 7 + code/g3soft-erp/web/src/preferences/config.ts | 1 + code/g3soft-erp/web/src/preferences/types.ts | 2 + .../web/src/views/ocean/Booking.vue | 371 +++- code/g3soft-libs/COMMIT_MESSAGE.md | 94 + code/g3soft-libs/README.md | 33 + .../g3soft-libs/docs/content/ui/data/table.md | 71 + .../docs/examples/table/editable.vue | 10 +- code/g3soft-libs/docs/table-design.md | 31 + .../g3soft-libs/packages/ui/e2e/table.spec.ts | 286 +++ .../packages/ui/playground/main.ts | 24 +- .../ui/src/fps-monitor/FpsMonitor.vue | 491 +++++ .../packages/ui/src/fps-monitor/index.ts | 2 + .../packages/ui/src/fps-monitor/types.ts | 10 + code/g3soft-libs/packages/ui/src/index.ts | 3 + .../packages/ui/src/select/Select.vue | 113 +- .../packages/ui/src/select/types.ts | 8 + .../packages/ui/src/table/G3Table.vue | 1066 ++++++++++- .../packages/ui/src/table/index.ts | 2 + .../packages/ui/src/table/types.ts | 126 ++ .../packages/ui/src/table/utils/column.ts | 25 +- .../ui/src/table/utils/editable.test.ts | 218 +++ .../packages/ui/src/table/utils/editable.ts | 245 ++- 70 files changed, 13074 insertions(+), 187 deletions(-) create mode 100644 code/g3soft-erp/docs/工作流迁移-交接说明.md create mode 100644 code/g3soft-erp/server/log-viewer.html create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/RequestParams.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/LogProperties.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/RequestLoggingFilter.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/WorkflowController.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/WorkflowRuntimeController.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowActorResolver.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluator.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigException.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigService.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowDelegationService.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowDraft.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepService.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowInstanceRow.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowJson.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeQueryService.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeService.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowSql.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowTx.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowValidator.java create mode 100644 code/g3soft-erp/server/src/main/resources/logback-spring.xml create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/RequestParamsTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/config/LogPropertiesTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluatorTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowValidatorTests.java create mode 100644 code/g3soft-erp/sql/00_core_companion/workflow_audit_tables.sql create mode 100644 code/g3soft-erp/sql/10_workflow/01_fms_workflow.sql create mode 100644 code/g3soft-erp/sql/10_workflow/02_fms_workflow_org_ext.sql create mode 100644 code/g3soft-erp/sql/20_registration/10_fms_workflow_module.sql create mode 100644 code/g3soft-erp/sql/20_registration/11_fms_workflow_menu.sql create mode 100644 code/g3soft-erp/sql/20_registration/12_fms_workflow_power.sql create mode 100644 code/g3soft-erp/sql/30_patches/01_fms_workflow_index_patch.sql create mode 100644 code/g3soft-erp/sql/30_patches/02_fms_workflow_unique_index_patch.sql create mode 100644 code/g3soft-erp/sql/90_mock/01_fms_workflow_mock.sql create mode 100644 code/g3soft-erp/sql/90_mock/02_fms_workflow_runtime_mock.sql create mode 100644 code/g3soft-erp/sql/README.md create mode 100644 code/g3soft-libs/COMMIT_MESSAGE.md create mode 100644 code/g3soft-libs/packages/ui/src/fps-monitor/FpsMonitor.vue create mode 100644 code/g3soft-libs/packages/ui/src/fps-monitor/index.ts create mode 100644 code/g3soft-libs/packages/ui/src/fps-monitor/types.ts diff --git a/code/g3soft-erp/.gitignore b/code/g3soft-erp/.gitignore index 264917c2..6a048941 100644 --- a/code/g3soft-erp/.gitignore +++ b/code/g3soft-erp/.gitignore @@ -22,6 +22,7 @@ server/src/main/resources/ip2region.xdb npm-debug.log* pnpm-debug.log* logs/ +logs-demo/ data/ # ---- 编辑器 / 系统 ---- diff --git a/code/g3soft-erp/README.md b/code/g3soft-erp/README.md index aaeefe6f..3e0db61c 100644 --- a/code/g3soft-erp/README.md +++ b/code/g3soft-erp/README.md @@ -74,6 +74,41 @@ overrides: 2. **`vite.config.ts` 必须保留 `dedupe: ['vue', ...]`**。link 进来的包会带自己的 Vue 副本,不去重会导致组件内响应式失效(组件内部 ref 变化不触发外部渲染)——link 方案最经典的坑。 3. **CI / 其他机器**:相对路径依赖目录层级。私库就绪后应把开发期 `link:` 换成私库版本号。 +### 本地联调组件库:构建时必须先停掉 dev server + +上面那条「改完组件库必须重新 build」还差半句:**构建组件库时要先停掉本项目的 dev server**。 + +原因是 Vite 监视着 link 进来的 `packages/ui/dist/`,而 `vite build` 会先清空再逐个写回产物文件,于是: + +1. 每个产物文件被重写都触发一次 `page reload`,浏览器反复刷新; +2. 构建中途 `dist/style.css` 会**短暂不存在**,而 `src/styles/index.scss` 里有 `@use '@g3soft/ui/style.css';`,Sass 解析时文件已经没了 —— dev server 进程直接崩溃: + +``` +[vite] Internal server error: [sass] ENOENT: no such file or directory, + open 'D:\workspace\code\g3soft-libs\packages\ui\dist\style.css' + ╷ +9 │ @use '@g3soft/ui/style.css'; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ╵ + src\styles\index.scss 9:1 root stylesheet +``` + +随后是 `ELIFECYCLE Command failed with exit code 4294967295.` + +3. 还有一种更隐蔽的:server 没崩,但缓存到了**写入中途**的模块图,页面白屏、控制台报 `Failed to resolve import "./tooltip/Tooltip.js" from ".../dist/index.js"`。此时磁盘上的 `dist` 其实是完整的,问题纯粹在 Vite 缓存。 + +**正确顺序**: + +```bash +# 1) 停掉本项目 dev server(占用 5090 端口) +# 2) 构建组件库 +cd ../g3soft-libs && pnpm --filter @g3soft/ui build +# 3) 重新启动 +cd ../g3soft-erp/web && pnpm dev +``` + +若怀疑是缓存问题(白屏、找不到模块),在启动前额外删掉 `web/node_modules/.vite` 再 `pnpm dev`。改 `@g3soft/tokens` 同理(`pnpm --filter @g3soft/tokens build`),也要在 dev server 停掉时做。 + ### 想要组件库热更新? `link:` 指向**已构建的 `dist/`**,改源码不会即时生效。若要像 `g3soft-libs/docs` 那样直连源码,在 `vite.config.ts` 里加 alias: diff --git a/code/g3soft-erp/docs/工作流迁移-交接说明.md b/code/g3soft-erp/docs/工作流迁移-交接说明.md new file mode 100644 index 00000000..dd07467b --- /dev/null +++ b/code/g3soft-erp/docs/工作流迁移-交接说明.md @@ -0,0 +1,261 @@ +# 工作流模块迁移 —— 交接说明 + +> 用途:把「把原型 fms-api 的工作流引擎迁移进 g3soft-erp/server 并修复一批缺陷」这件事的**当前状态、已定决策、踩过的坑、剩余工作**完整交接出去。 +> 接手者(人或 AI)只读本文件即可继续,不需要回溯之前的对话。 +> 最近更新:2026-10-13 + +--- + +## 一、背景 + +- **原型**:`D:/workspace/code/fms/fms-api`(包名 `cn.g3soft.fmsapi`),含一套**手写的轻量流程引擎**(无 Flowable/Activiti/Camunda 依赖)。 +- **目标**:`d:/workspace/code/g3soft-erp/server`(包名 `cn.g3soft.erp`,Spring Boot 4.1.0 / Java 21 / 纯 JDBC / 无 `@Transactional` / 多机构按 `OrgContext` 路由数据源)。 +- **原始设计文档**:`D:/workspace/code/fms/FMS工作流与审批设计.md`(§ 编号在本文件与代码注释里频繁引用)。 +- **迁移动因**:g3soft-erp 是 fms-api 的续作,工作流当初"未带入";本次要迁入并**同时修掉评审发现的一批缺陷**。 + +> 迁移前的评审结论:设计(文档)质量不错,但实现只落地七八成,且有几个硬伤(串行会签不通、乐观锁是假的、运行时零测试)。详见下文。 + +--- + +## 二、当前状态总览 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| **P0** | 建 `sql/` 目录与脚本;对真库执行补丁 | ✅ | +| **P1** | 8 个 service + 2 个 controller 改包名迁移;2 个测试随迁 | ✅ | +| **P1.5** | 删 `WorkflowConfigService` 私有 SQL 层(修 C2 时间口径) | ✅ | +| **P2** | A1 串行会签 / A2 claim 越权 / A3 投影丢更新;C2/C4/C5/C6/C7 | ✅ | +| **P3** | B1 乐观锁 / B2 锁序死锁 / B3 严格条件解析 | ✅ | +| **C3** | `WorkflowTx` 事务模板;`WorkflowRuntimeService` 8/8 转换;删孤儿方法 | ✅ | +| **P4** | D1 委托 / D2 超时扫描 / D3 路由留痕 / D4 职责分离 / D5 审计探写 | ✅ | +| **P5** | 拆两个上帝类 | 🔶 **仅完成 1 项**(`WorkflowInstanceRow` 抽取) | +| **P6** | 补测试 + 端到端验证 | ❌ **未开始** | + +### 尚未完成、但**已被批准**的一项(容易漏) + +**把工作流的动作权限校验改为复用 `SqlPermissionService`**: +- 已定方案:给 `SqlPermissionService` 加一个**带 `Connection` 的重载**(现有方法自己开连接、不进 workflow 事务),workflow 侧删除私有的重复 `s_power` 查询与硬编码管理员账号(改用 `isAdministrator`)。 +- **现状**:`SqlPermissionService` 没有 Connection 重载;`WorkflowRuntimeService.java:1285` 仍是私有 `requireAction(Connection, moduleId, userId, action)`,内部硬编码 `ADMIN_ACCOUNT = "g3soft"`。 +- 兼容性已核实:`WorkflowConfigService.ensureActionPowers` 写的是 `b_type='action', b_object_type='module', b_object_id=模块, b_action=动作`,与 `SqlPermissionService.requireAction` 的匹配列**一致**,可以直接对接。 +- 附带:`requireEnabledUser` 在目标侧**没有等价实现**,仍是 `WorkflowRuntimeService` 的私有方法,需自行决定是否抽到 `service/auth`。 + +--- + +## 三、环境与命令 + +### 数据库 + +- 配置:`server/config/dbconfigs/G3HD.properties`(**已 gitignore**) + - `url=...databaseName=G3ERP...`,机构码 `G3HD`,主机 `118.89.70.199:1433` +- 该库**已具备**:13 张 `wf_*` 表、核心表(`s_module`/`s_field`/`s_power`/`s_user_power`/`s_menu`/`b_user`/`b_dept`)、组织扩展(`b_position`/`b_user_position`/`b_org_role`/`b_user_org_role`、`b_dept.b_manager_id`、`b_user.b_leader_id`)、审计表(`s_log_audit`/`s_log_audit_field`)。 +- 已有 mock 数据:定义 `expense_approval`(14 节点 / 17 连线 / 13 审批人规则)、版本 `v1`(published) + `v2`(draft)、1 条**停用**绑定 `demo_expense_binding`(模块 `v_b_othercompany`,`b_canuse=0`)、6 个运行实例、31 条任务、4 条抄送。 + +### 构建 / 测试 / 运行 + +```bash +cd d:/workspace/code/g3soft-erp/server +export JAVA_HOME="D:\\devtool\\jdk\\jdk-21" +export PATH="/d/devtool/jdk/jdk-21/bin:$PATH" + +./mvnw.cmd -q -o compile # 编译 +./mvnw.cmd -o test -Dtest='WorkflowConditionEvaluatorTests,WorkflowValidatorTests' # 现有 19 个测试 +./mvnw.cmd -o spring-boot:run -Dspring-boot.run.arguments="--server.port=18090" # 起服务 +``` + +- 上下文:`/api`,默认端口 8088。 +- 工作流接口:`/api/workflow/**`(配置侧)、`/api/workflow/runtime/**`(运行侧),全部 POST、body 为 `Map`。 +- 无 DB 时 `mvnw test` 会因 `ExportServiceIntegrationTests` 失败(它打真库)。 + +### 执行 SQL 脚本 + +仓库里**没有** SQL 运行器(无 Flyway/Liquibase)。本轮是用一个临时 Java 程序(`java -cp Xxx.java`,读 `G3HD.properties`,按 `GO` 分批 `execute`)跑完即删。JDBC 驱动在 `~/.m2/repository/com/microsoft/sqlserver/mssql-jdbc/13.4.0.jre11/`。 + +--- + +## 四、已完成工作明细 + +### 4.1 新增文件 + +**`g3soft-erp/sql/`(10 个,全部 LF)** +``` +README.md # 执行顺序 / 幂等 / 两个引导陷阱 +00_core_companion/workflow_audit_tables.sql # s_log_audit + s_log_audit_field(幂等,D5 用) +10_workflow/01_fms_workflow.sql # 13 张 wf_* 表(不幂等) +10_workflow/02_fms_workflow_org_ext.sql # 组织主数据扩展 +20_registration/10_fms_workflow_module.sql # 4 个视图 + s_module/s_field/s_module_schema/s_menu_module +20_registration/11_fms_workflow_menu.sql # 菜单 + i18n +20_registration/12_fms_workflow_power.sql # 权限点(菜单 + 11 个动作) +30_patches/01_fms_workflow_index_patch.sql # 补 3 个索引(已执行) +30_patches/02_fms_workflow_unique_index_patch.sql # 恢复 4 个缺失的筛选唯一索引(已执行) +90_mock/01_fms_workflow_mock.sql # 示例流程定义 +90_mock/02_fms_workflow_runtime_mock.sql # 示例运行数据 +``` + +**`server/src/main/java/cn/g3soft/erp/service/workflow/`**(迁移 + 新增) + +| 文件 | 行数 | 说明 | +|---|---|---| +| `WorkflowRuntimeService.java` | 1616 | 运行门面(**待拆**) | +| `WorkflowConfigService.java` | 1026 | 配置门面(**待拆**,另 8 处事务样板待转) | +| `WorkflowRuntimeQueryService.java` | 522 | 待办/已办/进度图等读模型 | +| `WorkflowValidator.java` | 402 | 发布期校验 | +| `WorkflowActorResolver.java` | 325 | 审批人解析 | +| `WorkflowDraft.java` | 224 | 领域草稿 + 结构解析 | +| `WorkflowConditionEvaluator.java` | 198 | 条件 AST 求值(纯函数) | +| `WorkflowSql.java` | 97 | 包内 JDBC 小工具 | +| **`WorkflowTx.java`** | — | **新**:事务模板(2 参 / 3 参带恢复钩子 / `isUniqueViolation`) | +| **`WorkflowJson.java`** | — | **新**:JSON 严格/宽松双入口 | +| **`WorkflowConfigException.java`** | — | **新**:配置损坏异常(extends BusinessException) | +| **`WorkflowInstanceRow.java`** | 64 | **新**:由内嵌类提升为顶层包内类 | +| **`WorkflowDelegationService.java`** | — | **新**:委托(D1) | +| **`WorkflowExpirySweepService.java`** | — | **新**:超时扫描(D2) | + +**其他新增** +- `server/src/main/java/cn/g3soft/erp/controller/WorkflowController.java`、`WorkflowRuntimeController.java` +- `server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluatorTests.java`、`WorkflowValidatorTests.java`(19 个测试,全绿) +- `server/config/dbconfigs/G3HD.properties`(gitignored) + +### 4.2 修改文件 + +- `database/org/OrgDatabaseConfigLoader.java` — 新增 `listOrgIds()`(供无请求上下文的后台任务 fan-out) +- `src/main/resources/application.yaml` — 新增 `erp.workflow.expire-sweep-interval: 5m` + +### 4.3 关键改动点(每项都有对应缺陷) + +| 编号 | 缺陷 | 改法 | +|---|---|---| +| **A1** | 串行会签完全不通:任务建为 `waiting` 后**没有任何语句把它改回 `pending`**,节点永远凑不满票数 | 新增 `promoteNextSerialTask()`:在"未凑够票数"分支把 `(b_seq,b_id)` 最小的 `waiting` 翻成 `pending`,写 `activate` 动作 + outbox | +| **A2** | `claim` 只查动作权限,**不校验调用者是不是候选审批人** → 有 claim 权限可抢任意待办 | 锁 `node_run` 读 `b_actor_snapshot_json.resolvedUsers` 校验;**候选取自快照而非任务表**(`single` 模式只给第一个候选建任务);解析异常→空集合→拒绝(fail-closed) | +| **A3** | 业务状态投影 `update 业务表 set 状态=? where 主键=?`,**无行数校验、无版本谓词** → 静默丢更新 | 读投影前旧值;业务表有 `b_row_version` 时一并自增(否则会让通用保存的乐观锁读到陈旧版本);行数≠1 写 `wf_outbox(instance.projection_missing)` 留痕 | +| **B1** | 文档承诺的乐观锁**是假的**:`b_row_version` 只递增、从不进 `WHERE` | 新增 `updateInstance(...)` 统一入口,追加 `and b_id=? and b_row_version=?` 并校验行数;`WorkflowInstanceRow` 改**可变**(同事务内会多次更新实例) | +| **B2** | 锁序不一致:`act/claim/transfer/addApprover/urge` 是"任务→实例",`withdraw/cancel/finishInstance` 是"实例→任务" → **AB-BA 死锁** | 统一为**实例优先**:新增 `lockTask(...)`(无锁预读拿 instance_id → `lockInstance` → UPDLOCK 重读并复查状态/归属);`cancelSiblingTasks` 加 `UPDLOCK` | +| **B3** | 脏条件 JSON 被静默降级成空 → **当成"无条件边"命中**,静默错路由;绑定匹配同理 | 新增 `WorkflowJson.readMapOrNull`(严格)/`readMapOrEmpty`(宽松);`WorkflowDraft.conditionOf` 在**发布期**就拒绝"给了值却不是对象";`advance` 捕获 `WorkflowConfigException` → `failInstance` 置 error **并保留本次表决** | +| **C2** | `WorkflowConfigService` 私有复制了一份 `WorkflowSql`,**漏了 `TimeUtils.toApiValue`** → 配置侧时间出参不带 `Z` | 删除私有层,49 处调用统一走 `WorkflowSql` | +| **C4** | 缺 `wf_binding(b_version_id)`、`wf_task(b_completed_by)`、`wf_task(node_run,status,seq)` | `sql/30_patches/01_*`(已在真库执行) | +| **C5** | `wf_actor_rule` 的 `b_condition_json`/`b_fallback_type`/`b_fallback_ref` 解析器会读、但设计器**从来写不进去** | 扩展 `WorkflowDraft.ActorRule`(保留 2 参便捷构造以免测试改动);`writeNode` 补写、`loadDraft` 回读(**严格**,因为 loadDraft 是发布校验的输入);`WorkflowValidator` 补兜底类型 + 规则条件校验 | +| **C6** | `return_previous` 重进上一节点时**没关当前 node_run** → 永久 `running`,会让并行汇合误放行 | 补 `completeNodeRun(...,"completed")` | +| **C7** | `service_task` 的动作码被误标成 `"cc"`(看起来像抄送) | 改为 `"service"`,措辞去掉"第一阶段占位" | +| **C3** | 事务样板块板 | 新增 `WorkflowTx`;`WorkflowRuntimeService` 8/8 转换;删私有 `rollback`/`restoreAutoCommit`/`isUniqueViolation` | +| **D1** | `wf_delegation` 表建了但 **Java 零引用** | `WorkflowDelegationService`;**任务生成时物化**到代理人(`b_assignee_type='delegate'`),原审批人+委托单号进任务快照;动作期 `verifyDelegation` 复核 `actions` 范围 | +| **D2** | `expired` 状态从不写入、没有调度器 | `WorkflowExpirySweepService`(裸 `ScheduledExecutorService`,无请求上下文时按机构 fan-out) | +| **D3** | §9.5 要求的路由留痕从未实现 | `chooseEdge` 输出决策轨迹(每条出线的求值结果 / 是否默认边);`advance` 在**出线>1 的分支点**写一条 `route` 动作 | +| **D4** | §10.3 职责分离策略从不读取(只读了 `allowClaim`) | `WorkflowActorResolver.resolve` 加 `excludeUserIds`;`excludedActors(...)` 支持 `allowInitiator=false` / `excludeCreator` / `excludePreviousActors`;新增三态 `configFlagOrNull` 区分"没配"与"配了 false" | +| **D5** | §11 要求写 `s_log_audit` 但目标库原来没有该表 | 探写:表存在则写事件 + 字段级前后值;不存在写 `wf_outbox(audit.skipped)` 并继续 | + +--- + +## 五、数据库侧的两个重要发现 + +### 5.1 四个"筛选唯一索引"在真库里全部缺失(已修) + +`fms_workflow.sql` 里定义了 4 个 filtered unique index,但 G3ERP 库里**一个都没有**。后果是: + +| 缺失索引 | 后果 | +|---|---| +| `ux_wf_instance_idempotency` | **`submit` 幂等键形同虚设**——重复提交不被拦(代码靠捕获 SQL 2601/2627/23000 做幂等恢复,索引不在就等于没有) | +| `ux_wf_action_request` | **重复审批不被拦** | +| `ux_wf_version_draft` | 同一定义可并存多条草稿 | +| `ux_wf_binding_active_priority` | 同模块/事件可有两条同优先级启用绑定 | + +`sql/30_patches/02_*.sql` 已幂等恢复(创建前先查重,冲突则 `RAISERROR` 报错)。**已在真库执行并确认 7 个索引全部建上。** + +### 5.2 权限点脚本的引导顺序陷阱 + +`20_registration/12_fms_workflow_power.sql` 的动作权限点由 +`select distinct b_module_id from wf_binding where b_canuse=1` 生成——**binding 为空时生成 0 条**。 +缓解:① `sql/README.md` 写明了顺序;② 应用侧 `WorkflowConfigService.saveBinding` 每次保存绑定都会调 `ensureActionPowers` 自愈;③ 脚本可重复执行。 + +--- + +## 六、剩余工作 + +### 6.1 P5:拆两个上帝类(纯移动,零功能价值) + +**`WorkflowRuntimeService`(1616 行)→** + +| 新类 | 职责 / 方法 | +|---|---| +| `WorkflowRuntimeService` | 对外门面,持事务边界,public 方法签名**保持不变** | +| `WorkflowRoutingEngine` | `advance` / `enterNode` / `activateUserTask` / `runCcTask` / `reenterPreviousNode` / `chooseEdge` / `outgoingEdges` / `incomingEdges` / `allIncomingCompleted` / `recordNodeRun` / `completeNodeRun` | +| `WorkflowTaskService` | `claim` / `transfer` / `addApprover` / `urge` / `cancelSiblingTasks` / `cancelAllTasks` / `promoteNextSerialTask` / `writeAction` / `writeOutbox` / `requiredCount` / `dueMinutesOf` | +| `WorkflowInstanceService` | `withdraw` / `cancel` / `finishInstance` / `failInstance` / `lockInstance` / `updateInstance` / `projectBusinessStatus` / `writeAudit` / `requireAction` / `requireEnabledUser` | +| `WorkflowSubmissionService` | `submit` / `matchBinding` / `submittedResult` / `currentStatus` | + +**`WorkflowConfigService`(1026 行)→** `WorkflowDefinitionService` / `WorkflowVersionService` / `WorkflowBindingService` / `WorkflowDraftMapper`,门面保留、控制器签名不变。`WorkflowDraft` / `WorkflowValidator` / `WorkflowActorResolver` 原地保留;`WorkflowSql` 保持包内可见。 + +**拆类时顺手做完 C3 的余量**:`WorkflowConfigService` 还有 **8 处手写事务样板**(`restoreAutoCommit(connection, originalAutoCommit)` 在 150/210/287/372/404/487/590/611 附近),改用 `WorkflowTx.run(...)`,然后删掉它私有的 `rollback` / `restoreAutoCommit`。 + +> **注意**:`WorkflowConfigService` 的 8 处**是否与 RuntimeService 的一样统一**,接手时要**先核实再动手**(本轮在 RuntimeService 上发现"16 处里有 2 处不同",见 §七.2)。 + +### 6.2 P6:测试 + 端到端(**建议先做这个,见 §八**) + +**迁移测试**(已存在,2 个文件 19 个用例,只改了 package)。 + +**新增纯单测**(`cn.g3soft.erp.service.workflow`,JUnit 5 + AssertJ): + +| 覆盖 | 测试类 | +|---|---| +| `WorkflowJson` 严格/宽松(缺省→null;损坏→抛;非对象→抛;合法→Map) | `WorkflowJsonTests` | +| 委托 scope 匹配真值表(空数组=不限、moduleIds/orgIds/actions) | `WorkflowDelegationTests` | +| 投影守卫决策矩阵(有/无版本列 × 行数 0/1) | `WorkflowProjectionGuardTests` | +| `WorkflowDraft.ActorRule` 的 condition/fallback 往返解析 | `WorkflowDraftTests` | +| 职责分离三态开关(`configFlagOrNull` + `excludedActors` 语义) | `WorkflowActorResolverTests`(纯函数部分) | +| 串行提升的排序选择、`successorCount(serial)=候选数` | `WorkflowSerialTests` | +| `WorkflowInstanceRow` 内存版本推进 | `WorkflowInstanceRowTests` | + +**DB 集成测试**(真机 SQL Server,仿 `ExportServiceIntegrationTests`:`@SpringBootTest` + `OrgContext.setOrgId("G3HD")` + `setUserId("g3soft")`): + +A1 串行三连(通过→提升→中途驳回→waiting 取消→全通过→节点完成)/A2 非候选 claim 被拒/A3 投影并发冲突与 0 行/B1 陈旧版本更新失败/B2 两线程 `act` vs `withdraw` 无死锁(SQL 无 1205)/B3 库中坏条件→实例 `error`/C2 配置侧时间出参带 `Z`/C6 `return_previous` 不留 `running` node_run/C7 动作码为 `service`/D1 委托物化/D2 超时只标记不改流程/D3 `route` 动作落库/D5 审计两表落库。 + +> **门禁**:这些 DB 测试**必须加 `@Tag("db")` + Maven profile(默认不随 `mvnw test` 跑)**,否则在没有 workflow 表的库上会构建失败。 + +**端到端(真机)**:启用 `demo_expense_binding`(把 `b_canuse` 置 1),走完 mock 的 6 个实例 + 串行链 + 委托 + 超时 + 审计;确认无死锁、日志正常。 + +--- + +## 七、踩过的坑(接手者务必先看) + +1. **换行符**:fms-api 源文件是 **CRLF**,g3soft-erp 的 `.gitattributes` 强制 `*.java`/`*.sql`/`*.md` 为 **LF**。迁移时逐文件 `sed 's/\r$//'`。 +2. **事务样板"看似统一、实则不统一"**:`WorkflowRuntimeService` 的 16 个块里,只有 6 个是规整的 `catch (SQLException | RuntimeException)`;`submit` / `act` 各自**拆成两段 catch**,SQLException 分支要**先回滚、再查库**判定是否重复请求。简单模板表达不了这个语义——这就是 `WorkflowTx` 有 **3 参版 + `SqlErrorHandler` 钩子**的原因。**不要试图用统一脚本盲改。** +3. **lambda 体里的两条硬约束**(本轮编译失败两次): + - 被捕获的局部变量必须 **effectively final**。`event`(`if (event.isEmpty()) event = "submit"`)和 `deptId`(连续三次回退赋值)都是因此改成单次表达式。 + - **回调里不要自己 commit**。转换后有 6 处遗留了 `connection.commit();`,必须删掉(模板负责提交;留着会破坏恢复钩子的事务语义)。 +4. **`WorkflowInstanceRow` 必须可变**:一个事务内可能对同一实例多次更新(`activateUserTask` 写摘要 → 随后 `failInstance` 改状态)。每次成功更新都要推进**内存版本**,否则第二次更新会拿旧版本号去比对上一条 UPDATE 已经 +1 的值而误判"并发推进"。 +5. **机械替换后一定要看"孤儿方法"是否真的孤儿**:本轮原以为转换完 `rollback`/`restoreAutoCommit` 就没人用了,实际 `submit`/`act` 还在用,要等那两处也转换完才成立。 +6. **`wf_action.b_action` 词表被扩展**了 4 个码:`activate`(串行提升)、`service`(服务节点)、`route`(路由留痕)、`expire`(超时)。该列**无 CHECK 约束**,不需改表;但下游若按枚举过滤要同步。 +7. **超时扫描有真实副作用**:默认间隔 5 分钟,启动后会把**已到期且仍 pending/claimed** 的任务标成 `expired`。当前库里的示例任务有 31 条带 `b_due_at`,**其中已到期的会被标记**。不想动示例数据就把 `erp.workflow.expire-sweep-interval` 设为 `0s`。 +8. **`WorkflowActorResolver.resolve` 多了第 6 个参数** `excludeUserIds`(D4);抄送节点传 `null`(不参与审批判定,不做职责分离)。 +9. **`WorkflowJson` 用独立 `ObjectMapper` 只做读取**;序列化仍走各服务注入的 mapper(保留项目 `JacksonConfig` 对 Long→String 的处理)。不要把它改成序列化工具。 +10. **`excludeCreator` 目前以实例发起人近似**(业务表创建人字段未统一),代码注释已标明,待业务口径统一后再换。 + +--- + +## 八、已定的设计决策(沿用时不要再讨论) + +| 议题 | 决定 | +|---|---| +| 修复范围 | 全部 A+B+C+D(缺陷 + 并发 + 工程债含拆类 + 补齐文档承诺未实现的功能) | +| SQL 落地 | 新建 `g3soft-erp/sql/`,DDL + 注册 + mock | +| 权限服务 | 给 `SqlPermissionService` 加带 `Connection` 的重载,workflow 共享;删硬编码管理员 —— **⚠️ 尚未实现** | +| 测试 | 每个修复项补单测;DB 依赖的加门禁 | +| **D2 超时语义** | **只标记不自动改流程**:任务置 `expired` 并从待办过滤,实例保持 `running`,由管理员加签/转派。**不自动提升串行后继、不置 error、不自动通过** | +| **A3 投影 0 行** | 写 `wf_outbox(instance.projection_missing)` 并继续,不阻断审批 | +| **B3 坏条件 JSON** | 运行期重入该节点时实例置 `error`,**保留本次已记录的表决** | +| **C3 顺序** | 分两步:`WorkflowRuntimeService` 现在做(已完成);`WorkflowConfigService` 并进 P5 | +| **提交时点** | 接受"先 commit 再 return(且返回前查库)"的那 10 处读**挪进事务内**(结果相同,失败时改为回滚,更原子) | +| 前端 | **不改**(审批交互留在后端,前端照旧) | + +--- + +## 九、待确认事项 + +1. **P5 与 P6 的先后**:建议**先补测试(P6a)再拆类(P5)**。原因见下——这块大搬移目前**只有编译能验证**。 +2. **`excludeCreator` 的真实字段来源**:需要业务表创建人的统一口径。 +3. **`requireEnabledUser` 是否抽到 `service/auth`**。 +4. **串行链中当前审批人超期会停住**(D2 只标记不前进的必然结果)——确认这是可接受的运维语义。 + +--- + +## 十、给接手者的一句话提醒 + +**先补测试,再动 P5 的搬移。** 本轮在 RuntimeService 上做"以为机械"的替换时,编译失败过两次(lambda final、构造函数名),而那是 6 处、有明确模式的小改动;P5 是在**没有任何运行时测试**的 1600 行类上做结构性搬移(`RoutingEngine` 与 `TaskService`/`InstanceService` 互相调用),验证手段仍然只有编译。测试是这次搬移的唯一安全网,而它现在还不存在。 diff --git a/code/g3soft-erp/server/README.md b/code/g3soft-erp/server/README.md index e2e8e9eb..5377c34f 100644 --- a/code/g3soft-erp/server/README.md +++ b/code/g3soft-erp/server/README.md @@ -34,6 +34,84 @@ 内网 / 保留地址(RFC1918、回环、链路本地、IPv6 ULA)直接记「内网」,不外呼。 +## 日志 + +### 输出与落盘 + +控制台输出彩色文本;文件输出**结构化 JSON**(每行一个对象,logstash 格式),默认落在 `server/logs/` +(相对进程工作目录的 `./logs`,根 `.gitignore` 已忽略)。 + +| 文件 | 内容 | +|---|---| +| `logs/erp-api.jsonl` | 全量日志 | +| `logs/erp-api-error.jsonl` | 仅 `ERROR` | +| `logs/erp-api.<日期>.<序号>.jsonl.gz` | 滚动归档(gzip) | + +滚动策略:单文件 10MB、保留 30 天;主文件总量上限 1GB、错误文件 256MB。文件写入经 `AsyncAppender`, +队列满时**背压而非丢日志**,不阻塞请求线程。 + +配置在 `src/main/resources/logback-spring.xml`(appender / 滚动 / 编码器)+ `application.yaml` 的 `logging` 块(路径 / 格式 / 级别)。 + +> **必须命名为 `logback-spring.xml`**:结构化编码器依赖 Spring 注入的 `Environment`,改成 `logback.xml` 会启动失败。 + +### 请求追踪 + +每个请求分配一个 `traceId`(优先复用上游 `X-Request-Id` / W3C `traceparent`),并回写到响应头 `X-Request-Id`。 +通过 `MDC` 注入,因此**该请求的所有日志行**都带同一 `traceId`,按它即可串起完整链路。 + +> 入参 `X-Request-Id` 需匹配 `^[A-Za-z0-9._-]{8,64}$` 才会被复用,否则**静默忽略并自行生成**(防日志注入 / 换行伪造)。 +> 若发现上游的 traceId 没串起来,先检查这个格式约束。W3C `traceparent` 则要求标准 `00-<32hex>-<16hex>-<2hex>` 格式。 + +访问日志每请求一行(`logger_name=ACCESS`、`message=http_access`)。主要字段(均为 JSON 顶层字段): + +| 字段 | 说明 | +|---|---| +| `@timestamp` / `level` / `logger_name` / `thread_name` | 标准字段 | +| `traceId` | 请求链路 ID | +| `method` / `uri` | `uri` 仅为 path,**不含 query** | +| `status` / `durationMs` / `slow` | HTTP 状态 / 耗时 / 是否超过慢请求阈值 | +| `orgId` / `userId` | 鉴权通过后由 `JwtAuthFilter` 回写 MDC | +| `clientIp` / `userAgent` | 客户端信息(`userAgent` 截断 256 字符) | +| `params` | 请求参数,**仅错误或慢请求时出现** | +| `stack_trace` | 异常堆栈,仅错误行 | + +> 注意:本项目异常统一返回 **HTTP 200**,错误码在响应 body 里。所以判断出错要看 `level` / `erp-api-error.jsonl`,**不能看 `status`**。 +> 另外,被 `JwtAuthFilter` 直接拒绝的请求(未登录 / 会话被顶)不会读取 body,因此没有 `params`。 + +### 请求参数记录 + +请求参数(query + body)**只在出错或慢请求时**记录到 `params`,正常请求不记,避免刷屏与大 body 常态落盘; +文件上传(multipart / 二进制流)跳过,超出上限按 UTF-8 字节边界截断(不会切断中文)。 + +```yaml +erp: + log: + slow-request-ms: 1000 # 超过则视为慢请求,记录 params + max-param-bytes: 2048 # 参数记录上限(字节),同时是请求体缓存上限 + exclude-param-paths: # 这些路径完全不记参数(与 getServletPath() 精确匹配) + - /auth/login # 登录接口含明文密码,默认排除 +``` + +> **不做脱敏**:除 `exclude-param-paths` 外,参数原样写入日志(含业务数据、明文密码)。 +> 上线前请确认日志文件的访问权限与流转范围;如需收口,把对应接口加进排除名单即可。 + +### 分级与噪音 + +`ERROR`=系统故障、`WARN`=降级 / 预期内业务竞争、`INFO`=生命周期与访问、`DEBUG`=排障。 +第三方噪音(Druid、SQL Server JDBC、Spring)默认降到 `WARN`,可在 `application.yaml` 的 `logging.level.*` 调整; +临时排障时可把 `cn.g3soft.erp` 调到 `DEBUG`。 + +> **级别以 `application.yaml` 为准**:`logback-spring.xml` 里也给 `cn.g3soft.erp` / `ACCESS` 各写了一份 `INFO`, +> 但 Boot 是在加载 logback 配置**之后**才应用 `logging.level.*`,所以 YAML 会覆盖 XML。**改级别请改 YAML**, +> XML 里那两行只是兜底默认值。 + +### 查看与分析 + +- **图形界面**:双击 `server/log-viewer.html`(Edge / Chrome),选择 `logs`(或 `logs-demo`)文件夹即可。 + 支持 `.jsonl` 与轮转 `.gz`,**本地解析、不上传**。提供概览 / 错误 / 慢请求 / 按机构 / 按接口 / 状态码 / 按小时 / traceId 追踪。 +- **命令行**:活动文件直接 `jq`,归档用 `gzip -dc`;Windows 亦可用自带 PowerShell(`ConvertFrom-Json`),无需安装 jq。 +- `server/logs-demo/` 是**模拟日志**(已 gitignore),仅用于演示查看器,可随时删除。 + ## 数据访问层(`cn.g3soft.erp.database`) `database` 下分两块,**两者零互相引用**(一边管"连接怎么来",一边管"拿到连接后怎么读写"): @@ -64,9 +142,10 @@ cn.g3soft.erp ├── common/ # 通用返回、异常、纯静态工具 │ ├── ApiResponse.java # HTTP 响应外壳 + 全部错误码常量 │ ├── exception/ # 6 个异常与 GlobalExceptionHandler -│ └── util/ # ParamUtils / SqlText / StringUtils / TimeUtils / +│ └── util/ # ParamUtils / SqlText / StringUtils / TimeUtils / RequestParams / │ # UuidV7Generator + snowflake/(内嵌的雪花 ID 生成器) -├── config/ # AuthProperties / FileStorageProperties / JacksonConfig / JwtAuthFilter +├── config/ # AuthProperties / LogProperties / FileStorageProperties / +│ # JacksonConfig / JwtAuthFilter / RequestLoggingFilter ├── controller/ # 6 个 Controller ├── database/ # 数据访问层(见上):RowReader / RowWriter / MetadataReader + model/ + org/ └── service/ diff --git a/code/g3soft-erp/server/log-viewer.html b/code/g3soft-erp/server/log-viewer.html new file mode 100644 index 00000000..e69ed55e --- /dev/null +++ b/code/g3soft-erp/server/log-viewer.html @@ -0,0 +1,414 @@ + + + + +ERP 日志分析 + + + +
+

ERP 日志分析

+

双击本文件用浏览器打开(推荐 Edge / Chrome)→ 选择日志文件或整个 logs 文件夹,也可以直接把文件拖进来。

+
+ + + + + + + +
+
+ +
把 .jsonl / .jsonl.gz 日志文件或整个 logs 文件夹拖到这里
+
+
数据只在浏览器本地解析,不会上传任何地方。
+ + + + diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/GlobalExceptionHandler.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/GlobalExceptionHandler.java index 8cd6301e..2103b220 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/GlobalExceptionHandler.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/GlobalExceptionHandler.java @@ -1,9 +1,12 @@ package cn.g3soft.erp.common.exception; import cn.g3soft.erp.common.ApiResponse; +import cn.g3soft.erp.common.util.RequestParams; +import cn.g3soft.erp.config.LogProperties; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.spi.LoggingEventBuilder; import org.springframework.dao.DataAccessException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -12,11 +15,22 @@ import java.sql.SQLException; import java.util.LinkedHashMap; import java.util.Map; +/** + * 全局异常处理。除业务性异常(AuthenticationException / BusinessException)外, + * 各错误分支都会把请求参数(query + body,截断)作为结构化字段 params 附进日志, + * 便于出错时直接看到入参;请求参数由 {@link RequestParams} 从被缓存的请求中读取。 + */ @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class); + private final LogProperties logProperties; + + public GlobalExceptionHandler(LogProperties logProperties) { + this.logProperties = logProperties; + } + @ExceptionHandler(AuthenticationException.class) public ApiResponse handleAuthenticationException(AuthenticationException exception) { return ApiResponse.fail(ApiResponse.AUTH_ERROR_CODE, exception.getMessage()); @@ -29,15 +43,17 @@ public class GlobalExceptionHandler { @ExceptionHandler(SaveObjectException.class) public ApiResponse> handleSaveObjectException( - SaveObjectException exception + SaveObjectException exception, + HttpServletRequest request ) { - LOGGER.error( - "保存失败,table={},action={},index={}", - exception.getTable(), - exception.getAction(), - exception.getIndex(), - exception - ); + withParams(LOGGER.atError(), request) + .setCause(exception) + .log( + "保存失败,table={},action={},index={}", + exception.getTable(), + exception.getAction(), + exception.getIndex() + ); Map details = new LinkedHashMap<>(); details.put("table", exception.getTable()); details.put("action", exception.getAction()); @@ -60,16 +76,18 @@ public class GlobalExceptionHandler { */ @ExceptionHandler(SaveGuardException.class) public ApiResponse> handleSaveGuardException( - SaveGuardException exception + SaveGuardException exception, + HttpServletRequest request ) { - LOGGER.warn( - "保存前置条件不成立,table={},action={},index={},refs={},condition={}", - exception.getTable(), - exception.getAction(), - exception.getIndex(), - exception.getRefs(), - exception.getCondition() - ); + withParams(LOGGER.atWarn(), request) + .log( + "保存前置条件不成立,table={},action={},index={},refs={},condition={}", + exception.getTable(), + exception.getAction(), + exception.getIndex(), + exception.getRefs(), + exception.getCondition() + ); Map details = new LinkedHashMap<>(); details.put("table", exception.getTable()); details.put("action", exception.getAction()); @@ -88,15 +106,17 @@ public class GlobalExceptionHandler { */ @ExceptionHandler(SaveConflictException.class) public ApiResponse> handleSaveConflictException( - SaveConflictException exception + SaveConflictException exception, + HttpServletRequest request ) { - LOGGER.warn( - "并发编辑冲突,table={},action={},index={},fields={}", - exception.getTable(), - exception.getAction(), - exception.getIndex(), - exception.getFields() - ); + withParams(LOGGER.atWarn(), request) + .log( + "并发编辑冲突,table={},action={},index={},fields={}", + exception.getTable(), + exception.getAction(), + exception.getIndex(), + exception.getFields() + ); Map details = new LinkedHashMap<>(); details.put("table", exception.getTable()); details.put("action", exception.getAction()); @@ -131,12 +151,25 @@ public class GlobalExceptionHandler { Exception exception, HttpServletRequest request ) { - LOGGER.error( - "系统异常,method={},uri={}", - request.getMethod(), - request.getRequestURI(), - exception - ); + withParams(LOGGER.atError(), request) + .setCause(exception) + .log( + "系统异常,method={},uri={}", + request.getMethod(), + request.getRequestURI() + ); + } + + /** 请求参数(query + body,截断)非空时作为结构化字段 params 附加到日志事件;被排除的路径不记录。 */ + private LoggingEventBuilder withParams(LoggingEventBuilder event, HttpServletRequest request) { + if (logProperties.isParamLoggingExcluded(request.getServletPath())) { + return event; + } + String params = RequestParams.collect(request, logProperties.getMaxParamBytes()); + if (params != null) { + event.addKeyValue("params", params); + } + return event; } private ApiResponse systemErrorResponse() { diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/RequestParams.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/RequestParams.java new file mode 100644 index 00000000..848b4aa1 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/RequestParams.java @@ -0,0 +1,79 @@ +package cn.g3soft.erp.common.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import java.nio.charset.StandardCharsets; + +/** + * 请求参数收集:query string + 请求体(仅在请求被 {@link ContentCachingRequestWrapper} 包裹后可读)。 + *

用于出错 / 慢请求时的排障日志。注意: + *

    + *
  • 文件上传(multipart)与二进制流直接跳过,避免把二进制内容写进日志;
  • + *
  • 按字节截断而非按字符,避免切断 UTF-8 多字节字符;
  • + *
  • 本工具**不做脱敏**,调用方需自行确保不会把密码等敏感信息写入不该写的地方。
  • + *
+ */ +public final class RequestParams { + + private static final String ELLIPSIS = "…(截断)"; + + private RequestParams() { + } + + /** + * 收集请求参数。没有任何参数时返回 {@code null},便于调用方决定是否附加该字段。 + * + * @param request HttpServletRequest(应为 ContentCachingRequestWrapper 或包含其包装) + * @param maxBytes 最大字节数 + */ + public static String collect(HttpServletRequest request, int maxBytes) { + StringBuilder sb = new StringBuilder(); + String query = request.getQueryString(); + if (query != null && !query.isBlank()) { + sb.append('?').append(query); + } + String body = readBody(request); + if (body != null && !body.isBlank()) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(body); + } + if (sb.length() == 0) { + return null; + } + return truncate(sb.toString(), maxBytes); + } + + private static String readBody(HttpServletRequest request) { + String contentType = request.getContentType(); + if (contentType != null) { + String ct = contentType.toLowerCase(); + if (ct.startsWith("multipart/") || ct.contains("octet-stream")) { + return null; + } + } + if (!(request instanceof ContentCachingRequestWrapper wrapper)) { + return null; + } + byte[] cached = wrapper.getContentAsByteArray(); + if (cached.length == 0) { + return null; + } + return new String(cached, StandardCharsets.UTF_8); + } + + private static String truncate(String value, int maxBytes) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + if (bytes.length <= maxBytes) { + return value; + } + int end = maxBytes; + // 回退到 UTF-8 字符边界(续字节形如 10xxxxxx) + while (end > 0 && (bytes[end] & 0xC0) == 0x80) { + end--; + } + return new String(bytes, 0, end, StandardCharsets.UTF_8) + ELLIPSIS; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JwtAuthFilter.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JwtAuthFilter.java index 42bfda34..65f68305 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JwtAuthFilter.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JwtAuthFilter.java @@ -10,12 +10,21 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import tools.jackson.databind.ObjectMapper; import java.io.IOException; +/** + * JWT 鉴权过滤器。运行在 {@link RequestLoggingFilter} 内层: + * 后者负责 MDC 的建立与清理,本过滤器仅在通过鉴权后把 orgId / userId 回写进 MDC, + * 使后续业务日志与访问日志都带上机构 / 用户上下文。 + */ +@Order(Ordered.LOWEST_PRECEDENCE - 10) @Component public class JwtAuthFilter extends OncePerRequestFilter { @@ -72,6 +81,10 @@ public class JwtAuthFilter extends OncePerRequestFilter { OrgContext.setOrgId(claims.orgId()); OrgContext.setUserId(claims.userId()); + // 回写 MDC,供下行业务日志与访问日志携带机构 / 用户上下文。 + // 这里只 put 不 clear:MDC 生命周期由外层的 RequestLoggingFilter 统一负责。 + MDC.put("orgId", claims.orgId()); + MDC.put("userId", claims.userId()); try { filterChain.doFilter(request, response); } finally { diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/LogProperties.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/LogProperties.java new file mode 100644 index 00000000..a00328ed --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/LogProperties.java @@ -0,0 +1,68 @@ +package cn.g3soft.erp.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +/** + * 请求参数日志配置。 + *

请求参数(query + 请求体)**只在出错或慢请求时**记录,避免正常请求刷屏、 + * 也避免把大 body 常态化写入日志: + *

    + *
  • 慢请求:{@link RequestLoggingFilter} 在访问日志行附加 params 字段;
  • + *
  • 出错:{@code GlobalExceptionHandler} 在各错误处理分支附加 params 字段。
  • + *
+ *

{@link #excludeParamPaths} 用于排除某些路径**完全不记录参数**(如登录接口含明文密码)。 + */ +@ConfigurationProperties(prefix = "erp.log") +public class LogProperties { + + /** 慢请求阈值(ms);超过则在访问日志中附带请求参数。 */ + private int slowRequestMs = 1000; + + /** 请求参数记录上限(字节),超出按字节截断;同时作为请求体缓存上限,避免大 body 占内存。 */ + private int maxParamBytes = 2048; + + /** + * 不记录请求参数的路径列表(相对 context-path,与 {@code HttpServletRequest#getServletPath()} 精确匹配)。 + * 默认排除登录接口,避免明文密码进入日志。 + */ + private List excludeParamPaths = new ArrayList<>(List.of("/auth/login")); + + /** + * 判断给定路径是否被排除在请求参数记录之外。 + * + * @param servletPath {@code HttpServletRequest#getServletPath()},不含 context-path + */ + public boolean isParamLoggingExcluded(String servletPath) { + if (servletPath == null || excludeParamPaths == null) { + return false; + } + return excludeParamPaths.contains(servletPath); + } + + public int getSlowRequestMs() { + return slowRequestMs; + } + + public void setSlowRequestMs(int slowRequestMs) { + this.slowRequestMs = slowRequestMs; + } + + public int getMaxParamBytes() { + return maxParamBytes; + } + + public void setMaxParamBytes(int maxParamBytes) { + this.maxParamBytes = maxParamBytes; + } + + public List getExcludeParamPaths() { + return excludeParamPaths; + } + + public void setExcludeParamPaths(List excludeParamPaths) { + this.excludeParamPaths = excludeParamPaths; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/RequestLoggingFilter.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/RequestLoggingFilter.java new file mode 100644 index 00000000..3ddf8088 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/RequestLoggingFilter.java @@ -0,0 +1,140 @@ +package cn.g3soft.erp.config; + +import cn.g3soft.erp.common.util.RequestParams; +import cn.g3soft.erp.common.util.UuidV7Generator; +import cn.g3soft.erp.service.auth.client.IpUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.slf4j.spi.LoggingEventBuilder; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 请求级可观测性:注入 MDC 追踪上下文 + 输出结构化访问日志。 + *

位于 {@link JwtAuthFilter} 外层(HIGHEST_PRECEDENCE+10),因此: + *

    + *
  • 访问日志能覆盖包括 OPTIONS、/auth/login 在内的全部请求(JwtAuthFilter 会跳过这些);
  • + *
  • 能统计包含鉴权在内的完整请求耗时;
  • + *
  • MDC 生命周期完全由本过滤器负责 put / clear,JwtAuthFilter 只回写 orgId/userId 不清理。
  • + *
+ *

请求体通过 {@link ContentCachingRequestWrapper} 缓存一份(有上限),使得慢请求时 + * 仍能记录请求参数;错误路径的参数由 {@code GlobalExceptionHandler} 自行读取并记录。 + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger ACCESS = LoggerFactory.getLogger("ACCESS"); + + private static final String TRACE_ID = "traceId"; + private static final String METHOD = "method"; + private static final String URI = "uri"; + + private static final String HEADER_REQUEST_ID = "X-Request-Id"; + private static final String HEADER_TRACEPARENT = "traceparent"; + private static final String HEADER_USER_AGENT = "User-Agent"; + + /** 仅接受安全字符的外部 ID,防止日志注入 / 换行伪造。 */ + private static final Pattern SAFE_ID = Pattern.compile("^[A-Za-z0-9._-]{8,64}$"); + /** W3C traceparent: 00-<32hex trace-id>-<16hex span-id>-<2hex flags> */ + private static final Pattern TRACEPARENT = + Pattern.compile("^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$"); + + private final LogProperties logProperties; + + public RequestLoggingFilter(LogProperties logProperties) { + this.logProperties = logProperties; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String traceId = resolveTraceId(request); + MDC.put(TRACE_ID, traceId); + MDC.put(METHOD, request.getMethod()); + // uri 只记 path,不含 query;query 与 body 仅在出错/慢请求时按需附加 + MDC.put(URI, request.getRequestURI()); + response.setHeader(HEADER_REQUEST_ID, traceId); + + // 缓存请求体(上限 maxParamBytes),供慢请求时记录参数;multipart 由 RequestParams 自行跳过 + HttpServletRequest effectiveRequest = wrapForBodyCaching(request); + + long startNanos = System.nanoTime(); + int status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR; + try { + filterChain.doFilter(effectiveRequest, response); + status = response.getStatus(); + } finally { + long durationMs = (System.nanoTime() - startNanos) / 1_000_000L; + try { + // traceId / orgId / userId / method / uri 由 MDC 自动平铺进此行 JSON + LoggingEventBuilder event = ACCESS.atInfo() + .addKeyValue("status", status) + .addKeyValue("durationMs", durationMs) + .addKeyValue("clientIp", IpUtils.getClientIp(request)) + .addKeyValue("userAgent", truncate(request.getHeader(HEADER_USER_AGENT), 256)); + if (durationMs > logProperties.getSlowRequestMs()) { + event.addKeyValue("slow", true); + if (!logProperties.isParamLoggingExcluded(request.getServletPath())) { + String params = RequestParams.collect(effectiveRequest, logProperties.getMaxParamBytes()); + if (params != null) { + event.addKeyValue("params", params); + } + } + } + event.log("http_access"); + } finally { + // 线程池复用线程,必须清理,避免上下文串到下一个请求 + MDC.clear(); + } + } + } + + private HttpServletRequest wrapForBodyCaching(HttpServletRequest request) { + if (request instanceof ContentCachingRequestWrapper) { + return request; + } + // 缓存上限即参数记录上限,避免大请求体常驻内存 + return new ContentCachingRequestWrapper(request, logProperties.getMaxParamBytes()); + } + + /** + * 优先复用上游传入的 X-Request-Id / W3C traceparent,保证跨系统链路可关联;否则自行生成。 + */ + private String resolveTraceId(HttpServletRequest request) { + String inbound = request.getHeader(HEADER_REQUEST_ID); + if (inbound != null && SAFE_ID.matcher(inbound).matches()) { + return inbound; + } + String traceparent = request.getHeader(HEADER_TRACEPARENT); + if (traceparent != null) { + Matcher matcher = TRACEPARENT.matcher(traceparent); + if (matcher.matches()) { + return matcher.group(1); + } + } + return UuidV7Generator.nextUuid().toString().replace("-", ""); + } + + private static String truncate(String value, int maxLength) { + if (value == null) { + return null; + } + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/WorkflowController.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/WorkflowController.java new file mode 100644 index 00000000..1bdd8915 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/WorkflowController.java @@ -0,0 +1,104 @@ +package cn.g3soft.erp.controller; + +import cn.g3soft.erp.service.workflow.WorkflowConfigService; +import cn.g3soft.erp.common.ApiResponse; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.sql.SQLException; +import java.util.Map; + +/** + * 流程服务 —— 配置侧接口(流程定义 / 版本草稿 / 发布 / 绑定)。 + * + *

流程的读取走通用接口(列表用 v_wf_definition 等视图,设计器读回用 /data/loaddata 查 wf_*), + * 写入一律走这里:发布校验、版本快照、已发布不可改、幂等这些约束必须在后端一个事务内完成, + * 通用保存接口不参与(设计 §11 / §13)。 + * + *

运行侧(提交、审批动作、待办、抄送)在 WorkflowRuntimeController,见 /workflow/runtime/**。 + * 实际访问路径带 context-path:/api/workflow/**。 + */ +@RestController +@RequestMapping("/workflow") +public class WorkflowController { + + @Resource + private WorkflowConfigService workflowConfigService; + + /* ---------- 流程定义 ---------- */ + + @PostMapping("/definition/save") + public ApiResponse> saveDefinition( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("保存成功", workflowConfigService.saveDefinition(params)); + } + + @PostMapping("/definition/delete") + public ApiResponse> deleteDefinition( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("删除成功", workflowConfigService.deleteDefinition(params)); + } + + /* ---------- 版本与草稿 ---------- */ + + @PostMapping("/version/draft/save") + public ApiResponse> saveDraft( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("草稿已保存", workflowConfigService.saveDraft(params)); + } + + @PostMapping("/version/validate") + public ApiResponse> validateVersion( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(workflowConfigService.validateDraft(params)); + } + + @PostMapping("/version/publish") + public ApiResponse> publishVersion( + @RequestBody(required = false) Map params + ) throws SQLException { + Map result = workflowConfigService.publishVersion(params); + return Boolean.TRUE.equals(result.get("published")) + ? ApiResponse.success("发布成功", result) + : ApiResponse.success("发布校验未通过", result); + } + + @PostMapping("/version/retire") + public ApiResponse retireVersion( + @RequestBody(required = false) Map params + ) throws SQLException { + workflowConfigService.retireVersion(params); + return ApiResponse.success("版本已停用", null); + } + + @PostMapping("/version/copy") + public ApiResponse> copyVersion( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("已复制为新草稿", workflowConfigService.copyVersion(params)); + } + + /* ---------- 流程绑定 ---------- */ + + @PostMapping("/binding/save") + public ApiResponse> saveBinding( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("保存成功", workflowConfigService.saveBinding(params)); + } + + @PostMapping("/binding/delete") + public ApiResponse deleteBinding( + @RequestBody(required = false) Map params + ) throws SQLException { + workflowConfigService.deleteBinding(params); + return ApiResponse.success("删除成功", null); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/WorkflowRuntimeController.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/WorkflowRuntimeController.java new file mode 100644 index 00000000..90ef12ba --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/WorkflowRuntimeController.java @@ -0,0 +1,159 @@ +package cn.g3soft.erp.controller; + +import cn.g3soft.erp.service.workflow.WorkflowRuntimeQueryService; +import cn.g3soft.erp.service.workflow.WorkflowRuntimeService; +import cn.g3soft.erp.common.ApiResponse; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.sql.SQLException; +import java.util.Map; + +/** + * 流程服务 —— 运行侧接口(送审、审批动作、待办与运行查询)。 + * + *

前端只能提交意图:改状态、建实例、推进节点、写待办全部在后端一个事务里完成(设计 §5 / §11 / §17), + * 前端不允许直接修改 wf_instance / wf_task / wf_cc 或业务表状态。 + * + *

实际访问路径带 context-path:/api/workflow/runtime/**。 + */ +@RestController +@RequestMapping("/workflow/runtime") +public class WorkflowRuntimeController { + + @Resource + private WorkflowRuntimeService runtimeService; + + @Resource + private WorkflowRuntimeQueryService queryService; + + /* ---------- 送审与审批动作 ---------- */ + + /** 送审:匹配绑定 → 建实例 → 激活首个节点(同一事务);带 idempotencyKey 时重复请求返回已有实例 */ + @PostMapping("/submit") + public ApiResponse> submit( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("已提交", runtimeService.submit(params)); + } + + /** 审批动作:action = approve / reject / return */ + @PostMapping("/task/action") + public ApiResponse> act( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("处理成功", runtimeService.act(params)); + } + + /** 抢占待办(节点 allowClaim 生效时可用) */ + @PostMapping("/task/claim") + public ApiResponse> claim( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("已抢占", runtimeService.claim(params)); + } + + /** 转交待办 */ + @PostMapping("/task/transfer") + public ApiResponse> transfer( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("已转交", runtimeService.transfer(params)); + } + + /** 加签 */ + @PostMapping("/task/add-approver") + public ApiResponse> addApprover( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("已加签", runtimeService.addApprover(params)); + } + + /** 催办:只发提醒,不改任务状态 */ + @PostMapping("/task/urge") + public ApiResponse> urge( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("已催办", runtimeService.urge(params)); + } + + /** 撤回(发起人) */ + @PostMapping("/instance/withdraw") + public ApiResponse> withdraw( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("已撤回", runtimeService.withdraw(params)); + } + + /** 取消(管理员) */ + @PostMapping("/instance/cancel") + public ApiResponse> cancel( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("已取消", runtimeService.cancel(params)); + } + + /* ---------- 审批中心与进度查询 ---------- */ + + @PostMapping("/todo/list") + public ApiResponse> todoList( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(queryService.todoList(params)); + } + + @PostMapping("/done/list") + public ApiResponse> doneList( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(queryService.doneList(params)); + } + + @PostMapping("/instance/mine") + public ApiResponse> myInstances( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(queryService.myInstances(params)); + } + + /** 实例图:节点 / 连线状态 + 时间线,用于「查看审批进度」 */ + @PostMapping("/instance/graph") + public ApiResponse> instanceGraph( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(queryService.instanceGraph(params)); + } + + /** 按业务单据查审批状态:业务详情页的状态条用(最近实例 + 我的待办 + 是否已配置绑定) */ + @PostMapping("/instance/by-business") + public ApiResponse> instanceByBusiness( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(queryService.instanceByBusiness(params)); + } + + /** 任务详情:任务 + 实例 + 节点配置(含表单策略) */ + @PostMapping("/task/detail") + public ApiResponse> taskDetail( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(queryService.taskDetail(params)); + } + + @PostMapping("/cc/list") + public ApiResponse> ccList( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(queryService.ccList(params)); + } + + @PostMapping("/cc/read") + public ApiResponse> markCcRead( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success("已标记已读", queryService.markCcRead(params)); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoader.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoader.java index 186e7108..56c7823d 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoader.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoader.java @@ -12,9 +12,11 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Locale; import java.util.Properties; import java.util.regex.Pattern; +import java.util.stream.Stream; @Component public class OrgDatabaseConfigLoader { @@ -77,6 +79,31 @@ public class OrgDatabaseConfigLoader { return configDir; } + /** + * 列出配置目录下已配置的机构码({@code {ORG_ID}.properties} 的文件名,去扩展名后规范化)。 + * + *

供无请求上下文的后台任务(如工作流超时扫描)按机构逐个 fan-out 使用。 + * 目录不存在或读取失败时返回空列表,不抛异常 —— 后台任务不该因为读不到目录而中断。 + */ + public List listOrgIds() { + if (configDir == null || !Files.isDirectory(configDir)) { + return List.of(); + } + try (Stream files = Files.list(configDir)) { + return files + .filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .filter(name -> name.toLowerCase(Locale.ROOT).endsWith(".properties")) + .map(name -> normalizeOrgId(name.substring(0, name.length() - ".properties".length()))) + .filter(orgId -> orgId != null && !orgId.isBlank()) + .distinct() + .sorted() + .toList(); + } catch (IOException exception) { + return List.of(); + } + } + /** * 配置文件修改时间戳,用于检测配置变更。文件不存在时返回 -1。 */ diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowActorResolver.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowActorResolver.java new file mode 100644 index 00000000..c4eba835 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowActorResolver.java @@ -0,0 +1,325 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.exception.BusinessException; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 工作流审批人 / 抄送人解析(设计 §6.6 / §8.3 / §9.9)。 + * + *

规则来自 {@code wf_actor_rule}:按 {@code b_priority} 顺序逐条判断适用条件, + * 命中的规则按 {@code b_actor_type} 解析出实际用户,去重后保序返回(顺序即串行会签的 b_seq 来源)。 + * + *

解析结果必须落快照 —— 组织架构以后变了,也不能改动已经产生的历史审批责任(§6.6)。 + * + *

组织主数据的现实约束:当前库的 {@code b_dept} 没有负责人列、{@code b_user} 没有上级列, + * 也不存在岗位表与组织角色表。因此 position / org_role / dept_manager / initiator_manager + * 这四类解析采用「探测式降级」:表或列存在就查,不存在就返回空并记下原因, + * 由 fallback 兜底。补齐组织主数据(sql/fms_workflow_org_ext.sql)后无需改代码即可生效。 + */ +@Service +public class WorkflowActorResolver { + + /** 兜底到管理员时使用的账号:与 SqlPermissionService.ADMIN_ACCOUNT 同一口径 */ + private static final String ADMIN_ACCOUNT = "g3soft"; + + @Resource + private ObjectMapper objectMapper; + + /** + * 解析结果。 + * + * @param userIds 实际用户编码(已去重、保序、只留启用用户) + * @param notes 解析说明:落进节点快照,用于事后解释「为什么是这些人」 + */ + public record Resolved(List userIds, List notes) { + } + + /** + * 解析某节点在该上下文下的处理人。 + * + * @param connection 当前事务连接 + * @param versionId 流程版本 + * @param nodeKey 节点编码 + * @param context 运行变量(业务字段 + 发起人 / 部门等) + * @param initiatorId 发起人 + * @param excludeUserIds 职责分离(§10.3)要排除的候选;null / 空表示不排除 + */ + public Resolved resolve( + Connection connection, + String versionId, + String nodeKey, + Map context, + String initiatorId, + Set excludeUserIds + ) throws SQLException { + List> rules = WorkflowSql.query(connection, """ + select b_id, b_actor_type, b_actor_ref, b_condition_json, b_fallback_type, b_fallback_ref + from dbo.wf_actor_rule + where b_version_id = ? and b_node_key = ? and b_canuse = 1 + order by b_priority, b_id + """, versionId, nodeKey); + + List userIds = new ArrayList<>(); + List notes = new ArrayList<>(); + String fallbackType = null; + String fallbackRef = null; + + for (Map rule : rules) { + if (!WorkflowConditionEvaluator.evaluate( + WorkflowJson.readMapOrNull(rule.get("b_condition_json"), + "actorRule.cond:" + text(rule.get("b_id"))), + context)) { + continue; + } + // 兜底配置取自第一条命中的规则(规则顺序即优先级顺序) + if (fallbackType == null) { + String type = text(rule.get("b_fallback_type")); + if (!type.isEmpty() && !"none".equals(type)) { + fallbackType = type; + fallbackRef = text(rule.get("b_fallback_ref")); + } + } + String actorType = text(rule.get("b_actor_type")); + String actorRef = text(rule.get("b_actor_ref")); + for (String userId : resolveByType(connection, actorType, actorRef, context, initiatorId, notes)) { + if (!userIds.contains(userId)) { + userIds.add(userId); + } + } + } + + // 职责分离(§10.3):先剔除被排除的人,再走「只保留启用用户」与兜底。 + // 顺序很关键:兜底是安全网,不该被 SoD 挡掉 —— 若剔除后为空,仍由兜底接管。 + if (excludeUserIds != null && !excludeUserIds.isEmpty()) { + int before = userIds.size(); + userIds.removeIf(excludeUserIds::contains); + if (userIds.size() < before) { + notes.add("职责分离:排除 " + (before - userIds.size()) + " 名候选"); + } + } + + // 只保留存在且启用的用户:脏编码不该生成「幽灵待办」 + List enabled = filterEnabledUsers(connection, userIds, notes); + + if (enabled.isEmpty() && fallbackType != null) { + notes.add("按兜底策略解析:" + fallbackType); + for (String userId : resolveFallback(connection, fallbackType, fallbackRef, initiatorId, notes)) { + if (!enabled.contains(userId)) { + enabled.add(userId); + } + } + } + return new Resolved(List.copyOf(enabled), List.copyOf(notes)); + } + + /** 把解析过程与结果压成快照 JSON(写进 wf_node_run.b_actor_snapshot_json / wf_task.b_rule_snapshot_json) */ + public String snapshotJson(List> rules, Resolved resolved) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("rules", rules); + snapshot.put("resolvedUsers", resolved.userIds()); + snapshot.put("notes", resolved.notes()); + try { + return objectMapper.writeValueAsString(snapshot); + } catch (RuntimeException exception) { + throw new BusinessException("生成审批人快照失败:" + exception.getMessage()); + } + } + + /* ---------------------------------------------------------------------------- + 各解析类型 + ---------------------------------------------------------------------------- */ + + private List resolveByType( + Connection connection, + String actorType, + String actorRef, + Map context, + String initiatorId, + List notes + ) throws SQLException { + return switch (actorType) { + case "user" -> actorRef.isEmpty() ? List.of() : splitUsers(actorRef); + case "initiator" -> initiatorId == null || initiatorId.isEmpty() ? List.of() : List.of(initiatorId); + case "field_user" -> resolveFieldUser(actorRef, context, notes); + case "dept_manager" -> resolveDeptManager(connection, context, initiatorId, notes); + case "initiator_manager" -> resolveInitiatorManager(connection, initiatorId, notes); + case "position" -> resolveByRelation(connection, "position", actorRef, notes); + case "org_role" -> resolveByRelation(connection, "org_role", actorRef, notes); + case "custom" -> { + notes.add("custom 解析器尚未注册,规则被跳过"); + yield List.of(); + } + default -> { + notes.add("未知的审批人类型:" + actorType); + yield List.of(); + } + }; + } + + /** 业务字段里存的是用户编码(支持逗号分隔的多人) */ + private List resolveFieldUser(String actorRef, Map context, List notes) { + if (actorRef.isEmpty()) { + notes.add("field_user 规则没有指定业务字段"); + return List.of(); + } + Object value = context.get(actorRef); + if (value == null || String.valueOf(value).isBlank()) { + notes.add("业务字段「" + actorRef + "」为空,解析不到审批人"); + return List.of(); + } + return splitUsers(String.valueOf(value)); + } + + /** 部门负责人:部门取自运行变量,其次取发起人的部门 */ + private List resolveDeptManager( + Connection connection, + Map context, + String initiatorId, + List notes + ) throws SQLException { + String deptId = text(context.get("b_initiator_dept_id")); + if (deptId.isEmpty()) { + deptId = text(context.get("b_dept_id")); + } + if (deptId.isEmpty()) { + deptId = text(WorkflowSql.scalar(connection, + "select top 1 b_dept_id from dbo.b_user where b_id = ?", initiatorId)); + } + if (deptId.isEmpty()) { + notes.add("dept_manager:发起人没有所属部门"); + return List.of(); + } + if (!WorkflowSql.columnExists(connection, "dbo.b_dept", "b_manager_id")) { + notes.add("dept_manager:部门表还没有负责人字段(b_dept.b_manager_id)"); + return List.of(); + } + String managerId = text(WorkflowSql.scalar(connection, + "select top 1 b_manager_id from dbo.b_dept where b_id = ?", deptId)); + if (managerId.isEmpty()) { + notes.add("dept_manager:部门「" + deptId + "」没有配置负责人"); + return List.of(); + } + return List.of(managerId); + } + + /** 发起人直属领导:依赖 b_user.b_leader_id(组织主数据扩展列) */ + private List resolveInitiatorManager(Connection connection, String initiatorId, List notes) + throws SQLException { + if (initiatorId == null || initiatorId.isEmpty()) { + notes.add("initiator_manager:没有发起人"); + return List.of(); + } + if (!WorkflowSql.columnExists(connection, "dbo.b_user", "b_leader_id")) { + notes.add("initiator_manager:用户表还没有上级字段(b_user.b_leader_id)"); + return List.of(); + } + String leaderId = text(WorkflowSql.scalar(connection, + "select top 1 b_leader_id from dbo.b_user where b_id = ?", initiatorId)); + if (leaderId.isEmpty()) { + notes.add("initiator_manager:发起人「" + initiatorId + "」没有配置上级"); + return List.of(); + } + return List.of(leaderId); + } + + /** + * 岗位 / 组织角色:依赖 b_user_position / b_user_org_role 关联表(组织主数据扩展)。 + * 表不存在时降级为空并记录原因,由 fallback 兜底。 + */ + private List resolveByRelation(Connection connection, String kind, String ref, List notes) + throws SQLException { + if (ref.isEmpty()) { + notes.add(kind + " 规则没有指定对象编码"); + return List.of(); + } + boolean position = "position".equals(kind); + String table = position ? "dbo.b_user_position" : "dbo.b_user_org_role"; + String refColumn = position ? "b_position_id" : "b_role_id"; + if (!WorkflowSql.tableExists(connection, table)) { + notes.add(kind + ":组织主数据里还没有 " + table.substring("dbo.".length()) + " 表"); + return List.of(); + } + List users = new ArrayList<>(); + for (Map row : WorkflowSql.query(connection, + "select b_user_id from " + table + " where " + refColumn + " = ? and b_canuse = 1", ref)) { + users.add(text(row.get("b_user_id"))); + } + if (users.isEmpty()) { + notes.add(kind + ":「" + ref + "」下没有启用的用户"); + } + return users; + } + + private List resolveFallback( + Connection connection, + String fallbackType, + String fallbackRef, + String initiatorId, + List notes + ) throws SQLException { + return switch (fallbackType) { + case "admin" -> List.of(ADMIN_ACCOUNT); + case "initiator" -> initiatorId == null || initiatorId.isEmpty() ? List.of() : List.of(initiatorId); + case "custom" -> { + notes.add("custom 兜底尚未注册:" + fallbackRef); + yield List.of(); + } + default -> List.of(); + }; + } + + /** 过滤掉不存在或已停用的用户,并记录被过滤掉的编码 */ + private List filterEnabledUsers(Connection connection, List userIds, List notes) + throws SQLException { + if (userIds.isEmpty()) { + return new ArrayList<>(); + } + String placeholders = String.join(", ", java.util.Collections.nCopies(userIds.size(), "?")); + List> rows = WorkflowSql.query(connection, + "select b_id from dbo.b_user where b_canuse = 1 and b_id in (" + placeholders + ")", + userIds.toArray()); + LinkedHashSet enabled = new LinkedHashSet<>(); + rows.forEach(row -> enabled.add(text(row.get("b_id")))); + + List kept = new ArrayList<>(); + for (String userId : userIds) { + if (enabled.contains(userId)) { + kept.add(userId); + } else { + notes.add("用户「" + userId + "」不存在或已停用,已剔除"); + } + } + return kept; + } + + /* ---------------------------------------------------------------------------- + 小工具 + ---------------------------------------------------------------------------- */ + + private List splitUsers(String value) { + List users = new ArrayList<>(); + for (String part : value.split(",")) { + String userId = part.trim(); + if (!userId.isEmpty() && !users.contains(userId)) { + users.add(userId); + } + } + return users; + } + + @SuppressWarnings("unchecked") + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluator.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluator.java new file mode 100644 index 00000000..d8387da8 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluator.java @@ -0,0 +1,198 @@ +package cn.g3soft.erp.service.workflow; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 工作流条件求值(设计 §7.1 / §7.2)—— 纯逻辑,不碰数据库。 + * + *

AST 形状(与前端 domain.js 的 ConditionBuilder 一致): + *

+ * 叶子:{ "field": "b_amount", "operator": "gt", "value": 10000 }
+ * 组合:{ "all": [ ... ] } / { "any": [ ... ] } / { "not": { ... } }
+ * 
+ * + *

求值语义(设计文档未明确的部分,这里定死并保持前后端一致): + *

    + *
  • 字段值从上下文取(业务单据字段 + 运行变量如 b_initiator_id),取不到按 null 处理;
  • + *
  • {@code eq}/{@code ne} 先试数值比较,两边都能转数字就按数值,否则按字符串;
  • + *
  • {@code gt}/{@code ge}/{@code lt}/{@code le}/{@code between} 要求两边都能转数字,否则返回 false;
  • + *
  • {@code like} 为不区分大小写的包含匹配;{@code in}/{@code not_in} 逐个按 eq 语义比较;
  • + *
  • 空串与 null 都算「空」({@code is_null} 命中)。
  • + *
+ * + *

不求值异常:条件算不出来(缺字段、类型不可比)一律返回 false 并跳过该分支, + * 由调用方在找不到任何可用出线时按「无默认出口」处理 —— 一条脏条件不该让整个审批事务失败。 + */ +public final class WorkflowConditionEvaluator { + + private static final Set LOGIC_KEYS = Set.of("all", "any", "not"); + + private WorkflowConditionEvaluator() { + } + + /** + * 求值。condition 为空表示「无条件」(默认出口)——永远命中。 + * + * @param condition 条件 AST,null / 空对象视为无条件 + * @param context 业务字段与运行变量 + */ + public static boolean evaluate(Map condition, Map context) { + if (condition == null || condition.isEmpty()) { + return true; + } + Map variables = context == null ? Map.of() : context; + + for (String key : LOGIC_KEYS) { + if (!condition.containsKey(key)) { + continue; + } + Object children = condition.get(key); + if ("not".equals(key)) { + return children instanceof Map nested + && !evaluate(castMap(nested), variables); + } + if (!(children instanceof List list)) { + return false; + } + for (Object item : list) { + if (!(item instanceof Map nested)) { + return false; + } + boolean matched = evaluate(castMap(nested), variables); + if ("all".equals(key) && !matched) { + return false; + } + if ("any".equals(key) && matched) { + return true; + } + } + // all:全部命中;any:一个都没命中 + return "all".equals(key); + } + + String field = text(condition.get("field")); + String operator = text(condition.get("operator")); + Object expected = condition.get("value"); + Object actual = variables.get(field); + + return switch (operator) { + case "eq" -> equalsValue(actual, expected); + case "ne" -> !equalsValue(actual, expected); + case "gt" -> compare(actual, expected) > 0; + case "ge" -> compare(actual, expected) >= 0; + case "lt" -> compare(actual, expected) < 0; + case "le" -> compare(actual, expected) <= 0; + case "in" -> containsValue(expected, actual); + case "not_in" -> !containsValue(expected, actual); + case "like" -> like(actual, expected); + case "is_null" -> isBlank(actual); + case "is_not_null" -> !isBlank(actual); + case "between" -> between(actual, expected); + default -> false; + }; + } + + /* ---------- 比较细节 ---------- */ + + private static boolean equalsValue(Object actual, Object expected) { + if (actual == null && expected == null) { + return true; + } + if (actual == null || expected == null) { + // 一边是空:只有「空 vs 空串」这种等价情况算相等 + return isBlank(actual) && isBlank(expected); + } + BigDecimal left = decimal(actual); + BigDecimal right = decimal(expected); + if (left != null && right != null) { + return left.compareTo(right) == 0; + } + return String.valueOf(actual).equals(String.valueOf(expected)); + } + + /** 数值比较:任一边不是数字返回 0(调用方按 false 处理,不命中) */ + private static int compare(Object actual, Object expected) { + BigDecimal left = decimal(actual); + BigDecimal right = decimal(expected); + if (left == null || right == null) { + return 0; + } + return left.compareTo(right); + } + + private static boolean containsValue(Object expected, Object actual) { + if (actual == null) { + return false; + } + if (expected instanceof List list) { + return list.stream().anyMatch(item -> equalsValue(actual, item)); + } + // 字符串形式 "a,b,c" 也接受(老配置的容忍读法) + String text = String.valueOf(expected); + if (text.isBlank()) { + return false; + } + for (String part : text.split(",")) { + if (equalsValue(actual, part.trim())) { + return true; + } + } + return false; + } + + private static boolean like(Object actual, Object expected) { + if (actual == null || expected == null) { + return false; + } + return String.valueOf(actual).toLowerCase().contains(String.valueOf(expected).toLowerCase()); + } + + private static boolean between(Object actual, Object expected) { + if (!(expected instanceof List list) || list.size() != 2) { + return false; + } + BigDecimal value = decimal(actual); + BigDecimal low = decimal(list.get(0)); + BigDecimal high = decimal(list.get(1)); + if (value == null || low == null || high == null) { + return false; + } + return value.compareTo(low) >= 0 && value.compareTo(high) <= 0; + } + + private static boolean isBlank(Object value) { + if (value == null) { + return true; + } + return value instanceof String text && text.isBlank(); + } + + private static BigDecimal decimal(Object value) { + if (value instanceof BigDecimal decimal) { + return decimal; + } + if (value instanceof Number number) { + return new BigDecimal(number.toString()); + } + if (value instanceof String text && !text.isBlank()) { + try { + return new BigDecimal(text.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + @SuppressWarnings("unchecked") + private static Map castMap(Map map) { + return (Map) map; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigException.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigException.java new file mode 100644 index 00000000..0a0bd8f4 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigException.java @@ -0,0 +1,17 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.exception.BusinessException; + +/** + * 工作流配置数据损坏:某个 JSON 列非空,但无法解析、或解析结果不是对象。 + * + *

之所以单独成类,是为了让运行期能把它与「业务校验失败」区分开并转成**实例 error**: + * 连线条件 / 绑定匹配 / 节点配置这些「作者配置」一旦损坏,**绝不能静默降级成空条件**, + * 否则会被当成无条件的默认边命中,造成静默错路由(原型里的真实缺陷)。 + */ +public class WorkflowConfigException extends BusinessException { + + public WorkflowConfigException(String message) { + super(message); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigService.java new file mode 100644 index 00000000..d6bb7ceb --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigService.java @@ -0,0 +1,1026 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.ParamUtils; +import cn.g3soft.erp.common.util.TimeUtils; +import cn.g3soft.erp.common.util.snowflake.idgen.IdGenerator; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 工作流配置侧服务 —— 流程定义、版本、节点 / 连线 / 审批人规则与流程绑定。 + * + *

为什么不做成通用保存接口:流程写入必须经过发布校验、版本快照与「已发布不可改」约束 + * (设计 §6.2 / §12 / §13 明确「不能使用通用表保存接口直接承载流程动作」), + * 因此这里是一组受控服务方法:前端只提交领域草稿,由服务端决定落到 wf_* 的哪几行。 + * + *

事务:与 DataSaveService 同口径 —— 手动 setAutoCommit(false) / commit / rollback, + * 全项目不使用 @Transactional。 + * + *

连接:注入的 DataSource 是 @Primary 的 OrgRoutingDataSource, + * 按当前请求 OrgContext 路由到机构库,不需要显式取机构连接。 + */ +@Service +public class WorkflowConfigService { + + /** 流程定义编码:字母开头,字母数字下划线,创建后不可修改(§6.1) */ + private static final Pattern DEFINITION_ID_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,49}$"); + /** 版本状态(§4.2) */ + private static final String STATUS_DRAFT = "draft"; + private static final String STATUS_PUBLISHED = "published"; + private static final String STATUS_RETIRED = "retired"; + /** 业务事件(§6.3):绑定只允许这三个取值 */ + private static final Set BINDING_EVENTS = Set.of("submit", "resubmit", "change"); + /** 绑定接入审批后要自动准备的动作权限点(§10.2)。缺权限点时非管理员会被白名单直接拒绝 */ + private static final List ACTION_POWERS = List.of( + new ActionPower("submit", "送审"), + new ActionPower("approve", "通过"), + new ActionPower("reject", "驳回"), + new ActionPower("return", "退回"), + new ActionPower("claim", "抢占"), + new ActionPower("withdraw", "撤回"), + new ActionPower("transfer", "转交"), + new ActionPower("delegate", "委托"), + new ActionPower("reassign", "转派"), + new ActionPower("add_approver", "加签"), + new ActionPower("urge", "催办"), + new ActionPower("workflow_admin", "流程管理员") + ); + + private record ActionPower(String code, String name) { + } + + /** 走列的节点配置键:其余键进 b_config_json(与前端 rowsToWorkflowDraft 的读取口径一致) */ + private static final List NODE_CONFIG_COLUMNS = List.of("approvalMode", "rejectMode", "dueHours"); + /** 工作流运行变量:条件里可以引用(发起人、部门、公司) */ + private static final Set RUNTIME_CONDITION_FIELDS = Set.of( + "b_initiator_id", "b_initiator_dept_id", "b_org_id" + ); + + @Resource + private DataSource dataSource; + + @Resource + private ObjectMapper objectMapper; + + /* ============================================================================ + 流程定义 + ============================================================================ */ + + /** + * 新建或修改流程定义。新建时同时落一条空草稿的 v1 版本,保证设计器一进去就有可保存的目标。 + * + * @return { definitionId, versionId, created } + */ + public Map saveDefinition(Map params) throws SQLException { + String definitionId = ParamUtils.getRequiredString(params, "definitionId").trim(); + String name = ParamUtils.getRequiredString(params, "name").trim(); + String i18n = ParamUtils.getString(params, "i18n"); + String bz = ParamUtils.getString(params, "bz"); + boolean canuse = !Boolean.FALSE.equals(params.get("canuse")); + String operator = currentUser(); + + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + Map result = new LinkedHashMap<>(); + result.put("definitionId", definitionId); + + if (WorkflowSql.queryOne(connection, "select b_id from dbo.wf_definition where b_id = ?", definitionId) != null) { + WorkflowSql.update(connection, """ + update dbo.wf_definition + set b_name = ?, b_i18n = ?, b_canuse = ?, b_bz = ?, + b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, name, i18n, canuse ? 1 : 0, bz, operator, definitionId); + Map draft = WorkflowSql.queryOne(connection, """ + select top 1 b_id from dbo.wf_version + where b_definition_id = ? and b_status = ? + order by b_version_no desc + """, definitionId, STATUS_DRAFT); + result.put("versionId", draft == null ? null : draft.get("b_id")); + result.put("created", false); + } else { + if (!DEFINITION_ID_PATTERN.matcher(definitionId).matches()) { + throw new BusinessException( + "流程编码只能以字母开头,由字母、数字、下划线组成,且不超过 50 个字符"); + } + WorkflowSql.update(connection, """ + insert into dbo.wf_definition + (b_id, b_name, b_i18n, b_canuse, b_bz, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, ?, ?, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, definitionId, name, i18n, canuse ? 1 : 0, bz, operator, operator); + String versionId = versionIdOf(definitionId, 1); + WorkflowSql.update(connection, """ + insert into dbo.wf_version + (b_id, b_definition_id, b_version_no, b_status, b_schema_version, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, 1, ?, 1, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, versionId, definitionId, STATUS_DRAFT, operator, operator); + result.put("versionId", versionId); + result.put("created", true); + } + + connection.commit(); + return result; + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + restoreAutoCommit(connection, originalAutoCommit); + } + } + } + + /** + * 删除流程定义:连同它的版本、节点、连线、审批人规则与绑定一起删除。 + * 有未结束实例时拒绝 —— 运行历史必须能追溯到版本,不能留下悬空引用。 + * + * @return { versions, bindings } + */ + public Map deleteDefinition(Map params) throws SQLException { + String definitionId = ParamUtils.getRequiredString(params, "definitionId"); + + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + Map active = WorkflowSql.queryOne(connection, """ + select count(1) as cnt from dbo.wf_instance + where b_definition_id = ? and b_status in (?, ?, ?) + """, definitionId, + "running", "returned", "error"); + long running = (long) number(active == null ? null : active.get("cnt"), 0).doubleValue(); + if (running > 0) { + throw new BusinessException("该流程还有 " + running + " 个未结束的实例,不能删除"); + } + + List versionIds = WorkflowSql.query(connection, + "select b_id from dbo.wf_version where b_definition_id = ?", definitionId) + .stream().map(row -> String.valueOf(row.get("b_id"))).toList(); + + int bindings = 0; + if (!versionIds.isEmpty()) { + String placeholders = String.join(", ", Collections.nCopies(versionIds.size(), "?")); + bindings = WorkflowSql.update(connection, + "delete from dbo.wf_binding where b_version_id in (" + placeholders + ")", + versionIds.toArray()); + WorkflowSql.update(connection, + "delete from dbo.wf_actor_rule where b_version_id in (" + placeholders + ")", + versionIds.toArray()); + WorkflowSql.update(connection, + "delete from dbo.wf_edge where b_version_id in (" + placeholders + ")", + versionIds.toArray()); + WorkflowSql.update(connection, + "delete from dbo.wf_node where b_version_id in (" + placeholders + ")", + versionIds.toArray()); + } + WorkflowSql.update(connection, "delete from dbo.wf_version where b_definition_id = ?", definitionId); + WorkflowSql.update(connection, "delete from dbo.wf_definition where b_id = ?", definitionId); + + Map result = new LinkedHashMap<>(); + result.put("versions", versionIds.size()); + result.put("bindings", bindings); + connection.commit(); + return result; + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + restoreAutoCommit(connection, originalAutoCommit); + } + } + } + + /* ============================================================================ + 版本与草稿 + ============================================================================ */ + + /** + * 保存草稿:只允许 draft 版本,全量替换该版本的节点 / 连线 / 审批人规则(幂等)。 + * 已发布版本一律拒绝 —— 改流程要复制新版本(§4.2)。 + * + * @param params definitionId、versionId(可空:空则取该定义的草稿,没有就新建)、 + * draft({nodes, edges})、canvas(可空,只存布局) + * @return { versionId, versionNo, issues } + */ + public Map saveDraft(Map params) throws SQLException { + String definitionId = ParamUtils.getRequiredString(params, "definitionId"); + String versionId = ParamUtils.getString(params, "versionId"); + Map draftParam = mapParam(params, "draft"); + if (draftParam == null) { + throw new BusinessException("草稿内容不能为空"); + } + WorkflowDraft draft = WorkflowDraft.parse(draftParam); + Map canvas = mapParam(params, "canvas"); + String operator = currentUser(); + + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + if (WorkflowSql.queryOne(connection, "select b_id from dbo.wf_definition where b_id = ?", definitionId) == null) { + throw new BusinessException("流程「" + definitionId + "」不存在"); + } + + Map version = resolveDraftVersion(connection, definitionId, versionId, operator); + String targetVersionId = String.valueOf(version.get("b_id")); + + WorkflowSql.update(connection, "delete from dbo.wf_actor_rule where b_version_id = ?", targetVersionId); + WorkflowSql.update(connection, "delete from dbo.wf_edge where b_version_id = ?", targetVersionId); + WorkflowSql.update(connection, "delete from dbo.wf_node where b_version_id = ?", targetVersionId); + + int xh = 10; + for (WorkflowDraft.Node node : draft.nodes()) { + writeNode(connection, targetVersionId, node, xh); + xh += 10; + } + // 出线优先级 = 前端数组里同源节点的出现顺序(§6.5:条件按 b_priority 从小到大判断) + Map priorityByNode = new LinkedHashMap<>(); + Map edgeKeyMap = new LinkedHashMap<>(); + for (WorkflowDraft.Edge edge : draft.edges()) { + int priority = priorityByNode.merge(edge.from(), 10, Integer::sum); + String storedId = writeEdge(connection, targetVersionId, edge, priority); + if (!storedId.equals(edge.edgeKey())) { + // 编码已被其它版本占用而换了新 ID:把映射回给前端,前端据此保持 edgeKey 与库里一致 + edgeKeyMap.put(edge.edgeKey(), storedId); + } + } + + WorkflowSql.update(connection, """ + update dbo.wf_version + set b_canvas_json = ?, b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, canvas == null ? null : toJson(canvas), operator, targetVersionId); + + Map result = new LinkedHashMap<>(); + result.put("versionId", targetVersionId); + result.put("versionNo", version.get("b_version_no")); + result.put("issues", toIssueList(WorkflowValidator.validate(draft, null))); + result.put("edgeKeyMap", edgeKeyMap); + connection.commit(); + return result; + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + restoreAutoCommit(connection, originalAutoCommit); + } + } + } + + /** + * 校验草稿:传 draft 校验内存草稿(设计器边改边校验), + * 传 versionId 读库校验(发布前复核、版本历史里回看)。 + * + * @return { issues, errorCount, warnCount } + */ + public Map validateDraft(Map params) throws SQLException { + WorkflowDraft draft; + Set allowedFields = null; + if (params != null && params.get("draft") instanceof Map) { + draft = WorkflowDraft.parse(mapParam(params, "draft")); + } else { + String versionId = ParamUtils.getRequiredString(params, "versionId"); + try (Connection connection = dataSource.getConnection()) { + draft = loadDraft(connection, versionId); + allowedFields = allowedConditionFields(connection, versionId); + } + } + List issues = WorkflowValidator.validate(draft, allowedFields); + Map result = new LinkedHashMap<>(); + result.put("issues", toIssueList(issues)); + result.put("errorCount", issues.stream() + .filter(issue -> WorkflowValidator.LEVEL_ERROR.equals(issue.level())).count()); + result.put("warnCount", issues.stream() + .filter(issue -> WorkflowValidator.LEVEL_WARN.equals(issue.level())).count()); + return result; + } + + /** + * 发布版本:校验必须零 error,然后计算配置摘要(checksum)并锁定节点与连线。 + * 校验不通过时返回 {@code published:false} + issues,不抛异常 —— 调用方要能一次看到全部问题。 + * + * @return { published, issues, checksum } + */ + public Map publishVersion(Map params) throws SQLException { + String versionId = ParamUtils.getRequiredString(params, "versionId"); + String operator = currentUser(); + + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + Map version = WorkflowSql.queryOne(connection, + "select b_id, b_definition_id, b_status from dbo.wf_version with (UPDLOCK, ROWLOCK) where b_id = ?", versionId); + if (version == null) { + throw new BusinessException("版本「" + versionId + "」不存在"); + } + if (!STATUS_DRAFT.equals(String.valueOf(version.get("b_status")))) { + throw new BusinessException("只有草稿版本可以发布,当前版本状态:" + version.get("b_status")); + } + + WorkflowDraft draft = loadDraft(connection, versionId); + List issues = + WorkflowValidator.validate(draft, allowedConditionFields(connection, versionId)); + + Map result = new LinkedHashMap<>(); + result.put("issues", toIssueList(issues)); + if (WorkflowValidator.hasError(issues)) { + result.put("published", false); + connection.commit(); + return result; + } + + String checksum = checksumOf(draft); + WorkflowSql.update(connection, """ + update dbo.wf_version + set b_status = ?, b_checksum = ?, b_published_by = ?, b_published_at = sysutcdatetime(), + b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, STATUS_PUBLISHED, checksum, operator, operator, versionId); + + result.put("published", true); + result.put("checksum", checksum); + result.put("definitionId", version.get("b_definition_id")); + connection.commit(); + return result; + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + restoreAutoCommit(connection, originalAutoCommit); + } + } + } + + /** 停用版本:published → retired,不再接受新实例(§4.2);已有实例继续用原版本跑完。 */ + public void retireVersion(Map params) throws SQLException { + String versionId = ParamUtils.getRequiredString(params, "versionId"); + String operator = currentUser(); + + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + Map version = WorkflowSql.queryOne(connection, + "select b_status from dbo.wf_version with (UPDLOCK, ROWLOCK) where b_id = ?", versionId); + if (version == null) { + throw new BusinessException("版本「" + versionId + "」不存在"); + } + if (!STATUS_PUBLISHED.equals(String.valueOf(version.get("b_status")))) { + throw new BusinessException("只有已发布版本可以停用,当前版本状态:" + version.get("b_status")); + } + WorkflowSql.update(connection, """ + update dbo.wf_version + set b_status = ?, b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, STATUS_RETIRED, operator, versionId); + connection.commit(); + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + restoreAutoCommit(connection, originalAutoCommit); + } + } + } + + /** + * 以某个版本为模板复制出一条新草稿(版本号 = 当前最大值 + 1)。 + * 同一定义同时只允许一条草稿,避免「改的是哪一条」说不清。 + * + * @return { versionId, versionNo } + */ + public Map copyVersion(Map params) throws SQLException { + String versionId = ParamUtils.getRequiredString(params, "versionId"); + String operator = currentUser(); + + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + Map source = WorkflowSql.queryOne(connection, """ + select b_id, b_definition_id, b_version_no, b_canvas_json + from dbo.wf_version where b_id = ? + """, versionId); + if (source == null) { + throw new BusinessException("版本「" + versionId + "」不存在"); + } + String definitionId = String.valueOf(source.get("b_definition_id")); + + Map draftRow = WorkflowSql.queryOne(connection, + "select b_id from dbo.wf_version where b_definition_id = ? and b_status = ?", + definitionId, STATUS_DRAFT); + if (draftRow != null) { + throw new BusinessException( + "该流程已有草稿版本「" + draftRow.get("b_id") + "」,请先发布或删除它"); + } + + int nextNo = nextVersionNo(connection, definitionId); + String newVersionId = versionIdOf(definitionId, nextNo); + WorkflowSql.update(connection, """ + insert into dbo.wf_version + (b_id, b_definition_id, b_version_no, b_status, b_schema_version, b_canvas_json, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, ?, ?, 1, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, newVersionId, definitionId, nextNo, STATUS_DRAFT, + source.get("b_canvas_json"), operator, operator); + + // 节点 / 连线 / 审批人规则整份复制:node_key 稳定(§6.4), + // 但行主键 b_id 必须换新 —— 源版本还在,原样复制会直接撞主键 + WorkflowSql.update(connection, """ + insert into dbo.wf_node + (b_id, b_version_id, b_node_key, b_name, b_i18n, b_node_type, b_approval_mode, + b_reject_mode, b_due_hours, b_config_json, b_xh, b_canuse) + select convert(varchar(50), newid()), ?, b_node_key, b_name, b_i18n, b_node_type, + b_approval_mode, b_reject_mode, b_due_hours, b_config_json, b_xh, b_canuse + from dbo.wf_node where b_version_id = ? + """, newVersionId, versionId); + WorkflowSql.update(connection, """ + insert into dbo.wf_edge + (b_id, b_version_id, b_from_node_key, b_to_node_key, b_name, b_condition_json, + b_priority, b_canuse, b_bz) + select convert(varchar(50), newid()), ?, b_from_node_key, b_to_node_key, b_name, + b_condition_json, b_priority, b_canuse, b_bz + from dbo.wf_edge where b_version_id = ? + """, newVersionId, versionId); + WorkflowSql.update(connection, """ + insert into dbo.wf_actor_rule + (b_id, b_version_id, b_node_key, b_actor_type, b_actor_ref, + b_condition_json, b_fallback_type, b_fallback_ref, b_priority, b_canuse, b_bz) + select convert(varchar(50), newid()), ?, b_node_key, b_actor_type, b_actor_ref, + b_condition_json, b_fallback_type, b_fallback_ref, + b_priority, b_canuse, b_bz + from dbo.wf_actor_rule where b_version_id = ? + """, newVersionId, versionId); + + Map result = new LinkedHashMap<>(); + result.put("versionId", newVersionId); + result.put("versionNo", nextNo); + connection.commit(); + return result; + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + restoreAutoCommit(connection, originalAutoCommit); + } + } + } + + /* ============================================================================ + 流程绑定 + ============================================================================ */ + + /** + * 保存流程绑定(§6.3):模块 + 业务事件 + 已发布版本 + 匹配条件 + 组织范围。 + * 绑定只能指向 published 版本;同一模块同一事件下不允许存在两条同优先级的启用绑定 + * (§4.3「不能出现多个无法确定优先级的生效流程」,这里是后端最终校验)。 + * + * @return { bindingId, created } + */ + public Map saveBinding(Map params) throws SQLException { + String bindingId = ParamUtils.getString(params, "bindingId"); + String moduleId = ParamUtils.getRequiredString(params, "moduleId"); + String event = ParamUtils.getRequiredString(params, "event"); + String versionId = ParamUtils.getRequiredString(params, "versionId"); + if (!BINDING_EVENTS.contains(event)) { + throw new BusinessException("业务事件不合法:" + event); + } + int priority = (int) number(params.get("priority"), 0).doubleValue(); + int canuse = Boolean.FALSE.equals(params.get("canuse")) ? 0 : 1; + String operator = currentUser(); + + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + Map version = WorkflowSql.queryOne(connection, + "select b_status from dbo.wf_version where b_id = ?", versionId); + if (version == null) { + throw new BusinessException("流程版本「" + versionId + "」不存在"); + } + if (!STATUS_PUBLISHED.equals(String.valueOf(version.get("b_status")))) { + throw new BusinessException("绑定只能指向已发布版本,当前版本状态:" + version.get("b_status")); + } + + if (canuse == 1) { + Map conflict = bindingId == null || bindingId.isBlank() + ? WorkflowSql.queryOne(connection, """ + select b_id from dbo.wf_binding + where b_module_id = ? and b_event = ? and b_canuse = 1 and b_priority = ? + """, moduleId, event, priority) + : WorkflowSql.queryOne(connection, """ + select b_id from dbo.wf_binding + where b_module_id = ? and b_event = ? and b_canuse = 1 and b_priority = ? + and b_id <> ? + """, moduleId, event, priority, bindingId); + if (conflict != null) { + throw new BusinessException("同一模块同一事件下已存在同优先级的启用绑定「" + + conflict.get("b_id") + "」,请调整优先级或停用其中一条"); + } + } + + Map result = new LinkedHashMap<>(); + if (bindingId == null || bindingId.isBlank()) { + bindingId = String.valueOf(IdGenerator.nextId()); + WorkflowSql.update(connection, """ + insert into dbo.wf_binding + (b_id, b_module_id, b_event, b_version_id, b_match_json, b_org_scope_json, + b_status_field, b_status_map_json, b_priority, b_effective_from, b_effective_to, + b_canuse, b_bz, b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, bindingId, moduleId, event, versionId, + jsonParam(params, "match"), jsonParam(params, "orgScope"), + blankToNull(ParamUtils.getString(params, "statusField")), + jsonParam(params, "statusMap"), priority, + timeParam(params, "effectiveFrom"), timeParam(params, "effectiveTo"), + canuse, ParamUtils.getString(params, "bz"), operator, operator); + result.put("created", true); + } else { + int updated = WorkflowSql.update(connection, """ + update dbo.wf_binding + set b_module_id = ?, b_event = ?, b_version_id = ?, b_match_json = ?, + b_org_scope_json = ?, b_status_field = ?, b_status_map_json = ?, + b_priority = ?, b_effective_from = ?, b_effective_to = ?, b_canuse = ?, + b_bz = ?, b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, moduleId, event, versionId, + jsonParam(params, "match"), jsonParam(params, "orgScope"), + blankToNull(ParamUtils.getString(params, "statusField")), + jsonParam(params, "statusMap"), priority, + timeParam(params, "effectiveFrom"), timeParam(params, "effectiveTo"), + canuse, ParamUtils.getString(params, "bz"), operator, bindingId); + if (updated == 0) { + throw new BusinessException("绑定「" + bindingId + "」不存在"); + } + result.put("created", false); + } + result.put("bindingId", bindingId); + // 绑定即视为「这个模块接入了审批」:顺手把动作权限点备好, + // 否则管理员在用户权限页看不到可授权的权限点,非管理员送审会被白名单拒绝 + ensureActionPowers(connection, moduleId); + connection.commit(); + return result; + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + restoreAutoCommit(connection, originalAutoCommit); + } + } + } + + /** 删除流程绑定 */ + public void deleteBinding(Map params) throws SQLException { + String bindingId = ParamUtils.getRequiredString(params, "bindingId"); + + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + if (WorkflowSql.update(connection, "delete from dbo.wf_binding where b_id = ?", bindingId) == 0) { + throw new BusinessException("绑定「" + bindingId + "」不存在"); + } + connection.commit(); + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + restoreAutoCommit(connection, originalAutoCommit); + } + } + } + + /** + * 确保该模块的动作权限点存在(幂等)。 + * + *

权限点编码与命名遵循 §10.2:{@code action.{模块}.{动作}}; + * 已存在的不覆盖,避免覆盖管理员改过的名称。 + */ + private void ensureActionPowers(Connection connection, String moduleId) throws SQLException { + Map module = WorkflowSql.queryOne(connection, "select b_name from dbo.s_module where b_id = ?", moduleId); + String label = module == null || module.get("b_name") == null + ? moduleId + : String.valueOf(module.get("b_name")); + + int xh = 10; + for (ActionPower power : ACTION_POWERS) { + String powerId = "action." + moduleId + "." + power.code(); + WorkflowSql.update(connection, """ + if not exists (select 1 from dbo.s_power where b_id = ?) + insert into dbo.s_power + (b_id, b_name, b_type, b_object_type, b_object_id, b_action, b_canuse, b_xh) + values (?, ?, 'action', 'module', ?, ?, 1, ?) + """, powerId, powerId, label + " - " + power.name(), moduleId, power.code(), xh); + xh += 10; + } + } + + /* ============================================================================ + 读库 → 领域草稿 + ============================================================================ */ + + /** 读回某版本的领域草稿(节点 / 连线 / 审批人规则),供校验、发布与设计器复核 */ + public WorkflowDraft loadDraft(Connection connection, String versionId) throws SQLException { + List> nodeRows = WorkflowSql.query(connection, """ + select b_node_key, b_node_type, b_name, b_approval_mode, b_reject_mode, b_due_hours, b_config_json + from dbo.wf_node where b_version_id = ? order by b_xh, b_node_key + """, versionId); + List> edgeRows = WorkflowSql.query(connection, """ + select b_id, b_from_node_key, b_to_node_key, b_name, b_condition_json + from dbo.wf_edge where b_version_id = ? order by b_priority, b_id + """, versionId); + List> ruleRows = WorkflowSql.query(connection, """ + select b_node_key, b_actor_type, b_actor_ref, + b_condition_json, b_fallback_type, b_fallback_ref + from dbo.wf_actor_rule where b_version_id = ? order by b_priority, b_id + """, versionId); + + Map>> rulesByNode = new LinkedHashMap<>(); + ruleRows.forEach(row -> rulesByNode + .computeIfAbsent(String.valueOf(row.get("b_node_key")), key -> new ArrayList<>()) + .add(row)); + + List nodes = new ArrayList<>(); + for (Map row : nodeRows) { + String nodeKey = String.valueOf(row.get("b_node_key")); + Map config = new LinkedHashMap<>(readJsonMap(row.get("b_config_json"))); + if (row.get("b_approval_mode") != null) { + config.put("approvalMode", row.get("b_approval_mode")); + } + if (row.get("b_reject_mode") != null) { + config.put("rejectMode", row.get("b_reject_mode")); + } + if (row.get("b_due_hours") != null) { + config.put("dueHours", row.get("b_due_hours")); + } + + List actorRules = new ArrayList<>(); + for (Map rule : rulesByNode.getOrDefault(nodeKey, List.of())) { + // 规则条件用严格读取:loadDraft 同时是发布校验的输入, + // 损坏的条件若被降级成「无条件」,校验就再也拦不住它。 + actorRules.add(new WorkflowDraft.ActorRule( + String.valueOf(rule.get("b_actor_type")), + rule.get("b_actor_ref") == null ? "" : String.valueOf(rule.get("b_actor_ref")), + WorkflowJson.readMapOrNull(rule.get("b_condition_json"), + "actorRule.cond:" + nodeKey), + rule.get("b_fallback_type") == null ? null : String.valueOf(rule.get("b_fallback_type")), + rule.get("b_fallback_ref") == null ? null : String.valueOf(rule.get("b_fallback_ref")) + )); + } + nodes.add(new WorkflowDraft.Node( + nodeKey, + String.valueOf(row.get("b_node_type")), + String.valueOf(row.get("b_name")), + config, + List.copyOf(actorRules) + )); + } + + List edges = new ArrayList<>(); + for (Map row : edgeRows) { + edges.add(new WorkflowDraft.Edge( + String.valueOf(row.get("b_id")), + String.valueOf(row.get("b_from_node_key")), + String.valueOf(row.get("b_to_node_key")), + row.get("b_name") == null ? "" : String.valueOf(row.get("b_name")), + readJsonObjectOrNull(row.get("b_condition_json")) + )); + } + return new WorkflowDraft(List.copyOf(nodes), List.copyOf(edges)); + } + + /** + * 发布校验用的「允许条件字段」:绑定到该版本的模块的 s_field 清单 + 运行变量。 + * 没有绑定时返回 null —— 此时流程还没有归属模块,字段清单无从谈起,跳过字段校验。 + */ + private Set allowedConditionFields(Connection connection, String versionId) throws SQLException { + List> modules = WorkflowSql.query(connection, + "select b_module_id from dbo.wf_binding where b_version_id = ?", versionId); + if (modules.isEmpty()) { + return null; + } + Set fields = new LinkedHashSet<>(RUNTIME_CONDITION_FIELDS); + for (Map module : modules) { + WorkflowSql.query(connection, "select b_field from dbo.s_field where b_module_id = ? and b_canuse = 1", + String.valueOf(module.get("b_module_id"))) + .forEach(row -> fields.add(String.valueOf(row.get("b_field")))); + } + return fields; + } + + /* ============================================================================ + 写入 wf_* 的细节 + ============================================================================ */ + + /** 取该定义的草稿版本;指定了 versionId 就用它(必须是草稿),没指定就现取或新建 */ + private Map resolveDraftVersion( + Connection connection, + String definitionId, + String versionId, + String operator + ) throws SQLException { + if (versionId != null && !versionId.isBlank()) { + Map version = WorkflowSql.queryOne(connection, + "select b_id, b_definition_id, b_version_no, b_status from dbo.wf_version where b_id = ?", versionId); + if (version == null) { + throw new BusinessException("版本「" + versionId + "」不存在"); + } + if (!definitionId.equals(text(version.get("b_definition_id")))) { + throw new BusinessException("版本「" + versionId + "」不属于流程定义「" + definitionId + "」"); + } + if (!STATUS_DRAFT.equals(String.valueOf(version.get("b_status")))) { + throw new BusinessException("已发布或已停用的版本不能修改,请先复制出新版本"); + } + return version; + } + + Map draft = WorkflowSql.queryOne(connection, """ + select top 1 b_id, b_version_no, b_status from dbo.wf_version with (UPDLOCK, HOLDLOCK) + where b_definition_id = ? and b_status = ? + order by b_version_no desc + """, definitionId, STATUS_DRAFT); + if (draft != null) { + return draft; + } + + int nextNo = nextVersionNo(connection, definitionId); + String newVersionId = versionIdOf(definitionId, nextNo); + WorkflowSql.update(connection, """ + insert into dbo.wf_version + (b_id, b_definition_id, b_version_no, b_status, b_schema_version, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, ?, ?, 1, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, newVersionId, definitionId, nextNo, STATUS_DRAFT, operator, operator); + Map created = new LinkedHashMap<>(); + created.put("b_id", newVersionId); + created.put("b_version_no", nextNo); + created.put("b_status", STATUS_DRAFT); + return created; + } + + private void writeNode(Connection connection, String versionId, WorkflowDraft.Node node, int xh) + throws SQLException { + Map config = node.config() == null ? Map.of() : node.config(); + Map extra = new LinkedHashMap<>(config); + NODE_CONFIG_COLUMNS.forEach(extra::remove); + + // b_id 是行主键(全局唯一,跨版本也不能复用),b_node_key 才是版本内的稳定编码 + WorkflowSql.update(connection, """ + insert into dbo.wf_node + (b_id, b_version_id, b_node_key, b_name, b_node_type, b_approval_mode, b_reject_mode, + b_due_hours, b_config_json, b_xh, b_canuse) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + """, String.valueOf(IdGenerator.nextId()), versionId, node.nodeKey(), + node.name() == null || node.name().isBlank() ? node.nodeKey() : node.name(), + node.nodeType(), + blankToNull(text(config.get("approvalMode"))), + blankToNull(text(config.get("rejectMode"))), + decimalOrNull(config.get("dueHours")), + extra.isEmpty() ? null : toJson(extra), + xh); + + int priority = 10; + for (WorkflowDraft.ActorRule rule : node.actorRules()) { + WorkflowSql.update(connection, """ + insert into dbo.wf_actor_rule + (b_id, b_version_id, b_node_key, b_actor_type, b_actor_ref, + b_condition_json, b_fallback_type, b_fallback_ref, b_priority, b_canuse) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + """, String.valueOf(IdGenerator.nextId()), versionId, node.nodeKey(), + rule.actorType(), blankToNull(rule.actorRef()), + rule.condition() == null || rule.condition().isEmpty() ? null : toJson(rule.condition()), + blankToNull(rule.fallbackType()), blankToNull(rule.fallbackRef()), priority); + priority += 10; + } + } + + /** + * 写一条连线,返回实际落库的 b_id。 + * + *

前端给的 edgeKey 会被当作 b_id 复用(运行时的连线状态按 b_id 映射,编码要前后一致), + * 但 b_id 是行主键、跨版本唯一 —— 若它已被**其它版本**占用(复制版本后两个版本有同名连线), + * 就换一个新的 ID 并把映射回传前端,保证前端内存与库里始终对得上。 + */ + private String writeEdge(Connection connection, String versionId, WorkflowDraft.Edge edge, int priority) + throws SQLException { + boolean takenByOtherVersion = WorkflowSql.queryOne(connection, + "select top 1 b_id from dbo.wf_edge where b_id = ? and b_version_id <> ?", + edge.edgeKey(), versionId) != null; + String storedId = takenByOtherVersion ? String.valueOf(IdGenerator.nextId()) : edge.edgeKey(); + WorkflowSql.update(connection, """ + insert into dbo.wf_edge + (b_id, b_version_id, b_from_node_key, b_to_node_key, b_name, b_condition_json, b_priority, b_canuse) + values (?, ?, ?, ?, ?, ?, ?, 1) + """, storedId, versionId, edge.from(), edge.to(), blankToNull(edge.name()), + edge.condition() == null || edge.condition().isEmpty() ? null : toJson(edge.condition()), + priority); + return storedId; + } + + /* ============================================================================ + 通用 SQL 小工具已统一到包内 WorkflowSql(与运行侧同一份实现)。 + 原先此处有一份私有复制,且漏了 TimeUtils.toApiValue —— 配置侧读出的时间列 + 会脱离「带 Z 的 UTC ISO 串」出口口径,与运行侧不一致,故删除。 + ============================================================================ */ + + private void rollback(Connection connection, Exception cause) { + try { + connection.rollback(); + } catch (SQLException rollbackFailure) { + cause.addSuppressed(rollbackFailure); + } + } + + private void restoreAutoCommit(Connection connection, boolean originalAutoCommit) { + try { + connection.setAutoCommit(originalAutoCommit); + } catch (SQLException ignored) { + // 连接归还时由连接池复位,这里失败不影响主流程 + } + } + + private String currentUser() { + return OrgContext.requireUserId(); + } + + private int nextVersionNo(Connection connection, String definitionId) throws SQLException { + Map row = WorkflowSql.queryOne(connection, """ + select isnull(max(b_version_no), 0) as max_no + from dbo.wf_version with (UPDLOCK, HOLDLOCK) + where b_definition_id = ? + """, definitionId); + return (int) number(row == null ? null : row.get("max_no"), 0).doubleValue() + 1; + } + + /** 版本 ID:沿用「定义编码_v序号」,超长时退化为雪花号(b_id 是 varchar(50)) */ + private String versionIdOf(String definitionId, int versionNo) { + String candidate = definitionId + "_v" + versionNo; + return candidate.length() <= WorkflowDraft.KEY_MAX_LENGTH + ? candidate + : String.valueOf(IdGenerator.nextId()); + } + + /** + * 发布摘要:对节点 / 连线 / 规则做规范化拼接后取 SHA-256。 + * 设计文档未指定算法,这里定死一段可复算的规则(排序 + 字段分隔),供版本比对与排查。 + */ + private String checksumOf(WorkflowDraft draft) { + StringBuilder builder = new StringBuilder(); + draft.nodes().stream() + .sorted((left, right) -> left.nodeKey().compareTo(right.nodeKey())) + .forEach(node -> { + builder.append("N|").append(node.nodeKey()).append('|').append(node.nodeType()) + .append('|').append(node.name()).append('|').append(node.config()).append('\n'); + node.actorRules().forEach(rule -> builder.append("A|").append(node.nodeKey()) + .append('|').append(rule.actorType()).append('|').append(rule.actorRef()).append('\n')); + }); + draft.edges().stream() + .sorted((left, right) -> left.edgeKey().compareTo(right.edgeKey())) + .forEach(edge -> builder.append("E|").append(edge.edgeKey()).append('|').append(edge.from()) + .append('|').append(edge.to()).append('|').append(edge.condition()).append('\n')); + try { + byte[] bytes = MessageDigest.getInstance("SHA-256") + .digest(builder.toString().getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte value : bytes) { + hex.append(Character.forDigit((value >> 4) & 0xF, 16)); + hex.append(Character.forDigit(value & 0xF, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException exception) { + throw new BusinessException("计算版本摘要失败:" + exception.getMessage()); + } + } + + private List> toIssueList(List issues) { + List> rows = new ArrayList<>(); + for (WorkflowValidator.Issue issue : issues) { + Map row = new LinkedHashMap<>(); + row.put("level", issue.level()); + row.put("message", issue.message()); + row.put("nodeKey", issue.nodeKey()); + rows.add(row); + } + return rows; + } + + @SuppressWarnings("unchecked") + private Map mapParam(Map params, String key) { + Object value = params == null ? null : params.get(key); + return value instanceof Map map ? (Map) map : null; + } + + /** JSON 参数:Map / List 序列化;字符串原样透传;空值落 null */ + private String jsonParam(Map params, String key) { + Object value = params == null ? null : params.get(key); + if (value == null) { + return null; + } + if (value instanceof String text) { + return text.isBlank() ? null : text; + } + return toJson(value); + } + + /** 绑定生效期等时间参数:按 UTC 口径规范化(带 Z / ±HH:mm 的 ISO 串 → UTC 墙钟值,见 TimeUtils) */ + private Object timeParam(Map params, String key) { + Object value = params == null ? null : params.get(key); + if (value == null || (value instanceof String text && text.isBlank())) { + return null; + } + return TimeUtils.toStorageValue("datetime2", value); + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (RuntimeException exception) { + throw new BusinessException("配置内容不是合法 JSON:" + exception.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private Map readJsonMap(Object json) { + Object parsed = parseJson(json); + return parsed instanceof Map map ? (Map) map : Map.of(); + } + + /** 条件 JSON:缺失时返回 null(null = 默认出口,前端据此判定兜底分支) */ + @SuppressWarnings("unchecked") + private Map readJsonObjectOrNull(Object json) { + Object parsed = parseJson(json); + return parsed instanceof Map map ? (Map) map : null; + } + + private Object parseJson(Object json) { + if (json == null) { + return null; + } + String text = String.valueOf(json).trim(); + if (text.isEmpty()) { + return null; + } + try { + return objectMapper.readValue(text, Object.class); + } catch (RuntimeException exception) { + // 脏配置不该让整个流程打不开:按空值处理,由发布校验给出问题 + return null; + } + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private BigDecimal decimalOrNull(Object value) { + if (value == null || (value instanceof String text && text.isBlank())) { + return null; + } + try { + return new BigDecimal(String.valueOf(value).trim()); + } catch (NumberFormatException exception) { + throw new BusinessException("审批时限必须是数字:" + value); + } + } + + private Double number(Object value, double fallback) { + if (value instanceof Number num) { + return num.doubleValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Double.parseDouble(text.trim()); + } catch (NumberFormatException ignored) { + return fallback; + } + } + return fallback; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowDelegationService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowDelegationService.java new file mode 100644 index 00000000..2a2fe38a --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowDelegationService.java @@ -0,0 +1,80 @@ +package cn.g3soft.erp.service.workflow; + +import org.springframework.stereotype.Service; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; + +/** + * 委托(§8.5)。 + * + *

做法是**在任务生成时就物化**:候选人有生效中的委托时,任务直接建给代理人 + * ({@code b_assignee_type='delegate'}),这样代理人能在待办列表里看到它; + * 原审批人与委托单号并进任务快照,动作期据此复核并留痕。 + * + *

{@code b_scope_json} 形如 {@code {"moduleIds":[],"actions":[],"orgIds":[]}},**空数组 / 缺失 = 不限**: + * 创建期只能判 moduleIds / orgIds,actions 要到动作真正发生才能判,所以分成两个纯函数。 + */ +@Service +public class WorkflowDelegationService { + + /** 一条生效中的委托 */ + public record Delegation(String id, String fromUserId, String toUserId, Map scope) { + } + + /** 创建期判定:模块 / 组织是否在委托范围内 */ + public boolean matches(Map scope, String moduleId, String orgId) { + if (scope == null || scope.isEmpty()) { + return true; + } + return covers(scope.get("moduleIds"), moduleId) && covers(scope.get("orgIds"), orgId); + } + + /** 动作期判定:该动作是否被委托授权 */ + public boolean allowsAction(Map scope, String action) { + return scope == null || scope.isEmpty() || covers(scope.get("actions"), action); + } + + /** + * 查某个用户当前生效、且覆盖该模块 / 组织的委托;没有则返回 {@code null}。 + * 同一时刻可能存在多条,按 {@code b_id} 取第一条(等价于「先建先生效」)。 + */ + public Delegation activeDelegation(Connection connection, String userId, String moduleId, String orgId) + throws SQLException { + List> rows = WorkflowSql.query(connection, """ + select b_id, b_from_user_id, b_to_user_id, b_scope_json + from dbo.wf_delegation + where b_from_user_id = ? and b_canuse = 1 + and sysutcdatetime() between b_start_at and b_end_at + order by b_id + """, userId); + for (Map row : rows) { + Map scope = WorkflowJson.readMapOrNull(row.get("b_scope_json"), + "delegation.scope:" + row.get("b_id")); + if (matches(scope, moduleId, orgId)) { + return new Delegation( + String.valueOf(row.get("b_id")), + String.valueOf(row.get("b_from_user_id")), + String.valueOf(row.get("b_to_user_id")), + scope); + } + } + return null; + } + + /** 空列表 / 缺失 = 不限;否则要求目标值在列表里 */ + private boolean covers(Object allowed, String value) { + if (!(allowed instanceof List list) || list.isEmpty()) { + return true; + } + String target = value == null ? "" : value; + for (Object item : list) { + if (item != null && target.equals(String.valueOf(item))) { + return true; + } + } + return false; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowDraft.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowDraft.java new file mode 100644 index 00000000..77908055 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowDraft.java @@ -0,0 +1,224 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.exception.BusinessException; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 工作流领域草稿 —— 设计器与流程服务之间唯一的交换格式。 + * + *

形状与前端 {@code fms-vue/src/views/system/workflow/designer/domain.js} 的 WorkflowDraft 一致: + *

+ * {
+ *   nodes: [{ nodeKey, nodeType, name, x, y,
+ *             config: { approvalMode, rejectMode, dueHours, allowClaim, commentRequired,
+ *                       requiredCount, requiredRatio, formPolicy },
+ *             actorRules: [{ actorType, actorRef }] }],
+ *   edges: [{ edgeKey, from, to, name, condition }]
+ * }
+ * 
+ * + *

落库映射(设计 §19.4:不把编辑器的原始 JSON 直接当流程定义存储): + *

    + *
  • {@code nodeKey} → {@code wf_node.b_node_key}(版本内稳定编码);{@code wf_node.b_id} 复用同一编码, + * 因此要求编码不超过 50 字符(b_id 与 b_node_key 的列宽最小值)。
  • + *
  • {@code config} → {@code b_approval_mode} / {@code b_reject_mode} / {@code b_due_hours}, + * 其余键进 {@code b_config_json}。
  • + *
  • {@code actorRules} → {@code wf_actor_rule},列表顺序即 {@code b_priority}(串行会签的顺序来源)。
  • + *
  • {@code edgeKey} → {@code wf_edge.b_id}(同理 <= 50 字符),运行时连线状态按它映射。
  • + *
  • {@code x} / {@code y} → {@code wf_version.b_canvas_json},只存布局,不参与执行。
  • + *
+ */ +public record WorkflowDraft(List nodes, List edges) { + + /** 节点编码与连线编码共用的主键列宽(wf_node.b_id / wf_edge.b_id 都是 varchar(50)) */ + public static final int KEY_MAX_LENGTH = 50; + + public record Node( + String nodeKey, + String nodeType, + String name, + Map config, + List actorRules + ) { + + /** 取配置项;缺失或类型不符时回退默认值(脏配置不该让保存整体失败) */ + public Object configValue(String key, Object fallback) { + Object value = config == null ? null : config.get(key); + return value == null ? fallback : value; + } + } + + public record Edge( + String edgeKey, + String from, + String to, + String name, + Map condition + ) { + } + + /** + * 审批人规则。 + * + * @param condition 规则生效条件(结构化条件 AST)。null / 空表示无条件,永远命中。 + * @param fallbackType 解析为空时的兜底类型:none / admin / initiator / custom + * @param fallbackRef 兜底参数(custom 用) + */ + public record ActorRule( + String actorType, + String actorRef, + Map condition, + String fallbackType, + String fallbackRef + ) { + /** 简写:不带条件与兜底(等价于无条件、无兜底)。 */ + public ActorRule(String actorType, String actorRef) { + this(actorType, actorRef, null, null, null); + } + } + + /** + * 解析并做结构级校验(编码非空、不超长、不重复、连线两端存在)。 + * 语义级校验(可达性、审批人、条件字段等)见 {@link WorkflowValidator}。 + * + * @param raw 请求体里的 draft 对象(Map 或 null 表示空草稿) + * @throws BusinessException 结构非法时抛出,错误信息直接面向使用者 + */ + public static WorkflowDraft parse(Map raw) { + Map root = raw == null ? Map.of() : raw; + + List nodes = new ArrayList<>(); + Set nodeKeys = new LinkedHashSet<>(); + for (Map row : mapList(root, "nodes")) { + String nodeKey = text(row, "nodeKey"); + if (nodeKey.isEmpty()) { + throw new BusinessException("存在缺少编码(nodeKey)的节点"); + } + if (nodeKey.length() > KEY_MAX_LENGTH) { + throw new BusinessException("节点编码「" + nodeKey + "」超过 " + KEY_MAX_LENGTH + " 个字符"); + } + if (!nodeKeys.add(nodeKey)) { + throw new BusinessException("存在重复的节点编码「" + nodeKey + "」"); + } + String nodeType = text(row, "nodeType"); + if (nodeType.isEmpty()) { + throw new BusinessException("节点「" + nodeKey + "」缺少节点类型"); + } + + List actorRules = new ArrayList<>(); + for (Map rule : mapList(row, "actorRules")) { + String fallbackType = text(rule, "fallbackType"); + String fallbackRef = text(rule, "fallbackRef"); + actorRules.add(new ActorRule( + text(rule, "actorType"), + text(rule, "actorRef"), + conditionOf(rule, "审批人规则"), + fallbackType.isEmpty() ? null : fallbackType, + fallbackRef.isEmpty() ? null : fallbackRef)); + } + + nodes.add(new Node( + nodeKey, + nodeType, + text(row, "name"), + new LinkedHashMap<>(map(row, "config")), + List.copyOf(actorRules) + )); + } + + List edges = new ArrayList<>(); + Set edgeKeys = new LinkedHashSet<>(); + for (Map row : mapList(root, "edges")) { + String edgeKey = text(row, "edgeKey"); + if (edgeKey.isEmpty()) { + throw new BusinessException("存在缺少编码(edgeKey)的连线"); + } + if (edgeKey.length() > KEY_MAX_LENGTH) { + throw new BusinessException("连线编码「" + edgeKey + "」超过 " + KEY_MAX_LENGTH + " 个字符"); + } + if (!edgeKeys.add(edgeKey)) { + throw new BusinessException("存在重复的连线编码「" + edgeKey + "」"); + } + String from = text(row, "from"); + String to = text(row, "to"); + if (!nodeKeys.contains(from) || !nodeKeys.contains(to)) { + throw new BusinessException("连线「" + edgeKey + "」的两端节点不存在"); + } + edges.add(new Edge(edgeKey, from, to, text(row, "name"), conditionOf(row, "连线「" + edgeKey + "」"))); + } + + return new WorkflowDraft(List.copyOf(nodes), List.copyOf(edges)); + } + + /** 节点编码集合(校验用) */ + public Set nodeKeys() { + Set keys = new LinkedHashSet<>(); + nodes.forEach(node -> keys.add(node.nodeKey())); + return keys; + } + + public Node node(String nodeKey) { + return nodes.stream().filter(node -> node.nodeKey().equals(nodeKey)).findFirst().orElse(null); + } + + /** 指定节点的出线,按前端传入顺序(保存时落 b_priority) */ + public List outgoing(String nodeKey) { + return edges.stream().filter(edge -> edge.from().equals(nodeKey)).toList(); + } + + /* ---------- 请求体取值(宽容解析:脏数据只影响字段本身,不抛类型异常) ---------- */ + + private static String text(Map row, String key) { + Object value = row.get(key); + return value == null ? "" : String.valueOf(value).trim(); + } + + @SuppressWarnings("unchecked") + private static Map map(Map row, String key) { + Object value = row.get(key); + if (value instanceof Map nested) { + return (Map) nested; + } + return Map.of(); + } + + /** + * 读条件字段:允许缺失或 null(表示无条件 / 默认出口),但**不允许"给了值却不是对象"**。 + * + *

若把这种情况也降级成空 Map,等于把一个配坏的分支条件静默变成**无条件兜底** —— + * 发布校验看不出问题、运行期又被优先级抢先命中,是原型里的真实缺陷,故此处直接拒绝。 + */ + @SuppressWarnings("unchecked") + private static Map conditionOf(Map row, String what) { + Object value = row.get("condition"); + if (value == null) { + return null; + } + if (!(value instanceof Map nested)) { + throw new BusinessException(what + "的条件(condition)必须是对象,或留空表示无条件"); + } + Map condition = (Map) nested; + return condition.isEmpty() ? null : condition; + } + + @SuppressWarnings("unchecked") + private static List> mapList(Map row, String key) { + Object value = row.get(key); + if (!(value instanceof List list)) { + return List.of(); + } + List> result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map nested) { + result.add((Map) nested); + } + } + return result; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepService.java new file mode 100644 index 00000000..6a09f539 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepService.java @@ -0,0 +1,153 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.util.UuidV7Generator; +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.database.org.OrgDatabaseConfigLoader; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * 任务超时扫描(§12 补充策略)。 + * + *

语义:只标记、不自动改流程。到期的 {@code pending}/{@code claimed} 任务被置为 + * {@code expired} 并写一条系统动作与 outbox;不动 node_run、不动实例状态、不自动提升串行后继。 + * 实例保持 {@code running},由管理员通过加签 / 转派补救。这条策略是刻意选的:到期自动否决/自动通过 + * 风险太大,而自动置 error 会让"仅仅超期"直接打断流程。 + * + *

调度沿用项目里 {@code OrgDataSourceManager} 的裸 {@code ScheduledExecutorService} 模式 + * (不引入 {@code @EnableScheduling}):守护线程、{@code @PostConstruct} 启动、 + * {@code @PreDestroy} 关闭;间隔配置为 0 即关闭扫描。 + * + *

后台线程没有请求上下文,而主 DataSource 是按 {@code OrgContext} 路由的, + * 所以按机构逐个设置上下文再取连接。 + */ +@Component +public class WorkflowExpirySweepService { + + private static final Logger LOGGER = LoggerFactory.getLogger(WorkflowExpirySweepService.class); + + /** 单次扫描最多处理的任务数,避免一次拉太多 */ + private static final int BATCH_LIMIT = 500; + + @Resource + private DataSource dataSource; + + @Resource + private OrgDatabaseConfigLoader configLoader; + + /** 扫描间隔;0s 或负数关闭。 */ + @Value("${erp.workflow.expire-sweep-interval:5m}") + private Duration sweepInterval; + + private ScheduledExecutorService scheduler; + + @PostConstruct + public void start() { + long intervalMs = sweepInterval == null ? 0 : sweepInterval.toMillis(); + if (intervalMs <= 0) { + LOGGER.info("工作流超时扫描未启用(erp.workflow.expire-sweep-interval<=0)"); + return; + } + scheduler = Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "wf-expiry-sweep"); + thread.setDaemon(true); + return thread; + }); + // 首轮延迟一个间隔,避开应用刚启动时的连接池预热 + scheduler.scheduleWithFixedDelay(this::sweepQuietly, intervalMs, intervalMs, TimeUnit.MILLISECONDS); + LOGGER.info("工作流超时扫描已启动,间隔 {}s", intervalMs / 1000); + } + + @PreDestroy + public void stop() { + if (scheduler != null) { + scheduler.shutdownNow(); + } + } + + /** 外层兜异常:定时任务抛出去会被静默吞掉,这里至少留一条日志。 */ + void sweepQuietly() { + for (String orgId : configLoader.listOrgIds()) { + OrgContext.setOrgId(orgId); + try { + Integer expired = WorkflowTx.run(dataSource, this::sweepOrg); + if (expired != null && expired > 0) { + LOGGER.info("工作流超时扫描 orgId={} 标记超期任务 {} 条", orgId, expired); + } + } catch (Exception exception) { + LOGGER.warn("工作流超时扫描失败 orgId={}", orgId, exception); + } finally { + OrgContext.clear(); + } + } + } + + /** 单机构扫描:只标记任务为 expired,写系统动作 + outbox。 */ + private Integer sweepOrg(Connection connection) throws SQLException { + List> due = WorkflowSql.query(connection, """ + select top (%d) b_id, b_instance_id, b_node_run_id, b_node_key, b_status, b_assignee_id + from dbo.wf_task with (UPDLOCK, ROWLOCK) + where b_status in ('pending', 'claimed') + and b_due_at is not null and b_due_at < sysutcdatetime() + order by b_due_at, b_id + """.formatted(BATCH_LIMIT)); + + int expired = 0; + for (Map task : due) { + String taskId = String.valueOf(task.get("b_id")); + UUID taskUuid = UUID.fromString(taskId); + String fromStatus = String.valueOf(task.get("b_status")); + + WorkflowSql.update(connection, """ + update dbo.wf_task set b_status = 'expired', b_updated_at = sysutcdatetime() where b_id = ? + """, taskUuid); + WorkflowSql.update(connection, """ + insert into dbo.wf_action + (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, + b_from_status, b_to_status, b_comment, b_occurdatetime) + values (?, ?, ?, ?, 'expire', null, ?, 'expired', ?, sysutcdatetime()) + """, UuidV7Generator.nextUuid(), task.get("b_instance_id"), task.get("b_node_run_id"), + taskUuid, fromStatus, "任务超期,自动标记为过期(实例与节点状态不变,待管理员处理)"); + + Map payload = new LinkedHashMap<>(); + payload.put("instanceId", String.valueOf(task.get("b_instance_id"))); + payload.put("nodeKey", String.valueOf(task.get("b_node_key"))); + payload.put("assigneeId", String.valueOf(task.get("b_assignee_id"))); + WorkflowSql.update(connection, """ + insert into dbo.wf_outbox + (b_id, b_event_code, b_aggregate_type, b_aggregate_id, b_payload_json, + b_status, b_retry_count, b_occurdatetime) + values (?, 'task.expired', 'task', ?, ?, 'pending', 0, sysutcdatetime()) + """, UuidV7Generator.nextUuid(), taskId, toJson(payload)); + expired++; + } + return expired; + } + + private String toJson(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (RuntimeException exception) { + return "{}"; + } + } + + private static final tools.jackson.databind.ObjectMapper MAPPER = new tools.jackson.databind.ObjectMapper(); +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowInstanceRow.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowInstanceRow.java new file mode 100644 index 00000000..bf5f8aff --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowInstanceRow.java @@ -0,0 +1,71 @@ +package cn.g3soft.erp.service.workflow; + +/** + * 实例行的进程内快照(由 WorkflowRuntimeService 内嵌类提升为顶层包内类,行为不变)。 + * + *

package-private:仅工作流包内使用,不对外暴露。 + */ +final class WorkflowInstanceRow { + private final String instanceId; + private final String definitionId; + private final String versionId; + private final String bindingId; + private final String moduleId; + private final String businessId; + private final String businessNo; + private final String initiatorId; + private final String initiatorDeptId; + private final String status; + private long rowVersion; + + WorkflowInstanceRow( + String instanceId, + String definitionId, + String versionId, + String bindingId, + String moduleId, + String businessId, + String businessNo, + String initiatorId, + String initiatorDeptId, + String status, + long rowVersion + ) { + this.instanceId = instanceId; + this.definitionId = definitionId; + this.versionId = versionId; + this.bindingId = bindingId; + this.moduleId = moduleId; + this.businessId = businessId; + this.businessNo = businessNo; + this.initiatorId = initiatorId; + this.initiatorDeptId = initiatorDeptId; + this.status = status; + this.rowVersion = rowVersion; + } + + String instanceId() { return instanceId; } + + String definitionId() { return definitionId; } + + String versionId() { return versionId; } + + String bindingId() { return bindingId; } + + String moduleId() { return moduleId; } + + String businessId() { return businessId; } + + String businessNo() { return businessNo; } + + String initiatorId() { return initiatorId; } + + String initiatorDeptId() { return initiatorDeptId; } + + String status() { return status; } + + long rowVersion() { return rowVersion; } + + /** 仅由 {@code updateInstance} 在更新成功后推进,保证同事务内后续更新用的是最新版本。 */ + void rowVersion(long value) { this.rowVersion = value; } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowJson.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowJson.java new file mode 100644 index 00000000..4fff5a21 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowJson.java @@ -0,0 +1,82 @@ +package cn.g3soft.erp.service.workflow; + +import tools.jackson.databind.ObjectMapper; + +import java.util.Map; + +/** + * 工作流 JSON 列的**读取**工具(只读;序列化仍由各服务注入的 ObjectMapper 承担, + * 以保留项目 {@code JacksonConfig} 对 64 位 Long 的特殊处理)。所有调用方都在同一包内。 + * + *

刻意区分两种语义: + *

    + *
  • {@link #readMapOrNull} —— 面向**作者配置**(连线条件、绑定匹配、节点配置、审批人规则条件)。 + * 非空但解析失败或不是对象 → 抛 {@link WorkflowConfigException}。 + * 「null」明确表示「没配条件」,由调用方按默认/无条件处理,与「配坏了」是两回事。
  • + *
  • {@link #readMapOrEmpty} —— 面向**我们自己写的、仅用于展示或容错的列**(运行摘要、审批人快照、 + * 运行变量)。坏了当空对象,不阻断流程。
  • + *
+ */ +final class WorkflowJson { + + /** 仅用于读取;任何 ObjectMapper 反序列化行为一致,故独立一份不影响项目的序列化口径。 */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final int MAX_LABEL_LENGTH = 120; + + private WorkflowJson() { + } + + /** + * 严格读取:null / 空白 → {@code null}(表示没配);合法 JSON 对象 → Map; + * 非空但解析失败或不是对象 → 抛 {@link WorkflowConfigException}。 + * + * @param label 出错信息里的定位标识,如 {@code "edge.cond:edge_1"} / {@code "binding.match:demo"} + */ + static Map readMapOrNull(Object json, String label) { + String text = normalized(json); + if (text == null) { + return null; + } + Object parsed = tryParse(text); + if (!(parsed instanceof Map map)) { + throw new WorkflowConfigException(label + " 不是合法的 JSON 对象:" + abbreviate(text)); + } + return cast(map); + } + + /** 宽松读取:null / 空白 / 损坏都返回空 Map,绝不抛异常。 */ + static Map readMapOrEmpty(Object json, String label) { + String text = normalized(json); + if (text == null) { + return Map.of(); + } + Object parsed = tryParse(text); + return parsed instanceof Map map ? cast(map) : Map.of(); + } + + private static String normalized(Object json) { + if (json == null) { + return null; + } + String text = String.valueOf(json).trim(); + return text.isEmpty() ? null : text; + } + + private static Object tryParse(String text) { + try { + return MAPPER.readValue(text, Object.class); + } catch (RuntimeException exception) { + return null; + } + } + + @SuppressWarnings("unchecked") + private static Map cast(Map map) { + return (Map) map; + } + + private static String abbreviate(String text) { + return text.length() <= MAX_LABEL_LENGTH ? text : text.substring(0, MAX_LABEL_LENGTH) + "…"; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeQueryService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeQueryService.java new file mode 100644 index 00000000..22e7cf0a --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeQueryService.java @@ -0,0 +1,522 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.ParamUtils; +import cn.g3soft.erp.common.util.TimeUtils; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.time.Instant; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * 工作流运行侧查询 —— 审批中心四页与「查看审批进度」的数据出口(设计 §13 / §14.4)。 + * + *

这里是只读查询:待办 / 我已处理 / 我发起的 / 抄送我的,以及实例图(节点与连线状态 + 时间线)。 + * 写入一律走 {@link WorkflowRuntimeService},前端不能直接改 wf_* 运行表(§17)。 + * + *

数据范围由 SQL 本身保证:待办与抄送按接收人过滤、我发起的按发起人过滤, + * 不是「查出来再在代码里过滤」。 + */ +@Service +public class WorkflowRuntimeQueryService { + + /** 终态的实例:没走到的节点显示「未经过」而不是「待审批」 */ + private static final Set FINAL_INSTANCE_STATUSES = + Set.of("approved", "rejected", "returned", "withdrawn", "cancelled", "expired"); + + @Resource + private DataSource dataSource; + + /* ============================================================================ + 审批中心列表 + ============================================================================ */ + + /** 我的待办:分配给我(含已抢占)且未处理完的任务 */ + public Map todoList(Map params) throws SQLException { + String userId = OrgContext.requireUserId(); + int[] page = pageOf(params); + try (Connection connection = dataSource.getConnection()) { + return pageResult(connection, """ + select t.b_id as task_id, t.b_node_key, t.b_status as task_status, t.b_seq, + t.b_due_at, t.b_created_at, + i.b_id as instance_id, i.b_business_id, i.b_business_no, i.b_module_id, + i.b_definition_id, i.b_initiator_id, i.b_started_at, i.b_status as instance_status, + i.b_current_summary_json, u.b_name as initiator_name, + d.b_name as definition_name, d.b_i18n as definition_i18n, n.b_name as node_name + from dbo.wf_task t + join dbo.wf_instance i on i.b_id = t.b_instance_id + left join dbo.wf_definition d on d.b_id = i.b_definition_id + left join dbo.wf_node n on n.b_version_id = i.b_version_id and n.b_node_key = t.b_node_key + left join dbo.b_user u on u.b_id = i.b_initiator_id + where t.b_assignee_id = ? and t.b_status in ('pending', 'claimed') + order by case when t.b_due_at is null then 1 else 0 end, t.b_due_at, t.b_created_at + """, """ + select count(1) as cnt from dbo.wf_task t + where t.b_assignee_id = ? and t.b_status in ('pending', 'claimed') + """, userId, page); + } + } + + /** 我已处理:我实际处理过的任务(含通过、驳回、转交前的处理) */ + public Map doneList(Map params) throws SQLException { + String userId = OrgContext.requireUserId(); + int[] page = pageOf(params); + try (Connection connection = dataSource.getConnection()) { + return pageResult(connection, """ + select t.b_id as task_id, t.b_node_key, t.b_status as task_status, + t.b_completed_at, t.b_completed_by, + i.b_id as instance_id, i.b_business_id, i.b_business_no, i.b_module_id, + i.b_definition_id, i.b_initiator_id, i.b_started_at, i.b_status as instance_status, + u.b_name as initiator_name, + d.b_name as definition_name, d.b_i18n as definition_i18n, n.b_name as node_name + from dbo.wf_task t + join dbo.wf_instance i on i.b_id = t.b_instance_id + left join dbo.wf_definition d on d.b_id = i.b_definition_id + left join dbo.wf_node n on n.b_version_id = i.b_version_id and n.b_node_key = t.b_node_key + left join dbo.b_user u on u.b_id = i.b_initiator_id + where t.b_completed_by = ? + order by t.b_completed_at desc + """, """ + select count(1) as cnt from dbo.wf_task t where t.b_completed_by = ? + """, userId, page); + } + } + + /** 我发起的 */ + public Map myInstances(Map params) throws SQLException { + String userId = OrgContext.requireUserId(); + int[] page = pageOf(params); + try (Connection connection = dataSource.getConnection()) { + return pageResult(connection, """ + select i.b_id as instance_id, i.b_business_id, i.b_business_no, i.b_module_id, + i.b_definition_id, i.b_status, i.b_started_at, i.b_completed_at, + i.b_current_summary_json, d.b_name as definition_name, d.b_i18n as definition_i18n, + (select count(1) from dbo.wf_task t + where t.b_instance_id = i.b_id and t.b_status in ('pending', 'claimed')) as pending_count + from dbo.wf_instance i + left join dbo.wf_definition d on d.b_id = i.b_definition_id + where i.b_initiator_id = ? + order by i.b_started_at desc + """, """ + select count(1) as cnt from dbo.wf_instance i where i.b_initiator_id = ? + """, userId, page); + } + } + + /** 抄送我的 */ + public Map ccList(Map params) throws SQLException { + String userId = OrgContext.requireUserId(); + int[] page = pageOf(params); + try (Connection connection = dataSource.getConnection()) { + return pageResult(connection, """ + select c.b_id as cc_id, c.b_node_key, c.b_status as cc_status, c.b_read_at, c.b_created_at, + i.b_id as instance_id, i.b_business_id, i.b_business_no, i.b_module_id, + i.b_definition_id, i.b_status as instance_status, i.b_initiator_id, + i.b_current_summary_json, u.b_name as initiator_name, + d.b_name as definition_name, d.b_i18n as definition_i18n, n.b_name as node_name + from dbo.wf_cc c + join dbo.wf_instance i on i.b_id = c.b_instance_id + left join dbo.wf_definition d on d.b_id = i.b_definition_id + left join dbo.wf_node n on n.b_version_id = i.b_version_id and n.b_node_key = c.b_node_key + left join dbo.b_user u on u.b_id = i.b_initiator_id + where c.b_recipient_id = ? + order by c.b_created_at desc + """, """ + select count(1) as cnt from dbo.wf_cc c where c.b_recipient_id = ? + """, userId, page); + } + } + + /** 抄送已读标记(只允许标记发给自己的记录) */ + public Map markCcRead(Map params) throws SQLException { + String userId = OrgContext.requireUserId(); + List ccIds = stringList(params, "ccIds"); + if (ccIds.isEmpty()) { + throw new BusinessException("请选择要标记的抄送记录"); + } + String placeholders = String.join(", ", java.util.Collections.nCopies(ccIds.size(), "?")); + List args = new ArrayList<>(); + args.add(userId); + ccIds.forEach(id -> args.add(uuidParam(id))); + try (Connection connection = dataSource.getConnection()) { + boolean original = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + int updated = WorkflowSql.update(connection, + "update dbo.wf_cc set b_status = 'read', b_read_at = sysutcdatetime() " + + "where b_recipient_id = ? and b_id in (" + placeholders + ")", + args.toArray()); + connection.commit(); + return Map.of("updated", updated); + } catch (SQLException | RuntimeException exception) { + try { + connection.rollback(); + } catch (SQLException rollbackFailure) { + exception.addSuppressed(rollbackFailure); + } + throw exception; + } finally { + try { + connection.setAutoCommit(original); + } catch (SQLException ignored) { + // 归还连接时由连接池复位 + } + } + } + } + + /* ============================================================================ + 实例图与详情 + ============================================================================ */ + + /** + * 实例图:节点 / 连线状态 + 时间线(前端「查看审批进度」的直接数据源)。 + * 没走到的节点按实例状态派生:运行中算「待审批」,终态算「未经过」(与前端 applyRuntimeStatus 口径一致)。 + */ + public Map instanceGraph(Map params) throws SQLException { + String instanceId = ParamUtils.getRequiredString(params, "instanceId"); + try (Connection connection = dataSource.getConnection()) { + Map instance = WorkflowSql.queryOne(connection, """ + select i.b_id, i.b_business_id, i.b_business_no, i.b_module_id, i.b_definition_id, + i.b_version_id, i.b_initiator_id, i.b_initiator_dept_id, i.b_status, i.b_started_at, + i.b_completed_at, i.b_current_summary_json, i.b_snapshot_json, i.b_context_json, + u.b_name as initiator_name, + d.b_name as definition_name, d.b_i18n as definition_i18n + from dbo.wf_instance i + left join dbo.wf_definition d on d.b_id = i.b_definition_id + left join dbo.b_user u on u.b_id = i.b_initiator_id + where i.b_id = ? + """, uuidParam(instanceId)); + if (instance == null) { + throw new BusinessException("流程实例不存在"); + } + String versionId = text(instance.get("b_version_id")); + String instanceStatus = text(instance.get("b_status")); + + List> nodes = WorkflowSql.query(connection, """ + select b_node_key, b_name, b_node_type, b_approval_mode, b_due_hours, b_xh + from dbo.wf_node where b_version_id = ? order by b_xh, b_node_key + """, versionId); + List> edges = WorkflowSql.query(connection, """ + select b_id, b_from_node_key, b_to_node_key, b_name, b_condition_json + from dbo.wf_edge where b_version_id = ? order by b_priority, b_id + """, versionId); + List> nodeRuns = WorkflowSql.query(connection, """ + select b_node_key, b_run_no, b_status, b_required_count, b_completed_count, b_rejected_count, + b_entered_at, b_exited_at, b_actor_snapshot_json + from dbo.wf_node_run where b_instance_id = ? order by b_run_no, b_entered_at + """, uuidParam(instanceId)); + List> tasks = WorkflowSql.query(connection, """ + select t.b_id, t.b_node_key, t.b_assignee_id, u.b_name as assignee_name, t.b_status, + t.b_seq, t.b_due_at, t.b_claimed_by, t.b_completed_by, t.b_completed_at + from dbo.wf_task t + left join dbo.b_user u on u.b_id = t.b_assignee_id + where t.b_instance_id = ? + order by t.b_seq, t.b_created_at + """, uuidParam(instanceId)); + List> actions = WorkflowSql.query(connection, """ + select a.b_id, a.b_node_run_id, a.b_task_id, a.b_action, a.b_operator_id, a.b_from_status, + a.b_to_status, a.b_comment, a.b_occurdatetime, u.b_name as operator_name, + r.b_node_key + from dbo.wf_action a + left join dbo.b_user u on u.b_id = a.b_operator_id + left join dbo.wf_node_run r on r.b_id = a.b_node_run_id + where a.b_instance_id = ? + order by a.b_occurdatetime, a.b_id + """, uuidParam(instanceId)); + List> ccList = WorkflowSql.query(connection, """ + select c.b_id, c.b_node_key, c.b_recipient_id, u.b_name as recipient_name, c.b_status, + c.b_read_at, c.b_created_at + from dbo.wf_cc c + left join dbo.b_user u on u.b_id = c.b_recipient_id + where c.b_instance_id = ? + order by c.b_created_at, c.b_id + """, uuidParam(instanceId)); + // 画布布局(节点坐标):设计器落在 wf_version.b_canvas_json,进度 / 流程视图据此还原节点位置 + Map version = WorkflowSql.queryOne(connection, + "select b_canvas_json from dbo.wf_version where b_id = ?", versionId); + + Map nodeStatus = new LinkedHashMap<>(); + Map nodeTiming = new LinkedHashMap<>(); + Map nodeRunCounts = new LinkedHashMap<>(); + Map latestRun = new LinkedHashMap<>(); + boolean hasRejectedNode = false; + for (Map run : nodeRuns) { + String nodeKey = text(run.get("b_node_key")); + String runStatus = text(run.get("b_status")); + latestRun.put(nodeKey, runStatus); + nodeStatus.put(nodeKey, mappedNodeStatus(runStatus, run)); + if (number(run.get("b_rejected_count")) > 0) { + hasRejectedNode = true; + } + nodeTiming.put(nodeKey, timingOf(run)); + // 票数进度(会签 / 按比例用):应签、已通过、已驳回与第几次运行 + nodeRunCounts.put(nodeKey, Map.of( + "runNo", run.get("b_run_no") == null ? 1 : run.get("b_run_no"), + "requiredCount", run.get("b_required_count") == null ? 0 : run.get("b_required_count"), + "completedCount", run.get("b_completed_count") == null ? 0 : run.get("b_completed_count"), + "rejectedCount", run.get("b_rejected_count") == null ? 0 : run.get("b_rejected_count") + )); + } + String defaultStatus = FINAL_INSTANCE_STATUSES.contains(instanceStatus) ? "skipped" : "pending"; + for (Map node : nodes) { + String nodeKey = text(node.get("b_node_key")); + if (!nodeStatus.containsKey(nodeKey)) { + nodeStatus.put(nodeKey, defaultStatus); + } + } + if (hasRejectedNode) { + for (Map run : nodeRuns) { + if (number(run.get("b_rejected_count")) > 0) { + nodeStatus.put(text(run.get("b_node_key")), "rejected"); + } + } + } + if ("returned".equals(instanceStatus) && !hasRejectedNode) { + String lastKey = lastNodeKey(nodeRuns); + if (lastKey != null) { + nodeStatus.put(lastKey, "returned"); + } + } + + // 连线:源节点跑完且目标节点已经进过 → 认为实际走过 + Map edgeStatus = new LinkedHashMap<>(); + for (Map edge : edges) { + String from = text(edge.get("b_from_node_key")); + String to = text(edge.get("b_to_node_key")); + boolean passed = "completed".equals(latestRun.get(from)) + && (latestRun.containsKey(to) + || (isFinalNode(nodes, to) && FINAL_INSTANCE_STATUSES.contains(instanceStatus))); + edgeStatus.put(text(edge.get("b_id")), passed ? "passed" : "pending"); + } + + Map result = new LinkedHashMap<>(); + result.put("instance", instance); + result.put("canvas", version == null ? null : version.get("b_canvas_json")); + result.put("nodes", nodes); + result.put("edges", edges); + result.put("nodeRunStatus", nodeStatus); + result.put("edgeRunStatus", edgeStatus); + result.put("nodeTiming", nodeTiming); + result.put("nodeRunCounts", nodeRunCounts); + result.put("tasks", tasks); + result.put("ccList", ccList); + result.put("timeline", actions); + return result; + } + } + + /** + * 按业务单据查当前审批状态(业务详情页的状态条用)。 + * + *

返回最近一次实例(优先未结束的)、我在这次流程里的待办、以及该模块是否已配置送审绑定, + * 前端据此决定显示「送审 / 去处理 / 查看进度」。 + */ + public Map instanceByBusiness(Map params) throws SQLException { + String moduleId = ParamUtils.getRequiredString(params, "moduleId"); + String businessId = ParamUtils.getRequiredString(params, "businessId"); + String userId = OrgContext.requireUserId(); + + try (Connection connection = dataSource.getConnection()) { + Map instance = WorkflowSql.queryOne(connection, """ + select top 1 i.b_id, i.b_definition_id, i.b_version_id, i.b_status, i.b_initiator_id, + i.b_started_at, i.b_completed_at, i.b_current_summary_json, + d.b_name as definition_name, d.b_i18n as definition_i18n + from dbo.wf_instance i + left join dbo.wf_definition d on d.b_id = i.b_definition_id + where i.b_module_id = ? and i.b_business_id = ? + order by case when i.b_status in ('running', 'returned', 'error') then 0 else 1 end, + i.b_started_at desc + """, moduleId, businessId); + + Map myTask = null; + int pendingCount = 0; + if (instance != null) { + String instanceId = text(instance.get("b_id")); + myTask = WorkflowSql.queryOne(connection, """ + select top 1 t.b_id as task_id, t.b_node_key, t.b_status, t.b_due_at, n.b_name as node_name + from dbo.wf_task t + left join dbo.wf_instance i on i.b_id = t.b_instance_id + left join dbo.wf_node n on n.b_version_id = i.b_version_id and n.b_node_key = t.b_node_key + where t.b_instance_id = ? and t.b_assignee_id = ? and t.b_status in ('pending', 'claimed') + order by t.b_created_at + """, uuidParam(instanceId), userId); + pendingCount = WorkflowSql.count(connection, + "select count(1) from dbo.wf_task where b_instance_id = ? and b_status in ('pending', 'claimed')", + uuidParam(instanceId)); + } + + int bindingCount = WorkflowSql.count(connection, """ + select count(1) from dbo.wf_binding + where b_module_id = ? and b_event = 'submit' and b_canuse = 1 + and (b_effective_from is null or b_effective_from <= sysutcdatetime()) + and (b_effective_to is null or b_effective_to > sysutcdatetime()) + """, moduleId); + + Map result = new LinkedHashMap<>(); + result.put("instance", instance); + result.put("myTask", myTask); + result.put("pendingCount", pendingCount); + result.put("hasBinding", bindingCount > 0); + return result; + } + } + + /** 任务详情:待办卡片与动作弹窗需要的信息(含节点表单策略与当前实例摘要) */ + public Map taskDetail(Map params) throws SQLException { + String taskId = ParamUtils.getRequiredString(params, "taskId"); + try (Connection connection = dataSource.getConnection()) { + Map task = WorkflowSql.queryOne(connection, """ + select b_id, b_instance_id, b_node_run_id, b_node_key, b_assignee_id, b_status, b_seq, + b_due_at, b_claimed_by, b_completed_by, b_completed_at, b_rule_snapshot_json + from dbo.wf_task where b_id = ? + """, uuidParam(taskId)); + if (task == null) { + throw new BusinessException("任务不存在"); + } + Map instance = WorkflowSql.queryOne(connection, """ + select i.b_id, i.b_business_id, i.b_business_no, i.b_module_id, i.b_definition_id, + i.b_version_id, i.b_initiator_id, i.b_status, i.b_started_at, i.b_current_summary_json, + i.b_snapshot_json, d.b_name as definition_name, d.b_i18n as definition_i18n + from dbo.wf_instance i + left join dbo.wf_definition d on d.b_id = i.b_definition_id + where i.b_id = ? + """, uuidParam(text(task.get("b_instance_id")))); + Map node = null; + if (instance != null) { + node = WorkflowSql.queryOne(connection, """ + select b_node_key, b_name, b_node_type, b_approval_mode, b_reject_mode, b_due_hours, + b_config_json + from dbo.wf_node where b_version_id = ? and b_node_key = ? + """, text(instance.get("b_version_id")), text(task.get("b_node_key"))); + } + Map result = new LinkedHashMap<>(); + result.put("task", task); + result.put("instance", instance); + result.put("node", node); + return result; + } + } + + /* ============================================================================ + 小工具 + ============================================================================ */ + + private int[] pageOf(Map params) { + int pageNo = 1; + int pageSize = 50; + if (params != null && params.get("pageNo") instanceof Number number) { + pageNo = Math.max(number.intValue(), 1); + } + if (params != null && params.get("pageSize") instanceof Number number) { + pageSize = Math.min(Math.max(number.intValue(), 1), 200); + } + return new int[]{pageNo, pageSize}; + } + + /** 分页查询:总数用同样的过滤条件单独算一次(表规模小,不做窗口函数) */ + private Map pageResult( + Connection connection, + String listSql, + String countSql, + String userId, + int[] page + ) throws SQLException { + int total = WorkflowSql.count(connection, countSql, userId); + int offset = (page[0] - 1) * page[1]; + List> rows = WorkflowSql.query(connection, + listSql + " offset ? rows fetch next ? rows only", userId, offset, page[1]); + Map result = new LinkedHashMap<>(); + result.put("rows", rows); + result.put("total", total); + result.put("pageNo", page[0]); + result.put("pageSize", page[1]); + return result; + } + + /** 节点运行状态 → 画布状态色(与前端 RUN_STATUS 取值一致) */ + private String mappedNodeStatus(String runStatus, Map run) { + return switch (runStatus) { + case "completed" -> number(run.get("b_rejected_count")) > 0 ? "rejected" : "completed"; + case "running" -> "running"; + case "skipped" -> "skipped"; + default -> "pending"; + }; + } + + /** 节点耗时与进出时间:真实数据来自 entered_at / exited_at(UTC 带 Z 串);超时由前端按 dueHours 判定 */ + private Map timingOf(Map run) { + Instant entered = TimeUtils.toInstant(run.get("b_entered_at")); + if (entered == null) { + return Map.of(); + } + Instant exited = TimeUtils.toInstant(run.get("b_exited_at")); + long end = exited == null ? System.currentTimeMillis() : exited.toEpochMilli(); + Map timing = new LinkedHashMap<>(); + timing.put("elapsedMs", Math.max(end - entered.toEpochMilli(), 0)); + // 进出时间原样带出(WorkflowSql 已转成带 Z 的 ISO 串),进度视图直接展示节点时间 + timing.put("enteredAt", run.get("b_entered_at")); + timing.put("exitedAt", run.get("b_exited_at")); + return timing; + } + + private String lastNodeKey(List> nodeRuns) { + String last = null; + for (Map run : nodeRuns) { + String nodeKey = text(run.get("b_node_key")); + if (!nodeKey.isEmpty() && !"start".equals(nodeKey) && !"end".equals(nodeKey)) { + last = nodeKey; + } + } + return last; + } + + private boolean isFinalNode(List> nodes, String nodeKey) { + return nodes.stream().anyMatch(node -> nodeKey.equals(text(node.get("b_node_key"))) + && "end".equals(text(node.get("b_node_type")))); + } + + private List stringList(Map params, String field) { + List values = new ArrayList<>(); + Object raw = params == null ? null : params.get(field); + if (raw instanceof List list) { + list.forEach(item -> { + String value = text(item); + if (!value.isEmpty() && !values.contains(value)) { + values.add(value); + } + }); + } + return values; + } + + private Object uuidParam(String value) { + String text = text(value); + if (text.isEmpty()) { + throw new BusinessException("缺少主键"); + } + try { + return UUID.fromString(text); + } catch (IllegalArgumentException exception) { + throw new BusinessException("主键格式不合法:" + text); + } + } + + private double number(Object value) { + return value instanceof Number number ? number.doubleValue() : 0; + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeService.java new file mode 100644 index 00000000..82bd886e --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeService.java @@ -0,0 +1,1616 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.ParamUtils; +import cn.g3soft.erp.common.util.TimeUtils; +import cn.g3soft.erp.common.util.UuidV7Generator; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * 工作流运行侧服务 —— 送审、节点推进与审批动作(设计 §5 / §8 / §9 / §10 / §11 / §12)。 + * + *

核心约定: + *

    + *
  • 运行引擎只在后端执行,前端只能提交意图(§5):本服务是实例 / 节点运行 / 待办 / 动作的唯一写入口;
  • + *
  • 一次动作的全部副作用在同一个事务里完成(§11):锁定 → 校验 → 写 wf_action → 更新 task / node_run + * → 推进或结束 → 投影业务状态 → 写 wf_outbox → 提交;
  • + *
  • 幂等:送审按 {@code b_idempotency_key},动作按 {@code wf_action.b_request_id} 去重(§12.1);
  • + *
  • 并发:实例与任务都用 {@code UPDLOCK} 行锁 + {@code b_row_version} 递增(§12.2 / §12.3);
  • + *
  • 审批人解析为空时进入 {@code error} 而不是跳过节点(§12.4),管理员可用加签 / 转派补救。
  • + *
+ * + *

文档未明确、这里定死的语义(实现即约定,改动需同步前端与文档): + *

    + *
  1. 并行分支按 AND-join:出线 ≥ 2 视为分叉(每条出线各推进一个 token), + * 入线 ≥ 2 视为汇合(所有入线来源节点的最新一次运行都 completed 才继续);
  2. + *
  3. 单 / 或签 / 会签的完成条件统一为「已通过人数 ≥ 应签人数」, + * single 只生成第一名候选人的任务,any 在首票后取消其余任务;
  4. + *
  5. {@code return_previous} 回到最近一次 completed 的人工节点重跑(找不到则退化为退回发起人);
  6. + *
  7. 无匹配绑定时直接拒绝送审,不静默通过;
  8. + *
  9. {@code service_task}:不产生待办,自动完成并继续路由(动作码 {@code service})。
  10. + *
+ */ +@Service +public class WorkflowRuntimeService { + + private static final String ADMIN_ACCOUNT = "g3soft"; + private static final Set BINDING_EVENTS = Set.of("submit", "resubmit", "change"); + private static final Set TASK_ACTIONS = Set.of("approve", "reject", "return"); + /** 路由步数上限:正常流程远小于它,命中说明配置有环,此时把实例置为 error 而不是死循环 */ + private static final int MAX_ROUTE_STEPS = 200; + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + + @Resource + private DataSource dataSource; + + @Resource + private ObjectMapper objectMapper; + + @Resource + private WorkflowActorResolver actorResolver; + + @Resource + private WorkflowDelegationService delegationService; + + /* ============================================================================ + 提交送审 + ============================================================================ */ + + /** + * 送审:匹配流程绑定 → 建实例 → 激活首个节点 → 投影业务状态,全在一个事务里。 + * + * @param params moduleId、businessId、businessNo、event(默认 submit)、context(业务字段)、idempotencyKey + * @return { instanceId, status, currentSummary, taskCount, duplicated } + */ + public Map submit(Map params) throws SQLException { + String moduleId = ParamUtils.getRequiredString(params, "moduleId"); + String businessId = ParamUtils.getRequiredString(params, "businessId"); + String businessNo = text(params.get("businessNo")); + // 默认事件 submit;用单次表达式保证 event 是 effectively final(会被事务 lambda 捕获) + String requestedEvent = text(params.get("event")); + String event = requestedEvent.isEmpty() ? "submit" : requestedEvent; + if (!BINDING_EVENTS.contains(event)) { + throw new BusinessException("业务事件不合法:" + event); + } + String idempotencyKey = text(params.get("idempotencyKey")); + Map businessContext = mapOf(params.get("context")); + String userId = OrgContext.requireUserId(); + String orgId = OrgContext.requireOrgId(); + + return WorkflowTx.run(dataSource, + connection -> { + if (!idempotencyKey.isEmpty()) { + Map exists = WorkflowSql.queryOne(connection, """ + select b_id from dbo.wf_instance + where b_module_id = ? and b_business_id = ? and b_idempotency_key = ? + """, moduleId, businessId, idempotencyKey); + if (exists != null) { + return submittedResult(connection, String.valueOf(exists.get("b_id")), true); + } + } + requireAction(connection, moduleId, userId, "submit"); + + Map binding = matchBinding(connection, moduleId, event, businessContext); + String versionId = text(binding.get("b_version_id")); + String definitionId = text(binding.get("b_definition_id")); + + Map variables = new LinkedHashMap<>(businessContext); + variables.put("b_initiator_id", userId); + variables.put("b_org_id", orgId); + // 逐个回退取值。用单次表达式而非多次赋值:lambda 要求捕获的局部变量 effectively final。 + String deptFromContext = text(variables.get("b_initiator_dept_id")); + String deptFromVariable = text(variables.get("b_dept_id")); + String deptId = !deptFromContext.isEmpty() ? deptFromContext + : !deptFromVariable.isEmpty() ? deptFromVariable + : text(WorkflowSql.scalar(connection, + "select top 1 b_dept_id from dbo.b_user where b_id = ?", userId)); + variables.put("b_initiator_dept_id", deptId); + + UUID instanceId = UuidV7Generator.nextUuid(); + WorkflowSql.update(connection, """ + insert into dbo.wf_instance + (b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_event, + b_business_id, b_business_no, b_org_id, b_initiator_id, b_initiator_dept_id, b_status, + b_context_json, b_snapshot_json, b_row_version, b_idempotency_key, b_started_at, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, 0, ?, sysutcdatetime(), ?, sysutcdatetime(), ?, sysutcdatetime()) + """, instanceId, definitionId, versionId, text(binding.get("b_id")), moduleId, event, + businessId, businessNo, orgId, userId, deptId, + toJson(variables), toJson(businessContext), + idempotencyKey.isEmpty() ? null : idempotencyKey, userId, userId); + + WorkflowInstanceRow instance = new WorkflowInstanceRow( + instanceId.toString(), definitionId, versionId, text(binding.get("b_id")), + moduleId, businessId, businessNo, userId, deptId, "running", 0 + ); + + Map startNode = WorkflowSql.queryOne(connection, """ + select b_node_key from dbo.wf_node + where b_version_id = ? and b_node_type = 'start' + order by b_xh, b_node_key + """, versionId); + if (startNode == null) { + throw new BusinessException("流程版本没有开始节点,无法送审"); + } + String startKey = text(startNode.get("b_node_key")); + recordNodeRun(connection, instance, startKey, "completed", variables, null, 0); + writeAction(connection, instance, null, null, "submit", userId, null, "running", + businessNo.isEmpty() ? null : "提交审批", idempotencyKey, toJson(businessContext)); + writeOutbox(connection, "instance.submitted", "instance", instance.instanceId(), + Map.of("businessId", businessId, "moduleId", moduleId)); + + advance(connection, instance, startKey, variables, 0); + projectBusinessStatus(connection, instance, currentStatus(connection, instance.instanceId())); + + return submittedResult(connection, instance.instanceId(), false); + }, + (connection, cause) -> { + // 幂等:并发或重放导致撞 ux_wf_instance_idempotency 时,回查既有实例并返回「重复」结果。 + // 注意这段跑在**回滚之后、同一条连接上**(模板保证),与单实例唯一键一起构成幂等闭环。 + if (idempotencyKey.isEmpty() || !WorkflowTx.isUniqueViolation(cause)) { + return null; + } + Map duplicate = WorkflowSql.queryOne(connection, """ + select top 1 b_id from dbo.wf_instance + where b_module_id = ? and b_business_id = ? and b_idempotency_key = ? + """, moduleId, businessId, idempotencyKey); + return duplicate == null ? null + : submittedResult(connection, String.valueOf(duplicate.get("b_id")), true); + }); + } + + /* ============================================================================ + 审批动作 + ============================================================================ */ + + /** + * 审批动作:approve / reject / return。 + * + * @param params taskId、action、comment、idempotencyKey + */ + public Map act(Map params) throws SQLException { + String taskId = ParamUtils.getRequiredString(params, "taskId"); + String action = ParamUtils.getRequiredString(params, "action"); + if (!TASK_ACTIONS.contains(action)) { + throw new BusinessException("不支持的审批动作:" + action); + } + String comment = text(params.get("comment")); + String idempotencyKey = text(params.get("idempotencyKey")); + String userId = OrgContext.requireUserId(); + + return WorkflowTx.run(dataSource, + connection -> { + if (!idempotencyKey.isEmpty()) { + Map done = WorkflowSql.queryOne(connection, + "select top 1 b_id from dbo.wf_action where b_request_id = ?", idempotencyKey); + if (done != null) { + return Map.of("duplicated", true, "message", "重复请求已忽略"); + } + } + + // 加锁顺序:**实例行先于任务行**(见 updateInstance / lockInstance 的说明)。 + // 任务先做一次无锁读,仅为拿到 instance_id;拿到实例锁后再用 UPDLOCK 重读并校验。 + Map task = WorkflowSql.queryOne(connection, """ + select b_id, b_instance_id from dbo.wf_task where b_id = ? + """, uuidParam(taskId)); + if (task == null) { + throw new BusinessException("任务不存在或已被删除"); + } + WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + if (!"running".equals(instance.status())) { + throw new BusinessException("流程已结束(" + instance.status() + "),不能再处理"); + } + task = lockTask(connection, taskId, instance.instanceId(), + Set.of("pending", "claimed"), userId); + String status = text(task.get("b_status")); + if ("delegate".equals(text(task.get("b_assignee_type")))) { + verifyDelegation(connection, task, action); + } + + if ("approve".equals(action)) { + requireAction(connection, instance.moduleId(), userId, "approve"); + } else if ("reject".equals(action)) { + requireAction(connection, instance.moduleId(), userId, "reject"); + } else { + requireAction(connection, instance.moduleId(), userId, "return"); + } + + Map nodeRun = WorkflowSql.queryOne(connection, """ + select b_id, b_node_key, b_run_no, b_status, b_required_count, b_completed_count, b_rejected_count + from dbo.wf_node_run with (UPDLOCK, ROWLOCK) + where b_id = ? + """, uuidParam(text(task.get("b_node_run_id")))); + if (nodeRun == null) { + throw new BusinessException("节点运行记录缺失,无法推进流程"); + } + Map node = WorkflowSql.queryOne(connection, + "select b_node_key, b_name, b_approval_mode, b_reject_mode from dbo.wf_node where b_version_id = ? and b_node_key = ?", + instance.versionId(), text(nodeRun.get("b_node_key"))); + String nodeName = node == null ? text(nodeRun.get("b_node_key")) + : (text(node.get("b_name")).isEmpty() ? text(nodeRun.get("b_node_key")) : text(node.get("b_name"))); + String mode = node == null ? "single" : text(node.get("b_approval_mode")); + String rejectMode = node == null ? "terminate" : text(node.get("b_reject_mode")); + + Map variables = readVariables(connection, instance.instanceId()); + + if ("approve".equals(action)) { + WorkflowSql.update(connection, """ + update dbo.wf_task + set b_status = 'approved', b_completed_by = ?, b_completed_at = sysutcdatetime(), b_updated_at = sysutcdatetime() + where b_id = ? + """, userId, uuidParam(taskId)); + int completed = WorkflowSql.count(connection, + "select count(1) from dbo.wf_task where b_node_run_id = ? and b_status = 'approved'", + uuidParam(text(task.get("b_node_run_id")))); + WorkflowSql.update(connection, + "update dbo.wf_node_run set b_completed_count = ? where b_id = ?", + completed, uuidParam(text(task.get("b_node_run_id")))); + + int required = nodeRun.get("b_required_count") instanceof Number number + ? number.intValue() : 1; + if (completed >= Math.max(required, 1)) { + // 节点运行状态只有 pending / running / completed / skipped(表注释口径), + // 不能写任务的 approved —— 否则并行汇合与「退回上一节点」都找不到已完成节点 + completeNodeRun(connection, text(nodeRun.get("b_id")), "completed"); + cancelSiblingTasks(connection, instance, text(nodeRun.get("b_id")), text(taskId), + "同节点其余待办已自动取消"); + writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "approve", + userId, status, "approved", comment, idempotencyKey, null); + advance(connection, instance, text(nodeRun.get("b_node_key")), variables, 0); + projectBusinessStatus(connection, instance, currentStatus(connection, instance.instanceId())); + } else { + writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "approve", + userId, status, "running", comment, idempotencyKey, null); + // 串行会签:当前审批人通过后激活下一位(§9.6)。 + // 只在「尚未凑够票数」的分支调用,与上面的节点完成分支互斥; + // 非串行节点不会产生 waiting 任务,此调用是无副作用的空转。 + promoteNextSerialTask(connection, instance, + text(nodeRun.get("b_id")), text(nodeRun.get("b_node_key"))); + updateInstanceSummary(connection, instance, "审批中:" + nodeName); + } + } else { + // reject 与 return:节点内第一个驳回 / 退回立即生效(§9.7) + WorkflowSql.update(connection, """ + update dbo.wf_task + set b_status = ?, b_completed_by = ?, b_completed_at = sysutcdatetime(), b_updated_at = sysutcdatetime() + where b_id = ? + """, "reject".equals(action) ? "rejected" : "cancelled", userId, uuidParam(taskId)); + WorkflowSql.update(connection, + "update dbo.wf_node_run set b_rejected_count = b_rejected_count + 1 where b_id = ?", + uuidParam(text(task.get("b_node_run_id")))); + cancelSiblingTasks(connection, instance, text(nodeRun.get("b_id")), taskId, "同节点其余待办已自动取消"); + + if ("return".equals(action)) { + writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "return", + userId, status, "returned", comment, idempotencyKey, null); + finishInstance(connection, instance, "returned", nodeName); + } else if ("return_previous".equals(rejectMode)) { + writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "reject", + userId, status, "rejected", comment, idempotencyKey, + toJson(Map.of("rejectMode", rejectMode))); + // 当前节点必须先收口:否则该 node_run 永远停在 running, + // 并行汇合的 allIncomingCompleted 会因「最新一轮运行」判断而误放行后续节点。 + completeNodeRun(connection, text(nodeRun.get("b_id")), "completed"); + reenterPreviousNode(connection, instance, text(nodeRun.get("b_node_key")), variables); + } else if ("return_initiator".equals(rejectMode)) { + writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "reject", + userId, status, "returned", comment, idempotencyKey, + toJson(Map.of("rejectMode", rejectMode))); + finishInstance(connection, instance, "returned", nodeName); + } else { + completeNodeRun(connection, text(nodeRun.get("b_id")), "completed"); + writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "reject", + userId, status, "rejected", comment, idempotencyKey, + toJson(Map.of("rejectMode", "terminate"))); + finishInstance(connection, instance, "rejected", nodeName); + } + } + + return submittedResult(connection, instance.instanceId(), false); + }, + (connection, cause) -> { + // 幂等:审批请求重放撞 ux_wf_action_request 时,回查已有动作并返回「重复」结果。 + // 与 submit 同理,这段跑在回滚之后、同一条连接上(模板保证执行时机)。 + if (idempotencyKey.isEmpty() || !WorkflowTx.isUniqueViolation(cause)) { + return null; + } + Map duplicate = WorkflowSql.queryOne(connection, + "select top 1 b_id from dbo.wf_action where b_request_id = ?", + idempotencyKey); + return duplicate == null ? null + : Map.of("duplicated", true, "message", "重复请求已忽略"); + }); + } + + /** 撤回:发起人撤回运行中的实例(§20:撤回后重新提交会产生新实例与新快照) */ + public Map withdraw(Map params) throws SQLException { + String instanceId = ParamUtils.getRequiredString(params, "instanceId"); + String comment = text(params.get("comment")); + String userId = OrgContext.requireUserId(); + + return WorkflowTx.run(dataSource, connection -> { + WorkflowInstanceRow instance = lockInstance(connection, instanceId); + if (!"running".equals(instance.status())) { + throw new BusinessException("只有运行中的流程可以撤回(当前:" + instance.status() + ")"); + } + if (!userId.equals(instance.initiatorId())) { + requireAction(connection, instance.moduleId(), userId, "withdraw"); + } + cancelAllTasks(connection, instance.instanceId(), "流程已撤回"); + updateInstance(connection, instance, + "b_status = 'withdrawn', b_completed_at = sysutcdatetime()," + + " b_row_version = b_row_version + 1, b_current_summary_json = ?," + + " b_updated_by = ?, b_updated_at = sysutcdatetime()", + toJson(Map.of("summary", "已撤回")), userId); + writeAction(connection, instance, null, null, "withdraw", userId, "running", "withdrawn", + comment, null, null); + writeOutbox(connection, "instance.withdrawn", "instance", instance.instanceId(), Map.of()); + projectBusinessStatus(connection, instance, "withdrawn"); + return submittedResult(connection, instanceId, false); + }); + } + + /** 取消:管理员提前结束流程(§20:由系统记录取消原因) */ + public Map cancel(Map params) throws SQLException { + String instanceId = ParamUtils.getRequiredString(params, "instanceId"); + String comment = text(params.get("comment")); + String userId = OrgContext.requireUserId(); + + return WorkflowTx.run(dataSource, connection -> { + WorkflowInstanceRow instance = lockInstance(connection, instanceId); + if (!"running".equals(instance.status())) { + throw new BusinessException("只有运行中的流程可以取消(当前:" + instance.status() + ")"); + } + requireAction(connection, instance.moduleId(), userId, "workflow_admin"); + cancelAllTasks(connection, instance.instanceId(), "流程已取消"); + updateInstance(connection, instance, + "b_status = 'cancelled', b_completed_at = sysutcdatetime()," + + " b_row_version = b_row_version + 1, b_current_summary_json = ?," + + " b_updated_by = ?, b_updated_at = sysutcdatetime()", + toJson(Map.of("summary", "已取消")), userId); + writeAction(connection, instance, null, null, "cancel", userId, "running", "cancelled", + comment, null, null); + writeOutbox(connection, "instance.cancelled", "instance", instance.instanceId(), Map.of()); + projectBusinessStatus(connection, instance, "cancelled"); + return submittedResult(connection, instanceId, false); + }); + } + + /** 抢占待办:节点开了 allowClaim 且同一任务有多个候选人时使用(§12.3:先锁任务再校验 pending) */ + public Map claim(Map params) throws SQLException { + String taskId = ParamUtils.getRequiredString(params, "taskId"); + String userId = OrgContext.requireUserId(); + + return WorkflowTx.run(dataSource, connection -> { + // 加锁顺序:实例行先于任务行。无锁预读只为拿 instance_id,拿到实例锁后再重读校验。 + Map task = WorkflowSql.queryOne(connection, """ + select b_id, b_instance_id from dbo.wf_task where b_id = ? + """, uuidParam(taskId)); + if (task == null) { + throw new BusinessException("任务不存在"); + } + WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + if (!"running".equals(instance.status())) { + throw new BusinessException("流程已结束,不能抢占待办"); + } + // 抢占不做归属校验:抢占的对象本来就不是调用者自己的待办(候选资格由下面的快照校验负责) + task = lockTask(connection, taskId, instance.instanceId(), Set.of("pending"), null); + requireAction(connection, instance.moduleId(), userId, "claim"); + // 抢占者必须是该节点的候选审批人:仅凭 action.{模块}.claim 权限就能抢走任意待办属越权。 + // 候选集合取自 node_run 的审批人快照(single 模式只给第一个候选建任务,任务表判断不到其他人)。 + // 快照解析异常时返回空集合 → 拒绝,属 fail-closed。 + String nodeRunId = text(task.get("b_node_run_id")); + Map nodeRunRow = WorkflowSql.queryOne(connection, """ + select b_actor_snapshot_json from dbo.wf_node_run with (UPDLOCK, ROWLOCK) + where b_id = ? + """, uuidParam(nodeRunId)); + List candidates = + candidatesOf(nodeRunRow == null ? null : nodeRunRow.get("b_actor_snapshot_json")); + if (!candidates.contains(userId)) { + throw new BusinessException("你不是该任务的候选审批人,无法抢占"); + } + Map node = WorkflowSql.queryOne(connection, """ + select b_config_json from dbo.wf_node + where b_version_id = ? and b_node_key = ? + """, instance.versionId(), text(task.get("b_node_key"))); + Map nodeConfig = WorkflowJson.readMapOrNull( + node == null ? null : node.get("b_config_json"), + "node.config:" + text(task.get("b_node_key"))); + if (!configFlag(nodeConfig, "allowClaim")) { + throw new BusinessException("当前节点未开启抢占待办"); + } + WorkflowSql.update(connection, """ + update dbo.wf_task + set b_status = 'claimed', b_claimed_by = ?, b_claimed_at = sysutcdatetime(), b_updated_at = sysutcdatetime() + where b_id = ? + """, userId, uuidParam(taskId)); + writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "claim", userId, "pending", "claimed", + "抢占待办", null, null); + return Map.of("taskId", taskId, "status", "claimed"); + }); + } + + /** 转交:把待办转给他人(任务级转交并强制留痕) */ + public Map transfer(Map params) throws SQLException { + String taskId = ParamUtils.getRequiredString(params, "taskId"); + String toUserId = ParamUtils.getRequiredString(params, "toUserId"); + String comment = text(params.get("comment")); + String userId = OrgContext.requireUserId(); + + return WorkflowTx.run(dataSource, connection -> { + // 加锁顺序:实例行先于任务行。无锁预读只为拿 instance_id。 + Map task = WorkflowSql.queryOne(connection, """ + select b_id, b_instance_id from dbo.wf_task where b_id = ? + """, uuidParam(taskId)); + if (task == null) { + throw new BusinessException("任务不存在"); + } + requireEnabledUser(connection, toUserId); + WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + if (!"running".equals(instance.status())) { + throw new BusinessException("流程已结束,不能转交待办"); + } + task = lockTask(connection, taskId, instance.instanceId(), Set.of("pending", "claimed"), userId); + String status = text(task.get("b_status")); + requireAction(connection, instance.moduleId(), userId, "transfer"); + + WorkflowSql.update(connection, """ + update dbo.wf_task set b_status = 'cancelled', b_updated_at = sysutcdatetime() where b_id = ? + """, uuidParam(taskId)); + UUID newTaskId = UuidV7Generator.nextUuid(); + WorkflowSql.update(connection, """ + insert into dbo.wf_task + (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_created_at, b_updated_at) + values (?, ?, ?, ?, ?, 'delegate', ?, ?, 'pending', ?, sysutcdatetime(), sysutcdatetime()) + """, newTaskId, uuidParam(instance.instanceId()), uuidParam(text(task.get("b_node_run_id"))), + text(task.get("b_node_key")), text(task.get("b_actor_rule_id")), toUserId, + task.get("b_seq") == null ? 0 : ((Number) task.get("b_seq")).intValue(), + // 读出来的 b_due_at 是带 Z 的 UTC 串(TimeUtils 出口口径),回写前折回墙钟值 + TimeUtils.toStorageValue("datetime2", task.get("b_due_at"))); + writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "transfer", + userId, status, status == null ? null : status, comment, null, + toJson(Map.of("toUserId", toUserId, "newTaskId", newTaskId.toString()))); + writeOutbox(connection, "task.transferred", "task", newTaskId.toString(), + Map.of("toUserId", toUserId)); + return Map.of("taskId", newTaskId.toString(), "toUserId", toUserId); + }); + } + + /** + * 加签(§9.8):任务挂在同一个 node_run 下,按节点审批方式参与判定; + * required_count 不因加签自动改变(要改必须在动作载荷里显式声明)。 + */ + public Map addApprover(Map params) throws SQLException { + String taskId = ParamUtils.getRequiredString(params, "taskId"); + String comment = text(params.get("comment")); + List userIds = new ArrayList<>(); + Object raw = params == null ? null : params.get("userIds"); + if (raw instanceof List list) { + list.forEach(item -> { + String value = text(item); + if (!value.isEmpty() && !userIds.contains(value)) { + userIds.add(value); + } + }); + } + if (userIds.isEmpty()) { + throw new BusinessException("请选择加签人"); + } + String userId = OrgContext.requireUserId(); + + return WorkflowTx.run(dataSource, connection -> { + // 加锁顺序:实例行先于任务行。无锁预读只为拿 instance_id。 + Map task = WorkflowSql.queryOne(connection, """ + select b_id, b_instance_id from dbo.wf_task where b_id = ? + """, uuidParam(taskId)); + if (task == null) { + throw new BusinessException("任务不存在"); + } + WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + if (!"running".equals(instance.status())) { + throw new BusinessException("流程已结束,不能加签"); + } + // §9.8:加签由当前任务的处理人发起,故做归属校验 + task = lockTask(connection, taskId, instance.instanceId(), + Set.of("pending", "claimed", "waiting"), userId); + String status = text(task.get("b_status")); + requireAction(connection, instance.moduleId(), userId, "add_approver"); + int seq = task.get("b_seq") instanceof Number number ? number.intValue() : 0; + for (String target : userIds) { + requireEnabledUser(connection, target); + Map activeTask = WorkflowSql.queryOne(connection, """ + select top 1 b_id from dbo.wf_task + where b_node_run_id = ? and b_assignee_id = ? + and b_status in ('pending', 'claimed', 'waiting') + """, uuidParam(text(task.get("b_node_run_id"))), target); + if (activeTask != null) { + throw new BusinessException("用户「" + target + "」已经是该节点的活动审批人"); + } + seq += 1; + WorkflowSql.update(connection, """ + insert into dbo.wf_task + (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_created_at, b_updated_at) + values (?, ?, ?, ?, ?, 'user', ?, ?, 'pending', sysutcdatetime(), sysutcdatetime()) + """, UuidV7Generator.nextUuid(), uuidParam(instance.instanceId()), + uuidParam(text(task.get("b_node_run_id"))), text(task.get("b_node_key")), + text(task.get("b_actor_rule_id")), target, seq); + } + writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "add_approver", + userId, status, status, comment, null, toJson(Map.of("userIds", userIds))); + writeOutbox(connection, "task.approver_added", "task", taskId, Map.of("userIds", userIds)); + return Map.of("added", userIds.size()); + }); + } + + /** 催办:通过 outbox 发提醒,不改变任务状态(§20) */ + public Map urge(Map params) throws SQLException { + String taskId = ParamUtils.getRequiredString(params, "taskId"); + String comment = text(params.get("comment")); + String userId = OrgContext.requireUserId(); + + return WorkflowTx.run(dataSource, connection -> { + // 加锁顺序:实例行先于任务行。无锁预读只为拿 instance_id。 + Map task = WorkflowSql.queryOne(connection, """ + select b_id, b_instance_id from dbo.wf_task where b_id = ? + """, uuidParam(taskId)); + if (task == null) { + throw new BusinessException("任务不存在"); + } + WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + if (!"running".equals(instance.status())) { + throw new BusinessException("流程已结束,不能催办"); + } + // 催办只发提醒、不改任务状态,凭动作权限即可,不校验归属 + task = lockTask(connection, taskId, instance.instanceId(), + Set.of("pending", "claimed"), null); + String status = text(task.get("b_status")); + requireAction(connection, instance.moduleId(), userId, "urge"); + writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "urge", userId, + status, status, comment, null, null); + writeOutbox(connection, "task.urged", "task", taskId, + Map.of("assigneeId", text(task.get("b_assignee_id")))); + return Map.of("urged", true); + }); + } + + /* ============================================================================ + 路由与节点处理 + ============================================================================ */ + + /** 从 fromKey 选一条出线并进入目标节点 */ + private void advance( + Connection connection, + WorkflowInstanceRow instance, + String fromKey, + Map variables, + int depth + ) throws SQLException { + if (depth > MAX_ROUTE_STEPS) { + failInstance(connection, instance, "流程路由步数超过上限,可能存在循环配置"); + return; + } + try { + List> edges = outgoingEdges(connection, instance.versionId(), fromKey); + if (edges.isEmpty()) { + failInstance(connection, instance, "节点「" + fromKey + "」没有出线,流程走不下去"); + return; + } + List> trace = new ArrayList<>(); + Map chosen = chooseEdge(edges, variables, trace); + if (chosen == null) { + failInstance(connection, instance, "节点「" + fromKey + "」的条件出线没有一条命中"); + return; + } + // 路由留痕(§9.5):只有真正的分支选择(多条出线)才记一条 route 动作, + // 否则 14 个节点会写 14 条无信息量的记录。轨迹里保留每条出线的求值结果与是否默认边。 + if (edges.size() > 1) { + Map payload = new LinkedHashMap<>(); + payload.put("from", fromKey); + payload.put("chosen", text(chosen.get("b_id"))); + payload.put("evaluations", trace); + writeAction(connection, instance, null, null, "route", null, null, null, + "路由:" + fromKey + " → " + text(chosen.get("b_to_node_key")), null, toJson(payload)); + } + enterNode(connection, instance, text(chosen.get("b_to_node_key")), variables, depth + 1); + } catch (WorkflowConfigException exception) { + // 条件 / 节点配置损坏:绝不能当成「无条件边」放行 —— 那是静默错路由(原型里的真实缺陷)。 + // 与「一条都没命中」同处理:实例置 error,并保留本次已记录的表决(§12.4)。 + failInstance(connection, instance, exception.getMessage()); + } + } + + /** 进入节点:按类型分派(人工任务挂起等待,抄送 / 服务节点自动走完,网关继续路由) */ + private void enterNode( + Connection connection, + WorkflowInstanceRow instance, + String nodeKey, + Map variables, + int depth + ) throws SQLException { + Map node = WorkflowSql.queryOne(connection, """ + select b_node_key, b_name, b_node_type, b_approval_mode, b_reject_mode, b_due_hours, b_config_json + from dbo.wf_node where b_version_id = ? and b_node_key = ? + """, instance.versionId(), nodeKey); + if (node == null) { + failInstance(connection, instance, "节点「" + nodeKey + "」在流程版本里不存在"); + return; + } + String nodeType = text(node.get("b_node_type")); + switch (nodeType) { + case "end" -> { + recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + finishInstance(connection, instance, "approved", text(node.get("b_name"))); + } + case "exclusive_gateway" -> { + // 网关是瞬时通过的路由点:也留一条 completed 记录, + // 否则「查看审批进度」里网关节点没有状态、它前后的连线也算不出是否走过 + recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + advance(connection, instance, nodeKey, variables, depth + 1); + } + case "parallel_gateway" -> { + // 分叉 / 汇合按**出线数**判定(BPMN 语义):多条出线是分叉,单条出线是汇合。 + // 不能只看入线数 —— 分叉点也可能有多条入线(两个审批节点汇到同一个分叉点), + // 那时按入线判会把分叉当汇合,等一个永远不来的分支,流程直接卡死。 + List> outgoing = outgoingEdges(connection, instance.versionId(), nodeKey); + if (outgoing.size() >= 2) { + recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + for (Map edge : outgoing) { + enterNode(connection, instance, text(edge.get("b_to_node_key")), variables, depth + 1); + } + } else { + List> incoming = incomingEdges(connection, instance.versionId(), nodeKey); + if (!allIncomingCompleted(connection, instance, incoming)) { + return; // 汇合:等其余分支跑完 + } + recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + advance(connection, instance, nodeKey, variables, depth + 1); + } + } + case "user_task" -> activateUserTask(connection, instance, node, variables); + case "cc_task" -> { + runCcTask(connection, instance, node, variables); + advance(connection, instance, nodeKey, variables, depth + 1); + } + case "service_task" -> { + // 服务节点不产生待办:记录节点运行后自动完成并继续路由(当前不做外部调用)。 + // 注意动作码是 service —— 此前误标成 cc,会让「服务节点」在动作轨迹里看起来像抄送。 + recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + writeAction(connection, instance, null, null, "service", null, null, "running", + "服务节点「" + text(node.get("b_name")) + "」自动完成", null, null); + advance(connection, instance, nodeKey, variables, depth + 1); + } + case "start" -> advance(connection, instance, nodeKey, variables, depth + 1); + default -> failInstance(connection, instance, "不支持的节点类型:" + nodeType); + } + } + + /** 激活人工节点:解析审批人 → 建 node_run 与任务;解析为空时实例进入 error(§12.4) */ + private void activateUserTask( + Connection connection, + WorkflowInstanceRow instance, + Map node, + Map variables + ) throws SQLException { + String nodeKey = text(node.get("b_node_key")); + String nodeName = text(node.get("b_name")).isEmpty() ? nodeKey : text(node.get("b_name")); + List> rules = WorkflowSql.query(connection, """ + select b_actor_type, b_actor_ref, b_priority, b_fallback_type, b_fallback_ref + from dbo.wf_actor_rule where b_version_id = ? and b_node_key = ? and b_canuse = 1 + order by b_priority, b_id + """, instance.versionId(), nodeKey); + WorkflowActorResolver.Resolved resolved = + actorResolver.resolve(connection, instance.versionId(), nodeKey, variables, instance.initiatorId(), + excludedActors(connection, instance, node)); + String snapshot = actorResolver.snapshotJson(rules, resolved); + + if (resolved.userIds().isEmpty()) { + recordNodeRun(connection, instance, nodeKey, "running", variables, snapshot, 0); + writeAction(connection, instance, null, null, "add_approver", null, "running", "error", + "节点「" + nodeName + "」解析不到审批人:" + String.join(";", resolved.notes()), null, + toJson(Map.of("notes", resolved.notes()))); + failInstance(connection, instance, "节点「" + nodeName + "」解析不到审批人"); + return; + } + + String mode = text(node.get("b_approval_mode")); + if (mode.isEmpty()) { + mode = "single"; + } + int required = requiredCount(mode, node, resolved.userIds().size()); + String nodeRunId = recordNodeRun(connection, instance, nodeKey, "running", variables, snapshot, required); + Integer dueMinutes = dueMinutesOf(node); + + List taskUsers = resolved.userIds(); + if ("single".equals(mode) && taskUsers.size() > 1) { + taskUsers = List.of(taskUsers.get(0)); + } + int seq = 0; + for (String userId : taskUsers) { + seq += 10; + // 串行会签:同一时刻只有第一名是 pending,其余 waiting,通过一位激活下一位(§9.6) + String status = "serial".equals(mode) && seq > 10 ? "waiting" : "pending"; + // 委托(§8.5):创建期就物化到代理人身上,代理人才能在待办列表里看到它。 + // 组织取自当前请求上下文(实例的 b_org_id 与之一致)。 + WorkflowDelegationService.Delegation delegation = delegationService + .activeDelegation(connection, userId, instance.moduleId(), OrgContext.getOrgId()); + String assigneeId = delegation == null ? userId : delegation.toUserId(); + String assigneeType = delegation == null ? "user" : "delegate"; + String taskSnapshot = delegation == null ? snapshot + : taskSnapshotWithDelegation(snapshot, userId, delegation.id()); + WorkflowSql.update(connection, """ + insert into dbo.wf_task + (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_rule_snapshot_json, b_created_at, b_updated_at) + values (?, ?, ?, ?, null, ?, ?, ?, ?, dateadd(minute, ?, sysutcdatetime()), ?, sysutcdatetime(), sysutcdatetime()) + """, UuidV7Generator.nextUuid(), uuidParam(instance.instanceId()), uuidParam(nodeRunId), + nodeKey, assigneeType, assigneeId, seq, status, dueMinutes, taskSnapshot); + writeOutbox(connection, "task.created", "task", instance.instanceId(), + Map.of("nodeKey", nodeKey, "assigneeId", assigneeId)); + } + updateInstanceSummary(connection, instance, "待审批:" + nodeName); + } + + /** 抄送节点:解析抄送人 → 写 wf_cc → 通知 → 自动完成(不产生待办、不参与节点判定) */ + private void runCcTask( + Connection connection, + WorkflowInstanceRow instance, + Map node, + Map variables + ) throws SQLException { + String nodeKey = text(node.get("b_node_key")); + List> rules = WorkflowSql.query(connection, """ + select b_actor_type, b_actor_ref, b_priority, b_fallback_type, b_fallback_ref + from dbo.wf_actor_rule where b_version_id = ? and b_node_key = ? and b_canuse = 1 + order by b_priority, b_id + """, instance.versionId(), nodeKey); + WorkflowActorResolver.Resolved resolved = + // 抄送不参与审批判定,不做职责分离排除 + actorResolver.resolve(connection, instance.versionId(), nodeKey, variables, instance.initiatorId(), + null); + String snapshot = actorResolver.snapshotJson(rules, resolved); + String nodeRunId = recordNodeRun(connection, instance, nodeKey, "completed", variables, snapshot, + resolved.userIds().size()); + + for (String userId : resolved.userIds()) { + WorkflowSql.update(connection, """ + insert into dbo.wf_cc + (b_id, b_instance_id, b_node_run_id, b_node_key, b_recipient_id, b_status, + b_rule_snapshot_json, b_created_at) + values (?, ?, ?, ?, ?, 'unread', ?, sysutcdatetime()) + """, UuidV7Generator.nextUuid(), uuidParam(instance.instanceId()), uuidParam(nodeRunId), + nodeKey, userId, snapshot); + writeOutbox(connection, "cc.created", "instance", instance.instanceId(), Map.of("recipientId", userId)); + } + writeAction(connection, instance, nodeRunId, null, "cc", null, "running", "running", + "抄送「" + text(node.get("b_name")) + "」共 " + resolved.userIds().size() + " 人", null, null); + } + + /** 退回上一节点:找最近一次已完成的人工节点重跑(找不到就退回发起人) */ + private void reenterPreviousNode( + Connection connection, + WorkflowInstanceRow instance, + String fromNodeKey, + Map variables + ) throws SQLException { + Map previous = WorkflowSql.queryOne(connection, """ + select top 1 r.b_node_key + from dbo.wf_node_run r + join dbo.wf_node n on n.b_version_id = ? and n.b_node_key = r.b_node_key + where r.b_instance_id = ? and r.b_node_key <> ? and r.b_status = 'completed' + and n.b_node_type = 'user_task' + order by r.b_entered_at desc + """, instance.versionId(), uuidParam(instance.instanceId()), fromNodeKey); + if (previous == null) { + finishInstance(connection, instance, "returned", "退回发起人(上一节点不存在)"); + return; + } + Map node = WorkflowSql.queryOne(connection, """ + select b_node_key, b_name, b_node_type, b_approval_mode, b_reject_mode, b_due_hours, b_config_json + from dbo.wf_node where b_version_id = ? and b_node_key = ? + """, instance.versionId(), text(previous.get("b_node_key"))); + if (node == null) { + finishInstance(connection, instance, "returned", "退回发起人(上一节点已不存在)"); + return; + } + activateUserTask(connection, instance, node, variables); + } + + /* ============================================================================ + 实例与节点状态维护 + ============================================================================ */ + + /** + * 记录一次节点运行,返回 node_run 主键。 + * run_no 递增支持「退回后重新进入同一节点」(§8.2)。 + */ + private String recordNodeRun( + Connection connection, + WorkflowInstanceRow instance, + String nodeKey, + String status, + Map variables, + String snapshot, + int requiredCount + ) throws SQLException { + int runNo = WorkflowSql.count(connection, + "select count(1) from dbo.wf_node_run where b_instance_id = ? and b_node_key = ?", + uuidParam(instance.instanceId()), nodeKey) + 1; + UUID nodeRunId = UuidV7Generator.nextUuid(); + WorkflowSql.update(connection, """ + insert into dbo.wf_node_run + (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, b_completed_count, + b_rejected_count, b_entered_at, b_exited_at, b_actor_snapshot_json, b_created_at) + values (?, ?, ?, ?, ?, ?, 0, 0, sysutcdatetime(), null, ?, sysutcdatetime()) + """, nodeRunId, uuidParam(instance.instanceId()), nodeKey, runNo, status, + requiredCount <= 0 ? null : requiredCount, snapshot); + // 完成态插入后立刻补 exited_at:状态与完成时间都走 completeNodeRun, + // 时间一律取数据库时钟 sysutcdatetime()(UTC 口径,见 TimeUtils)—— 不混入 Java 本地时间,读写两端一致 + if ("completed".equals(status) || "skipped".equals(status)) { + completeNodeRun(connection, nodeRunId.toString(), status); + } + return nodeRunId.toString(); + } + + private void completeNodeRun(Connection connection, String nodeRunId, String status) throws SQLException { + WorkflowSql.update(connection, """ + update dbo.wf_node_run set b_status = ?, b_exited_at = sysutcdatetime() where b_id = ? + """, status, uuidParam(nodeRunId)); + } + + /** 结束实例:写终态、取消未完成待办、投影业务状态、发通知 */ + private void finishInstance( + Connection connection, + WorkflowInstanceRow instance, + String status, + String nodeName + ) throws SQLException { + cancelAllTasks(connection, instance.instanceId(), "流程已结束(" + status + ")"); + String summary = switch (status) { + case "approved" -> "已通过"; + case "rejected" -> "已驳回"; + case "returned" -> "已退回"; + case "withdrawn" -> "已撤回"; + case "cancelled" -> "已取消"; + default -> status; + }; + updateInstance(connection, instance, + "b_status = ?, b_completed_at = sysutcdatetime(), b_row_version = b_row_version + 1," + + " b_current_summary_json = ?, b_updated_by = ?, b_updated_at = sysutcdatetime()", + status, toJson(Map.of("summary", summary, "node", nodeName == null ? "" : nodeName)), + instance.initiatorId()); + writeAction(connection, instance, null, null, status.equals("approved") ? "approve" : "reject", + null, "running", status, summary, null, null); + writeOutbox(connection, "instance." + status, "instance", instance.instanceId(), Map.of("summary", summary)); + projectBusinessStatus(connection, instance, status); + } + + /** + * 实例锁已持有后,用 UPDLOCK 重读任务并校验状态(及可选归属)。 + * + *

无锁预读拿到的 instance_id 在取到实例锁之前**不可信**:并发的那位审批人可能已经处理掉这条任务。 + * 这里把校验补上,同时把加锁顺序统一为「实例 → 任务」,消除与 {@code withdraw} / + * {@code cancel} / {@code finishInstance}(同样实例优先)之间的 AB-BA 死锁。 + * + * @param ownerUserId 非 null 时要求任务归属该用户(领取/转交场景);{@code null} 时跳过归属校验(抢占场景) + */ + private Map lockTask( + Connection connection, + String taskId, + String instanceId, + Set allowedStatuses, + String ownerUserId + ) throws SQLException { + Map task = WorkflowSql.queryOne(connection, """ + select b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_seq, b_status, b_due_at, b_assignee_id, b_claimed_by, b_rule_snapshot_json + from dbo.wf_task with (UPDLOCK, ROWLOCK) + where b_id = ? + """, uuidParam(taskId)); + if (task == null) { + throw new BusinessException("任务不存在或已被删除"); + } + if (!instanceId.equals(text(task.get("b_instance_id")))) { + throw new BusinessException("任务不属于该流程实例"); + } + String status = text(task.get("b_status")); + if (!allowedStatuses.contains(status)) { + throw new BusinessException("任务已被处理或状态不允许操作(当前:" + status + ")"); + } + if (ownerUserId != null) { + String assignee = text(task.get("b_assignee_id")); + String claimedBy = text(task.get("b_claimed_by")); + if (!ownerUserId.equals(assignee) && !ownerUserId.equals(claimedBy)) { + throw new BusinessException("该任务不属于当前用户"); + } + } + return task; + } + + /** + * 统一的实例更新入口(乐观锁,§12.2)。 + * + *

SET 子句由调用方提供(各自负责写 {@code b_row_version = b_row_version + 1}), + * 这里统一追加谓词 {@code b_id = ? and b_row_version = ?} 并校验影响行数,成功后推进内存版本。 + * + *

落到 0 行说明实例被并发推进。本事务内理论上不会发生({@code lockInstance} 持 UPDLOCK), + * 这道谓词既是 §12.2 的契约校验,也为将来「不先取锁就更新」的写入路径兜底。 + */ + private void updateInstance(Connection connection, WorkflowInstanceRow instance, String setClause, Object... args) + throws SQLException { + List params = new ArrayList<>(); + for (Object arg : args) { + params.add(arg); + } + params.add(uuidParam(instance.instanceId())); + params.add(instance.rowVersion()); + int updated = WorkflowSql.update(connection, + "update dbo.wf_instance set " + setClause + " where b_id = ? and b_row_version = ?", + params.toArray()); + if (updated != 1) { + throw new BusinessException("流程实例已被并发推进(版本不匹配),请刷新后重试"); + } + instance.rowVersion(instance.rowVersion() + 1); + } + + /** 实例进入 error:不静默跳过,等管理员用加签 / 转派补救(§12.4) */ + private void failInstance(Connection connection, WorkflowInstanceRow instance, String message) throws SQLException { + updateInstance(connection, instance, + "b_status = 'error', b_row_version = b_row_version + 1," + + " b_current_summary_json = ?, b_updated_by = ?, b_updated_at = sysutcdatetime()", + toJson(Map.of("summary", message)), instance.initiatorId()); + writeOutbox(connection, "instance.error", "instance", instance.instanceId(), Map.of("message", message)); + projectBusinessStatus(connection, instance, "error"); + } + + private void updateInstanceSummary(Connection connection, WorkflowInstanceRow instance, String summary) + throws SQLException { + updateInstance(connection, instance, + "b_current_summary_json = ?, b_row_version = b_row_version + 1," + + " b_updated_by = ?, b_updated_at = sysutcdatetime()", + toJson(Map.of("summary", summary)), instance.initiatorId()); + } + + /** 取消同一节点下的其余未完成待办(首票 / 驳回后自动作废,写系统动作留痕) */ + private void cancelSiblingTasks( + Connection connection, + WorkflowInstanceRow instance, + String nodeRunId, + String exceptTaskId, + String reason + ) throws SQLException { + List> siblings = WorkflowSql.query(connection, """ + select b_id from dbo.wf_task with (UPDLOCK, ROWLOCK) + where b_node_run_id = ? and b_status in ('pending', 'claimed', 'waiting') and b_id <> ? + """, uuidParam(nodeRunId), uuidParam(exceptTaskId)); + for (Map row : siblings) { + WorkflowSql.update(connection, + "update dbo.wf_task set b_status = 'cancelled', b_updated_at = sysutcdatetime() where b_id = ?", + uuidParam(text(row.get("b_id")))); + } + if (!siblings.isEmpty()) { + writeAction(connection, instance, nodeRunId, null, "cancel", null, "pending", "cancelled", + reason + "(" + siblings.size() + " 条)", null, null); + } + } + + /** + * 串行会签激活(§9.6):把 (b_seq, b_id) 最小的 waiting 任务翻成 pending,使其进入待办。 + * + *

只在节点「尚未凑够票数」的分支调用,与节点完成时调用的 {@link #cancelSiblingTasks} + * 处于互斥分支,不会互相抢同一批任务;非串行节点不产生 waiting 任务,调用是无副作用的空转。 + */ + private void promoteNextSerialTask( + Connection connection, + WorkflowInstanceRow instance, + String nodeRunId, + String nodeKey + ) throws SQLException { + Map next = WorkflowSql.queryOne(connection, """ + select top 1 b_id, b_assignee_id + from dbo.wf_task with (UPDLOCK, ROWLOCK) + where b_node_run_id = ? and b_status = 'waiting' + order by b_seq, b_id + """, uuidParam(nodeRunId)); + if (next == null) { + return; + } + String nextTaskId = text(next.get("b_id")); + String nextAssignee = text(next.get("b_assignee_id")); + WorkflowSql.update(connection, """ + update dbo.wf_task + set b_status = 'pending', b_updated_at = sysutcdatetime() + where b_id = ? + """, uuidParam(nextTaskId)); + writeAction(connection, instance, nodeRunId, nextTaskId, "activate", null, "waiting", "pending", + "串行会签:激活下一位审批人 " + nextAssignee, null, null); + Map payload = new LinkedHashMap<>(); + payload.put("nodeKey", nodeKey); + payload.put("taskId", nextTaskId); + payload.put("assigneeId", nextAssignee); + writeOutbox(connection, "task.activated", "task", instance.instanceId(), payload); + } + + /** + * 从 node_run 的审批人快照取候选审批人(快照结构见 {@code WorkflowActorResolver.snapshotJson}: + * {@code {"rules":[...],"resolvedUsers":[...],"notes":[...]}})。 + * + *

解析不出来时返回空集合 —— 调用方(抢占归属校验)按 fail-closed 处理。 + */ + /** 把「代某人审批」并进任务快照:动作期据此复核委托范围并留痕(谁在代谁)。 */ + private String taskSnapshotWithDelegation(String nodeSnapshot, String delegatedFrom, String delegationId) { + Map merged = new LinkedHashMap<>(); + merged.put("delegatedFrom", delegatedFrom); + merged.put("delegationId", delegationId); + merged.put("node", WorkflowJson.readMapOrEmpty(nodeSnapshot, "task.snapshot")); + return toJson(merged); + } + + /** + * 动作期复核委托范围(§8.5):创建期只能判模块 / 组织,{@code actions} 要到动作真正发生才能判, + * 所以这里再查一次委托单并校验该动作是否在授权范围内。 + */ + private void verifyDelegation(Connection connection, Map task, String action) + throws SQLException { + Map snapshot = WorkflowJson.readMapOrEmpty(task.get("b_rule_snapshot_json"), + "task.snapshot"); + String delegationId = text(snapshot.get("delegationId")); + if (delegationId.isEmpty()) { + return; + } + Map row = WorkflowSql.queryOne(connection, + "select b_scope_json from dbo.wf_delegation where b_id = ?", delegationId); + Map scope = row == null ? null + : WorkflowJson.readMapOrNull(row.get("b_scope_json"), "delegation.scope:" + delegationId); + if (!delegationService.allowsAction(scope, action)) { + throw new BusinessException("该委托未授权「" + action + "」动作"); + } + } + + private List candidatesOf(Object snapshotJson) { + Map snapshot = WorkflowJson.readMapOrEmpty(snapshotJson, "nodeRun.actorSnapshot"); + if (!(snapshot.get("resolvedUsers") instanceof List users)) { + return List.of(); + } + List candidates = new ArrayList<>(); + for (Object user : users) { + if (user != null && !String.valueOf(user).isBlank()) { + candidates.add(String.valueOf(user)); + } + } + return candidates; + } + + private void cancelAllTasks(Connection connection, String instanceId, String reason) throws SQLException { + WorkflowSql.update(connection, """ + update dbo.wf_task set b_status = 'cancelled', b_updated_at = sysutcdatetime() + where b_instance_id = ? and b_status in ('pending', 'claimed', 'waiting') + """, uuidParam(instanceId)); + } + + /* ============================================================================ + 绑定匹配与业务状态投影 + ============================================================================ */ + + /** 绑定匹配:模块 + 事件 + 生效期 + 匹配条件,按 b_priority 取第一条命中(§6.3 / §4.3) */ + private Map matchBinding( + Connection connection, + String moduleId, + String event, + Map context + ) throws SQLException { + List> bindings = WorkflowSql.query(connection, """ + select b.b_id, b.b_version_id, b.b_status_field, b.b_status_map_json, b.b_match_json, b.b_priority, + v.b_definition_id, v.b_status as version_status + from dbo.wf_binding b + join dbo.wf_version v on v.b_id = b.b_version_id + where b.b_module_id = ? and b.b_event = ? and b.b_canuse = 1 + and (b.b_effective_from is null or b.b_effective_from <= sysutcdatetime()) + and (b.b_effective_to is null or b.b_effective_to > sysutcdatetime()) + order by b.b_priority, b.b_id + """, moduleId, event); + for (Map binding : bindings) { + if (!"published".equals(text(binding.get("version_status")))) { + continue; // 停用版本不再接受新实例 + } + if (!WorkflowConditionEvaluator.evaluate( + WorkflowJson.readMapOrNull(binding.get("b_match_json"), + "binding.match:" + text(binding.get("b_id"))), + context)) { + continue; + } + return binding; + } + throw new BusinessException("模块「" + moduleId + "」的事件「" + event + "」没有匹配到流程绑定"); + } + + /** + * 业务状态投影(§11):按绑定的 b_status_field + b_status_map_json 更新业务表状态列。 + * 只读模块(b_save_table 为空)或未映射到该状态时跳过,不算失败。 + */ + private void projectBusinessStatus(Connection connection, WorkflowInstanceRow instance, String status) + throws SQLException { + Map binding = WorkflowSql.queryOne(connection, """ + select b_status_field, b_status_map_json from dbo.wf_binding where b_id = ? + """, instance.bindingId()); + if (binding == null) { + return; + } + String statusField = text(binding.get("b_status_field")); + if (statusField.isEmpty()) { + return; + } + Map mapping = WorkflowJson.readMapOrNull(binding.get("b_status_map_json"), + "binding.statusMap:" + instance.bindingId()); + String target = text(mapping.get(status)); + if (target.isEmpty()) { + return; + } + Map module = WorkflowSql.queryOne(connection, + "select b_save_table, b_key_field from dbo.s_module where b_id = ?", instance.moduleId()); + if (module == null) { + return; + } + String saveTable = text(module.get("b_save_table")); + String keyField = text(module.get("b_key_field")); + if (saveTable.isEmpty() || keyField.isEmpty()) { + return; + } + if (!SAFE_IDENTIFIER.matcher(saveTable).matches() || !SAFE_IDENTIFIER.matcher(keyField).matches() + || !SAFE_IDENTIFIER.matcher(statusField).matches()) { + throw new BusinessException("业务状态投影的表名 / 字段名不合法:" + saveTable + "." + statusField); + } + // 业务主键可能是 bigint 雪花(工作流里统一按字符串保存):值不是数字时跳过投影并留痕, + // 不要让「配置不匹配」把整个审批事务打回滚(投影是副作用,状态已在实例上落定) + if (!isTextKey(connection, saveTable, keyField) && !isDigits(instance.businessId())) { + writeOutbox(connection, "instance.projection_skipped", "instance", instance.instanceId(), + Map.of("reason", "业务主键不是数字,无法匹配列类型", "businessId", instance.businessId(), + "target", saveTable + "." + keyField)); + return; + } + // 读投影前的旧值,供审计留痕(§11 要求记录字段级前后值) + Object before = WorkflowSql.scalar(connection, + "select [" + statusField + "] from dbo.[" + saveTable + "] where [" + keyField + "] = ?", + instance.businessId()); + + // 业务表若带 b_row_version,投影时一并自增:否则工作流改了这一行却不动版本, + // 会让通用保存路径基于 $original / b_row_version 的并发校验读到陈旧版本而误判为无冲突。 + boolean hasVersionColumn = WorkflowSql.columnExists(connection, "dbo." + saveTable, "b_row_version"); + int updated = hasVersionColumn + ? WorkflowSql.update(connection, + "update dbo.[" + saveTable + "]" + + " set [" + statusField + "] = ?, [b_row_version] = [b_row_version] + 1" + + " where [" + keyField + "] = ?", + target, instance.businessId()) + : WorkflowSql.update(connection, + "update dbo.[" + saveTable + "] set [" + statusField + "] = ? where [" + keyField + "] = ?", + target, instance.businessId()); + + // 投影未命中恰好 1 行:并发编辑 / 记录不存在 / 键类型不匹配。 + // 投影是副作用且实例状态已落定,这里只留痕不回滚(运行时语义见 sql/README)。 + if (updated != 1) { + Map payload = new LinkedHashMap<>(); + payload.put("reason", "业务状态投影影响行数=" + updated); + payload.put("target", saveTable + "." + keyField + " / " + statusField); + payload.put("businessId", instance.businessId()); + payload.put("hasVersionColumn", hasVersionColumn); + writeOutbox(connection, "instance.projection_missing", "instance", instance.instanceId(), payload); + return; + } + writeAudit(connection, instance, statusField, + before == null ? null : String.valueOf(before), target); + } + + /** + * 审计探写(§11):{@code s_log_audit} 存在则写一条事件 + 一条字段级变更, + * 不存在则写一条 outbox 并继续 —— **审计缺失绝不阻断审批**(与工作流侧同样的降级策略)。 + */ + private void writeAudit(Connection connection, WorkflowInstanceRow instance, String field, + String beforeValue, String afterValue) throws SQLException { + if (!WorkflowSql.tableExists(connection, "dbo.s_log_audit")) { + writeOutbox(connection, "audit.skipped", "instance", instance.instanceId(), + Map.of("reason", "s_log_audit 不存在,投影审计未落库", "field", field)); + return; + } + String eventId = UuidV7Generator.nextUuid().toString(); + WorkflowSql.update(connection, """ + insert into dbo.s_log_audit + (b_id, b_event_code, b_module_id, b_data_id, b_business_no, b_operation, + b_operator_id, b_source_type, b_visibility, b_payload_text, b_occurdatetime) + values (?, 'workflow.projection', ?, ?, ?, '审批状态投影', ?, 'system', 'internal', ?, + sysutcdatetime()) + """, uuidParam(eventId), instance.moduleId(), instance.businessId(), instance.businessNo(), + instance.initiatorId(), toJson(Map.of( + "instanceId", instance.instanceId(), + "field", field, + "before", beforeValue == null ? "" : beforeValue, + "after", afterValue == null ? "" : afterValue))); + WorkflowSql.update(connection, """ + insert into dbo.s_log_audit_field + (b_id, b_event_id, b_module_id, b_data_id, b_field, b_before_value, b_after_value, b_visibility) + values (?, ?, ?, ?, ?, ?, ?, 'internal') + """, UuidV7Generator.nextUuid(), uuidParam(eventId), instance.moduleId(), + instance.businessId(), field, beforeValue, afterValue); + } + + /** 目标键列是否为字符类型(决定字符串业务主键能不能直接比) */ + private boolean isTextKey(Connection connection, String table, String column) throws SQLException { + Object typeName = WorkflowSql.scalar(connection, """ + select t.name from sys.columns c + join sys.types t on t.user_type_id = c.user_type_id + where c.object_id = object_id(?) and c.name = ? + """, "dbo." + table, column); + String name = text(typeName).toLowerCase(); + return name.contains("char") || name.contains("text"); + } + + private boolean isDigits(String value) { + if (value == null || value.isEmpty()) { + return false; + } + for (int index = 0; index < value.length(); index++) { + if (!Character.isDigit(value.charAt(index))) { + return false; + } + } + return true; + } + + /* ============================================================================ + 权限、审计与结果集 + ============================================================================ */ + + /** + * 动作权限校验(§10.1 第一层):白名单语义 —— s_power 里没有对应权限点即拒绝。 + * 权限点编码统一为 {@code action.{模块}.{动作}}(§10.2)。 + */ + private void requireAction(Connection connection, String moduleId, String userId, String action) + throws SQLException { + if (ADMIN_ACCOUNT.equalsIgnoreCase(userId)) { + return; + } + String powerId = "action." + moduleId + "." + action; + Map row = WorkflowSql.queryOne(connection, """ + select top 1 p.b_id + from dbo.s_power p + join dbo.s_user_power up on up.b_power_id = p.b_id and up.b_canuse = 1 + where p.b_id = ? and p.b_canuse = 1 and up.b_user_id = ? + """, powerId, userId); + if (row == null) { + throw new BusinessException("缺少动作权限「" + powerId + "」,请联系管理员在用户权限里授权"); + } + } + + private void requireEnabledUser(Connection connection, String userId) throws SQLException { + Object exists = WorkflowSql.scalar(connection, + "select top 1 b_id from dbo.b_user where b_id = ? and b_canuse = 1", userId); + if (exists == null) { + throw new BusinessException("用户「" + userId + "」不存在或已停用"); + } + } + + private void writeAction( + Connection connection, + WorkflowInstanceRow instance, + String nodeRunId, + String taskId, + String action, + String operatorId, + String fromStatus, + String toStatus, + String comment, + String requestId, + String payloadJson + ) throws SQLException { + WorkflowSql.update(connection, """ + insert into dbo.wf_action + (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, b_from_status, + b_to_status, b_comment, b_payload_json, b_request_id, b_occurdatetime) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, sysutcdatetime()) + """, UuidV7Generator.nextUuid(), uuidParam(instance.instanceId()), + nodeRunId == null ? null : uuidParam(nodeRunId), + taskId == null ? null : uuidParam(taskId), + action, operatorId == null ? null : operatorId, + fromStatus, toStatus, comment == null ? null : comment, + payloadJson, requestId == null || requestId.isEmpty() ? null : requestId); + } + + private void writeOutbox( + Connection connection, + String eventCode, + String aggregateType, + String aggregateId, + Map payload + ) throws SQLException { + WorkflowSql.update(connection, """ + insert into dbo.wf_outbox + (b_id, b_event_code, b_aggregate_type, b_aggregate_id, b_payload_json, b_status, b_retry_count, + b_occurdatetime) + values (?, ?, ?, ?, ?, 'pending', 0, sysutcdatetime()) + """, UuidV7Generator.nextUuid(), eventCode, aggregateType, aggregateId, toJson(payload)); + } + + private Map submittedResult(Connection connection, String instanceId, boolean duplicated) + throws SQLException { + Map row = WorkflowSql.queryOne(connection, """ + select b_status, b_current_summary_json from dbo.wf_instance where b_id = ? + """, uuidParam(instanceId)); + int tasks = WorkflowSql.count(connection, + "select count(1) from dbo.wf_task where b_instance_id = ? and b_status in ('pending', 'claimed')", + uuidParam(instanceId)); + Map result = new LinkedHashMap<>(); + result.put("instanceId", instanceId); + result.put("status", row == null ? null : text(row.get("b_status"))); + result.put("currentSummary", row == null ? null + : WorkflowJson.readMapOrEmpty(row.get("b_current_summary_json"), "instance.currentSummary")); + result.put("pendingTaskCount", tasks); + result.put("duplicated", duplicated); + return result; + } + + private String currentStatus(Connection connection, String instanceId) throws SQLException { + return text(WorkflowSql.scalar(connection, "select b_status from dbo.wf_instance where b_id = ?", + uuidParam(instanceId))); + } + + /* ============================================================================ + 通用小工具 + ============================================================================ */ + + private WorkflowInstanceRow lockInstance(Connection connection, String instanceId) throws SQLException { + Map row = WorkflowSql.queryOne(connection, """ + select b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_business_id, b_business_no, + b_initiator_id, b_initiator_dept_id, b_status, b_row_version + from dbo.wf_instance with (UPDLOCK, ROWLOCK) + where b_id = ? + """, uuidParam(instanceId)); + if (row == null) { + throw new BusinessException("流程实例不存在"); + } + return new WorkflowInstanceRow( + text(row.get("b_id")), text(row.get("b_definition_id")), text(row.get("b_version_id")), + text(row.get("b_binding_id")), text(row.get("b_module_id")), text(row.get("b_business_id")), + text(row.get("b_business_no")), text(row.get("b_initiator_id")), text(row.get("b_initiator_dept_id")), + text(row.get("b_status")), + row.get("b_row_version") instanceof Number number ? number.longValue() : 0 + ); + } + + private Map readVariables(Connection connection, String instanceId) throws SQLException { + Object json = WorkflowSql.scalar(connection, + "select b_context_json from dbo.wf_instance where b_id = ?", uuidParam(instanceId)); + return WorkflowJson.readMapOrEmpty(json, "instance.context"); + } + + private List> outgoingEdges(Connection connection, String versionId, String nodeKey) + throws SQLException { + return WorkflowSql.query(connection, """ + select b_id, b_to_node_key, b_condition_json from dbo.wf_edge + where b_version_id = ? and b_from_node_key = ? and b_canuse = 1 + order by b_priority, b_id + """, versionId, nodeKey); + } + + private List> incomingEdges(Connection connection, String versionId, String nodeKey) + throws SQLException { + return WorkflowSql.query(connection, """ + select b_id, b_from_node_key, b_condition_json from dbo.wf_edge + where b_version_id = ? and b_to_node_key = ? and b_canuse = 1 + order by b_priority, b_id + """, versionId, nodeKey); + } + + /** 选边:按 b_priority 顺序,第一条条件命中(或无条件)即用(§6.5) */ + /** 选边:按 b_priority 逐条求值,第一条命中(或无条件)即用(§6.5);同时把每条出线的求值结果写进 trace 供留痕。 */ + private Map chooseEdge(List> edges, Map variables, + List> trace) { + for (Map edge : edges) { + Map condition = WorkflowJson.readMapOrNull(edge.get("b_condition_json"), + "edge.cond:" + text(edge.get("b_id"))); + boolean matched = WorkflowConditionEvaluator.evaluate(condition, variables); + Map step = new LinkedHashMap<>(); + step.put("edgeId", text(edge.get("b_id"))); + step.put("to", text(edge.get("b_to_node_key"))); + step.put("default", condition == null || condition.isEmpty()); + step.put("matched", matched); + trace.add(step); + if (matched) { + return edge; + } + } + return null; + } + + /** 并行汇合判定:所有入线来源节点的最新一次运行都已完成 */ + private boolean allIncomingCompleted( + Connection connection, + WorkflowInstanceRow instance, + List> incoming + ) throws SQLException { + for (Map edge : incoming) { + Object status = WorkflowSql.scalar(connection, """ + select top 1 b_status from dbo.wf_node_run + where b_instance_id = ? and b_node_key = ? + order by b_run_no desc + """, uuidParam(instance.instanceId()), text(edge.get("b_from_node_key"))); + if (!"completed".equals(text(status))) { + return false; + } + } + return true; + } + + /** + * 应签人数:any / single 为 1;all / serial 为候选人数;n_of_m 用 requiredCount;percentage 按比例进位。 + * + *

三类配置值都按**实际候选人数收敛**:候选人会因为「同一人多规则去重」「不存在的用户被剔除」 + * 「解析为空走 fallback」而变少,配置的应签人数若高于实际候选数,节点就永远等不到足够票数。 + */ + private int requiredCount(String mode, Map node, int candidateCount) { + Map config = WorkflowJson.readMapOrNull(node.get("b_config_json"), + "node.config:" + text(node.get("b_node_key"))); + int candidates = Math.max(candidateCount, 1); + return switch (mode) { + case "all", "serial" -> candidates; + case "n_of_m" -> Math.min(Math.max((int) number(config.get("requiredCount"), 1), 1), candidates); + case "percentage" -> { + double ratio = number(config.get("requiredRatio"), 0); + int count = BigDecimal.valueOf(candidates) + .multiply(BigDecimal.valueOf(ratio)) + .setScale(0, RoundingMode.CEILING) + .intValue(); + yield Math.min(Math.max(count, 1), candidates); + } + default -> 1; + }; + } + + /** 审批时限 → 分钟;没配置返回 null(不设应处理时间)。时间基准由数据库 sysutcdatetime() 承担 */ + private Integer dueMinutesOf(Map node) { + double hours = number(node.get("b_due_hours"), 0); + return hours <= 0 ? null : (int) Math.round(hours * 60); + } + + private double number(Object value, double fallback) { + if (value instanceof Number number) { + return number.doubleValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Double.parseDouble(text.trim()); + } catch (NumberFormatException ignored) { + return fallback; + } + } + return fallback; + } + + /** + * 职责分离(§10.3):按节点配置算出本节点要排除的候选。 + * + *

    + *
  • {@code allowInitiator} 显式为 false → 排除发起人(缺省允许);
  • + *
  • {@code excludeCreator} 为 true → 排除创建人。业务表的创建人字段尚未统一, + * 暂以实例发起人近似,待业务表口径统一后再换成真实创建人;
  • + *
  • {@code excludePreviousActors} 为 true → 排除本实例此前节点运行记录里出现过的人 + * (解析发生在 recordNodeRun 之前,所以查到的天然都是「此前」的人)。
  • + *
+ */ + private Set excludedActors(Connection connection, WorkflowInstanceRow instance, Map node) + throws SQLException { + Map config = WorkflowJson.readMapOrNull(node.get("b_config_json"), + "node.config:" + text(node.get("b_node_key"))); + Set excluded = new LinkedHashSet<>(); + if (config == null || config.isEmpty()) { + return excluded; + } + if (Boolean.FALSE.equals(configFlagOrNull(config, "allowInitiator")) + || Boolean.TRUE.equals(configFlagOrNull(config, "excludeCreator"))) { + excluded.add(instance.initiatorId()); + } + if (Boolean.TRUE.equals(configFlagOrNull(config, "excludePreviousActors"))) { + List> runs = WorkflowSql.query(connection, """ + select b_actor_snapshot_json from dbo.wf_node_run where b_instance_id = ? + """, uuidParam(instance.instanceId())); + for (Map run : runs) { + excluded.addAll(candidatesOf(run.get("b_actor_snapshot_json"))); + } + } + excluded.remove(""); + return excluded; + } + + /** 三态读布尔配置:显式为 true / false 才返回,缺失或无法识别返回 null(用于区分「没配」与「配了 false」)。 */ + private Boolean configFlagOrNull(Map config, String key) { + Object value = config.get(key); + if (value instanceof Boolean flag) { + return flag; + } + if (value instanceof Number number) { + return number.intValue() != 0; + } + if (value instanceof String string) { + String trimmed = string.trim(); + if (trimmed.equalsIgnoreCase("true")) { + return Boolean.TRUE; + } + if (trimmed.equalsIgnoreCase("false")) { + return Boolean.FALSE; + } + } + return null; + } + + private boolean configFlag(Map config, String key) { + Object value = config == null ? null : config.get(key); + if (value instanceof Boolean flag) { + return flag; + } + if (value instanceof Number number) { + return number.intValue() != 0; + } + return value instanceof String text && Boolean.parseBoolean(text.trim()); + } + + private Object uuidParam(String value) { + String text = text(value); + if (text.isEmpty()) { + throw new BusinessException("缺少实例 / 任务主键"); + } + try { + return UUID.fromString(text); + } catch (IllegalArgumentException exception) { + throw new BusinessException("主键格式不合法:" + text); + } + } + + @SuppressWarnings("unchecked") + private Map mapOf(Object value) { + return value instanceof Map map ? (Map) map : new LinkedHashMap<>(); + } + + @SuppressWarnings("unchecked") + private Map castMap(Map map) { + return (Map) map; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (RuntimeException exception) { + throw new BusinessException("生成 JSON 失败:" + exception.getMessage()); + } + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + /** 实例运行上下文(一次动作内复用,避免反复回表) */ + /** + * 实例行的进程内快照。 + * + *

{@code rowVersion} 刻意**可变**:一个事务里可能对同一实例做多次更新 + * (例如 activateUserTask 写摘要、随后 failInstance 改状态),每次成功更新都必须推进内存版本, + * 否则第二次更新会拿自己的旧版本号去对上一条 UPDATE 已经 +1 的值而误判「并发推进」。 + * 见 {@code updateInstance}。 + */ +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowSql.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowSql.java new file mode 100644 index 00000000..3c829ec3 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowSql.java @@ -0,0 +1,97 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.util.TimeUtils; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 工作流运行侧的 SQL 小工具:把「手写 SQL + PreparedStatement + 行列转 Map」这段样板收在一处。 + * + *

只服务工作流两个运行侧服务(进程推进与运行查询),不对外暴露: + * 表名与列名一律写在调用处的常量字符串里,不存在动态拼接标识符,天然无注入面。 + */ +final class WorkflowSql { + + private WorkflowSql() { + } + + static List> query(Connection connection, String sql, Object... args) + throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + ResultSetMetaData metadata = resultSet.getMetaData(); + List> rows = new ArrayList<>(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + for (int index = 1; index <= metadata.getColumnCount(); index++) { + // 时间列统一按 UTC 出口(带 Z 的 ISO 串),与 DbUtils 同口径 + row.put( + metadata.getColumnLabel(index), + TimeUtils.toApiValue(resultSet.getObject(index)) + ); + } + rows.add(row); + } + return rows; + } + } + } + + static Map queryOne(Connection connection, String sql, Object... args) + throws SQLException { + List> rows = query(connection, sql, args); + return rows.isEmpty() ? null : rows.get(0); + } + + static int update(Connection connection, String sql, Object... args) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + return statement.executeUpdate(); + } + } + + /** 单值查询:取第一行第一列,常用于 count / max / 单字段回读 */ + static Object scalar(Connection connection, String sql, Object... args) throws SQLException { + Map row = queryOne(connection, sql, args); + if (row == null || row.isEmpty()) { + return null; + } + return row.values().iterator().next(); + } + + static int count(Connection connection, String sql, Object... args) throws SQLException { + Object value = scalar(connection, sql, args); + return value instanceof Number number ? number.intValue() : 0; + } + + /** 表是否存在(机构库结构可能不同,组织主数据相关解析靠它做降级判断) */ + static boolean tableExists(Connection connection, String qualifiedName) throws SQLException { + Object value = scalar(connection, + "select top 1 1 as ok from sys.tables where object_id = object_id(?)", qualifiedName); + return value != null; + } + + /** 列是否存在(如 b_dept.b_manager_id、b_user.b_leader_id 属于可选的组织主数据扩展) */ + static boolean columnExists(Connection connection, String qualifiedName, String column) + throws SQLException { + Object value = scalar(connection, + "select top 1 1 as ok from sys.columns where object_id = object_id(?) and name = ?", + qualifiedName, column); + return value != null; + } + + private static void bind(PreparedStatement statement, Object... args) throws SQLException { + for (int index = 0; index < args.length; index++) { + statement.setObject(index + 1, args[index]); + } + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowTx.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowTx.java new file mode 100644 index 00000000..6a84144b --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowTx.java @@ -0,0 +1,111 @@ +package cn.g3soft.erp.service.workflow; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +/** + * 工作流事务模板:把「取连接 → 关自动提交 → 执行 → 提交 / 回滚 → 还原自动提交」这段样板收在一处。 + * + *

全项目不使用 {@code @Transactional}(与 {@code DataSaveService} 同口径),事务边界由服务层手动控制; + * 本类只负责消除重复,不改变语义。 + * + *

约定: + *

    + *
  • 回调里**不要自己 commit / rollback**,提交与回滚由模板负责;
  • + *
  • 回调抛出的 {@code SQLException} / {@code RuntimeException} 一律先回滚再原样抛出;
  • + *
  • 回滚失败会以 suppressed 形式挂在原异常上,不掩盖根因;
  • + *
  • 还原自动提交失败不抛(连接即将归还连接池,由池复位)。
  • + *
+ */ +final class WorkflowTx { + + private WorkflowTx() { + } + + @FunctionalInterface + interface Callback { + T apply(Connection connection) throws SQLException; + } + + /** + * SQL 异常恢复钩子:在**回滚之后**调用,用于「幂等键撞唯一索引 → 查出既有单据并返回重复结果」这类语义。 + * + *

之所以做成钩子而不是模板内建,是因为恢复动作的类型和查询各接口不同; + * 但**调用时机是固定的**:必须在回滚之后、同一条连接上(autocommit 仍为 false)执行。 + * + * @return {@code null} 表示「不是可恢复的情形」,模板会原样抛出原异常 + */ + @FunctionalInterface + interface SqlErrorHandler { + T onSqlError(Connection connection, SQLException cause) throws SQLException; + } + + /** 常规事务:SQL 异常一律回滚后原样抛出。 */ + static T run(DataSource dataSource, Callback callback) throws SQLException { + return run(dataSource, callback, (connection, cause) -> null); + } + + /** + * 带 SQL 异常恢复的事务。 + * + * @param onSqlError 回滚之后调用;返回非 null 即作为最终结果返回,返回 null 则抛出原异常 + */ + static T run(DataSource dataSource, Callback callback, SqlErrorHandler onSqlError) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + T result = callback.apply(connection); + connection.commit(); + return result; + } catch (SQLException exception) { + rollback(connection, exception); + T recovered = onSqlError.onSqlError(connection, exception); + if (recovered != null) { + return recovered; + } + throw exception; + } catch (RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + restoreAutoCommit(connection, originalAutoCommit); + } + } + } + + /** + * 是否为唯一键 / 主键冲突(SQL Server:2601 唯一索引、2627 唯一约束、23000 完整性约束)。 + * + *

幂等链路的幂等性依赖「DB 层真的拦住重复行」,因此这里判的是错误码而不是回查结果。 + */ + static boolean isUniqueViolation(SQLException cause) { + SQLException current = cause; + while (current != null) { + int code = current.getErrorCode(); + if (code == 2601 || code == 2627 || code == 23000) { + return true; + } + current = current.getNextException(); + } + return false; + } + + private static void rollback(Connection connection, Exception cause) { + try { + connection.rollback(); + } catch (SQLException rollbackFailure) { + cause.addSuppressed(rollbackFailure); + } + } + + private static void restoreAutoCommit(Connection connection, boolean originalAutoCommit) { + try { + connection.setAutoCommit(originalAutoCommit); + } catch (SQLException ignored) { + // 连接归还时由连接池复位,这里失败不影响主流程 + } + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowValidator.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowValidator.java new file mode 100644 index 00000000..86a26b5d --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowValidator.java @@ -0,0 +1,402 @@ +package cn.g3soft.erp.service.workflow; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 工作流发布校验 —— 纯逻辑,不碰数据库(设计 §6.2 / §17 / §19.6)。 + * + *

前端 {@code designer/domain.js} 的即时校验只提升体验,这里是最终规则: + * 保存草稿时只报错不阻断(草稿允许不完整),发布时必须零 error。 + * + *

error / warn 分级:设计文档未给分级表,这里按「是否会导致流程运行卡死或不可解释」划分 —— + * 会让实例走不下去或走错的一律 error(不可达、无出口、缺审批人、条件不合法、死循环), + * 只影响可读性/健壮性的给 warn(并行分支只挂一条出线、条件挂在非条件分支上)。 + */ +public final class WorkflowValidator { + + public static final String LEVEL_ERROR = "error"; + public static final String LEVEL_WARN = "warn"; + + private static final Set NODE_TYPES = Set.of( + "start", "user_task", "cc_task", "exclusive_gateway", "parallel_gateway", "service_task", "end" + ); + /** 需要配置审批人/抄送人规则的节点类型(§6.6 / §9.9) */ + private static final Set ACTOR_NODE_TYPES = Set.of("user_task", "cc_task"); + private static final Set ACTOR_TYPES = Set.of( + "user", "initiator", "initiator_manager", "dept_manager", "position", "org_role", "field_user", "custom" + ); + /** 必须填 b_actor_ref 的解析类型,其余靠上下文解析(§6.6) */ + private static final Set ACTOR_TYPES_WITH_REF = Set.of( + "user", "position", "org_role", "field_user", "custom" + ); + /** 审批人解析为空时的兜底类型(§12.4) */ + private static final Set FALLBACK_TYPES = Set.of("none", "admin", "initiator", "custom"); + private static final Set APPROVAL_MODES = Set.of( + "single", "any", "all", "serial", "n_of_m", "percentage" + ); + private static final Set REJECT_MODES = Set.of( + "terminate", "return_initiator", "return_previous" + ); + private static final Set OPERATORS = Set.of( + "eq", "ne", "gt", "ge", "lt", "le", "in", "not_in", "like", "is_null", "is_not_null", "between" + ); + private static final Set OPERATORS_WITHOUT_VALUE = Set.of("is_null", "is_not_null"); + /** 条件组合节点(§7.1):值为子条件数组,not 为单个子条件对象 */ + private static final Set LOGIC_KEYS = Set.of("all", "any", "not"); + + public record Issue(String level, String message, String nodeKey) { + + static Issue error(String message, String nodeKey) { + return new Issue(LEVEL_ERROR, message, nodeKey); + } + + static Issue warn(String message, String nodeKey) { + return new Issue(LEVEL_WARN, message, nodeKey); + } + } + + private WorkflowValidator() { + } + + /** + * 校验领域草稿。 + * + * @param draft 领域草稿 + * @param allowedConditionFields 允许在条件里出现的业务字段(该流程绑定模块的 s_field.b_field); + * 传 null 表示当前没有绑定上下文,跳过字段存在性校验 + */ + public static List validate(WorkflowDraft draft, Set allowedConditionFields) { + List issues = new ArrayList<>(); + List nodes = draft.nodes(); + List edges = draft.edges(); + + if (nodes.isEmpty()) { + issues.add(Issue.error("流程没有任何节点", null)); + return issues; + } + + // 节点类型与节点自身配置 + for (WorkflowDraft.Node node : nodes) { + if (!NODE_TYPES.contains(node.nodeType())) { + issues.add(Issue.error("节点「" + label(node) + "」的节点类型不合法:" + node.nodeType(), + node.nodeKey())); + } + validateNodeConfig(node, issues); + } + + // 开始 / 结束节点 + List starts = nodes.stream() + .filter(node -> "start".equals(node.nodeType())) + .toList(); + List ends = nodes.stream() + .filter(node -> "end".equals(node.nodeType())) + .toList(); + if (starts.isEmpty()) { + issues.add(Issue.error("缺少开始节点", null)); + } else if (starts.size() > 1) { + issues.add(Issue.error("存在 " + starts.size() + " 个开始节点,只允许一个", starts.get(1).nodeKey())); + } + if (ends.isEmpty()) { + issues.add(Issue.error("缺少结束节点", null)); + } + + // 出线约束:非结束节点必须有出线,结束节点不能有出线(§6.2 第 4 条「无出口节点」) + for (WorkflowDraft.Node node : nodes) { + List outgoing = draft.outgoing(node.nodeKey()); + if ("end".equals(node.nodeType())) { + if (!outgoing.isEmpty()) { + issues.add(Issue.error("结束节点「" + label(node) + "」不应有出线", node.nodeKey())); + } + } else if (outgoing.isEmpty()) { + issues.add(Issue.error("节点「" + label(node) + "」没有出线,流程会走不下去", node.nodeKey())); + } + } + + // 可达性 + 死循环:都从开始节点出发判定 + if (!starts.isEmpty()) { + String startKey = starts.get(0).nodeKey(); + Set reachable = reachableFrom(startKey, draft); + for (WorkflowDraft.Node node : nodes) { + if (!reachable.contains(node.nodeKey())) { + issues.add(Issue.error("节点「" + label(node) + "」从开始节点不可达", node.nodeKey())); + } + } + if (hasCycle(startKey, draft)) { + issues.add(Issue.error("流程存在循环连线,工作流不支持回到已执行过的节点", startKey)); + } + } + + // 审批人 / 抄送人规则 + for (WorkflowDraft.Node node : nodes) { + if (!ACTOR_NODE_TYPES.contains(node.nodeType())) { + continue; + } + boolean cc = "cc_task".equals(node.nodeType()); + String who = cc ? "抄送人" : "审批人"; + if (node.actorRules().isEmpty()) { + issues.add(Issue.error("「" + label(node) + "」未配置" + who + "规则", node.nodeKey())); + continue; + } + for (WorkflowDraft.ActorRule rule : node.actorRules()) { + if (!ACTOR_TYPES.contains(rule.actorType())) { + issues.add(Issue.error("「" + label(node) + "」的" + who + "类型不合法:" + rule.actorType(), + node.nodeKey())); + continue; + } + if (ACTOR_TYPES_WITH_REF.contains(rule.actorType()) && isBlank(rule.actorRef())) { + issues.add(Issue.error( + "「" + label(node) + "」的" + who + "来源「" + rule.actorType() + "」缺少具体对象", + node.nodeKey())); + } + if (!isBlank(rule.fallbackType()) && !FALLBACK_TYPES.contains(rule.fallbackType())) { + issues.add(Issue.error( + "「" + label(node) + "」的" + who + "兜底类型不合法:" + rule.fallbackType(), + node.nodeKey())); + } + // 规则条件可留空(表示无条件命中);一旦配了就走与连线条件同一套校验 + if (rule.condition() != null && !rule.condition().isEmpty()) { + validateCondition(rule.condition(), "「" + label(node) + "」的" + who + "规则", + node.nodeKey(), allowedConditionFields, issues); + } + } + } + + // 分支出线:条件分支必须有默认出口(§6.5);并行分支至少两条出线 + for (WorkflowDraft.Node node : nodes) { + if ("exclusive_gateway".equals(node.nodeType())) { + List outgoing = draft.outgoing(node.nodeKey()); + if (!outgoing.isEmpty() && outgoing.stream().noneMatch(edge -> isDefaultEdge(edge))) { + issues.add(Issue.error( + "条件分支「" + label(node) + "」没有默认出口,所有条件都不满足时流程会卡住", + node.nodeKey())); + } + } else if ("parallel_gateway".equals(node.nodeType()) + && draft.outgoing(node.nodeKey()).size() == 1) { + issues.add(Issue.warn( + "并行分支「" + label(node) + "」只有一条出线,等于没有任何分支效果", + node.nodeKey())); + } + } + + // 连线条件 + for (WorkflowDraft.Edge edge : edges) { + if (!isDefaultEdge(edge)) { + validateCondition(edge.condition(), "连线「" + edge.edgeKey() + "」", null, + allowedConditionFields, issues); + } + WorkflowDraft.Node from = draft.node(edge.from()); + if (from != null && !"exclusive_gateway".equals(from.nodeType()) && !isDefaultEdge(edge)) { + issues.add(Issue.warn( + "节点「" + label(from) + "」不是条件分支,出线条件不会参与判断", + from.nodeKey())); + } + } + + return issues; + } + + public static boolean hasError(List issues) { + return issues.stream().anyMatch(issue -> LEVEL_ERROR.equals(issue.level())); + } + + /* ---------------------------------------------------------------------------- + 节点配置 + ---------------------------------------------------------------------------- */ + + private static void validateNodeConfig(WorkflowDraft.Node node, List issues) { + Object approvalMode = node.configValue("approvalMode", null); + if (approvalMode != null && !APPROVAL_MODES.contains(String.valueOf(approvalMode))) { + issues.add(Issue.error("「" + label(node) + "」的审批方式不合法:" + approvalMode, node.nodeKey())); + } + Object rejectMode = node.configValue("rejectMode", null); + if (rejectMode != null && !REJECT_MODES.contains(String.valueOf(rejectMode))) { + issues.add(Issue.error("「" + label(node) + "」的驳回策略不合法:" + rejectMode, node.nodeKey())); + } + double dueHours = number(node.configValue("dueHours", null)); + if (!Double.isNaN(dueHours) && dueHours < 0) { + issues.add(Issue.error("「" + label(node) + "」的审批时限不能为负数", node.nodeKey())); + } + // 注意用「不成立」而非「小于 / 大于」来判:缺参数时 number() 返回 NaN, + // 而 NaN 与任何数比较都是 false,写成 required < 1 会让「没配置」漏过校验 + if ("n_of_m".equals(String.valueOf(approvalMode))) { + double required = number(node.configValue("requiredCount", null)); + if (!(required >= 1)) { + issues.add(Issue.error("「" + label(node) + "」选择 N 人通过,但没配置有效的人数", node.nodeKey())); + } + } + if ("percentage".equals(String.valueOf(approvalMode))) { + double ratio = number(node.configValue("requiredRatio", null)); + if (!(ratio > 0 && ratio <= 1)) { + issues.add(Issue.error("「" + label(node) + "」选择按比例通过,但比例不在 0~1 之间", node.nodeKey())); + } + } + } + + /* ---------------------------------------------------------------------------- + 条件(§7.1:{field, operator, value} 叶子 + all/any/not 组合) + ---------------------------------------------------------------------------- */ + + private static void validateCondition( + Map condition, + String prefix, + String nodeKey, + Set allowedFields, + List issues + ) { + if (condition == null || condition.isEmpty()) { + issues.add(Issue.error(prefix + "的条件为空", nodeKey)); + return; + } + for (String key : LOGIC_KEYS) { + if (!condition.containsKey(key)) { + continue; + } + if ("not".equals(key)) { + Object child = condition.get(key); + if (child instanceof Map nested) { + validateCondition(castMap(nested), prefix + " 的非条件", nodeKey, allowedFields, issues); + } else { + issues.add(Issue.error(prefix + "的逻辑条件 not 结构不正确", nodeKey)); + } + return; + } + Object children = condition.get(key); + if (!(children instanceof List list) || list.isEmpty()) { + issues.add(Issue.error(prefix + "的逻辑条件 " + key + " 为空", nodeKey)); + return; + } + for (Object item : list) { + if (item instanceof Map nested) { + validateCondition(castMap(nested), prefix, nodeKey, allowedFields, issues); + } else { + issues.add(Issue.error(prefix + "的逻辑条件 " + key + " 结构不正确", nodeKey)); + } + } + return; + } + + String field = text(condition.get("field")); + String operator = text(condition.get("operator")); + if (field.isEmpty()) { + issues.add(Issue.error(prefix + "缺少条件字段", nodeKey)); + return; + } + if (allowedFields != null && !allowedFields.contains(field)) { + issues.add(Issue.error(prefix + "引用了模块里不存在的字段:" + field, nodeKey)); + } + if (!OPERATORS.contains(operator)) { + issues.add(Issue.error(prefix + "的操作符不合法:" + operator, nodeKey)); + return; + } + if (OPERATORS_WITHOUT_VALUE.contains(operator)) { + return; + } + Object value = condition.get("value"); + if (value == null || (value instanceof String text && text.isBlank())) { + issues.add(Issue.error(prefix + "缺少比较值", nodeKey)); + return; + } + if (("in".equals(operator) || "not_in".equals(operator)) && !(value instanceof List)) { + issues.add(Issue.error(prefix + "的「" + operator + "」需要一个值列表", nodeKey)); + } + if ("between".equals(operator) && (!(value instanceof List list) || list.size() != 2)) { + issues.add(Issue.error(prefix + "的「区间」需要两个值", nodeKey)); + } + } + + /* ---------------------------------------------------------------------------- + 图算法 + ---------------------------------------------------------------------------- */ + + /** 从开始节点出发能走到的节点集合(含量为 0 的孤立分支会被判不可达) */ + private static Set reachableFrom(String startKey, WorkflowDraft draft) { + Set seen = new LinkedHashSet<>(); + Deque queue = new ArrayDeque<>(); + queue.add(startKey); + while (!queue.isEmpty()) { + String current = queue.poll(); + if (!seen.add(current)) { + continue; + } + for (WorkflowDraft.Edge edge : draft.outgoing(current)) { + queue.add(edge.to()); + } + } + return seen; + } + + /** 深度优先检测有向环(只沿从开始节点可达的部分) */ + private static boolean hasCycle(String startKey, WorkflowDraft draft) { + Set visited = new LinkedHashSet<>(); + Set inStack = new LinkedHashSet<>(); + return visit(startKey, draft, visited, inStack); + } + + private static boolean visit( + String nodeKey, + WorkflowDraft draft, + Set visited, + Set inStack + ) { + if (inStack.contains(nodeKey)) { + return true; + } + if (!visited.add(nodeKey)) { + return false; + } + inStack.add(nodeKey); + for (WorkflowDraft.Edge edge : draft.outgoing(nodeKey)) { + if (visit(edge.to(), draft, visited, inStack)) { + return true; + } + } + inStack.remove(nodeKey); + return false; + } + + /* ---------------------------------------------------------------------------- + 小工具 + ---------------------------------------------------------------------------- */ + + /** 无条件的连线即默认出口(前端约定 condition 为 null 表示兜底) */ + private static boolean isDefaultEdge(WorkflowDraft.Edge edge) { + return edge.condition() == null || edge.condition().isEmpty(); + } + + private static String label(WorkflowDraft.Node node) { + return isBlank(node.name()) ? node.nodeKey() : node.name(); + } + + private static double number(Object value) { + if (value instanceof Number num) { + return num.doubleValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Double.parseDouble(text.trim()); + } catch (NumberFormatException ignored) { + return Double.NaN; + } + } + return Double.NaN; + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + @SuppressWarnings("unchecked") + private static Map castMap(Map map) { + return (Map) map; + } +} diff --git a/code/g3soft-erp/server/src/main/resources/application.yaml b/code/g3soft-erp/server/src/main/resources/application.yaml index 7c4c15a5..0580357a 100644 --- a/code/g3soft-erp/server/src/main/resources/application.yaml +++ b/code/g3soft-erp/server/src/main/resources/application.yaml @@ -40,3 +40,34 @@ erp: url-prefix: /api/file/static oss: g3oss-url: http://g3oss.g3soft.cn:8082 + log: + # 慢请求阈值(ms),超过则在访问日志行附加请求参数(params 字段) + slow-request-ms: 1000 + # 请求参数记录上限(字节),超出按字节截断;同时是请求体缓存上限 + max-param-bytes: 2048 + # 不记录请求参数的路径(相对 context-path),与 getServletPath() 精确匹配 + # 登录接口含明文密码,默认排除 + exclude-param-paths: + - /auth/login + + workflow: + # 任务超时扫描间隔;0s 关闭。扫描只把到期任务标记为 expired, + # 不改实例/节点状态、不自动提升串行后继(策略见 sql/README) + expire-sweep-interval: 5m + +logging: + file: + # 相对进程工作目录(server/),实际落盘 server/logs;根 .gitignore 已忽略 logs/ + path: ./logs + structured: + # 仅文件输出结构化 JSON;console 不设该值,保持彩色文本 + format: + file: logstash + level: + root: INFO + cn.g3soft.erp: INFO # 业务日志;排障时可临时调 DEBUG + ACCESS: INFO + com.alibaba.druid: WARN # 连接池噪音 + com.microsoft.sqlserver.jdbc: WARN + org.springframework: WARN + org.apache: WARN diff --git a/code/g3soft-erp/server/src/main/resources/logback-spring.xml b/code/g3soft-erp/server/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..3db72bb5 --- /dev/null +++ b/code/g3soft-erp/server/src/main/resources/logback-spring.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + ${CONSOLE_PATTERN} + UTF-8 + + + + + + ${LOG_DIR}/${APP_NAME}.jsonl + + ${LOGSTASH_FORMAT} + UTF-8 + + + ${LOG_DIR}/${APP_NAME}.%d{yyyy-MM-dd}.%i.jsonl.gz + 10MB + 30 + 1GB + + + + + + ${LOG_DIR}/${APP_NAME}-error.jsonl + + ERROR + ACCEPT + DENY + + + ${LOGSTASH_FORMAT} + UTF-8 + + + ${LOG_DIR}/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.jsonl.gz + 10MB + 30 + 256MB + + + + + + 1024 + 0 + false + false + 5000 + + + + + 512 + 0 + false + false + 5000 + + + + + + + + + + + + + + diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/exception/GlobalExceptionHandlerTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/exception/GlobalExceptionHandlerTests.java index 18ca46a6..ba3a8a7c 100644 --- a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/exception/GlobalExceptionHandlerTests.java +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/exception/GlobalExceptionHandlerTests.java @@ -1,6 +1,7 @@ package cn.g3soft.erp.common.exception; import cn.g3soft.erp.common.ApiResponse; +import cn.g3soft.erp.config.LogProperties; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; @@ -11,9 +12,10 @@ import static org.assertj.core.api.Assertions.assertThat; class GlobalExceptionHandlerTests { + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new LogProperties()); + @Test void databaseExceptionReturnsSystemError() { - GlobalExceptionHandler handler = new GlobalExceptionHandler(); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/auth/login"); ApiResponse response = handler.handleDatabaseException( @@ -28,15 +30,15 @@ class GlobalExceptionHandlerTests { @Test void saveExceptionReturnsOperationLocation() { - GlobalExceptionHandler handler = new GlobalExceptionHandler(); SaveObjectException exception = new SaveObjectException( "bs_business", "update", 2, new SQLException("update failed") ); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/data/saveobjt"); - ApiResponse> response = handler.handleSaveObjectException(exception); + ApiResponse> response = handler.handleSaveObjectException(exception, request); assertThat(response.code()).isEqualTo(ApiResponse.SAVE_ERROR_CODE); assertThat(response.message()).isEqualTo("保存失败: update failed"); diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/RequestParamsTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/RequestParamsTests.java new file mode 100644 index 00000000..4b061e1a --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/RequestParamsTests.java @@ -0,0 +1,78 @@ +package cn.g3soft.erp.common.util; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class RequestParamsTests { + + @Test + void returnsNullWhenNoQueryAndNoBody() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/file/static/logo.png"); + assertThat(RequestParams.collect(request, 2048)).isNull(); + } + + @Test + void collectsQueryStringOnly() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/export/list"); + request.setQueryString("table=t_stock&pageSize=5000"); + assertThat(RequestParams.collect(request, 2048)) + .isEqualTo("?table=t_stock&pageSize=5000"); + } + + @Test + void collectsQueryAndCachedBody() { + MockHttpServletRequest raw = new MockHttpServletRequest("POST", "/api/data/saveobjt"); + raw.setQueryString("org=SH01"); + raw.setContentType("application/json"); + raw.setContent("{\"table\":\"t_stock\",\"action\":\"update\"}".getBytes(StandardCharsets.UTF_8)); + ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(raw, 2048); + // 必须经 wrapper 读取,缓存才会被填充 + try { + wrapper.getInputStream().readAllBytes(); + } catch (Exception ignored) { + throw new AssertionError(ignored); + } + + assertThat(RequestParams.collect(wrapper, 2048)) + .isEqualTo("?org=SH01 {\"table\":\"t_stock\",\"action\":\"update\"}"); + } + + @Test + void skipsMultipartBody() { + MockHttpServletRequest raw = new MockHttpServletRequest("POST", "/api/file/upload"); + raw.setContentType("multipart/form-data; boundary=----abc"); + raw.setContent("------abc\r\nbinary\r\n".getBytes(StandardCharsets.UTF_8)); + ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(raw, 2048); + try { + wrapper.getInputStream().readAllBytes(); + } catch (Exception ignored) { + throw new AssertionError(ignored); + } + + assertThat(RequestParams.collect(wrapper, 2048)).isNull(); + } + + @Test + void truncatesOnByteBoundaryWithoutBreakingChinese() { + MockHttpServletRequest raw = new MockHttpServletRequest("POST", "/api/data/saveobjt"); + raw.setContentType("application/json"); + String body = "{\"name\":\"" + "不锈钢螺栓".repeat(20) + "\"}"; + raw.setContent(body.getBytes(StandardCharsets.UTF_8)); + ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(raw, 100000); + try { + wrapper.getInputStream().readAllBytes(); + } catch (Exception ignored) { + throw new AssertionError(ignored); + } + + String params = RequestParams.collect(wrapper, 30); + assertThat(params).endsWith("…(截断)"); + // 截断后仍是合法 UTF-8,不含替换字符 + assertThat(params).doesNotContain("\uFFFD"); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/config/LogPropertiesTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/config/LogPropertiesTests.java new file mode 100644 index 00000000..16c28b9e --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/config/LogPropertiesTests.java @@ -0,0 +1,35 @@ +package cn.g3soft.erp.config; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LogPropertiesTests { + + @Test + void loginPathExcludedByDefault() { + LogProperties properties = new LogProperties(); + + assertThat(properties.getExcludeParamPaths()).contains("/auth/login"); + assertThat(properties.isParamLoggingExcluded("/auth/login")).isTrue(); + } + + @Test + void otherPathsAndNullAreNotExcluded() { + LogProperties properties = new LogProperties(); + + assertThat(properties.isParamLoggingExcluded("/api/data/saveobjt")).isFalse(); + assertThat(properties.isParamLoggingExcluded(null)).isFalse(); + } + + @Test + void exclusionListIsConfigurable() { + LogProperties properties = new LogProperties(); + properties.setExcludeParamPaths(List.of("/api/file/upload")); + + assertThat(properties.isParamLoggingExcluded("/auth/login")).isFalse(); + assertThat(properties.isParamLoggingExcluded("/api/file/upload")).isTrue(); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluatorTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluatorTests.java new file mode 100644 index 00000000..cdda7f6a --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluatorTests.java @@ -0,0 +1,135 @@ +package cn.g3soft.erp.service.workflow; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 条件求值({@link WorkflowConditionEvaluator})的回归测试。 + * + *

求值结果是「流程往哪条线走」的唯一依据,算错的后果是走错分支或卡死, + * 所以把取值语义(数值/字符串/空值)、组合逻辑与容错行为都固化在这里。 + */ +@DisplayName("工作流条件求值") +class WorkflowConditionEvaluatorTests { + + @Test + @DisplayName("无条件(null 或空对象)视为命中:对应默认出口") + void emptyConditionAlwaysMatches() { + assertThat(WorkflowConditionEvaluator.evaluate(null, Map.of())).isTrue(); + assertThat(WorkflowConditionEvaluator.evaluate(Map.of(), Map.of())).isTrue(); + } + + @Test + @DisplayName("相等:数值与字符串两种写法都能对上") + void equality() { + assertThat(eval(leaf("b_amount", "eq", 10000), Map.of("b_amount", 10000))).isTrue(); + assertThat(eval(leaf("b_amount", "eq", 10000), Map.of("b_amount", "10000"))).isTrue(); + assertThat(eval(leaf("b_amount", "eq", 10000), Map.of("b_amount", "10000.0"))).isTrue(); + assertThat(eval(leaf("b_amount", "eq", 10000), Map.of("b_amount", 9999))).isFalse(); + assertThat(eval(leaf("b_name", "eq", "金地"), Map.of("b_name", "金地"))).isTrue(); + assertThat(eval(leaf("b_name", "ne", "金地"), Map.of("b_name", "万科"))).isTrue(); + } + + @Test + @DisplayName("大小比较:数值列按数值比,非数字一律不命中") + void comparison() { + assertThat(eval(leaf("b_amount", "gt", 10000), Map.of("b_amount", 10001))).isTrue(); + assertThat(eval(leaf("b_amount", "ge", 50000), Map.of("b_amount", 50000))).isTrue(); + assertThat(eval(leaf("b_amount", "lt", 50000), Map.of("b_amount", 49999.99))).isTrue(); + assertThat(eval(leaf("b_amount", "le", 10000), Map.of("b_amount", 10000))).isTrue(); + // 字段值不是数字:比不出结果,按不命中处理(不抛异常) + assertThat(eval(leaf("b_amount", "gt", 0), Map.of("b_amount", "待定"))).isFalse(); + // 字段缺失同样不命中 + assertThat(eval(leaf("b_amount", "gt", 0), Map.of())).isFalse(); + } + + @Test + @DisplayName("in / not_in:支持数组,也兼容逗号分隔的老写法") + void inAndNotIn() { + Map context = Map.of("b_dept_id", "D002"); + assertThat(eval(leaf("b_dept_id", "in", List.of("D001", "D002")), context)).isTrue(); + assertThat(eval(leaf("b_dept_id", "not_in", List.of("D001", "D003")), context)).isTrue(); + assertThat(eval(leaf("b_dept_id", "in", "D001,D002"), context)).isTrue(); + assertThat(eval(leaf("b_dept_id", "in", List.of("D009")), context)).isFalse(); + } + + @Test + @DisplayName("like 不区分大小写;is_null / is_not_null 把空串也算空") + void likeAndNullCheck() { + assertThat(eval(leaf("b_name", "like", "金"), Map.of("b_name", "金地集团"))).isTrue(); + assertThat(eval(leaf("b_code", "like", "abc"), Map.of("b_code", "XXABCxx"))).isTrue(); + + assertThat(eval(leaf("b_bz", "is_null", null), Map.of("b_bz", ""))).isTrue(); + assertThat(eval(leaf("b_bz", "is_null", null), Map.of())).isTrue(); + assertThat(eval(leaf("b_bz", "is_null", null), Map.of("b_bz", "有值"))).isFalse(); + assertThat(eval(leaf("b_bz", "is_not_null", null), Map.of("b_bz", "有值"))).isTrue(); + } + + @Test + @DisplayName("between 是闭区间") + void between() { + Map bounds = leaf("b_amount", "between", List.of(1000, 2000)); + assertThat(eval(bounds, Map.of("b_amount", 1000))).isTrue(); + assertThat(eval(bounds, Map.of("b_amount", 2000))).isTrue(); + assertThat(eval(bounds, Map.of("b_amount", 1500))).isTrue(); + assertThat(eval(bounds, Map.of("b_amount", 2001))).isFalse(); + // 区间值不是两个元素:不命中 + assertThat(eval(leaf("b_amount", "between", List.of(1000)), Map.of("b_amount", 1500))).isFalse(); + } + + @Test + @DisplayName("all / any / not 组合") + void logicComposition() { + Map context = Map.of("b_amount", 30000, "b_dept_id", "D002"); + + Map all = new LinkedHashMap<>(); + all.put("all", List.of(leaf("b_amount", "gt", 10000), leaf("b_dept_id", "eq", "D002"))); + assertThat(WorkflowConditionEvaluator.evaluate(all, context)).isTrue(); + + all.put("all", List.of(leaf("b_amount", "gt", 10000), leaf("b_dept_id", "eq", "D009"))); + assertThat(WorkflowConditionEvaluator.evaluate(all, context)).isFalse(); + + Map any = new LinkedHashMap<>(); + any.put("any", List.of(leaf("b_amount", "lt", 1000), leaf("b_dept_id", "eq", "D002"))); + assertThat(WorkflowConditionEvaluator.evaluate(any, context)).isTrue(); + + Map anyMiss = new LinkedHashMap<>(); + anyMiss.put("any", List.of(leaf("b_amount", "lt", 1000), leaf("b_dept_id", "eq", "D009"))); + assertThat(WorkflowConditionEvaluator.evaluate(anyMiss, context)).isFalse(); + + Map not = new LinkedHashMap<>(); + not.put("not", leaf("b_amount", "gt", 100000)); + assertThat(WorkflowConditionEvaluator.evaluate(not, context)).isTrue(); + } + + @Test + @DisplayName("未知操作符与结构损坏一律不命中,不抛异常") + void unknownOperatorIsFalse() { + assertThat(eval(leaf("b_amount", "unknown", 1), Map.of("b_amount", 1))).isFalse(); + assertThat(eval(leaf("b_amount", "gt", 1), Map.of())).isFalse(); + // all 里混进非条件对象 + Map broken = new LinkedHashMap<>(); + broken.put("all", List.of("不是条件")); + assertThat(WorkflowConditionEvaluator.evaluate(broken, Map.of())).isFalse(); + } + + private static boolean eval(Map condition, Map context) { + return WorkflowConditionEvaluator.evaluate(condition, context); + } + + private static Map leaf(String field, String operator, Object value) { + Map condition = new LinkedHashMap<>(); + condition.put("field", field); + condition.put("operator", operator); + if (value != null) { + condition.put("value", value); + } + return condition; + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowValidatorTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowValidatorTests.java new file mode 100644 index 00000000..bdbf44e9 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowValidatorTests.java @@ -0,0 +1,285 @@ +package cn.g3soft.erp.service.workflow; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 发布校验({@link WorkflowValidator})的回归测试。 + * + *

这些规则是「阻止把跑不下去的流程发布出去」的最后一道闸: + * 前端只做即时提示,后端校验才是最终规则(设计 §19.6),所以每条都固化在这里。 + */ +@DisplayName("工作流发布校验") +class WorkflowValidatorTests { + + @Test + @DisplayName("最小可用流程:无错误无警告") + void minimalWorkflowPasses() { + WorkflowDraft draft = draft( + List.of(start(), userTask("approve", "部门审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "approve"), edge("e2", "approve", "end")) + ); + + assertThat(WorkflowValidator.validate(draft, null)).isEmpty(); + assertThat(WorkflowValidator.hasError(WorkflowValidator.validate(draft, null))).isFalse(); + } + + @Test + @DisplayName("缺少开始节点 / 结束节点都是错误") + void missingStartOrEnd() { + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(userTask("approve", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "approve", "end")) + ), null), "缺少开始节点")).isTrue(); + + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("approve", "审批", "single", null, null, rule("user", "u1"))), + List.of(edge("e1", "start", "approve")) + ), null), "缺少结束节点")).isTrue(); + } + + @Test + @DisplayName("两个开始节点是错误") + void multipleStarts() { + List issues = WorkflowValidator.validate(draft( + List.of(start(), start(), end()), + List.of(edge("e1", "start", "end")) + ), null); + + assertThat(hasError(issues, "只允许一个")).isTrue(); + } + + @Test + @DisplayName("不可达节点与死循环都会拦住发布") + void unreachableAndCycle() { + // orphan 没有任何入线:从开始节点走不到 + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("approve", "审批", "single", null, null, rule("user", "u1")), end(), + node("orphan", "user_task")), + List.of(edge("e1", "start", "approve"), edge("e2", "approve", "end")) + ), null), "不可达")).isTrue(); + + // 审批 → 回到开始节点:形成环 + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("approve", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "approve"), edge("e2", "approve", "start")) + ), null), "循环")).isTrue(); + } + + @Test + @DisplayName("非结束节点没有出线、结束节点有出线都是错误") + void outgoingRules() { + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("approve", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "approve"), edge("e2", "approve", "end")) + ), null), "没有出线")).isFalse(); + + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("approve", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "approve")) + ), null), "没有出线")).isTrue(); + + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("approve", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "approve"), edge("e2", "approve", "end"), edge("e3", "end", "approve")) + ), null), "不应有出线")).isTrue(); + } + + @Test + @DisplayName("人工节点与抄送节点必须配好处理人规则,缺具体对象也是错误") + void actorRulesRequired() { + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), node("approve", "user_task"), end()), + List.of(edge("e1", "start", "approve"), edge("e2", "approve", "end")) + ), null), "未配置审批人规则")).isTrue(); + + // position 这类解析类型必须给出具体对象编码 + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("approve", "审批", "single", null, null, rule("position", "")), end()), + List.of(edge("e1", "start", "approve"), edge("e2", "approve", "end")) + ), null), "缺少具体对象")).isTrue(); + + // 抄送节点单独提示抄送人 + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), node("cc", "cc_task"), end()), + List.of(edge("e1", "start", "cc"), edge("e2", "cc", "end")) + ), null), "未配置抄送人规则")).isTrue(); + } + + @Test + @DisplayName("条件分支:没有出线是错误,没有默认出口也是错误") + void gatewayRules() { + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), node("gate", "exclusive_gateway")), + List.of(edge("e1", "start", "gate")) + ), null), "没有出线")).isTrue(); + + // 两条出线都带条件、没有兜底 + Map condition = condition("b_amount", "gt", 10000); + List issues = WorkflowValidator.validate(draft( + List.of(start(), node("gate", "exclusive_gateway"), + userTask("a", "大额审批", "single", null, null, rule("user", "u1")), + userTask("b", "小额审批", "single", null, null, rule("user", "u2")), end()), + List.of(edge("e1", "start", "gate"), + edge("e2", "gate", "a", condition), + edge("e3", "gate", "b", condition("b_amount", "le", 10000)), + edge("e4", "a", "end"), edge("e5", "b", "end")) + ), null); + + assertThat(hasError(issues, "没有默认出口")).isTrue(); + + // 条件挂在「不是条件分支」的节点上:只提示不阻断(那条出线不会被按条件判断) + List onTask = WorkflowValidator.validate(draft( + List.of(start(), userTask("a", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "a"), + edge("e2", "a", "end", condition("b_amount", "gt", 1))) + ), null); + assertThat(onTask).anyMatch(issue -> WorkflowValidator.LEVEL_WARN.equals(issue.level()) + && issue.message().contains("不是条件分支")); + } + + @Test + @DisplayName("并行分支只有一条出线只给警告,不阻断发布") + void parallelWithSingleExitIsWarning() { + List issues = WorkflowValidator.validate(draft( + List.of(start(), node("split", "parallel_gateway"), + userTask("a", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "split"), edge("e2", "split", "a"), edge("e3", "a", "end")) + ), null); + + assertThat(WorkflowValidator.hasError(issues)).isFalse(); + assertThat(issues).anyMatch(issue -> WorkflowValidator.LEVEL_WARN.equals(issue.level()) + && issue.message().contains("并行分支")); + } + + @Test + @DisplayName("N 人通过与按比例通过必须给出有效参数") + void approvalModeParameters() { + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("a", "董事长审批", "n_of_m", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "a"), edge("e2", "a", "end")) + ), null), "没配置有效的人数")).isTrue(); + + assertThat(hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("a", "风控", "percentage", null, 1.5, rule("user", "u1")), end()), + List.of(edge("e1", "start", "a"), edge("e2", "a", "end")) + ), null), "比例不在 0~1")).isTrue(); + + // 参数齐备时不应报错 + assertThat(WorkflowValidator.hasError(WorkflowValidator.validate(draft( + List.of(start(), userTask("a", "风控", "percentage", null, 0.6, rule("user", "u1")), end()), + List.of(edge("e1", "start", "a"), edge("e2", "a", "end")) + ), null))).isFalse(); + } + + @Test + @DisplayName("条件里的字段必须存在于绑定模块(传入允许字段清单时生效)") + void conditionFieldMustExist() { + WorkflowDraft draft = draft( + List.of(start(), node("gate", "exclusive_gateway"), + userTask("a", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "gate"), + edge("e2", "gate", "a", condition("b_unknown", "gt", 1)), + edge("e3", "a", "end")) + ); + + assertThat(hasError(WorkflowValidator.validate(draft, Set.of("b_amount")), "不存在的字段")).isTrue(); + // 不传清单(没有绑定上下文)时跳过字段校验 + assertThat(hasError(WorkflowValidator.validate(draft, null), "不存在的字段")).isFalse(); + } + + @Test + @DisplayName("条件操作符与比较值不合法是错误") + void conditionValueRules() { + WorkflowDraft bad = draft( + List.of(start(), node("gate", "exclusive_gateway"), + userTask("a", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "gate"), + edge("e2", "gate", "a", condition("b_amount", "unknown_op", 1)), + edge("e3", "a", "end")) + ); + assertThat(hasError(WorkflowValidator.validate(bad, null), "操作符不合法")).isTrue(); + + WorkflowDraft noValue = draft( + List.of(start(), node("gate", "exclusive_gateway"), + userTask("a", "审批", "single", null, null, rule("user", "u1")), end()), + List.of(edge("e1", "start", "gate"), + edge("e2", "gate", "a", condition("b_amount", "gt", null)), + edge("e3", "a", "end")) + ); + assertThat(hasError(WorkflowValidator.validate(noValue, null), "缺少比较值")).isTrue(); + } + + /* ---------------------------------------------------------------------------- + 构造草稿的小工具 + ---------------------------------------------------------------------------- */ + + private static WorkflowDraft draft(List nodes, List edges) { + return new WorkflowDraft(nodes, edges); + } + + private static WorkflowDraft.Node start() { + return node("start", "start"); + } + + private static WorkflowDraft.Node end() { + return node("end", "end"); + } + + private static WorkflowDraft.Node node(String key, String type) { + return new WorkflowDraft.Node(key, type, key, Map.of(), List.of()); + } + + private static WorkflowDraft.Node userTask( + String key, + String name, + String mode, + Integer requiredCount, + Double requiredRatio, + WorkflowDraft.ActorRule... rules + ) { + Map config = new LinkedHashMap<>(); + config.put("approvalMode", mode); + if (requiredCount != null) { + config.put("requiredCount", requiredCount); + } + if (requiredRatio != null) { + config.put("requiredRatio", requiredRatio); + } + return new WorkflowDraft.Node(key, "user_task", name, config, List.of(rules)); + } + + private static WorkflowDraft.ActorRule rule(String type, String ref) { + return new WorkflowDraft.ActorRule(type, ref); + } + + private static WorkflowDraft.Edge edge(String key, String from, String to) { + return new WorkflowDraft.Edge(key, from, to, "", null); + } + + private static WorkflowDraft.Edge edge(String key, String from, String to, Map condition) { + return new WorkflowDraft.Edge(key, from, to, "", condition); + } + + private static Map condition(String field, String operator, Object value) { + Map condition = new LinkedHashMap<>(); + condition.put("field", field); + condition.put("operator", operator); + if (value != null) { + condition.put("value", value); + } + return condition; + } + + private static boolean hasError(List issues, String keyword) { + return issues.stream().anyMatch(issue -> + WorkflowValidator.LEVEL_ERROR.equals(issue.level()) && issue.message().contains(keyword)); + } +} diff --git a/code/g3soft-erp/server/start.cmd b/code/g3soft-erp/server/start.cmd index 80536163..3ca83ae7 100644 --- a/code/g3soft-erp/server/start.cmd +++ b/code/g3soft-erp/server/start.cmd @@ -1,4 +1,5 @@ @echo off +chcp 65001 >nul set "JAVA_HOME=D:\devtool\jdk\jdk-21" set "MAVEN_HOME=D:\devtool\maven\apache-maven-3.8.9" set "PATH=%JAVA_HOME%\bin;%MAVEN_HOME%\bin;%PATH%" diff --git a/code/g3soft-erp/sql/00_core_companion/workflow_audit_tables.sql b/code/g3soft-erp/sql/00_core_companion/workflow_audit_tables.sql new file mode 100644 index 00000000..f1492ec6 --- /dev/null +++ b/code/g3soft-erp/sql/00_core_companion/workflow_audit_tables.sql @@ -0,0 +1,69 @@ +/* ============================================================ + 工作流审计表(设计 §11:业务状态投影需留审计) + ------------------------------------------------------------ + 背景:工作流推进时会把流程状态投影回业务表的状态列,设计文档要求 + 在同一事务写 s_log_audit / s_log_audit_field 记录这次字段级变更。 + 这两张表属于「核心/日志」表,不属于工作流表;本脚本是可选的 + 伴随脚本——已存在(例如从 FMS 核心库建的库)则原样跳过。 + + 运行时行为(WorkflowAuditWriter):探表存在则写;不存在则写一条 + wf_outbox(audit.skipped) 并继续,审计缺失绝不阻断审批。 + + 幂等:按 object_id 判断,可重复执行。 + ============================================================ */ + +if object_id(N'dbo.s_log_audit', N'U') is null +begin + create table dbo.s_log_audit ( + b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) + b_event_code varchar(50) not null, -- 事件编码 + b_module_id varchar(50) null, -- 模块编码 + b_data_id varchar(50) null, -- 数据 ID + b_business_no varchar(50) null, -- 业务单号 + b_operation varchar(100) null, -- 操作 + b_operator_id varchar(50) null, -- 操作人 ID + b_operator_name nvarchar(200) null, -- 操作人名称 + b_source_type varchar(20) not null default 'user', -- 来源类型 + b_visibility varchar(20) not null default 'internal',-- 可见性 + b_request_id varchar(64) null, -- 请求 ID + b_trace_id varchar(64) null, -- 链路 ID + b_payload_text nvarchar(max) null, -- 载荷文本 + b_occurdatetime datetime2 default sysutcdatetime() -- 发生时间(UTC 墙钟值,见后端 TimeUtils) + ); + + create index ix_s_log_audit_occur + on dbo.s_log_audit (b_occurdatetime, b_id); + + create index ix_s_log_audit_module + on dbo.s_log_audit (b_module_id, b_data_id, b_occurdatetime); + + print N'已创建 dbo.s_log_audit'; +end +else + print N'dbo.s_log_audit 已存在,跳过'; +go + +if object_id(N'dbo.s_log_audit_field', N'U') is null +begin + create table dbo.s_log_audit_field ( + b_id uniqueidentifier not null primary key, -- 日志 ID(UUIDv7) + b_event_id uniqueidentifier not null, -- 审计事件 ID + b_module_id varchar(50) null, -- 模块编码 + b_data_id varchar(50) null, -- 数据 ID + b_field varchar(50) not null, -- 字段编码 + b_field_label nvarchar(200) null, -- 字段名称 + b_before_value nvarchar(max) null, -- 变更前值 + b_after_value nvarchar(max) null, -- 变更后值 + b_before_display nvarchar(1000) null, -- 变更前显示值 + b_after_display nvarchar(1000) null, -- 变更后显示值 + b_visibility varchar(20) not null default 'internal' -- 可见性 + ); + + create index ix_s_log_audit_field_event + on dbo.s_log_audit_field (b_event_id, b_id); + + print N'已创建 dbo.s_log_audit_field'; +end +else + print N'dbo.s_log_audit_field 已存在,跳过'; +go diff --git a/code/g3soft-erp/sql/10_workflow/01_fms_workflow.sql b/code/g3soft-erp/sql/10_workflow/01_fms_workflow.sql new file mode 100644 index 00000000..6767b971 --- /dev/null +++ b/code/g3soft-erp/sql/10_workflow/01_fms_workflow.sql @@ -0,0 +1,805 @@ +/* ============================================================================ + FMS 工作流与审批表建表脚本(wf_*) + + 依据文档:FMS新系统核心表结构设计.md §16 工作流与审批 + 语义设计见 FMS工作流与审批设计.md + + 使用说明: + 1. 执行前先切换到目标数据库(USE [数据库名])。 + 2. 脚本只建表、索引和说明属性,不含初始化数据。 + 3. 不使用外键、CHECK、触发器;关联完整性和业务校验由业务层处理。 + 4. SQL Server 不支持建表内联注释,列注释通过扩展属性 MS_Description 持久化, + SSMS 的表设计器和列属性说明直接读取。 + 5. 脚本按新建表编写;表已存在时需先删除后重新执行。 + ============================================================================ */ + +SET NOCOUNT ON; +GO + +/* ============================================================================ + 16.1 流程定义 + ============================================================================ */ + +create table dbo.wf_definition ( + b_id varchar(50) not null primary key, -- 流程定义编码(创建后不可修改) + b_name nvarchar(200) not null, -- 流程名称 + b_i18n varchar(150) null, -- 多语言资源键(s_i18n.b_key) + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_bz nvarchar(2000) null, -- 备注 + b_created_by varchar(50) null, -- 创建人(用户编码) + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null -- 最后修改时间 +); +GO + +create index ix_wf_definition_canuse + on dbo.wf_definition (b_canuse, b_id); +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程定义表(稳定逻辑身份,执行内容在版本表)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程定义编码(创建后不可修改)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程名称', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition', @level2type=N'COLUMN', @level2name=N'b_name'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'多语言资源键(s_i18n.b_key)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition', @level2type=N'COLUMN', @level2name=N'b_i18n'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'是否启用(0/1)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition', @level2type=N'COLUMN', @level2name=N'b_canuse'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'备注', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition', @level2type=N'COLUMN', @level2name=N'b_bz'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建人(用户编码)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition', @level2type=N'COLUMN', @level2name=N'b_created_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition', @level2type=N'COLUMN', @level2name=N'b_created_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition', @level2type=N'COLUMN', @level2name=N'b_updated_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_definition', @level2type=N'COLUMN', @level2name=N'b_updated_at'; +GO + +/* ============================================================================ + 16.2 流程版本 + ============================================================================ */ + +create table dbo.wf_version ( + b_id varchar(50) not null primary key, -- 版本 ID + b_definition_id varchar(50) not null, -- 流程定义编码(wf_definition.b_id) + b_version_no int not null, -- 版本序号(同一流程内递增) + b_status varchar(20) not null default 'draft', -- 版本状态:draft(草稿)/published(已发布)/retired(已停用) + b_schema_version int not null default 1, -- 配置结构版本(JSON 结构升级用) + b_canvas_json nvarchar(max) null, -- 设计器布局(节点坐标、缩放),不参与执行 + b_checksum varchar(128) null, -- 发布时计算的配置摘要 + b_published_by varchar(50) null, -- 发布人(用户编码) + b_published_at datetime2 null, -- 发布时间 + b_bz nvarchar(2000) null, -- 备注 + b_created_by varchar(50) null, -- 创建人 + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null -- 最后修改时间 +); +GO + +create unique index ux_wf_version_no + on dbo.wf_version (b_definition_id, b_version_no); +GO + +create index ix_wf_version_status + on dbo.wf_version (b_definition_id, b_status, b_version_no); +GO + +create unique index ux_wf_version_draft + on dbo.wf_version (b_definition_id) + where b_status = 'draft'; +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程版本表(draft/published/retired,已发布版本不可原地修改)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'版本 ID', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程定义编码(wf_definition.b_id)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_definition_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'版本序号(同一流程内递增)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_version_no'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'版本状态:draft(草稿)/published(已发布)/retired(已停用)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_status'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'配置结构版本(JSON 结构升级用)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_schema_version'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'设计器布局(节点坐标、缩放),不参与执行', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_canvas_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'发布时计算的配置摘要', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_checksum'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'发布人(用户编码)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_published_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'发布时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_published_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'备注', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_bz'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_created_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_created_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_updated_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version', @level2type=N'COLUMN', @level2name=N'b_updated_at'; +GO + +/* ============================================================================ + 16.3 流程绑定 + ============================================================================ */ + +create table dbo.wf_binding ( + b_id varchar(50) not null primary key, -- 绑定 ID + b_module_id varchar(50) not null, -- 模块编码(s_module.b_id,data/virtual 模块) + b_event varchar(30) not null, -- 业务事件:submit(送审)/resubmit(重新提交)/change(变更) + b_version_id varchar(50) not null, -- 已发布流程版本(wf_version.b_id) + b_match_json nvarchar(max) null, -- 业务匹配条件 AST + b_org_scope_json nvarchar(max) null, -- 公司、组织范围 + b_status_field varchar(50) null, -- 业务状态字段(必须来自 s_field) + b_status_map_json nvarchar(max) null, -- 流程状态到业务状态的映射 + b_priority int not null default 0, -- 匹配优先级(小的先匹配) + b_effective_from datetime2 null, -- 生效开始时间 + b_effective_to datetime2 null, -- 生效结束时间 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_bz nvarchar(2000) null, -- 备注 + b_created_by varchar(50) null, -- 创建人 + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null -- 最后修改时间 +); +GO + +create index ix_wf_binding_select + on dbo.wf_binding (b_module_id, b_event, b_canuse, b_priority, b_effective_from, b_effective_to); +GO + +create unique index ux_wf_binding_active_priority + on dbo.wf_binding (b_module_id, b_event, b_priority) + where b_canuse = 1; +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程绑定表(模块 + 业务事件 + 条件 → 流程版本)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'绑定 ID', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'模块编码(s_module.b_id,data/virtual 模块)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_module_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'业务事件:submit(送审)/resubmit(重新提交)/change(变更)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_event'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'已发布流程版本(wf_version.b_id)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_version_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'业务匹配条件 AST', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_match_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'公司、组织范围', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_org_scope_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'业务状态字段(必须来自 s_field)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_status_field'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程状态到业务状态的映射', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_status_map_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'匹配优先级(小的先匹配)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_priority'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'生效开始时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_effective_from'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'生效结束时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_effective_to'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'是否启用(0/1)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_canuse'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'备注', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_bz'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_created_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_created_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_updated_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding', @level2type=N'COLUMN', @level2name=N'b_updated_at'; +GO + +/* ============================================================================ + 16.4 流程节点 + ============================================================================ */ + +create table dbo.wf_node ( + b_id varchar(50) not null primary key, -- 节点 ID + b_version_id varchar(50) not null, -- 流程版本(wf_version.b_id) + b_node_key varchar(80) not null, -- 版本内稳定编码(历史记录引用它) + b_name nvarchar(200) not null, -- 节点名称 + b_i18n varchar(150) null, -- 多语言资源键 + b_node_type varchar(30) not null, -- 节点类型:start(开始)/user_task(人工审批)/cc_task(抄送)/exclusive_gateway(条件分支)/parallel_gateway(并行分支)/service_task(服务节点)/end(结束) + b_approval_mode varchar(30) null, -- 审批方式:single(单人)/any(或签)/all(会签)/serial(串行会签)/n_of_m(N 人通过)/percentage(按比例通过) + b_reject_mode varchar(30) null, -- 驳回策略:terminate(驳回结束)/return_initiator(退回发起人)/return_previous(退回上一节点) + b_due_hours decimal(10,2) null, -- 审批时限(小时) + b_config_json nvarchar(max) null, -- 节点扩展配置(requiredCount、requiredRatio、formPolicy 等) + b_xh int not null default 0, -- 显示顺序 + b_canuse tinyint not null default 1 -- 是否启用(0/1) +); +GO + +create unique index ux_wf_node_key + on dbo.wf_node (b_version_id, b_node_key); +GO + +create index ix_wf_node_type + on dbo.wf_node (b_version_id, b_node_type, b_xh, b_node_key); +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程节点表(版本内节点定义)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点 ID', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程版本(wf_version.b_id)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_version_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'版本内稳定编码(历史记录引用它)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_node_key'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点名称', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_name'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'多语言资源键', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_i18n'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点类型:start(开始)/user_task(人工审批)/cc_task(抄送)/exclusive_gateway(条件分支)/parallel_gateway(并行分支)/service_task(服务节点)/end(结束)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_node_type'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'审批方式:single(单人)/any(或签)/all(会签)/serial(串行会签)/n_of_m(N 人通过)/percentage(按比例通过)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_approval_mode'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'驳回策略:terminate(驳回结束)/return_initiator(退回发起人)/return_previous(退回上一节点)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_reject_mode'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'审批时限(小时)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_due_hours'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点扩展配置(requiredCount、requiredRatio、formPolicy 等)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_config_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'显示顺序', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_xh'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'是否启用(0/1)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node', @level2type=N'COLUMN', @level2name=N'b_canuse'; +GO + +/* ============================================================================ + 16.5 节点连线 + ============================================================================ */ + +create table dbo.wf_edge ( + b_id varchar(50) not null primary key, -- 连线 ID + b_version_id varchar(50) not null, -- 流程版本 + b_from_node_key varchar(80) not null, -- 来源节点编码 + b_to_node_key varchar(80) not null, -- 目标节点编码 + b_name nvarchar(200) null, -- 连线名称(画布标签,留空时前端按条件摘要显示) + b_condition_json nvarchar(max) null, -- 连线条件 AST + b_priority int not null default 0, -- 判断优先级(小的先判断) + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_bz nvarchar(1000) null -- 备注 +); +GO + +create index ix_wf_edge_from + on dbo.wf_edge (b_version_id, b_from_node_key, b_canuse, b_priority, b_to_node_key); +GO + +create index ix_wf_edge_to + on dbo.wf_edge (b_version_id, b_to_node_key, b_canuse, b_priority, b_from_node_key); +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点连线表(含条件分支)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'连线 ID', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程版本', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge', @level2type=N'COLUMN', @level2name=N'b_version_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'来源节点编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge', @level2type=N'COLUMN', @level2name=N'b_from_node_key'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'目标节点编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge', @level2type=N'COLUMN', @level2name=N'b_to_node_key'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'连线名称(画布标签,留空时前端按条件摘要显示)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge', @level2type=N'COLUMN', @level2name=N'b_name'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'连线条件 AST', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge', @level2type=N'COLUMN', @level2name=N'b_condition_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'判断优先级(小的先判断)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge', @level2type=N'COLUMN', @level2name=N'b_priority'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'是否启用(0/1)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge', @level2type=N'COLUMN', @level2name=N'b_canuse'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'备注', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_edge', @level2type=N'COLUMN', @level2name=N'b_bz'; +GO + +/* ============================================================================ + 16.6 审批人规则 + ============================================================================ */ + +create table dbo.wf_actor_rule ( + b_id varchar(50) not null primary key, -- 规则 ID + b_version_id varchar(50) not null, -- 流程版本 + b_node_key varchar(80) not null, -- 节点编码 + b_actor_type varchar(30) not null, -- 解析类型:user(指定用户)/initiator(发起人)/initiator_manager(发起人直属领导)/dept_manager(部门负责人)/position(岗位)/org_role(组织角色)/field_user(业务字段用户)/custom(自定义解析器) + b_actor_ref varchar(150) null, -- 用户、岗位、角色或字段编码 + b_resolve_json nvarchar(max) null, -- 解析参数 + b_condition_json nvarchar(max) null, -- 规则适用条件 AST + b_fallback_type varchar(30) null, -- 解析为空时的兜底:none(不兜底)/admin(管理员)/initiator(发起人)/custom(自定义) + b_fallback_ref varchar(150) null, -- 兜底引用 + b_priority int not null default 0, -- 优先级(串行会签的顺序来源) + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_bz nvarchar(1000) null -- 备注 +); +GO + +create index ix_wf_actor_rule_node + on dbo.wf_actor_rule (b_version_id, b_node_key, b_canuse, b_priority, b_actor_type); +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'审批人规则表(节点内审批人、抄送人的解析规则)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'规则 ID', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程版本', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_version_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_node_key'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'解析类型:user(指定用户)/initiator(发起人)/initiator_manager(发起人直属领导)/dept_manager(部门负责人)/position(岗位)/org_role(组织角色)/field_user(业务字段用户)/custom(自定义解析器)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_actor_type'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'用户、岗位、角色或字段编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_actor_ref'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'解析参数', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_resolve_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'规则适用条件 AST', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_condition_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'解析为空时的兜底:none(不兜底)/admin(管理员)/initiator(发起人)/custom(自定义)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_fallback_type'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'兜底引用', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_fallback_ref'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'优先级(串行会签的顺序来源)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_priority'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'是否启用(0/1)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_canuse'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'备注', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_actor_rule', @level2type=N'COLUMN', @level2name=N'b_bz'; +GO + +/* ============================================================================ + 16.7 流程实例 + ============================================================================ */ + +create table dbo.wf_instance ( + b_id uniqueidentifier not null primary key, -- 实例 ID(应用层生成 UUIDv7) + b_definition_id varchar(50) not null, -- 流程定义编码 + b_version_id varchar(50) not null, -- 流程版本(提交时固定) + b_binding_id varchar(50) not null, -- 命中的绑定(wf_binding.b_id) + b_module_id varchar(50) not null, -- 业务模块编码 + b_event varchar(30) not null, -- 业务事件:submit(送审)/resubmit(重新提交)/change(变更) + b_business_id varchar(50) not null, -- 业务主键(统一按字符串保存) + b_business_no nvarchar(200) null, -- 业务单号(列表展示) + b_org_id varchar(50) null, -- 公司/组织编码 + b_initiator_id varchar(50) not null, -- 发起人(用户编码) + b_initiator_dept_id varchar(50) null, -- 发起人部门编码 + b_status varchar(30) not null, -- 实例状态:running(运行中)/approved(已通过)/rejected(已驳回)/returned(已退回)/withdrawn(已撤回)/cancelled(已取消)/expired(已过期)/error(异常待处理) + b_context_json nvarchar(max) null, -- 运行变量 + b_snapshot_json nvarchar(max) null, -- 提交时业务快照 + b_current_summary_json nvarchar(max) null, -- 列表展示用当前节点摘要 + b_row_version bigint not null default 0, -- 乐观锁版本号 + b_idempotency_key varchar(100) null, -- 幂等键 + b_started_at datetime2 not null, -- 开始时间 + b_completed_at datetime2 null, -- 结束时间 + b_created_by varchar(50) null, -- 创建人 + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null -- 最后修改时间 +); +GO + +create index ix_wf_instance_business + on dbo.wf_instance (b_module_id, b_business_id, b_event, b_status, b_id); +GO + +create index ix_wf_instance_initiator + on dbo.wf_instance (b_initiator_id, b_status, b_started_at, b_id); +GO + +create index ix_wf_instance_version + on dbo.wf_instance (b_version_id, b_started_at, b_id); +GO + +create unique index ux_wf_instance_idempotency + on dbo.wf_instance (b_module_id, b_business_id, b_idempotency_key) + where b_idempotency_key is not null; +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程实例表(某张业务单据某次提交的运行流程)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'实例 ID(应用层生成 UUIDv7)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程定义编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_definition_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程版本(提交时固定)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_version_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'命中的绑定(wf_binding.b_id)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_binding_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'业务模块编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_module_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'业务事件:submit(送审)/resubmit(重新提交)/change(变更)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_event'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'业务主键(统一按字符串保存)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_business_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'业务单号(列表展示)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_business_no'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'公司/组织编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_org_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'发起人(用户编码)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_initiator_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'发起人部门编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_initiator_dept_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'实例状态:running(运行中)/approved(已通过)/rejected(已驳回)/returned(已退回)/withdrawn(已撤回)/cancelled(已取消)/expired(已过期)/error(异常待处理)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_status'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'运行变量', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_context_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'提交时业务快照', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_snapshot_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'列表展示用当前节点摘要', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_current_summary_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'乐观锁版本号', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_row_version'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'幂等键', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_idempotency_key'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'开始时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_started_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'结束时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_completed_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_created_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_created_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_updated_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance', @level2type=N'COLUMN', @level2name=N'b_updated_at'; +GO + +/* ============================================================================ + 16.8 节点运行 + ============================================================================ */ + +create table dbo.wf_node_run ( + b_id uniqueidentifier not null primary key, -- 节点运行 ID(UUIDv7) + b_instance_id uniqueidentifier not null, -- 流程实例(wf_instance.b_id) + b_node_key varchar(80) not null, -- 节点编码 + b_run_no int not null default 1, -- 同一节点的第几次运行(退回、循环用) + b_status varchar(30) not null, -- 节点状态:pending(待激活)/running(运行中)/completed(已完成)/skipped(已跳过) + b_required_count int null, -- 应签人数(按比例时在节点激活时换算) + b_completed_count int not null default 0, -- 已通过人数 + b_rejected_count int not null default 0, -- 已驳回人数 + b_entered_at datetime2 null, -- 进入时间 + b_exited_at datetime2 null, -- 离开时间 + b_actor_snapshot_json nvarchar(max) null, -- 审批人规则快照 + b_created_at datetime2 null -- 创建时间 +); +GO + +create index ix_wf_node_run_instance + on dbo.wf_node_run (b_instance_id, b_status, b_node_key, b_run_no, b_id); +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点运行表(节点在实例中的一次进入和处理记录)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点运行 ID(UUIDv7)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程实例(wf_instance.b_id)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_instance_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_node_key'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'同一节点的第几次运行(退回、循环用)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_run_no'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点状态:pending(待激活)/running(运行中)/completed(已完成)/skipped(已跳过)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_status'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'应签人数(按比例时在节点激活时换算)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_required_count'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'已通过人数', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_completed_count'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'已驳回人数', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_rejected_count'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'进入时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_entered_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'离开时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_exited_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'审批人规则快照', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_actor_snapshot_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_node_run', @level2type=N'COLUMN', @level2name=N'b_created_at'; +GO + +/* ============================================================================ + 16.9 待办任务 + ============================================================================ */ + +create table dbo.wf_task ( + b_id uniqueidentifier not null primary key, -- 任务 ID(UUIDv7) + b_instance_id uniqueidentifier not null, -- 流程实例 + b_node_run_id uniqueidentifier not null, -- 节点运行(会签、或签聚合判断用) + b_node_key varchar(80) not null, -- 节点编码 + b_actor_rule_id varchar(50) null, -- 命中的审批人规则 + b_assignee_type varchar(30) not null, -- 受理人类型:user(指定用户)/position(岗位)/role(角色)/delegate(委托) + b_assignee_id varchar(50) not null, -- 实际用户编码 + b_seq int not null default 0, -- 同节点内处理顺序(串行会签生效) + b_status varchar(30) not null, -- 任务状态:waiting(未激活)/pending(待处理)/claimed(已抢占)/approved(已通过)/rejected(已驳回)/cancelled(已取消)/expired(已过期) + b_due_at datetime2 null, -- 应处理时间 + b_claimed_by varchar(50) null, -- 抢占人 + b_claimed_at datetime2 null, -- 抢占时间 + b_completed_by varchar(50) null, -- 处理人 + b_completed_at datetime2 null, -- 处理时间 + b_rule_snapshot_json nvarchar(max) null, -- 审批人解析快照 + b_created_at datetime2 null, -- 创建时间 + b_updated_at datetime2 null -- 最后修改时间 +); +GO + +create index ix_wf_task_assignee + on dbo.wf_task (b_assignee_id, b_status, b_due_at, b_id); +GO + +create index ix_wf_task_instance + on dbo.wf_task (b_instance_id, b_status, b_node_run_id, b_id); +GO + +create index ix_wf_task_due + on dbo.wf_task (b_status, b_due_at, b_id); +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'待办任务表(一名实际审批人一行)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'任务 ID(UUIDv7)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程实例', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_instance_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点运行(会签、或签聚合判断用)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_node_run_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_node_key'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'命中的审批人规则', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_actor_rule_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'受理人类型:user(指定用户)/position(岗位)/role(角色)/delegate(委托)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_assignee_type'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'实际用户编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_assignee_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'同节点内处理顺序(串行会签生效)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_seq'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'任务状态:waiting(未激活)/pending(待处理)/claimed(已抢占)/approved(已通过)/rejected(已驳回)/cancelled(已取消)/expired(已过期)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_status'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'应处理时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_due_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'抢占人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_claimed_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'抢占时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_claimed_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'处理人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_completed_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'处理时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_completed_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'审批人解析快照', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_rule_snapshot_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_created_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_task', @level2type=N'COLUMN', @level2name=N'b_updated_at'; +GO + +/* ============================================================================ + 16.10 审批动作 + ============================================================================ */ + +create table dbo.wf_action ( + b_id uniqueidentifier not null primary key, -- 动作 ID(UUIDv7) + b_instance_id uniqueidentifier not null, -- 流程实例 + b_node_run_id uniqueidentifier null, -- 节点运行 + b_task_id uniqueidentifier null, -- 待办任务 + b_action varchar(30) not null, -- 动作编码:submit(送审)/approve(通过)/reject(驳回)/return(退回)/withdraw(撤回)/cancel(取消)/claim(抢占)/transfer(转交)/delegate(委托)/reassign(转派)/add_approver(加签)/urge(催办)/cc(抄送) + b_operator_id varchar(50) null, -- 操作人(用户编码,系统动作为空) + b_from_status varchar(30) null, -- 变更前状态 + b_to_status varchar(30) null, -- 变更后状态 + b_comment nvarchar(2000) null, -- 审批意见 + b_payload_json nvarchar(max) null, -- 动作载荷(命中的条件、边、加签位置等) + b_request_id varchar(64) null, -- 请求 ID + b_trace_id varchar(64) null, -- 链路 ID + b_occurdatetime datetime2 not null -- 发生时间 +); +GO + +create index ix_wf_action_instance + on dbo.wf_action (b_instance_id, b_occurdatetime, b_id); +GO + +create index ix_wf_action_operator + on dbo.wf_action (b_operator_id, b_occurdatetime, b_id); +GO + +create unique index ux_wf_action_request + on dbo.wf_action (b_request_id) + where b_request_id is not null; +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'审批动作表(只追加,不更新历史动作)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'动作 ID(UUIDv7)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程实例', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_instance_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'节点运行', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_node_run_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'待办任务', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_task_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'动作编码:submit(送审)/approve(通过)/reject(驳回)/return(退回)/withdraw(撤回)/cancel(取消)/claim(抢占)/transfer(转交)/delegate(委托)/reassign(转派)/add_approver(加签)/urge(催办)/cc(抄送)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_action'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'操作人(用户编码,系统动作为空)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_operator_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'变更前状态', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_from_status'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'变更后状态', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_to_status'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'审批意见', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_comment'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'动作载荷(命中的条件、边、加签位置等)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_payload_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'请求 ID', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_request_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'链路 ID', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_trace_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'发生时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action', @level2type=N'COLUMN', @level2name=N'b_occurdatetime'; +GO + +/* ============================================================================ + 16.11 抄送记录 + ============================================================================ */ + +create table dbo.wf_cc ( + b_id uniqueidentifier not null primary key, -- 抄送 ID(UUIDv7) + b_instance_id uniqueidentifier not null, -- 流程实例 + b_node_run_id uniqueidentifier null, -- 来源节点运行 + b_node_key varchar(80) null, -- 来源节点编码 + b_recipient_id varchar(50) not null, -- 抄送接收人(用户编码) + b_actor_rule_id varchar(50) null, -- 命中的抄送规则 + b_status varchar(20) not null default 'unread', -- 阅读状态:unread(未读)/read(已读) + b_read_at datetime2 null, -- 阅读时间 + b_rule_snapshot_json nvarchar(max) null, -- 抄送人解析快照 + b_created_at datetime2 null -- 创建时间 +); +GO + +create index ix_wf_cc_recipient + on dbo.wf_cc (b_recipient_id, b_status, b_created_at, b_id); +GO + +create index ix_wf_cc_instance + on dbo.wf_cc (b_instance_id, b_created_at, b_id); +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'抄送记录表(抄送人不产生待办,不参与节点判定)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'抄送 ID(UUIDv7)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程实例', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_instance_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'来源节点运行', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_node_run_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'来源节点编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_node_key'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'抄送接收人(用户编码)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_recipient_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'命中的抄送规则', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_actor_rule_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'阅读状态:unread(未读)/read(已读)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_status'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'阅读时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_read_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'抄送人解析快照', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_rule_snapshot_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_cc', @level2type=N'COLUMN', @level2name=N'b_created_at'; +GO + +/* ============================================================================ + 16.12 委托 + ============================================================================ */ + +create table dbo.wf_delegation ( + b_id varchar(50) not null primary key, -- 委托 ID + b_from_user_id varchar(50) not null, -- 委托人(用户编码) + b_to_user_id varchar(50) not null, -- 受托人(用户编码) + b_scope_json nvarchar(max) null, -- 委托范围(模块、动作、组织) + b_start_at datetime2 not null, -- 生效时间 + b_end_at datetime2 not null, -- 失效时间 + b_reason nvarchar(500) null, -- 委托原因 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_created_by varchar(50) null, -- 创建人 + b_created_at datetime2 null, -- 创建时间 + b_updated_by varchar(50) null, -- 最后修改人 + b_updated_at datetime2 null -- 最后修改时间 +); +GO + +create index ix_wf_delegation_user + on dbo.wf_delegation (b_from_user_id, b_canuse, b_start_at, b_end_at, b_to_user_id); +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'委托表(审批人把待办委托给他人处理)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'委托 ID', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'委托人(用户编码)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_from_user_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'受托人(用户编码)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_to_user_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'委托范围(模块、动作、组织)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_scope_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'生效时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_start_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'失效时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_end_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'委托原因', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_reason'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'是否启用(0/1)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_canuse'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_created_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'创建时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_created_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改人', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_updated_by'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后修改时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_delegation', @level2type=N'COLUMN', @level2name=N'b_updated_at'; +GO + +/* ============================================================================ + 16.13 通知消息 + ============================================================================ */ + +create table dbo.wf_outbox ( + b_id uniqueidentifier not null primary key, -- 消息 ID(UUIDv7) + b_event_code varchar(80) not null, -- 事件编码 + b_aggregate_type varchar(30) not null, -- 聚合类型:instance(流程实例)/task(待办任务) + b_aggregate_id varchar(50) not null, -- 聚合 ID + b_payload_json nvarchar(max) not null, -- 消息载荷 + b_status varchar(20) not null default 'pending', -- 发送状态:pending(待发送)/sent(已发送)/failed(发送失败) + b_retry_count int not null default 0, -- 重试次数 + b_next_retry_at datetime2 null, -- 下次重试时间 + b_last_error nvarchar(2000) null, -- 最后错误 + b_occurdatetime datetime2 not null -- 发生时间 +); +GO + +create index ix_wf_outbox_dispatch + on dbo.wf_outbox (b_status, b_next_retry_at, b_occurdatetime, b_id); +GO + +-- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'通知消息表(outbox,异步发送,不阻塞审批事务)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'消息 ID(UUIDv7)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'事件编码', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_event_code'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'聚合类型:instance(流程实例)/task(待办任务)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_aggregate_type'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'聚合 ID', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_aggregate_id'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'消息载荷', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_payload_json'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'发送状态:pending(待发送)/sent(已发送)/failed(发送失败)', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_status'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'重试次数', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_retry_count'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'下次重试时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_next_retry_at'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'最后错误', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_last_error'; +exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'发生时间', + @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_outbox', @level2type=N'COLUMN', @level2name=N'b_occurdatetime'; +GO + diff --git a/code/g3soft-erp/sql/10_workflow/02_fms_workflow_org_ext.sql b/code/g3soft-erp/sql/10_workflow/02_fms_workflow_org_ext.sql new file mode 100644 index 00000000..404301da --- /dev/null +++ b/code/g3soft-erp/sql/10_workflow/02_fms_workflow_org_ext.sql @@ -0,0 +1,144 @@ +/* ============================================================================ + 工作流审批人解析所需的组织主数据扩展(可选执行) + + 背景:当前库的用户 / 部门主数据只到「用户 - 部门」这一层, + b_user 没有上级、b_dept 没有负责人,也不存在岗位表与组织角色表。 + 因此审批人解析里这四类暂时解析不出人(会记原因并走 fallback): + + dept_manager 部门负责人 → 需要 b_dept.b_manager_id + initiator_manager 发起人直属领导 → 需要 b_user.b_leader_id + position 岗位 → 需要 b_position + b_user_position + org_role 组织角色 → 需要 b_org_role + b_user_org_role + + 执行本脚本后,WorkflowActorResolver 无需改代码即可解析这四类 + (它按「表 / 列是否存在」探测,存在就用)。 + + 说明: + 1. 脚本幂等,可重复执行;只新增列与表,不改动已有数据。 + 2. 岗位 / 组织角色是**组织主数据**,不是功能权限角色(§6.6 明确两者不同)。 + 3. 相关列与表补齐后,请在组织管理页维护负责人 / 上级 / 岗位成员。 + ============================================================================ */ + +SET NOCOUNT ON; +GO + +/* ---------------------------------------------------------------------------- + 1. 部门负责人 + ---------------------------------------------------------------------------- */ + +if not exists (select 1 from sys.columns where object_id = object_id('dbo.b_dept') and name = 'b_manager_id') +begin + alter table dbo.b_dept add b_manager_id varchar(50) null; + exec sys.sp_addextendedproperty @name = N'MS_Description', @value = N'部门负责人(b_user.b_id,工作流 dept_manager 解析用)', + @level0type = N'SCHEMA', @level0name = N'dbo', @level1type = N'TABLE', @level1name = N'b_dept', + @level2type = N'COLUMN', @level2name = N'b_manager_id'; +end +GO + +/* ---------------------------------------------------------------------------- + 2. 用户直属领导 + ---------------------------------------------------------------------------- */ + +if not exists (select 1 from sys.columns where object_id = object_id('dbo.b_user') and name = 'b_leader_id') +begin + alter table dbo.b_user add b_leader_id varchar(50) null; + exec sys.sp_addextendedproperty @name = N'MS_Description', @value = N'直属领导(b_user.b_id,工作流 initiator_manager 解析用)', + @level0type = N'SCHEMA', @level0name = N'dbo', @level1type = N'TABLE', @level1name = N'b_user', + @level2type = N'COLUMN', @level2name = N'b_leader_id'; +end +GO + +/* ---------------------------------------------------------------------------- + 3. 岗位与用户岗位 + ---------------------------------------------------------------------------- */ + +if object_id('dbo.b_position') is null +begin + create table dbo.b_position ( + b_id varchar(50) not null primary key, -- 岗位编码 + b_name nvarchar(200) not null, -- 岗位名称 + b_i18n varchar(150) null, -- 多语言资源键 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_xh int not null default 0, -- 显示顺序 + b_bz nvarchar(2000) null, -- 备注 + b_created_by varchar(50) null, + b_created_at datetime2 null, + b_updated_by varchar(50) null, + b_updated_at datetime2 null + ); +end +GO + +if object_id('dbo.b_user_position') is null +begin + create table dbo.b_user_position ( + b_user_id varchar(50) not null, -- 用户编码 + b_position_id varchar(50) not null, -- 岗位编码 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_created_by varchar(50) null, + b_created_at datetime2 null, + b_updated_by varchar(50) null, + b_updated_at datetime2 null, + primary key (b_user_id, b_position_id) + ); + create index ix_b_user_position_position on dbo.b_user_position (b_position_id, b_canuse, b_user_id); +end +GO + +/* ---------------------------------------------------------------------------- + 4. 组织角色与用户组织角色 + ---------------------------------------------------------------------------- */ + +if object_id('dbo.b_org_role') is null +begin + create table dbo.b_org_role ( + b_id varchar(50) not null primary key, -- 组织角色编码(如 finance / legal_lead / cfo) + b_name nvarchar(200) not null, -- 角色名称 + b_i18n varchar(150) null, -- 多语言资源键 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_xh int not null default 0, -- 显示顺序 + b_bz nvarchar(2000) null, + b_created_by varchar(50) null, + b_created_at datetime2 null, + b_updated_by varchar(50) null, + b_updated_at datetime2 null + ); +end +GO + +if object_id('dbo.b_user_org_role') is null +begin + create table dbo.b_user_org_role ( + b_user_id varchar(50) not null, -- 用户编码 + b_role_id varchar(50) not null, -- 组织角色编码 + b_canuse tinyint not null default 1, -- 是否启用(0/1) + b_created_by varchar(50) null, + b_created_at datetime2 null, + b_updated_by varchar(50) null, + b_updated_at datetime2 null, + primary key (b_user_id, b_role_id) + ); + create index ix_b_user_org_role_role on dbo.b_user_org_role (b_role_id, b_canuse, b_user_id); +end +GO + +/* ---------------------------------------------------------------------------- + 5. 自检 + ---------------------------------------------------------------------------- */ + +select 'b_dept.b_manager_id' as 对象, case when exists ( + select 1 from sys.columns where object_id = object_id('dbo.b_dept') and name = 'b_manager_id') + then N'已就绪' else N'缺失' end as 状态 +union all +select 'b_user.b_leader_id', case when exists ( + select 1 from sys.columns where object_id = object_id('dbo.b_user') and name = 'b_leader_id') + then N'已就绪' else N'缺失' end +union all +select 'b_position', case when object_id('dbo.b_position') is null then N'缺失' else N'已就绪' end +union all +select 'b_user_position', case when object_id('dbo.b_user_position') is null then N'缺失' else N'已就绪' end +union all +select 'b_org_role', case when object_id('dbo.b_org_role') is null then N'缺失' else N'已就绪' end +union all +select 'b_user_org_role', case when object_id('dbo.b_user_org_role') is null then N'缺失' else N'已就绪' end; +GO diff --git a/code/g3soft-erp/sql/20_registration/10_fms_workflow_module.sql b/code/g3soft-erp/sql/20_registration/10_fms_workflow_module.sql new file mode 100644 index 00000000..34f909d4 --- /dev/null +++ b/code/g3soft-erp/sql/20_registration/10_fms_workflow_module.sql @@ -0,0 +1,505 @@ +/* ============================================================================ + FMS 模块注册脚本 — 流程(wf_* 的配置与监控部分) + + 依据文档:FMS新系统核心表结构设计.md「16. 工作流与审批」、 + FMS工作流与审批设计.md(§14 前端页面) + + 前置条件: + wf_* 表已由 fms_workflow.sql 建好。 + + 使用说明: + 1. 执行前先切换到目标数据库(USE FMS)。 + 2. 本脚本写查询视图 + 模块元数据(s_module / s_field / s_module_schema) + + 菜单绑定(s_menu_module),不含业务数据。 + 3. 脚本可重复执行:先按模块路径清理,再重建。 + 4. 四个模块都是只读数据源(b_save_table 为空):流程的写入必须走流程服务 + (校验、版本快照、发布后不可改、幂等都在服务端保证),通用保存接口不参与。 + + 不注册的模块(有意为之): + - wf_task / wf_cc:审批中心的「我的待办 / 抄送我的」是动作页,需要按当前用户 + 过滤、按状态给不同按钮,走专用 API;登记成数据模块会给通用保存接口开后门。 + - wf_node / wf_edge / wf_actor_rule:设计器内部结构,只在画布与属性面板里读写。 + - wf_action:审批历史是单据详情页的时间线,不做独立列表。 + - wf_outbox:内部消息队列,只给运维看。 + ============================================================================ */ + +SET NOCOUNT ON; +GO + +/* ---------------------------------------------------------------------------- + 1. 查询视图 + + 两个目的: + a) 裁剪列:wf_version.b_canvas_json、wf_node.b_config_json、wf_instance.b_context_json + / b_snapshot_json 都是大 JSON,不进程列表字段; + b) 派生展示列:状态枚举拼中文、关联出名称、聚合出计数,避免前端写转换逻辑。 + 约定:v_ 前缀列是派生列(只读),其余列名与基表保持一致。 + ---------------------------------------------------------------------------- */ + +/* 流程定义:带出已发布版本、版本数、绑定模块数 */ +create or alter view dbo.v_wf_definition as +select d.b_id, + d.b_name, + d.b_canuse, + case when d.b_canuse = 1 then N'启用' else N'停用' end as v_canuse_name, + p.b_version_no as v_published_version_no, + p.b_published_at as v_published_at, + p.b_published_by as v_published_by, + (select count(*) from dbo.wf_version x where x.b_definition_id = d.b_id) as v_version_count, + (select count(*) from dbo.wf_binding g + where g.b_canuse = 1 + and exists (select 1 from dbo.wf_version v where v.b_id = g.b_version_id and v.b_definition_id = d.b_id)) as v_binding_count, + d.b_bz, + d.b_created_at, + d.b_updated_at +from dbo.wf_definition d + outer apply (select top 1 v.b_version_no, v.b_published_at, v.b_published_by + from dbo.wf_version v + where v.b_definition_id = d.b_id + and v.b_status = 'published' + order by v.b_version_no desc) p; +GO + +/* 流程版本:状态拼中文,带出定义名称与节点数;b_canvas_json 不进视图 */ +create or alter view dbo.v_wf_version as +select v.b_id, + v.b_definition_id, + d.b_name as v_definition_name, + v.b_version_no, + v.b_status, + case v.b_status + when 'draft' then N'草稿' + when 'published' then N'已发布' + when 'retired' then N'已停用' + else v.b_status end as v_status_name, + (select count(*) from dbo.wf_node n where n.b_version_id = v.b_id) as v_node_count, + v.b_bz, + v.b_published_by, + v.b_published_at, + v.b_updated_at +from dbo.wf_version v + left join dbo.wf_definition d on d.b_id = v.b_definition_id; +GO + +/* 流程绑定:模块名、事件中文、版本显示名 */ +create or alter view dbo.v_wf_binding as +select g.b_id, + g.b_module_id, + m.b_name as v_module_name, + g.b_event, + case g.b_event + when 'submit' then N'送审' + when 'resubmit' then N'重新提交' + when 'change' then N'变更' + else g.b_event end as v_event_name, + g.b_version_id, + isnull(d.b_name + N' V' + cast(v.b_version_no as nvarchar(10)), g.b_version_id) as v_version_name, + g.b_priority, + g.b_effective_from, + g.b_effective_to, + g.b_canuse, + case when g.b_canuse = 1 then N'启用' else N'停用' end as v_canuse_name, + g.b_bz +from dbo.wf_binding g + left join dbo.s_module m on m.b_id = g.b_module_id + left join dbo.wf_version v on v.b_id = g.b_version_id + left join dbo.wf_definition d on d.b_id = v.b_definition_id; +GO + +/* 流程实例(流程监控):状态中文、发起人姓名、当前待办数、耗时;两个大 JSON 不进视图 */ +create or alter view dbo.v_wf_instance as +select i.b_id, + i.b_business_no, + i.b_module_id, + i.b_definition_id, + i.b_version_id, + i.b_initiator_id, + u.b_name as v_initiator_name, + i.b_status, + case i.b_status + when 'running' then N'运行中' + when 'approved' then N'已通过' + when 'rejected' then N'已驳回' + when 'returned' then N'已退回' + when 'withdrawn' then N'已撤回' + when 'cancelled' then N'已取消' + when 'expired' then N'已过期' + when 'error' then N'异常待处理' + else i.b_status end as v_status_name, + (select count(*) from dbo.wf_task t + where t.b_instance_id = i.b_id and t.b_status in ('pending', 'claimed')) as v_pending_task_count, + i.b_started_at, + i.b_completed_at, + datediff(minute, i.b_started_at, isnull(i.b_completed_at, sysutcdatetime())) as v_duration_minutes +from dbo.wf_instance i + left join dbo.b_user u on u.b_id = i.b_initiator_id; +GO + +/* ---------------------------------------------------------------------------- + 2. 清理(脚本可重复执行):本分组下的界面配置、字段、模块行与菜单绑定 + ---------------------------------------------------------------------------- */ + +delete from dbo.s_module_schema +where b_module_id in (select b_id from dbo.s_module where b_path like '/workflow/%'); +GO + +delete from dbo.s_field +where b_module_id in (select b_id from dbo.s_module where b_path like '/workflow/%'); +GO + +delete from dbo.s_module +where b_path like '/workflow/%' or b_id = 'workflow'; +GO + +delete from dbo.s_menu_module where b_menu_id = 'workflow'; +GO + +/* ---------------------------------------------------------------------------- + 3. 分组节点(module 类型):流程 + ---------------------------------------------------------------------------- */ + +insert into dbo.s_module (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_module_type, b_canuse, b_xh, b_bz) +values ('workflow', null, 0, '/workflow/', N'流程', 'module.workflow', 'module', 1, 20, + N'流程定义、版本、绑定与运行实例;只读,写入一律走流程服务'); +GO + +/* ---------------------------------------------------------------------------- + 4. 数据模块(data 类型,只读) + + b_view_table 指向上面的视图,b_save_table 留空 = 列表页不提供新增 / 编辑 / 删除。 + ---------------------------------------------------------------------------- */ + +insert into dbo.s_module (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_module_type, + b_view_table, b_save_table, b_key_field, b_order_sql, b_canuse, b_xh, b_bz) +values ('v_wf_definition', 'workflow', 1, '/workflow/v_wf_definition/', N'流程定义', 'module.v_wf_definition', 'data', + 'v_wf_definition', null, 'b_id', 'b_id ASC', 1, 10, + N'流程的稳定身份;发布内容在版本表,绑定关系在流程绑定表'); +GO + +insert into dbo.s_module (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_module_type, + b_view_table, b_save_table, b_key_field, b_order_sql, b_canuse, b_xh, b_bz) +values ('v_wf_version', 'workflow', 1, '/workflow/v_wf_version/', N'流程版本', 'module.v_wf_version', 'data', + 'v_wf_version', null, 'b_id', 'b_version_no DESC', 1, 20, + N'draft 可编辑,published 可被新实例使用,retired 不再接受新实例;已发布版本不可原地修改'); +GO + +insert into dbo.s_module (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_module_type, + b_view_table, b_save_table, b_key_field, b_order_sql, b_canuse, b_xh, b_bz) +values ('v_wf_binding', 'workflow', 1, '/workflow/v_wf_binding/', N'流程绑定', 'module.v_wf_binding', 'data', + 'v_wf_binding', null, 'b_id', 'b_module_id ASC, b_priority ASC', 1, 30, + N'模块 + 业务事件 + 条件 → 流程版本;同一业务上下文只能匹配一个最终流程'); +GO + +insert into dbo.s_module (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_module_type, + b_view_table, b_save_table, b_key_field, b_order_sql, b_canuse, b_xh, b_bz) +values ('v_wf_instance', 'workflow', 1, '/workflow/v_wf_instance/', N'流程实例', 'module.v_wf_instance', 'data', + 'v_wf_instance', null, 'b_id', 'b_started_at DESC', 1, 40, + N'流程监控:某张业务单据某次提交的运行情况,含当前待办数与耗时'); +GO + +/* ---------------------------------------------------------------------------- + 5. 字段定义 + + b_field 必须存在于对应视图;b_type 决定单元格渲染与查询操作符分组: + input(文本)/ number / datetime。 + ---------------------------------------------------------------------------- */ + +/* 5.1 流程定义 */ +insert into dbo.s_field (b_module_id, b_field, b_name, b_i18n, b_type, b_canuse, b_xh) +values ('v_wf_definition', 'b_id', N'流程编码', null, 'input', 1, 10), + ('v_wf_definition', 'b_name', N'流程名称', null, 'input', 1, 20), + ('v_wf_definition', 'v_published_version_no', N'已发布版本', null, 'number', 1, 30), + ('v_wf_definition', 'v_version_count', N'版本数', null, 'number', 1, 40), + ('v_wf_definition', 'v_binding_count', N'绑定模块数', null, 'number', 1, 50), + ('v_wf_definition', 'v_published_at', N'发布时间', null, 'datetime', 1, 60), + ('v_wf_definition', 'v_published_by', N'发布人', null, 'input', 1, 70), + ('v_wf_definition', 'v_canuse_name', N'是否启用', null, 'input', 1, 80), + ('v_wf_definition', 'b_bz', N'备注', null, 'input', 1, 90), + ('v_wf_definition', 'b_updated_at', N'最后修改时间', null, 'datetime', 1, 100); +GO + +/* 5.2 流程版本 */ +insert into dbo.s_field (b_module_id, b_field, b_name, b_i18n, b_type, b_canuse, b_xh) +values ('v_wf_version', 'b_definition_id', N'流程编码', null, 'input', 1, 10), + ('v_wf_version', 'v_definition_name', N'流程名称', null, 'input', 1, 20), + ('v_wf_version', 'b_version_no', N'版本号', null, 'number', 1, 30), + ('v_wf_version', 'v_status_name', N'版本状态', null, 'input', 1, 40), + ('v_wf_version', 'v_node_count', N'节点数', null, 'number', 1, 50), + ('v_wf_version', 'b_published_by', N'发布人', null, 'input', 1, 60), + ('v_wf_version', 'b_published_at', N'发布时间', null, 'datetime', 1, 70), + ('v_wf_version', 'b_bz', N'备注', null, 'input', 1, 80), + ('v_wf_version', 'b_updated_at', N'最后修改时间', null, 'datetime', 1, 90), + ('v_wf_version', 'b_id', N'版本 ID', null, 'input', 1, 100); +GO + +/* 5.3 流程绑定 */ +insert into dbo.s_field (b_module_id, b_field, b_name, b_i18n, b_type, b_canuse, b_xh) +values ('v_wf_binding', 'b_module_id', N'模块编码', null, 'input', 1, 10), + ('v_wf_binding', 'v_module_name', N'模块名称', null, 'input', 1, 20), + ('v_wf_binding', 'v_event_name', N'业务事件', null, 'input', 1, 30), + ('v_wf_binding', 'v_version_name', N'流程版本', null, 'input', 1, 40), + ('v_wf_binding', 'b_priority', N'优先级', null, 'number', 1, 50), + ('v_wf_binding', 'b_effective_from', N'生效开始', null, 'datetime', 1, 60), + ('v_wf_binding', 'b_effective_to', N'生效结束', null, 'datetime', 1, 70), + ('v_wf_binding', 'v_canuse_name', N'是否启用', null, 'input', 1, 80), + ('v_wf_binding', 'b_bz', N'备注', null, 'input', 1, 90), + ('v_wf_binding', 'b_id', N'绑定 ID', null, 'input', 1, 100); +GO + +/* 5.4 流程实例 */ +insert into dbo.s_field (b_module_id, b_field, b_name, b_i18n, b_type, b_canuse, b_xh) +values ('v_wf_instance', 'b_business_no', N'业务单号', null, 'input', 1, 10), + ('v_wf_instance', 'b_module_id', N'模块编码', null, 'input', 1, 20), + ('v_wf_instance', 'b_definition_id', N'流程编码', null, 'input', 1, 30), + ('v_wf_instance', 'v_initiator_name', N'发起人', null, 'input', 1, 40), + ('v_wf_instance', 'v_status_name', N'实例状态', null, 'input', 1, 50), + ('v_wf_instance', 'v_pending_task_count', N'当前待办数', null, 'number', 1, 60), + ('v_wf_instance', 'b_started_at', N'开始时间', null, 'datetime', 1, 70), + ('v_wf_instance', 'b_completed_at', N'结束时间', null, 'datetime', 1, 80), + ('v_wf_instance', 'v_duration_minutes', N'耗时(分钟)', null, 'number', 1, 90), + ('v_wf_instance', 'b_id', N'实例 ID', null, 'input', 1, 100); +GO + +/* ---------------------------------------------------------------------------- + 6. 界面配置(s_module_schema):view / query + + 只读模块不生成 edit 配置。列宽按内容给:编码类窄、名称类宽、时间类 170。 + ---------------------------------------------------------------------------- */ + +insert into dbo.s_module_schema (b_module_id, b_schema_type, b_schema_json, b_canuse) +values ('v_wf_definition', 'view', N'{ + "schemaVersion": 1, + "columns": [ + { "field": "b_id", "width": 160 }, + { "field": "b_name", "width": 200 }, + { "field": "v_published_version_no", "width": 100 }, + { "field": "v_version_count", "width": 90 }, + { "field": "v_binding_count", "width": 100 }, + { "field": "v_canuse_name", "width": 90 }, + { "field": "v_published_at", "width": 170 }, + { "field": "v_published_by", "width": 110 }, + { "field": "b_bz", "width": 200, "visible": false } + ] +}', 1), + ('v_wf_definition', 'query', N'{ + "schemaVersion": 2, + "defaultViewCode": "all", + "views": [ + { "code": "all", "label": "全部", "order": 10 }, + { "code": "recent_published", "label": "近三个月发布", "order": 20, + "conditions": [ { "field": "v_published_at", "operator": "between", "value": { "preset": "recent_3_months" } } ] }, + { "code": "unpublished", "label": "未发布", "order": 30, + "conditions": [ { "field": "v_published_at", "operator": "is_null" } ] } + ], + "keywordSearch": { + "placeholder": "搜索流程名称、编码", + "trigger": "enter_or_debounce", + "fields": ["b_name", "b_id"] + }, + "quickFilters": [ + { "field": "v_published_at", "order": 10 }, + { "field": "v_canuse_name", "order": 20 } + ], + "filterGroups": [ + { "code": "basic", "label": "基础信息", "order": 10, "fields": ["b_name", "b_id", "v_canuse_name"] }, + { "code": "time", "label": "发布时间", "order": 20, "fields": ["v_published_at", "v_published_by"] } + ], + "queryFields": { + "b_name": { "component": "input", "operator": "like" }, + "b_id": { "component": "input", "operator": "like" }, + "v_canuse_name": { "component": "select", "operator": "eq" }, + "v_published_at": { "component": "datetime", "operator": "between" }, + "v_published_by": { "component": "input", "operator": "like" } + } +}', 1); +GO + +insert into dbo.s_module_schema (b_module_id, b_schema_type, b_schema_json, b_canuse) +values ('v_wf_version', 'view', N'{ + "schemaVersion": 1, + "columns": [ + { "field": "b_definition_id", "width": 160, "visible": false }, + { "field": "v_definition_name","width": 200 }, + { "field": "b_version_no", "width": 90 }, + { "field": "v_status_name", "width": 90 }, + { "field": "v_node_count", "width": 90 }, + { "field": "b_published_at", "width": 170 }, + { "field": "b_published_by", "width": 110 }, + { "field": "b_bz", "width": 200, "visible": false } + ] +}', 1), + ('v_wf_version', 'query', N'{ + "schemaVersion": 2, + "defaultViewCode": "all", + "views": [ + { "code": "all", "label": "全部", "order": 10 }, + { "code": "unpublished", "label": "未发布", "order": 20, + "conditions": [ { "field": "b_published_at", "operator": "is_null" } ] } + ], + "keywordSearch": { + "placeholder": "搜索流程名称、定义编码", + "trigger": "enter_or_debounce", + "fields": ["v_definition_name", "b_definition_id"] + }, + "quickFilters": [ + { "field": "b_published_at", "order": 10 }, + { "field": "v_status_name", "order": 20 } + ], + "filterGroups": [ + { "code": "basic", "label": "基础信息", "order": 10, "fields": ["v_definition_name", "b_definition_id", "v_status_name", "v_node_count"] }, + { "code": "time", "label": "发布时间", "order": 20, "fields": ["b_published_at", "b_published_by"] } + ], + "queryFields": { + "b_definition_id": { "component": "input", "operator": "eq" }, + "v_definition_name": { "component": "input", "operator": "like" }, + "v_status_name": { "component": "select", "operator": "eq" }, + "b_published_at": { "component": "datetime", "operator": "between" }, + "b_published_by": { "component": "input", "operator": "like" } + } +}', 1); +GO + +insert into dbo.s_module_schema (b_module_id, b_schema_type, b_schema_json, b_canuse) +values ('v_wf_binding', 'view', N'{ + "schemaVersion": 1, + "columns": [ + { "field": "b_module_id", "width": 160, "visible": false }, + { "field": "v_module_name", "width": 180 }, + { "field": "v_event_name", "width": 100 }, + { "field": "v_version_name", "width": 220 }, + { "field": "b_priority", "width": 80 }, + { "field": "v_canuse_name", "width": 90 }, + { "field": "b_effective_from","width": 170 }, + { "field": "b_effective_to", "width": 170, "visible": false }, + { "field": "b_bz", "width": 200, "visible": false } + ] +}', 1), + ('v_wf_binding', 'query', N'{ + "schemaVersion": 2, + "defaultViewCode": "all", + "views": [ + { "code": "all", "label": "全部", "order": 10 } + ], + "keywordSearch": { + "placeholder": "搜索模块、流程版本", + "trigger": "enter_or_debounce", + "fields": ["v_module_name", "v_version_name", "b_module_id"] + }, + "quickFilters": [ + { "field": "v_event_name", "order": 10 }, + { "field": "v_canuse_name", "order": 20 } + ], + "filterGroups": [ + { "code": "basic", "label": "基础信息", "order": 10, "fields": ["b_module_id", "v_module_name", "v_event_name", "v_version_name"] }, + { "code": "state", "label": "状态", "order": 20, "fields": ["v_canuse_name", "b_priority", "b_effective_from", "b_effective_to"] } + ], + "queryFields": { + "b_module_id": { "component": "input", "operator": "eq" }, + "v_module_name": { "component": "input", "operator": "like" }, + "v_event_name": { "component": "select", "operator": "eq" }, + "v_version_name": { "component": "input", "operator": "like" }, + "v_canuse_name": { "component": "select", "operator": "eq" }, + "b_priority": { "component": "number", "operator": "eq" }, + "b_effective_from": { "component": "datetime", "operator": "between" }, + "b_effective_to": { "component": "datetime", "operator": "between" } + } +}', 1); +GO + +insert into dbo.s_module_schema (b_module_id, b_schema_type, b_schema_json, b_canuse) +values ('v_wf_instance', 'view', N'{ + "schemaVersion": 1, + "columns": [ + { "field": "b_business_no", "width": 180 }, + { "field": "b_module_id", "width": 150, "visible": false }, + { "field": "b_definition_id", "width": 160, "visible": false }, + { "field": "v_initiator_name", "width": 110 }, + { "field": "v_status_name", "width": 100 }, + { "field": "v_pending_task_count", "width": 110 }, + { "field": "b_started_at", "width": 170 }, + { "field": "b_completed_at", "width": 170 }, + { "field": "v_duration_minutes", "width": 110 } + ] +}', 1), + ('v_wf_instance', 'query', N'{ + "schemaVersion": 2, + "defaultViewCode": "all", + "views": [ + { "code": "all", "label": "全部", "order": 10 }, + { "code": "running", "label": "进行中", "order": 20, "showCount": true, + "conditions": [ { "field": "b_completed_at", "operator": "is_null" } ] }, + { "code": "today", "label": "今天发起", "order": 30, + "conditions": [ { "field": "b_started_at", "operator": "between", "value": { "preset": "today" } } ] } + ], + "keywordSearch": { + "placeholder": "搜索业务单号、发起人", + "trigger": "enter_or_debounce", + "fields": ["b_business_no", "v_initiator_name", "b_module_id"] + }, + "quickFilters": [ + { "field": "b_started_at", "order": 10 }, + { "field": "v_status_name", "order": 20 } + ], + "filterGroups": [ + { "code": "basic", "label": "基础信息", "order": 10, "fields": ["b_business_no", "b_module_id", "b_definition_id", "v_initiator_name"] }, + { "code": "state", "label": "状态与耗时", "order": 20, "fields": ["v_status_name", "v_pending_task_count", "v_duration_minutes"] }, + { "code": "time", "label": "时间", "order": 30, "fields": ["b_started_at", "b_completed_at"] } + ], + "queryFields": { + "b_business_no": { "component": "input", "operator": "like" }, + "v_initiator_name": { "component": "input", "operator": "like" }, + "v_status_name": { "component": "select", "operator": "eq" }, + "b_started_at": { "component": "datetime", "operator": "between" }, + "b_completed_at": { "component": "datetime", "operator": "between" } + } +}', 1); +GO + +/* ---------------------------------------------------------------------------- + 7. 菜单绑定(s_menu_module):流程管理页挂上三个配置模块 + 审批中心各页是专用页面(待办 / 抄送 / 我发起的 / 我已处理),不绑数据模块。 + ---------------------------------------------------------------------------- */ + +insert into dbo.s_menu_module (b_menu_id, b_module_id, b_xh, b_canuse) +values ('workflow', 'v_wf_definition', 10, 1), + ('workflow', 'v_wf_version', 20, 1), + ('workflow', 'v_wf_binding', 30, 1), + ('workflow', 'v_wf_instance', 40, 1); +GO + +/* ---------------------------------------------------------------------------- + 8. 多语言(s_i18n):模块名,默认语言为中文时写入中文值 + ---------------------------------------------------------------------------- */ + +declare @i18n table (k varchar(150) primary key, v nvarchar(1000) not null); +insert into @i18n (k, v) values + ('module.workflow', N'流程'), + ('module.v_wf_definition', N'流程定义'), + ('module.v_wf_version', N'流程版本'), + ('module.v_wf_binding', N'流程绑定'), + ('module.v_wf_instance', N'流程实例'); + +insert into dbo.s_i18n (b_key, b_locale, b_value, b_canuse) +select i.k, t.b_id, i.v, 1 +from @i18n i + cross join (select b_id from dbo.s_i18n_type where b_default = 1) t +where not exists (select 1 from dbo.s_i18n x where x.b_key = i.k and x.b_locale = t.b_id); +GO + +/* ---------------------------------------------------------------------------- + 9. 自检 + ---------------------------------------------------------------------------- */ + +select '模块' as 项, count(*) as 行数 from dbo.s_module where b_path like '/workflow/%' or b_id = 'workflow' +union all +select '字段', count(*) from dbo.s_field where b_module_id in + ('v_wf_definition', 'v_wf_version', 'v_wf_binding', 'v_wf_instance') +union all +select '界面配置', count(*) from dbo.s_module_schema where b_module_id in + ('v_wf_definition', 'v_wf_version', 'v_wf_binding', 'v_wf_instance') +union all +select '菜单绑定', count(*) from dbo.s_menu_module where b_menu_id = 'workflow' +union all +select '视图', count(*) from sys.views where name in + ('v_wf_definition', 'v_wf_version', 'v_wf_binding', 'v_wf_instance'); +GO \ No newline at end of file diff --git a/code/g3soft-erp/sql/20_registration/11_fms_workflow_menu.sql b/code/g3soft-erp/sql/20_registration/11_fms_workflow_menu.sql new file mode 100644 index 00000000..8498b90b --- /dev/null +++ b/code/g3soft-erp/sql/20_registration/11_fms_workflow_menu.sql @@ -0,0 +1,97 @@ +/* ============================================================================ + FMS 工作流菜单(s_menu) + + 用途:挂两处菜单,与前端路由一一对应(见 fms-vue/src/router/routes.js): + 1. 「系统管理 → 流程管理」→ /system/workflow(管理员配置流程) + 2. 「审批中心」→ /approval 下的四个页面(我的待办 / 我发起的 / 我已处理 / 抄送我的) + + 说明: + 1. 幂等:菜单按 b_id 判断存在性;日志菜单的排序号只在仍是旧值时后移。 + 2. 权限:本脚本不建权限点。侧栏可见性与路由进入由 menu.{菜单编码} 授权控制 + (g3soft 超管默认可进入);需要给普通账号开放时,另行补 s_power(menu.xxx) + + s_user_power 授权。 + 3. 排序:一级菜单 审批中心 5 → 基础资料 10 → 系统管理 10; + 系统管理内 用户管理 10 → 流程管理 20 → 日志 30。 + ============================================================================ */ + +-- 流程管理:系统管理下的页面菜单 +if not exists (select 1 from dbo.s_menu where b_id = 'workflow') +begin + insert into dbo.s_menu (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_menu_type, b_route, b_icon, b_xh, b_canuse) + values ('workflow', 'system', 1, '/system/workflow/', N'流程管理', 'menu.workflow', 'page', '/system/workflow', null, 20, 1); +end +GO + +-- 日志让位:目录后移到 30(只在还是 20 时执行,重复运行无副作用) +update dbo.s_menu set b_xh = 30 where b_id = 'log' and b_xh = 20; +GO + +/* ---------------------------------------------------------------------------- + 审批中心(一级菜单):业务人员的日常入口,与「系统管理 → 流程管理」分开 + ---------------------------------------------------------------------------- */ + +if not exists (select 1 from dbo.s_menu where b_id = 'approval') +begin + insert into dbo.s_menu (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_menu_type, b_route, b_icon, b_xh, b_canuse) + values ('approval', null, 0, '/approval/', N'审批中心', 'menu.approval', 'directory', null, null, 5, 1); +end +GO + +if not exists (select 1 from dbo.s_menu where b_id = 'approval_todo') +begin + insert into dbo.s_menu (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_menu_type, b_route, b_icon, b_xh, b_canuse) + values ('approval_todo', 'approval', 1, '/approval/approval_todo/', N'我的待办', 'menu.approval_todo', 'page', '/approval/todo', null, 10, 1); +end +GO + +if not exists (select 1 from dbo.s_menu where b_id = 'approval_initiated') +begin + insert into dbo.s_menu (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_menu_type, b_route, b_icon, b_xh, b_canuse) + values ('approval_initiated', 'approval', 1, '/approval/approval_initiated/', N'我发起的', 'menu.approval_initiated', 'page', '/approval/initiated', null, 20, 1); +end +GO + +if not exists (select 1 from dbo.s_menu where b_id = 'approval_done') +begin + insert into dbo.s_menu (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_menu_type, b_route, b_icon, b_xh, b_canuse) + values ('approval_done', 'approval', 1, '/approval/approval_done/', N'我已处理', 'menu.approval_done', 'page', '/approval/done', null, 30, 1); +end +GO + +if not exists (select 1 from dbo.s_menu where b_id = 'approval_cc') +begin + insert into dbo.s_menu (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_menu_type, b_route, b_icon, b_xh, b_canuse) + values ('approval_cc', 'approval', 1, '/approval/approval_cc/', N'抄送我的', 'menu.approval_cc', 'page', '/approval/cc', null, 40, 1); +end +GO + +/* ---------------------------------------------------------------------------- + 多语言(s_i18n):菜单名,默认语言为中文时写入中文值 + 已存在同键同语言的行不覆盖(人工翻译优先)。 + ---------------------------------------------------------------------------- */ + +declare @i18n table (k varchar(150) primary key, v nvarchar(1000) not null); +insert into @i18n (k, v) values + ('menu.workflow', N'流程管理'), + ('menu.approval', N'审批中心'), + ('menu.approval_todo', N'我的待办'), + ('menu.approval_initiated', N'我发起的'), + ('menu.approval_done', N'我已处理'), + ('menu.approval_cc', N'抄送我的'); + +insert into dbo.s_i18n (b_key, b_locale, b_value, b_canuse) +select i.k, t.b_id, i.v, 1 +from @i18n i + cross join (select b_id from dbo.s_i18n_type where b_default = 1) t +where not exists (select 1 from dbo.s_i18n x where x.b_key = i.k and x.b_locale = t.b_id); +GO + +/* ---------------------------------------------------------------------------- + 自检 + ---------------------------------------------------------------------------- */ + +select '菜单' as 项, count(*) as 行数 from dbo.s_menu +where b_id in ('workflow', 'log', 'approval', 'approval_todo', 'approval_initiated', 'approval_done', 'approval_cc') +union all +select '多语言', count(*) from dbo.s_i18n where b_key like 'menu.workflow' or b_key like 'menu.approval%'; +GO \ No newline at end of file diff --git a/code/g3soft-erp/sql/20_registration/12_fms_workflow_power.sql b/code/g3soft-erp/sql/20_registration/12_fms_workflow_power.sql new file mode 100644 index 00000000..2943e7cf --- /dev/null +++ b/code/g3soft-erp/sql/20_registration/12_fms_workflow_power.sql @@ -0,0 +1,96 @@ +/* ============================================================================ + 工作流权限点注册(s_power / s_user_power) + + 依据文档:FMS工作流与审批设计.md §10.1 / §10.2(动作权限 action.{模块}.{动作})、 + §14.4(审批中心四页与流程管理的菜单授权) + + 说明: + 1. 运行侧服务是白名单语义:s_power 里没有对应权限点、或用户没被授权,一律拒绝 + (WorkflowRuntimeService.requireAction)。所以流程上线前必须先把权限点补上。 + 2. 菜单权限点:menu.{菜单编码},b_object_type='menu',b_action='access'。 + 3. 动作权限点按「已配置流程绑定的模块」批量生成,共 11 个动作 + (submit / approve / reject / return / withdraw / transfer / delegate / reassign / + add_approver / urge / workflow_admin)。 + 4. g3soft 是管理员账号(后端已提权),这里仍显式授权,便于在用户权限页看到。 + 5. 脚本幂等,可重复执行。 + ============================================================================ */ + +SET NOCOUNT ON; +GO + +/* ---------------------------------------------------------------------------- + 1. 菜单权限点 + ---------------------------------------------------------------------------- */ + +declare @menus table (b_id varchar(150), b_name nvarchar(200), b_menu_id varchar(50), b_xh int); + +insert into @menus (b_id, b_name, b_menu_id, b_xh) +values + ('menu.workflow', N'流程管理', 'workflow', 10), + ('menu.approval', N'审批中心', 'approval', 20), + ('menu.approval_todo', N'我的待办', 'approval_todo', 30), + ('menu.approval_initiated', N'我发起的', 'approval_initiated', 40), + ('menu.approval_done', N'我已处理', 'approval_done', 50), + ('menu.approval_cc', N'抄送我的', 'approval_cc', 60); + +insert into dbo.s_power (b_id, b_name, b_type, b_object_type, b_object_id, b_action, b_canuse, b_xh) +select m.b_id, m.b_name, 'menu', 'menu', m.b_menu_id, 'access', 1, m.b_xh + from @menus m + where exists (select 1 from dbo.s_menu s where s.b_id = m.b_menu_id) + and not exists (select 1 from dbo.s_power p where p.b_id = m.b_id); +GO + +/* ---------------------------------------------------------------------------- + 2. 动作权限点:按已配置绑定的模块生成 + ---------------------------------------------------------------------------- */ + +declare @actions table (b_action varchar(30), b_name nvarchar(200), b_xh int); + +insert into @actions (b_action, b_name, b_xh) +values + ('submit', N'送审', 10), + ('approve', N'通过', 20), + ('reject', N'驳回', 30), + ('return', N'退回', 40), + ('withdraw', N'撤回', 50), + ('transfer', N'转交', 60), + ('delegate', N'委托', 70), + ('reassign', N'转派', 80), + ('add_approver', N'加签', 90), + ('urge', N'催办', 100), + ('workflow_admin', N'流程管理员', 110); + +insert into dbo.s_power (b_id, b_name, b_type, b_object_type, b_object_id, b_action, b_canuse, b_xh) +select 'action.' + b.b_module_id + '.' + a.b_action, + isnull(m.b_name, b.b_module_id) + N' - ' + a.b_name, + 'action', 'module', b.b_module_id, a.b_action, 1, a.b_xh + from (select distinct b_module_id from dbo.wf_binding where b_canuse = 1) b + left join dbo.s_module m on m.b_id = b.b_module_id + cross join @actions a + where not exists ( + select 1 from dbo.s_power p + where p.b_id = 'action.' + b.b_module_id + '.' + a.b_action); +GO + +/* ---------------------------------------------------------------------------- + 3. 管理员显式授权(后端对 g3soft 已提权,这里只是为了在权限页可见) + ---------------------------------------------------------------------------- */ + +insert into dbo.s_user_power (b_user_id, b_power_id, b_canuse, b_created_by, b_created_at, b_updated_by, b_updated_at) +select u.b_id, p.b_id, 1, 'system', sysutcdatetime(), 'system', sysutcdatetime() + from dbo.b_user u + cross join dbo.s_power p + where u.b_id = 'g3soft' + and (p.b_id like 'menu.%' or p.b_id like 'action.%') + and not exists ( + select 1 from dbo.s_user_power up where up.b_user_id = u.b_id and up.b_power_id = p.b_id); +GO + +/* ---------------------------------------------------------------------------- + 4. 自检 + ---------------------------------------------------------------------------- */ + +select '菜单权限点' as 项目, count(1) as 数量 from dbo.s_power where b_id like 'menu.%' +union all +select '动作权限点', count(1) from dbo.s_power where b_id like 'action.%'; +GO diff --git a/code/g3soft-erp/sql/30_patches/01_fms_workflow_index_patch.sql b/code/g3soft-erp/sql/30_patches/01_fms_workflow_index_patch.sql new file mode 100644 index 00000000..41da7436 --- /dev/null +++ b/code/g3soft-erp/sql/30_patches/01_fms_workflow_index_patch.sql @@ -0,0 +1,41 @@ +/* ============================================================ + 工作流补丁:补齐缺失索引 + ------------------------------------------------------------ + 来源:迁移评审发现的三处全表扫描 / 排序开销。 + 幂等:按 sys.indexes 判断,可重复执行。 + ============================================================ */ + +/* 发布路径:allowedConditionFields / deleteDefinition 都按 b_version_id 直查, + 而原有索引 ix_wf_binding_select 不以 b_version_id 打头 → 每次发布必扫。 */ +if not exists (select 1 from sys.indexes + where name = N'ix_wf_binding_version' + and object_id = object_id(N'dbo.wf_binding')) +begin + create index ix_wf_binding_version + on dbo.wf_binding (b_version_id) + include (b_module_id, b_canuse, b_priority); + print N'已创建 ix_wf_binding_version'; +end +go + +/* 「我已处理」列表按 b_completed_by 过滤,原 wf_task 只按 assignee/instance/due 建索引。 */ +if not exists (select 1 from sys.indexes + where name = N'ix_wf_task_completed_by' + and object_id = object_id(N'dbo.wf_task')) +begin + create index ix_wf_task_completed_by + on dbo.wf_task (b_completed_by, b_completed_at desc); + print N'已创建 ix_wf_task_completed_by'; +end +go + +/* 串行会签提升 / cancelSiblingTasks 都按 (node_run, status, seq) 取行。 */ +if not exists (select 1 from sys.indexes + where name = N'ix_wf_task_node_run' + and object_id = object_id(N'dbo.wf_task')) +begin + create index ix_wf_task_node_run + on dbo.wf_task (b_node_run_id, b_status, b_seq, b_id); + print N'已创建 ix_wf_task_node_run'; +end +go diff --git a/code/g3soft-erp/sql/30_patches/02_fms_workflow_unique_index_patch.sql b/code/g3soft-erp/sql/30_patches/02_fms_workflow_unique_index_patch.sql new file mode 100644 index 00000000..48b7effd --- /dev/null +++ b/code/g3soft-erp/sql/30_patches/02_fms_workflow_unique_index_patch.sql @@ -0,0 +1,81 @@ +/* ============================================================ + 工作流补丁:恢复缺失的「筛选唯一索引」 + ------------------------------------------------------------ + 背景:现有 G3ERP 库的 wf_* 表已建,但基础 DDL 里的 4 个筛选唯一索引 + (unique filtered index)在库中并不存在。缺了它们意味着: + + - 同一定义可以并存多条草稿(应用层靠代码判,DB 层无兜底); + - submit 的幂等键 b_idempotency_key 不生效 —— 重复提交不会被拦; + - wf_action.b_request_id 不生效 —— 重复审批不会被拦; + - 同一模块/事件下可以有两条同优先级启用绑定。 + + 本脚本幂等;创建前先查重,有冲突则 RAISERROR 报错而不是静默失败。 + ============================================================ */ + +if not exists (select 1 from sys.indexes + where name = N'ux_wf_version_draft' and object_id = object_id(N'dbo.wf_version')) +begin + if exists (select 1 from dbo.wf_version + where b_status = 'draft' + group by b_definition_id having count(1) > 1) + raiserror(N'存在同一流程定义的多条草稿版本,无法创建 ux_wf_version_draft,请先清理', 16, 1); + else + begin + create unique index ux_wf_version_draft + on dbo.wf_version (b_definition_id) + where b_status = 'draft'; + print N'已创建 ux_wf_version_draft'; + end +end +go + +if not exists (select 1 from sys.indexes + where name = N'ux_wf_binding_active_priority' and object_id = object_id(N'dbo.wf_binding')) +begin + if exists (select 1 from dbo.wf_binding + where b_canuse = 1 + group by b_module_id, b_event, b_priority having count(1) > 1) + raiserror(N'存在同一模块/事件下同优先级的启用绑定,无法创建 ux_wf_binding_active_priority,请先清理', 16, 1); + else + begin + create unique index ux_wf_binding_active_priority + on dbo.wf_binding (b_module_id, b_event, b_priority) + where b_canuse = 1; + print N'已创建 ux_wf_binding_active_priority'; + end +end +go + +if not exists (select 1 from sys.indexes + where name = N'ux_wf_instance_idempotency' and object_id = object_id(N'dbo.wf_instance')) +begin + if exists (select 1 from dbo.wf_instance + where b_idempotency_key is not null + group by b_module_id, b_business_id, b_idempotency_key having count(1) > 1) + raiserror(N'存在重复的幂等键,无法创建 ux_wf_instance_idempotency,请先清理', 16, 1); + else + begin + create unique index ux_wf_instance_idempotency + on dbo.wf_instance (b_module_id, b_business_id, b_idempotency_key) + where b_idempotency_key is not null; + print N'已创建 ux_wf_instance_idempotency'; + end +end +go + +if not exists (select 1 from sys.indexes + where name = N'ux_wf_action_request' and object_id = object_id(N'dbo.wf_action')) +begin + if exists (select 1 from dbo.wf_action + where b_request_id is not null + group by b_request_id having count(1) > 1) + raiserror(N'存在重复的请求 ID,无法创建 ux_wf_action_request,请先清理', 16, 1); + else + begin + create unique index ux_wf_action_request + on dbo.wf_action (b_request_id) + where b_request_id is not null; + print N'已创建 ux_wf_action_request'; + end +end +go diff --git a/code/g3soft-erp/sql/90_mock/01_fms_workflow_mock.sql b/code/g3soft-erp/sql/90_mock/01_fms_workflow_mock.sql new file mode 100644 index 00000000..a569454a --- /dev/null +++ b/code/g3soft-erp/sql/90_mock/01_fms_workflow_mock.sql @@ -0,0 +1,275 @@ +/* ============================================================================ + FMS 工作流模拟数据 —— 费用报销审批(expense_approval) + + 用途:给流程设计器 / 流程管理页造一份「够复杂」的样本,用来测试编辑体验。 + 不是业务模板,也不代表任何一家公司的真实审批制度。 + + 刻意覆盖到的能力(对照《FMS工作流与审批设计》): + - 7 种节点类型全用到:start / user_task / cc_task / exclusive_gateway + / parallel_gateway / service_task / end(§4.4) + - 6 种审批方式全用到:single / any / all / serial / n_of_m / percentage(§6.4) + - 条件分支(两条显式条件左右分开、再汇合到中间,合起来覆盖全部取值)、并行分支与汇合、抄送节点(§9.9) + - 8 种审批人解析类型中的 5 种:dept_manager / position / org_role / user(§6.6) + - 节点扩展配置:requiredCount(n_of_m)、requiredRatio(percentage)、 + formPolicy(只读/必填字段)、dueHours、allowClaim、commentRequired + + 前置条件:wf_* 表已由 fms_workflow.sql 建好。 + 使用说明: + 1. 执行前先切换到目标数据库(USE FMS)。 + 2. 本脚本可重复执行:先按流程编码清理本流程的版本/节点/连线/规则,再重建。 + 3. 只写 wf_definition / wf_version / wf_node / wf_edge / wf_actor_rule; + 不写 wf_binding(绑哪个模块是实施决定),也不写任何运行表。 + + 流程走向(两条条件分支都是左右分开、再汇合到中间): + 开始 → 部门负责人审批(或签) → 金额判断 + ├─ 金额 > 10000 → 总经理审批(单人) ┐ + └─ 金额 <= 10000 → 财务复核(会签) ┴→ 并行分支 + ├→ 法务串行会签(串行) ┐ + ├→ 抄送财务总监(抄送) ├→ 并行汇合 → 是否超阈值 + └→ 风控按比例通过(0.6)┘ ├─ 金额 >= 50000 → 董事长审批(N 人通过,2 人) ┐ + └─ 金额 < 50000 → 归档 ────────────────────┴→ 结束 + ============================================================================ */ + +SET NOCOUNT ON; + +declare @def varchar(50) = 'expense_approval'; +declare @ver varchar(50) = 'expense_approval_v1'; +declare @by varchar(50) = 'g3soft'; +declare @now datetime2 = sysutcdatetime(); + +/* ---------------------------------------------------------------------------- + 1. 清理(脚本可重复执行):只动本流程自己的行 + ---------------------------------------------------------------------------- */ + +delete from dbo.wf_actor_rule +where b_version_id in (select b_id from dbo.wf_version where b_definition_id = @def); + +delete from dbo.wf_edge +where b_version_id in (select b_id from dbo.wf_version where b_definition_id = @def); + +delete from dbo.wf_node +where b_version_id in (select b_id from dbo.wf_version where b_definition_id = @def); + +delete from dbo.wf_binding +where b_version_id in (select b_id from dbo.wf_version where b_definition_id = @def); + +delete from dbo.wf_version where b_definition_id = @def; +delete from dbo.wf_definition where b_id = @def; + +/* ---------------------------------------------------------------------------- + 2. 流程定义 + 版本 + + b_canvas_json 只放界面布局(节点坐标),不参与执行(§6.2)。 + 格式与设计器 domain 层约定一致:{ schemaVersion, nodes: { : {x,y} } } + 坐标是纵向(TB)排布:主干在 x=520 的中线上,两个条件分支的两条支线分别落在中线 + 左右(x=240 / x=800),到汇合节点再回到中线。 + ---------------------------------------------------------------------------- */ + +insert into dbo.wf_definition (b_id, b_name, b_i18n, b_canuse, b_bz, + b_created_by, b_created_at, b_updated_by, b_updated_at) +values (@def, N'费用报销审批', 'workflow.expense_approval', 1, + N'模拟数据:覆盖条件分支、并行、会签、串行会签、N 人通过、按比例通过与抄送', + @by, @now, @by, @now); + +insert into dbo.wf_version (b_id, b_definition_id, b_version_no, b_status, b_schema_version, + b_canvas_json, b_checksum, b_published_by, b_published_at, b_bz, + b_created_by, b_created_at, b_updated_by, b_updated_at) +values (@ver, @def, 1, 'published', 1, + N'{ + "schemaVersion": 1, + "nodes": { + "start": { "x": 520, "y": 60 }, + "dept_manager": { "x": 520, "y": 170 }, + "amount_gateway": { "x": 520, "y": 280 }, + "gm_approve": { "x": 240, "y": 400 }, + "finance_review": { "x": 800, "y": 400 }, + "parallel_split": { "x": 520, "y": 520 }, + "legal_countersign": { "x": 200, "y": 640 }, + "cc_cfo": { "x": 520, "y": 640 }, + "risk_review": { "x": 840, "y": 640 }, + "parallel_join": { "x": 520, "y": 760 }, + "big_amount_gateway":{ "x": 520, "y": 870 }, + "chairman_approve": { "x": 240, "y": 990 }, + "archive": { "x": 800, "y": 990 }, + "end": { "x": 520, "y": 1110 } + } +}', + 'mock-checksum-expense_approval_v1', @by, @now, N'模拟数据:已发布版本', + @by, @now, @by, @now); + +/* ---------------------------------------------------------------------------- + 3. 节点(14 个) + + b_approval_mode / b_reject_mode / b_due_hours 是常用列; + requiredCount、requiredRatio、formPolicy 等低频配置进 b_config_json(§6.4)。 + ---------------------------------------------------------------------------- */ + +insert into dbo.wf_node (b_id, b_version_id, b_node_key, b_name, b_node_type, + b_approval_mode, b_reject_mode, b_due_hours, b_config_json, b_xh, b_canuse) +values +-- 开始 +('n_exp_start', @ver, 'start', N'开始', 'start', + null, null, null, null, 10, 1), + +-- 部门负责人审批:或签(部门负责人 或 分管副总 任一人通过即可) +('n_exp_dept_manager', @ver, 'dept_manager', N'部门负责人审批', 'user_task', + 'any', 'return_initiator', 24, + N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":true,"commentRequired":false,"formPolicy":{"readonly":[],"required":["b_reason"]}}', + 20, 1), + +-- 条件分支:金额判断 +('n_exp_amount_gateway', @ver, 'amount_gateway', N'金额判断', 'exclusive_gateway', + null, null, null, null, 30, 1), + +-- 总经理审批:单人 +('n_exp_gm_approve', @ver, 'gm_approve', N'总经理审批', 'user_task', + 'single', 'terminate', 48, + N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":true,"formPolicy":{"readonly":["b_amount","b_dept_id"],"required":[]}}', + 40, 1), + +-- 财务复核:会签(全部通过) +('n_exp_finance_review', @ver, 'finance_review', N'财务复核', 'user_task', + 'all', 'terminate', 24, + N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":false,"formPolicy":{"readonly":["b_amount"],"required":[]}}', + 50, 1), + +-- 并行分支 +('n_exp_parallel_split', @ver, 'parallel_split', N'并行分支', 'parallel_gateway', + null, null, null, null, 60, 1), + +-- 法务串行会签:按 wf_actor_rule.b_priority 逐个激活 +('n_exp_legal_countersign', @ver, 'legal_countersign', N'法务串行会签', 'user_task', + 'serial', 'return_previous', 72, + N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":true,"formPolicy":{"readonly":[],"required":[]}}', + 70, 1), + +-- 抄送财务总监:不产生待办,节点激活即完成 +('n_exp_cc_cfo', @ver, 'cc_cfo', N'抄送财务总监', 'cc_task', + null, null, null, + N'{"schemaVersion":1,"commentRequired":false}', 80, 1), + +-- 风控按比例通过:requiredRatio 在节点激活时换算成应签人数 +('n_exp_risk_review', @ver, 'risk_review', N'风控按比例通过', 'user_task', + 'percentage', 'terminate', 48, + N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":false,"requiredRatio":0.6,"formPolicy":{"readonly":[],"required":[]}}', + 90, 1), + +-- 并行汇合 +('n_exp_parallel_join', @ver, 'parallel_join', N'并行汇合', 'parallel_gateway', + null, null, null, null, 100, 1), + +-- 条件分支:是否超阈值 +('n_exp_big_amount_gateway', @ver, 'big_amount_gateway', N'是否超阈值', 'exclusive_gateway', + null, null, null, null, 110, 1), + +-- 董事长审批:N 人通过(3 个候选人里 2 人通过即完成) +('n_exp_chairman_approve', @ver, 'chairman_approve', N'董事长审批', 'user_task', + 'n_of_m', 'terminate', 72, + N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":true,"requiredCount":2,"formPolicy":{"readonly":[],"required":[]}}', + 120, 1), + +-- 服务节点:归档(第一阶段只占位,不执行) +('n_exp_archive', @ver, 'archive', N'归档', 'service_task', + null, null, null, + N'{"schemaVersion":1,"serviceKey":"expense_archive"}', 130, 1), + +-- 结束 +('n_exp_end', @ver, 'end', N'结束', 'end', + null, null, null, null, 140, 1); + +/* ---------------------------------------------------------------------------- + 4. 连线(17 条) + + b_condition_json 为 null 表示无条件的兜底分支。本样本两条条件分支都写成显式条件, + 两条合起来覆盖全部取值,不依赖兜底(§6.5)。 + b_priority 小的先判断。 + ---------------------------------------------------------------------------- */ + +insert into dbo.wf_edge (b_id, b_version_id, b_from_node_key, b_to_node_key, b_name, + b_condition_json, b_priority, b_canuse, b_bz) +values +('e_exp_01', @ver, 'start', 'dept_manager', null, null, 10, 1, null), +('e_exp_02', @ver, 'dept_manager', 'amount_gateway', null, null, 10, 1, null), + +-- 金额判断:> 10000 走总经理,<= 10000 走财务复核。两条都是显式条件,合起来覆盖全部金额 +('e_exp_03', @ver, 'amount_gateway', 'gm_approve', N'金额大于 1 万', + N'{"field":"b_amount","operator":"gt","value":10000}', 10, 1, null), +('e_exp_04', @ver, 'amount_gateway', 'finance_review', N'金额不大于 1 万', + N'{"field":"b_amount","operator":"le","value":10000}', 20, 1, null), +-- 总经理审批 → 并行分支:与「财务复核」并列,两条支线在并行分支汇合 +('e_exp_05', @ver, 'gm_approve', 'parallel_split', null, null, 10, 1, null), +('e_exp_06', @ver, 'finance_review', 'parallel_split', null, null, 10, 1, null), + +-- 并行分支:三条出线同时激活 +('e_exp_07', @ver, 'parallel_split', 'legal_countersign', null, null, 10, 1, null), +('e_exp_08', @ver, 'parallel_split', 'cc_cfo', null, null, 20, 1, null), +('e_exp_09', @ver, 'parallel_split', 'risk_review', null, null, 30, 1, null), + +-- 并行汇合:三条支线全部到达后才继续 +('e_exp_10', @ver, 'legal_countersign', 'parallel_join', null, null, 10, 1, null), +('e_exp_11', @ver, 'cc_cfo', 'parallel_join', null, null, 10, 1, null), +('e_exp_12', @ver, 'risk_review', 'parallel_join', null, null, 10, 1, null), +('e_exp_13', @ver, 'parallel_join', 'big_amount_gateway',null, null, 10, 1, null), + +-- 是否超阈值:>= 50000 走董事长审批,< 50000 走归档。两条都是显式条件,合起来覆盖全部金额 +('e_exp_14', @ver, 'big_amount_gateway','chairman_approve', N'金额大于等于 5 万', + N'{"field":"b_amount","operator":"ge","value":50000}', 10, 1, null), +('e_exp_15', @ver, 'big_amount_gateway','archive', N'金额小于 5 万', + N'{"field":"b_amount","operator":"lt","value":50000}', 20, 1, null), +-- 董事长审批 → 结束:与「归档」并列,两条支线在结束汇合 +('e_exp_16', @ver, 'chairman_approve', 'end', null, null, 10, 1, null), +('e_exp_17', @ver, 'archive', 'end', null, null, 10, 1, null); + +/* ---------------------------------------------------------------------------- + 5. 审批人 / 抄送人规则 + + 解析规则执行后必须把规则快照写进运行表(§6.6);这里只配规则本身。 + b_priority 既是串行会签的顺序来源,也是同类型规则的判断顺序。 + ---------------------------------------------------------------------------- */ + +insert into dbo.wf_actor_rule (b_id, b_version_id, b_node_key, b_actor_type, b_actor_ref, + b_resolve_json, b_condition_json, b_fallback_type, b_fallback_ref, + b_priority, b_canuse, b_bz) +values +-- 部门负责人审批(或签):发起人所属部门负责人 或 分管副总 +('r_exp_dm_1', @ver, 'dept_manager', 'dept_manager', null, null, null, 'admin', null, 10, 1, N'发起人或业务归属部门负责人'), +('r_exp_dm_2', @ver, 'dept_manager', 'position', 'vice_president', null, null, 'admin', null, 20, 1, N'分管副总'), + +-- 总经理审批(单人) +('r_exp_gm_1', @ver, 'gm_approve', 'position', 'gm', null, null, 'admin', null, 10, 1, N'总经理岗'), + +-- 财务复核(会签):两条规则 → 两名审批人都必须通过 +('r_exp_fr_1', @ver, 'finance_review', 'org_role', 'finance', null, null, 'admin', null, 10, 1, N'财务组织角色'), +('r_exp_fr_2', @ver, 'finance_review', 'position', 'finance_manager', null, null, 'admin', null, 20, 1, N'财务经理岗'), + +-- 法务串行会签(串行):按 priority 10 → 20 → 30 逐个激活 +('r_exp_lc_1', @ver, 'legal_countersign', 'org_role', 'legal_lead', null, null, 'admin', null, 10, 1, N'法务负责人(第一个签)'), +('r_exp_lc_2', @ver, 'legal_countersign', 'user', 'U001', null, null, 'admin', null, 20, 1, N'指定用户(第二个签)'), +('r_exp_lc_3', @ver, 'legal_countersign', 'position', 'legal_staff', null, null, 'admin', null, 30, 1, N'法务专员(第三个签)'), + +-- 抄送财务总监(抄送):不产生待办 +('r_exp_cc_1', @ver, 'cc_cfo', 'org_role', 'cfo', null, null, 'none', null, 10, 1, N'财务总监'), + +-- 风控按比例通过(3 名候选人里过 60% → 2 人) +('r_exp_rr_1', @ver, 'risk_review', 'org_role', 'risk', null, null, 'admin', null, 10, 1, N'风控组织角色'), +('r_exp_rr_2', @ver, 'risk_review', 'position', 'risk_manager', null, null, 'admin', null, 20, 1, N'风控经理岗'), +('r_exp_rr_3', @ver, 'risk_review', 'user', 'U002', null, null, 'admin', null, 30, 1, N'指定用户'), + +-- 董事长审批(N 人通过,requiredCount = 2) +('r_exp_ca_1', @ver, 'chairman_approve', 'user', 'U001', null, null, 'admin', null, 10, 1, N'候选人一'), +('r_exp_ca_2', @ver, 'chairman_approve', 'user', 'U002', null, null, 'admin', null, 20, 1, N'候选人二'), +('r_exp_ca_3', @ver, 'chairman_approve', 'user', 'U003', null, null, 'admin', null, 30, 1, N'候选人三'); + +/* ---------------------------------------------------------------------------- + 6. 自检 + ---------------------------------------------------------------------------- */ + +select N'流程定义' as 项, count(*) as 行数 from dbo.wf_definition where b_id = @def +union all +select N'流程版本', count(*) from dbo.wf_version where b_definition_id = @def +union all +select N'节点', count(*) from dbo.wf_node where b_version_id = @ver +union all +select N'连线', count(*) from dbo.wf_edge where b_version_id = @ver +union all +select N'审批人规则', count(*) from dbo.wf_actor_rule where b_version_id = @ver; diff --git a/code/g3soft-erp/sql/90_mock/02_fms_workflow_runtime_mock.sql b/code/g3soft-erp/sql/90_mock/02_fms_workflow_runtime_mock.sql new file mode 100644 index 00000000..ba0f7fc4 --- /dev/null +++ b/code/g3soft-erp/sql/90_mock/02_fms_workflow_runtime_mock.sql @@ -0,0 +1,741 @@ +/* ============================================================================ + FMS 工作流运行态模拟数据 —— 费用报销审批(expense_approval v1) + + 用途:给「流程管理 → 运行监控」与「审批中心(我的待办 / 我发起的 / 我已处理 / + 抄送我的)」造一批可直接查看、可继续操作的运行数据。 + + 前置条件: + 1. 已执行 sql/fms_workflow.sql(wf_* 建表)。 + 2. 已执行 sql/fms_workflow_mock.sql(流程定义 expense_approval + 已发布版本 + expense_approval_v1)。本脚本以该版本为模板,不重复定义流程。 + 3. 已执行 sql/fms_workflow_org_ext.sql(岗位 / 组织角色等组织主数据表)。 + + 本脚本写入: + - 演示组织数据:1 个部门 + 4 个演示用户 + 5 个岗位 + 4 个组织角色 + 授权 + (演示用户密码与账号相同,仅供演示环境登录) + - 1 条停用的流程绑定(给运行数据一个真实出处,不拦截真实送审) + - 6 条流程实例及其节点运行 / 待办任务 / 动作流水 / 抄送记录 + - 把 mock 脚本里的占位用户 U001 / U002 / U003 就地替换为演示用户 + + 覆盖的场景(6 条实例): + 模拟-费用报销-001 运行中 · 部门负责人审批(或签,两条待办) + 模拟-费用报销-002 运行中 · 并行分支(法务串行会签第 2 签 + 风控按比例 2/3 + 抄送) + 模拟-费用报销-003 运行中 · 总经理审批(金额 > 1 万分支) + 模拟-费用报销-004 已通过 · 完整链路(会签 → 并行 → 董事长 N 人通过 → 归档 → 结束) + 模拟-费用报销-005 已驳回 · 总经理终止(reject_mode = terminate) + 模拟-费用报销-006 已退回 · 部门负责人退回发起人(return_initiator) + + 用 g3soft 登录后的预期结果: + 我的待办 2 条(002 法务串行会签、003 总经理审批) + 我发起的 2 条(001 运行中、006 已退回) + 我已处理 2 条(004 董事长审批通过、005 总经理驳回) + 抄送我的 2 条(002 未读、004 已读) + 运行监控 6 条实例,其中 3 条运行中 + + 可重复执行:先按业务单号前缀「模拟-」清理旧运行数据,再重建; + 组织数据 / 绑定 / 规则补丁都按「不存在才写入」处理。 + 全部清理(含演示组织数据)见文件末尾注释。 + + 说明:只写运行表与演示组织数据;不写 wf_outbox(避免触发通知派发), + 不写任何业务表(运行监控与审批中心都不依赖业务表数据)。 + ============================================================================ */ + +SET NOCOUNT ON; +GO + +/* ---------------------------------------------------------------------------- + 0. 前置检查与清理 + ---------------------------------------------------------------------------- */ + +if not exists (select 1 from dbo.wf_version where b_id = 'expense_approval_v1' and b_status = 'published') +begin + raiserror (N'缺少已发布版本 expense_approval_v1:请先执行 sql/fms_workflow_mock.sql', 16, 1); + return; +end +GO + +if not exists (select 1 from dbo.wf_definition where b_id = 'expense_approval') +begin + raiserror (N'缺少流程定义 expense_approval:请先执行 sql/fms_workflow_mock.sql', 16, 1); + return; +end +GO + +/* 清理本脚本上一次造出来的运行数据(业务单号前缀「模拟-」是识别标记) */ +declare @old table (b_id uniqueidentifier primary key); + +insert into @old (b_id) +select b_id from dbo.wf_instance where b_business_no like N'模拟-%'; + +delete from dbo.wf_action where b_instance_id in (select b_id from @old); +delete from dbo.wf_cc where b_instance_id in (select b_id from @old); +delete from dbo.wf_task where b_instance_id in (select b_id from @old); +delete from dbo.wf_node_run where b_instance_id in (select b_id from @old); +delete from dbo.wf_instance where b_id in (select b_id from @old); +GO + +/* ---------------------------------------------------------------------------- + 1. 演示组织数据 + + 演示用户的身兼数职只为把 mock 流程的审批人规则解析完整,不代表真实职务设计: + 张伟 demo_zhangwei 演示事业部负责人(dept_manager)+ 财务经理 + 法务负责人 + 李强 demo_liqiang 分管副总(vice_president) + g3soft 总经理(gm)+ 指定审批人 U001 + 王芳 demo_wangfang 财务 + 财务总监(抄送对象)+ 法务专员 + 刘敏 demo_liumin 风控 + 风控经理 + 指定审批人 U003 + ---------------------------------------------------------------------------- */ + +/* 1.1 部门:演示事业部 */ +declare @now datetime2 = sysutcdatetime(); + +if not exists (select 1 from dbo.b_dept where b_id = 'demo_dept') +begin + insert into dbo.b_dept (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_canuse, b_xh, b_bz, b_manager_id, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values ('demo_dept', null, 0, '/demo_dept/', N'演示事业部', null, 1, 10, N'工作流模拟数据:演示用部门', null, + 'g3soft', @now, 'g3soft', @now); +end + +update dbo.b_dept + set b_manager_id = 'demo_zhangwei', b_updated_by = 'g3soft', b_updated_at = @now + where b_id = 'demo_dept' + and (b_manager_id is null or b_manager_id <> 'demo_zhangwei'); + +/* 1.2 演示用户(密码与账号相同) */ +declare @users table (b_id varchar(50) primary key, b_name nvarchar(200) not null, b_bz nvarchar(800)); + +insert into @users (b_id, b_name, b_bz) +values ('demo_zhangwei', N'张伟', N'工作流模拟数据:演示事业部负责人 / 财务经理 / 法务负责人'), + ('demo_liqiang', N'李强', N'工作流模拟数据:分管副总'), + ('demo_wangfang', N'王芳', N'工作流模拟数据:财务 / 财务总监 / 法务专员'), + ('demo_liumin', N'刘敏', N'工作流模拟数据:风控 / 风控经理'); + +insert into dbo.b_user (b_id, b_name, b_dept_id, b_bz, b_password, b_canuse, b_logincount, + b_created_by, b_created_at, b_updated_by, b_updated_at) +select u.b_id, u.b_name, 'demo_dept', u.b_bz, u.b_id, 1, 0, 'g3soft', @now, 'g3soft', @now + from @users u + where not exists (select 1 from dbo.b_user x where x.b_id = u.b_id); +GO + +/* 1.3 岗位 */ +declare @now datetime2 = sysutcdatetime(); +declare @positions table (b_id varchar(50) primary key, b_name nvarchar(200) not null, b_xh int); + +insert into @positions (b_id, b_name, b_xh) +values ('gm', N'总经理', 10), + ('vice_president', N'分管副总', 20), + ('finance_manager', N'财务经理', 30), + ('legal_staff', N'法务专员', 40), + ('risk_manager', N'风控经理', 50); + +insert into dbo.b_position (b_id, b_name, b_canuse, b_xh, b_bz, b_created_by, b_created_at, b_updated_by, b_updated_at) +select p.b_id, p.b_name, 1, p.b_xh, N'工作流模拟数据', 'g3soft', @now, 'g3soft', @now + from @positions p + where not exists (select 1 from dbo.b_position x where x.b_id = p.b_id); + +/* 1.4 组织角色 */ +declare @roles table (b_id varchar(50) primary key, b_name nvarchar(200) not null, b_xh int); + +insert into @roles (b_id, b_name, b_xh) +values ('finance', N'财务', 10), + ('legal_lead', N'法务负责人', 20), + ('cfo', N'财务总监', 30), + ('risk', N'风控', 40); + +insert into dbo.b_org_role (b_id, b_name, b_canuse, b_xh, b_bz, b_created_by, b_created_at, b_updated_by, b_updated_at) +select r.b_id, r.b_name, 1, r.b_xh, N'工作流模拟数据', 'g3soft', @now, 'g3soft', @now + from @roles r + where not exists (select 1 from dbo.b_org_role x where x.b_id = r.b_id); + +/* 1.5 用户岗位 */ +declare @userPositions table (b_user_id varchar(50), b_position_id varchar(50)); + +insert into @userPositions (b_user_id, b_position_id) +values ('demo_zhangwei', 'finance_manager'), + ('demo_liqiang', 'vice_president'), + ('g3soft', 'gm'), + ('demo_wangfang', 'legal_staff'), + ('demo_wangfang', 'risk_manager'), + ('demo_liumin', 'risk_manager'); + +insert into dbo.b_user_position (b_user_id, b_position_id, b_canuse, b_created_by, b_created_at, b_updated_by, b_updated_at) +select up.b_user_id, up.b_position_id, 1, 'g3soft', @now, 'g3soft', @now + from @userPositions up + where not exists (select 1 from dbo.b_user_position x + where x.b_user_id = up.b_user_id and x.b_position_id = up.b_position_id); + +/* 1.6 用户组织角色 */ +declare @userRoles table (b_user_id varchar(50), b_role_id varchar(50)); + +insert into @userRoles (b_user_id, b_role_id) +values ('demo_zhangwei', 'legal_lead'), + ('demo_wangfang', 'finance'), + ('demo_wangfang', 'cfo'), + ('demo_liumin', 'risk'); + +insert into dbo.b_user_org_role (b_user_id, b_role_id, b_canuse, b_created_by, b_created_at, b_updated_by, b_updated_at) +select ur.b_user_id, ur.b_role_id, 1, 'g3soft', @now, 'g3soft', @now + from @userRoles ur + where not exists (select 1 from dbo.b_user_org_role x + where x.b_user_id = ur.b_user_id and x.b_role_id = ur.b_role_id); +GO + +/* 1.7 演示用户授权:审批中心菜单 + 审批动作(演示用户登录后要能处理待办) */ +declare @now datetime2 = sysutcdatetime(); + +insert into dbo.s_user_power (b_user_id, b_power_id, b_canuse, b_created_by, b_created_at, b_updated_by, b_updated_at) +select u.b_id, p.b_id, 1, 'g3soft', @now, 'g3soft', @now + from (values ('demo_zhangwei'), ('demo_liqiang'), ('demo_wangfang'), ('demo_liumin')) as u(b_id) + cross join dbo.s_power p + where (p.b_id like 'menu.approval%' or p.b_id like 'action.v_b_othercompany.%') + and not exists (select 1 from dbo.s_user_power x where x.b_user_id = u.b_id and x.b_power_id = p.b_id); +GO + +/* ---------------------------------------------------------------------------- + 2. 流程绑定与审批人规则补丁 + ---------------------------------------------------------------------------- */ + +/* + 2.1 演示绑定:故意 b_canuse = 0(停用)。 + 绑定的作用是给运行数据一个真实出处;停用是为了不让「合作伙伴」模块的真实送审 + 命中这个演示流程(expense_approval 的条件引用 b_amount,合作伙伴表没有该字段, + 真送审会走成「条件出线没有一条命中」)。要真实跑通送审,请另建启用绑定, + 并把流程条件字段对准业务模块自己的列。 +*/ +if not exists (select 1 from dbo.wf_binding where b_id = 'demo_expense_binding') +begin + insert into dbo.wf_binding (b_id, b_module_id, b_event, b_version_id, b_match_json, b_org_scope_json, + b_status_field, b_status_map_json, b_priority, b_effective_from, b_effective_to, + b_canuse, b_bz, b_created_by, b_created_at, b_updated_by, b_updated_at) + values ('demo_expense_binding', 'v_b_othercompany', 'submit', 'expense_approval_v1', null, null, + null, null, 10, null, null, + 0, N'工作流模拟数据:停用的演示绑定,仅为运行数据提供出处', 'g3soft', sysutcdatetime(), 'g3soft', sysutcdatetime()); +end + +/* + 2.2 审批人规则补丁:fms_workflow_mock.sql 里的 U001 / U002 / U003 是占位用户, + 库里并不存在,解析时会被剔除。这里替换为本脚本的演示用户,让后续流转解析得到人: + U001 → g3soft(总经理) + U002 → demo_zhangwei(张伟) + U003 → demo_liqiang(李强) +*/ +update dbo.wf_actor_rule + set b_actor_ref = 'g3soft' + where b_version_id = 'expense_approval_v1' and b_actor_ref = 'U001' and b_actor_type = 'user'; + +update dbo.wf_actor_rule + set b_actor_ref = 'demo_zhangwei' + where b_version_id = 'expense_approval_v1' and b_actor_ref = 'U002' and b_actor_type = 'user'; + +update dbo.wf_actor_rule + set b_actor_ref = 'demo_liqiang' + where b_version_id = 'expense_approval_v1' and b_actor_ref = 'U003' and b_actor_type = 'user'; +GO + +/* ============================================================================ + 3. 实例 A:运行中 · 部门负责人审批(或签) + + 模拟-费用报销-001 发起人 g3soft 金额 3,200 两小时前送审 + 或签两名候选人(部门负责人张伟 / 分管副总李强)各有一条待办。 + ============================================================================ */ + +declare @now datetime2 = sysutcdatetime(); +declare @inst uniqueidentifier = newid(); +declare @runStart uniqueidentifier = newid(); +declare @runDm uniqueidentifier = newid(); +declare @t0 datetime2 = dateadd(hour, -2, @now); +declare @due datetime2 = dateadd(hour, 22, @now); +declare @snap nvarchar(max) = N'{"rules":[{"b_actor_type":"dept_manager","b_actor_ref":null,"b_priority":10},{"b_actor_type":"position","b_actor_ref":"vice_president","b_priority":20}],"resolvedUsers":["demo_zhangwei","demo_liqiang"],"notes":[]}'; + +insert into dbo.wf_instance (b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_event, + b_business_id, b_business_no, b_org_id, b_initiator_id, b_initiator_dept_id, b_status, + b_context_json, b_snapshot_json, b_current_summary_json, b_row_version, + b_idempotency_key, b_started_at, b_completed_at, + b_created_by, b_created_at, b_updated_by, b_updated_at) +values (@inst, 'expense_approval', 'expense_approval_v1', 'demo_expense_binding', 'v_b_othercompany', 'submit', + 'MOCK-EXP-001', N'模拟-费用报销-001', 'G3HD', 'g3soft', 'demo_dept', 'running', + N'{"b_amount":3200,"b_dept_id":"demo_dept","b_initiator_dept_id":"demo_dept"}', + N'{"b_business_no":"模拟-费用报销-001","b_amount":3200,"b_reason":"客户拜访差旅费","b_dept_id":"demo_dept"}', + N'{"summary":"待审批:部门负责人审批"}', 1, + null, @t0, null, + 'g3soft', @t0, 'g3soft', @t0); + +insert into dbo.wf_node_run (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, + b_completed_count, b_rejected_count, b_entered_at, b_exited_at, + b_actor_snapshot_json, b_created_at) +values (@runStart, @inst, 'start', 1, 'completed', null, 0, 0, @t0, @t0, null, @t0), + (@runDm, @inst, 'dept_manager', 1, 'running', 1, 0, 0, @t0, null, @snap, @t0); + +insert into dbo.wf_task (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_claimed_by, b_claimed_at, + b_completed_by, b_completed_at, b_rule_snapshot_json, b_created_at, b_updated_at) +values (newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_zhangwei', 10, 'pending', @due, null, null, null, null, @snap, @t0, @t0), + (newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_liqiang', 20, 'pending', @due, null, null, null, null, @snap, @t0, @t0); + +insert into dbo.wf_action (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, + b_from_status, b_to_status, b_comment, b_payload_json, b_request_id, b_trace_id, + b_occurdatetime) +values (newid(), @inst, null, null, 'submit', 'g3soft', null, 'running', N'提交审批', null, null, null, @t0); +GO + +/* ============================================================================ + 4. 实例 B:运行中 · 并行分支 + + 模拟-费用报销-002 发起人 刘敏 金额 30,000 30 小时前送审 + 已过:部门负责人(或签)→ 金额判断(> 1 万)→ 总经理审批 → 并行分支 + 进行中:法务串行会签(第 1 签已过,第 2 签 g3soft 待办,第 3 签未激活)、 + 风控按比例通过(3 人过 2 人,已过 1 票)、抄送财务总监已发出 + ============================================================================ */ + +declare @now datetime2 = sysutcdatetime(); +declare @inst uniqueidentifier = newid(); +declare @runStart uniqueidentifier = newid(); +declare @runDm uniqueidentifier = newid(); +declare @runGateway uniqueidentifier = newid(); +declare @runGm uniqueidentifier = newid(); +declare @runSplit uniqueidentifier = newid(); +declare @runLegal uniqueidentifier = newid(); +declare @runCc uniqueidentifier = newid(); +declare @runRisk uniqueidentifier = newid(); +declare @t0 datetime2 = dateadd(hour, -30, @now); +declare @t1 datetime2 = dateadd(minute, 30, @t0); +declare @t2 datetime2 = dateadd(hour, 4, @t1); +declare @t3 datetime2 = dateadd(hour, 1, @t2); +declare @snapDm nvarchar(max) = N'{"rules":[{"b_actor_type":"dept_manager","b_actor_ref":null,"b_priority":10},{"b_actor_type":"position","b_actor_ref":"vice_president","b_priority":20}],"resolvedUsers":["demo_zhangwei","demo_liqiang"],"notes":[]}'; +declare @snapGm nvarchar(max) = N'{"rules":[{"b_actor_type":"position","b_actor_ref":"gm","b_priority":10}],"resolvedUsers":["g3soft"],"notes":[]}'; +declare @snapLegal nvarchar(max) = N'{"rules":[{"b_actor_type":"org_role","b_actor_ref":"legal_lead","b_priority":10},{"b_actor_type":"user","b_actor_ref":"g3soft","b_priority":20},{"b_actor_type":"position","b_actor_ref":"legal_staff","b_priority":30}],"resolvedUsers":["demo_zhangwei","g3soft","demo_wangfang"],"notes":[]}'; +declare @snapRisk nvarchar(max) = N'{"rules":[{"b_actor_type":"org_role","b_actor_ref":"risk","b_priority":10},{"b_actor_type":"position","b_actor_ref":"risk_manager","b_priority":20},{"b_actor_type":"user","b_actor_ref":"demo_zhangwei","b_priority":30}],"resolvedUsers":["demo_liumin","demo_wangfang","demo_zhangwei"],"notes":[]}'; +declare @snapCc nvarchar(max) = N'{"rules":[{"b_actor_type":"org_role","b_actor_ref":"cfo","b_priority":10}],"resolvedUsers":["demo_wangfang","g3soft"],"notes":["演示数据额外抄送 g3soft,便于管理员账号直接看到抄送页"]}'; + +insert into dbo.wf_instance (b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_event, + b_business_id, b_business_no, b_org_id, b_initiator_id, b_initiator_dept_id, b_status, + b_context_json, b_snapshot_json, b_current_summary_json, b_row_version, + b_idempotency_key, b_started_at, b_completed_at, + b_created_by, b_created_at, b_updated_by, b_updated_at) +values (@inst, 'expense_approval', 'expense_approval_v1', 'demo_expense_binding', 'v_b_othercompany', 'submit', + 'MOCK-EXP-002', N'模拟-费用报销-002', 'G3HD', 'demo_liumin', 'demo_dept', 'running', + N'{"b_amount":30000,"b_dept_id":"demo_dept","b_initiator_dept_id":"demo_dept"}', + N'{"b_business_no":"模拟-费用报销-002","b_amount":30000,"b_reason":"季度团建费用","b_dept_id":"demo_dept"}', + N'{"summary":"审批中:风控按比例通过"}', 5, + null, @t0, null, + 'demo_liumin', @t0, 'demo_liumin', @t3); + +insert into dbo.wf_node_run (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, + b_completed_count, b_rejected_count, b_entered_at, b_exited_at, + b_actor_snapshot_json, b_created_at) +values (@runStart, @inst, 'start', 1, 'completed', null, 0, 0, @t0, @t0, null, @t0), + (@runDm, @inst, 'dept_manager', 1, 'completed', 1, 1, 0, @t0, @t1, @snapDm, @t0), + (@runGateway,@inst, 'amount_gateway', 1, 'completed', null, 0, 0, @t1, @t1, null, @t1), + (@runGm, @inst, 'gm_approve', 1, 'completed', 1, 1, 0, @t1, @t2, @snapGm, @t1), + (@runSplit, @inst, 'parallel_split', 1, 'completed', null, 0, 0, @t2, @t2, null, @t2), + (@runLegal, @inst, 'legal_countersign', 1, 'running', 3, 1, 0, @t2, null, @snapLegal, @t2), + (@runCc, @inst, 'cc_cfo', 1, 'completed', 1, 0, 0, @t2, @t2, @snapCc, @t2), + (@runRisk, @inst, 'risk_review', 1, 'running', 2, 1, 0, @t2, null, @snapRisk, @t2); + +insert into dbo.wf_task (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_claimed_by, b_claimed_at, + b_completed_by, b_completed_at, b_rule_snapshot_json, b_created_at, b_updated_at) +values +-- 部门负责人(或签):张伟通过,李强被自动取消 +(newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_zhangwei', 10, 'approved', + dateadd(hour, 24, @t0), null, null, 'demo_zhangwei', @t1, @snapDm, @t0, @t1), +(newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_liqiang', 20, 'cancelled', + dateadd(hour, 24, @t0), null, null, null, null, @snapDm, @t0, @t1), +-- 总经理(单人):g3soft 通过 +(newid(), @inst, @runGm, 'gm_approve', null, 'user', 'g3soft', 10, 'approved', + dateadd(hour, 48, @t1), null, null, 'g3soft', @t2, @snapGm, @t1, @t2), +-- 法务串行会签:第 1 签张伟已过,第 2 签 g3soft 待办,第 3 签王芳未激活 +(newid(), @inst, @runLegal, 'legal_countersign', null, 'user', 'demo_zhangwei', 10, 'approved', + dateadd(hour, 72, @t2), null, null, 'demo_zhangwei', @t2, @snapLegal, @t2, @t2), +(newid(), @inst, @runLegal, 'legal_countersign', null, 'user', 'g3soft', 20, 'pending', + dateadd(hour, 72, @t2), null, null, null, null, @snapLegal, @t2, @t2), +(newid(), @inst, @runLegal, 'legal_countersign', null, 'user', 'demo_wangfang', 30, 'waiting', + dateadd(hour, 72, @t2), null, null, null, null, @snapLegal, @t2, @t2), +-- 风控按比例通过(3 人需过 2 人):刘敏已过 1 票,王芳已抢占,张伟待办 +(newid(), @inst, @runRisk, 'risk_review', null, 'user', 'demo_liumin', 10, 'approved', + dateadd(hour, 48, @t2), null, null, 'demo_liumin', @t3, @snapRisk, @t2, @t3), +(newid(), @inst, @runRisk, 'risk_review', null, 'user', 'demo_wangfang', 20, 'claimed', + dateadd(hour, 48, @t2), 'demo_wangfang', @t3, null, null, @snapRisk, @t2, @t3), +(newid(), @inst, @runRisk, 'risk_review', null, 'user', 'demo_zhangwei', 30, 'pending', + dateadd(hour, 48, @t2), null, null, null, null, @snapRisk, @t2, @t2); + +insert into dbo.wf_cc (b_id, b_instance_id, b_node_run_id, b_node_key, b_recipient_id, b_actor_rule_id, + b_status, b_read_at, b_rule_snapshot_json, b_created_at) +values (newid(), @inst, @runCc, 'cc_cfo', 'demo_wangfang', null, 'unread', null, @snapCc, @t2), + (newid(), @inst, @runCc, 'cc_cfo', 'g3soft', null, 'unread', null, @snapCc, @t2); + +insert into dbo.wf_action (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, + b_from_status, b_to_status, b_comment, b_payload_json, b_request_id, b_trace_id, + b_occurdatetime) +values (newid(), @inst, null, null, 'submit', 'demo_liumin', null, 'running', N'提交审批', null, null, null, @t0), + (newid(), @inst, @runDm, null, 'approve', 'demo_zhangwei', 'pending', 'approved', N'费用在季度预算内,同意', null, null, null, @t1), + (newid(), @inst, @runDm, null, 'cancel', null, 'pending', 'cancelled', N'同节点其余待办已自动取消(1 条)', null, null, null, dateadd(second, 1, @t1)), + (newid(), @inst, @runGm, null, 'approve', 'g3soft', 'pending', 'approved', N'金额属实,同意', null, null, null, @t2), + (newid(), @inst, @runCc, null, 'cc', null, 'running', 'running', N'抄送「抄送财务总监」共 2 人', null, null, null, dateadd(second, 1, @t2)), + (newid(), @inst, @runLegal, null, 'approve', 'demo_zhangwei', 'pending', 'approved', N'合同条款已确认', null, null, null, dateadd(second, 2, @t2)), + (newid(), @inst, @runRisk, null, 'approve', 'demo_liumin', 'pending', 'approved', N'风险可控,同意', null, null, null, @t3); +GO + +/* ============================================================================ + 5. 实例 C:运行中 · 总经理审批 + + 模拟-费用报销-003 发起人 王芳 金额 15,000 6 小时前送审 + 金额 > 1 万走总经理;部门负责人已通过,总经理(g3soft)有一条待办。 + ============================================================================ */ + +declare @now datetime2 = sysutcdatetime(); +declare @inst uniqueidentifier = newid(); +declare @runStart uniqueidentifier = newid(); +declare @runDm uniqueidentifier = newid(); +declare @runGateway uniqueidentifier = newid(); +declare @runGm uniqueidentifier = newid(); +declare @t0 datetime2 = dateadd(hour, -6, @now); +declare @t1 datetime2 = dateadd(hour, 1, @t0); +declare @snapDm nvarchar(max) = N'{"rules":[{"b_actor_type":"dept_manager","b_actor_ref":null,"b_priority":10},{"b_actor_type":"position","b_actor_ref":"vice_president","b_priority":20}],"resolvedUsers":["demo_zhangwei","demo_liqiang"],"notes":[]}'; +declare @snapGm nvarchar(max) = N'{"rules":[{"b_actor_type":"position","b_actor_ref":"gm","b_priority":10}],"resolvedUsers":["g3soft"],"notes":[]}'; + +insert into dbo.wf_instance (b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_event, + b_business_id, b_business_no, b_org_id, b_initiator_id, b_initiator_dept_id, b_status, + b_context_json, b_snapshot_json, b_current_summary_json, b_row_version, + b_idempotency_key, b_started_at, b_completed_at, + b_created_by, b_created_at, b_updated_by, b_updated_at) +values (@inst, 'expense_approval', 'expense_approval_v1', 'demo_expense_binding', 'v_b_othercompany', 'submit', + 'MOCK-EXP-003', N'模拟-费用报销-003', 'G3HD', 'demo_wangfang', 'demo_dept', 'running', + N'{"b_amount":15000,"b_dept_id":"demo_dept","b_initiator_dept_id":"demo_dept"}', + N'{"b_business_no":"模拟-费用报销-003","b_amount":15000,"b_reason":"部门年度体检费用","b_dept_id":"demo_dept"}', + N'{"summary":"待审批:总经理审批"}', 2, + null, @t0, null, + 'demo_wangfang', @t0, 'demo_wangfang', dateadd(second, 1, @t1)); + +insert into dbo.wf_node_run (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, + b_completed_count, b_rejected_count, b_entered_at, b_exited_at, + b_actor_snapshot_json, b_created_at) +values (@runStart, @inst, 'start', 1, 'completed', null, 0, 0, @t0, @t0, null, @t0), + (@runDm, @inst, 'dept_manager', 1, 'completed', 1, 1, 0, @t0, @t1, @snapDm, @t0), + (@runGateway,@inst, 'amount_gateway', 1, 'completed', null, 0, 0, @t1, @t1, null, @t1), + (@runGm, @inst, 'gm_approve', 1, 'running', 1, 0, 0, @t1, null, @snapGm, @t1); + +insert into dbo.wf_task (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_claimed_by, b_claimed_at, + b_completed_by, b_completed_at, b_rule_snapshot_json, b_created_at, b_updated_at) +values (newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_zhangwei', 10, 'approved', + dateadd(hour, 24, @t0), null, null, 'demo_zhangwei', @t1, @snapDm, @t0, @t1), + (newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_liqiang', 20, 'cancelled', + dateadd(hour, 24, @t0), null, null, null, null, @snapDm, @t0, @t1), + (newid(), @inst, @runGm, 'gm_approve', null, 'user', 'g3soft', 10, 'pending', + dateadd(hour, 48, @t1), null, null, null, null, @snapGm, @t1, @t1); + +insert into dbo.wf_action (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, + b_from_status, b_to_status, b_comment, b_payload_json, b_request_id, b_trace_id, + b_occurdatetime) +values (newid(), @inst, null, null, 'submit', 'demo_wangfang', null, 'running', N'提交审批', null, null, null, @t0), + (newid(), @inst, @runDm, null, 'approve', 'demo_zhangwei', 'pending', 'approved', N'同意', null, null, null, @t1), + (newid(), @inst, @runDm, null, 'cancel', null, 'pending', 'cancelled', N'同节点其余待办已自动取消(1 条)', null, null, null, dateadd(second, 1, @t1)); +GO + +/* ============================================================================ + 6. 实例 D:已通过 · 完整链路 + + 模拟-费用报销-004 发起人 刘敏 金额 60,000 六天前送审、五天前通过 + 完整走了:部门负责人(或签)→ 金额判断(> 1 万)→ 总经理 → + 并行(法务串行会签 3 人 / 抄送财务总监 / 风控按比例 3 人过 2 人)→ 并行汇合 → + 是否超阈值(>= 5 万)→ 董事长 N 人通过(3 人过 2 人)→ 结束 + ============================================================================ */ + +declare @now datetime2 = sysutcdatetime(); +declare @inst uniqueidentifier = newid(); +declare @runStart uniqueidentifier = newid(); +declare @runDm uniqueidentifier = newid(); +declare @runGateway uniqueidentifier = newid(); +declare @runGm uniqueidentifier = newid(); +declare @runSplit uniqueidentifier = newid(); +declare @runLegal uniqueidentifier = newid(); +declare @runCc uniqueidentifier = newid(); +declare @runRisk uniqueidentifier = newid(); +declare @runJoin uniqueidentifier = newid(); +declare @runBigGateway uniqueidentifier = newid(); +declare @runChairman uniqueidentifier = newid(); +declare @runEnd uniqueidentifier = newid(); +declare @t0 datetime2 = dateadd(day, -6, @now); +declare @t1 datetime2 = dateadd(hour, 2, @t0); +declare @t2 datetime2 = dateadd(hour, 14, @t1); +declare @t3 datetime2 = dateadd(hour, 1, @t2); +declare @t4 datetime2 = dateadd(hour, 1, @t3); +declare @t5 datetime2 = dateadd(hour, 1, @t4); +declare @t6 datetime2 = dateadd(hour, 1, @t5); +declare @t7 datetime2 = dateadd(hour, 2, @t6); +declare @t8 datetime2 = dateadd(hour, 1, @t7); +declare @t9 datetime2 = dateadd(hour, 3, @t8); +declare @snapDm nvarchar(max) = N'{"rules":[{"b_actor_type":"dept_manager","b_actor_ref":null,"b_priority":10},{"b_actor_type":"position","b_actor_ref":"vice_president","b_priority":20}],"resolvedUsers":["demo_zhangwei","demo_liqiang"],"notes":[]}'; +declare @snapGm nvarchar(max) = N'{"rules":[{"b_actor_type":"position","b_actor_ref":"gm","b_priority":10}],"resolvedUsers":["g3soft"],"notes":[]}'; +declare @snapLegal nvarchar(max) = N'{"rules":[{"b_actor_type":"org_role","b_actor_ref":"legal_lead","b_priority":10},{"b_actor_type":"user","b_actor_ref":"g3soft","b_priority":20},{"b_actor_type":"position","b_actor_ref":"legal_staff","b_priority":30}],"resolvedUsers":["demo_zhangwei","g3soft","demo_wangfang"],"notes":[]}'; +declare @snapRisk nvarchar(max) = N'{"rules":[{"b_actor_type":"org_role","b_actor_ref":"risk","b_priority":10},{"b_actor_type":"position","b_actor_ref":"risk_manager","b_priority":20},{"b_actor_type":"user","b_actor_ref":"demo_zhangwei","b_priority":30}],"resolvedUsers":["demo_liumin","demo_wangfang","demo_zhangwei"],"notes":[]}'; +declare @snapCc nvarchar(max) = N'{"rules":[{"b_actor_type":"org_role","b_actor_ref":"cfo","b_priority":10}],"resolvedUsers":["demo_wangfang","g3soft"],"notes":["演示数据额外抄送 g3soft,便于管理员账号直接看到抄送页"]}'; +declare @snapChairman nvarchar(max) = N'{"rules":[{"b_actor_type":"user","b_actor_ref":"g3soft","b_priority":10},{"b_actor_type":"user","b_actor_ref":"demo_zhangwei","b_priority":20},{"b_actor_type":"user","b_actor_ref":"demo_liqiang","b_priority":30}],"resolvedUsers":["g3soft","demo_zhangwei","demo_liqiang"],"notes":[]}'; + +insert into dbo.wf_instance (b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_event, + b_business_id, b_business_no, b_org_id, b_initiator_id, b_initiator_dept_id, b_status, + b_context_json, b_snapshot_json, b_current_summary_json, b_row_version, + b_idempotency_key, b_started_at, b_completed_at, + b_created_by, b_created_at, b_updated_by, b_updated_at) +values (@inst, 'expense_approval', 'expense_approval_v1', 'demo_expense_binding', 'v_b_othercompany', 'submit', + 'MOCK-EXP-004', N'模拟-费用报销-004', 'G3HD', 'demo_liumin', 'demo_dept', 'approved', + N'{"b_amount":60000,"b_dept_id":"demo_dept","b_initiator_dept_id":"demo_dept"}', + N'{"b_business_no":"模拟-费用报销-004","b_amount":60000,"b_reason":"办公设备采购","b_dept_id":"demo_dept"}', + N'{"summary":"已通过","node":"结束"}', 14, + null, @t0, dateadd(second, 2, @t9), + 'demo_liumin', @t0, 'demo_liumin', dateadd(second, 2, @t9)); + +insert into dbo.wf_node_run (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, + b_completed_count, b_rejected_count, b_entered_at, b_exited_at, + b_actor_snapshot_json, b_created_at) +values (@runStart, @inst, 'start', 1, 'completed', null, 0, 0, @t0, @t0, null, @t0), + (@runDm, @inst, 'dept_manager', 1, 'completed', 1, 1, 0, @t0, @t1, @snapDm, @t0), + (@runGateway, @inst, 'amount_gateway', 1, 'completed', null, 0, 0, @t1, @t1, null, @t1), + (@runGm, @inst, 'gm_approve', 1, 'completed', 1, 1, 0, @t1, @t2, @snapGm, @t1), + (@runSplit, @inst, 'parallel_split', 1, 'completed', null, 0, 0, @t2, @t2, null, @t2), + (@runLegal, @inst, 'legal_countersign', 1, 'completed', 3, 3, 0, @t2, @t5, @snapLegal, @t2), + (@runCc, @inst, 'cc_cfo', 1, 'completed', 2, 0, 0, @t2, @t2, @snapCc, @t2), + (@runRisk, @inst, 'risk_review', 1, 'completed', 2, 2, 0, @t2, @t7, @snapRisk, @t2), + (@runJoin, @inst, 'parallel_join', 1, 'completed', null, 0, 0, @t7, @t7, null, @t7), + (@runBigGateway,@inst,'big_amount_gateway',1, 'completed', null, 0, 0, @t7, @t7, null, @t7), + (@runChairman, @inst, 'chairman_approve', 1, 'completed', 2, 2, 0, @t7, @t9, @snapChairman, @t7), + (@runEnd, @inst, 'end', 1, 'completed', null, 0, 0, @t9, @t9, null, @t9); + +insert into dbo.wf_task (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_claimed_by, b_claimed_at, + b_completed_by, b_completed_at, b_rule_snapshot_json, b_created_at, b_updated_at) +values +-- 部门负责人(或签) +(newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_zhangwei', 10, 'approved', + dateadd(hour, 24, @t0), null, null, 'demo_zhangwei', @t1, @snapDm, @t0, @t1), +(newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_liqiang', 20, 'cancelled', + dateadd(hour, 24, @t0), null, null, null, null, @snapDm, @t0, @t1), +-- 总经理(单人) +(newid(), @inst, @runGm, 'gm_approve', null, 'user', 'g3soft', 10, 'approved', + dateadd(hour, 48, @t1), null, null, 'g3soft', @t2, @snapGm, @t1, @t2), +-- 法务串行会签(3 人全部通过) +(newid(), @inst, @runLegal, 'legal_countersign', null, 'user', 'demo_zhangwei', 10, 'approved', + dateadd(hour, 72, @t2), null, null, 'demo_zhangwei', @t3, @snapLegal, @t2, @t3), +(newid(), @inst, @runLegal, 'legal_countersign', null, 'user', 'g3soft', 20, 'approved', + dateadd(hour, 72, @t2), null, null, 'g3soft', @t4, @snapLegal, @t2, @t4), +(newid(), @inst, @runLegal, 'legal_countersign', null, 'user', 'demo_wangfang', 30, 'approved', + dateadd(hour, 72, @t2), null, null, 'demo_wangfang', @t5, @snapLegal, @t2, @t5), +-- 风控按比例通过(3 人过 2 人:刘敏、王芳通过,张伟被自动取消) +(newid(), @inst, @runRisk, 'risk_review', null, 'user', 'demo_liumin', 10, 'approved', + dateadd(hour, 48, @t2), null, null, 'demo_liumin', @t6, @snapRisk, @t2, @t6), +(newid(), @inst, @runRisk, 'risk_review', null, 'user', 'demo_wangfang', 20, 'approved', + dateadd(hour, 48, @t2), null, null, 'demo_wangfang', @t7, @snapRisk, @t2, @t7), +(newid(), @inst, @runRisk, 'risk_review', null, 'user', 'demo_zhangwei', 30, 'cancelled', + dateadd(hour, 48, @t2), null, null, null, null, @snapRisk, @t2, @t7), +-- 董事长审批(N 人通过,3 人过 2 人:g3soft、张伟通过,李强被自动取消) +(newid(), @inst, @runChairman, 'chairman_approve', null, 'user', 'g3soft', 10, 'approved', + dateadd(hour, 72, @t7), null, null, 'g3soft', @t8, @snapChairman, @t7, @t8), +(newid(), @inst, @runChairman, 'chairman_approve', null, 'user', 'demo_zhangwei', 20, 'approved', + dateadd(hour, 72, @t7), null, null, 'demo_zhangwei', @t9, @snapChairman, @t7, @t9), +(newid(), @inst, @runChairman, 'chairman_approve', null, 'user', 'demo_liqiang', 30, 'cancelled', + dateadd(hour, 72, @t7), null, null, null, null, @snapChairman, @t7, @t9); + +insert into dbo.wf_cc (b_id, b_instance_id, b_node_run_id, b_node_key, b_recipient_id, b_actor_rule_id, + b_status, b_read_at, b_rule_snapshot_json, b_created_at) +values (newid(), @inst, @runCc, 'cc_cfo', 'demo_wangfang', null, 'read', @t9, @snapCc, @t2), + (newid(), @inst, @runCc, 'cc_cfo', 'g3soft', null, 'read', @t9, @snapCc, @t2); + +insert into dbo.wf_action (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, + b_from_status, b_to_status, b_comment, b_payload_json, b_request_id, b_trace_id, + b_occurdatetime) +values (newid(), @inst, null, null, 'submit', 'demo_liumin', null, 'running', N'提交审批', null, null, null, @t0), + (newid(), @inst, @runDm, null, 'approve', 'demo_zhangwei', 'pending', 'approved', N'采购必要性确认,同意', null, null, null, @t1), + (newid(), @inst, @runDm, null, 'cancel', null, 'pending', 'cancelled', N'同节点其余待办已自动取消(1 条)', null, null, null, dateadd(second, 1, @t1)), + (newid(), @inst, @runGm, null, 'approve', 'g3soft', 'pending', 'approved', N'预算内,同意', null, null, null, @t2), + (newid(), @inst, @runCc, null, 'cc', null, 'running', 'running', N'抄送「抄送财务总监」共 2 人', null, null, null, dateadd(second, 1, @t2)), + (newid(), @inst, @runLegal, null, 'approve', 'demo_zhangwei', 'pending', 'approved', N'法务条款已审核', null, null, null, @t3), + (newid(), @inst, @runLegal, null, 'approve', 'g3soft', 'pending', 'approved', N'用印流程已确认', null, null, null, @t4), + (newid(), @inst, @runLegal, null, 'approve', 'demo_wangfang', 'pending', 'approved', N'法务复核完成', null, null, null, @t5), + (newid(), @inst, @runRisk, null, 'approve', 'demo_liumin', 'pending', 'approved', N'风险可控', null, null, null, @t6), + (newid(), @inst, @runRisk, null, 'approve', 'demo_wangfang', 'pending', 'approved', N'同意采购', null, null, null, @t7), + (newid(), @inst, @runRisk, null, 'cancel', null, 'pending', 'cancelled', N'同节点其余待办已自动取消(1 条)', null, null, null, dateadd(second, 1, @t7)), + (newid(), @inst, @runChairman, null, 'approve', 'g3soft', 'pending', 'approved', N'同意', null, null, null, @t8), + (newid(), @inst, @runChairman, null, 'approve', 'demo_zhangwei', 'pending', 'approved', N'同意', null, null, null, @t9), + (newid(), @inst, @runChairman, null, 'cancel', null, 'pending', 'cancelled', N'同节点其余待办已自动取消(1 条)', null, null, null, dateadd(second, 1, @t9)), + (newid(), @inst, null, null, 'approve', null, 'running', 'approved', N'已通过', null, null, null, dateadd(second, 2, @t9)); +GO + +/* ============================================================================ + 7. 实例 E:已驳回 · 总经理终止 + + 模拟-费用报销-005 发起人 王芳 金额 45,000 四天前送审,当天被驳回 + 部门负责人已通过,总经理(g3soft)驳回;reject_mode = terminate,实例直接结束。 + ============================================================================ */ + +declare @now datetime2 = sysutcdatetime(); +declare @inst uniqueidentifier = newid(); +declare @runStart uniqueidentifier = newid(); +declare @runDm uniqueidentifier = newid(); +declare @runGateway uniqueidentifier = newid(); +declare @runGm uniqueidentifier = newid(); +declare @t0 datetime2 = dateadd(day, -4, @now); +declare @t1 datetime2 = dateadd(hour, 2, @t0); +declare @t2 datetime2 = dateadd(hour, 2, @t1); +declare @snapDm nvarchar(max) = N'{"rules":[{"b_actor_type":"dept_manager","b_actor_ref":null,"b_priority":10},{"b_actor_type":"position","b_actor_ref":"vice_president","b_priority":20}],"resolvedUsers":["demo_zhangwei","demo_liqiang"],"notes":[]}'; +declare @snapGm nvarchar(max) = N'{"rules":[{"b_actor_type":"position","b_actor_ref":"gm","b_priority":10}],"resolvedUsers":["g3soft"],"notes":[]}'; + +insert into dbo.wf_instance (b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_event, + b_business_id, b_business_no, b_org_id, b_initiator_id, b_initiator_dept_id, b_status, + b_context_json, b_snapshot_json, b_current_summary_json, b_row_version, + b_idempotency_key, b_started_at, b_completed_at, + b_created_by, b_created_at, b_updated_by, b_updated_at) +values (@inst, 'expense_approval', 'expense_approval_v1', 'demo_expense_binding', 'v_b_othercompany', 'submit', + 'MOCK-EXP-005', N'模拟-费用报销-005', 'G3HD', 'demo_wangfang', 'demo_dept', 'rejected', + N'{"b_amount":45000,"b_dept_id":"demo_dept","b_initiator_dept_id":"demo_dept"}', + N'{"b_business_no":"模拟-费用报销-005","b_amount":45000,"b_reason":"市场推广物料制作费","b_dept_id":"demo_dept"}', + N'{"summary":"已驳回","node":"总经理审批"}', 3, + null, @t0, dateadd(second, 1, @t2), + 'demo_wangfang', @t0, 'demo_wangfang', dateadd(second, 1, @t2)); + +insert into dbo.wf_node_run (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, + b_completed_count, b_rejected_count, b_entered_at, b_exited_at, + b_actor_snapshot_json, b_created_at) +values (@runStart, @inst, 'start', 1, 'completed', null, 0, 0, @t0, @t0, null, @t0), + (@runDm, @inst, 'dept_manager', 1, 'completed', 1, 1, 0, @t0, @t1, @snapDm, @t0), + (@runGateway,@inst, 'amount_gateway', 1, 'completed', null, 0, 0, @t1, @t1, null, @t1), + (@runGm, @inst, 'gm_approve', 1, 'completed', 1, 0, 1, @t1, @t2, @snapGm, @t1); + +insert into dbo.wf_task (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_claimed_by, b_claimed_at, + b_completed_by, b_completed_at, b_rule_snapshot_json, b_created_at, b_updated_at) +values (newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_zhangwei', 10, 'approved', + dateadd(hour, 24, @t0), null, null, 'demo_zhangwei', @t1, @snapDm, @t0, @t1), + (newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_liqiang', 20, 'cancelled', + dateadd(hour, 24, @t0), null, null, null, null, @snapDm, @t0, @t1), + (newid(), @inst, @runGm, 'gm_approve', null, 'user', 'g3soft', 10, 'rejected', + dateadd(hour, 48, @t1), null, null, 'g3soft', @t2, @snapGm, @t1, @t2); + +insert into dbo.wf_action (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, + b_from_status, b_to_status, b_comment, b_payload_json, b_request_id, b_trace_id, + b_occurdatetime) +values (newid(), @inst, null, null, 'submit', 'demo_wangfang', null, 'running', N'提交审批', null, null, null, @t0), + (newid(), @inst, @runDm, null, 'approve', 'demo_zhangwei', 'pending', 'approved', N'同意', null, null, null, @t1), + (newid(), @inst, @runDm, null, 'cancel', null, 'pending', 'cancelled', N'同节点其余待办已自动取消(1 条)', null, null, null, dateadd(second, 1, @t1)), + (newid(), @inst, @runGm, null, 'reject', 'g3soft', 'pending', 'rejected', N'费用超出季度预算,请拆分后重新提交', N'{"rejectMode":"terminate"}', null, null, @t2), + (newid(), @inst, null, null, 'reject', null, 'running', 'rejected', N'已驳回', null, null, null, dateadd(second, 1, @t2)); +GO + +/* ============================================================================ + 8. 实例 F:已退回 · 部门负责人退回发起人 + + 模拟-费用报销-006 发起人 g3soft 金额 2,800 三天前送审,一小时后退回 + 部门负责人(张伟)执行退回(return_initiator),实例直接结束。 + 节点运行的 b_rejected_count = 1 与真实实现一致(退回同样计入驳回计数)。 + ============================================================================ */ + +declare @now datetime2 = sysutcdatetime(); +declare @inst uniqueidentifier = newid(); +declare @runStart uniqueidentifier = newid(); +declare @runDm uniqueidentifier = newid(); +declare @t0 datetime2 = dateadd(day, -3, @now); +declare @t1 datetime2 = dateadd(hour, 1, @t0); +declare @snapDm nvarchar(max) = N'{"rules":[{"b_actor_type":"dept_manager","b_actor_ref":null,"b_priority":10},{"b_actor_type":"position","b_actor_ref":"vice_president","b_priority":20}],"resolvedUsers":["demo_zhangwei","demo_liqiang"],"notes":[]}'; + +insert into dbo.wf_instance (b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_event, + b_business_id, b_business_no, b_org_id, b_initiator_id, b_initiator_dept_id, b_status, + b_context_json, b_snapshot_json, b_current_summary_json, b_row_version, + b_idempotency_key, b_started_at, b_completed_at, + b_created_by, b_created_at, b_updated_by, b_updated_at) +values (@inst, 'expense_approval', 'expense_approval_v1', 'demo_expense_binding', 'v_b_othercompany', 'submit', + 'MOCK-EXP-006', N'模拟-费用报销-006', 'G3HD', 'g3soft', 'demo_dept', 'returned', + N'{"b_amount":2800,"b_dept_id":"demo_dept","b_initiator_dept_id":"demo_dept"}', + N'{"b_business_no":"模拟-费用报销-006","b_amount":2800,"b_reason":"市内交通费","b_dept_id":"demo_dept"}', + N'{"summary":"已退回","node":"部门负责人审批"}', 2, + null, @t0, dateadd(second, 2, @t1), + 'g3soft', @t0, 'g3soft', dateadd(second, 2, @t1)); + +insert into dbo.wf_node_run (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, + b_completed_count, b_rejected_count, b_entered_at, b_exited_at, + b_actor_snapshot_json, b_created_at) +values (@runStart, @inst, 'start', 1, 'completed', null, 0, 0, @t0, @t0, null, @t0), + (@runDm, @inst, 'dept_manager', 1, 'completed', 1, 0, 1, @t0, @t1, @snapDm, @t0); + +insert into dbo.wf_task (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_claimed_by, b_claimed_at, + b_completed_by, b_completed_at, b_rule_snapshot_json, b_created_at, b_updated_at) +values (newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_zhangwei', 10, 'cancelled', + dateadd(hour, 24, @t0), null, null, 'demo_zhangwei', @t1, @snapDm, @t0, @t1), + (newid(), @inst, @runDm, 'dept_manager', null, 'user', 'demo_liqiang', 20, 'cancelled', + dateadd(hour, 24, @t0), null, null, null, null, @snapDm, @t0, @t1); + +insert into dbo.wf_action (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, + b_from_status, b_to_status, b_comment, b_payload_json, b_request_id, b_trace_id, + b_occurdatetime) +values (newid(), @inst, null, null, 'submit', 'g3soft', null, 'running', N'提交审批', null, null, null, @t0), + (newid(), @inst, @runDm, null, 'return', 'demo_zhangwei', 'pending', 'returned', N'发票信息不全,请补充后重新提交', null, null, null, @t1), + (newid(), @inst, @runDm, null, 'cancel', null, 'pending', 'cancelled', N'同节点其余待办已自动取消(1 条)', null, null, null, dateadd(second, 1, @t1)), + (newid(), @inst, null, null, 'reject', null, 'running', 'returned', N'已退回', null, null, null, dateadd(second, 2, @t1)); +GO + +/* ============================================================================ + 9. 自检 + ============================================================================ */ + +select N'实例' as 项, count(1) as 行数 from dbo.wf_instance where b_business_no like N'模拟-%' +union all +select N' 运行中', count(1) from dbo.wf_instance where b_business_no like N'模拟-%' and b_status = 'running' +union all +select N'节点运行', count(1) from dbo.wf_node_run + where b_instance_id in (select b_id from dbo.wf_instance where b_business_no like N'模拟-%') +union all +select N'任务', count(1) from dbo.wf_task + where b_instance_id in (select b_id from dbo.wf_instance where b_business_no like N'模拟-%') +union all +select N' 待办(g3soft)', count(1) from dbo.wf_task + where b_assignee_id = 'g3soft' and b_status in ('pending', 'claimed') + and b_instance_id in (select b_id from dbo.wf_instance where b_business_no like N'模拟-%') +union all +select N'动作流水', count(1) from dbo.wf_action + where b_instance_id in (select b_id from dbo.wf_instance where b_business_no like N'模拟-%') +union all +select N'抄送', count(1) from dbo.wf_cc + where b_instance_id in (select b_id from dbo.wf_instance where b_business_no like N'模拟-%') +union all +select N'演示用户', count(1) from dbo.b_user where b_id like 'demo\_%' escape '\' +union all +select N'演示部门', count(1) from dbo.b_dept where b_id = 'demo_dept' +union all +select N'演示绑定(停用)', count(1) from dbo.wf_binding where b_id = 'demo_expense_binding' and b_canuse = 0; +GO + +/* ============================================================================ + 10. 清理(需要时手动执行) + + 删除本脚本造出的运行数据(按业务单号前缀识别): + + declare @old table (b_id uniqueidentifier primary key); + insert into @old (b_id) select b_id from dbo.wf_instance where b_business_no like N'模拟-%'; + delete from dbo.wf_action where b_instance_id in (select b_id from @old); + delete from dbo.wf_cc where b_instance_id in (select b_id from @old); + delete from dbo.wf_task where b_instance_id in (select b_id from @old); + delete from dbo.wf_node_run where b_instance_id in (select b_id from @old); + delete from dbo.wf_instance where b_id in (select b_id from @old); + + 删除演示组织数据(确认这些用户 / 岗位 / 角色没有别的用途后再执行): + + delete from dbo.s_user_power where b_user_id like 'demo\_%' escape '\'; + delete from dbo.b_user_position where b_user_id like 'demo\_%' escape '\'; + delete from dbo.b_user_org_role where b_user_id like 'demo\_%' escape '\'; + delete from dbo.b_user where b_id like 'demo\_%' escape '\'; + delete from dbo.b_dept where b_id = 'demo_dept'; + delete from dbo.b_user_position where b_position_id in ('gm', 'vice_president', 'finance_manager', 'legal_staff', 'risk_manager'); + delete from dbo.b_user_org_role where b_role_id in ('finance', 'legal_lead', 'cfo', 'risk'); + delete from dbo.b_position where b_id in ('gm', 'vice_president', 'finance_manager', 'legal_staff', 'risk_manager'); + delete from dbo.b_org_role where b_id in ('finance', 'legal_lead', 'cfo', 'risk'); + delete from dbo.wf_binding where b_id = 'demo_expense_binding'; + + 删除演示绑定与规则补丁(把 U001 / U002 / U003 还原为 mock 脚本的占位值): + + update dbo.wf_actor_rule set b_actor_ref = 'U001' where b_version_id = 'expense_approval_v1' and b_actor_ref = 'g3soft' and b_actor_type = 'user'; + update dbo.wf_actor_rule set b_actor_ref = 'U002' where b_version_id = 'expense_approval_v1' and b_actor_ref = 'demo_zhangwei' and b_actor_type = 'user'; + update dbo.wf_actor_rule set b_actor_ref = 'U003' where b_version_id = 'expense_approval_v1' and b_actor_ref = 'demo_liqiang' and b_actor_type = 'user'; + + 注:g3soft 岗位(b_user_position 的 gm 行)在清理演示用户时会一并删除; + 如需保留请自行调整上面语句。 + ============================================================================ */ diff --git a/code/g3soft-erp/sql/README.md b/code/g3soft-erp/sql/README.md new file mode 100644 index 00000000..a8bbeb3b --- /dev/null +++ b/code/g3soft-erp/sql/README.md @@ -0,0 +1,85 @@ +# sql —— 工作流模块 SQL 脚本 + +本目录是从原型 `fms-api` 迁移工作流引擎时一并带过来的 SQL Server 脚本,用于把工作流的 +表、视图、模块/菜单/权限注册和示例数据建到目标库。 + +> **本目录不包含 `fms_core.sql`。** 目标项目 g3soft-erp 的库已经具备核心表 +> (`s_module` / `s_field` / `s_module_schema` / `s_menu` / `s_power` / `s_user_power` / +> `b_user` / `b_dept` 等)。**如果目标库缺少这些核心表,本目录的脚本无法独立完成初始化**, +> 请先建好核心表再执行。 + +## 执行顺序 + +按目录名前缀的数字顺序执行: + +| 顺序 | 脚本 | 作用 | 幂等 | +|---|---|---|---| +| 1 | `00_core_companion/workflow_audit_tables.sql` | `s_log_audit` / `s_log_audit_field`(**可选**,用于 §11 审计) | 是 | +| 2 | `10_workflow/01_fms_workflow.sql` | 13 张 `wf_*` 表 + 索引(只建表,不含数据) | 否(重复执行会报表已存在) | +| 3 | `10_workflow/02_fms_workflow_org_ext.sql` | 组织主数据扩展:`b_dept.b_manager_id`、`b_user.b_leader_id`、`b_position` / `b_user_position` / `b_org_role` / `b_user_org_role` | 是 | +| 4 | `20_registration/10_fms_workflow_module.sql` | 4 个只读视图 + `s_module` / `s_field` / `s_module_schema` / `s_menu_module` 注册 | 是(先删后建) | +| 5 | `20_registration/11_fms_workflow_menu.sql` | 流程管理 / 审批中心菜单 + `s_i18n` | 是 | +| 6 | `20_registration/12_fms_workflow_power.sql` | 菜单权限点 + 动作权限点 + 授予 `g3soft` | 是 | +| 7 | `30_patches/01_fms_workflow_index_patch.sql` | 补充索引(见下) | 是 | +| 8 | `90_mock/01_fms_workflow_mock.sql` | **示例**流程定义 `expense_approval`(14 节点 / 17 连线 / 13 审批人规则) | 否 | +| 9 | `90_mock/02_fms_workflow_runtime_mock.sql` | **示例**运行数据:演示机构/用户 + 6 个实例 | 否 | + +**第 2 步不幂等**:`01_fms_workflow.sql` 是纯 `create table`。表已存在时会报错,需要先 drop +(脚本头部有说明)。第 8、9 步是示例数据,**生产环境不要执行**。 + +### 第 6 步的顺序陷阱 + +`12_fms_workflow_power.sql` 的动作权限点是这样生成的: + +```sql +select distinct b_module_id from dbo.wf_binding where b_canuse = 1 +``` + +**`wf_binding` 为空时,动作权限点会生成 0 条。** 三个缓解手段,按需选用: + +1. 先跑 mock(第 8 步,它含一条 binding)再跑第 6 步;或先在应用里保存一条流程绑定; +2. 应用侧本身会自愈——`WorkflowConfigService.saveBinding` 每次保存绑定都会调用 + `ensureActionPowers(moduleId)`,按 `action.{模块}.{动作}` 幂等补点; +3. 该脚本可重复执行,binding 补上后再跑一次即可。 + +动作权限点写入 `s_power (b_type='action', b_object_type='module', b_object_id=模块, b_action=动作)`, +与 `SqlPermissionService.requireAction` 的匹配列一致,两边天然对齐。 + +## 组织主数据扩展(第 3 步)不是可选项 + +`02_fms_workflow_org_ext.sql` 建的表/列是 8 种审批人里 4 种的前提: + +| 审批人类型 | 依赖 | +|---|---| +| `dept_manager` | `b_dept.b_manager_id` | +| `initiator_manager` | `b_user.b_leader_id` | +| `position` | `b_position` / `b_user_position` | +| `org_role` | `b_org_role` / `b_user_org_role` | + +不执行该脚本时,这 4 类审批人会**静默解析为空**,再由节点兜底策略(fallback / 置 `error`)接管。 + +## mock 数据的模块依赖 + +`90_mock/02_fms_workflow_runtime_mock.sql` 使用模块 `v_b_othercompany` 作为绑定目标。 +目标库若没有该 `s_module` 行,请把它替换为库里真实存在的业务模块,或先补建该模块。 + +## 运行时语义备忘(与本目录脚本相关的两条约定) + +- **任务超时只标记、不自动改流程**:超时任务会被置 `expired` 并从待办列表过滤掉, + 实例保持 `running`,由管理员通过加签 / 转派处理。**不自动提升串行后继、不置 `error`、不自动通过。** + 因此串行会签链中若当前审批人超时,链路会停住等待人工介入。 +- **审计探写**:`WorkflowAuditWriter` 每事务探测 `s_log_audit` 是否存在;不存在时写一条 + `wf_outbox(audit.skipped)` 并继续。**库里没有审计表不算故障**,不会阻断审批。 + +## 补充索引说明(第 7 步) + +| 索引 | 解决什么 | +|---|---| +| `ix_wf_binding_version (b_version_id)` | 发布校验与删除定义按 `b_version_id` 直查,原索引不以它打头 | +| `ix_wf_task_completed_by (b_completed_by, b_completed_at)` | 「我已处理」列表过滤列原无索引 | +| `ix_wf_task_node_run (b_node_run_id, b_status, b_seq, b_id)` | 串行会签提升、兄弟任务取消 | + +## 行尾 + +本目录 `*.sql` 一律 LF(根 `.gitattributes` 已声明 `*.sql text eol=lf`)。 +从 fms 仓拷贝时已做 LF 转换。 diff --git a/code/g3soft-erp/web/src/App.vue b/code/g3soft-erp/web/src/App.vue index 07cbbb5a..2519b735 100644 --- a/code/g3soft-erp/web/src/App.vue +++ b/code/g3soft-erp/web/src/App.vue @@ -1,8 +1,11 @@ @@ -207,30 +378,37 @@ function onRefresh() { - + + + + + {{ errorCount }} 处待修正

- 单击单元格进入编辑 · 失焦(点别处)提交 · Enter 全选内容 · Esc 取消并恢复原值 —— + 单击单元格直接编辑 · 失焦提交 · Enter 全选内容 · Esc 取消并恢复原值 —— + 灰底列不可改,必填 / 箱量 / 目的港已预置若干校验项 -

@@ -252,7 +430,7 @@ function onRefresh() { display: flex; flex-wrap: wrap; align-items: center; - gap: 16px; + gap: 12px; flex: none; } @@ -300,6 +478,25 @@ function onRefresh() { &:hover { opacity: 0.88; } + + &--ghost { + color: var(--g3-color-text, #1f2329); + background: transparent; + border: 1px solid var(--g3-color-border, #e5e6eb); + + &:hover { + background: rgba(0, 0, 0, 0.03); + opacity: 1; + } + } + } + + &__badge { + padding: 3px 8px; + font-size: 12px; + color: var(--g3-color-danger, #ec4246); + background: rgba(236, 66, 70, 0.1); + border-radius: 999px; } &__tip { diff --git a/code/g3soft-libs/COMMIT_MESSAGE.md b/code/g3soft-libs/COMMIT_MESSAGE.md new file mode 100644 index 00000000..397b9d89 --- /dev/null +++ b/code/g3soft-libs/COMMIT_MESSAGE.md @@ -0,0 +1,94 @@ +feat(table): 重构行内编辑——无缝编辑器、全表校验态、列宽拖拽、行高亮 + +## 概述 + +重做 G3Table 行内编辑的视觉与交互模型:编辑器「无缝嵌入单元格」(无外间距、 +无常驻边框),校验态全表可见(不再依赖点击行),列宽拖拽与行高亮的行为与 +fms-vue 的 FmsTable 对齐,并补上缺失的 row-click 事件与 warning 级校验。 + +## 组件库(g3soft-libs) + +### G3Table.vue +- 新增 `editorSeamless` prop(默认 true):编辑格无外间距、编辑器无常驻边框, + 焦点提示由 td 的 `:focus-within` inset 环承担,hover 用 outline、校验用文字色 + + 内描边;编辑格底色用 `--td-bgc`(不透明、主题自适应),仅在未选中时铺在 + td 上,避免盖住库的边框渐变与选区底色。 +- 新增 `row-click` 事件透传(配合受控 `active-row-key` 实现「点击行高亮」, + 与 fms 的 `@row-click="(_, row) => activeKey = ..."` 用法一致)。 +- `active-row-key` 判定修复:`vnode.props` 里存的是父级模板的**原始写法**, + 需同时匹配 camelCase 与 kebab-case(`activeRowKey` / `active-row-key`), + 此前只认驼峰导致官方推荐的 kebab 写法完全失效。 +- `activeRowKey` watcher 改为同步(去掉 `await nextTick()`),修复编辑态下 + 点击行时「高亮设上又被清掉」的竞态;仅在未受控时才由内部设置当前行。 +- 校验态(error / warning)抽取为参数化 mixin,两种等级共用;选择器覆盖 + **编辑器**与**展示态 span** 两种形态(校验全表可见的关键)。 +- 列宽拖拽:恢复 `columnsBound` 门控(未绑 `v-model:columns` 时关闭,与 fms + 一致);移除实测无效的「内部自存列宽」方案(库会把拖拽结果写进自己的列 + 对象后直接丢弃,不绑 v-model 根本无法回写)。 +- 只读列灰底判据修正:从 `tr:has(编辑器)` 改为「该行有 input/select 编辑器」, + 并排除序号列(`.seq-column`)。此前 checkbox/select 始终挂载导致「除了启用列 + 全灰」的误判。 + +### utils/column.ts +- `resolveCustomCell` 门控新增:配了 `validate` / `validateWarning` 的列, + **无论编辑态与否**都注入 customCell(走 displayValue)。这是「校验态全表可见」 + 的根因修复——此前 text/number 列在非编辑态走库原生 td,校验态根本不渲染。 + +### utils/editable.ts +- `displayValue` 在非编辑态也求校验态并打状态类(error/warning 全表可见)。 +- 新增 `validateWarning` 支持:与 `validate` 同约定(falsy 通过 / string 有问题), + error 优先于 warning;抽 `editorStatusClass` 统一 input/select/checkbox 三条路径。 +- 修复 `resolveEditorStatus` 的布尔语义:此前 `if (!result) return ''` 是 + 「truthy 报错」,与文档「falsy 通过」**正好相反**,导致 `validate: v => v > 0` + 这类最自然写法判定翻转。 + +### select/Select.vue + types.ts +- 新增 `seamless` prop:去掉触发器自身描边/圆角/底色,`min-height: 0` 释放 + 32px 下限;根节点改 `display:flex; align-items:stretch` 让触发器撑满、文字 + 垂直居中(与 input 行为一致)。 +- 网格间距从内层 input 上移到触发器(flex 容器),让清除/箭头图标一起内缩, + 不再贴到格右边界。 + +### types.ts +- `TableEditor` 新增 `validate`、`validateWarning`、`errorStyle` 字段。 +- `TableEmits` 新增 `row-click` 事件。 + +### 测试 +- `utils/editable.test.ts`:新增 validate / validateWarning 语义、error 优先、 + 抛错降级、三种编辑器类型共用状态类等 6 组用例(合计 47 个)。 +- `e2e/table.spec.ts`:新增「校验态全表可见」「校验态落在正确列」「列宽拖拽 + 需绑 v-model」「点击行进入编辑时高亮跟随」4 条真浏览器断言(合计 26 个)。 +- `playground/main.ts`:金额列加 error 校验、备注列加 warning 校验,提供三级 + 状态的真实样例数据。 + +### 文档 +- `README.md`:补「构建组件库前必须停 ERP dev server」的约定(含真实报错日志)。 +- `docs/content/ui/data/table.md`、`docs/table-design.md`、`docs/examples/table/editable.vue`: + 同步 editorSeamless、validate/validateWarning、row-click、只读列灰底的说明。 + +## ERP(g3soft-erp) + +### web/src/views/ocean/Booking.vue +- 重写订舱单范例:覆盖 text/number/select/checkbox 四种编辑器、error/warning + 校验、按行禁用、只读列灰底、当前行高亮、保存时整表校验并定位首个错误格。 +- 删除「编辑明细/取消编辑」按钮与 snapshot 回滚机制,页面**始终可编辑**, + 且不默认激活任何一行(编辑目标由用户点击产生)。 +- 给 G3Table 绑定 `v-model:columns`(列宽拖拽的必要条件,与 fms 用法一致)。 + +### README.md +- 补「本地联调组件库:构建时必须先停 dev server」的约定。 + +## 验证 + +- `pnpm --filter @g3soft/ui typecheck` 通过 +- `pnpm --filter @g3soft/ui exec vitest run src/table` → 124 passed +- `pnpm --filter @g3soft/ui exec playwright test e2e/table.spec.ts` → 26 passed + +## 备注 + +- **本次改动仅涉及前端**。`git diff` 中出现的 ERP server 端 Java 文件 + (GlobalExceptionHandler.java、JwtAuthFilter.java、RequestLoggingFilter.java、 + logback-spring.xml 等)及 `log-viewer.html`、`_gen.demologs.js` 等,属于仓库 + 其它工作流的改动,**不在本次提交范围**,请勿一并提交。 +- 校验态底色比例(error 12% / warning 8%,暗色 8% / 6%)为观感推定的档位, + 非实测调优值,可按实际观感调整。 diff --git a/code/g3soft-libs/README.md b/code/g3soft-libs/README.md index df7b117e..e5505b6e 100644 --- a/code/g3soft-libs/README.md +++ b/code/g3soft-libs/README.md @@ -47,6 +47,39 @@ pnpm build:docs # 构建文档站静态文件 文档站静态产物在 `docs/.output/public`,可直接部署到任意静态托管。 +### 改完组件库,怎么让业务方生效 + +`g3soft-erp` 通过 `overrides` link 到本仓库的 `packages/ui`,**消费的是构建产物 `dist/`**(`exports` 指向 `./dist/index.js` 与 `./dist/style.css`),不是源码。所以改了 `packages/ui/src` 之后不重新 build,业务方一点变化都看不到。 + +关键在于**不能边跑 ERP dev server 边构建**。ERP 的 Vite 监视着 `dist/`,而 `vite build` 会先清空再逐个写回产物文件,于是: + +1. 每个产物文件被重写都触发一次 `page reload`,浏览器反复刷新; +2. 构建中途 `dist/style.css` 会**短暂不存在**,而 ERP 的 `src/styles/index.scss` 里有 `@use '@g3soft/ui/style.css';`,Sass 解析时文件已经没了 —— dev server 进程直接崩溃: + +``` +[vite] Internal server error: [sass] ENOENT: no such file or directory, + open 'D:\workspace\code\g3soft-libs\packages\ui\dist\style.css' + ╷ +9 │ @use '@g3soft/ui/style.css'; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ╵ + src\styles\index.scss 9:1 root stylesheet +``` + +随后是 `ELIFECYCLE Command failed with exit code 4294967295.` + +还有更隐蔽的一种:server 没崩,但缓存到了**写入中途**的模块图,页面白屏、控制台报 `Failed to resolve import "./tooltip/Tooltip.js" from ".../dist/index.js"`。此时 `dist` 盘上的产物其实是完整的,问题纯粹在 Vite 缓存。 + +**因此顺序只能是「先停、再构建、后启动」**: + +1. 停掉 ERP 的 dev server(占用 5090 端口) +2. 在本仓库执行 `pnpm --filter @g3soft/ui build` +3. 在 `g3soft-erp/web` 下执行 `pnpm dev` 重新启动 + +若怀疑是缓存问题(白屏、找不到模块),额外删掉 `g3soft-erp/web/node_modules/.vite` 再启动即可。改 `packages/tokens` 同理 —— 它也要 build,且要在业务方 dev server 停掉时做。 + +> 这跟 `pnpm dev`(文档站)无关:文档站通过 alias 直连 `packages/ui/src`,改源码即时热更,不需要也不应该为它重新构建组件库。 + ### 部署到子路径 文档站支持挂载到子路径,构建时通过环境变量指定: diff --git a/code/g3soft-libs/docs/content/ui/data/table.md b/code/g3soft-libs/docs/content/ui/data/table.md index f1230e5c..06f3305a 100644 --- a/code/g3soft-libs/docs/content/ui/data/table.md +++ b/code/g3soft-libs/docs/content/ui/data/table.md @@ -70,6 +70,25 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、 编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。 +### 编辑器形态:单元格内无外间距、无常驻边框 + +默认(`editorSeamless`)下编辑器的**外间距为 0、自身不画常驻边框**,整格铺满、融进表格: + +- 适合整行编辑、键盘连续录入、固定列宽、专业用户(Excel / AG Grid / Handsontable 的表格态); +- 单元格几何在进入 / 离开编辑时**完全不变**——编辑器的盒高就等于 `rowHeight`,同一行的纯文本列不会跟着跳; +- 水平内边距由单元格统一提供(编辑器和纯文本格用同一套网格),**进出编辑态零位移**; +- 视觉提示只在**焦点、hover 与校验**时出现,且都不占用布局空间: + - 焦点 → 单元格内嵌的主色焦点环(`td:focus-within` 的内阴影)+ 输入框自身的焦点光晕; + - hover → 格内极淡填充; + - 校验(`status` = `error` / `warning`)→ 文字色 + 1px 内描边。 + +```vue + + +``` + +两种形态的**编辑器盒高都等于 `rowHeight`**,所以切换真值不会改变行高。`textarea` 编辑器在默认的 30px 行里放不下正文,需要显式给足 `row-height`。 + ### ⚠ 数据源必须是响应式的 **行对象必须能被 Vue 追踪**,否则编辑回写不会触发重渲染。典型的踩坑写法是用 `computed` 派生数据: @@ -92,6 +111,15 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) } 开发模式下,进入编辑时会检测并打印一条警告指明改法(每个组件实例只警告一次)。 +### 编辑期的只读列 + +某一列没有配 `editor` 时,它在编辑期**不可修改**。为了让人一眼看出「这格点不动」, +该行里**没有编辑器的格子会自动铺一层灰底**、文字也降一档亮度—— +不需要业务额外配任何东西,也不需要加锁图标。 + +灰底只出现在**正在编辑的那一行**(其余行保持原样),且用 `--td-bgc` 叠中性色得到, +所以亮 / 暗主题自动跟随。 + ## 操作列 @@ -129,6 +157,7 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) } | `seq` | `boolean \| { title?, width? }` | `true` | 序号列 | | `activeRowKey` | `string \| number \| null` | `null` | 当前高亮行 | | `editable` | `boolean` | `true` | 行内编辑总开关 | +| `editorSeamless` | `boolean` | `true` | 编辑器无缝嵌入单元格:格内无外间距、无常驻边框,只有焦点 / hover / 校验态才给视觉提示。传 `false` 回到留间距 + 自身描边的形态 | | `noDataFull` | `boolean` | `true` | 空态铺满容器高度 | | `showOverflow` | `boolean` | `true` | 内容溢出省略 | | `autoFitWidth` | `boolean` | `false` | 非横向虚拟时铺满容器宽度(开启后关闭列宽拖拽) | @@ -159,6 +188,47 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) } | `allowClear` | `boolean` | select 是否可清空,默认 `true` | | `emptyValue` | `unknown` | select 清空后落库的值,默认 `null` | | `onChange` | `(value, row, col) => void` | 本列值变化回调(早于组件的 `change`) | +| `validate` | `(value, row, col) => boolean \| string` | 校验规则:**返回 falsy 表示通过**,返回非空 string 表示不通过(该字符串即错误原因)。 | +| `errorStyle` | `'cell' \| 'inline'` | 校验失败的展示形态,默认 `'cell'`。见下 | + +### 校验(`validate`) + +给列配 `validate` 即可获得**实时校验**: + +```ts +{ + dataIndex: 'qty', + title: '采购数量', + editor: { + type: 'number', + // 返回 falsy = 通过;返回 string = 不通过,字符串即错误原因 + validate: (value) => Number(value) > 0 || '采购数量必须大于 0', + }, +} +``` + +三件需要知道的事: + +- **返回值的语义是「falsy 通过 / string 不通过」**,不是「true 通过」。所以 + `validate: (v) => Number(v) > 0 || '必须大于 0'` 这种最自然的写法是对的; + 但若写成 `(v) => Number(v) > 0`(只返回布尔),则**永远不会报错**—— + 布尔 `true` 被当作「通过」、`false` 也被当作「通过」。要让布尔参与判错, + 必须返回字符串。这一点容易踩,故单独说明。 +- **校验是派生值,不是一次性状态**:每次渲染都按当前行数据重算, + 所以用户把空值改成合法值后,红底与角标在下次渲染就自动消失, + **不需要**业务手动清状态,组件内部也不维护任何 error 注册表。 +- **校验函数抛错会被吞掉**并降级为「通过」(仅 DEV 打印警告)。 + 因为它在渲染期被调用,抛出去会让整格渲染失败,比「没有红底」严重得多。 + +#### 展示形态 `errorStyle` + +| 值 | 效果 | +| --- | --- | +| `'cell'`(默认) | 单元格铺**浅红底** + 右上角 **`!` 角标**;聚焦时焦点环转为错误色 | +| `'inline'` | 只在编辑器自身画文字色与 1px 内描边,**不**铺底、**不**加角标 | + +浅红底取自设计原型,适合 ERP 明细这类以单元格为视觉单位的场景; +若表格本身已经很密、或那一列很窄(角标会挤到文字),用 `'inline'` 更克制。 ### `actions` @@ -215,4 +285,5 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) } - 没有圆角;`bordered: false` 时四边与格线全部消失。 - **`--th-bgc` 等表头/表尾底色**由覆盖层的变量映射提供,不通过行内 style 注入。 - **行编辑态存在 `WeakMap` 里**:按行对象存,不给业务数据打标记;行被回收时状态自动释放。行与行之间切换编辑**不翻转** `editingActive`,避免整表列重建。 +- **编辑器无缝嵌入单元格**(`editorSeamless`):把 `G3Input` / `G3Select` 自带的 `padding` / `border` / `border-radius` / `background` 置零,只保留焦点、hover 与校验提示;编辑器的高度由组件显式钉成 `var(--row-height)`(不能被控件的 `min-height: 32px` 顶高,也不能靠 `height: 100%` —— td 的内容盒不等于行高),因此**编辑前后的行高完全一致**。焦点环画在外层 `td` 的 `inset` 阴影上:不参与盒模型计算,几何零影响。 - **RTL**:布局用逻辑属性,带符号位移走 `--g3-dir-sign`;分隔条拖拽与键盘方向由库的 `useDirection` 处理。 diff --git a/code/g3soft-libs/docs/examples/table/editable.vue b/code/g3soft-libs/docs/examples/table/editable.vue index a74c853c..5379bf32 100644 --- a/code/g3soft-libs/docs/examples/table/editable.vue +++ b/code/g3soft-libs/docs/examples/table/editable.vue @@ -10,6 +10,13 @@ import type { G3TableColumn, TableRow } from '@g3soft/ui' // select → 用 G3Select 渲染;options 可传数组,也可传「接收当前行的函数」 // checkbox → 点击即切换,无需先激活整行;约定 1 = 选中、0 = 未选中(对齐后端 tinyint) // +// 形态(editorSeamless,默认 true):**单元格内无外间距 + 编辑器无常驻边框**。 +// 编辑器整格铺满、融进表格,只有焦点(格内主色环)、hover(极淡填充) +// 和校验(红色文字)才出现视觉提示——这是整行编辑 / 键盘连续录入 / +// 固定列宽 / 专业用户(Excel、AG Grid)的表格态。 +// 传 :editor-seamless="false" 则回到「格内留一圈间距 + 自身描边」的悬浮卡片观感。 +// 两种形态下编辑器的盒高都等于行高,切换不会改变行高、也不会让同行其它格跳动。 +// // 关键行为:输入值先写进内部草稿,**失焦才落库并抛出 change**。 // 这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。 const columns: G3TableColumn[] = [ @@ -73,7 +80,8 @@ function onChange(row: TableRow, col: G3TableColumn, value: unknown) { 最近一次 change:{{ lastChange || '(点击单元格试试)' }}

- 单击进入编辑 · Enter 确认 · Esc 取消 · Tab / 方向键可换格 + 单击进入编辑 · Enter 确认 · Esc 取消 · Tab / 方向键可换格 · + 单元格内无外间距、无常驻边框,焦点时才出现主色焦点环

diff --git a/code/g3soft-libs/docs/table-design.md b/code/g3soft-libs/docs/table-design.md index 2bc4f7c6..ed4140de 100644 --- a/code/g3soft-libs/docs/table-design.md +++ b/code/g3soft-libs/docs/table-design.md @@ -116,6 +116,7 @@ packages/ui/src/table/ | `seq` | `boolean \| object` | `true` | 序号列 | | `activeRowKey` | `string \| number \| null` | `null` | 当前高亮行 | | `editable` | `boolean` | `true` | 行内编辑总开关 | +| `editorSeamless` | `boolean` | `true` | 单元格内无外间距 + 无常驻边框(§5.3) | | `noDataFull` / `showOverflow` | `boolean` | `true` | 空态铺满 / 溢出省略 | | `autoFitWidth` | `boolean` | `false` | 非横向虚拟时铺满容器 | | `theme` | `'light' \| 'dark'` | `undefined` | 不传则跟随全局主题(**按 §4.5 从 ConfigProvider 取,不依赖 pinia**) | @@ -214,6 +215,36 @@ const displayColumns = computed(() => { --- +## 4.7 编辑器无缝嵌入单元格(`editorSeamless`,默认开启) + +**形态**:单元格内部**无外间距** + 编辑器**无常驻边框**。适合整行编辑、键盘连续 +录入、固定列宽以及专业用户(Excel / AG Grid / Handsontable 的表格态)。 + +**目标**:单元格保持稳定的几何结构,编辑器融进表格,**只有焦点、校验和特殊 +交互状态才增强视觉提示**。 + +三条硬约束(改动这块前必读): + +1. **编辑器盒高 = `var(--row-height)`**。td 是 border-box,指定 `height: 30px` 时 + 内容盒只有 `30 - padding`,而子元素 `height: 100%` 解析的基准是**内容盒**。 + 所以 td 垂直 padding 必须为 0,且编辑器不能靠 `height: 100%` 撑—— + `G3Select` 的触发器自带 `min-height: 32px`,在 30px 的行里会直接把行顶高 2px。 + 显式 `height: var(--row-height)` + `min-height: 0` 把下限压死。 +2. **水平内边距只能由 td 提供**(库的 `--cell-padding-x`)。若改成给编辑器加 + 透明边框,编辑器与纯文本格会各用一套逻辑像素取整,窄列下出现「编辑前文字 + 偏移 1px」的漂移;td padding 对两者一视同仁,**进出编辑态零位移**。 +3. **无边框 ≠ 无提示**。焦点环画在**外层 td 的 `inset` 阴影**上(`:focus-within`): + 内阴影在 padding box 内侧绘制,不参与 td 自身的盒模型计算,几何绝对稳定, + 且天然铺满整格;hover 换成格内极淡填充,校验用文字色 + 1px 内描边。 + 三者都不占用布局空间。 + +去零的是 `padding` / `border` / `border-radius` / `background`, +**绝不动 height**;被移除的内边距由编辑器内层控件补回一次网格间距 +(`space-1` / `space-2`,与纯文本格同一档),因此「无间距」的收益是 +**格内不留空白**,而不是让文字失去网格对齐。 + +--- + ## 5. 与现有约定的对齐(必改项) | 参考实现 | 我们必须改成 | diff --git a/code/g3soft-libs/packages/ui/e2e/table.spec.ts b/code/g3soft-libs/packages/ui/e2e/table.spec.ts index 0218fea5..386d6697 100644 --- a/code/g3soft-libs/packages/ui/e2e/table.spec.ts +++ b/code/g3soft-libs/packages/ui/e2e/table.spec.ts @@ -154,6 +154,292 @@ test.describe('G3Table 选择列(真浏览器命中)', () => { }) }) +/** + * 行内编辑的几何契约。 + * + * happy-dom 没有布局引擎,这些只能在真浏览器里量:编辑器的盒高必须等于行高, + * 且进入编辑**不得改变**所在行的高度(同行的纯文本列也不能跟着跳)。 + */ +test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => { + /** 量表格里每一行的高度(取整) */ + async function rowHeights(page: import('@playwright/test').Page, wrapSelector: string) { + return page.locator(wrapSelector).locator('.stk-tbody-main tr').evaluateAll((rows) => + rows.map((el) => Math.round(el.getBoundingClientRect().height)), + ) + } + + /** + * 按**列标题**定位单元格,不写死列序号。 + * + * 写死 `td:nth-child(n)` 是错的来源:列顺序由调用方决定(演示数据里 + * 「备注」是第 7 列、「状态」是第 5 列),列一变断言就指到别的格子, + * 表现为「莫名其妙失败」。 + */ + async function cellByTitle( + page: import('@playwright/test').Page, + wrapSelector: string, + title: string, + ) { + const wrap = page.locator(wrapSelector) + const index = await wrap + .locator('thead th') + .evaluateAll((ths, t) => ths.findIndex((th) => (th.textContent || '').trim() === t), title) + if (index < 0) throw new Error(`找不到列标题「${title}」`) + return wrap.locator('.stk-tbody-main tr').first().locator('td').nth(index) + } + + /** + * 编辑器根节点选择器。 + * + * ⚠️ 不能只用 `.g3-table__cell-editor-root`:checkbox 编辑器也用同一个类 + * (`label.g3-table__checkbox.g3-table__cell-editor-root`),演示数据里每行 + * 有两个复选框列,于是 strict mode 直接报「resolved to 3 elements」。 + * 这里只取 Input / Select 这两种「带外框的」编辑器。 + */ + const EDITOR = '.g3-table__cell-editor-root.g3-input, .g3-table__cell-editor-root.g3-select' + + test('根节点带 is-editor-seamless(默认开启)', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + await expect(wrap.locator('.g3-table').first()).toHaveClass(/is-editor-seamless/) + }) + + test('进入编辑:行高不变,且编辑器铺满单元格(无外间距)', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + + const before = await rowHeights(page, '[data-testid="table-wrap"]') + + await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click() + const editor = wrap.locator(EDITOR).first() + await expect(editor).toBeVisible() + + // 编辑器盒高 == 所在 td 的高(td 上下 padding 为 0 → 两者相等) + const metrics = await editor.evaluate((el) => { + const td = el.closest('td') as HTMLElement + const style = getComputedStyle(el) + const tdStyle = getComputedStyle(td) + return { + editor: Math.round(el.getBoundingClientRect().height), + td: Math.round(td.getBoundingClientRect().height), + tdPadTop: tdStyle.paddingTop, + tdPadBottom: tdStyle.paddingBottom, + border: style.borderTopWidth, + radius: style.borderTopLeftRadius, + background: style.backgroundColor, + } + }) + + // 无外间距:编辑器与格同高,td 垂直 padding 为 0(编辑器不被内缩) + expect(metrics.editor).toBe(metrics.td) + expect(metrics.tdPadTop).toBe('0px') + expect(metrics.tdPadBottom).toBe('0px') + // 无常驻边框 / 圆角 / 底色 + expect(metrics.border).toBe('0px') + expect(metrics.radius).toBe('0px') + expect(metrics.background).toBe('rgba(0, 0, 0, 0)') + + // 关键回归:编辑后行高不变(历史 bug 是从 30px 变 42px) + expect(await rowHeights(page, '[data-testid="table-wrap"]')).toEqual(before) + }) + + test('Input 编辑器:内边距转移到内层原生 input,不落在外壳上', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + + await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click() + await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible() + + const padding = await wrap + .locator('.g3-table__cell-editor-root.g3-input') + .first() + .evaluate((el) => ({ + shell: getComputedStyle(el).padding, + // 精确取原生控件;`.g3-input input` 会连 password/clear 按钮一起算进来 + control: getComputedStyle(el.querySelector('.g3-input__control') as HTMLElement).padding, + })) + + // 外壳无内边距 = 无外间距;网格间距由内层控件补回 + expect(padding.shell).toBe('0px') + expect(padding.control).not.toBe('0px') + }) + + test('焦点态才出现视觉提示:单元格内嵌焦点环', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + + const target = await cellByTitle(page, '[data-testid="table-wrap"]', '备注') + + // 未聚焦时该格没有内嵌焦点环 + const idle = await target.evaluate((el) => getComputedStyle(el).boxShadow) + expect(idle).not.toContain('inset') + + await target.click() + await expect(wrap.locator(EDITOR).first()).toBeVisible() + + // 焦点环画在**被点的那一格**上(限定 target,避免匹配到同行其它格) + const focused = await target.evaluate((el) => getComputedStyle(el).boxShadow) + expect(focused).toContain('inset') + }) + + test('select 编辑器同样铺满:触发器的描边 / 圆角 / 32px 下限都被去掉', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + + const before = await rowHeights(page, '[data-testid="table-wrap"]') + + await (await cellByTitle(page, '[data-testid="table-wrap"]', '状态')).click() + const select = wrap.locator('.g3-table__cell-editor-root.g3-select').first() + await expect(select).toBeVisible() + + /** + * 关键:量**触发器**而不是组件根。 + * + * 触发器的真实 DOM 位置是 `div.g3-select > [Popover] > span.g3-select__trigger`, + * 不在根的直接子树里 —— 只量根会漏掉真正带描边/圆角的那个元素 + * (这正是此前「改了但看着没变」的原因)。 + */ + const metrics = await select.evaluate((el) => { + const trigger = el.querySelector('.g3-select__trigger') as HTMLElement + const s = getComputedStyle(trigger) + const root = getComputedStyle(el) + return { + rootClass: el.className, + triggerHeight: Math.round(trigger.getBoundingClientRect().height), + triggerBorder: s.borderTopWidth, + triggerRadius: s.borderTopLeftRadius, + triggerBg: s.backgroundColor, + rootBorder: root.borderTopWidth, + rootRadius: root.borderTopLeftRadius, + } + }) + + // 无缝修饰类必须真的落到 G3Select 根上(否则下面的样式规则不生效) + expect(metrics.rootClass).toContain('is-seamless') + + expect(metrics.triggerBorder).toBe('0px') + expect(metrics.triggerRadius).toBe('0px') + expect(metrics.triggerBg).toBe('rgba(0, 0, 0, 0)') + expect(metrics.rootBorder).toBe('0px') + expect(metrics.rootRadius).toBe('0px') + + // 触发器不再锁 32px(行高 30px),否则会把行顶高 2px + const rowHeight = await wrap + .locator('.stk-tbody-main tr') + .first() + .evaluate((el) => Math.round(el.getBoundingClientRect().height)) + expect(metrics.triggerHeight).toBeLessThanOrEqual(rowHeight) + + expect(await rowHeights(page, '[data-testid="table-wrap"]')).toEqual(before) + }) +}) + +/** + * 校验态、列宽拖拽、当前行高亮 —— 这三条是本组件反复回归过的行为, + * 专门用真浏览器锁住(happy-dom 没有布局引擎,也测不了 :has() 命中)。 + * + * 宿主导入的 playground 数据里: + * - 金额列 validate:> 0 否则 error(第 2 行 amount=0 → 必有一处 error) + * - 备注列 validateWarning:非空否则 warning(第 2 行 remark='' → 必有一处 warning) + * - table-wrap 绑了 v-model:columns(列宽拖拽的前提,缺了拖不动) + */ +test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => { + test('校验态全表可见:不点任何行就能看到 error 与 warning', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + + // 关键前提:进入页面后**不点击任何单元格**,直接断言校验态存在。 + // 这锁的是「校验是数据的状态,不依赖哪一行被交互过」——此前 text/number + // 列在非编辑态走库原生 td,校验态根本不渲染(必须点行才出现)。 + const errCells = wrap.locator('td:has(> .is-editor-error)') + const warnCells = wrap.locator('td:has(> .is-editor-warning)') + await expect(errCells).toHaveCount(1) // 金额列第 2 行 amount=0 + await expect(warnCells).toHaveCount(1) // 备注列第 2 行 remark='' + }) + + test('校验态落在正确的列上(error=金额、warning=备注)', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + + // 用列标题定位,不写死索引 + const ths = await wrap.locator('thead th').evaluateAll((all) => + all.map((t) => (t.textContent || '').trim()), + ) + const amountIdx = ths.findIndex((t) => t === '金额') + const remarkIdx = ths.findIndex((t) => t === '备注') + + // 第 2 行(合同 B)的金额格是 error、备注格是 warning + const row = wrap.locator('.stk-tbody-main tr').nth(1) + await expect(row.locator('td').nth(amountIdx).locator('.is-editor-error')).toHaveCount(1) + await expect(row.locator('td').nth(remarkIdx).locator('.is-editor-warning')).toHaveCount(1) + }) + + test('列宽拖拽:绑定 v-model:columns 后可拖动,宽度真实变化', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + + // 表格应带 col-resizable 类(列宽拖拽已启用) + await expect(wrap.locator('.stk-table')).toHaveClass(/col-resizable/) + + // 取「名称」列表头宽度(第 2 个 th,序号列是第 1 个) + const th = wrap.locator('thead th').nth(2) + const before = await th.evaluate((el) => Math.round(el.getBoundingClientRect().width)) + + // 拖它的右边缘手柄 +60px + const handle = th.locator('.table-header-resizer.right') + const hb = await handle.boundingBox() + expect(hb).not.toBeNull() + if (hb) { + const cx = hb.x + hb.width / 2 + const cy = hb.y + hb.height / 2 + await page.mouse.move(cx, cy) + await page.mouse.down() + for (let i = 1; i <= 15; i += 1) { + await page.mouse.move(cx + i * 4, cy) + await page.waitForTimeout(15) + } + await page.mouse.up() + await page.waitForTimeout(500) + } + + const after = await th.evaluate((el) => Math.round(el.getBoundingClientRect().width)) + expect(after).toBeGreaterThan(before) + }) + + test('点击行进入编辑时,当前行高亮(active)跟随', async ({ page }) => { + await page.goto('/') + const wrap = page.locator('[data-testid="table-wrap"]') + await wrap.scrollIntoViewIfNeeded() + + const rows = wrap.locator('.stk-tbody-main tr') + + // 初始无 active 行 + const activeCount = async () => + wrap.locator('.stk-tbody-main tr.active').count() + expect(await activeCount()).toBe(0) + + // 点第 2 行的一个可编辑格(金额列),进入编辑并高亮 + const ths = await wrap.locator('thead th').evaluateAll((all) => + all.map((t) => (t.textContent || '').trim()), + ) + const amountIdx = ths.findIndex((t) => t === '金额') + await rows.nth(1).locator('td').nth(amountIdx).click() + await page.waitForTimeout(300) + + // 第 2 行应带 active + await expect(rows.nth(1)).toHaveClass(/active/) + expect(await activeCount()).toBe(1) + }) +}) + /** * 这两条盯的是「库的 flex 布局 + 自绘分隔线」这对组合: * 库的 DOM 里 table.stk-table-main 是 .stk-table-scroll-container 的 flex item, diff --git a/code/g3soft-libs/packages/ui/playground/main.ts b/code/g3soft-libs/packages/ui/playground/main.ts index e8a97bdf..1c6a4ebf 100644 --- a/code/g3soft-libs/packages/ui/playground/main.ts +++ b/code/g3soft-libs/packages/ui/playground/main.ts @@ -197,13 +197,31 @@ const App = { // 注意必须是 ref:模板里 v-model:columns 要能回写,否则拖列宽无处落库(松手回弹) const tableColumns = ref([ { dataIndex: 'name', title: '名称', width: 180 }, - { dataIndex: 'amount', title: '金额', width: 120, align: 'right' }, + { + dataIndex: 'amount', + title: '金额', + width: 120, + align: 'right', + editor: { + type: 'number', + // 金额必须 > 0:让「校验态全表可见」在 E2E 里有可断言的 error 数据 + validate: (v: unknown) => Number(v) > 0 || '金额必须大于 0', + }, + }, { dataIndex: 'status', title: '状态', width: 110, editor: { type: 'select', options: [ { label: '启用', value: 1 }, { label: '停用', value: 0 }, ] } }, { dataIndex: 'enabled', title: '启用', width: 80, editor: { type: 'checkbox' } }, - { dataIndex: 'remark', title: '备注', editor: { type: 'text' } }, + { + dataIndex: 'remark', + title: '备注', + editor: { + type: 'text', + // 备注非空:提供 warning 级校验(金额是 error,这里是 warning,两级都有样例) + validateWarning: (v: unknown) => String(v ?? '').length > 0 || '建议填写备注', + }, + }, { dataIndex: 'op', title: '操作', width: 140, resizable: false, actions: [ { label: '编辑', onClick: () => {} }, { label: '删除', type: 'danger', confirm: '确认删除该行?', onClick: () => {} }, @@ -211,7 +229,7 @@ const App = { ]) const tableData = ref([ { id: 1, name: '合同 A', amount: 12000, status: 1, enabled: 1, remark: '已归档' }, - { id: 2, name: '合同 B', amount: 8600, status: 0, enabled: 0, remark: '' }, + { id: 2, name: '合同 B', amount: 0, status: 0, enabled: 0, remark: '' }, { id: 3, name: '合同 C', amount: 43000, status: 1, enabled: 1, remark: '待复核' }, ]) const tableSelectedKeys = ref([]) diff --git a/code/g3soft-libs/packages/ui/src/fps-monitor/FpsMonitor.vue b/code/g3soft-libs/packages/ui/src/fps-monitor/FpsMonitor.vue new file mode 100644 index 00000000..a379f507 --- /dev/null +++ b/code/g3soft-libs/packages/ui/src/fps-monitor/FpsMonitor.vue @@ -0,0 +1,491 @@ + + + + + diff --git a/code/g3soft-libs/packages/ui/src/fps-monitor/index.ts b/code/g3soft-libs/packages/ui/src/fps-monitor/index.ts new file mode 100644 index 00000000..50f76e00 --- /dev/null +++ b/code/g3soft-libs/packages/ui/src/fps-monitor/index.ts @@ -0,0 +1,2 @@ +export { default as G3FpsMonitor } from './FpsMonitor.vue' +export type { FpsMonitorProps } from './types' diff --git a/code/g3soft-libs/packages/ui/src/fps-monitor/types.ts b/code/g3soft-libs/packages/ui/src/fps-monitor/types.ts new file mode 100644 index 00000000..b2416f20 --- /dev/null +++ b/code/g3soft-libs/packages/ui/src/fps-monitor/types.ts @@ -0,0 +1,10 @@ +export interface FpsMonitorProps { + /** 采样间隔(毫秒):FPS 上报与柱状图刷新周期。低于 100 会被钳到 100。 */ + sampleInterval?: number + /** 柱状图保留的历史样本数。低于 1 会被钳到 1。 */ + historySize?: number + /** 基准 FPS:用于颜色分级与柱高换算。低于 1 会被钳到 1。 */ + baseFps?: number + /** 初始是否折叠为迷你胶囊。默认 true。 */ + collapsed?: boolean +} diff --git a/code/g3soft-libs/packages/ui/src/index.ts b/code/g3soft-libs/packages/ui/src/index.ts index 485dbcb8..ed367e1b 100644 --- a/code/g3soft-libs/packages/ui/src/index.ts +++ b/code/g3soft-libs/packages/ui/src/index.ts @@ -13,6 +13,7 @@ import { G3ColorPicker } from './color-picker' import { G3CalendarPanel, G3DatePicker, G3RangePicker, G3TimeSelect } from './date' import { G3Drawer } from './drawer' import { G3Dropdown, G3DropdownItem } from './dropdown' +import { G3FpsMonitor } from './fps-monitor' import { G3Form, G3FormGroup, G3FormItem } from './form' import { G3Col, G3Row } from './grid' import { G3Input } from './input' @@ -45,6 +46,7 @@ export * from './color-picker' export * from './date' export * from './drawer' export * from './dropdown' +export * from './fps-monitor' export * from './form' export * from './grid' export * from './input' @@ -136,6 +138,7 @@ const components = { G3Drawer, G3Dropdown, G3DropdownItem, + G3FpsMonitor, G3Form, G3FormGroup, G3FormItem, diff --git a/code/g3soft-libs/packages/ui/src/select/Select.vue b/code/g3soft-libs/packages/ui/src/select/Select.vue index c3f175b7..4f98b795 100644 --- a/code/g3soft-libs/packages/ui/src/select/Select.vue +++ b/code/g3soft-libs/packages/ui/src/select/Select.vue @@ -28,6 +28,7 @@ const props = withDefaults(defineProps(), { allowFreeInput: false, labelKey: 'label', valueKey: 'value', + seamless: false, }) const emit = defineEmits<{ @@ -38,7 +39,7 @@ const emit = defineEmits<{ loadMore: [] }>() -const { b, e, m, is } = useNamespace('select') +const { b, e, is } = useNamespace('select') const locale = useLocale() interface NormalizedOption { @@ -482,7 +483,17 @@ defineExpose({