workflow.py 19 KB

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