diff --git a/docs/superpowers/plans/2026-07-27-oa-auto-pass-initiator-approver.md b/docs/superpowers/plans/2026-07-27-oa-auto-pass-initiator-approver.md new file mode 100644 index 00000000..c6bdb65d --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-oa-auto-pass-initiator-approver.md @@ -0,0 +1,699 @@ +# OA 审批人为发起人时自动通过 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 OA 应用高级设置增加默认关闭的开关;开启后,审批节点建待办若含发起人本人,则系统代为同意并推进(或签整节点过、会签仅本人、可连续自动通过),进度意见为「系统自动通过」。 + +**Architecture:** 在 `DEFAULT_ADVANCED_SETTINGS` 增加 `autoPassWhenApproverIsInitiator`。从 `complete_task` 抽出无 commit 的同意收口 `_approve_task_core`;人工同意仍走校验 + commit。在 `advance_from` 对审批节点 `_create_tasks_for_node` 之后调用 `_maybe_auto_pass_initiator`:开关开且存在本人 pending 时调用核心(跳过 `commentRequired`,意见固定「系统自动通过」),或签/会签收口与现网一致;推进时嵌套 `advance_from` 自然连续自动通过。设计器高级设置增加对应开关。 + +**Tech Stack:** Python 3.9 / FastAPI OA 引擎、SQLAlchemy、unittest + SQLite in-memory;Vue 3 + Element Plus(`web-admin` 设计器)。 + +**Spec:** `docs/superpowers/specs/2026-07-27-oa-auto-pass-initiator-approver-design.md` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| `backend/app/crud/oa_approval.py` | 默认高级设置增加键 | +| `backend/app/services/oa_engine.py` | 抽 `_approve_task_core`;`_maybe_auto_pass_initiator`;`advance_from` 挂钩;`complete_task` 改调核心 | +| `backend/tests/test_oa_auto_pass_initiator.py` | 新测:开关关/开、或签、会签、连续、意见必填 | +| `web-admin/src/views/oa-approval/designer/index.vue` | 高级设置 UI + reactive / loadDraft | + +--- + +### Task 1: 默认设置键 + 失败用例骨架 + +**Files:** +- Modify: `backend/app/crud/oa_approval.py`(`DEFAULT_ADVANCED_SETTINGS`) +- Create: `backend/tests/test_oa_auto_pass_initiator.py` + +- [ ] **Step 1: 写入默认键** + +在 `DEFAULT_ADVANCED_SETTINGS` 增加一行: + +```python +DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = { + "supervisorFallbackToDeptLeader": True, + "allowWithdrawRunning": True, + "approverDedup": False, + "commentRequired": False, + "allowAddSign": True, + "allowReturn": True, + "autoPassWhenApproverIsInitiator": False, +} +``` + +- [ ] **Step 2: 写失败测试(开关开 + 或签单人=发起人,期望自动通过;当前应 FAIL)** + +创建 `backend/tests/test_oa_auto_pass_initiator.py`: + +```python +"""OA:审批人为发起人时自动通过""" +import json +import unittest +from datetime import datetime +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + + +class TestAutoPassInitiator(unittest.TestCase): + def setUp(self): + from app.models.models import OaAppVersion, OaInstance, OaTask + + self.engine = create_engine("sqlite:///:memory:") + OaAppVersion.__table__.create(self.engine) + OaInstance.__table__.create(self.engine) + OaTask.__table__.create(self.engine) + Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) + self.db = Session() + self.OaAppVersion = OaAppVersion + self.OaInstance = OaInstance + self.OaTask = OaTask + self.now = datetime.now() + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def _seed(self, *, flow, advanced, initiator="u0", tasks=None): + self.db.add( + self.OaAppVersion( + id="v1", + app_id="app1", + version_no=1, + status="published", + is_latest_published="1", + form_schema_json="{}", + flow_tree_json=json.dumps(flow), + advanced_settings_json=json.dumps(advanced), + tenant_no="zkxw", + create_dept_id="", + create_time=self.now, + update_time=self.now, + del_flag=0, + ) + ) + self.db.add( + self.OaInstance( + id="i1", + app_id="app1", + app_version_id="v1", + title="t", + initiator_user_id=initiator, + initiator_dept_id="d1", + form_data_json="{}", + status="running", + tenant_no="zkxw", + create_dept_id="", + create_time=self.now, + update_time=self.now, + del_flag=0, + ) + ) + for t in tasks or []: + self.db.add(t) + self.db.commit() + + def test_or_sign_sole_initiator_auto_passes_to_end(self): + """开关开:或签唯一审批人=发起人 → 提交推进后自动通过到结束。""" + from app.services.oa_engine import advance_from + + flow = { + "id": "s", + "type": "start", + "child": { + "id": "a1", + "type": "approval", + "name": "审批人", + "approverConfig": { + "assigneeType": "users", + "userIds": ["u0"], + "signMode": "or", + }, + "child": {"id": "e1", "type": "end"}, + }, + } + self._seed( + flow=flow, + advanced={"autoPassWhenApproverIsInitiator": True}, + ) + inst = self.db.query(self.OaInstance).filter_by(id="i1").first() + ver = self.db.query(self.OaAppVersion).filter_by(id="v1").first() + + with patch( + "app.services.oa_engine.resolve_assignees", + return_value=(["u0"], {"resolve_source": "users"}), + ), patch( + "app.services.oa_engine._next_task_order_num", + return_value=1, + ): + advance_from( + self.db, inst=inst, ver=ver, from_node=None, user_id="u0" + ) + + tasks = self.db.query(self.OaTask).filter_by(instance_id="i1").all() + self.assertEqual(len(tasks), 1) + self.assertEqual(tasks[0].status, "approved") + self.assertEqual(tasks[0].comment_text, "系统自动通过") + self.assertEqual(inst.status, "completed") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 3: 跑测确认失败** + +Run: + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_auto_pass_initiator.py::TestAutoPassInitiator::test_or_sign_sole_initiator_auto_passes_to_end -v +``` + +Expected: FAIL(任务仍 `pending` 或实例仍 `running`,因尚未实现自动通过) + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/crud/oa_approval.py backend/tests/test_oa_auto_pass_initiator.py +git commit -m "test(oa): add failing auto-pass-when-approver-is-initiator case" +``` + +--- + +### Task 2: 抽取 `_approve_task_core` 并实现自动通过挂钩 + +**Files:** +- Modify: `backend/app/services/oa_engine.py` +- Test: `backend/tests/test_oa_auto_pass_initiator.py` + +- [ ] **Step 1: 在 `oa_engine.py` 顶部常量附近增加固定意见文案** + +```python +_AUTO_PASS_COMMENT = "系统自动通过" +``` + +- [ ] **Step 2: 从 `complete_task` 抽出同意收口(拒绝逻辑可留在 `complete_task`)** + +在 `complete_task` 上方新增(签名与行为对齐现网同意分支;**不** `commit`、**不**校验 `commentRequired`): + +```python +def _approve_task_core( + db: Session, + *, + task: OaTask, + inst: OaInstance, + ver: OaAppVersion, + user_id: str, + comment: Optional[str], +) -> bool: + """将 pending 审批任务置为 approved 并按或签/会签决定是否推进。 + + 返回 True 表示已调用 advance_from 推进;False 表示会签未齐、未推进。 + 调用方负责 commit(若需要)。 + """ + now = datetime.now() + task.comment_text = (comment or "").strip() or None + task.status = "approved" + task.update_by = user_id + task.update_time = now + db.add(task) + db.flush() + + sign_mode = (task.sign_mode or "or").strip() or "or" + pending_same = ( + db.query(OaTask) + .filter( + OaTask.instance_id == inst.id, + OaTask.node_key == task.node_key, + OaTask.task_type == "approval", + OaTask.id != task.id, + OaTask.status == "pending", + OaTask.del_flag == 0, + ) + .count() + ) + if sign_mode == "and" and pending_same > 0: + return False + + if sign_mode != "and": + for s in ( + db.query(OaTask) + .filter( + OaTask.instance_id == inst.id, + OaTask.node_key == task.node_key, + OaTask.id != task.id, + OaTask.status == "pending", + OaTask.del_flag == 0, + ) + .all() + ): + s.status = "done" + s.update_by = user_id + s.update_time = now + db.add(s) + + root = _flow_root(_loads(ver.flow_tree_json, {})) + node = _find_node(root, task.node_key or "") + if not node: + raise OaEngineError("找不到对应流程节点") + advance_from( + db, + inst=inst, + ver=ver, + from_node=node, + user_id=user_id, + initiator_select_by_node=_load_initiator_select_by_node(inst), + ) + return True +``` + +将 `complete_task` 的 approve 分支改为:校验 `commentRequired` 后调用 `_approve_task_core(...)`,再 `db.commit()` 并 `return get_instance_detail(...)`。拒绝分支保持原样。 + +- [ ] **Step 3: 实现 `_maybe_auto_pass_initiator`** + +```python +def _maybe_auto_pass_initiator( + db: Session, + *, + inst: OaInstance, + ver: OaAppVersion, + tasks: List[OaTask], + user_id: Optional[str], +) -> None: + advanced = _advanced(ver) + if not advanced.get("autoPassWhenApproverIsInitiator"): + return + if inst.status != "running": + return + initiator = (inst.initiator_user_id or "").strip() + if not initiator: + return + self_task = None + for t in tasks: + if ( + t.task_type == "approval" + and t.status == "pending" + and (t.assignee_user_id or "") == initiator + ): + self_task = t + break + if not self_task: + return + _approve_task_core( + db, + task=self_task, + inst=inst, + ver=ver, + user_id=initiator, + comment=_AUTO_PASS_COMMENT, + ) +``` + +- [ ] **Step 4: 在 `advance_from` 审批节点建待办后挂钩** + +将审批分支从: + +```python + if not tasks: + raise OaEngineError("审批节点未生成任务") + return +``` + +改为: + +```python + if not tasks: + raise OaEngineError("审批节点未生成任务") + _maybe_auto_pass_initiator( + db, + inst=inst, + ver=ver, + tasks=tasks, + user_id=user_id, + ) + return +``` + +说明:若自动通过触发嵌套 `advance_from`,下一节点同样会挂钩,从而实现连续自动通过。会签未齐时核心返回 False,外层 `return` 后实例停在当前节点。 + +- [ ] **Step 5: 跑 Task 1 单测应通过** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_auto_pass_initiator.py::TestAutoPassInitiator::test_or_sign_sole_initiator_auto_passes_to_end -v +``` + +Expected: PASS + +- [ ] **Step 6: 跑现有同意/或签回归** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestCompleteTaskOrSign -v +``` + +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/services/oa_engine.py backend/tests/test_oa_auto_pass_initiator.py +git commit -m "feat(oa): auto-pass approval when assignee is initiator" +``` + +--- + +### Task 3: 补齐规格场景单测 + +**Files:** +- Modify: `backend/tests/test_oa_auto_pass_initiator.py` + +- [ ] **Step 1: 追加以下测试方法(沿用 `setUp` / `_seed`)** + +```python + def test_switch_off_keeps_pending(self): + from app.services.oa_engine import advance_from + + flow = { + "id": "s", + "type": "start", + "child": { + "id": "a1", + "type": "approval", + "name": "审批人", + "approverConfig": { + "assigneeType": "users", + "userIds": ["u0"], + "signMode": "or", + }, + "child": {"id": "e1", "type": "end"}, + }, + } + self._seed(flow=flow, advanced={"autoPassWhenApproverIsInitiator": False}) + inst = self.db.query(self.OaInstance).filter_by(id="i1").first() + ver = self.db.query(self.OaAppVersion).filter_by(id="v1").first() + with patch( + "app.services.oa_engine.resolve_assignees", + return_value=(["u0"], {"resolve_source": "users"}), + ), patch( + "app.services.oa_engine._next_task_order_num", return_value=1 + ): + advance_from(self.db, inst=inst, ver=ver, from_node=None, user_id="u0") + t = self.db.query(self.OaTask).filter_by(instance_id="i1").one() + self.assertEqual(t.status, "pending") + self.assertEqual(inst.status, "running") + + def test_or_sign_multi_cancels_siblings(self): + from app.services.oa_engine import advance_from + + flow = { + "id": "s", + "type": "start", + "child": { + "id": "a1", + "type": "approval", + "name": "审批人", + "approverConfig": { + "assigneeType": "users", + "userIds": ["u0", "u2"], + "signMode": "or", + }, + "child": {"id": "e1", "type": "end"}, + }, + } + self._seed(flow=flow, advanced={"autoPassWhenApproverIsInitiator": True}) + inst = self.db.query(self.OaInstance).filter_by(id="i1").first() + ver = self.db.query(self.OaAppVersion).filter_by(id="v1").first() + with patch( + "app.services.oa_engine.resolve_assignees", + return_value=(["u0", "u2"], {"resolve_source": "users"}), + ), patch( + "app.services.oa_engine._next_task_order_num", return_value=1 + ): + advance_from(self.db, inst=inst, ver=ver, from_node=None, user_id="u0") + by_user = { + t.assignee_user_id: t + for t in self.db.query(self.OaTask).filter_by(instance_id="i1").all() + } + self.assertEqual(by_user["u0"].status, "approved") + self.assertEqual(by_user["u0"].comment_text, "系统自动通过") + self.assertEqual(by_user["u2"].status, "done") + self.assertEqual(inst.status, "completed") + + def test_and_sign_only_completes_self(self): + from app.services.oa_engine import advance_from + + flow = { + "id": "s", + "type": "start", + "child": { + "id": "a1", + "type": "approval", + "name": "审批人", + "approverConfig": { + "assigneeType": "users", + "userIds": ["u0", "u2"], + "signMode": "and", + }, + "child": {"id": "e1", "type": "end"}, + }, + } + self._seed(flow=flow, advanced={"autoPassWhenApproverIsInitiator": True}) + inst = self.db.query(self.OaInstance).filter_by(id="i1").first() + ver = self.db.query(self.OaAppVersion).filter_by(id="v1").first() + with patch( + "app.services.oa_engine.resolve_assignees", + return_value=(["u0", "u2"], {"resolve_source": "users"}), + ), patch( + "app.services.oa_engine._next_task_order_num", return_value=1 + ): + advance_from(self.db, inst=inst, ver=ver, from_node=None, user_id="u0") + by_user = { + t.assignee_user_id: t + for t in self.db.query(self.OaTask).filter_by(instance_id="i1").all() + } + self.assertEqual(by_user["u0"].status, "approved") + self.assertEqual(by_user["u2"].status, "pending") + self.assertEqual(inst.status, "running") + + def test_consecutive_nodes_both_auto_pass(self): + from app.services.oa_engine import advance_from + + flow = { + "id": "s", + "type": "start", + "child": { + "id": "a1", + "type": "approval", + "name": "一审", + "approverConfig": { + "assigneeType": "users", + "userIds": ["u0"], + "signMode": "or", + }, + "child": { + "id": "a2", + "type": "approval", + "name": "二审", + "approverConfig": { + "assigneeType": "users", + "userIds": ["u0"], + "signMode": "or", + }, + "child": {"id": "e1", "type": "end"}, + }, + }, + } + self._seed(flow=flow, advanced={"autoPassWhenApproverIsInitiator": True}) + inst = self.db.query(self.OaInstance).filter_by(id="i1").first() + ver = self.db.query(self.OaAppVersion).filter_by(id="v1").first() + with patch( + "app.services.oa_engine.resolve_assignees", + return_value=(["u0"], {"resolve_source": "users"}), + ), patch( + "app.services.oa_engine._next_task_order_num", return_value=1 + ): + advance_from(self.db, inst=inst, ver=ver, from_node=None, user_id="u0") + tasks = self.db.query(self.OaTask).filter_by(instance_id="i1").all() + self.assertEqual(len(tasks), 2) + self.assertTrue(all(t.status == "approved" for t in tasks)) + self.assertTrue(all(t.comment_text == "系统自动通过" for t in tasks)) + self.assertEqual(inst.status, "completed") + + def test_comment_required_does_not_block_auto_pass(self): + from app.services.oa_engine import advance_from + + flow = { + "id": "s", + "type": "start", + "child": { + "id": "a1", + "type": "approval", + "name": "审批人", + "approverConfig": { + "assigneeType": "users", + "userIds": ["u0"], + "signMode": "or", + }, + "child": {"id": "e1", "type": "end"}, + }, + } + self._seed( + flow=flow, + advanced={ + "autoPassWhenApproverIsInitiator": True, + "commentRequired": True, + }, + ) + inst = self.db.query(self.OaInstance).filter_by(id="i1").first() + ver = self.db.query(self.OaAppVersion).filter_by(id="v1").first() + with patch( + "app.services.oa_engine.resolve_assignees", + return_value=(["u0"], {"resolve_source": "users"}), + ), patch( + "app.services.oa_engine._next_task_order_num", return_value=1 + ): + advance_from(self.db, inst=inst, ver=ver, from_node=None, user_id="u0") + t = self.db.query(self.OaTask).filter_by(instance_id="i1").one() + self.assertEqual(t.status, "approved") + self.assertEqual(inst.status, "completed") +``` + +- [ ] **Step 2: 跑全文件** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_auto_pass_initiator.py -v +``` + +Expected: 全部 PASS + +- [ ] **Step 3: 跑 advance_from 相关旧测(mock 不建真实 assignee 时不应误触发)** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestAdvanceFrom -v +``` + +Expected: PASS(mock 的 `fake_task` 无 `assignee_user_id==initiator` 匹配或开关默认关;若 FAIL,给 mock task 设 `assignee_user_id="u1"` 且 `task_type="approval"`、`status="pending"`,或 advanced 显式 `autoPassWhenApproverIsInitiator: False`) + +- [ ] **Step 4: Commit** + +```bash +git add backend/tests/test_oa_auto_pass_initiator.py backend/tests/test_oa_engine.py +git commit -m "test(oa): cover auto-pass or/and/consecutive/commentRequired cases" +``` + +--- + +### Task 4: 设计器高级设置 UI + +**Files:** +- Modify: `web-admin/src/views/oa-approval/designer/index.vue` + +- [ ] **Step 1: reactive 默认值增加字段** + +```typescript + const advanced = reactive({ + supervisorFallbackToDeptLeader: true, + allowWithdrawRunning: true, + commentRequired: false, + allowAddSign: true, + allowReturn: true, + approverDedup: false, + autoPassWhenApproverIsInitiator: false + }) +``` + +- [ ] **Step 2: `loadDraft` 同步** + +在 `advanced.approverDedup = !!a.approverDedup` 后增加: + +```typescript + advanced.autoPassWhenApproverIsInitiator = !!a.autoPassWhenApproverIsInitiator +``` + +- [ ] **Step 3: 模板增加开关(放在「审批人去重」之前或之后均可;推荐去重之前,语义更清晰)** + +```vue + + + + 开启后,解析到的审批人含发起人本人时,系统代为同意并推进;或签整节点通过,会签仅完成本人;可连续自动通过多个节点。默认关闭。 + + +``` + +若文件尚无 `.oa-designer__hint` 样式,在同文件 `