workflow.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. """工作流管理接口"""
  2. from typing import Any, Dict, List, Optional
  3. from fastapi import APIRouter, Depends, Query
  4. from pydantic import AliasChoices, BaseModel, ConfigDict, Field
  5. from sqlalchemy.orm import Session
  6. from app.core.data_scope import assert_row_in_data_scope
  7. from app.core.database import get_db
  8. from app.core.menu_guard import require_admin_or_super_admin
  9. from app.core.response import success_response, error_response
  10. from app.crud import workflow as wf_crud
  11. from app.models.models import SysUser, WfNode, WfProcessDef, WfProcessInstance, WfProcessVersion, WfTask
  12. from app.services.workflow_engine import WorkflowEngine
  13. router = APIRouter(prefix="/web/admin/workflow", tags=["工作流管理"])
  14. # ---------------------------------------------------------------------------
  15. # 业务类型 → 中文名称映射
  16. # ---------------------------------------------------------------------------
  17. _BUSINESS_TYPE_LABELS: Dict[str, str] = {
  18. "leave": "请假申请",
  19. }
  20. def _business_type_label(code: Optional[str]) -> str:
  21. if not code:
  22. return ""
  23. return _BUSINESS_TYPE_LABELS.get(code, code)
  24. def _portal_user_info(user: dict) -> dict:
  25. dept_id = (user.get("region_id") or user.get("dept_id") or "").strip()
  26. return {
  27. "user_id": (user.get("user_id") or user.get("id") or "").strip(),
  28. "role": (user.get("role") or "").strip(),
  29. "tenant_no": (user.get("tenant_no") or "").strip(),
  30. "dept_id": dept_id,
  31. "region_id": dept_id,
  32. }
  33. def _assert_wf_instance_in_scope(
  34. db: Session,
  35. instance: WfProcessInstance,
  36. user: dict,
  37. *,
  38. skip_scope: bool = False,
  39. ) -> None:
  40. if skip_scope:
  41. return
  42. tenant_no = user.get("tenant_no", "shanxia2.0")
  43. assert_row_in_data_scope(db, instance, WfProcessInstance, _portal_user_info(user), tenant_no)
  44. # ---------------------------------------------------------------------------
  45. # 业务表单字段提供者注册表
  46. # 新增业务类型时,只需添加一个 provider 函数并注册即可,前端无需改动
  47. # ---------------------------------------------------------------------------
  48. import logging
  49. _form_field_logger = logging.getLogger("workflow.form_fields")
  50. def _leave_form_fields(db: Session, business_id: str) -> List[Dict[str, str]]:
  51. from app.crud import leave as leave_crud
  52. item = leave_crud.get_leave(db, business_id)
  53. if not item:
  54. return []
  55. leave_type_map = {"personal": "事假", "sick": "病假", "annual": "年假", "other": "其他"}
  56. fields = [
  57. {"label": "请假类型", "value": leave_type_map.get(item.get("leaveType", ""), item.get("leaveType", ""))},
  58. {"label": "请假天数", "value": f"{item.get('days', 0)} 天" if item.get("days") else "--"},
  59. {"label": "开始日期", "value": item.get("startDate", "") or "--"},
  60. {"label": "结束日期", "value": item.get("endDate", "") or "--"},
  61. ]
  62. if item.get("reason"):
  63. fields.append({"label": "请假原因", "value": item["reason"]})
  64. if item.get("remark"):
  65. fields.append({"label": "备注", "value": item["remark"]})
  66. return fields
  67. _BUSINESS_FORM_FIELD_PROVIDERS: Dict[str, Any] = {
  68. "leave": _leave_form_fields,
  69. }
  70. def _get_business_form_fields(db: Session, business_type: Optional[str], business_id: Optional[str]) -> List[Dict[str, str]]:
  71. if not business_type or not business_id:
  72. return []
  73. provider = _BUSINESS_FORM_FIELD_PROVIDERS.get(business_type)
  74. if not provider:
  75. return []
  76. try:
  77. return provider(db, business_id)
  78. except Exception as e:
  79. _form_field_logger.warning("加载业务表单字段失败: type=%s id=%s err=%s", business_type, business_id, e)
  80. return []
  81. def _enrich_task_list(db: Session, items: List[Dict[str, Any]]) -> None:
  82. starter_ids = {it.get("starterUserId") for it in items if it.get("starterUserId")}
  83. user_name_map: Dict[str, str] = {}
  84. if starter_ids:
  85. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(starter_ids)).all()
  86. user_name_map = {r.id: r.nick_name for r in rows}
  87. for it in items:
  88. sid = it.get("starterUserId")
  89. it["starterUserName"] = user_name_map.get(sid, sid or "")
  90. it["businessTypeLabel"] = _business_type_label(it.get("businessType"))
  91. # ---------------------------------------------------------------------------
  92. # Pydantic request bodies
  93. # ---------------------------------------------------------------------------
  94. class ProcessDefCreateBody(BaseModel):
  95. model_config = ConfigDict(populate_by_name=True)
  96. processCode: str = Field(..., alias="processCode")
  97. processName: str = Field(..., alias="processName")
  98. status: Optional[str] = Field("1", alias="status")
  99. orderNum: Optional[int] = Field(0, alias="orderNum")
  100. remark: Optional[str] = None
  101. class ProcessDefUpdateBody(BaseModel):
  102. model_config = ConfigDict(populate_by_name=True)
  103. processCode: Optional[str] = Field(None, alias="processCode")
  104. processName: Optional[str] = Field(None, alias="processName")
  105. status: Optional[str] = Field(None, alias="status")
  106. orderNum: Optional[int] = Field(None, alias="orderNum")
  107. remark: Optional[str] = None
  108. class PublishVersionBody(BaseModel):
  109. model_config = ConfigDict(populate_by_name=True)
  110. graphJson: Optional[str] = Field(None, alias="graphJson")
  111. nodes: List[Dict[str, Any]] = Field(default_factory=list, alias="nodes")
  112. transitions: List[Dict[str, Any]] = Field(default_factory=list, alias="transitions")
  113. remark: Optional[str] = None
  114. class StartInstanceBody(BaseModel):
  115. model_config = ConfigDict(populate_by_name=True)
  116. processCode: str = Field(..., alias="processCode")
  117. businessType: Optional[str] = Field(None, alias="businessType")
  118. businessId: Optional[str] = Field(None, alias="businessId")
  119. variables: Optional[Dict[str, Any]] = None
  120. callbackUrl: Optional[str] = Field(None, alias="callbackUrl")
  121. class TaskActionBody(BaseModel):
  122. """审批意见:前端传 commentText,兼容 comment。"""
  123. model_config = ConfigDict(populate_by_name=True)
  124. comment: Optional[str] = Field(
  125. default=None,
  126. validation_alias=AliasChoices("comment", "commentText"),
  127. )
  128. # ---------------------------------------------------------------------------
  129. # 流程定义 CRUD
  130. # ---------------------------------------------------------------------------
  131. @router.get("/defs")
  132. def list_process_defs(
  133. page: int = Query(1, ge=1),
  134. pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
  135. keyword: Optional[str] = None,
  136. status: Optional[str] = None,
  137. _user: dict = Depends(require_admin_or_super_admin),
  138. db: Session = Depends(get_db),
  139. ):
  140. tenant_no = _user.get("tenant_no", "shanxia2.0")
  141. items, total = wf_crud.list_process_defs(
  142. db, page=page, page_size=pageSize, keyword=keyword, status=status, tenant_no=tenant_no, user_info=_user
  143. )
  144. tp = wf_crud.total_pages(total, pageSize)
  145. return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
  146. @router.post("/defs")
  147. def create_process_def(
  148. body: ProcessDefCreateBody,
  149. user: dict = Depends(require_admin_or_super_admin),
  150. db: Session = Depends(get_db),
  151. ):
  152. try:
  153. tenant_no = user.get("tenant_no", "shanxia2.0")
  154. obj = wf_crud.create_process_def(
  155. db,
  156. process_code=body.processCode,
  157. process_name=body.processName,
  158. tenant_no=tenant_no,
  159. status=body.status,
  160. order_num=body.orderNum,
  161. remark=body.remark,
  162. user_id=user.get("user_id"),
  163. )
  164. return success_response(wf_crud._row_to_dict(obj), message="创建成功")
  165. except ValueError as e:
  166. return error_response(400, str(e))
  167. @router.put("/defs/{def_id}")
  168. def update_process_def(
  169. def_id: str,
  170. body: ProcessDefUpdateBody,
  171. user: dict = Depends(require_admin_or_super_admin),
  172. db: Session = Depends(get_db),
  173. ):
  174. try:
  175. payload: Dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=True)
  176. obj = wf_crud.update_process_def(
  177. db,
  178. def_id,
  179. process_code=payload.get("processCode"),
  180. process_name=payload.get("processName"),
  181. status=payload.get("status"),
  182. order_num=payload.get("orderNum"),
  183. remark=payload.get("remark"),
  184. user_id=user.get("user_id"),
  185. tenant_no=user.get("tenant_no", "shanxia2.0"),
  186. )
  187. return success_response(wf_crud._row_to_dict(obj), message="更新成功")
  188. except ValueError as e:
  189. return error_response(400, str(e))
  190. @router.delete("/defs/{def_id}")
  191. def delete_process_def(
  192. def_id: str,
  193. _user: dict = Depends(require_admin_or_super_admin),
  194. db: Session = Depends(get_db),
  195. ):
  196. try:
  197. wf_crud.soft_delete_process_def(db, def_id, user_info=_user)
  198. return success_response(None, message="删除成功")
  199. except ValueError as e:
  200. return error_response(400, str(e))
  201. # ---------------------------------------------------------------------------
  202. # 版本发布 / 查看
  203. # ---------------------------------------------------------------------------
  204. @router.post("/defs/{def_id}/publish")
  205. def publish_version(
  206. def_id: str,
  207. body: PublishVersionBody,
  208. user: dict = Depends(require_admin_or_super_admin),
  209. db: Session = Depends(get_db),
  210. ):
  211. try:
  212. tenant_no = user.get("tenant_no", "shanxia2.0")
  213. ver = wf_crud.publish_version(
  214. db,
  215. process_def_id=def_id,
  216. graph_json=body.graphJson or "",
  217. nodes=body.nodes,
  218. transitions=body.transitions,
  219. tenant_no=tenant_no,
  220. remark=body.remark,
  221. user_id=user.get("user_id"),
  222. )
  223. return success_response({"versionId": ver.id, "versionNo": ver.version_no}, message="发布成功")
  224. except ValueError as e:
  225. return error_response(400, str(e))
  226. @router.get("/defs/{def_id}/versions")
  227. def list_versions(
  228. def_id: str,
  229. _user: dict = Depends(require_admin_or_super_admin),
  230. db: Session = Depends(get_db),
  231. ):
  232. tenant_no = _user.get("tenant_no", "shanxia2.0")
  233. items = wf_crud.list_versions(db, def_id, tenant_no=tenant_no, user_info=_user)
  234. return success_response(items)
  235. @router.get("/defs/{def_id}/designer")
  236. def get_designer_data(
  237. def_id: str,
  238. _user: dict = Depends(require_admin_or_super_admin),
  239. db: Session = Depends(get_db),
  240. ):
  241. ver = wf_crud.get_latest_version(db, def_id)
  242. if not ver:
  243. return success_response({"graphJson": None, "nodes": [], "transitions": []})
  244. nodes = wf_crud.get_nodes_by_version(db, ver.id)
  245. transitions = wf_crud.get_transitions_by_version(db, ver.id)
  246. return success_response({
  247. "graphJson": ver.graph_json,
  248. "versionId": ver.id,
  249. "versionNo": ver.version_no,
  250. "nodes": [
  251. {"nodeKey": n.node_key, "nodeType": n.node_type, "nodeName": n.node_name, "configJson": n.config_json}
  252. for n in nodes
  253. ],
  254. "transitions": [
  255. {"fromNodeKey": t.from_node_key, "toNodeKey": t.to_node_key, "conditionExpr": t.condition_expr}
  256. for t in transitions
  257. ],
  258. })
  259. # ---------------------------------------------------------------------------
  260. # 流程实例
  261. # ---------------------------------------------------------------------------
  262. @router.post("/instances")
  263. def start_instance(
  264. body: StartInstanceBody,
  265. user: dict = Depends(require_admin_or_super_admin),
  266. db: Session = Depends(get_db),
  267. ):
  268. try:
  269. tenant_no = user.get("tenant_no", "shanxia2.0")
  270. user_id = user.get("user_id") or user.get("id")
  271. defn = (
  272. db.query(WfProcessDef)
  273. .filter(
  274. WfProcessDef.process_code == body.processCode,
  275. WfProcessDef.del_flag == 0,
  276. )
  277. .first()
  278. )
  279. if not defn:
  280. return error_response(404, "流程定义不存在")
  281. engine = WorkflowEngine(db)
  282. instance = engine.start_process(
  283. process_def_id=defn.id,
  284. business_type=body.businessType,
  285. business_id=body.businessId,
  286. starter_user_id=user_id,
  287. variables=body.variables,
  288. callback_url=body.callbackUrl,
  289. tenant_no=tenant_no,
  290. user_id=user_id,
  291. )
  292. return success_response({
  293. "instanceId": instance.id,
  294. "status": instance.status,
  295. }, message="发起成功")
  296. except ValueError as e:
  297. return error_response(400, str(e))
  298. @router.get("/instances/my")
  299. def list_my_instances(
  300. page: int = Query(1, ge=1),
  301. pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
  302. keyword: Optional[str] = None,
  303. status: Optional[str] = None,
  304. user: dict = Depends(require_admin_or_super_admin),
  305. db: Session = Depends(get_db),
  306. ):
  307. user_id = user.get("user_id") or user.get("id")
  308. tenant_no = user.get("tenant_no", "shanxia2.0")
  309. items, total = wf_crud.list_my_instances(
  310. db, user_id, page=page, page_size=pageSize,
  311. keyword=keyword, status=status, tenant_no=tenant_no, user_info=user,
  312. )
  313. _enrich_task_list(db, items)
  314. tp = wf_crud.total_pages(total, pageSize)
  315. return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
  316. @router.get("/instances/{instance_id}")
  317. def get_instance_detail(
  318. instance_id: str,
  319. user: dict = Depends(require_admin_or_super_admin),
  320. db: Session = Depends(get_db),
  321. ):
  322. instance = wf_crud.get_instance(db, instance_id)
  323. if not instance:
  324. return error_response(404, "流程实例不存在")
  325. _assert_wf_instance_in_scope(db, instance, user)
  326. tasks = db.query(WfTask).filter(
  327. WfTask.instance_id == instance_id,
  328. WfTask.del_flag == 0,
  329. ).order_by(WfTask.create_time.asc()).all()
  330. # 查询节点名称映射 node_key → node_name
  331. node_name_map: Dict[str, str] = {}
  332. if instance.process_version_id:
  333. for n in wf_crud.get_nodes_by_version(db, instance.process_version_id):
  334. node_name_map[n.node_key] = n.node_name or n.node_key
  335. # 批量查询用户名
  336. user_ids = {t.assignee_user_id for t in tasks if t.assignee_user_id}
  337. if instance.starter_user_id:
  338. user_ids.add(instance.starter_user_id)
  339. user_name_map: Dict[str, str] = {}
  340. if user_ids:
  341. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(user_ids)).all()
  342. user_name_map = {r.id: r.nick_name for r in rows}
  343. return success_response({
  344. "id": instance.id,
  345. "businessType": instance.business_type,
  346. "businessTypeLabel": _business_type_label(instance.business_type),
  347. "businessId": instance.business_id,
  348. "businessFormFields": _get_business_form_fields(db, instance.business_type, instance.business_id),
  349. "starterUserId": instance.starter_user_id,
  350. "starterUserName": user_name_map.get(instance.starter_user_id, instance.starter_user_id or ""),
  351. "status": instance.status,
  352. "variablesJson": instance.variables_json,
  353. "callbackUrl": instance.callback_url,
  354. "createTime": instance.create_time.strftime("%Y-%m-%d %H:%M:%S") if instance.create_time else "",
  355. "tasks": [
  356. {
  357. "id": t.id,
  358. "nodeKey": t.node_key,
  359. "nodeName": node_name_map.get(t.node_key, t.node_key or ""),
  360. "assigneeUserId": t.assignee_user_id,
  361. "assigneeUserName": user_name_map.get(t.assignee_user_id, t.assignee_user_id or ""),
  362. "status": t.status,
  363. "commentText": t.comment_text,
  364. "createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
  365. "updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
  366. }
  367. for t in tasks
  368. ],
  369. })
  370. # ---------------------------------------------------------------------------
  371. # 待办 / 已办
  372. # ---------------------------------------------------------------------------
  373. @router.get("/tasks/pending")
  374. def list_pending_tasks(
  375. page: int = Query(1, ge=1),
  376. pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
  377. keyword: Optional[str] = None,
  378. user: dict = Depends(require_admin_or_super_admin),
  379. db: Session = Depends(get_db),
  380. ):
  381. user_id = user.get("user_id") or user.get("id")
  382. tenant_no = user.get("tenant_no", "shanxia2.0")
  383. items, total = wf_crud.list_pending_tasks(
  384. db, user_id, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no, user_info=user
  385. )
  386. _enrich_task_list(db, items)
  387. tp = wf_crud.total_pages(total, pageSize)
  388. return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
  389. @router.get("/tasks/completed")
  390. def list_completed_tasks(
  391. page: int = Query(1, ge=1),
  392. pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
  393. keyword: Optional[str] = None,
  394. user: dict = Depends(require_admin_or_super_admin),
  395. db: Session = Depends(get_db),
  396. ):
  397. user_id = user.get("user_id") or user.get("id")
  398. tenant_no = user.get("tenant_no", "shanxia2.0")
  399. items, total = wf_crud.list_completed_tasks(
  400. db, user_id, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no, user_info=user
  401. )
  402. _enrich_task_list(db, items)
  403. tp = wf_crud.total_pages(total, pageSize)
  404. return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
  405. # ---------------------------------------------------------------------------
  406. # 审批 / 驳回
  407. # ---------------------------------------------------------------------------
  408. @router.post("/tasks/{task_id}/approve")
  409. def approve_task(
  410. task_id: str,
  411. body: TaskActionBody,
  412. user: dict = Depends(require_admin_or_super_admin),
  413. db: Session = Depends(get_db),
  414. ):
  415. try:
  416. task = wf_crud.get_task(db, task_id)
  417. if not task:
  418. return error_response(404, "任务不存在")
  419. instance = wf_crud.get_instance(db, task.instance_id)
  420. if not instance:
  421. return error_response(404, "流程实例不存在")
  422. user_id = (user.get("user_id") or user.get("id") or "").strip()
  423. skip_scope = task.assignee_user_id == user_id
  424. _assert_wf_instance_in_scope(db, instance, user, skip_scope=skip_scope)
  425. tenant_no = user.get("tenant_no", "shanxia2.0")
  426. engine = WorkflowEngine(db)
  427. instance = engine.approve_task(task_id, body.comment or "", tenant_no)
  428. return success_response({"instanceId": instance.id, "status": instance.status}, message="审批成功")
  429. except ValueError as e:
  430. return error_response(400, str(e))
  431. @router.post("/tasks/{task_id}/reject")
  432. def reject_task(
  433. task_id: str,
  434. body: TaskActionBody,
  435. user: dict = Depends(require_admin_or_super_admin),
  436. db: Session = Depends(get_db),
  437. ):
  438. try:
  439. task = wf_crud.get_task(db, task_id)
  440. if not task:
  441. return error_response(404, "任务不存在")
  442. instance = wf_crud.get_instance(db, task.instance_id)
  443. if not instance:
  444. return error_response(404, "流程实例不存在")
  445. user_id = (user.get("user_id") or user.get("id") or "").strip()
  446. skip_scope = task.assignee_user_id == user_id
  447. _assert_wf_instance_in_scope(db, instance, user, skip_scope=skip_scope)
  448. tenant_no = user.get("tenant_no", "shanxia2.0")
  449. engine = WorkflowEngine(db)
  450. instance = engine.reject_task(task_id, body.comment or "", tenant_no)
  451. return success_response({"instanceId": instance.id, "status": instance.status}, message="已驳回")
  452. except ValueError as e:
  453. return error_response(400, str(e))
  454. # ---------------------------------------------------------------------------
  455. # 按业务查询所有审批历史(跨实例)
  456. # ---------------------------------------------------------------------------
  457. @router.get("/instances/by-business/{business_type}/{business_id}")
  458. def get_business_all_tasks(
  459. business_type: str,
  460. business_id: str,
  461. user: dict = Depends(require_admin_or_super_admin),
  462. db: Session = Depends(get_db),
  463. ):
  464. instances = (
  465. db.query(WfProcessInstance)
  466. .filter(
  467. WfProcessInstance.business_type == business_type,
  468. WfProcessInstance.business_id == business_id,
  469. WfProcessInstance.del_flag == 0,
  470. )
  471. .order_by(WfProcessInstance.create_time.asc())
  472. .all()
  473. )
  474. if instances:
  475. _assert_wf_instance_in_scope(db, instances[0], user)
  476. tasks = wf_crud.get_all_tasks_for_business(db, business_type, business_id)
  477. business_form_fields = _get_business_form_fields(db, business_type, business_id)
  478. return success_response({
  479. "businessType": business_type,
  480. "businessId": business_id,
  481. "businessFormFields": business_form_fields,
  482. "tasks": tasks,
  483. })