"""OA 审批运行时引擎(钉钉式竖向树)""" from __future__ import annotations import json from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from sqlalchemy.orm import Session from app.core.utils import generate_uuid from app.crud import oa_approval as oa_crud from app.models.models import ( OaApp, OaAppVersion, OaInstance, OaInstanceEvent, OaRelation, OaRole, OaTask, SysUser, ) from app.services.oa_assignee import AssigneeResolveError, resolve_assignees from app.services.oa_formula import FormulaError, apply_formulas_and_linkages, eval_formula class OaEngineError(ValueError): """引擎业务错误""" def _loads(text: Optional[str], default: Any = None) -> Any: if text is None or text == "": return default if not isinstance(text, str): return text try: return json.loads(text) except (TypeError, ValueError): return default def _dumps(data: Any) -> str: return json.dumps(data if data is not None else {}, ensure_ascii=False) def _node_type(node: Dict[str, Any]) -> str: return (node.get("type") or "").strip() def _flow_root(flow_tree: Any) -> Dict[str, Any]: if not isinstance(flow_tree, dict): raise OaEngineError("流程树无效") if isinstance(flow_tree.get("root"), dict): return flow_tree["root"] return flow_tree def _find_node(root: Dict[str, Any], node_key: str) -> Optional[Dict[str, Any]]: if not root or not node_key: return None if (root.get("id") or "") == node_key: return root child = root.get("child") if isinstance(child, dict): found = _find_node(child, node_key) if found: return found for g in root.get("conditionGroups") or []: if isinstance(g, dict) and isinstance(g.get("child"), dict): found = _find_node(g["child"], node_key) if found: return found return None def _flow_node_key_order(root: Dict[str, Any]) -> Dict[str, int]: """按流程树先序遍历为 node.id 分配稳定序号(用于详情时间轴排序)。""" order: Dict[str, int] = {} counter = 0 def walk(node: Any) -> None: nonlocal counter if not isinstance(node, dict): return nid = (node.get("id") or "").strip() if nid and nid not in order: order[nid] = counter counter += 1 if _node_type(node) == "condition": for g in node.get("conditionGroups") or []: if isinstance(g, dict) and isinstance(g.get("child"), dict): walk(g["child"]) child = node.get("child") if isinstance(child, dict): walk(child) walk(root) return order def _next_task_order_num(db: Session, instance_id: str) -> int: """同一实例内任务展示序号取 max+1(无任务从 1 起)。""" rows = ( db.query(OaTask.order_num) .filter(OaTask.instance_id == instance_id, OaTask.del_flag == 0) .all() ) vals = [int(r[0]) for r in rows if r[0] is not None] return (max(vals) + 1) if vals else 1 def _sort_tasks_for_detail( tasks: List[OaTask], *, flow_root: Optional[Dict[str, Any]] ) -> List[OaTask]: """详情时间轴:按流程树节点顺序,再 order_num / create_time(兼容旧数据同秒创建)。""" node_order = _flow_node_key_order(flow_root) if isinstance(flow_root, dict) else {} far = 10**9 def key(t: OaTask) -> Tuple[int, int, int, datetime, str]: nk = (t.node_key or "").strip() return ( int(getattr(t, "round_no", None) or 1), node_order.get(nk, far), int(t.order_num) if t.order_num is not None else 0, t.create_time or datetime.min, t.id or "", ) return sorted(tasks, key=key) def _fmt_dt(dt: Any) -> str: if not dt: return "" if hasattr(dt, "strftime"): return dt.strftime("%Y-%m-%d %H:%M:%S") return str(dt) def _terminal_item(status: str, title: str, inst: Any) -> Dict[str, Any]: return { "kind": "terminal", "roundNo": getattr(inst, "current_round", None) or 1, "title": title, "status": status, "assigneeUserId": "", "assigneeUserName": "", "timestamp": _fmt_dt(getattr(inst, "update_time", None)), "commentText": "", "nodeKey": "", "taskType": "", } def _task_round(t: Any) -> int: v = getattr(t, "round_no", None) try: if v is None: return 1 return int(v) except (TypeError, ValueError): return 1 def _append_pending_nodes( items: List[Dict[str, Any]], *, flow_root: Dict[str, Any], reached_keys: set, round_no: int, ) -> None: order = _flow_node_key_order(flow_root) for nid, _idx in sorted(order.items(), key=lambda x: x[1]): node = _find_node(flow_root, nid) if not node: continue nt = _node_type(node) if nt not in ("approval", "cc") or nid in reached_keys: continue items.append( { "kind": "pending_node", "roundNo": round_no, "title": node.get("name") or ("抄送人" if nt == "cc" else "审批人"), "status": "not_reached", "assigneeUserId": "", "assigneeUserName": "", "timestamp": "", "commentText": "", "nodeKey": nid, "taskType": nt, } ) def build_progress_items( *, inst: Any, flow_root: Optional[Dict[str, Any]], tasks: List[Any], name_map: Dict[str, str], events: Optional[List[Any]] = None, ) -> List[Dict[str, Any]]: """投影审批进度:发起人 → 树序 tasks → 未到达;终态收尾。 钉钉对齐:打回结果落在审批任务节点上,不另插 kind=return;不做同单多轮分段。 """ events = list(events or []) sorted_tasks = _sort_tasks_for_detail(list(tasks), flow_root=flow_root) # 展示压成单轴(roundNo 统一为 1);历史多轮任务仍按树序合并展示 rn = 1 submit_ts = _fmt_dt(getattr(inst, "create_time", None)) for e in events: if (getattr(e, "event_type", None) or "") == "submit": submit_ts = _fmt_dt(getattr(e, "create_time", None)) or submit_ts break items: List[Dict[str, Any]] = [ { "kind": "submit", "roundNo": rn, "title": "发起人", "status": "submitted", "assigneeUserId": inst.initiator_user_id, "assigneeUserName": name_map.get(inst.initiator_user_id or "", ""), "timestamp": submit_ts, "commentText": "", "nodeKey": "", "taskType": "", } ] reached_keys = { (t.node_key or "").strip() for t in sorted_tasks if (t.node_key or "").strip() } for t in sorted_tasks: items.append( { "kind": "task", "roundNo": rn, "title": t.node_name or t.node_key or "-", "status": t.status, "assigneeUserId": t.assignee_user_id, "assigneeUserName": name_map.get(t.assignee_user_id or "", ""), "timestamp": _fmt_dt(t.update_time or t.create_time), "commentText": t.comment_text or "", "nodeKey": t.node_key or "", "taskType": t.task_type or "", "resolveSource": getattr(t, "resolve_source", None) or "", "taskId": t.id, } ) if inst.status == "running" and isinstance(flow_root, dict): _append_pending_nodes( items, flow_root=flow_root, reached_keys=reached_keys, round_no=rn ) if inst.status == "completed": items.append(_terminal_item("done", "流程结束", inst)) elif inst.status == "rejected": items.append(_terminal_item("rejected", "流程结束(已拒绝)", inst)) elif inst.status == "cancelled": items.append(_terminal_item("cancelled", "已撤销", inst)) elif inst.status == "returned": items.append(_terminal_item("returned", "已打回", inst)) return items def _add_instance_event( db: Session, *, inst: OaInstance, event_type: str, actor_user_id: Optional[str], round_no: int, comment: Optional[str] = None, task_id: Optional[str] = None, node_key: Optional[str] = None, ) -> OaInstanceEvent: now = datetime.now() ev = OaInstanceEvent( id=generate_uuid(), instance_id=inst.id, round_no=round_no, event_type=event_type, actor_user_id=actor_user_id, task_id=task_id, node_key=node_key, comment_text=(comment or "").strip() or None, tenant_no=inst.tenant_no, order_num=0, remark=None, create_by=actor_user_id, create_dept_id=inst.initiator_dept_id or "", create_time=now, update_by=actor_user_id, update_time=now, del_flag=0, ) db.add(ev) return ev def _advanced(ver: OaAppVersion) -> Dict[str, Any]: data = _loads(ver.advanced_settings_json, {}) or {} if not isinstance(data, dict): return dict(oa_crud.DEFAULT_ADVANCED_SETTINGS) merged = dict(oa_crud.DEFAULT_ADVANCED_SETTINGS) merged.update(data) return merged def _check_initiator_scope(app: OaApp, user_id: str) -> None: scope = (app.initiator_scope or "all").strip() if scope == "all": return ids = _loads(app.initiator_user_ids, []) or [] if user_id not in ids: raise OaEngineError("您不在该审批的发起范围内") def _instance_to_dict(inst: OaInstance, *, app_name: str = "") -> Dict[str, Any]: return { "id": inst.id, "appId": inst.app_id, "appVersionId": inst.app_version_id, "appName": app_name, "title": inst.title or "", "initiatorUserId": inst.initiator_user_id, "initiatorDeptId": inst.initiator_dept_id or "", "formDataJson": _loads(inst.form_data_json, {}), "status": inst.status, "sourceInstanceId": getattr(inst, "source_instance_id", None) or "", "currentRound": int(getattr(inst, "current_round", None) or 1), "createTime": inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "", "updateTime": inst.update_time.strftime("%Y-%m-%d %H:%M:%S") if inst.update_time else "", } def _task_to_dict(t: OaTask, *, assignee_user_name: str = "") -> Dict[str, Any]: return { "id": t.id, "instanceId": t.instance_id, "nodeKey": t.node_key or "", "nodeName": t.node_name or "", "taskType": t.task_type, "assigneeUserId": t.assignee_user_id, "assigneeUserName": assignee_user_name or t.assignee_user_id or "", "status": t.status, "commentText": t.comment_text or "", "resolveSource": t.resolve_source or "", "signMode": t.sign_mode or "", "createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "", "updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "", } _INSTANCE_STATUS_NODE_LABEL = { "completed": "已完成", "rejected": "已拒绝", "returned": "已打回", "cancelled": "已撤销", } def _instance_progress_summary( inst: OaInstance, *, app_name: str, initiator_user_name: str, pending_approval_tasks: List[OaTask], name_map: Dict[str, str], ) -> Dict[str, Any]: status = (inst.status or "").strip() apply_time = ( inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "" ) if status == "running": node_names: List[str] = [] seen_nodes = set() approver_names: List[str] = [] for t in pending_approval_tasks: nn = (t.node_name or t.node_key or "").strip() if nn and nn not in seen_nodes: seen_nodes.add(nn) node_names.append(nn) uid = (t.assignee_user_id or "").strip() if uid: approver_names.append(name_map.get(uid, uid)) current_node = "、".join(node_names) if node_names else "-" current_approvers = "、".join(approver_names) if approver_names else "-" else: current_node = _INSTANCE_STATUS_NODE_LABEL.get(status, status or "-") current_approvers = "-" return { "appName": app_name or (inst.title or ""), "initiatorUserId": inst.initiator_user_id or "", "initiatorUserName": initiator_user_name or name_map.get(inst.initiator_user_id or "", inst.initiator_user_id or ""), "applyTime": apply_time, "instanceStatus": status, "currentNodeName": current_node, "currentApproverNames": current_approvers, } def _enrich_list_rows( db: Session, *, tenant_no: str, rows: List[Dict[str, Any]], instances: List[OaInstance], ) -> List[Dict[str, Any]]: if not rows: return [] inst_by_id = {i.id: i for i in instances} app_ids = list({i.app_id for i in instances if i.app_id}) apps = ( db.query(OaApp) .filter( OaApp.id.in_(app_ids), OaApp.tenant_no == tenant_no, OaApp.del_flag == 0, ) .all() if app_ids else [] ) app_name_by_id = {a.id: (a.app_name or "") for a in apps} inst_ids = list(inst_by_id.keys()) pending_tasks = ( db.query(OaTask) .filter( OaTask.tenant_no == tenant_no, OaTask.del_flag == 0, OaTask.instance_id.in_(inst_ids), OaTask.task_type == "approval", OaTask.status == "pending", ) .order_by(OaTask.create_time.asc()) .all() if inst_ids else [] ) pending_by_inst: Dict[str, List[OaTask]] = {} for t in pending_tasks: pending_by_inst.setdefault(t.instance_id, []).append(t) user_ids: List[str] = [] for i in instances: if i.initiator_user_id: user_ids.append(i.initiator_user_id) for t in pending_tasks: if t.assignee_user_id: user_ids.append(t.assignee_user_id) name_map = _user_name_map(db, user_ids) out: List[Dict[str, Any]] = [] for row in rows: iid = row.get("instanceId") or row.get("id") inst = inst_by_id.get(iid) if not inst: out.append(row) continue summary = _instance_progress_summary( inst, app_name=app_name_by_id.get(inst.app_id, ""), initiator_user_name=name_map.get(inst.initiator_user_id or "", ""), pending_approval_tasks=pending_by_inst.get(inst.id, []), name_map=name_map, ) merged = dict(row) merged.update(summary) out.append(merged) return out def _user_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]: ids = [uid for uid in {str(u or "").strip() for u in user_ids} if uid] if not ids: return {} rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all() return {r.id: (r.nick_name or r.id) for r in rows} def list_templates(db: Session, *, tenant_no: str, user_id: str) -> List[Dict[str, Any]]: apps = ( db.query(OaApp) .filter(OaApp.tenant_no == tenant_no, OaApp.del_flag == 0, OaApp.status == "1") .order_by(OaApp.order_num.asc(), OaApp.create_time.desc()) .all() ) out: List[Dict[str, Any]] = [] for app in apps: pub = oa_crud._find_latest_published(db, app.id, tenant_no) if not pub: continue try: _check_initiator_scope(app, user_id) except OaEngineError: continue out.append( { "id": app.id, "appName": app.app_name, "groupId": app.group_id or "", "iconUrl": app.icon_url or "", "description": app.description or "", "versionId": pub.id, "versionNo": pub.version_no, } ) return out def get_template_detail( db: Session, *, tenant_no: str, app_id: str, user_id: str ) -> Dict[str, Any]: app = oa_crud.get_app(db, app_id, tenant_no) if not app or app.status != "1": raise OaEngineError("应用不存在或已停用") _check_initiator_scope(app, user_id) pub = oa_crud._find_latest_published(db, app_id, tenant_no) if not pub: raise OaEngineError("应用尚未发布") return { "id": app.id, "appName": app.app_name, "groupId": app.group_id or "", "iconUrl": app.icon_url or "", "description": app.description or "", "versionId": pub.id, "versionNo": pub.version_no, "formSchemaJson": _loads(pub.form_schema_json, {}), "flowTreeJson": _loads(pub.flow_tree_json, {}), "advancedSettingsJson": _advanced(pub), } _ASSIGNEE_TYPE_LABEL = { "users": "指定成员", "oa_roles": "工作流角色", "dept_leader": "发起人部门负责人", "direct_supervisor": "发起人的主管", "initiator_select": "发起人自选", } def _role_name_map(db: Session, tenant_no: str, role_ids: List[str], codes: List[str]) -> List[str]: names: List[str] = [] ids = [str(x).strip() for x in (role_ids or []) if str(x).strip()] code_list = [str(c).strip().upper() for c in (codes or []) if str(c).strip()] if ids: rows = ( db.query(OaRole) .filter(OaRole.tenant_no == tenant_no, OaRole.id.in_(ids), OaRole.del_flag == 0) .all() ) names.extend([(r.role_name or r.role_code or r.id) for r in rows]) if code_list: rows = ( db.query(OaRole) .filter( OaRole.tenant_no == tenant_no, OaRole.role_code.in_(code_list), OaRole.del_flag == 0, ) .all() ) for r in rows: label = r.role_name or r.role_code or r.id if label not in names: names.append(label) return names def _assignee_meta(db: Session, tenant_no: str, cfg: Dict[str, Any]) -> str: assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip() base = _ASSIGNEE_TYPE_LABEL.get(assignee_type, assignee_type or "未配置审批人") if assignee_type == "oa_roles": names = _role_name_map( db, tenant_no, cfg.get("oaRoleIds") or cfg.get("oa_role_ids") or [], cfg.get("oaRoleCodes") or cfg.get("oa_role_codes") or [], ) if names: return f"{base} · {'/'.join(names)}" return base def _preview_people(db: Session, user_ids: List[str]) -> List[Dict[str, str]]: name_map = _user_name_map(db, user_ids) out: List[Dict[str, str]] = [] for uid in user_ids: out.append({"id": uid, "nickName": name_map.get(uid) or uid}) return out def _walk_preview_nodes( db: Session, *, node: Optional[Dict[str, Any]], tenant_no: str, initiator_user_id: str, initiator_dept_id: Optional[str], advanced: Dict[str, Any], initiator_select_user_ids: Optional[List[str]], steps: List[Dict[str, Any]], ) -> None: cur = node while cur and isinstance(cur, dict): ntype = _node_type(cur) if ntype == "start": people = _preview_people(db, [initiator_user_id] if initiator_user_id else []) steps.append( { "id": cur.get("id") or "", "type": "start", "name": cur.get("name") or "发起人", "signMode": "", "assigneeType": "", "meta": "", "people": people, "resolveError": "", } ) cur = cur.get("child") continue if ntype == "end": steps.append( { "id": cur.get("id") or "", "type": "end", "name": cur.get("name") or "结束", "signMode": "", "assigneeType": "", "meta": "", "people": [], "resolveError": "", } ) break if ntype == "condition": groups = cur.get("conditionGroups") or [] for g in groups: if not isinstance(g, dict): continue steps.append( { "id": f"{cur.get('id') or 'cond'}_{(g.get('name') or '分支')}", "type": "branch", "name": g.get("name") or "条件分支", "signMode": "", "assigneeType": "", "meta": "条件分支(实际路径以提交时表单为准)", "people": [], "resolveError": "", } ) child = g.get("child") if isinstance(child, dict): _walk_preview_nodes( db, node=child, tenant_no=tenant_no, initiator_user_id=initiator_user_id, initiator_dept_id=initiator_dept_id, advanced=advanced, initiator_select_user_ids=initiator_select_user_ids, steps=steps, ) cur = cur.get("child") continue if ntype in ("approval", "cc"): cfg = cur.get("approverConfig") or {} assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip() sign_mode = (cfg.get("signMode") or cfg.get("sign_mode") or "").strip() if ntype == "cc": sign_mode = "cc" elif not sign_mode and assignee_type != "initiator_select": sign_mode = "or" meta = _assignee_meta(db, tenant_no, cfg) people: List[Dict[str, str]] = [] resolve_error = "" if assignee_type == "initiator_select": ids = initiator_select_user_ids or [] people = _preview_people(db, [str(x) for x in ids if str(x).strip()]) if not people: meta = meta or "发起人自选" else: try: assignees, _meta = resolve_assignees( db, tenant_no=tenant_no, assignee_config=cfg, initiator_user_id=initiator_user_id, initiator_dept_id=initiator_dept_id, advanced=advanced, initiator_select_user_ids=initiator_select_user_ids, ) people = _preview_people(db, assignees) except AssigneeResolveError as e: resolve_error = str(e) steps.append( { "id": cur.get("id") or "", "type": ntype, "name": cur.get("name") or ("抄送人" if ntype == "cc" else "审批人"), "signMode": sign_mode, "assigneeType": assignee_type, "meta": meta, "people": people, "resolveError": resolve_error, } ) cur = cur.get("child") continue cur = cur.get("child") def preview_start_flow( db: Session, *, tenant_no: str, app_id: str, initiator_user_id: str, initiator_dept_id: Optional[str] = None, initiator_select_user_ids: Optional[List[str]] = None, ) -> Dict[str, Any]: """发起前预览审批链(解析具体人员;条件分支展开展示)。""" app = oa_crud.get_app(db, app_id, tenant_no) if not app or app.status != "1": raise OaEngineError("应用不存在或已停用") _check_initiator_scope(app, initiator_user_id) pub = oa_crud._find_latest_published(db, app_id, tenant_no) if not pub: raise OaEngineError("应用尚未发布") initiator = ( db.query(SysUser) .filter( SysUser.id == initiator_user_id, SysUser.tenant_no == tenant_no, SysUser.del_flag == 0, ) .first() ) dept_id = (initiator_dept_id or "").strip() or ( (initiator.dept_id or "") if initiator else "" ) flow = _loads(pub.flow_tree_json, {}) or {} root = flow.get("root") if isinstance(flow, dict) else None if not isinstance(root, dict): root = flow if isinstance(flow, dict) else {} steps: List[Dict[str, Any]] = [] _walk_preview_nodes( db, node=root, tenant_no=tenant_no, initiator_user_id=initiator_user_id, initiator_dept_id=dept_id or None, advanced=_advanced(pub), initiator_select_user_ids=initiator_select_user_ids, steps=steps, ) return {"steps": steps} def _create_tasks_for_node( db: Session, *, inst: OaInstance, node: Dict[str, Any], ver: OaAppVersion, user_id: Optional[str], initiator_select_user_ids: Optional[List[str]] = None, order_num_start: Optional[int] = None, ) -> List[OaTask]: ntype = _node_type(node) now = datetime.now() created: List[OaTask] = [] if ntype == "cc": task_type = "cc" elif ntype == "approval": task_type = "approval" else: return created cfg = node.get("approverConfig") or {} advanced = _advanced(ver) try: assignees, meta = resolve_assignees( db, tenant_no=inst.tenant_no, assignee_config=cfg, initiator_user_id=inst.initiator_user_id, initiator_dept_id=inst.initiator_dept_id, advanced=advanced, initiator_select_user_ids=initiator_select_user_ids, ) except AssigneeResolveError as e: raise OaEngineError(str(e)) from e sign_mode = (cfg.get("signMode") or cfg.get("sign_mode") or "or").strip() or "or" resolve_source = meta.get("resolve_source") status = "done" if task_type == "cc" else "pending" seq = order_num_start if order_num_start is not None else _next_task_order_num(db, inst.id) for uid in assignees: t = OaTask( id=generate_uuid(), instance_id=inst.id, node_key=node.get("id") or "", node_name=node.get("name") or "", task_type=task_type, assignee_user_id=uid, status=status, comment_text=None, resolve_source=resolve_source, sign_mode=sign_mode if task_type == "approval" else None, round_no=int(getattr(inst, "current_round", None) or 1), tenant_no=inst.tenant_no, order_num=seq, remark=None, create_by=user_id, create_dept_id=inst.initiator_dept_id or "", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) seq += 1 db.add(t) created.append(t) return created def _match_condition_group(groups: List[Any], form_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: default_g = None for g in groups: if not isinstance(g, dict): continue rules = g.get("rules") when = g.get("when") # 空规则 = 默认分支 if (not rules and not when) or g.get("isDefault"): default_g = g continue expr = when if not expr and isinstance(rules, str): expr = rules if not expr and isinstance(rules, list) and rules: # 简化:每条 {field, op, value} 用 AND parts = [] for r in rules: if not isinstance(r, dict): continue field = r.get("field") op = r.get("op") or "==" val = r.get("value") if field is None: continue if isinstance(val, str): parts.append(f"{{{field}}} {op} '{val}'") else: parts.append(f"{{{field}}} {op} {val}") expr = " and ".join(parts) if parts else None if not expr: default_g = g continue try: if eval_formula(str(expr), form_data): return g except FormulaError: continue return default_g def advance_from( db: Session, *, inst: OaInstance, ver: OaAppVersion, from_node: Optional[Dict[str, Any]], user_id: Optional[str], initiator_select_user_ids: Optional[List[str]] = None, ) -> None: """从某节点的下一跳开始推进;from_node 为 None 表示从 start 进入。""" root = _flow_root(_loads(ver.flow_tree_json, {})) form_data = _loads(inst.form_data_json, {}) or {} def next_node(node: Dict[str, Any]) -> Optional[Dict[str, Any]]: ntype = _node_type(node) if ntype == "condition": groups = node.get("conditionGroups") or [] g = _match_condition_group(groups, form_data) if not g or not isinstance(g.get("child"), dict): raise OaEngineError("条件分支无法匹配出线") return g["child"] child = node.get("child") return child if isinstance(child, dict) else None # 起点 if from_node is None: cur = next_node(root) if _node_type(root) == "start" else root else: cur = next_node(from_node) # 同一次推进里可能连续落抄送+审批:共用递增序号,避免 create_time 同秒时乱序 next_order = _next_task_order_num(db, inst.id) safety = 0 while cur and safety < 50: safety += 1 ntype = _node_type(cur) if ntype == "end": inst.status = "completed" inst.update_time = datetime.now() db.add(inst) return if ntype == "condition": cur = next_node(cur) continue if ntype in ("approval", "cc"): tasks = _create_tasks_for_node( db, inst=inst, node=cur, ver=ver, user_id=user_id, initiator_select_user_ids=initiator_select_user_ids, order_num_start=next_order, ) next_order += max(len(tasks), 1) if ntype == "cc": # 抄送不阻断,继续往下 cur = next_node(cur) continue if not tasks: raise OaEngineError("审批节点未生成任务") return # 未知类型跳过子节点 cur = next_node(cur) # 无结束也无待办:异常结束 if inst.status == "running": raise OaEngineError("流程推进异常:未到达结束且无待办任务") def start_instance( db: Session, *, tenant_no: str, app_id: str, initiator_user_id: str, initiator_dept_id: Optional[str] = None, form_data: Optional[Dict[str, Any]] = None, title: Optional[str] = None, initiator_select_user_ids: Optional[List[str]] = None, related_instance_ids: Optional[List[str]] = None, source_instance_id: Optional[str] = None, ) -> Dict[str, Any]: # source_instance_id 保留入参兼容,不再写入关联(复制开新单不挂旧单) _ = source_instance_id app = oa_crud.get_app(db, app_id, tenant_no) if not app or app.status != "1": raise OaEngineError("应用不存在或已停用") _check_initiator_scope(app, initiator_user_id) ver = oa_crud._find_latest_published(db, app_id, tenant_no) if not ver: raise OaEngineError("应用尚未发布,无法发起") schema = _loads(ver.form_schema_json, {}) data = dict(form_data or {}) try: data = apply_formulas_and_linkages(schema, data) except FormulaError as e: raise OaEngineError(f"公式计算失败: {e}") from e user = ( db.query(SysUser) .filter(SysUser.id == initiator_user_id, SysUser.del_flag == 0) .first() ) dept_id = (initiator_dept_id or (user.dept_id if user else "") or "").strip() or None now = datetime.now() title_text = (title or app.app_name or "").strip() inst = OaInstance( id=generate_uuid(), app_id=app_id, app_version_id=ver.id, title=title_text[:200], initiator_user_id=initiator_user_id, initiator_dept_id=dept_id, form_data_json=_dumps(data), status="running", source_instance_id=None, current_round=1, tenant_no=tenant_no, order_num=0, remark=None, create_by=initiator_user_id, create_dept_id=dept_id or "", create_time=now, update_by=initiator_user_id, update_time=now, del_flag=0, ) db.add(inst) for to_id in related_instance_ids or []: to_id = (to_id or "").strip() if not to_id: continue db.add( OaRelation( id=generate_uuid(), from_instance_id=inst.id, to_instance_id=to_id, field_key=None, tenant_no=tenant_no, order_num=0, remark=None, create_by=initiator_user_id, create_dept_id=dept_id or "", create_time=now, update_by=initiator_user_id, update_time=now, del_flag=0, ) ) _add_instance_event( db, inst=inst, event_type="submit", actor_user_id=initiator_user_id, round_no=1, ) advance_from( db, inst=inst, ver=ver, from_node=None, user_id=initiator_user_id, initiator_select_user_ids=initiator_select_user_ids, ) db.commit() db.refresh(inst) return get_instance_detail(db, inst.id, tenant_no=tenant_no) def _get_instance(db: Session, instance_id: str, tenant_no: str) -> OaInstance: inst = ( db.query(OaInstance) .filter(OaInstance.id == instance_id, OaInstance.tenant_no == tenant_no, OaInstance.del_flag == 0) .first() ) if not inst: raise OaEngineError("实例不存在") return inst def get_instance_detail(db: Session, instance_id: str, *, tenant_no: str) -> Dict[str, Any]: inst = _get_instance(db, instance_id, tenant_no) app = oa_crud.get_app(db, inst.app_id, tenant_no) ver = ( db.query(OaAppVersion) .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0) .first() ) tasks = ( db.query(OaTask) .filter(OaTask.instance_id == instance_id, OaTask.del_flag == 0) .all() ) flow_root = None if ver: try: flow_root = _flow_root(_loads(ver.flow_tree_json, {})) except OaEngineError: flow_root = None tasks = _sort_tasks_for_detail(tasks, flow_root=flow_root) rels = ( db.query(OaRelation) .filter(OaRelation.from_instance_id == instance_id, OaRelation.del_flag == 0) .all() ) name_map = _user_name_map( db, [t.assignee_user_id for t in tasks if t.assignee_user_id] + ([inst.initiator_user_id] if inst.initiator_user_id else []), ) data = _instance_to_dict(inst, app_name=app.app_name if app else "") data["initiatorUserName"] = name_map.get(inst.initiator_user_id or "", inst.initiator_user_id or "") data["tasks"] = [ _task_to_dict(t, assignee_user_name=name_map.get(t.assignee_user_id or "", "")) for t in tasks ] data["relatedInstanceIds"] = [r.to_instance_id for r in rels] data["formSchemaJson"] = _loads(ver.form_schema_json, {}) if ver else {} adv = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS) data["advancedSettingsJson"] = adv data["allowAddSign"] = bool(adv.get("allowAddSign", True)) data["allowReturn"] = bool(adv.get("allowReturn", True)) events = ( db.query(OaInstanceEvent) .filter(OaInstanceEvent.instance_id == instance_id, OaInstanceEvent.del_flag == 0) .order_by(OaInstanceEvent.create_time.asc()) .all() ) event_actors = [e.actor_user_id for e in events if e.actor_user_id] if event_actors: name_map.update(_user_name_map(db, event_actors)) data["progress"] = build_progress_items( inst=inst, flow_root=flow_root, tasks=tasks, name_map=name_map, events=events, ) # 不再暴露旧单/重提单关联跳转 data["sourceInstanceId"] = "" data["resubmittedInstanceId"] = "" return data def list_tasks( db: Session, *, tenant_no: str, user_id: str, kind: str, ) -> List[Dict[str, Any]]: kind = (kind or "").strip() if kind == "pending": q = db.query(OaTask).filter( OaTask.tenant_no == tenant_no, OaTask.del_flag == 0, OaTask.assignee_user_id == user_id, OaTask.task_type == "approval", OaTask.status == "pending", ) elif kind == "done": q = db.query(OaTask).filter( OaTask.tenant_no == tenant_no, OaTask.del_flag == 0, OaTask.assignee_user_id == user_id, OaTask.task_type == "approval", OaTask.status.in_(("approved", "rejected", "returned")), ) elif kind == "cc": q = db.query(OaTask).filter( OaTask.tenant_no == tenant_no, OaTask.del_flag == 0, OaTask.assignee_user_id == user_id, OaTask.task_type == "cc", ) elif kind == "mine": insts = ( db.query(OaInstance) .filter( OaInstance.tenant_no == tenant_no, OaInstance.del_flag == 0, OaInstance.initiator_user_id == user_id, ) .order_by(OaInstance.create_time.desc()) .all() ) rows = [_instance_to_dict(i) for i in insts] return _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts) else: raise OaEngineError(f"不支持的列表类型: {kind}") rows_orm = q.order_by(OaTask.create_time.desc()).all() rows = [_task_to_dict(t) for t in rows_orm] inst_ids = list({t.instance_id for t in rows_orm if t.instance_id}) insts = ( db.query(OaInstance) .filter( OaInstance.tenant_no == tenant_no, OaInstance.del_flag == 0, OaInstance.id.in_(inst_ids), ) .all() if inst_ids else [] ) return _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts) def complete_task( db: Session, task_id: str, *, tenant_no: str, user_id: str, action: str, comment: Optional[str] = None, ) -> Dict[str, Any]: action = (action or "").strip() if action not in ("approve", "reject"): raise OaEngineError("不支持的操作") task = ( db.query(OaTask) .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0) .first() ) if not task: raise OaEngineError("任务不存在") if task.assignee_user_id != user_id: raise OaEngineError("无权处理该任务") if task.task_type != "approval" or task.status != "pending": raise OaEngineError("任务状态不可处理") inst = _get_instance(db, task.instance_id, tenant_no) if inst.status != "running": raise OaEngineError("实例已结束") ver = ( db.query(OaAppVersion) .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0) .first() ) if not ver: raise OaEngineError("流程版本不存在") advanced = _advanced(ver) if advanced.get("commentRequired") and not (comment or "").strip(): raise OaEngineError("请填写审批意见") now = datetime.now() task.comment_text = (comment or "").strip() or None task.status = "approved" if action == "approve" else "rejected" task.update_by = user_id task.update_time = now db.add(task) # Session 默认 autoflush=False:不先 flush 时,后续按 status=pending 的查询仍会命中本任务, # 或签「取消同节点其余待办」会把本人已同意/已拒绝覆盖成 done,导致「已处理」列表查不到。 db.flush() if action == "reject": # 同节点其他待办作废(排除当前任务) siblings = ( 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() ) for s in siblings: s.status = "done" s.update_by = user_id s.update_time = now db.add(s) inst.status = "rejected" inst.update_by = user_id inst.update_time = now db.add(inst) db.commit() return get_instance_detail(db, inst.id, tenant_no=tenant_no) # 或签:一人通过即可;会签:全部通过 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: db.commit() return get_instance_detail(db, inst.id, tenant_no=tenant_no) # 或签:取消同节点其余待办(排除当前任务) 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) db.commit() return get_instance_detail(db, inst.id, tenant_no=tenant_no) def add_sign( db: Session, task_id: str, *, tenant_no: str, user_id: str, user_ids: List[str], comment: Optional[str] = None, ) -> Dict[str, Any]: """当前待办处理人加签:为同节点新增待办审批人。""" task = ( db.query(OaTask) .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0) .first() ) if not task: raise OaEngineError("任务不存在") if task.assignee_user_id != user_id: raise OaEngineError("无权加签该任务") if task.task_type != "approval" or task.status != "pending": raise OaEngineError("仅待处理的审批任务可加签") inst = _get_instance(db, task.instance_id, tenant_no) if inst.status != "running": raise OaEngineError("实例已结束") ver = ( db.query(OaAppVersion) .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0) .first() ) if not ver: raise OaEngineError("流程版本不存在") advanced = _advanced(ver) if not advanced.get("allowAddSign", True): raise OaEngineError("该应用不允许加签") ids = [] seen = set() for uid in user_ids or []: uid = (uid or "").strip() if not uid or uid in seen: continue seen.add(uid) ids.append(uid) if not ids: raise OaEngineError("请选择加签人") existing = { r.assignee_user_id for r in db.query(OaTask) .filter( OaTask.instance_id == inst.id, OaTask.node_key == task.node_key, OaTask.del_flag == 0, OaTask.status == "pending", ) .all() } now = datetime.now() created = 0 next_order = _next_task_order_num(db, inst.id) for uid in ids: if uid in existing: continue # 校验用户有效 u = ( db.query(SysUser) .filter( SysUser.id == uid, SysUser.tenant_no == tenant_no, SysUser.del_flag == 0, SysUser.status == "1", ) .first() ) if not u: raise OaEngineError(f"加签用户无效:{uid}") db.add( OaTask( id=generate_uuid(), instance_id=inst.id, node_key=task.node_key, node_name=task.node_name, task_type="approval", assignee_user_id=uid, status="pending", comment_text=None, resolve_source="add_sign", sign_mode=task.sign_mode or "or", round_no=int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1), tenant_no=tenant_no, order_num=next_order, remark=(comment or "").strip() or None, create_by=user_id, create_dept_id=inst.initiator_dept_id or "", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) ) next_order += 1 created += 1 if created == 0: raise OaEngineError("所选用户已在当前节点待办中") # 加签后默认按会签语义收口更稳妥:保留原 sign_mode;若原或签则一人通过仍可结束 if comment: task.comment_text = ((task.comment_text or "") + f"\n[加签说明] {comment}").strip() task.update_by = user_id task.update_time = now db.add(task) db.commit() return get_instance_detail(db, inst.id, tenant_no=tenant_no) def return_task( db: Session, task_id: str, *, tenant_no: str, user_id: str, comment: Optional[str] = None, ) -> Dict[str, Any]: """审批人打回:本单终态 returned;再办须复制表单开新实例。""" task = ( db.query(OaTask) .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0) .first() ) if not task: raise OaEngineError("任务不存在") if task.assignee_user_id != user_id: raise OaEngineError("无权打回该任务") if task.task_type != "approval" or task.status != "pending": raise OaEngineError("仅待处理的审批任务可打回") inst = _get_instance(db, task.instance_id, tenant_no) if inst.status != "running": raise OaEngineError("仅进行中的审批可打回") ver = ( db.query(OaAppVersion) .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0) .first() ) if not ver: raise OaEngineError("流程版本不存在") advanced = _advanced(ver) if not advanced.get("allowReturn", True): raise OaEngineError("该应用不允许打回") comment_text = (comment or "").strip() if not comment_text: raise OaEngineError("打回意见必填") now = datetime.now() round_no = int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1) task.status = "returned" task.comment_text = comment_text task.update_by = user_id task.update_time = now db.add(task) for t in ( db.query(OaTask) .filter( OaTask.instance_id == inst.id, OaTask.status == "pending", OaTask.del_flag == 0, OaTask.id != task.id, ) .all() ): t.status = "cancelled" t.update_by = user_id t.update_time = now db.add(t) inst.status = "returned" inst.update_by = user_id inst.update_time = now db.add(inst) _add_instance_event( db, inst=inst, event_type="return", actor_user_id=user_id, round_no=round_no, comment=comment_text, task_id=task.id, node_key=task.node_key, ) db.commit() return get_instance_detail(db, inst.id, tenant_no=tenant_no) def resubmit_instance( db: Session, instance_id: str, *, tenant_no: str, user_id: str, form_data: Optional[Dict[str, Any]] = None, initiator_select_user_ids: Optional[List[str]] = None, ) -> Dict[str, Any]: """已废弃:同实例再次提交。请复制表单重新发起新实例。""" _ = (db, instance_id, tenant_no, user_id, form_data, initiator_select_user_ids) raise OaEngineError("同实例再次提交已废弃,请复制表单重新发起") def withdraw_instance( db: Session, instance_id: str, *, tenant_no: str, user_id: str ) -> Dict[str, Any]: inst = _get_instance(db, instance_id, tenant_no) if inst.initiator_user_id != user_id: raise OaEngineError("仅发起人可撤销") if inst.status != "running": raise OaEngineError("仅运行中的实例可撤销") ver = ( db.query(OaAppVersion) .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0) .first() ) advanced = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS) if not advanced.get("allowWithdrawRunning", True): raise OaEngineError("该应用不允许撤销进行中的审批") now = datetime.now() inst.status = "cancelled" inst.update_by = user_id inst.update_time = now db.add(inst) for t in ( db.query(OaTask) .filter(OaTask.instance_id == inst.id, OaTask.status == "pending", OaTask.del_flag == 0) .all() ): t.status = "cancelled" t.update_by = user_id t.update_time = now db.add(t) _add_instance_event( db, inst=inst, event_type="withdraw", actor_user_id=user_id, round_no=int(getattr(inst, "current_round", None) or 1), ) db.commit() return get_instance_detail(db, inst.id, tenant_no=tenant_no)