3591 lines
118 KiB
Python
3591 lines
118 KiB
Python
"""OA 审批运行时引擎(钉钉式竖向树)"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from collections import OrderedDict
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from sqlalchemy import or_
|
||
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,
|
||
OaTask,
|
||
SysDept,
|
||
SysPost,
|
||
SysUser,
|
||
)
|
||
from app.services.oa_assignee import (
|
||
AssigneeResolveError,
|
||
normalize_cc_assignee_types,
|
||
resolve_assignees,
|
||
resolve_cc_assignees,
|
||
)
|
||
from app.services.oa_formula import FormulaError, apply_formulas_and_linkages, eval_formula
|
||
from app.services.oa_confirmation_kit_sync import apply_confirmation_kit_user_sync
|
||
from app.services.oa_resignation_kit_sync import apply_resignation_kit_user_sync
|
||
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OaEngineError(ValueError):
|
||
"""引擎业务错误"""
|
||
|
||
|
||
_AUTO_PASS_COMMENT = "系统自动通过"
|
||
|
||
INITIATOR_CONDITION_FIELD = "__initiator__"
|
||
|
||
|
||
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 _normalize_initiator_select_by_node(raw: Any) -> Dict[str, List[str]]:
|
||
if not isinstance(raw, dict):
|
||
return {}
|
||
out: Dict[str, List[str]] = {}
|
||
for k, v in raw.items():
|
||
nid = str(k or "").strip()
|
||
if not nid or not isinstance(v, (list, tuple)):
|
||
continue
|
||
ids: List[str] = []
|
||
seen = set()
|
||
for x in v:
|
||
uid = str(x or "").strip()
|
||
if uid and uid not in seen:
|
||
seen.add(uid)
|
||
ids.append(uid)
|
||
out[nid] = ids
|
||
return out
|
||
|
||
|
||
def _ids_for_initiator_select_node(
|
||
select_by_node: Optional[Dict[str, List[str]]], node_id: str
|
||
) -> List[str]:
|
||
m = select_by_node or {}
|
||
nid = str(node_id or "").strip()
|
||
if not nid:
|
||
return []
|
||
return list(m.get(nid) or [])
|
||
|
||
|
||
def _load_initiator_select_by_node(inst: Any) -> Dict[str, List[str]]:
|
||
return _normalize_initiator_select_by_node(
|
||
_loads(getattr(inst, "initiator_select_json", None), {}) or {}
|
||
)
|
||
|
||
|
||
def _node_type(node: Dict[str, Any]) -> str:
|
||
return (node.get("type") or "").strip()
|
||
|
||
|
||
def _subtree_contains(node: Optional[Dict[str, Any]], node_id: str) -> bool:
|
||
"""节点子树(含自身、child、条件分支)是否包含指定 id。"""
|
||
if not isinstance(node, dict):
|
||
return False
|
||
if (node.get("id") or "") == node_id:
|
||
return True
|
||
child = node.get("child")
|
||
if isinstance(child, dict) and _subtree_contains(child, node_id):
|
||
return True
|
||
for g in node.get("conditionGroups") or []:
|
||
if isinstance(g, dict) and _subtree_contains(g.get("child"), node_id):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _merge_after_branch_end(
|
||
root: Dict[str, Any], end_id: str
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""若 end 落在某条件分支组内,返回最近一层条件节点的汇合后 child(若有)。"""
|
||
|
||
def walk(node: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||
if not isinstance(node, dict):
|
||
return None
|
||
if _node_type(node) == "condition":
|
||
for g in node.get("conditionGroups") or []:
|
||
if not isinstance(g, dict):
|
||
continue
|
||
if not _subtree_contains(g.get("child"), end_id):
|
||
continue
|
||
# 优先内层条件的汇合,再回退到本层
|
||
nested = walk(g.get("child"))
|
||
if nested is not None:
|
||
return nested
|
||
merge = node.get("child")
|
||
return merge if isinstance(merge, dict) else None
|
||
merge_child = node.get("child")
|
||
if isinstance(merge_child, dict):
|
||
return walk(merge_child)
|
||
return None
|
||
child = node.get("child")
|
||
if isinstance(child, dict):
|
||
return walk(child)
|
||
return None
|
||
|
||
return walk(root)
|
||
|
||
|
||
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,
|
||
name_map: Optional[Dict[str, str]] = None,
|
||
) -> Dict[str, Any]:
|
||
name_map = name_map or {}
|
||
uid = ""
|
||
uname = ""
|
||
comment = ""
|
||
# 撤销终态:归因发起人,勿让任务节点承担「已撤销」语义
|
||
if status == "cancelled":
|
||
uid = (getattr(inst, "initiator_user_id", None) or "").strip()
|
||
uname = name_map.get(uid, "") or uid
|
||
comment = "发起人撤销"
|
||
return {
|
||
"kind": "terminal",
|
||
"roundNo": getattr(inst, "current_round", None) or 1,
|
||
"title": title,
|
||
"status": status,
|
||
"assigneeUserId": uid,
|
||
"assigneeUserName": uname,
|
||
"timestamp": _fmt_dt(getattr(inst, "update_time", None)),
|
||
"commentText": comment,
|
||
"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", "end") or nid in reached_keys:
|
||
continue
|
||
if nt == "end":
|
||
title = node.get("name") or "结束"
|
||
elif nt == "cc":
|
||
title = node.get("name") or "抄送人"
|
||
else:
|
||
title = node.get("name") or "审批人"
|
||
items.append(
|
||
{
|
||
"kind": "pending_node",
|
||
"roundNo": round_no,
|
||
"title": title,
|
||
"status": "not_reached",
|
||
"assigneeUserId": "",
|
||
"assigneeUserName": "",
|
||
"timestamp": "",
|
||
"commentText": "",
|
||
"nodeKey": nid,
|
||
"taskType": nt,
|
||
"signMode": "",
|
||
"people": [],
|
||
}
|
||
)
|
||
|
||
|
||
def _pending_people_for_node(
|
||
db: Session,
|
||
*,
|
||
node: Dict[str, Any],
|
||
tenant_no: str,
|
||
initiator_user_id: str,
|
||
initiator_dept_id: Optional[str],
|
||
advanced: Dict[str, Any],
|
||
initiator_select_by_node: Optional[Dict[str, List[str]]],
|
||
) -> Tuple[List[Dict[str, Any]], str, str]:
|
||
"""解析未到达节点的展示人员与签批模式。返回 (people, sign_mode, resolve_source)。"""
|
||
ntype = _node_type(node)
|
||
cfg = node.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"
|
||
people: List[Dict[str, Any]] = []
|
||
node_select_ids = _ids_for_initiator_select_node(
|
||
initiator_select_by_node, node.get("id") or ""
|
||
)
|
||
resolve_source = assignee_type
|
||
if ntype == "cc":
|
||
types = normalize_cc_assignee_types(cfg)
|
||
assignees, meta = resolve_cc_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=node_select_ids,
|
||
)
|
||
resolve_source = str(meta.get("resolve_source") or "")
|
||
if not resolve_source:
|
||
resolve_source = "multi" if len(types) > 1 else (types[0] if types else "")
|
||
for uid in assignees:
|
||
people.append(
|
||
{
|
||
"id": uid,
|
||
"nickName": "",
|
||
"status": "not_reached",
|
||
"timestamp": "",
|
||
"commentText": "",
|
||
"taskId": "",
|
||
}
|
||
)
|
||
elif assignee_type == "initiator_select":
|
||
for uid in node_select_ids:
|
||
people.append(
|
||
{
|
||
"id": uid,
|
||
"nickName": "", # 下方统一补名
|
||
"status": "not_reached",
|
||
"timestamp": "",
|
||
"commentText": "",
|
||
"taskId": "",
|
||
}
|
||
)
|
||
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=node_select_ids,
|
||
)
|
||
for uid in assignees:
|
||
people.append(
|
||
{
|
||
"id": uid,
|
||
"nickName": "",
|
||
"status": "not_reached",
|
||
"timestamp": "",
|
||
"commentText": "",
|
||
"taskId": "",
|
||
}
|
||
)
|
||
except AssigneeResolveError:
|
||
people = []
|
||
if people:
|
||
name_map = _user_name_map(db, [p["id"] for p in people if p.get("id")])
|
||
for p in people:
|
||
p["nickName"] = name_map.get(p["id"], "") or p["id"]
|
||
return people, sign_mode, resolve_source
|
||
|
||
|
||
def _iter_resolved_path_nodes(
|
||
root: Optional[Dict[str, Any]],
|
||
form_data: Optional[Dict[str, Any]],
|
||
*,
|
||
initiator_user_id: Optional[str] = None,
|
||
flow_root: Optional[Dict[str, Any]] = None,
|
||
) -> Optional[List[Dict[str, Any]]]:
|
||
"""沿条件命中路径收集节点;无法匹配时返回 None。"""
|
||
if not isinstance(root, dict):
|
||
return []
|
||
data = form_data or {}
|
||
tree_root = flow_root if isinstance(flow_root, dict) else root
|
||
out: List[Dict[str, Any]] = []
|
||
cur: Optional[Dict[str, Any]] = root
|
||
while cur and isinstance(cur, dict):
|
||
ntype = _node_type(cur)
|
||
if ntype == "end":
|
||
end_id = (cur.get("id") or "").strip()
|
||
# 分支叶子 end 若后面还有汇合主链,不算真正结束
|
||
if end_id and _merge_after_branch_end(tree_root, end_id) is not None:
|
||
break
|
||
out.append(cur)
|
||
break
|
||
if ntype == "condition":
|
||
g = _match_condition_group(
|
||
cur.get("conditionGroups") or [], data, initiator_user_id=initiator_user_id
|
||
)
|
||
if not g:
|
||
return None
|
||
child = g.get("child")
|
||
if isinstance(child, dict):
|
||
sub = _iter_resolved_path_nodes(
|
||
child,
|
||
data,
|
||
initiator_user_id=initiator_user_id,
|
||
flow_root=tree_root,
|
||
)
|
||
if sub is None:
|
||
return None
|
||
out.extend(sub)
|
||
cur = cur.get("child")
|
||
continue
|
||
out.append(cur)
|
||
cur = cur.get("child")
|
||
return out
|
||
|
||
|
||
def _append_pending_nodes_on_path(
|
||
items: List[Dict[str, Any]],
|
||
*,
|
||
db: Session,
|
||
flow_root: Dict[str, Any],
|
||
reached_keys: set,
|
||
round_no: int,
|
||
tenant_no: str,
|
||
initiator_user_id: str,
|
||
initiator_dept_id: Optional[str],
|
||
advanced: Dict[str, Any],
|
||
form_data: Optional[Dict[str, Any]],
|
||
initiator_select_by_node: Optional[Dict[str, List[str]]],
|
||
) -> None:
|
||
"""按命中路径列出全部后续审批/抄送/结束节点(含人员),对齐发起预览。"""
|
||
path = _iter_resolved_path_nodes(
|
||
flow_root, form_data, initiator_user_id=initiator_user_id
|
||
)
|
||
if path is None:
|
||
already = {
|
||
(x.get("nodeKey") or "").strip()
|
||
for x in items
|
||
if x.get("kind") in ("task", "pending_node") and (x.get("nodeKey") or "").strip()
|
||
}
|
||
_append_pending_nodes(
|
||
items,
|
||
flow_root=flow_root,
|
||
reached_keys=reached_keys | already,
|
||
round_no=round_no,
|
||
)
|
||
return
|
||
|
||
for node in path:
|
||
ntype = _node_type(node)
|
||
if ntype not in ("approval", "cc", "end"):
|
||
continue
|
||
nid = (node.get("id") or "").strip()
|
||
if not nid or nid in reached_keys:
|
||
continue
|
||
if ntype == "end":
|
||
items.append(
|
||
{
|
||
"kind": "pending_node",
|
||
"roundNo": round_no,
|
||
"title": node.get("name") or "结束",
|
||
"status": "not_reached",
|
||
"assigneeUserId": "",
|
||
"assigneeUserName": "",
|
||
"timestamp": "",
|
||
"commentText": "",
|
||
"nodeKey": nid,
|
||
"taskType": "end",
|
||
"signMode": "",
|
||
"people": [],
|
||
}
|
||
)
|
||
continue
|
||
people, sign_mode, _src = _pending_people_for_node(
|
||
db,
|
||
node=node,
|
||
tenant_no=tenant_no,
|
||
initiator_user_id=initiator_user_id,
|
||
initiator_dept_id=initiator_dept_id,
|
||
advanced=advanced,
|
||
initiator_select_by_node=initiator_select_by_node,
|
||
)
|
||
items.append(
|
||
{
|
||
"kind": "pending_node",
|
||
"roundNo": round_no,
|
||
"title": node.get("name") or ("抄送人" if ntype == "cc" else "审批人"),
|
||
"status": "not_reached",
|
||
"assigneeUserId": "",
|
||
"assigneeUserName": "",
|
||
"timestamp": "",
|
||
"commentText": "",
|
||
"nodeKey": nid,
|
||
"taskType": ntype,
|
||
"signMode": sign_mode,
|
||
"people": people,
|
||
}
|
||
)
|
||
|
||
|
||
def _progress_person(t: Any, name_map: Dict[str, str]) -> Dict[str, Any]:
|
||
uid = (getattr(t, "assignee_user_id", None) or "").strip()
|
||
return {
|
||
"id": uid,
|
||
"nickName": name_map.get(uid, "") or uid,
|
||
"status": getattr(t, "status", None) or "",
|
||
"timestamp": _fmt_dt(getattr(t, "update_time", None) or getattr(t, "create_time", None)),
|
||
"commentText": getattr(t, "comment_text", None) or "",
|
||
"taskId": getattr(t, "id", None) or "",
|
||
"transferToUserId": "",
|
||
"transferToUserName": "",
|
||
}
|
||
|
||
|
||
def _enrich_transfer_targets(
|
||
people: List[Dict[str, Any]],
|
||
group: List[Any],
|
||
name_map: Dict[str, str],
|
||
) -> None:
|
||
"""为已转交人员补齐 transferTo*:同节点 resolve_source=transfer 且 create_by=转交人 的新任务处理人。"""
|
||
by_creator: Dict[str, List[Any]] = {}
|
||
for t in group:
|
||
if (getattr(t, "resolve_source", None) or "").strip() != "transfer":
|
||
continue
|
||
creator = (getattr(t, "create_by", None) or "").strip()
|
||
if not creator:
|
||
continue
|
||
by_creator.setdefault(creator, []).append(t)
|
||
|
||
for p in people:
|
||
if (p.get("status") or "") != "transferred":
|
||
continue
|
||
uid = (p.get("id") or "").strip()
|
||
if not uid:
|
||
continue
|
||
candidates = by_creator.get(uid) or []
|
||
if not candidates:
|
||
continue
|
||
# 连续转交时同一人可能只对应一次;若有多条取创建时间最接近转交时间的
|
||
p_ts = (p.get("timestamp") or "").strip()
|
||
|
||
def _score(t: Any) -> tuple:
|
||
cts = _fmt_dt(getattr(t, "create_time", None))
|
||
# 优先同秒/最近;无时间时靠后
|
||
if p_ts and cts:
|
||
return (0, abs(_dt_sort_key(cts) - _dt_sort_key(p_ts)), cts)
|
||
return (1, 0, cts or "")
|
||
|
||
target_task = sorted(candidates, key=_score)[0]
|
||
tid = (getattr(target_task, "assignee_user_id", None) or "").strip()
|
||
if not tid:
|
||
continue
|
||
p["transferToUserId"] = tid
|
||
p["transferToUserName"] = name_map.get(tid, "") or tid
|
||
|
||
|
||
def _dt_sort_key(s: str) -> int:
|
||
"""将 'YYYY-MM-DD HH:MM:SS' 粗转为可比较整数;非法则 0。"""
|
||
raw = (s or "").strip().replace("-", "").replace(":", "").replace(" ", "")
|
||
try:
|
||
return int(raw[:14]) if raw else 0
|
||
except ValueError:
|
||
return 0
|
||
|
||
|
||
def _aggregate_node_status(statuses: List[str]) -> str:
|
||
"""同节点多人任务收成卡片状态:拒绝/打回优先,其次待办,再已同意。"""
|
||
sset = [str(s or "").strip() for s in statuses]
|
||
if any(s == "rejected" for s in sset):
|
||
return "rejected"
|
||
if any(s == "returned" for s in sset):
|
||
return "returned"
|
||
if any(s in ("pending", "running") for s in sset):
|
||
return "pending"
|
||
if any(s == "approved" for s in sset):
|
||
return "approved"
|
||
if any(s == "completed" for s in sset):
|
||
return "completed"
|
||
if sset and all(s == "done" for s in sset):
|
||
return "done"
|
||
return sset[0] if sset else "pending"
|
||
|
||
|
||
def _group_tasks_by_node(sorted_tasks: List[Any]) -> List[List[Any]]:
|
||
"""按 node_key 合并同节点多人(或签/会签/抄送),无 key 的任务各自独立。"""
|
||
buckets: OrderedDict[str, List[Any]] = OrderedDict()
|
||
for t in sorted_tasks:
|
||
nk = (getattr(t, "node_key", None) or "").strip()
|
||
key = nk if nk else f"__task_{(getattr(t, 'id', None) or id(t))}"
|
||
buckets.setdefault(key, []).append(t)
|
||
return list(buckets.values())
|
||
|
||
|
||
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,
|
||
db: Optional[Session] = None,
|
||
advanced: Optional[Dict[str, Any]] = None,
|
||
initiator_select_by_node: Optional[Dict[str, List[str]]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""投影审批进度:发起人 → 树序 tasks(同节点多人合并)→ 未到达;终态收尾。
|
||
|
||
钉钉对齐:打回结果落在审批任务节点上,不另插 kind=return / terminal 终态「已打回」行;不做同单多轮分段。
|
||
或签/会签:同一 nodeKey 只出一张卡片,people 列出各审批人及状态。
|
||
进行中:按命中路径把后续审批/抄送节点全部挂为未到达(尽量解析人员)。
|
||
"""
|
||
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": "",
|
||
"signMode": "",
|
||
"people": [],
|
||
}
|
||
]
|
||
reached_keys = {
|
||
(t.node_key or "").strip() for t in sorted_tasks if (t.node_key or "").strip()
|
||
}
|
||
for group in _group_tasks_by_node(sorted_tasks):
|
||
head = group[0]
|
||
people = [_progress_person(t, name_map) for t in group]
|
||
_enrich_transfer_targets(people, group, name_map)
|
||
statuses = [p["status"] for p in people]
|
||
node_status = _aggregate_node_status(statuses)
|
||
# 时间:优先取已操作人最新时间,否则取组内最新
|
||
acted = [
|
||
p
|
||
for p in people
|
||
if p["status"] in ("approved", "rejected", "returned", "completed", "transferred")
|
||
]
|
||
ts_src = acted or people
|
||
timestamp = ""
|
||
for p in ts_src:
|
||
if p.get("timestamp") and (not timestamp or p["timestamp"] > timestamp):
|
||
timestamp = p["timestamp"]
|
||
# 整节点仅系统清待办(cancelled)时勿带时间,避免像审批人操作过
|
||
if node_status == "cancelled":
|
||
timestamp = ""
|
||
comments = [
|
||
f"{p['nickName']}:{p['commentText']}"
|
||
if len(people) > 1 and p.get("nickName")
|
||
else p["commentText"]
|
||
for p in people
|
||
if (p.get("commentText") or "").strip()
|
||
]
|
||
sign_mode = (getattr(head, "sign_mode", None) or "").strip()
|
||
primary = next(
|
||
(p for p in people if p["status"] == node_status),
|
||
people[0] if people else None,
|
||
)
|
||
items.append(
|
||
{
|
||
"kind": "task",
|
||
"roundNo": rn,
|
||
"title": head.node_name or head.node_key or "-",
|
||
"status": node_status,
|
||
"assigneeUserId": (primary or {}).get("id", "") if len(people) == 1 else "",
|
||
"assigneeUserName": (primary or {}).get("nickName", "")
|
||
if len(people) == 1
|
||
else "",
|
||
"timestamp": timestamp,
|
||
"commentText": ";".join(comments),
|
||
"nodeKey": head.node_key or "",
|
||
"taskType": head.task_type or "",
|
||
"resolveSource": getattr(head, "resolve_source", None) or "",
|
||
"signMode": sign_mode,
|
||
"taskId": getattr(head, "id", None) or "",
|
||
"people": people,
|
||
}
|
||
)
|
||
if inst.status == "running" and isinstance(flow_root, dict):
|
||
form_data = _loads(getattr(inst, "form_data_json", None), {}) or {}
|
||
if not isinstance(form_data, dict):
|
||
form_data = {}
|
||
# 自选人:优先入参 → 实例 JSON → 旧数据从任务按 node_key 聚合
|
||
select_by_node = _normalize_initiator_select_by_node(initiator_select_by_node)
|
||
if not select_by_node:
|
||
select_by_node = _load_initiator_select_by_node(inst)
|
||
if not select_by_node:
|
||
for t in sorted_tasks:
|
||
src = (getattr(t, "resolve_source", None) or "").strip()
|
||
uid = (getattr(t, "assignee_user_id", None) or "").strip()
|
||
nk = (getattr(t, "node_key", None) or "").strip()
|
||
if src == "initiator_select" and uid and nk:
|
||
bucket = select_by_node.setdefault(nk, [])
|
||
if uid not in bucket:
|
||
bucket.append(uid)
|
||
if db is not None:
|
||
_append_pending_nodes_on_path(
|
||
items,
|
||
db=db,
|
||
flow_root=flow_root,
|
||
reached_keys=reached_keys,
|
||
round_no=rn,
|
||
tenant_no=getattr(inst, "tenant_no", None) or "",
|
||
initiator_user_id=inst.initiator_user_id or "",
|
||
initiator_dept_id=getattr(inst, "initiator_dept_id", None),
|
||
advanced=advanced or dict(oa_crud.DEFAULT_ADVANCED_SETTINGS),
|
||
form_data=form_data,
|
||
initiator_select_by_node=select_by_node,
|
||
)
|
||
else:
|
||
_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, name_map))
|
||
elif inst.status == "rejected":
|
||
items.append(_terminal_item("rejected", "流程结束(已拒绝)", inst, name_map))
|
||
elif inst.status == "cancelled":
|
||
items.append(_terminal_item("cancelled", "已撤销", inst, name_map))
|
||
# returned:打回结果只落在审批任务节点上,不再追加独立终态「已打回」行
|
||
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 "",
|
||
"isRead": bool(int(getattr(t, "is_read", 0) or 0)),
|
||
"readTime": (
|
||
t.read_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
if getattr(t, "read_time", None)
|
||
else ""
|
||
),
|
||
"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],
|
||
preview_limit: int = 3,
|
||
) -> List[Dict[str, Any]]:
|
||
if not rows:
|
||
return []
|
||
from app.services.oa_related_approval import flatten_form_fields_for_summary
|
||
|
||
limit = max(0, int(preview_limit or 3))
|
||
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}
|
||
|
||
ver_ids = list({i.app_version_id for i in instances if i.app_version_id})
|
||
ver_by_id: Dict[str, OaAppVersion] = {}
|
||
if ver_ids:
|
||
for v in (
|
||
db.query(OaAppVersion)
|
||
.filter(OaAppVersion.id.in_(ver_ids), OaAppVersion.del_flag == 0)
|
||
.all()
|
||
):
|
||
ver_by_id[v.id] = v
|
||
|
||
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,
|
||
)
|
||
ver = ver_by_id.get(inst.app_version_id or "")
|
||
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
|
||
form_data = _loads(inst.form_data_json, {}) or {}
|
||
preview = []
|
||
for f in flatten_form_fields_for_summary(schema, form_data)[:limit]:
|
||
item = dict(f)
|
||
item["label"] = item.get("label") or ""
|
||
item["value"] = item.get("value") or ""
|
||
preview.append(item)
|
||
merged = dict(row)
|
||
merged.update(summary)
|
||
merged["previewFields"] = preview
|
||
st = (inst.status or "").strip()
|
||
if st in _FINISH_STATUSES and inst.update_time:
|
||
merged["finishTime"] = inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
else:
|
||
merged["finishTime"] = None
|
||
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 _dept_name(db: Session, dept_id: Optional[str], *, tenant_no: str) -> str:
|
||
did = (dept_id or "").strip()
|
||
if not did:
|
||
return ""
|
||
row = (
|
||
db.query(SysDept.dept_name)
|
||
.filter(SysDept.id == did, SysDept.tenant_no == tenant_no, SysDept.del_flag == 0)
|
||
.first()
|
||
)
|
||
if not row:
|
||
return ""
|
||
return (row[0] or "").strip()
|
||
|
||
|
||
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": "指定成员",
|
||
"posts": "职位",
|
||
"dept_leader": "发起人部门负责人",
|
||
"direct_supervisor": "发起人的主管",
|
||
"initiator_select": "发起人自选",
|
||
}
|
||
|
||
|
||
def _post_name_map(db: Session, tenant_no: str, post_ids: List[str]) -> List[str]:
|
||
names: List[str] = []
|
||
ids = [str(x).strip() for x in (post_ids or []) if str(x).strip()]
|
||
if not ids:
|
||
return names
|
||
rows = (
|
||
db.query(SysPost)
|
||
.filter(SysPost.tenant_no == tenant_no, SysPost.id.in_(ids), SysPost.del_flag == 0)
|
||
.all()
|
||
)
|
||
by_id = {r.id: (r.post_name or r.post_code or r.id) for r in rows}
|
||
for pid in ids:
|
||
label = by_id.get(pid)
|
||
if label and 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 == "posts":
|
||
names = _post_name_map(
|
||
db,
|
||
tenant_no,
|
||
cfg.get("postIds") or cfg.get("post_ids") or [],
|
||
)
|
||
if names:
|
||
return f"{base} · {'/'.join(names)}"
|
||
return base
|
||
|
||
|
||
def _assignee_meta_for_cc(
|
||
db: Session, tenant_no: str, cfg: Dict[str, Any], types: List[str]
|
||
) -> str:
|
||
if not types:
|
||
return "未配置抄送人"
|
||
if len(types) == 1:
|
||
return _assignee_meta(db, tenant_no, {**cfg, "assigneeType": types[0]})
|
||
labels = [_ASSIGNEE_TYPE_LABEL.get(t, t) for t in types]
|
||
return " + ".join(labels)
|
||
|
||
|
||
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
|
||
|
||
|
||
_FLOW_PREVIEW_PENDING_MSG = "必填信息填写后,流程将自动显示"
|
||
_FLOW_PREVIEW_UNMATCHED_MSG = "流程条件无法匹配,请检查表单或联系管理员"
|
||
_FORMULA_FIELD_RE = re.compile(r"\{([^{}]+)\}")
|
||
|
||
|
||
def _form_value_present(form_data: Optional[Dict[str, Any]], field: str) -> bool:
|
||
"""条件字段是否已有有效值(0 / false 视为已填)。"""
|
||
if not field:
|
||
return True
|
||
data = form_data or {}
|
||
if field not in data:
|
||
return False
|
||
val = data.get(field)
|
||
if val is None:
|
||
return False
|
||
if isinstance(val, str) and not val.strip():
|
||
return False
|
||
if isinstance(val, (list, tuple, set, dict)) and len(val) == 0:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _collect_condition_fields(node: Optional[Dict[str, Any]]) -> List[str]:
|
||
"""收集流程树中非默认条件分支引用的表单字段(去重保序)。
|
||
|
||
系统伪字段 ``__initiator__`` 不依赖 formData,不纳入预览门闩。
|
||
"""
|
||
fields: List[str] = []
|
||
seen = set()
|
||
|
||
def add(name: Any) -> None:
|
||
key = str(name or "").strip()
|
||
if not key or key in seen:
|
||
return
|
||
if key == INITIATOR_CONDITION_FIELD:
|
||
return
|
||
seen.add(key)
|
||
fields.append(key)
|
||
|
||
def walk(n: Any) -> None:
|
||
if not isinstance(n, dict):
|
||
return
|
||
if _node_type(n) == "condition":
|
||
for g in n.get("conditionGroups") or []:
|
||
if not isinstance(g, dict):
|
||
continue
|
||
rules = g.get("rules")
|
||
when = g.get("when")
|
||
if g.get("isDefault") or (not rules and not when):
|
||
child = g.get("child")
|
||
if isinstance(child, dict):
|
||
walk(child)
|
||
continue
|
||
if isinstance(rules, list) and rules:
|
||
for r in rules:
|
||
if isinstance(r, dict) and r.get("field") is not None:
|
||
add(r.get("field"))
|
||
elif isinstance(rules, str) and rules.strip():
|
||
for m in _FORMULA_FIELD_RE.findall(rules):
|
||
add(m)
|
||
elif when:
|
||
for m in _FORMULA_FIELD_RE.findall(str(when)):
|
||
add(m)
|
||
child = g.get("child")
|
||
if isinstance(child, dict):
|
||
walk(child)
|
||
join_child = n.get("child")
|
||
if isinstance(join_child, dict):
|
||
walk(join_child)
|
||
return
|
||
child = n.get("child")
|
||
if isinstance(child, dict):
|
||
walk(child)
|
||
for g in n.get("conditionGroups") or []:
|
||
if isinstance(g, dict) and isinstance(g.get("child"), dict):
|
||
walk(g.get("child"))
|
||
|
||
walk(node)
|
||
return fields
|
||
|
||
|
||
def _condition_fields_ready(
|
||
root: Optional[Dict[str, Any]], form_data: Optional[Dict[str, Any]]
|
||
) -> bool:
|
||
fields = _collect_condition_fields(root)
|
||
if not fields:
|
||
return True
|
||
data = form_data or {}
|
||
return all(_form_value_present(data, f) for f in fields)
|
||
|
||
|
||
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_by_node: Optional[Dict[str, List[str]]],
|
||
form_data: Optional[Dict[str, Any]],
|
||
steps: List[Dict[str, Any]],
|
||
flow_root: Optional[Dict[str, Any]] = None,
|
||
) -> bool:
|
||
"""沿最终路径写入 steps。条件无法匹配时返回 False。"""
|
||
cur = node
|
||
data = form_data or {}
|
||
tree_root = (
|
||
flow_root if isinstance(flow_root, dict) else (node if isinstance(node, dict) else {})
|
||
)
|
||
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":
|
||
end_id = (cur.get("id") or "").strip()
|
||
# 分支叶子 end 后若有汇合主链,不写入「结束」(与引擎推进一致)
|
||
if end_id and _merge_after_branch_end(tree_root, end_id) is not None:
|
||
break
|
||
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 []
|
||
g = _match_condition_group(
|
||
groups, data, initiator_user_id=initiator_user_id
|
||
)
|
||
if not g:
|
||
return False
|
||
child = g.get("child")
|
||
if isinstance(child, dict):
|
||
ok = _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_by_node=initiator_select_by_node,
|
||
form_data=data,
|
||
steps=steps,
|
||
flow_root=tree_root,
|
||
)
|
||
if not ok:
|
||
return False
|
||
cur = cur.get("child")
|
||
continue
|
||
|
||
if ntype in ("approval", "cc"):
|
||
cfg = cur.get("approverConfig") or {}
|
||
sign_mode = (cfg.get("signMode") or cfg.get("sign_mode") or "").strip()
|
||
people: List[Dict[str, str]] = []
|
||
resolve_error = ""
|
||
node_select_ids = _ids_for_initiator_select_node(
|
||
initiator_select_by_node, cur.get("id") or ""
|
||
)
|
||
if ntype == "cc":
|
||
types = normalize_cc_assignee_types(cfg)
|
||
if "initiator_select" in types:
|
||
display_type = "initiator_select"
|
||
elif len(types) == 1:
|
||
display_type = types[0]
|
||
elif types:
|
||
display_type = "multi"
|
||
else:
|
||
display_type = ""
|
||
sign_mode = "cc"
|
||
meta = _assignee_meta_for_cc(db, tenant_no, cfg, types)
|
||
assignees, _meta = resolve_cc_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=node_select_ids,
|
||
)
|
||
people = _preview_people(db, assignees)
|
||
steps.append(
|
||
{
|
||
"id": cur.get("id") or "",
|
||
"type": ntype,
|
||
"name": cur.get("name") or "抄送人",
|
||
"signMode": sign_mode,
|
||
"assigneeType": display_type,
|
||
"assigneeTypes": types,
|
||
"meta": meta,
|
||
"people": people,
|
||
"resolveError": resolve_error,
|
||
}
|
||
)
|
||
else:
|
||
assignee_type = (
|
||
cfg.get("assigneeType") or cfg.get("assignee_type") or ""
|
||
).strip()
|
||
if not sign_mode and assignee_type != "initiator_select":
|
||
sign_mode = "or"
|
||
meta = _assignee_meta(db, tenant_no, cfg)
|
||
if assignee_type == "initiator_select":
|
||
people = _preview_people(
|
||
db, [str(x) for x in node_select_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=node_select_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 "审批人",
|
||
"signMode": sign_mode,
|
||
"assigneeType": assignee_type,
|
||
"meta": meta,
|
||
"people": people,
|
||
"resolveError": resolve_error,
|
||
}
|
||
)
|
||
cur = cur.get("child")
|
||
continue
|
||
|
||
cur = cur.get("child")
|
||
return True
|
||
|
||
|
||
def preview_start_flow(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
app_id: str,
|
||
initiator_user_id: str,
|
||
initiator_dept_id: Optional[str] = None,
|
||
initiator_select_by_node: Optional[Dict[str, List[str]]] = None,
|
||
form_data: Optional[Dict[str, Any]] = 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 {}
|
||
|
||
data = dict(form_data or {})
|
||
if not _condition_fields_ready(root, data):
|
||
return {
|
||
"resolved": False,
|
||
"message": _FLOW_PREVIEW_PENDING_MSG,
|
||
"steps": [],
|
||
}
|
||
|
||
steps: List[Dict[str, Any]] = []
|
||
select_by_node = _normalize_initiator_select_by_node(initiator_select_by_node)
|
||
ok = _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_by_node=select_by_node,
|
||
form_data=data,
|
||
steps=steps,
|
||
flow_root=root,
|
||
)
|
||
if not ok:
|
||
return {
|
||
"resolved": False,
|
||
"message": _FLOW_PREVIEW_UNMATCHED_MSG,
|
||
"steps": [],
|
||
}
|
||
return {"resolved": True, "message": "", "steps": steps}
|
||
|
||
|
||
def _create_tasks_for_node(
|
||
db: Session,
|
||
*,
|
||
inst: OaInstance,
|
||
node: Dict[str, Any],
|
||
ver: OaAppVersion,
|
||
user_id: Optional[str],
|
||
initiator_select_by_node: Optional[Dict[str, 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 {}
|
||
node_select_ids = _ids_for_initiator_select_node(
|
||
initiator_select_by_node, node.get("id") or ""
|
||
)
|
||
advanced = _advanced(ver)
|
||
|
||
if ntype == "cc":
|
||
assignees, meta = resolve_cc_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=node_select_ids,
|
||
)
|
||
if not assignees:
|
||
return created
|
||
else:
|
||
assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
|
||
if assignee_type == "initiator_select" and not node_select_ids:
|
||
raise OaEngineError("发起人未选择审批人")
|
||
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=node_select_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,
|
||
is_read=0,
|
||
read_time=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 _eval_condition_rules(
|
||
rules: List[Any],
|
||
form_data: Dict[str, Any],
|
||
*,
|
||
initiator_user_id: Optional[str] = None,
|
||
) -> bool:
|
||
"""AND 多条 {field, op, value};contains 走人员/职位/部门集合交集,其余走公式。"""
|
||
from app.services.oa_dept_select import dept_contains_match
|
||
from app.services.oa_person_select import person_contains_match
|
||
from app.services.oa_post_select import post_contains_match
|
||
|
||
for r in rules:
|
||
if not isinstance(r, dict):
|
||
return False
|
||
field = r.get("field")
|
||
op = r.get("op") or "=="
|
||
val = r.get("value")
|
||
if field is None:
|
||
return False
|
||
if op == "contains":
|
||
if field == INITIATOR_CONDITION_FIELD:
|
||
uid = (initiator_user_id or "").strip()
|
||
if isinstance(val, list):
|
||
allowed = {str(x or "").strip() for x in val if str(x or "").strip()}
|
||
elif isinstance(val, str) and val.strip():
|
||
allowed = {val.strip()}
|
||
else:
|
||
allowed = set()
|
||
if not uid or uid not in allowed:
|
||
return False
|
||
continue
|
||
form_val = form_data.get(field)
|
||
if not (
|
||
person_contains_match(form_val, val)
|
||
or post_contains_match(form_val, val)
|
||
or dept_contains_match(form_val, val)
|
||
):
|
||
return False
|
||
continue
|
||
if isinstance(val, str):
|
||
expr = f"{{{field}}} {op} '{val}'"
|
||
else:
|
||
expr = f"{{{field}}} {op} {val}"
|
||
try:
|
||
if not eval_formula(expr, form_data):
|
||
return False
|
||
except FormulaError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _match_condition_group(
|
||
groups: List[Any],
|
||
form_data: Dict[str, Any],
|
||
*,
|
||
initiator_user_id: Optional[str] = None,
|
||
) -> 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
|
||
if when:
|
||
try:
|
||
if eval_formula(str(when), form_data):
|
||
return g
|
||
except FormulaError:
|
||
continue
|
||
continue
|
||
if isinstance(rules, str):
|
||
try:
|
||
if eval_formula(str(rules), form_data):
|
||
return g
|
||
except FormulaError:
|
||
continue
|
||
continue
|
||
if isinstance(rules, list) and rules:
|
||
try:
|
||
if _eval_condition_rules(
|
||
rules, form_data, initiator_user_id=initiator_user_id
|
||
):
|
||
return g
|
||
except FormulaError:
|
||
continue
|
||
continue
|
||
default_g = g
|
||
return default_g
|
||
|
||
|
||
def advance_from(
|
||
db: Session,
|
||
*,
|
||
inst: OaInstance,
|
||
ver: OaAppVersion,
|
||
from_node: Optional[Dict[str, Any]],
|
||
user_id: Optional[str],
|
||
initiator_select_by_node: Optional[Dict[str, List[str]]] = None,
|
||
) -> List[str]:
|
||
"""从某节点的下一跳开始推进;from_node 为 None 表示从 start 进入。
|
||
|
||
返回本次推进新创建的任务 id 列表(含抄送与嵌套自动通过产生的后续任务)。
|
||
"""
|
||
root = _flow_root(_loads(ver.flow_tree_json, {}))
|
||
form_data = _loads(inst.form_data_json, {}) or {}
|
||
created_ids: List[str] = []
|
||
|
||
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, initiator_user_id=inst.initiator_user_id
|
||
)
|
||
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":
|
||
merge = _merge_after_branch_end(root, cur.get("id") or "")
|
||
if merge is not None:
|
||
cur = merge
|
||
continue
|
||
inst.status = "completed"
|
||
inst.update_time = datetime.now()
|
||
db.add(inst)
|
||
try:
|
||
apply_confirmation_kit_user_sync(db, inst)
|
||
except Exception:
|
||
logger.exception(
|
||
"confirmation kit user sync failed instance=%s", getattr(inst, "id", None)
|
||
)
|
||
try:
|
||
apply_resignation_kit_user_sync(db, inst)
|
||
except Exception:
|
||
logger.exception(
|
||
"resignation kit user sync failed instance=%s", getattr(inst, "id", None)
|
||
)
|
||
return created_ids
|
||
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_by_node=initiator_select_by_node,
|
||
order_num_start=next_order,
|
||
)
|
||
created_ids.extend([t.id for t in tasks if getattr(t, "id", None)])
|
||
next_order += max(len(tasks), 1)
|
||
if ntype == "cc":
|
||
# 抄送不阻断,继续往下
|
||
cur = next_node(cur)
|
||
continue
|
||
if not tasks:
|
||
raise OaEngineError("审批节点未生成任务")
|
||
nested = _maybe_auto_pass_initiator(
|
||
db,
|
||
inst=inst,
|
||
ver=ver,
|
||
tasks=tasks,
|
||
user_id=user_id,
|
||
)
|
||
created_ids.extend(nested or [])
|
||
return created_ids
|
||
# 未知类型跳过子节点
|
||
cur = next_node(cur)
|
||
|
||
# 无结束也无待办:异常结束
|
||
if inst.status == "running":
|
||
raise OaEngineError("流程推进异常:未到达结束且无待办任务")
|
||
return created_ids
|
||
|
||
|
||
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_by_node: Optional[Dict[str, 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
|
||
|
||
from app.services.oa_leave_hours import apply_leave_kit_hours, validate_leave_kit_form_values
|
||
from app.services.oa_trip_days import apply_trip_kit_days, validate_trip_kit_form_values
|
||
from app.services.oa_overtime_hours import (
|
||
apply_overtime_kit_hours,
|
||
validate_overtime_kit_form_values,
|
||
)
|
||
from app.services.cn_holiday import is_rest_day as cn_is_rest_day
|
||
|
||
data = apply_leave_kit_hours(schema, data)
|
||
leave_errs = validate_leave_kit_form_values(schema, data)
|
||
if leave_errs:
|
||
raise OaEngineError(leave_errs[0])
|
||
|
||
data = apply_trip_kit_days(schema, data)
|
||
trip_errs = validate_trip_kit_form_values(schema, data)
|
||
if trip_errs:
|
||
raise OaEngineError(trip_errs[0])
|
||
|
||
def _ot_rest(ymd: str) -> bool:
|
||
from datetime import datetime as _dt
|
||
|
||
d = _dt.strptime(ymd, "%Y-%m-%d").date()
|
||
return cn_is_rest_day(db, d)
|
||
|
||
data = apply_overtime_kit_hours(schema, data, _ot_rest)
|
||
ot_errs = validate_overtime_kit_form_values(schema, data)
|
||
if ot_errs:
|
||
raise OaEngineError(ot_errs[0])
|
||
|
||
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
|
||
from app.services.oa_owner_fields import apply_owner_field_defaults
|
||
|
||
data = apply_owner_field_defaults(
|
||
schema,
|
||
data,
|
||
user_id=initiator_user_id,
|
||
user_name=(user.nick_name if user else "") or "",
|
||
dept_id=dept_id,
|
||
dept_name=_dept_name(db, dept_id, tenant_no=tenant_no) if dept_id else None,
|
||
)
|
||
|
||
from app.services.oa_dept_select import validate_dept_form_values
|
||
from app.services.oa_money_field import validate_money_form_values
|
||
from app.services.oa_person_select import validate_person_form_values
|
||
from app.services.oa_post_select import validate_post_form_values
|
||
from app.services.oa_receive_account import validate_receive_account_form_values
|
||
|
||
dept_errs = validate_dept_form_values(schema, data)
|
||
if dept_errs:
|
||
raise OaEngineError(dept_errs[0])
|
||
|
||
money_errs = validate_money_form_values(schema, data)
|
||
if money_errs:
|
||
raise OaEngineError(money_errs[0])
|
||
|
||
person_errs = validate_person_form_values(schema, data)
|
||
if person_errs:
|
||
raise OaEngineError(person_errs[0])
|
||
|
||
post_errs = validate_post_form_values(schema, data)
|
||
if post_errs:
|
||
raise OaEngineError(post_errs[0])
|
||
|
||
recv_errs = validate_receive_account_form_values(schema, data)
|
||
if recv_errs:
|
||
raise OaEngineError(recv_errs[0])
|
||
|
||
now = datetime.now()
|
||
title_text = (title or app.app_name or "").strip()
|
||
normalized_select = _normalize_initiator_select_by_node(initiator_select_by_node)
|
||
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),
|
||
initiator_select_json=_dumps(normalized_select),
|
||
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)
|
||
|
||
from app.services.oa_related_approval import (
|
||
assert_linkable_instance,
|
||
merge_relation_targets,
|
||
)
|
||
|
||
targets = merge_relation_targets(schema, data, related_instance_ids)
|
||
for field_key, to_id in targets:
|
||
target = (
|
||
db.query(OaInstance)
|
||
.filter(
|
||
OaInstance.id == to_id,
|
||
OaInstance.tenant_no == tenant_no,
|
||
OaInstance.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
assert_linkable_instance(
|
||
target, user_id=initiator_user_id, tenant_no=tenant_no, db=db
|
||
)
|
||
db.add(
|
||
OaRelation(
|
||
id=generate_uuid(),
|
||
from_instance_id=inst.id,
|
||
to_instance_id=to_id,
|
||
field_key=field_key,
|
||
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,
|
||
)
|
||
created = advance_from(
|
||
db,
|
||
inst=inst,
|
||
ver=ver,
|
||
from_node=None,
|
||
user_id=initiator_user_id,
|
||
initiator_select_by_node=normalized_select,
|
||
)
|
||
db.commit()
|
||
from app.services.oa_wechat_notify import schedule_oa_task_notifies
|
||
|
||
schedule_oa_task_notifies(created or [])
|
||
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, viewer_user_id: 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["initiatorDeptName"] = _dept_name(
|
||
db, inst.initiator_dept_id, tenant_no=tenant_no
|
||
)
|
||
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,
|
||
db=db,
|
||
advanced=adv,
|
||
)
|
||
# 不再暴露旧单/重提单关联跳转
|
||
data["sourceInstanceId"] = ""
|
||
data["resubmittedInstanceId"] = ""
|
||
from app.services import oa_instance_comment as oa_comment
|
||
|
||
data["comments"] = oa_comment.list_comments(
|
||
db,
|
||
instance_id=instance_id,
|
||
tenant_no=tenant_no,
|
||
viewer_user_id=viewer_user_id or "",
|
||
)
|
||
return data
|
||
|
||
|
||
def _normalize_cc_read_status(raw: Optional[str]) -> str:
|
||
v = (raw or "").strip().lower() or "unread"
|
||
if v not in ("unread", "read", "all"):
|
||
raise OaEngineError("readStatus 仅支持 unread、read、all")
|
||
return v
|
||
|
||
|
||
def _apply_cc_task_read(
|
||
db: Session,
|
||
*,
|
||
task: OaTask,
|
||
user_id: str,
|
||
now: datetime,
|
||
) -> bool:
|
||
"""将抄送任务标为已读。返回是否发生了写入。幂等时返回 False。"""
|
||
if int(getattr(task, "is_read", 0) or 0) == 1:
|
||
return False
|
||
task.is_read = 1
|
||
task.read_time = now
|
||
task.update_by = user_id
|
||
task.update_time = now
|
||
db.add(task)
|
||
return True
|
||
|
||
|
||
def count_cc_unread(db: Session, *, tenant_no: str, user_id: str) -> int:
|
||
return (
|
||
db.query(OaTask)
|
||
.filter(
|
||
OaTask.tenant_no == tenant_no,
|
||
OaTask.del_flag == 0,
|
||
OaTask.assignee_user_id == user_id,
|
||
OaTask.task_type == "cc",
|
||
OaTask.is_read == 0,
|
||
)
|
||
.count()
|
||
)
|
||
|
||
|
||
def count_pending_tasks(db: Session, *, tenant_no: str, user_id: str) -> int:
|
||
return (
|
||
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",
|
||
)
|
||
.count()
|
||
)
|
||
|
||
|
||
def mark_cc_task_read(
|
||
db: Session, *, tenant_no: str, user_id: str, task_id: str
|
||
) -> 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 or task.task_type != "cc" or task.assignee_user_id != user_id:
|
||
raise OaEngineError("抄送任务不存在或无权操作")
|
||
now = datetime.now()
|
||
_apply_cc_task_read(db, task=task, user_id=user_id, now=now)
|
||
db.commit()
|
||
db.refresh(task)
|
||
return _task_to_dict(task)
|
||
|
||
|
||
def mark_cc_tasks_read_batch(
|
||
db: Session, *, tenant_no: str, user_id: str, ids: List[str]
|
||
) -> Dict[str, Any]:
|
||
id_list = [x.strip() for x in (ids or []) if (x or "").strip()]
|
||
if not id_list:
|
||
return {"updated": 0}
|
||
rows = (
|
||
db.query(OaTask)
|
||
.filter(
|
||
OaTask.tenant_no == tenant_no,
|
||
OaTask.del_flag == 0,
|
||
OaTask.assignee_user_id == user_id,
|
||
OaTask.task_type == "cc",
|
||
OaTask.id.in_(id_list),
|
||
)
|
||
.all()
|
||
)
|
||
now = datetime.now()
|
||
updated = 0
|
||
for t in rows:
|
||
if _apply_cc_task_read(db, task=t, user_id=user_id, now=now):
|
||
updated += 1
|
||
db.commit()
|
||
return {"updated": updated}
|
||
|
||
|
||
_INSTANCE_STATUS_FILTER_VALUES = frozenset(
|
||
{"running", "completed", "rejected", "returned", "cancelled"}
|
||
)
|
||
_FINISH_STATUSES = frozenset({"completed", "rejected", "cancelled"})
|
||
|
||
|
||
def _parse_day_start(s: Optional[str]) -> Optional[datetime]:
|
||
raw = (s or "").strip()
|
||
if not raw:
|
||
return None
|
||
try:
|
||
d = datetime.strptime(raw[:10], "%Y-%m-%d")
|
||
except ValueError:
|
||
return None
|
||
return d.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
|
||
def _parse_day_end(s: Optional[str]) -> Optional[datetime]:
|
||
raw = (s or "").strip()
|
||
if not raw:
|
||
return None
|
||
try:
|
||
d = datetime.strptime(raw[:10], "%Y-%m-%d")
|
||
except ValueError:
|
||
return None
|
||
return d.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||
|
||
|
||
def _apply_instance_list_filters(
|
||
q,
|
||
*,
|
||
app_name: Optional[str] = None,
|
||
initiator_name: Optional[str] = None,
|
||
keyword: Optional[str] = None,
|
||
apply_time_from: Optional[str] = None,
|
||
apply_time_to: Optional[str] = None,
|
||
instance_status: Optional[str] = None,
|
||
joined_app: bool = False,
|
||
joined_initiator: bool = False,
|
||
):
|
||
"""在已包含 OaInstance 的 query 上叠加列表筛选。返回 (query, joined_app, joined_initiator)。"""
|
||
combined = (keyword or "").strip()
|
||
if combined:
|
||
if not joined_app:
|
||
q = q.outerjoin(
|
||
OaApp,
|
||
(OaApp.id == OaInstance.app_id) & (OaApp.del_flag == 0),
|
||
)
|
||
joined_app = True
|
||
if not joined_initiator:
|
||
q = q.outerjoin(
|
||
SysUser,
|
||
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
|
||
)
|
||
joined_initiator = True
|
||
like = f"%{combined}%"
|
||
q = q.filter(
|
||
or_(
|
||
OaApp.app_name.like(like),
|
||
OaInstance.title.like(like),
|
||
SysUser.nick_name.like(like),
|
||
)
|
||
)
|
||
else:
|
||
kw = (app_name or "").strip()
|
||
if kw:
|
||
if not joined_app:
|
||
q = q.outerjoin(
|
||
OaApp,
|
||
(OaApp.id == OaInstance.app_id) & (OaApp.del_flag == 0),
|
||
)
|
||
joined_app = True
|
||
like = f"%{kw}%"
|
||
q = q.filter(or_(OaApp.app_name.like(like), OaInstance.title.like(like)))
|
||
|
||
iname = (initiator_name or "").strip()
|
||
if iname:
|
||
if not joined_initiator:
|
||
q = q.outerjoin(
|
||
SysUser,
|
||
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
|
||
)
|
||
joined_initiator = True
|
||
q = q.filter(SysUser.nick_name.like(f"%{iname}%"))
|
||
|
||
t0 = _parse_day_start(apply_time_from)
|
||
if t0 is not None:
|
||
q = q.filter(OaInstance.create_time >= t0)
|
||
t1 = _parse_day_end(apply_time_to)
|
||
if t1 is not None:
|
||
q = q.filter(OaInstance.create_time <= t1)
|
||
|
||
st = (instance_status or "").strip()
|
||
if st in _INSTANCE_STATUS_FILTER_VALUES:
|
||
q = q.filter(OaInstance.status == st)
|
||
|
||
return q, joined_app, joined_initiator
|
||
|
||
|
||
def list_instance_history(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: str,
|
||
can_view_all: bool,
|
||
app_id: str,
|
||
instance_id: Optional[str] = None,
|
||
initiator_name: Optional[str] = None,
|
||
apply_time_from: Optional[str] = None,
|
||
apply_time_to: Optional[str] = None,
|
||
finish_time_from: Optional[str] = None,
|
||
finish_time_to: Optional[str] = None,
|
||
instance_status: Optional[str] = None,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> Dict[str, Any]:
|
||
app_id = (app_id or "").strip()
|
||
if not app_id:
|
||
raise OaEngineError("appId 不能为空")
|
||
page = max(1, int(page or 1))
|
||
page_size = min(100, max(1, int(page_size or 20)))
|
||
offset = (page - 1) * page_size
|
||
|
||
q = db.query(OaInstance).filter(
|
||
OaInstance.tenant_no == tenant_no,
|
||
OaInstance.del_flag == 0,
|
||
OaInstance.app_id == app_id,
|
||
)
|
||
if not can_view_all:
|
||
q = q.filter(OaInstance.initiator_user_id == user_id)
|
||
initiator_name = None
|
||
|
||
iid = (instance_id or "").strip()
|
||
if iid:
|
||
q = q.filter(OaInstance.id.like(f"%{iid}%"))
|
||
|
||
q, _, _ = _apply_instance_list_filters(
|
||
q,
|
||
initiator_name=initiator_name,
|
||
apply_time_from=apply_time_from,
|
||
apply_time_to=apply_time_to,
|
||
instance_status=instance_status,
|
||
)
|
||
|
||
ft0 = _parse_day_start(finish_time_from)
|
||
ft1 = _parse_day_end(finish_time_to)
|
||
if ft0 is not None or ft1 is not None:
|
||
q = q.filter(OaInstance.status.in_(tuple(_FINISH_STATUSES)))
|
||
if ft0 is not None:
|
||
q = q.filter(OaInstance.update_time >= ft0)
|
||
if ft1 is not None:
|
||
q = q.filter(OaInstance.update_time <= ft1)
|
||
|
||
total = q.count()
|
||
insts = (
|
||
q.order_by(OaInstance.create_time.desc())
|
||
.offset(offset)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
rows = [_instance_to_dict(i) for i in insts]
|
||
return {
|
||
"list": _enrich_list_rows(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
rows=rows,
|
||
instances=insts,
|
||
preview_limit=6,
|
||
),
|
||
"total": int(total),
|
||
"canViewAll": bool(can_view_all),
|
||
}
|
||
|
||
|
||
_HISTORY_EXPORT_MAX_ROWS = 5000
|
||
|
||
_EXPORT_RESULT_TEXT = {
|
||
"completed": "审批通过",
|
||
"approved": "审批通过",
|
||
"rejected": "审批拒绝",
|
||
"returned": "已打回",
|
||
"cancelled": "已撤销",
|
||
"running": "",
|
||
"pending": "",
|
||
}
|
||
|
||
_EXPORT_STATUS_TEXT = {
|
||
"completed": "已结束",
|
||
"approved": "已结束",
|
||
"rejected": "已结束",
|
||
"cancelled": "已结束",
|
||
"returned": "已打回",
|
||
"running": "进行中",
|
||
"pending": "待处理",
|
||
}
|
||
|
||
_EXPORT_ACTION_TEXT = {
|
||
"submitted": "提交申请",
|
||
"approved": "同意",
|
||
"rejected": "拒绝",
|
||
"returned": "打回",
|
||
"completed": "同意",
|
||
"pending": "待处理",
|
||
"cancelled": "取消",
|
||
"transferred": "转交",
|
||
"done": "抄送",
|
||
}
|
||
|
||
_FIXED_HISTORY_EXPORT_COLUMNS: List[Tuple[str, str]] = [
|
||
("instanceNo", "审批编号"),
|
||
("createTime", "创建时间"),
|
||
("initiatorName", "创建人"),
|
||
("currentAssignee", "当前负责人"),
|
||
("approvalResult", "审批结果"),
|
||
("approvalStatus", "审批状态"),
|
||
("updateTime", "更新时间"),
|
||
("finishTime", "完成时间"),
|
||
("initiatorDept", "创建人部门"),
|
||
("title", "审批单标题"),
|
||
("historyApprovers", "历史审批人"),
|
||
("duration", "耗时(时:分:秒)"),
|
||
("approvalRecord", "审批记录"),
|
||
]
|
||
|
||
|
||
def _format_export_duration(start: Optional[datetime], end: Optional[datetime]) -> str:
|
||
if not start or not end:
|
||
return ""
|
||
delta = end - start
|
||
if delta.total_seconds() < 0:
|
||
return ""
|
||
total = int(delta.total_seconds())
|
||
h, rem = divmod(total, 3600)
|
||
m, s = divmod(rem, 60)
|
||
return f"{h}:{m}:{s}"
|
||
|
||
|
||
def _export_upload_cell_value(field: Dict[str, Any]) -> str:
|
||
files = field.get("files") or []
|
||
if not isinstance(files, list):
|
||
return str(field.get("value") or "")
|
||
urls: List[str] = []
|
||
for f in files:
|
||
if not isinstance(f, dict):
|
||
continue
|
||
u = str(f.get("url") or "").strip()
|
||
if u:
|
||
urls.append(u)
|
||
if urls:
|
||
return ",".join(urls)
|
||
return str(field.get("value") or "")
|
||
|
||
|
||
def _export_progress_action(status: str, *, task_type: str = "") -> str:
|
||
st = (status or "").strip()
|
||
if (task_type or "").strip() == "cc" or st == "done":
|
||
return "抄送"
|
||
return _EXPORT_ACTION_TEXT.get(st, st or "")
|
||
|
||
|
||
def _format_export_progress_record(progress: List[Dict[str, Any]]) -> str:
|
||
lines: List[str] = []
|
||
for item in progress or []:
|
||
kind = (item.get("kind") or "").strip()
|
||
title = (item.get("title") or "").strip()
|
||
ts = (item.get("timestamp") or "").strip()
|
||
task_type = (item.get("taskType") or "").strip()
|
||
if kind == "submit":
|
||
name = (item.get("assigneeUserName") or "").strip()
|
||
if not name:
|
||
continue
|
||
lines.append(f"{name}|提交申请|{ts}|提交申请")
|
||
continue
|
||
people = item.get("people") or []
|
||
if isinstance(people, list) and people:
|
||
for p in people:
|
||
if not isinstance(p, dict):
|
||
continue
|
||
name = (p.get("nickName") or p.get("assigneeUserName") or "").strip()
|
||
if not name:
|
||
continue
|
||
pst = (p.get("status") or "").strip()
|
||
if pst in ("pending", "running", "cancelled") and task_type != "cc":
|
||
continue
|
||
node = title or (item.get("nodeKey") or "").strip() or "-"
|
||
pts = (p.get("timestamp") or ts or "").strip()
|
||
action = _export_progress_action(pst, task_type=task_type)
|
||
comment = (p.get("commentText") or "").strip()
|
||
seg = f"{name}|{node}|{pts}|{action}"
|
||
if comment:
|
||
seg += f"|{comment}"
|
||
lines.append(seg)
|
||
continue
|
||
name = (item.get("assigneeUserName") or "").strip()
|
||
if not name:
|
||
continue
|
||
st = (item.get("status") or "").strip()
|
||
if st in ("pending", "running", "cancelled") and task_type != "cc":
|
||
continue
|
||
node = title or (item.get("nodeKey") or "").strip() or "-"
|
||
action = _export_progress_action(st, task_type=task_type)
|
||
comment = (item.get("commentText") or "").strip()
|
||
seg = f"{name}|{node}|{ts}|{action}"
|
||
if comment:
|
||
seg += f"|{comment}"
|
||
lines.append(seg)
|
||
return ";\r\n".join(lines)
|
||
|
||
|
||
def _collect_history_approver_names(progress: List[Dict[str, Any]]) -> str:
|
||
names: List[str] = []
|
||
seen = set()
|
||
for item in progress or []:
|
||
if (item.get("kind") or "").strip() == "submit":
|
||
continue
|
||
people = item.get("people") or []
|
||
if isinstance(people, list) and people:
|
||
for p in people:
|
||
if not isinstance(p, dict):
|
||
continue
|
||
st = (p.get("status") or "").strip()
|
||
if st not in ("approved", "completed", "rejected", "returned", "transferred"):
|
||
continue
|
||
name = (p.get("nickName") or p.get("assigneeUserName") or "").strip()
|
||
if name and name not in seen:
|
||
seen.add(name)
|
||
names.append(name)
|
||
continue
|
||
st = (item.get("status") or "").strip()
|
||
if st not in ("approved", "completed", "rejected", "returned", "transferred"):
|
||
continue
|
||
name = (item.get("assigneeUserName") or "").strip()
|
||
if name and name not in seen:
|
||
seen.add(name)
|
||
names.append(name)
|
||
return ",".join(names)
|
||
|
||
|
||
def _build_history_export_business_columns(schema: Any) -> List[Dict[str, Any]]:
|
||
from app.services.oa_related_approval import LAYOUT_SKIP, _schema_rules
|
||
|
||
cols: List[Dict[str, Any]] = []
|
||
for rule in _schema_rules(schema):
|
||
typ = str(rule.get("type") or "")
|
||
if not typ or typ in LAYOUT_SKIP:
|
||
continue
|
||
field = str(rule.get("field") or "").strip()
|
||
if not field:
|
||
continue
|
||
label = str(rule.get("title") or rule.get("label") or field)
|
||
if typ == "relatedApproval":
|
||
cols.append(
|
||
{
|
||
"key": field,
|
||
"label": label,
|
||
"kind": "related",
|
||
"subLabel": "关联标题",
|
||
}
|
||
)
|
||
else:
|
||
cols.append({"key": field, "label": label, "kind": "business"})
|
||
return cols
|
||
|
||
|
||
def _merge_history_export_business_columns(col_lists: List[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
||
merged: List[Dict[str, Any]] = []
|
||
seen = set()
|
||
for cols in col_lists:
|
||
for c in cols:
|
||
key = str(c.get("key") or "")
|
||
if not key or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
merged.append(dict(c))
|
||
return merged
|
||
|
||
|
||
def _history_export_query(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: str,
|
||
can_view_all: bool,
|
||
app_id: str,
|
||
instance_id: Optional[str] = None,
|
||
instance_ids: Optional[List[str]] = None,
|
||
initiator_name: Optional[str] = None,
|
||
apply_time_from: Optional[str] = None,
|
||
apply_time_to: Optional[str] = None,
|
||
finish_time_from: Optional[str] = None,
|
||
finish_time_to: Optional[str] = None,
|
||
instance_status: Optional[str] = None,
|
||
):
|
||
q = db.query(OaInstance).filter(
|
||
OaInstance.tenant_no == tenant_no,
|
||
OaInstance.del_flag == 0,
|
||
OaInstance.app_id == app_id,
|
||
)
|
||
if not can_view_all:
|
||
q = q.filter(OaInstance.initiator_user_id == user_id)
|
||
initiator_name = None
|
||
|
||
clean_ids = [str(x).strip() for x in (instance_ids or []) if str(x).strip()]
|
||
if clean_ids:
|
||
q = q.filter(OaInstance.id.in_(clean_ids))
|
||
else:
|
||
iid = (instance_id or "").strip()
|
||
if iid:
|
||
q = q.filter(OaInstance.id.like(f"%{iid}%"))
|
||
|
||
q, _, _ = _apply_instance_list_filters(
|
||
q,
|
||
initiator_name=initiator_name,
|
||
apply_time_from=apply_time_from,
|
||
apply_time_to=apply_time_to,
|
||
instance_status=instance_status,
|
||
)
|
||
|
||
ft0 = _parse_day_start(finish_time_from)
|
||
ft1 = _parse_day_end(finish_time_to)
|
||
if ft0 is not None or ft1 is not None:
|
||
q = q.filter(OaInstance.status.in_(tuple(_FINISH_STATUSES)))
|
||
if ft0 is not None:
|
||
q = q.filter(OaInstance.update_time >= ft0)
|
||
if ft1 is not None:
|
||
q = q.filter(OaInstance.update_time <= ft1)
|
||
return q
|
||
|
||
|
||
def export_instance_history(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: str,
|
||
can_view_all: bool,
|
||
app_id: str,
|
||
instance_id: Optional[str] = None,
|
||
instance_ids: Optional[List[str]] = None,
|
||
initiator_name: Optional[str] = None,
|
||
apply_time_from: Optional[str] = None,
|
||
apply_time_to: Optional[str] = None,
|
||
finish_time_from: Optional[str] = None,
|
||
finish_time_to: Optional[str] = None,
|
||
instance_status: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
from app.services.oa_related_approval import flatten_form_fields_for_summary
|
||
|
||
app_id = (app_id or "").strip()
|
||
if not app_id:
|
||
raise OaEngineError("appId 不能为空")
|
||
|
||
app = oa_crud.get_app(db, app_id, tenant_no)
|
||
app_name = (app.app_name or "") if app else ""
|
||
|
||
q = _history_export_query(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
can_view_all=can_view_all,
|
||
app_id=app_id,
|
||
instance_id=instance_id,
|
||
instance_ids=instance_ids,
|
||
initiator_name=initiator_name,
|
||
apply_time_from=apply_time_from,
|
||
apply_time_to=apply_time_to,
|
||
finish_time_from=finish_time_from,
|
||
finish_time_to=finish_time_to,
|
||
instance_status=instance_status,
|
||
)
|
||
total = q.count()
|
||
if total > _HISTORY_EXPORT_MAX_ROWS:
|
||
raise OaEngineError(f"导出条数超过上限 {_HISTORY_EXPORT_MAX_ROWS},请缩小筛选范围")
|
||
insts = q.order_by(OaInstance.create_time.desc()).limit(_HISTORY_EXPORT_MAX_ROWS).all()
|
||
if not insts:
|
||
pub = oa_crud._find_latest_published(db, app_id, tenant_no)
|
||
schema = _loads(getattr(pub, "form_schema_json", None), {}) if pub else {}
|
||
business_columns = _build_history_export_business_columns(schema)
|
||
return {
|
||
"appName": app_name,
|
||
"businessColumns": business_columns,
|
||
"fixedColumns": [{"key": k, "label": lb} for k, lb in _FIXED_HISTORY_EXPORT_COLUMNS],
|
||
"rows": [],
|
||
"total": 0,
|
||
}
|
||
|
||
ver_by_id: Dict[str, OaAppVersion] = {}
|
||
ver_ids = list({i.app_version_id for i in insts if i.app_version_id})
|
||
if ver_ids:
|
||
for v in (
|
||
db.query(OaAppVersion)
|
||
.filter(OaAppVersion.id.in_(ver_ids), OaAppVersion.del_flag == 0)
|
||
.all()
|
||
):
|
||
ver_by_id[v.id] = v
|
||
|
||
pub = oa_crud._find_latest_published(db, app_id, tenant_no)
|
||
pub_schema = _loads(getattr(pub, "form_schema_json", None), {}) if pub else {}
|
||
col_lists = [_build_history_export_business_columns(pub_schema)]
|
||
for inst in insts:
|
||
ver = ver_by_id.get(inst.app_version_id or "")
|
||
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
|
||
col_lists.append(_build_history_export_business_columns(schema))
|
||
business_columns = _merge_history_export_business_columns(col_lists)
|
||
|
||
inst_ids = [i.id for i in insts]
|
||
all_tasks = (
|
||
db.query(OaTask)
|
||
.filter(OaTask.instance_id.in_(inst_ids), OaTask.del_flag == 0)
|
||
.all()
|
||
)
|
||
tasks_by_inst: Dict[str, List[OaTask]] = {}
|
||
user_ids: List[str] = []
|
||
for t in all_tasks:
|
||
tasks_by_inst.setdefault(t.instance_id, []).append(t)
|
||
if t.assignee_user_id:
|
||
user_ids.append(t.assignee_user_id)
|
||
|
||
all_events = (
|
||
db.query(OaInstanceEvent)
|
||
.filter(OaInstanceEvent.instance_id.in_(inst_ids), OaInstanceEvent.del_flag == 0)
|
||
.order_by(OaInstanceEvent.create_time.asc())
|
||
.all()
|
||
)
|
||
events_by_inst: Dict[str, List[OaInstanceEvent]] = {}
|
||
for e in all_events:
|
||
events_by_inst.setdefault(e.instance_id, []).append(e)
|
||
if e.actor_user_id:
|
||
user_ids.append(e.actor_user_id)
|
||
|
||
for inst in insts:
|
||
if inst.initiator_user_id:
|
||
user_ids.append(inst.initiator_user_id)
|
||
|
||
name_map = _user_name_map(db, user_ids)
|
||
|
||
related_ids: List[str] = []
|
||
flat_by_inst: Dict[str, List[Dict[str, Any]]] = {}
|
||
for inst in insts:
|
||
ver = ver_by_id.get(inst.app_version_id or "")
|
||
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
|
||
form_data = _loads(inst.form_data_json, {}) or {}
|
||
flat = flatten_form_fields_for_summary(schema, form_data)
|
||
flat_by_inst[inst.id] = flat
|
||
for f in flat:
|
||
for rid in f.get("relatedIds") or []:
|
||
rid_s = str(rid or "").strip()
|
||
if rid_s:
|
||
related_ids.append(rid_s)
|
||
|
||
related_title_map: Dict[str, str] = {}
|
||
if related_ids:
|
||
rel_insts = (
|
||
db.query(OaInstance)
|
||
.filter(
|
||
OaInstance.tenant_no == tenant_no,
|
||
OaInstance.del_flag == 0,
|
||
OaInstance.id.in_(list(set(related_ids))),
|
||
)
|
||
.all()
|
||
)
|
||
rel_app_ids = list({r.app_id for r in rel_insts if r.app_id})
|
||
rel_app_map = {}
|
||
if rel_app_ids:
|
||
for a in db.query(OaApp).filter(OaApp.id.in_(rel_app_ids)).all():
|
||
rel_app_map[a.id] = a.app_name or ""
|
||
for r in rel_insts:
|
||
initiator = name_map.get(r.initiator_user_id or "", "")
|
||
related_title_map[r.id] = (
|
||
f"{initiator}提交的{rel_app_map.get(r.app_id, '')}" if initiator else (r.title or r.id)
|
||
)
|
||
|
||
pending_by_inst: Dict[str, List[OaTask]] = {}
|
||
for t in all_tasks:
|
||
if (t.task_type or "") == "approval" and (t.status or "") == "pending":
|
||
pending_by_inst.setdefault(t.instance_id, []).append(t)
|
||
|
||
rows_out: List[Dict[str, Any]] = []
|
||
for idx, inst in enumerate(insts, start=1):
|
||
ver = ver_by_id.get(inst.app_version_id or "")
|
||
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_by_inst.get(inst.id, []), flow_root=flow_root)
|
||
events = events_by_inst.get(inst.id, [])
|
||
adv = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
|
||
progress = build_progress_items(
|
||
inst=inst,
|
||
flow_root=flow_root,
|
||
tasks=tasks,
|
||
name_map=name_map,
|
||
events=events,
|
||
db=db,
|
||
advanced=adv,
|
||
)
|
||
summary = _instance_progress_summary(
|
||
inst,
|
||
app_name=app_name,
|
||
initiator_user_name=name_map.get(inst.initiator_user_id or "", ""),
|
||
pending_approval_tasks=pending_by_inst.get(inst.id, []),
|
||
name_map=name_map,
|
||
)
|
||
st = (inst.status or "").strip()
|
||
finish_time = (
|
||
inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
if st in _FINISH_STATUSES and inst.update_time
|
||
else ""
|
||
)
|
||
duration_end = inst.update_time if st in _FINISH_STATUSES else None
|
||
business: Dict[str, Any] = {}
|
||
flat_map = {str(f.get("fieldKey") or ""): f for f in flat_by_inst.get(inst.id, [])}
|
||
for col in business_columns:
|
||
key = str(col.get("key") or "")
|
||
field = flat_map.get(key) or {}
|
||
if col.get("kind") == "related":
|
||
ids = field.get("relatedIds") or []
|
||
business[key] = f"已关联 {len(ids)} 条" if ids else ""
|
||
titles = [related_title_map.get(str(i), str(i)) for i in ids]
|
||
business[f"{key}__titles"] = "、".join(t for t in titles if t)
|
||
elif field.get("fieldType") in ("upload", "fcUpload", "image", "oaAttachment"):
|
||
business[key] = _export_upload_cell_value(field)
|
||
else:
|
||
business[key] = str(field.get("value") or "")
|
||
|
||
rows_out.append(
|
||
{
|
||
"index": idx,
|
||
"id": inst.id,
|
||
"business": business,
|
||
"fixed": {
|
||
"instanceNo": inst.id,
|
||
"createTime": summary.get("applyTime") or "",
|
||
"initiatorName": summary.get("initiatorUserName") or "",
|
||
"currentAssignee": summary.get("currentApproverNames") or "-",
|
||
"approvalResult": _EXPORT_RESULT_TEXT.get(st, st),
|
||
"approvalStatus": _EXPORT_STATUS_TEXT.get(st, st),
|
||
"updateTime": inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
if inst.update_time
|
||
else "",
|
||
"finishTime": finish_time,
|
||
"initiatorDept": _dept_name(
|
||
db, inst.initiator_dept_id, tenant_no=tenant_no
|
||
),
|
||
"title": inst.title or "",
|
||
"historyApprovers": _collect_history_approver_names(progress),
|
||
"duration": _format_export_duration(inst.create_time, duration_end),
|
||
"approvalRecord": _format_export_progress_record(progress),
|
||
},
|
||
}
|
||
)
|
||
|
||
return {
|
||
"appName": app_name,
|
||
"businessColumns": business_columns,
|
||
"fixedColumns": [{"key": k, "label": lb} for k, lb in _FIXED_HISTORY_EXPORT_COLUMNS],
|
||
"rows": rows_out,
|
||
"total": len(rows_out),
|
||
}
|
||
|
||
|
||
def list_tasks(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: str,
|
||
kind: str,
|
||
read_status: Optional[str] = None,
|
||
app_name: Optional[str] = None,
|
||
initiator_name: Optional[str] = None,
|
||
keyword: Optional[str] = None,
|
||
apply_time_from: Optional[str] = None,
|
||
apply_time_to: Optional[str] = None,
|
||
instance_status: Optional[str] = None,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> Dict[str, Any]:
|
||
kind = (kind or "").strip()
|
||
page = max(1, int(page or 1))
|
||
page_size = min(100, max(1, int(page_size or 20)))
|
||
offset = (page - 1) * page_size
|
||
|
||
if kind == "mine":
|
||
q = db.query(OaInstance).filter(
|
||
OaInstance.tenant_no == tenant_no,
|
||
OaInstance.del_flag == 0,
|
||
OaInstance.initiator_user_id == user_id,
|
||
)
|
||
q, _, _ = _apply_instance_list_filters(
|
||
q,
|
||
app_name=app_name,
|
||
initiator_name=initiator_name,
|
||
keyword=keyword,
|
||
apply_time_from=apply_time_from,
|
||
apply_time_to=apply_time_to,
|
||
instance_status=instance_status,
|
||
)
|
||
total = q.count()
|
||
insts = (
|
||
q.order_by(OaInstance.create_time.desc())
|
||
.offset(offset)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
rows = [_instance_to_dict(i) for i in insts]
|
||
return {
|
||
"list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
|
||
"total": int(total),
|
||
}
|
||
|
||
if kind == "mention":
|
||
from app.services.oa_instance_comment import list_mentioned_instance_ids
|
||
|
||
ids = list_mentioned_instance_ids(db, tenant_no=tenant_no, user_id=user_id)
|
||
if not ids:
|
||
return {"list": [], "total": 0}
|
||
q = db.query(OaInstance).filter(
|
||
OaInstance.tenant_no == tenant_no,
|
||
OaInstance.del_flag == 0,
|
||
OaInstance.id.in_(ids),
|
||
)
|
||
q, _, _ = _apply_instance_list_filters(
|
||
q,
|
||
app_name=app_name,
|
||
initiator_name=initiator_name,
|
||
keyword=keyword,
|
||
apply_time_from=apply_time_from,
|
||
apply_time_to=apply_time_to,
|
||
instance_status=instance_status,
|
||
)
|
||
insts = q.all()
|
||
rank = {iid: i for i, iid in enumerate(ids)}
|
||
insts.sort(key=lambda inst: rank.get(inst.id, len(ids)))
|
||
total = len(insts)
|
||
insts = insts[offset : offset + page_size]
|
||
rows = [_instance_to_dict(i) for i in insts]
|
||
return {
|
||
"list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
|
||
"total": int(total),
|
||
}
|
||
|
||
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", "transferred")),
|
||
)
|
||
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",
|
||
)
|
||
rs = _normalize_cc_read_status(read_status)
|
||
if rs == "unread":
|
||
q = q.filter(OaTask.is_read == 0)
|
||
elif rs == "read":
|
||
q = q.filter(OaTask.is_read == 1)
|
||
# all: 不加 is_read 条件
|
||
else:
|
||
raise OaEngineError(f"不支持的列表类型: {kind}")
|
||
|
||
need_inst_join = any(
|
||
[
|
||
(keyword or "").strip(),
|
||
(app_name or "").strip(),
|
||
(initiator_name or "").strip(),
|
||
(apply_time_from or "").strip(),
|
||
(apply_time_to or "").strip(),
|
||
(instance_status or "").strip() in _INSTANCE_STATUS_FILTER_VALUES,
|
||
]
|
||
)
|
||
if need_inst_join:
|
||
q = q.join(
|
||
OaInstance,
|
||
(OaInstance.id == OaTask.instance_id)
|
||
& (OaInstance.tenant_no == tenant_no)
|
||
& (OaInstance.del_flag == 0),
|
||
)
|
||
q, _, _ = _apply_instance_list_filters(
|
||
q,
|
||
app_name=app_name,
|
||
initiator_name=initiator_name,
|
||
keyword=keyword,
|
||
apply_time_from=apply_time_from,
|
||
apply_time_to=apply_time_to,
|
||
instance_status=instance_status,
|
||
)
|
||
|
||
total = q.count()
|
||
rows_orm = (
|
||
q.order_by(OaTask.create_time.desc()).offset(offset).limit(page_size).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 {
|
||
"list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
|
||
"total": int(total),
|
||
}
|
||
|
||
|
||
def _approve_task_core(
|
||
db: Session,
|
||
*,
|
||
task: OaTask,
|
||
inst: OaInstance,
|
||
ver: OaAppVersion,
|
||
user_id: str,
|
||
comment: Optional[str],
|
||
) -> List[str]:
|
||
"""将 pending 审批任务置为 approved 并按或签/会签决定是否推进。
|
||
|
||
返回 advance_from 新创建的任务 id 列表;会签未齐、未推进时返回空列表。
|
||
调用方负责 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)
|
||
# Session 默认 autoflush=False:不先 flush 时,后续按 status=pending 的查询仍会命中本任务,
|
||
# 或签「取消同节点其余待办」会把本人已同意覆盖成 done。
|
||
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 []
|
||
|
||
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("找不到对应流程节点")
|
||
return advance_from(
|
||
db,
|
||
inst=inst,
|
||
ver=ver,
|
||
from_node=node,
|
||
user_id=user_id,
|
||
initiator_select_by_node=_load_initiator_select_by_node(inst),
|
||
)
|
||
|
||
|
||
def _maybe_auto_pass_initiator(
|
||
db: Session,
|
||
*,
|
||
inst: OaInstance,
|
||
ver: OaAppVersion,
|
||
tasks: List[OaTask],
|
||
user_id: Optional[str],
|
||
) -> List[str]:
|
||
_ = user_id
|
||
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 []
|
||
return _approve_task_core(
|
||
db,
|
||
task=self_task,
|
||
inst=inst,
|
||
ver=ver,
|
||
user_id=initiator,
|
||
comment=_AUTO_PASS_COMMENT,
|
||
)
|
||
|
||
|
||
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("请填写审批意见")
|
||
|
||
if action == "reject":
|
||
now = datetime.now()
|
||
task.comment_text = (comment or "").strip() or None
|
||
task.status = "rejected"
|
||
task.update_by = user_id
|
||
task.update_time = now
|
||
db.add(task)
|
||
db.flush()
|
||
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()
|
||
from app.services.oa_wechat_notify import schedule_oa_applicant_progress
|
||
|
||
schedule_oa_applicant_progress(
|
||
instance_id=inst.id,
|
||
actor_user_id=user_id,
|
||
action="reject",
|
||
task_id=task.id,
|
||
)
|
||
return get_instance_detail(db, inst.id, tenant_no=tenant_no)
|
||
|
||
created = _approve_task_core(
|
||
db,
|
||
task=task,
|
||
inst=inst,
|
||
ver=ver,
|
||
user_id=user_id,
|
||
comment=comment,
|
||
)
|
||
db.commit()
|
||
from app.services.oa_wechat_notify import (
|
||
schedule_oa_applicant_progress,
|
||
schedule_oa_task_notifies,
|
||
)
|
||
|
||
schedule_oa_task_notifies(created or [])
|
||
schedule_oa_applicant_progress(
|
||
instance_id=inst.id,
|
||
actor_user_id=user_id,
|
||
action="approve",
|
||
task_id=task.id,
|
||
)
|
||
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_ids: List[str] = []
|
||
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}")
|
||
new_id = generate_uuid()
|
||
db.add(
|
||
OaTask(
|
||
id=new_id,
|
||
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_ids.append(new_id)
|
||
if not created_ids:
|
||
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()
|
||
from app.services.oa_wechat_notify import (
|
||
schedule_oa_applicant_progress,
|
||
schedule_oa_task_notifies,
|
||
)
|
||
|
||
schedule_oa_task_notifies(created_ids)
|
||
schedule_oa_applicant_progress(
|
||
instance_id=inst.id,
|
||
actor_user_id=user_id,
|
||
action="add_sign",
|
||
task_id=task.id,
|
||
)
|
||
return get_instance_detail(db, inst.id, tenant_no=tenant_no)
|
||
|
||
|
||
def transfer_task(
|
||
db: Session,
|
||
task_id: str,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: str,
|
||
target_user_id: str,
|
||
comment: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""当前待办处理人转交:结束本人待办并为同节点目标用户新建 pending 任务。"""
|
||
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("实例已结束")
|
||
|
||
target = (target_user_id or "").strip()
|
||
if not target:
|
||
raise OaEngineError("请选择转交人")
|
||
if target == user_id:
|
||
raise OaEngineError("不能转交给自己")
|
||
|
||
u = (
|
||
db.query(SysUser)
|
||
.filter(
|
||
SysUser.id == target,
|
||
SysUser.tenant_no == tenant_no,
|
||
SysUser.del_flag == 0,
|
||
SysUser.status == "1",
|
||
)
|
||
.first()
|
||
)
|
||
if not u:
|
||
raise OaEngineError(f"转交用户无效:{target}")
|
||
|
||
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()
|
||
}
|
||
if target in existing:
|
||
raise OaEngineError("该用户已在当前节点待办中")
|
||
|
||
now = datetime.now()
|
||
comment_text = (comment or "").strip() or None
|
||
round_no = int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1)
|
||
|
||
task.status = "transferred"
|
||
task.comment_text = comment_text
|
||
task.update_by = user_id
|
||
task.update_time = now
|
||
db.add(task)
|
||
|
||
next_order = _next_task_order_num(db, inst.id)
|
||
new_task_id = generate_uuid()
|
||
db.add(
|
||
OaTask(
|
||
id=new_task_id,
|
||
instance_id=inst.id,
|
||
node_key=task.node_key,
|
||
node_name=task.node_name,
|
||
task_type="approval",
|
||
assignee_user_id=target,
|
||
status="pending",
|
||
comment_text=None,
|
||
resolve_source="transfer",
|
||
sign_mode=task.sign_mode or "or",
|
||
round_no=round_no,
|
||
tenant_no=tenant_no,
|
||
order_num=next_order,
|
||
remark=comment_text,
|
||
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,
|
||
)
|
||
)
|
||
|
||
event_comment = comment_text or f"转交给 {target}"
|
||
_add_instance_event(
|
||
db,
|
||
inst=inst,
|
||
event_type="transfer",
|
||
actor_user_id=user_id,
|
||
round_no=round_no,
|
||
comment=event_comment,
|
||
task_id=task.id,
|
||
node_key=task.node_key,
|
||
)
|
||
|
||
db.commit()
|
||
from app.services.oa_wechat_notify import (
|
||
schedule_oa_applicant_progress,
|
||
schedule_oa_task_notifies,
|
||
)
|
||
|
||
schedule_oa_task_notifies([new_task_id])
|
||
schedule_oa_applicant_progress(
|
||
instance_id=inst.id,
|
||
actor_user_id=user_id,
|
||
action="transfer",
|
||
task_id=task.id,
|
||
)
|
||
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()
|
||
from app.services.oa_wechat_notify import schedule_oa_applicant_progress
|
||
|
||
schedule_oa_applicant_progress(
|
||
instance_id=inst.id,
|
||
actor_user_id=user_id,
|
||
action="return",
|
||
task_id=task.id,
|
||
)
|
||
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_by_node: Optional[Dict[str, List[str]]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""已废弃:同实例再次提交。请复制表单重新发起新实例。"""
|
||
_ = (db, instance_id, tenant_no, user_id, form_data, initiator_select_by_node)
|
||
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)
|