|
@@ -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
|
|
|
|
|
+ <el-form-item label="审批人为发起人时自动通过">
|
|
|
|
|
+ <el-switch v-model="advanced.autoPassWhenApproverIsInitiator" />
|
|
|
|
|
+ <div class="oa-designer__hint">
|
|
|
|
|
+ 开启后,解析到的审批人含发起人本人时,系统代为同意并推进;或签整节点通过,会签仅完成本人;可连续自动通过多个节点。默认关闭。
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+若文件尚无 `.oa-designer__hint` 样式,在同文件 `<style scoped>` 增加:
|
|
|
|
|
+
|
|
|
|
|
+```scss
|
|
|
|
|
+.oa-designer__hint {
|
|
|
|
|
+ margin-top: 6px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ line-height: 1.5;
|
|
|
|
|
+ color: var(--mint-text-muted, #64748b);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`saveDraft` 已用 `advancedSettingsJson: { ...advanced }`,无需另改。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 手动核对(无前端单测时)**
|
|
|
|
|
+
|
|
|
|
|
+1. 打开任一 OA 应用设计器 → 高级设置,见新开关默认关。
|
|
|
|
|
+2. 打开开关 → 保存草稿 → 刷新,仍为开。
|
|
|
|
|
+3. 发布后新发起:第一步审批人=自己时进度见「系统自动通过」。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add web-admin/src/views/oa-approval/designer/index.vue
|
|
|
|
|
+git commit -m "feat(oa): add auto-pass-when-approver-is-initiator advanced switch"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: 全量回归与收尾
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 跑 OA 相关后端测**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_auto_pass_initiator.py tests/test_oa_engine.py tests/test_oa_assignee.py tests/test_oa_initiator_select_by_node.py -v
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: PASS
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 若改了 backend/web-admin 代码结构,按仓库约定增量更新图谱**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+./scripts/update-graph.sh backend
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+(仅前端 Task 4 时也可 `./scripts/update-graph.sh frontend`;两边都改则 `all`。)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 最终确认无未提交实现**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git status -sb
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Spec coverage checklist
|
|
|
|
|
+
|
|
|
|
|
+| Spec 要求 | Task |
|
|
|
|
|
+|-----------|------|
|
|
|
|
|
+| `autoPassWhenApproverIsInitiator` 默认 false | Task 1 |
|
|
|
|
|
+| 抽同意内核 + 建待办后挂钩 | Task 2 |
|
|
|
|
|
+| 或签整过 / 会签只过本人 / 连续 / 跳过意见必填 | Task 2–3 |
|
|
|
|
|
+| 意见「系统自动通过」 | Task 2–3 |
|
|
|
|
|
+| 设计器开关 + tip | Task 4 |
|
|
|
|
|
+| 不加独立 event / 预览旁注 / 不去重引擎 | 刻意不做 |
|
|
|
|
|
+| 加签不触发整节点再自动过 | 挂钩仅在 `advance_from` 新建审批任务后;加签不走该路径 |
|
|
|
|
|
+
|
|
|
|
|
+## Placeholder / consistency review
|
|
|
|
|
+
|
|
|
|
|
+- 键名统一:`autoPassWhenApproverIsInitiator`
|
|
|
|
|
+- 意见文案统一:`系统自动通过` / `_AUTO_PASS_COMMENT`
|
|
|
|
|
+- `_approve_task_core` 不 commit;`complete_task` 负责 commit
|
|
|
|
|
+- 无 TBD/TODO 占位
|