390 lines
14 KiB
Python
390 lines
14 KiB
Python
"""工作流引擎 —— 读取节点与连线执行审批流转"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.crud import workflow as wf_crud
|
|
from app.models.models import (
|
|
SysUser,
|
|
SysUserRole,
|
|
WfNode,
|
|
WfProcessInstance,
|
|
WfTask,
|
|
WfTransition,
|
|
)
|
|
from app.services.workflow_callback import trigger_callback
|
|
|
|
logger = logging.getLogger("sqxp.workflow_engine")
|
|
|
|
_FORBIDDEN_SQL_KEYWORDS = re.compile(
|
|
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE)\b", re.IGNORECASE
|
|
)
|
|
|
|
|
|
class WorkflowEngine:
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
# ------------------------------------------------------------------
|
|
# start_process
|
|
# ------------------------------------------------------------------
|
|
|
|
def start_process(
|
|
self,
|
|
*,
|
|
process_def_id: str,
|
|
business_type: Optional[str] = None,
|
|
business_id: Optional[str] = None,
|
|
starter_user_id: Optional[str] = None,
|
|
variables: Optional[Dict[str, Any]] = None,
|
|
callback_url: Optional[str] = None,
|
|
tenant_no: Optional[str] = None,
|
|
user_id: Optional[str] = None,
|
|
) -> WfProcessInstance:
|
|
version = wf_crud.get_latest_version(self.db, process_def_id)
|
|
if not version:
|
|
raise ValueError("流程定义没有可用版本,请先发布")
|
|
instance = wf_crud.create_instance(
|
|
self.db,
|
|
process_version_id=version.id,
|
|
business_type=business_type,
|
|
business_id=business_id,
|
|
starter_user_id=starter_user_id,
|
|
variables=variables,
|
|
callback_url=callback_url,
|
|
tenant_no=tenant_no,
|
|
user_id=user_id,
|
|
)
|
|
nodes = wf_crud.get_nodes_by_version(self.db, version.id)
|
|
start_node = next((n for n in nodes if n.node_type == "start"), None)
|
|
if not start_node:
|
|
raise ValueError("流程缺少开始节点")
|
|
self._advance(instance.id, start_node.node_key, tenant_no or "shanxia2.0")
|
|
self.db.refresh(instance)
|
|
return instance
|
|
|
|
# ------------------------------------------------------------------
|
|
# approve_task
|
|
# ------------------------------------------------------------------
|
|
|
|
def approve_task(self, task_id: str, comment: str, tenant_no: str) -> WfProcessInstance:
|
|
task = wf_crud.get_task(self.db, task_id)
|
|
if not task:
|
|
raise ValueError("任务不存在")
|
|
if task.status != "pending":
|
|
raise ValueError(f"任务状态为 {task.status},无法审批")
|
|
|
|
task.status = "approved"
|
|
task.comment_text = comment
|
|
task.update_time = datetime.now()
|
|
self.db.add(task)
|
|
self.db.commit()
|
|
|
|
instance = wf_crud.get_instance(self.db, task.instance_id)
|
|
if not instance:
|
|
raise ValueError("流程实例不存在")
|
|
|
|
version_id = instance.process_version_id
|
|
nodes = wf_crud.get_nodes_by_version(self.db, version_id)
|
|
current_node = next(
|
|
(n for n in nodes if n.node_key == task.node_key), None
|
|
)
|
|
config = self._parse_node_config(current_node)
|
|
pass_type = config.get("passType", "any")
|
|
|
|
if pass_type == "any":
|
|
others = wf_crud.get_pending_tasks_by_node(
|
|
self.db, task.instance_id, task.node_key
|
|
)
|
|
for t in others:
|
|
t.status = "withdrawn"
|
|
t.update_time = datetime.now()
|
|
self.db.add(t)
|
|
self.db.commit()
|
|
self._advance(instance.id, task.node_key, tenant_no)
|
|
else:
|
|
remaining = wf_crud.get_pending_tasks_by_node(
|
|
self.db, task.instance_id, task.node_key
|
|
)
|
|
if not remaining:
|
|
self._advance(instance.id, task.node_key, tenant_no)
|
|
|
|
self.db.refresh(instance)
|
|
return instance
|
|
|
|
# ------------------------------------------------------------------
|
|
# reject_task
|
|
# ------------------------------------------------------------------
|
|
|
|
def reject_task(self, task_id: str, comment: str, tenant_no: str) -> WfProcessInstance:
|
|
task = wf_crud.get_task(self.db, task_id)
|
|
if not task:
|
|
raise ValueError("任务不存在")
|
|
if task.status != "pending":
|
|
raise ValueError(f"任务状态为 {task.status},无法驳回")
|
|
|
|
task.status = "rejected"
|
|
task.comment_text = comment
|
|
task.update_time = datetime.now()
|
|
self.db.add(task)
|
|
self.db.commit()
|
|
|
|
others = wf_crud.get_pending_tasks_by_node(
|
|
self.db, task.instance_id, task.node_key
|
|
)
|
|
for t in others:
|
|
t.status = "withdrawn"
|
|
t.update_time = datetime.now()
|
|
self.db.add(t)
|
|
self.db.commit()
|
|
|
|
instance = wf_crud.get_instance(self.db, task.instance_id)
|
|
if instance:
|
|
wf_crud.update_instance_status(self.db, instance.id, "rejected")
|
|
self.db.refresh(instance)
|
|
trigger_callback(self.db, instance, "rejected")
|
|
|
|
return instance
|
|
|
|
# ------------------------------------------------------------------
|
|
# _advance (internal)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _advance(
|
|
self, instance_id: str, from_node_key: str, tenant_no: str
|
|
) -> None:
|
|
instance = wf_crud.get_instance(self.db, instance_id)
|
|
if not instance:
|
|
return
|
|
if instance.status != "running":
|
|
return
|
|
|
|
version_id = instance.process_version_id
|
|
nodes = wf_crud.get_nodes_by_version(self.db, version_id)
|
|
transitions = wf_crud.get_transitions_by_version(self.db, version_id)
|
|
variables = self._parse_variables(instance)
|
|
|
|
current_node = next(
|
|
(n for n in nodes if n.node_key == from_node_key), None
|
|
)
|
|
if not current_node:
|
|
return
|
|
|
|
out_edges = [
|
|
t for t in transitions if t.from_node_key == from_node_key
|
|
]
|
|
if not out_edges:
|
|
return
|
|
|
|
if current_node.node_type in ("start", "approval"):
|
|
target_key = out_edges[0].to_node_key
|
|
elif current_node.node_type == "condition":
|
|
target_key = self._evaluate_condition(out_edges, variables)
|
|
else:
|
|
target_key = out_edges[0].to_node_key
|
|
|
|
next_node = next((n for n in nodes if n.node_key == target_key), None)
|
|
if next_node:
|
|
self._execute_node(instance_id, next_node, variables, tenant_no)
|
|
|
|
# ------------------------------------------------------------------
|
|
# _execute_node
|
|
# ------------------------------------------------------------------
|
|
|
|
def _execute_node(
|
|
self,
|
|
instance_id: str,
|
|
node: WfNode,
|
|
variables: Dict[str, Any],
|
|
tenant_no: str,
|
|
) -> None:
|
|
if node.node_type == "end":
|
|
wf_crud.update_instance_status(self.db, instance_id, "completed")
|
|
instance = wf_crud.get_instance(self.db, instance_id)
|
|
if instance:
|
|
trigger_callback(self.db, instance, "completed")
|
|
return
|
|
|
|
if node.node_type == "approval":
|
|
config = self._parse_node_config(node)
|
|
user_ids = self._resolve_approvers(config, variables, tenant_no)
|
|
for uid in user_ids:
|
|
wf_crud.create_task(
|
|
self.db,
|
|
instance_id=instance_id,
|
|
node_key=node.node_key,
|
|
assignee_user_id=uid,
|
|
tenant_no=tenant_no,
|
|
)
|
|
return
|
|
|
|
if node.node_type == "condition":
|
|
self._advance(instance_id, node.node_key, tenant_no)
|
|
return
|
|
|
|
self._advance(instance_id, node.node_key, tenant_no)
|
|
|
|
# ------------------------------------------------------------------
|
|
# _resolve_approvers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _resolve_approvers(
|
|
self,
|
|
config: Dict[str, Any],
|
|
variables: Dict[str, Any],
|
|
tenant_no: str,
|
|
) -> List[str]:
|
|
strategy = config.get("strategy", "users")
|
|
|
|
if strategy == "users":
|
|
return config.get("userIds", [])
|
|
|
|
if strategy == "roles":
|
|
role_ids = config.get("roleIds", [])
|
|
if not role_ids:
|
|
return []
|
|
rows = (
|
|
self.db.query(SysUser.id)
|
|
.join(SysUserRole, SysUserRole.user_id == SysUser.id)
|
|
.filter(
|
|
SysUserRole.role_id.in_(role_ids),
|
|
SysUser.del_flag == 0,
|
|
SysUser.status == "1",
|
|
)
|
|
.all()
|
|
)
|
|
return [r.id for r in rows]
|
|
|
|
if strategy == "sql":
|
|
sql_template = config.get("sql", "")
|
|
if not sql_template or not sql_template.strip().upper().startswith("SELECT"):
|
|
raise ValueError("SQL 审批人策略必须以 SELECT 开头")
|
|
if _FORBIDDEN_SQL_KEYWORDS.search(sql_template):
|
|
raise ValueError("SQL 审批人策略包含禁止的关键字")
|
|
try:
|
|
result = self.db.execute(text(sql_template), variables or {})
|
|
return [str(row[0]) for row in result.fetchall()]
|
|
except Exception as exc:
|
|
logger.exception("SQL 审批人解析失败: %s", exc)
|
|
raise ValueError(f"SQL 审批人解析失败: {exc}") from exc
|
|
|
|
return []
|
|
|
|
# ------------------------------------------------------------------
|
|
# _evaluate_condition
|
|
# ------------------------------------------------------------------
|
|
|
|
def _evaluate_condition(
|
|
self, out_edges: List[WfTransition], variables: Dict[str, Any]
|
|
) -> str:
|
|
unconditional_fallback = out_edges[-1].to_node_key if out_edges else ""
|
|
|
|
for edge in out_edges:
|
|
cond_str = edge.condition_expr
|
|
if not cond_str:
|
|
continue
|
|
try:
|
|
rules = json.loads(cond_str)
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
if self._check_rules(rules, variables):
|
|
return edge.to_node_key
|
|
|
|
return unconditional_fallback
|
|
|
|
# ------------------------------------------------------------------
|
|
# _check_rules (recursive)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _check_rules(self, rules: Any, variables: Dict[str, Any]) -> bool:
|
|
if isinstance(rules, list):
|
|
return any(self._check_rules(r, variables) for r in rules)
|
|
|
|
if isinstance(rules, dict):
|
|
if "and" in rules:
|
|
return all(self._check_rules(r, variables) for r in rules["and"])
|
|
if "or" in rules:
|
|
return any(self._check_rules(r, variables) for r in rules["or"])
|
|
if "rules" in rules:
|
|
return self._check_rules(rules["rules"], variables)
|
|
if "field" in rules:
|
|
actual = self._get_variable(rules["field"], variables)
|
|
op = rules.get("op", "==")
|
|
expected = rules.get("value")
|
|
return self._compare(actual, op, expected)
|
|
|
|
return False
|
|
|
|
# ------------------------------------------------------------------
|
|
# _compare
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _compare(actual: Any, op: str, expected: Any) -> bool:
|
|
try:
|
|
if op == "==":
|
|
return str(actual) == str(expected)
|
|
if op == "!=":
|
|
return str(actual) != str(expected)
|
|
if op == ">":
|
|
return float(actual) > float(expected)
|
|
if op == "<":
|
|
return float(actual) < float(expected)
|
|
if op == ">=":
|
|
return float(actual) >= float(expected)
|
|
if op == "<=":
|
|
return float(actual) <= float(expected)
|
|
if op == "in":
|
|
return str(actual) in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
|
|
if op == "not_in":
|
|
return str(actual) not in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
|
|
if op == "contains":
|
|
return str(expected) in str(actual)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
return False
|
|
|
|
# ------------------------------------------------------------------
|
|
# _get_variable (dot-notation)
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _get_variable(field: str, variables: Dict[str, Any]) -> Any:
|
|
parts = field.split(".")
|
|
current: Any = variables
|
|
for part in parts:
|
|
if isinstance(current, dict):
|
|
current = current.get(part)
|
|
else:
|
|
return None
|
|
return current
|
|
|
|
# ------------------------------------------------------------------
|
|
# helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _parse_node_config(node: Optional[WfNode]) -> Dict[str, Any]:
|
|
if not node or not node.config_json:
|
|
return {}
|
|
try:
|
|
return json.loads(node.config_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _parse_variables(instance: WfProcessInstance) -> Dict[str, Any]:
|
|
if not instance.variables_json:
|
|
return {}
|
|
try:
|
|
return json.loads(instance.variables_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return {}
|