oa_engine.py 121 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702
  1. """OA 审批运行时引擎(钉钉式竖向树)"""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from collections import OrderedDict
  6. from datetime import datetime
  7. from typing import Any, Dict, List, Optional, Tuple
  8. from sqlalchemy import or_
  9. from sqlalchemy.orm import Session
  10. from app.core.utils import generate_uuid
  11. from app.crud import oa_approval as oa_crud
  12. from app.models.models import (
  13. OaApp,
  14. OaAppVersion,
  15. OaInstance,
  16. OaInstanceEvent,
  17. OaRelation,
  18. OaTask,
  19. SysDept,
  20. SysPost,
  21. SysUser,
  22. )
  23. from app.services.oa_assignee import (
  24. AssigneeResolveError,
  25. normalize_cc_assignee_types,
  26. resolve_assignees,
  27. resolve_cc_assignees,
  28. )
  29. from app.services.oa_formula import FormulaError, apply_formulas_and_linkages, eval_formula
  30. from app.services.oa_confirmation_kit_sync import apply_confirmation_kit_user_sync
  31. from app.services.oa_resignation_kit_sync import apply_resignation_kit_user_sync
  32. import logging
  33. logger = logging.getLogger(__name__)
  34. class OaEngineError(ValueError):
  35. """引擎业务错误"""
  36. _AUTO_PASS_COMMENT = "系统自动通过"
  37. _DEDUP_AUTO_PASS_COMMENT = "审批人去重,系统自动通过"
  38. INITIATOR_CONDITION_FIELD = "__initiator__"
  39. def _loads(text: Optional[str], default: Any = None) -> Any:
  40. if text is None or text == "":
  41. return default
  42. if not isinstance(text, str):
  43. return text
  44. try:
  45. return json.loads(text)
  46. except (TypeError, ValueError):
  47. return default
  48. def _dumps(data: Any) -> str:
  49. return json.dumps(data if data is not None else {}, ensure_ascii=False)
  50. def _normalize_initiator_select_by_node(raw: Any) -> Dict[str, List[str]]:
  51. if not isinstance(raw, dict):
  52. return {}
  53. out: Dict[str, List[str]] = {}
  54. for k, v in raw.items():
  55. nid = str(k or "").strip()
  56. if not nid or not isinstance(v, (list, tuple)):
  57. continue
  58. ids: List[str] = []
  59. seen = set()
  60. for x in v:
  61. uid = str(x or "").strip()
  62. if uid and uid not in seen:
  63. seen.add(uid)
  64. ids.append(uid)
  65. out[nid] = ids
  66. return out
  67. def _ids_for_initiator_select_node(
  68. select_by_node: Optional[Dict[str, List[str]]], node_id: str
  69. ) -> List[str]:
  70. m = select_by_node or {}
  71. nid = str(node_id or "").strip()
  72. if not nid:
  73. return []
  74. return list(m.get(nid) or [])
  75. def _load_initiator_select_by_node(inst: Any) -> Dict[str, List[str]]:
  76. return _normalize_initiator_select_by_node(
  77. _loads(getattr(inst, "initiator_select_json", None), {}) or {}
  78. )
  79. def _node_type(node: Dict[str, Any]) -> str:
  80. return (node.get("type") or "").strip()
  81. def _subtree_contains(node: Optional[Dict[str, Any]], node_id: str) -> bool:
  82. """节点子树(含自身、child、条件分支)是否包含指定 id。"""
  83. if not isinstance(node, dict):
  84. return False
  85. if (node.get("id") or "") == node_id:
  86. return True
  87. child = node.get("child")
  88. if isinstance(child, dict) and _subtree_contains(child, node_id):
  89. return True
  90. for g in node.get("conditionGroups") or []:
  91. if isinstance(g, dict) and _subtree_contains(g.get("child"), node_id):
  92. return True
  93. return False
  94. def _merge_after_branch_end(
  95. root: Dict[str, Any], end_id: str
  96. ) -> Optional[Dict[str, Any]]:
  97. """若 end 落在某条件分支组内,返回最近一层条件节点的汇合后 child(若有)。"""
  98. def walk(node: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
  99. if not isinstance(node, dict):
  100. return None
  101. if _node_type(node) == "condition":
  102. for g in node.get("conditionGroups") or []:
  103. if not isinstance(g, dict):
  104. continue
  105. if not _subtree_contains(g.get("child"), end_id):
  106. continue
  107. # 优先内层条件的汇合,再回退到本层
  108. nested = walk(g.get("child"))
  109. if nested is not None:
  110. return nested
  111. merge = node.get("child")
  112. return merge if isinstance(merge, dict) else None
  113. merge_child = node.get("child")
  114. if isinstance(merge_child, dict):
  115. return walk(merge_child)
  116. return None
  117. child = node.get("child")
  118. if isinstance(child, dict):
  119. return walk(child)
  120. return None
  121. return walk(root)
  122. def _flow_root(flow_tree: Any) -> Dict[str, Any]:
  123. if not isinstance(flow_tree, dict):
  124. raise OaEngineError("流程树无效")
  125. if isinstance(flow_tree.get("root"), dict):
  126. return flow_tree["root"]
  127. return flow_tree
  128. def _find_node(root: Dict[str, Any], node_key: str) -> Optional[Dict[str, Any]]:
  129. if not root or not node_key:
  130. return None
  131. if (root.get("id") or "") == node_key:
  132. return root
  133. child = root.get("child")
  134. if isinstance(child, dict):
  135. found = _find_node(child, node_key)
  136. if found:
  137. return found
  138. for g in root.get("conditionGroups") or []:
  139. if isinstance(g, dict) and isinstance(g.get("child"), dict):
  140. found = _find_node(g["child"], node_key)
  141. if found:
  142. return found
  143. return None
  144. def _flow_node_key_order(root: Dict[str, Any]) -> Dict[str, int]:
  145. """按流程树先序遍历为 node.id 分配稳定序号(用于详情时间轴排序)。"""
  146. order: Dict[str, int] = {}
  147. counter = 0
  148. def walk(node: Any) -> None:
  149. nonlocal counter
  150. if not isinstance(node, dict):
  151. return
  152. nid = (node.get("id") or "").strip()
  153. if nid and nid not in order:
  154. order[nid] = counter
  155. counter += 1
  156. if _node_type(node) == "condition":
  157. for g in node.get("conditionGroups") or []:
  158. if isinstance(g, dict) and isinstance(g.get("child"), dict):
  159. walk(g["child"])
  160. child = node.get("child")
  161. if isinstance(child, dict):
  162. walk(child)
  163. walk(root)
  164. return order
  165. def _next_task_order_num(db: Session, instance_id: str) -> int:
  166. """同一实例内任务展示序号取 max+1(无任务从 1 起)。"""
  167. rows = (
  168. db.query(OaTask.order_num)
  169. .filter(OaTask.instance_id == instance_id, OaTask.del_flag == 0)
  170. .all()
  171. )
  172. vals = [int(r[0]) for r in rows if r[0] is not None]
  173. return (max(vals) + 1) if vals else 1
  174. def _sort_tasks_for_detail(
  175. tasks: List[OaTask], *, flow_root: Optional[Dict[str, Any]]
  176. ) -> List[OaTask]:
  177. """详情时间轴:按流程树节点顺序,再 order_num / create_time(兼容旧数据同秒创建)。"""
  178. node_order = _flow_node_key_order(flow_root) if isinstance(flow_root, dict) else {}
  179. far = 10**9
  180. def key(t: OaTask) -> Tuple[int, int, int, datetime, str]:
  181. nk = (t.node_key or "").strip()
  182. return (
  183. int(getattr(t, "round_no", None) or 1),
  184. node_order.get(nk, far),
  185. int(t.order_num) if t.order_num is not None else 0,
  186. t.create_time or datetime.min,
  187. t.id or "",
  188. )
  189. return sorted(tasks, key=key)
  190. def _fmt_dt(dt: Any) -> str:
  191. if not dt:
  192. return ""
  193. if hasattr(dt, "strftime"):
  194. return dt.strftime("%Y-%m-%d %H:%M:%S")
  195. return str(dt)
  196. def _terminal_item(
  197. status: str,
  198. title: str,
  199. inst: Any,
  200. name_map: Optional[Dict[str, str]] = None,
  201. ) -> Dict[str, Any]:
  202. name_map = name_map or {}
  203. uid = ""
  204. uname = ""
  205. comment = ""
  206. # 撤销终态:归因发起人,勿让任务节点承担「已撤销」语义
  207. if status == "cancelled":
  208. uid = (getattr(inst, "initiator_user_id", None) or "").strip()
  209. uname = name_map.get(uid, "") or uid
  210. comment = "发起人撤销"
  211. return {
  212. "kind": "terminal",
  213. "roundNo": getattr(inst, "current_round", None) or 1,
  214. "title": title,
  215. "status": status,
  216. "assigneeUserId": uid,
  217. "assigneeUserName": uname,
  218. "timestamp": _fmt_dt(getattr(inst, "update_time", None)),
  219. "commentText": comment,
  220. "nodeKey": "",
  221. "taskType": "",
  222. }
  223. def _task_round(t: Any) -> int:
  224. v = getattr(t, "round_no", None)
  225. try:
  226. if v is None:
  227. return 1
  228. return int(v)
  229. except (TypeError, ValueError):
  230. return 1
  231. def _append_pending_nodes(
  232. items: List[Dict[str, Any]],
  233. *,
  234. flow_root: Dict[str, Any],
  235. reached_keys: set,
  236. round_no: int,
  237. ) -> None:
  238. """兜底:按整棵树先序挂未到达节点(无路径解析 / 无人员)。"""
  239. order = _flow_node_key_order(flow_root)
  240. for nid, _idx in sorted(order.items(), key=lambda x: x[1]):
  241. node = _find_node(flow_root, nid)
  242. if not node:
  243. continue
  244. nt = _node_type(node)
  245. if nt not in ("approval", "cc", "end") or nid in reached_keys:
  246. continue
  247. if nt == "end":
  248. title = node.get("name") or "结束"
  249. elif nt == "cc":
  250. title = node.get("name") or "抄送人"
  251. else:
  252. title = node.get("name") or "审批人"
  253. items.append(
  254. {
  255. "kind": "pending_node",
  256. "roundNo": round_no,
  257. "title": title,
  258. "status": "not_reached",
  259. "assigneeUserId": "",
  260. "assigneeUserName": "",
  261. "timestamp": "",
  262. "commentText": "",
  263. "nodeKey": nid,
  264. "taskType": nt,
  265. "signMode": "",
  266. "people": [],
  267. }
  268. )
  269. def _pending_people_for_node(
  270. db: Session,
  271. *,
  272. node: Dict[str, Any],
  273. tenant_no: str,
  274. initiator_user_id: str,
  275. initiator_dept_id: Optional[str],
  276. advanced: Dict[str, Any],
  277. initiator_select_by_node: Optional[Dict[str, List[str]]],
  278. ) -> Tuple[List[Dict[str, Any]], str, str]:
  279. """解析未到达节点的展示人员与签批模式。返回 (people, sign_mode, resolve_source)。"""
  280. ntype = _node_type(node)
  281. cfg = node.get("approverConfig") or {}
  282. assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
  283. sign_mode = (cfg.get("signMode") or cfg.get("sign_mode") or "").strip()
  284. if ntype == "cc":
  285. sign_mode = "cc"
  286. elif not sign_mode and assignee_type != "initiator_select":
  287. sign_mode = "or"
  288. people: List[Dict[str, Any]] = []
  289. node_select_ids = _ids_for_initiator_select_node(
  290. initiator_select_by_node, node.get("id") or ""
  291. )
  292. resolve_source = assignee_type
  293. if ntype == "cc":
  294. types = normalize_cc_assignee_types(cfg)
  295. assignees, meta = resolve_cc_assignees(
  296. db,
  297. tenant_no=tenant_no,
  298. assignee_config=cfg,
  299. initiator_user_id=initiator_user_id,
  300. initiator_dept_id=initiator_dept_id,
  301. advanced=advanced,
  302. initiator_select_user_ids=node_select_ids,
  303. )
  304. resolve_source = str(meta.get("resolve_source") or "")
  305. if not resolve_source:
  306. resolve_source = "multi" if len(types) > 1 else (types[0] if types else "")
  307. for uid in assignees:
  308. people.append(
  309. {
  310. "id": uid,
  311. "nickName": "",
  312. "status": "not_reached",
  313. "timestamp": "",
  314. "commentText": "",
  315. "taskId": "",
  316. }
  317. )
  318. elif assignee_type == "initiator_select":
  319. for uid in node_select_ids:
  320. people.append(
  321. {
  322. "id": uid,
  323. "nickName": "", # 下方统一补名
  324. "status": "not_reached",
  325. "timestamp": "",
  326. "commentText": "",
  327. "taskId": "",
  328. }
  329. )
  330. else:
  331. try:
  332. assignees, _meta = resolve_assignees(
  333. db,
  334. tenant_no=tenant_no,
  335. assignee_config=cfg,
  336. initiator_user_id=initiator_user_id,
  337. initiator_dept_id=initiator_dept_id,
  338. advanced=advanced,
  339. initiator_select_user_ids=node_select_ids,
  340. )
  341. for uid in assignees:
  342. people.append(
  343. {
  344. "id": uid,
  345. "nickName": "",
  346. "status": "not_reached",
  347. "timestamp": "",
  348. "commentText": "",
  349. "taskId": "",
  350. }
  351. )
  352. except AssigneeResolveError:
  353. people = []
  354. if people:
  355. name_map = _user_name_map(db, [p["id"] for p in people if p.get("id")])
  356. for p in people:
  357. p["nickName"] = name_map.get(p["id"], "") or p["id"]
  358. return people, sign_mode, resolve_source
  359. def _iter_resolved_path_nodes(
  360. root: Optional[Dict[str, Any]],
  361. form_data: Optional[Dict[str, Any]],
  362. *,
  363. initiator_user_id: Optional[str] = None,
  364. flow_root: Optional[Dict[str, Any]] = None,
  365. ) -> Optional[List[Dict[str, Any]]]:
  366. """沿条件命中路径收集节点;无法匹配时返回 None。"""
  367. if not isinstance(root, dict):
  368. return []
  369. data = form_data or {}
  370. tree_root = flow_root if isinstance(flow_root, dict) else root
  371. out: List[Dict[str, Any]] = []
  372. cur: Optional[Dict[str, Any]] = root
  373. while cur and isinstance(cur, dict):
  374. ntype = _node_type(cur)
  375. if ntype == "end":
  376. end_id = (cur.get("id") or "").strip()
  377. # 分支叶子 end 若后面还有汇合主链,不算真正结束
  378. if end_id and _merge_after_branch_end(tree_root, end_id) is not None:
  379. break
  380. out.append(cur)
  381. break
  382. if ntype == "condition":
  383. g = _match_condition_group(
  384. cur.get("conditionGroups") or [], data, initiator_user_id=initiator_user_id
  385. )
  386. if not g:
  387. return None
  388. child = g.get("child")
  389. if isinstance(child, dict):
  390. sub = _iter_resolved_path_nodes(
  391. child,
  392. data,
  393. initiator_user_id=initiator_user_id,
  394. flow_root=tree_root,
  395. )
  396. if sub is None:
  397. return None
  398. out.extend(sub)
  399. cur = cur.get("child")
  400. continue
  401. out.append(cur)
  402. cur = cur.get("child")
  403. return out
  404. def _append_pending_nodes_on_path(
  405. items: List[Dict[str, Any]],
  406. *,
  407. db: Session,
  408. flow_root: Dict[str, Any],
  409. reached_keys: set,
  410. round_no: int,
  411. tenant_no: str,
  412. initiator_user_id: str,
  413. initiator_dept_id: Optional[str],
  414. advanced: Dict[str, Any],
  415. form_data: Optional[Dict[str, Any]],
  416. initiator_select_by_node: Optional[Dict[str, List[str]]],
  417. ) -> None:
  418. """按命中路径列出全部后续审批/抄送/结束节点(含人员),对齐发起预览。"""
  419. path = _iter_resolved_path_nodes(
  420. flow_root, form_data, initiator_user_id=initiator_user_id
  421. )
  422. if path is None:
  423. already = {
  424. (x.get("nodeKey") or "").strip()
  425. for x in items
  426. if x.get("kind") in ("task", "pending_node") and (x.get("nodeKey") or "").strip()
  427. }
  428. _append_pending_nodes(
  429. items,
  430. flow_root=flow_root,
  431. reached_keys=reached_keys | already,
  432. round_no=round_no,
  433. )
  434. return
  435. for node in path:
  436. ntype = _node_type(node)
  437. if ntype not in ("approval", "cc", "end"):
  438. continue
  439. nid = (node.get("id") or "").strip()
  440. if not nid or nid in reached_keys:
  441. continue
  442. if ntype == "end":
  443. items.append(
  444. {
  445. "kind": "pending_node",
  446. "roundNo": round_no,
  447. "title": node.get("name") or "结束",
  448. "status": "not_reached",
  449. "assigneeUserId": "",
  450. "assigneeUserName": "",
  451. "timestamp": "",
  452. "commentText": "",
  453. "nodeKey": nid,
  454. "taskType": "end",
  455. "signMode": "",
  456. "people": [],
  457. }
  458. )
  459. continue
  460. people, sign_mode, _src = _pending_people_for_node(
  461. db,
  462. node=node,
  463. tenant_no=tenant_no,
  464. initiator_user_id=initiator_user_id,
  465. initiator_dept_id=initiator_dept_id,
  466. advanced=advanced,
  467. initiator_select_by_node=initiator_select_by_node,
  468. )
  469. items.append(
  470. {
  471. "kind": "pending_node",
  472. "roundNo": round_no,
  473. "title": node.get("name") or ("抄送人" if ntype == "cc" else "审批人"),
  474. "status": "not_reached",
  475. "assigneeUserId": "",
  476. "assigneeUserName": "",
  477. "timestamp": "",
  478. "commentText": "",
  479. "nodeKey": nid,
  480. "taskType": ntype,
  481. "signMode": sign_mode,
  482. "people": people,
  483. }
  484. )
  485. def _progress_person(t: Any, name_map: Dict[str, str]) -> Dict[str, Any]:
  486. uid = (getattr(t, "assignee_user_id", None) or "").strip()
  487. return {
  488. "id": uid,
  489. "nickName": name_map.get(uid, "") or uid,
  490. "status": getattr(t, "status", None) or "",
  491. "timestamp": _fmt_dt(getattr(t, "update_time", None) or getattr(t, "create_time", None)),
  492. "commentText": getattr(t, "comment_text", None) or "",
  493. "taskId": getattr(t, "id", None) or "",
  494. "transferToUserId": "",
  495. "transferToUserName": "",
  496. }
  497. def _enrich_transfer_targets(
  498. people: List[Dict[str, Any]],
  499. group: List[Any],
  500. name_map: Dict[str, str],
  501. ) -> None:
  502. """为已转交人员补齐 transferTo*:同节点 resolve_source=transfer 且 create_by=转交人 的新任务处理人。"""
  503. by_creator: Dict[str, List[Any]] = {}
  504. for t in group:
  505. if (getattr(t, "resolve_source", None) or "").strip() != "transfer":
  506. continue
  507. creator = (getattr(t, "create_by", None) or "").strip()
  508. if not creator:
  509. continue
  510. by_creator.setdefault(creator, []).append(t)
  511. for p in people:
  512. if (p.get("status") or "") != "transferred":
  513. continue
  514. uid = (p.get("id") or "").strip()
  515. if not uid:
  516. continue
  517. candidates = by_creator.get(uid) or []
  518. if not candidates:
  519. continue
  520. # 连续转交时同一人可能只对应一次;若有多条取创建时间最接近转交时间的
  521. p_ts = (p.get("timestamp") or "").strip()
  522. def _score(t: Any) -> tuple:
  523. cts = _fmt_dt(getattr(t, "create_time", None))
  524. # 优先同秒/最近;无时间时靠后
  525. if p_ts and cts:
  526. return (0, abs(_dt_sort_key(cts) - _dt_sort_key(p_ts)), cts)
  527. return (1, 0, cts or "")
  528. target_task = sorted(candidates, key=_score)[0]
  529. tid = (getattr(target_task, "assignee_user_id", None) or "").strip()
  530. if not tid:
  531. continue
  532. p["transferToUserId"] = tid
  533. p["transferToUserName"] = name_map.get(tid, "") or tid
  534. def _dt_sort_key(s: str) -> int:
  535. """将 'YYYY-MM-DD HH:MM:SS' 粗转为可比较整数;非法则 0。"""
  536. raw = (s or "").strip().replace("-", "").replace(":", "").replace(" ", "")
  537. try:
  538. return int(raw[:14]) if raw else 0
  539. except ValueError:
  540. return 0
  541. def _aggregate_node_status(statuses: List[str]) -> str:
  542. """同节点多人任务收成卡片状态:拒绝/打回优先,其次待办,再已同意。"""
  543. sset = [str(s or "").strip() for s in statuses]
  544. if any(s == "rejected" for s in sset):
  545. return "rejected"
  546. if any(s == "returned" for s in sset):
  547. return "returned"
  548. if any(s in ("pending", "running") for s in sset):
  549. return "pending"
  550. if any(s == "approved" for s in sset):
  551. return "approved"
  552. if any(s == "completed" for s in sset):
  553. return "completed"
  554. if sset and all(s == "done" for s in sset):
  555. return "done"
  556. return sset[0] if sset else "pending"
  557. def _group_tasks_by_node(sorted_tasks: List[Any]) -> List[List[Any]]:
  558. """按 node_key 合并同节点多人(或签/会签/抄送),无 key 的任务各自独立。"""
  559. buckets: OrderedDict[str, List[Any]] = OrderedDict()
  560. for t in sorted_tasks:
  561. nk = (getattr(t, "node_key", None) or "").strip()
  562. key = nk if nk else f"__task_{(getattr(t, 'id', None) or id(t))}"
  563. buckets.setdefault(key, []).append(t)
  564. return list(buckets.values())
  565. def build_progress_items(
  566. *,
  567. inst: Any,
  568. flow_root: Optional[Dict[str, Any]],
  569. tasks: List[Any],
  570. name_map: Dict[str, str],
  571. events: Optional[List[Any]] = None,
  572. db: Optional[Session] = None,
  573. advanced: Optional[Dict[str, Any]] = None,
  574. initiator_select_by_node: Optional[Dict[str, List[str]]] = None,
  575. ) -> List[Dict[str, Any]]:
  576. """投影审批进度:发起人 → 树序 tasks(同节点多人合并)→ 未到达;终态收尾。
  577. 钉钉对齐:打回结果落在审批任务节点上,不另插 kind=return / terminal 终态「已打回」行;不做同单多轮分段。
  578. 或签/会签:同一 nodeKey 只出一张卡片,people 列出各审批人及状态。
  579. 进行中:按命中路径把后续审批/抄送节点全部挂为未到达(尽量解析人员)。
  580. """
  581. events = list(events or [])
  582. sorted_tasks = _sort_tasks_for_detail(list(tasks), flow_root=flow_root)
  583. # 展示压成单轴(roundNo 统一为 1);历史多轮任务仍按树序合并展示
  584. rn = 1
  585. submit_ts = _fmt_dt(getattr(inst, "create_time", None))
  586. for e in events:
  587. if (getattr(e, "event_type", None) or "") == "submit":
  588. submit_ts = _fmt_dt(getattr(e, "create_time", None)) or submit_ts
  589. break
  590. items: List[Dict[str, Any]] = [
  591. {
  592. "kind": "submit",
  593. "roundNo": rn,
  594. "title": "发起人",
  595. "status": "submitted",
  596. "assigneeUserId": inst.initiator_user_id,
  597. "assigneeUserName": name_map.get(inst.initiator_user_id or "", ""),
  598. "timestamp": submit_ts,
  599. "commentText": "",
  600. "nodeKey": "",
  601. "taskType": "",
  602. "signMode": "",
  603. "people": [],
  604. }
  605. ]
  606. reached_keys = {
  607. (t.node_key or "").strip() for t in sorted_tasks if (t.node_key or "").strip()
  608. }
  609. for group in _group_tasks_by_node(sorted_tasks):
  610. head = group[0]
  611. people = [_progress_person(t, name_map) for t in group]
  612. _enrich_transfer_targets(people, group, name_map)
  613. statuses = [p["status"] for p in people]
  614. node_status = _aggregate_node_status(statuses)
  615. # 时间:优先取已操作人最新时间,否则取组内最新
  616. acted = [
  617. p
  618. for p in people
  619. if p["status"] in ("approved", "rejected", "returned", "completed", "transferred")
  620. ]
  621. ts_src = acted or people
  622. timestamp = ""
  623. for p in ts_src:
  624. if p.get("timestamp") and (not timestamp or p["timestamp"] > timestamp):
  625. timestamp = p["timestamp"]
  626. # 整节点仅系统清待办(cancelled)时勿带时间,避免像审批人操作过
  627. if node_status == "cancelled":
  628. timestamp = ""
  629. comments = [
  630. f"{p['nickName']}:{p['commentText']}"
  631. if len(people) > 1 and p.get("nickName")
  632. else p["commentText"]
  633. for p in people
  634. if (p.get("commentText") or "").strip()
  635. ]
  636. sign_mode = (getattr(head, "sign_mode", None) or "").strip()
  637. primary = next(
  638. (p for p in people if p["status"] == node_status),
  639. people[0] if people else None,
  640. )
  641. items.append(
  642. {
  643. "kind": "task",
  644. "roundNo": rn,
  645. "title": head.node_name or head.node_key or "-",
  646. "status": node_status,
  647. "assigneeUserId": (primary or {}).get("id", "") if len(people) == 1 else "",
  648. "assigneeUserName": (primary or {}).get("nickName", "")
  649. if len(people) == 1
  650. else "",
  651. "timestamp": timestamp,
  652. "commentText": ";".join(comments),
  653. "nodeKey": head.node_key or "",
  654. "taskType": head.task_type or "",
  655. "resolveSource": getattr(head, "resolve_source", None) or "",
  656. "signMode": sign_mode,
  657. "taskId": getattr(head, "id", None) or "",
  658. "people": people,
  659. }
  660. )
  661. if inst.status == "running" and isinstance(flow_root, dict):
  662. form_data = _loads(getattr(inst, "form_data_json", None), {}) or {}
  663. if not isinstance(form_data, dict):
  664. form_data = {}
  665. # 自选人:优先入参 → 实例 JSON → 旧数据从任务按 node_key 聚合
  666. select_by_node = _normalize_initiator_select_by_node(initiator_select_by_node)
  667. if not select_by_node:
  668. select_by_node = _load_initiator_select_by_node(inst)
  669. if not select_by_node:
  670. for t in sorted_tasks:
  671. src = (getattr(t, "resolve_source", None) or "").strip()
  672. uid = (getattr(t, "assignee_user_id", None) or "").strip()
  673. nk = (getattr(t, "node_key", None) or "").strip()
  674. if src == "initiator_select" and uid and nk:
  675. bucket = select_by_node.setdefault(nk, [])
  676. if uid not in bucket:
  677. bucket.append(uid)
  678. if db is not None:
  679. _append_pending_nodes_on_path(
  680. items,
  681. db=db,
  682. flow_root=flow_root,
  683. reached_keys=reached_keys,
  684. round_no=rn,
  685. tenant_no=getattr(inst, "tenant_no", None) or "",
  686. initiator_user_id=inst.initiator_user_id or "",
  687. initiator_dept_id=getattr(inst, "initiator_dept_id", None),
  688. advanced=advanced or dict(oa_crud.DEFAULT_ADVANCED_SETTINGS),
  689. form_data=form_data,
  690. initiator_select_by_node=select_by_node,
  691. )
  692. else:
  693. _append_pending_nodes(
  694. items, flow_root=flow_root, reached_keys=reached_keys, round_no=rn
  695. )
  696. if inst.status == "completed":
  697. items.append(_terminal_item("done", "流程结束", inst, name_map))
  698. elif inst.status == "rejected":
  699. items.append(_terminal_item("rejected", "流程结束(已拒绝)", inst, name_map))
  700. elif inst.status == "cancelled":
  701. items.append(_terminal_item("cancelled", "已撤销", inst, name_map))
  702. # returned:打回结果只落在审批任务节点上,不再追加独立终态「已打回」行
  703. return items
  704. def _add_instance_event(
  705. db: Session,
  706. *,
  707. inst: OaInstance,
  708. event_type: str,
  709. actor_user_id: Optional[str],
  710. round_no: int,
  711. comment: Optional[str] = None,
  712. task_id: Optional[str] = None,
  713. node_key: Optional[str] = None,
  714. ) -> OaInstanceEvent:
  715. now = datetime.now()
  716. ev = OaInstanceEvent(
  717. id=generate_uuid(),
  718. instance_id=inst.id,
  719. round_no=round_no,
  720. event_type=event_type,
  721. actor_user_id=actor_user_id,
  722. task_id=task_id,
  723. node_key=node_key,
  724. comment_text=(comment or "").strip() or None,
  725. tenant_no=inst.tenant_no,
  726. order_num=0,
  727. remark=None,
  728. create_by=actor_user_id,
  729. create_dept_id=inst.initiator_dept_id or "",
  730. create_time=now,
  731. update_by=actor_user_id,
  732. update_time=now,
  733. del_flag=0,
  734. )
  735. db.add(ev)
  736. return ev
  737. def _advanced(ver: OaAppVersion) -> Dict[str, Any]:
  738. data = _loads(ver.advanced_settings_json, {}) or {}
  739. if not isinstance(data, dict):
  740. return dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
  741. merged = dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
  742. merged.update(data)
  743. return merged
  744. def _check_initiator_scope(app: OaApp, user_id: str) -> None:
  745. scope = (app.initiator_scope or "all").strip()
  746. if scope == "all":
  747. return
  748. ids = _loads(app.initiator_user_ids, []) or []
  749. if user_id not in ids:
  750. raise OaEngineError("您不在该审批的发起范围内")
  751. def _instance_to_dict(inst: OaInstance, *, app_name: str = "") -> Dict[str, Any]:
  752. return {
  753. "id": inst.id,
  754. "appId": inst.app_id,
  755. "appVersionId": inst.app_version_id,
  756. "appName": app_name,
  757. "title": inst.title or "",
  758. "initiatorUserId": inst.initiator_user_id,
  759. "initiatorDeptId": inst.initiator_dept_id or "",
  760. "formDataJson": _loads(inst.form_data_json, {}),
  761. "status": inst.status,
  762. "sourceInstanceId": getattr(inst, "source_instance_id", None) or "",
  763. "currentRound": int(getattr(inst, "current_round", None) or 1),
  764. "createTime": inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "",
  765. "updateTime": inst.update_time.strftime("%Y-%m-%d %H:%M:%S") if inst.update_time else "",
  766. }
  767. def _task_to_dict(t: OaTask, *, assignee_user_name: str = "") -> Dict[str, Any]:
  768. return {
  769. "id": t.id,
  770. "instanceId": t.instance_id,
  771. "nodeKey": t.node_key or "",
  772. "nodeName": t.node_name or "",
  773. "taskType": t.task_type,
  774. "assigneeUserId": t.assignee_user_id,
  775. "assigneeUserName": assignee_user_name or t.assignee_user_id or "",
  776. "status": t.status,
  777. "commentText": t.comment_text or "",
  778. "resolveSource": t.resolve_source or "",
  779. "signMode": t.sign_mode or "",
  780. "isRead": bool(int(getattr(t, "is_read", 0) or 0)),
  781. "readTime": (
  782. t.read_time.strftime("%Y-%m-%d %H:%M:%S")
  783. if getattr(t, "read_time", None)
  784. else ""
  785. ),
  786. "createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
  787. "updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
  788. }
  789. _INSTANCE_STATUS_NODE_LABEL = {
  790. "completed": "已完成",
  791. "rejected": "已拒绝",
  792. "returned": "已打回",
  793. "cancelled": "已撤销",
  794. }
  795. def _instance_progress_summary(
  796. inst: OaInstance,
  797. *,
  798. app_name: str,
  799. initiator_user_name: str,
  800. pending_approval_tasks: List[OaTask],
  801. name_map: Dict[str, str],
  802. ) -> Dict[str, Any]:
  803. status = (inst.status or "").strip()
  804. apply_time = (
  805. inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else ""
  806. )
  807. if status == "running":
  808. node_names: List[str] = []
  809. seen_nodes = set()
  810. approver_names: List[str] = []
  811. for t in pending_approval_tasks:
  812. nn = (t.node_name or t.node_key or "").strip()
  813. if nn and nn not in seen_nodes:
  814. seen_nodes.add(nn)
  815. node_names.append(nn)
  816. uid = (t.assignee_user_id or "").strip()
  817. if uid:
  818. approver_names.append(name_map.get(uid, uid))
  819. current_node = "、".join(node_names) if node_names else "-"
  820. current_approvers = "、".join(approver_names) if approver_names else "-"
  821. else:
  822. current_node = _INSTANCE_STATUS_NODE_LABEL.get(status, status or "-")
  823. current_approvers = "-"
  824. return {
  825. "appName": app_name or (inst.title or ""),
  826. "initiatorUserId": inst.initiator_user_id or "",
  827. "initiatorUserName": initiator_user_name
  828. or name_map.get(inst.initiator_user_id or "", inst.initiator_user_id or ""),
  829. "applyTime": apply_time,
  830. "instanceStatus": status,
  831. "currentNodeName": current_node,
  832. "currentApproverNames": current_approvers,
  833. }
  834. def _enrich_list_rows(
  835. db: Session,
  836. *,
  837. tenant_no: str,
  838. rows: List[Dict[str, Any]],
  839. instances: List[OaInstance],
  840. preview_limit: int = 3,
  841. ) -> List[Dict[str, Any]]:
  842. if not rows:
  843. return []
  844. from app.services.oa_related_approval import flatten_form_fields_for_summary
  845. limit = max(0, int(preview_limit or 3))
  846. inst_by_id = {i.id: i for i in instances}
  847. app_ids = list({i.app_id for i in instances if i.app_id})
  848. apps = (
  849. db.query(OaApp)
  850. .filter(
  851. OaApp.id.in_(app_ids),
  852. OaApp.tenant_no == tenant_no,
  853. OaApp.del_flag == 0,
  854. )
  855. .all()
  856. if app_ids
  857. else []
  858. )
  859. app_name_by_id = {a.id: (a.app_name or "") for a in apps}
  860. ver_ids = list({i.app_version_id for i in instances if i.app_version_id})
  861. ver_by_id: Dict[str, OaAppVersion] = {}
  862. if ver_ids:
  863. for v in (
  864. db.query(OaAppVersion)
  865. .filter(OaAppVersion.id.in_(ver_ids), OaAppVersion.del_flag == 0)
  866. .all()
  867. ):
  868. ver_by_id[v.id] = v
  869. inst_ids = list(inst_by_id.keys())
  870. pending_tasks = (
  871. db.query(OaTask)
  872. .filter(
  873. OaTask.tenant_no == tenant_no,
  874. OaTask.del_flag == 0,
  875. OaTask.instance_id.in_(inst_ids),
  876. OaTask.task_type == "approval",
  877. OaTask.status == "pending",
  878. )
  879. .order_by(OaTask.create_time.asc())
  880. .all()
  881. if inst_ids
  882. else []
  883. )
  884. pending_by_inst: Dict[str, List[OaTask]] = {}
  885. for t in pending_tasks:
  886. pending_by_inst.setdefault(t.instance_id, []).append(t)
  887. user_ids: List[str] = []
  888. for i in instances:
  889. if i.initiator_user_id:
  890. user_ids.append(i.initiator_user_id)
  891. for t in pending_tasks:
  892. if t.assignee_user_id:
  893. user_ids.append(t.assignee_user_id)
  894. name_map = _user_name_map(db, user_ids)
  895. out: List[Dict[str, Any]] = []
  896. for row in rows:
  897. iid = row.get("instanceId") or row.get("id")
  898. inst = inst_by_id.get(iid)
  899. if not inst:
  900. out.append(row)
  901. continue
  902. summary = _instance_progress_summary(
  903. inst,
  904. app_name=app_name_by_id.get(inst.app_id, ""),
  905. initiator_user_name=name_map.get(inst.initiator_user_id or "", ""),
  906. pending_approval_tasks=pending_by_inst.get(inst.id, []),
  907. name_map=name_map,
  908. )
  909. ver = ver_by_id.get(inst.app_version_id or "")
  910. schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
  911. form_data = _loads(inst.form_data_json, {}) or {}
  912. preview = []
  913. for f in flatten_form_fields_for_summary(schema, form_data)[:limit]:
  914. item = dict(f)
  915. item["label"] = item.get("label") or ""
  916. item["value"] = item.get("value") or ""
  917. preview.append(item)
  918. merged = dict(row)
  919. merged.update(summary)
  920. merged["previewFields"] = preview
  921. st = (inst.status or "").strip()
  922. if st in _FINISH_STATUSES and inst.update_time:
  923. merged["finishTime"] = inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
  924. else:
  925. merged["finishTime"] = None
  926. out.append(merged)
  927. return out
  928. def _user_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
  929. ids = [uid for uid in {str(u or "").strip() for u in user_ids} if uid]
  930. if not ids:
  931. return {}
  932. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
  933. return {r.id: (r.nick_name or r.id) for r in rows}
  934. def _dept_name(db: Session, dept_id: Optional[str], *, tenant_no: str) -> str:
  935. did = (dept_id or "").strip()
  936. if not did:
  937. return ""
  938. row = (
  939. db.query(SysDept.dept_name)
  940. .filter(SysDept.id == did, SysDept.tenant_no == tenant_no, SysDept.del_flag == 0)
  941. .first()
  942. )
  943. if not row:
  944. return ""
  945. return (row[0] or "").strip()
  946. def list_templates(db: Session, *, tenant_no: str, user_id: str) -> List[Dict[str, Any]]:
  947. apps = (
  948. db.query(OaApp)
  949. .filter(OaApp.tenant_no == tenant_no, OaApp.del_flag == 0, OaApp.status == "1")
  950. .order_by(OaApp.order_num.asc(), OaApp.create_time.desc())
  951. .all()
  952. )
  953. out: List[Dict[str, Any]] = []
  954. for app in apps:
  955. pub = oa_crud._find_latest_published(db, app.id, tenant_no)
  956. if not pub:
  957. continue
  958. try:
  959. _check_initiator_scope(app, user_id)
  960. except OaEngineError:
  961. continue
  962. out.append(
  963. {
  964. "id": app.id,
  965. "appName": app.app_name,
  966. "groupId": app.group_id or "",
  967. "iconUrl": app.icon_url or "",
  968. "description": app.description or "",
  969. "versionId": pub.id,
  970. "versionNo": pub.version_no,
  971. }
  972. )
  973. return out
  974. def get_template_detail(
  975. db: Session, *, tenant_no: str, app_id: str, user_id: str
  976. ) -> Dict[str, Any]:
  977. app = oa_crud.get_app(db, app_id, tenant_no)
  978. if not app or app.status != "1":
  979. raise OaEngineError("应用不存在或已停用")
  980. _check_initiator_scope(app, user_id)
  981. pub = oa_crud._find_latest_published(db, app_id, tenant_no)
  982. if not pub:
  983. raise OaEngineError("应用尚未发布")
  984. return {
  985. "id": app.id,
  986. "appName": app.app_name,
  987. "groupId": app.group_id or "",
  988. "iconUrl": app.icon_url or "",
  989. "description": app.description or "",
  990. "versionId": pub.id,
  991. "versionNo": pub.version_no,
  992. "formSchemaJson": _loads(pub.form_schema_json, {}),
  993. "flowTreeJson": _loads(pub.flow_tree_json, {}),
  994. "advancedSettingsJson": _advanced(pub),
  995. }
  996. _ASSIGNEE_TYPE_LABEL = {
  997. "users": "指定成员",
  998. "posts": "职位",
  999. "dept_leader": "发起人部门负责人",
  1000. "direct_supervisor": "发起人的主管",
  1001. "initiator_select": "发起人自选",
  1002. }
  1003. def _post_name_map(db: Session, tenant_no: str, post_ids: List[str]) -> List[str]:
  1004. names: List[str] = []
  1005. ids = [str(x).strip() for x in (post_ids or []) if str(x).strip()]
  1006. if not ids:
  1007. return names
  1008. rows = (
  1009. db.query(SysPost)
  1010. .filter(SysPost.tenant_no == tenant_no, SysPost.id.in_(ids), SysPost.del_flag == 0)
  1011. .all()
  1012. )
  1013. by_id = {r.id: (r.post_name or r.post_code or r.id) for r in rows}
  1014. for pid in ids:
  1015. label = by_id.get(pid)
  1016. if label and label not in names:
  1017. names.append(label)
  1018. return names
  1019. def _assignee_meta(db: Session, tenant_no: str, cfg: Dict[str, Any]) -> str:
  1020. assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
  1021. base = _ASSIGNEE_TYPE_LABEL.get(assignee_type, assignee_type or "未配置审批人")
  1022. if assignee_type == "posts":
  1023. names = _post_name_map(
  1024. db,
  1025. tenant_no,
  1026. cfg.get("postIds") or cfg.get("post_ids") or [],
  1027. )
  1028. if names:
  1029. return f"{base} · {'/'.join(names)}"
  1030. return base
  1031. def _assignee_meta_for_cc(
  1032. db: Session, tenant_no: str, cfg: Dict[str, Any], types: List[str]
  1033. ) -> str:
  1034. if not types:
  1035. return "未配置抄送人"
  1036. if len(types) == 1:
  1037. return _assignee_meta(db, tenant_no, {**cfg, "assigneeType": types[0]})
  1038. labels = [_ASSIGNEE_TYPE_LABEL.get(t, t) for t in types]
  1039. return " + ".join(labels)
  1040. def _preview_people(db: Session, user_ids: List[str]) -> List[Dict[str, str]]:
  1041. name_map = _user_name_map(db, user_ids)
  1042. out: List[Dict[str, str]] = []
  1043. for uid in user_ids:
  1044. out.append({"id": uid, "nickName": name_map.get(uid) or uid})
  1045. return out
  1046. _FLOW_PREVIEW_PENDING_MSG = "必填信息填写后,流程将自动显示"
  1047. _FLOW_PREVIEW_UNMATCHED_MSG = "流程条件无法匹配,请检查表单或联系管理员"
  1048. _FORMULA_FIELD_RE = re.compile(r"\{([^{}]+)\}")
  1049. def _form_value_present(form_data: Optional[Dict[str, Any]], field: str) -> bool:
  1050. """条件字段是否已有有效值(0 / false 视为已填)。"""
  1051. if not field:
  1052. return True
  1053. data = form_data or {}
  1054. if field not in data:
  1055. return False
  1056. val = data.get(field)
  1057. if val is None:
  1058. return False
  1059. if isinstance(val, str) and not val.strip():
  1060. return False
  1061. if isinstance(val, (list, tuple, set, dict)) and len(val) == 0:
  1062. return False
  1063. return True
  1064. def _collect_condition_fields(node: Optional[Dict[str, Any]]) -> List[str]:
  1065. """收集流程树中非默认条件分支引用的表单字段(去重保序)。
  1066. 系统伪字段 ``__initiator__`` 不依赖 formData,不纳入预览门闩。
  1067. """
  1068. fields: List[str] = []
  1069. seen = set()
  1070. def add(name: Any) -> None:
  1071. key = str(name or "").strip()
  1072. if not key or key in seen:
  1073. return
  1074. if key == INITIATOR_CONDITION_FIELD:
  1075. return
  1076. seen.add(key)
  1077. fields.append(key)
  1078. def walk(n: Any) -> None:
  1079. if not isinstance(n, dict):
  1080. return
  1081. if _node_type(n) == "condition":
  1082. for g in n.get("conditionGroups") or []:
  1083. if not isinstance(g, dict):
  1084. continue
  1085. rules = g.get("rules")
  1086. when = g.get("when")
  1087. if g.get("isDefault") or (not rules and not when):
  1088. child = g.get("child")
  1089. if isinstance(child, dict):
  1090. walk(child)
  1091. continue
  1092. if isinstance(rules, list) and rules:
  1093. for r in rules:
  1094. if isinstance(r, dict) and r.get("field") is not None:
  1095. add(r.get("field"))
  1096. elif isinstance(rules, str) and rules.strip():
  1097. for m in _FORMULA_FIELD_RE.findall(rules):
  1098. add(m)
  1099. elif when:
  1100. for m in _FORMULA_FIELD_RE.findall(str(when)):
  1101. add(m)
  1102. child = g.get("child")
  1103. if isinstance(child, dict):
  1104. walk(child)
  1105. join_child = n.get("child")
  1106. if isinstance(join_child, dict):
  1107. walk(join_child)
  1108. return
  1109. child = n.get("child")
  1110. if isinstance(child, dict):
  1111. walk(child)
  1112. for g in n.get("conditionGroups") or []:
  1113. if isinstance(g, dict) and isinstance(g.get("child"), dict):
  1114. walk(g.get("child"))
  1115. walk(node)
  1116. return fields
  1117. def _condition_fields_ready(
  1118. root: Optional[Dict[str, Any]], form_data: Optional[Dict[str, Any]]
  1119. ) -> bool:
  1120. fields = _collect_condition_fields(root)
  1121. if not fields:
  1122. return True
  1123. data = form_data or {}
  1124. return all(_form_value_present(data, f) for f in fields)
  1125. def _walk_preview_nodes(
  1126. db: Session,
  1127. *,
  1128. node: Optional[Dict[str, Any]],
  1129. tenant_no: str,
  1130. initiator_user_id: str,
  1131. initiator_dept_id: Optional[str],
  1132. advanced: Dict[str, Any],
  1133. initiator_select_by_node: Optional[Dict[str, List[str]]],
  1134. form_data: Optional[Dict[str, Any]],
  1135. steps: List[Dict[str, Any]],
  1136. flow_root: Optional[Dict[str, Any]] = None,
  1137. ) -> bool:
  1138. """沿最终路径写入 steps。条件无法匹配时返回 False。"""
  1139. cur = node
  1140. data = form_data or {}
  1141. tree_root = (
  1142. flow_root if isinstance(flow_root, dict) else (node if isinstance(node, dict) else {})
  1143. )
  1144. while cur and isinstance(cur, dict):
  1145. ntype = _node_type(cur)
  1146. if ntype == "start":
  1147. people = _preview_people(db, [initiator_user_id] if initiator_user_id else [])
  1148. steps.append(
  1149. {
  1150. "id": cur.get("id") or "",
  1151. "type": "start",
  1152. "name": cur.get("name") or "发起人",
  1153. "signMode": "",
  1154. "assigneeType": "",
  1155. "meta": "",
  1156. "people": people,
  1157. "resolveError": "",
  1158. }
  1159. )
  1160. cur = cur.get("child")
  1161. continue
  1162. if ntype == "end":
  1163. end_id = (cur.get("id") or "").strip()
  1164. # 分支叶子 end 后若有汇合主链,不写入「结束」(与引擎推进一致)
  1165. if end_id and _merge_after_branch_end(tree_root, end_id) is not None:
  1166. break
  1167. steps.append(
  1168. {
  1169. "id": cur.get("id") or "",
  1170. "type": "end",
  1171. "name": cur.get("name") or "结束",
  1172. "signMode": "",
  1173. "assigneeType": "",
  1174. "meta": "",
  1175. "people": [],
  1176. "resolveError": "",
  1177. }
  1178. )
  1179. break
  1180. if ntype == "condition":
  1181. groups = cur.get("conditionGroups") or []
  1182. g = _match_condition_group(
  1183. groups, data, initiator_user_id=initiator_user_id
  1184. )
  1185. if not g:
  1186. return False
  1187. child = g.get("child")
  1188. if isinstance(child, dict):
  1189. ok = _walk_preview_nodes(
  1190. db,
  1191. node=child,
  1192. tenant_no=tenant_no,
  1193. initiator_user_id=initiator_user_id,
  1194. initiator_dept_id=initiator_dept_id,
  1195. advanced=advanced,
  1196. initiator_select_by_node=initiator_select_by_node,
  1197. form_data=data,
  1198. steps=steps,
  1199. flow_root=tree_root,
  1200. )
  1201. if not ok:
  1202. return False
  1203. cur = cur.get("child")
  1204. continue
  1205. if ntype in ("approval", "cc"):
  1206. cfg = cur.get("approverConfig") or {}
  1207. sign_mode = (cfg.get("signMode") or cfg.get("sign_mode") or "").strip()
  1208. people: List[Dict[str, str]] = []
  1209. resolve_error = ""
  1210. node_select_ids = _ids_for_initiator_select_node(
  1211. initiator_select_by_node, cur.get("id") or ""
  1212. )
  1213. if ntype == "cc":
  1214. types = normalize_cc_assignee_types(cfg)
  1215. if "initiator_select" in types:
  1216. display_type = "initiator_select"
  1217. elif len(types) == 1:
  1218. display_type = types[0]
  1219. elif types:
  1220. display_type = "multi"
  1221. else:
  1222. display_type = ""
  1223. sign_mode = "cc"
  1224. meta = _assignee_meta_for_cc(db, tenant_no, cfg, types)
  1225. assignees, _meta = resolve_cc_assignees(
  1226. db,
  1227. tenant_no=tenant_no,
  1228. assignee_config=cfg,
  1229. initiator_user_id=initiator_user_id,
  1230. initiator_dept_id=initiator_dept_id,
  1231. advanced=advanced,
  1232. initiator_select_user_ids=node_select_ids,
  1233. )
  1234. people = _preview_people(db, assignees)
  1235. steps.append(
  1236. {
  1237. "id": cur.get("id") or "",
  1238. "type": ntype,
  1239. "name": cur.get("name") or "抄送人",
  1240. "signMode": sign_mode,
  1241. "assigneeType": display_type,
  1242. "assigneeTypes": types,
  1243. "meta": meta,
  1244. "people": people,
  1245. "resolveError": resolve_error,
  1246. }
  1247. )
  1248. else:
  1249. assignee_type = (
  1250. cfg.get("assigneeType") or cfg.get("assignee_type") or ""
  1251. ).strip()
  1252. if not sign_mode and assignee_type != "initiator_select":
  1253. sign_mode = "or"
  1254. meta = _assignee_meta(db, tenant_no, cfg)
  1255. if assignee_type == "initiator_select":
  1256. people = _preview_people(
  1257. db, [str(x) for x in node_select_ids if str(x).strip()]
  1258. )
  1259. if not people:
  1260. meta = meta or "发起人自选"
  1261. else:
  1262. try:
  1263. assignees, _meta = resolve_assignees(
  1264. db,
  1265. tenant_no=tenant_no,
  1266. assignee_config=cfg,
  1267. initiator_user_id=initiator_user_id,
  1268. initiator_dept_id=initiator_dept_id,
  1269. advanced=advanced,
  1270. initiator_select_user_ids=node_select_ids,
  1271. )
  1272. people = _preview_people(db, assignees)
  1273. except AssigneeResolveError as e:
  1274. resolve_error = str(e)
  1275. steps.append(
  1276. {
  1277. "id": cur.get("id") or "",
  1278. "type": ntype,
  1279. "name": cur.get("name") or "审批人",
  1280. "signMode": sign_mode,
  1281. "assigneeType": assignee_type,
  1282. "meta": meta,
  1283. "people": people,
  1284. "resolveError": resolve_error,
  1285. }
  1286. )
  1287. cur = cur.get("child")
  1288. continue
  1289. cur = cur.get("child")
  1290. return True
  1291. def preview_start_flow(
  1292. db: Session,
  1293. *,
  1294. tenant_no: str,
  1295. app_id: str,
  1296. initiator_user_id: str,
  1297. initiator_dept_id: Optional[str] = None,
  1298. initiator_select_by_node: Optional[Dict[str, List[str]]] = None,
  1299. form_data: Optional[Dict[str, Any]] = None,
  1300. ) -> Dict[str, Any]:
  1301. """发起前预览审批链:条件字段齐后只走命中路径并解析人员。"""
  1302. app = oa_crud.get_app(db, app_id, tenant_no)
  1303. if not app or app.status != "1":
  1304. raise OaEngineError("应用不存在或已停用")
  1305. _check_initiator_scope(app, initiator_user_id)
  1306. pub = oa_crud._find_latest_published(db, app_id, tenant_no)
  1307. if not pub:
  1308. raise OaEngineError("应用尚未发布")
  1309. initiator = (
  1310. db.query(SysUser)
  1311. .filter(
  1312. SysUser.id == initiator_user_id,
  1313. SysUser.tenant_no == tenant_no,
  1314. SysUser.del_flag == 0,
  1315. )
  1316. .first()
  1317. )
  1318. dept_id = (initiator_dept_id or "").strip() or (
  1319. (initiator.dept_id or "") if initiator else ""
  1320. )
  1321. flow = _loads(pub.flow_tree_json, {}) or {}
  1322. root = flow.get("root") if isinstance(flow, dict) else None
  1323. if not isinstance(root, dict):
  1324. root = flow if isinstance(flow, dict) else {}
  1325. data = dict(form_data or {})
  1326. if not _condition_fields_ready(root, data):
  1327. return {
  1328. "resolved": False,
  1329. "message": _FLOW_PREVIEW_PENDING_MSG,
  1330. "steps": [],
  1331. }
  1332. steps: List[Dict[str, Any]] = []
  1333. select_by_node = _normalize_initiator_select_by_node(initiator_select_by_node)
  1334. ok = _walk_preview_nodes(
  1335. db,
  1336. node=root,
  1337. tenant_no=tenant_no,
  1338. initiator_user_id=initiator_user_id,
  1339. initiator_dept_id=dept_id or None,
  1340. advanced=_advanced(pub),
  1341. initiator_select_by_node=select_by_node,
  1342. form_data=data,
  1343. steps=steps,
  1344. flow_root=root,
  1345. )
  1346. if not ok:
  1347. return {
  1348. "resolved": False,
  1349. "message": _FLOW_PREVIEW_UNMATCHED_MSG,
  1350. "steps": [],
  1351. }
  1352. return {"resolved": True, "message": "", "steps": steps}
  1353. def _create_tasks_for_node(
  1354. db: Session,
  1355. *,
  1356. inst: OaInstance,
  1357. node: Dict[str, Any],
  1358. ver: OaAppVersion,
  1359. user_id: Optional[str],
  1360. initiator_select_by_node: Optional[Dict[str, List[str]]] = None,
  1361. order_num_start: Optional[int] = None,
  1362. ) -> List[OaTask]:
  1363. ntype = _node_type(node)
  1364. now = datetime.now()
  1365. created: List[OaTask] = []
  1366. if ntype == "cc":
  1367. task_type = "cc"
  1368. elif ntype == "approval":
  1369. task_type = "approval"
  1370. else:
  1371. return created
  1372. cfg = node.get("approverConfig") or {}
  1373. node_select_ids = _ids_for_initiator_select_node(
  1374. initiator_select_by_node, node.get("id") or ""
  1375. )
  1376. advanced = _advanced(ver)
  1377. if ntype == "cc":
  1378. assignees, meta = resolve_cc_assignees(
  1379. db,
  1380. tenant_no=inst.tenant_no,
  1381. assignee_config=cfg,
  1382. initiator_user_id=inst.initiator_user_id,
  1383. initiator_dept_id=inst.initiator_dept_id,
  1384. advanced=advanced,
  1385. initiator_select_user_ids=node_select_ids,
  1386. )
  1387. if not assignees:
  1388. return created
  1389. else:
  1390. assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
  1391. if assignee_type == "initiator_select" and not node_select_ids:
  1392. raise OaEngineError("发起人未选择审批人")
  1393. try:
  1394. assignees, meta = resolve_assignees(
  1395. db,
  1396. tenant_no=inst.tenant_no,
  1397. assignee_config=cfg,
  1398. initiator_user_id=inst.initiator_user_id,
  1399. initiator_dept_id=inst.initiator_dept_id,
  1400. advanced=advanced,
  1401. initiator_select_user_ids=node_select_ids,
  1402. )
  1403. except AssigneeResolveError as e:
  1404. raise OaEngineError(str(e)) from e
  1405. sign_mode = (cfg.get("signMode") or cfg.get("sign_mode") or "or").strip() or "or"
  1406. resolve_source = meta.get("resolve_source")
  1407. status = "done" if task_type == "cc" else "pending"
  1408. seq = order_num_start if order_num_start is not None else _next_task_order_num(db, inst.id)
  1409. for uid in assignees:
  1410. t = OaTask(
  1411. id=generate_uuid(),
  1412. instance_id=inst.id,
  1413. node_key=node.get("id") or "",
  1414. node_name=node.get("name") or "",
  1415. task_type=task_type,
  1416. assignee_user_id=uid,
  1417. status=status,
  1418. comment_text=None,
  1419. resolve_source=resolve_source,
  1420. sign_mode=sign_mode if task_type == "approval" else None,
  1421. is_read=0,
  1422. read_time=None,
  1423. round_no=int(getattr(inst, "current_round", None) or 1),
  1424. tenant_no=inst.tenant_no,
  1425. order_num=seq,
  1426. remark=None,
  1427. create_by=user_id,
  1428. create_dept_id=inst.initiator_dept_id or "",
  1429. create_time=now,
  1430. update_by=user_id,
  1431. update_time=now,
  1432. del_flag=0,
  1433. )
  1434. seq += 1
  1435. db.add(t)
  1436. created.append(t)
  1437. return created
  1438. def _eval_condition_rules(
  1439. rules: List[Any],
  1440. form_data: Dict[str, Any],
  1441. *,
  1442. initiator_user_id: Optional[str] = None,
  1443. ) -> bool:
  1444. """AND 多条 {field, op, value};contains 走人员/职位/部门集合交集,其余走公式。"""
  1445. from app.services.oa_dept_select import dept_contains_match
  1446. from app.services.oa_person_select import person_contains_match
  1447. from app.services.oa_post_select import post_contains_match
  1448. for r in rules:
  1449. if not isinstance(r, dict):
  1450. return False
  1451. field = r.get("field")
  1452. op = r.get("op") or "=="
  1453. val = r.get("value")
  1454. if field is None:
  1455. return False
  1456. if op == "contains":
  1457. if field == INITIATOR_CONDITION_FIELD:
  1458. uid = (initiator_user_id or "").strip()
  1459. if isinstance(val, list):
  1460. allowed = {str(x or "").strip() for x in val if str(x or "").strip()}
  1461. elif isinstance(val, str) and val.strip():
  1462. allowed = {val.strip()}
  1463. else:
  1464. allowed = set()
  1465. if not uid or uid not in allowed:
  1466. return False
  1467. continue
  1468. form_val = form_data.get(field)
  1469. if not (
  1470. person_contains_match(form_val, val)
  1471. or post_contains_match(form_val, val)
  1472. or dept_contains_match(form_val, val)
  1473. ):
  1474. return False
  1475. continue
  1476. if isinstance(val, str):
  1477. expr = f"{{{field}}} {op} '{val}'"
  1478. else:
  1479. expr = f"{{{field}}} {op} {val}"
  1480. try:
  1481. if not eval_formula(expr, form_data):
  1482. return False
  1483. except FormulaError:
  1484. return False
  1485. return True
  1486. def _match_condition_group(
  1487. groups: List[Any],
  1488. form_data: Dict[str, Any],
  1489. *,
  1490. initiator_user_id: Optional[str] = None,
  1491. ) -> Optional[Dict[str, Any]]:
  1492. default_g = None
  1493. for g in groups:
  1494. if not isinstance(g, dict):
  1495. continue
  1496. rules = g.get("rules")
  1497. when = g.get("when")
  1498. # 空规则 = 默认分支
  1499. if (not rules and not when) or g.get("isDefault"):
  1500. default_g = g
  1501. continue
  1502. if when:
  1503. try:
  1504. if eval_formula(str(when), form_data):
  1505. return g
  1506. except FormulaError:
  1507. continue
  1508. continue
  1509. if isinstance(rules, str):
  1510. try:
  1511. if eval_formula(str(rules), form_data):
  1512. return g
  1513. except FormulaError:
  1514. continue
  1515. continue
  1516. if isinstance(rules, list) and rules:
  1517. try:
  1518. if _eval_condition_rules(
  1519. rules, form_data, initiator_user_id=initiator_user_id
  1520. ):
  1521. return g
  1522. except FormulaError:
  1523. continue
  1524. continue
  1525. default_g = g
  1526. return default_g
  1527. def advance_from(
  1528. db: Session,
  1529. *,
  1530. inst: OaInstance,
  1531. ver: OaAppVersion,
  1532. from_node: Optional[Dict[str, Any]],
  1533. user_id: Optional[str],
  1534. initiator_select_by_node: Optional[Dict[str, List[str]]] = None,
  1535. ) -> List[str]:
  1536. """从某节点的下一跳开始推进;from_node 为 None 表示从 start 进入。
  1537. 返回本次推进新创建的任务 id 列表(含抄送与嵌套自动通过产生的后续任务)。
  1538. """
  1539. root = _flow_root(_loads(ver.flow_tree_json, {}))
  1540. form_data = _loads(inst.form_data_json, {}) or {}
  1541. created_ids: List[str] = []
  1542. def next_node(node: Dict[str, Any]) -> Optional[Dict[str, Any]]:
  1543. ntype = _node_type(node)
  1544. if ntype == "condition":
  1545. groups = node.get("conditionGroups") or []
  1546. g = _match_condition_group(
  1547. groups, form_data, initiator_user_id=inst.initiator_user_id
  1548. )
  1549. if not g or not isinstance(g.get("child"), dict):
  1550. raise OaEngineError("条件分支无法匹配出线")
  1551. return g["child"]
  1552. child = node.get("child")
  1553. return child if isinstance(child, dict) else None
  1554. # 起点
  1555. if from_node is None:
  1556. cur = next_node(root) if _node_type(root) == "start" else root
  1557. else:
  1558. cur = next_node(from_node)
  1559. # 同一次推进里可能连续落抄送+审批:共用递增序号,避免 create_time 同秒时乱序
  1560. next_order = _next_task_order_num(db, inst.id)
  1561. safety = 0
  1562. while cur and safety < 50:
  1563. safety += 1
  1564. ntype = _node_type(cur)
  1565. if ntype == "end":
  1566. merge = _merge_after_branch_end(root, cur.get("id") or "")
  1567. if merge is not None:
  1568. cur = merge
  1569. continue
  1570. inst.status = "completed"
  1571. inst.update_time = datetime.now()
  1572. db.add(inst)
  1573. try:
  1574. apply_confirmation_kit_user_sync(db, inst)
  1575. except Exception:
  1576. logger.exception(
  1577. "confirmation kit user sync failed instance=%s", getattr(inst, "id", None)
  1578. )
  1579. try:
  1580. apply_resignation_kit_user_sync(db, inst)
  1581. except Exception:
  1582. logger.exception(
  1583. "resignation kit user sync failed instance=%s", getattr(inst, "id", None)
  1584. )
  1585. return created_ids
  1586. if ntype == "condition":
  1587. cur = next_node(cur)
  1588. continue
  1589. if ntype in ("approval", "cc"):
  1590. tasks = _create_tasks_for_node(
  1591. db,
  1592. inst=inst,
  1593. node=cur,
  1594. ver=ver,
  1595. user_id=user_id,
  1596. initiator_select_by_node=initiator_select_by_node,
  1597. order_num_start=next_order,
  1598. )
  1599. created_ids.extend([t.id for t in tasks if getattr(t, "id", None)])
  1600. next_order += max(len(tasks), 1)
  1601. if ntype == "cc":
  1602. # 抄送不阻断,继续往下
  1603. cur = next_node(cur)
  1604. continue
  1605. if not tasks:
  1606. raise OaEngineError("审批节点未生成任务")
  1607. nested = _maybe_auto_pass_initiator(
  1608. db,
  1609. inst=inst,
  1610. ver=ver,
  1611. tasks=tasks,
  1612. user_id=user_id,
  1613. )
  1614. created_ids.extend(nested or [])
  1615. deduped = _maybe_auto_pass_approver_dedup(
  1616. db,
  1617. inst=inst,
  1618. ver=ver,
  1619. tasks=tasks,
  1620. )
  1621. created_ids.extend(deduped or [])
  1622. return created_ids
  1623. # 未知类型跳过子节点
  1624. cur = next_node(cur)
  1625. # 无结束也无待办:异常结束
  1626. if inst.status == "running":
  1627. raise OaEngineError("流程推进异常:未到达结束且无待办任务")
  1628. return created_ids
  1629. def start_instance(
  1630. db: Session,
  1631. *,
  1632. tenant_no: str,
  1633. app_id: str,
  1634. initiator_user_id: str,
  1635. initiator_dept_id: Optional[str] = None,
  1636. form_data: Optional[Dict[str, Any]] = None,
  1637. title: Optional[str] = None,
  1638. initiator_select_by_node: Optional[Dict[str, List[str]]] = None,
  1639. related_instance_ids: Optional[List[str]] = None,
  1640. source_instance_id: Optional[str] = None,
  1641. ) -> Dict[str, Any]:
  1642. # source_instance_id 保留入参兼容,不再写入关联(复制开新单不挂旧单)
  1643. _ = source_instance_id
  1644. app = oa_crud.get_app(db, app_id, tenant_no)
  1645. if not app or app.status != "1":
  1646. raise OaEngineError("应用不存在或已停用")
  1647. _check_initiator_scope(app, initiator_user_id)
  1648. ver = oa_crud._find_latest_published(db, app_id, tenant_no)
  1649. if not ver:
  1650. raise OaEngineError("应用尚未发布,无法发起")
  1651. schema = _loads(ver.form_schema_json, {})
  1652. data = dict(form_data or {})
  1653. from app.services.oa_datetime_normalize import normalize_datetime_form_values
  1654. # 小程序历史默认到分、PC 默认到秒:入库前对「期望到秒」字段补 :00
  1655. data = normalize_datetime_form_values(schema, data)
  1656. try:
  1657. data = apply_formulas_and_linkages(schema, data)
  1658. except FormulaError as e:
  1659. raise OaEngineError(f"公式计算失败: {e}") from e
  1660. from app.services.oa_leave_hours import apply_leave_kit_hours, validate_leave_kit_form_values
  1661. from app.services.oa_trip_days import apply_trip_kit_days, validate_trip_kit_form_values
  1662. from app.services.oa_overtime_hours import (
  1663. apply_overtime_kit_hours,
  1664. validate_overtime_kit_form_values,
  1665. )
  1666. from app.services.cn_holiday import is_rest_day as cn_is_rest_day
  1667. data = apply_leave_kit_hours(schema, data)
  1668. leave_errs = validate_leave_kit_form_values(schema, data)
  1669. if leave_errs:
  1670. raise OaEngineError(leave_errs[0])
  1671. data = apply_trip_kit_days(schema, data)
  1672. trip_errs = validate_trip_kit_form_values(schema, data)
  1673. if trip_errs:
  1674. raise OaEngineError(trip_errs[0])
  1675. def _ot_rest(ymd: str) -> bool:
  1676. from datetime import datetime as _dt
  1677. d = _dt.strptime(ymd, "%Y-%m-%d").date()
  1678. return cn_is_rest_day(db, d)
  1679. data = apply_overtime_kit_hours(schema, data, _ot_rest)
  1680. ot_errs = validate_overtime_kit_form_values(schema, data)
  1681. if ot_errs:
  1682. raise OaEngineError(ot_errs[0])
  1683. user = (
  1684. db.query(SysUser)
  1685. .filter(SysUser.id == initiator_user_id, SysUser.del_flag == 0)
  1686. .first()
  1687. )
  1688. dept_id = (initiator_dept_id or (user.dept_id if user else "") or "").strip() or None
  1689. from app.services.oa_owner_fields import apply_owner_field_defaults
  1690. data = apply_owner_field_defaults(
  1691. schema,
  1692. data,
  1693. user_id=initiator_user_id,
  1694. user_name=(user.nick_name if user else "") or "",
  1695. dept_id=dept_id,
  1696. dept_name=_dept_name(db, dept_id, tenant_no=tenant_no) if dept_id else None,
  1697. )
  1698. from app.services.oa_dept_select import validate_dept_form_values
  1699. from app.services.oa_money_field import validate_money_form_values
  1700. from app.services.oa_person_select import validate_person_form_values
  1701. from app.services.oa_post_select import validate_post_form_values
  1702. from app.services.oa_receive_account import validate_receive_account_form_values
  1703. dept_errs = validate_dept_form_values(schema, data)
  1704. if dept_errs:
  1705. raise OaEngineError(dept_errs[0])
  1706. money_errs = validate_money_form_values(schema, data)
  1707. if money_errs:
  1708. raise OaEngineError(money_errs[0])
  1709. person_errs = validate_person_form_values(schema, data)
  1710. if person_errs:
  1711. raise OaEngineError(person_errs[0])
  1712. post_errs = validate_post_form_values(schema, data)
  1713. if post_errs:
  1714. raise OaEngineError(post_errs[0])
  1715. recv_errs = validate_receive_account_form_values(schema, data)
  1716. if recv_errs:
  1717. raise OaEngineError(recv_errs[0])
  1718. from app.services.oa_receive_account import validate_receive_account_ownership
  1719. own_errs = validate_receive_account_ownership(
  1720. db,
  1721. schema,
  1722. data,
  1723. tenant_no=tenant_no,
  1724. initiator_user_id=initiator_user_id,
  1725. )
  1726. if own_errs:
  1727. raise OaEngineError(own_errs[0])
  1728. now = datetime.now()
  1729. title_text = (title or app.app_name or "").strip()
  1730. normalized_select = _normalize_initiator_select_by_node(initiator_select_by_node)
  1731. inst = OaInstance(
  1732. id=generate_uuid(),
  1733. app_id=app_id,
  1734. app_version_id=ver.id,
  1735. title=title_text[:200],
  1736. initiator_user_id=initiator_user_id,
  1737. initiator_dept_id=dept_id,
  1738. form_data_json=_dumps(data),
  1739. initiator_select_json=_dumps(normalized_select),
  1740. status="running",
  1741. source_instance_id=None,
  1742. current_round=1,
  1743. tenant_no=tenant_no,
  1744. order_num=0,
  1745. remark=None,
  1746. create_by=initiator_user_id,
  1747. create_dept_id=dept_id or "",
  1748. create_time=now,
  1749. update_by=initiator_user_id,
  1750. update_time=now,
  1751. del_flag=0,
  1752. )
  1753. db.add(inst)
  1754. from app.services.oa_related_approval import (
  1755. assert_linkable_instance,
  1756. merge_relation_targets,
  1757. )
  1758. targets = merge_relation_targets(schema, data, related_instance_ids)
  1759. for field_key, to_id in targets:
  1760. target = (
  1761. db.query(OaInstance)
  1762. .filter(
  1763. OaInstance.id == to_id,
  1764. OaInstance.tenant_no == tenant_no,
  1765. OaInstance.del_flag == 0,
  1766. )
  1767. .first()
  1768. )
  1769. assert_linkable_instance(
  1770. target, user_id=initiator_user_id, tenant_no=tenant_no, db=db
  1771. )
  1772. db.add(
  1773. OaRelation(
  1774. id=generate_uuid(),
  1775. from_instance_id=inst.id,
  1776. to_instance_id=to_id,
  1777. field_key=field_key,
  1778. tenant_no=tenant_no,
  1779. order_num=0,
  1780. remark=None,
  1781. create_by=initiator_user_id,
  1782. create_dept_id=dept_id or "",
  1783. create_time=now,
  1784. update_by=initiator_user_id,
  1785. update_time=now,
  1786. del_flag=0,
  1787. )
  1788. )
  1789. _add_instance_event(
  1790. db,
  1791. inst=inst,
  1792. event_type="submit",
  1793. actor_user_id=initiator_user_id,
  1794. round_no=1,
  1795. )
  1796. created = advance_from(
  1797. db,
  1798. inst=inst,
  1799. ver=ver,
  1800. from_node=None,
  1801. user_id=initiator_user_id,
  1802. initiator_select_by_node=normalized_select,
  1803. )
  1804. db.commit()
  1805. from app.services.oa_wechat_notify import schedule_oa_task_notifies
  1806. schedule_oa_task_notifies(created or [])
  1807. db.refresh(inst)
  1808. return get_instance_detail(db, inst.id, tenant_no=tenant_no)
  1809. def _get_instance(db: Session, instance_id: str, tenant_no: str) -> OaInstance:
  1810. inst = (
  1811. db.query(OaInstance)
  1812. .filter(OaInstance.id == instance_id, OaInstance.tenant_no == tenant_no, OaInstance.del_flag == 0)
  1813. .first()
  1814. )
  1815. if not inst:
  1816. raise OaEngineError("实例不存在")
  1817. return inst
  1818. def get_instance_detail(
  1819. db: Session, instance_id: str, *, tenant_no: str, viewer_user_id: str = ""
  1820. ) -> Dict[str, Any]:
  1821. inst = _get_instance(db, instance_id, tenant_no)
  1822. app = oa_crud.get_app(db, inst.app_id, tenant_no)
  1823. ver = (
  1824. db.query(OaAppVersion)
  1825. .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
  1826. .first()
  1827. )
  1828. tasks = (
  1829. db.query(OaTask)
  1830. .filter(OaTask.instance_id == instance_id, OaTask.del_flag == 0)
  1831. .all()
  1832. )
  1833. flow_root = None
  1834. if ver:
  1835. try:
  1836. flow_root = _flow_root(_loads(ver.flow_tree_json, {}))
  1837. except OaEngineError:
  1838. flow_root = None
  1839. tasks = _sort_tasks_for_detail(tasks, flow_root=flow_root)
  1840. rels = (
  1841. db.query(OaRelation)
  1842. .filter(OaRelation.from_instance_id == instance_id, OaRelation.del_flag == 0)
  1843. .all()
  1844. )
  1845. name_map = _user_name_map(
  1846. db,
  1847. [t.assignee_user_id for t in tasks if t.assignee_user_id]
  1848. + ([inst.initiator_user_id] if inst.initiator_user_id else []),
  1849. )
  1850. data = _instance_to_dict(inst, app_name=app.app_name if app else "")
  1851. data["initiatorUserName"] = name_map.get(inst.initiator_user_id or "", inst.initiator_user_id or "")
  1852. data["initiatorDeptName"] = _dept_name(
  1853. db, inst.initiator_dept_id, tenant_no=tenant_no
  1854. )
  1855. data["tasks"] = [
  1856. _task_to_dict(t, assignee_user_name=name_map.get(t.assignee_user_id or "", ""))
  1857. for t in tasks
  1858. ]
  1859. data["relatedInstanceIds"] = [r.to_instance_id for r in rels]
  1860. data["formSchemaJson"] = _loads(ver.form_schema_json, {}) if ver else {}
  1861. from app.services.oa_datetime_normalize import normalize_datetime_form_values
  1862. # 详情回显:历史到分值补秒,避免 PC Element 日期控件解析失败显示空白
  1863. data["formDataJson"] = normalize_datetime_form_values(
  1864. data["formSchemaJson"], data.get("formDataJson") or {}
  1865. )
  1866. adv = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
  1867. data["advancedSettingsJson"] = adv
  1868. data["allowAddSign"] = bool(adv.get("allowAddSign", True))
  1869. data["allowReturn"] = bool(adv.get("allowReturn", True))
  1870. events = (
  1871. db.query(OaInstanceEvent)
  1872. .filter(OaInstanceEvent.instance_id == instance_id, OaInstanceEvent.del_flag == 0)
  1873. .order_by(OaInstanceEvent.create_time.asc())
  1874. .all()
  1875. )
  1876. event_actors = [e.actor_user_id for e in events if e.actor_user_id]
  1877. if event_actors:
  1878. name_map.update(_user_name_map(db, event_actors))
  1879. data["progress"] = build_progress_items(
  1880. inst=inst,
  1881. flow_root=flow_root,
  1882. tasks=tasks,
  1883. name_map=name_map,
  1884. events=events,
  1885. db=db,
  1886. advanced=adv,
  1887. )
  1888. # 不再暴露旧单/重提单关联跳转
  1889. data["sourceInstanceId"] = ""
  1890. data["resubmittedInstanceId"] = ""
  1891. from app.services import oa_instance_comment as oa_comment
  1892. data["comments"] = oa_comment.list_comments(
  1893. db,
  1894. instance_id=instance_id,
  1895. tenant_no=tenant_no,
  1896. viewer_user_id=viewer_user_id or "",
  1897. )
  1898. return data
  1899. def _normalize_cc_read_status(raw: Optional[str]) -> str:
  1900. v = (raw or "").strip().lower() or "unread"
  1901. if v not in ("unread", "read", "all"):
  1902. raise OaEngineError("readStatus 仅支持 unread、read、all")
  1903. return v
  1904. def _apply_cc_task_read(
  1905. db: Session,
  1906. *,
  1907. task: OaTask,
  1908. user_id: str,
  1909. now: datetime,
  1910. ) -> bool:
  1911. """将抄送任务标为已读。返回是否发生了写入。幂等时返回 False。"""
  1912. if int(getattr(task, "is_read", 0) or 0) == 1:
  1913. return False
  1914. task.is_read = 1
  1915. task.read_time = now
  1916. task.update_by = user_id
  1917. task.update_time = now
  1918. db.add(task)
  1919. return True
  1920. def count_cc_unread(db: Session, *, tenant_no: str, user_id: str) -> int:
  1921. return (
  1922. db.query(OaTask)
  1923. .filter(
  1924. OaTask.tenant_no == tenant_no,
  1925. OaTask.del_flag == 0,
  1926. OaTask.assignee_user_id == user_id,
  1927. OaTask.task_type == "cc",
  1928. OaTask.is_read == 0,
  1929. )
  1930. .count()
  1931. )
  1932. def count_pending_tasks(db: Session, *, tenant_no: str, user_id: str) -> int:
  1933. return (
  1934. db.query(OaTask)
  1935. .filter(
  1936. OaTask.tenant_no == tenant_no,
  1937. OaTask.del_flag == 0,
  1938. OaTask.assignee_user_id == user_id,
  1939. OaTask.task_type == "approval",
  1940. OaTask.status == "pending",
  1941. )
  1942. .count()
  1943. )
  1944. def mark_cc_task_read(
  1945. db: Session, *, tenant_no: str, user_id: str, task_id: str
  1946. ) -> Dict[str, Any]:
  1947. task = (
  1948. db.query(OaTask)
  1949. .filter(
  1950. OaTask.id == task_id,
  1951. OaTask.tenant_no == tenant_no,
  1952. OaTask.del_flag == 0,
  1953. )
  1954. .first()
  1955. )
  1956. if not task or task.task_type != "cc" or task.assignee_user_id != user_id:
  1957. raise OaEngineError("抄送任务不存在或无权操作")
  1958. now = datetime.now()
  1959. _apply_cc_task_read(db, task=task, user_id=user_id, now=now)
  1960. db.commit()
  1961. db.refresh(task)
  1962. return _task_to_dict(task)
  1963. def mark_cc_tasks_read_batch(
  1964. db: Session, *, tenant_no: str, user_id: str, ids: List[str]
  1965. ) -> Dict[str, Any]:
  1966. id_list = [x.strip() for x in (ids or []) if (x or "").strip()]
  1967. if not id_list:
  1968. return {"updated": 0}
  1969. rows = (
  1970. db.query(OaTask)
  1971. .filter(
  1972. OaTask.tenant_no == tenant_no,
  1973. OaTask.del_flag == 0,
  1974. OaTask.assignee_user_id == user_id,
  1975. OaTask.task_type == "cc",
  1976. OaTask.id.in_(id_list),
  1977. )
  1978. .all()
  1979. )
  1980. now = datetime.now()
  1981. updated = 0
  1982. for t in rows:
  1983. if _apply_cc_task_read(db, task=t, user_id=user_id, now=now):
  1984. updated += 1
  1985. db.commit()
  1986. return {"updated": updated}
  1987. _INSTANCE_STATUS_FILTER_VALUES = frozenset(
  1988. {"running", "completed", "rejected", "returned", "cancelled"}
  1989. )
  1990. _FINISH_STATUSES = frozenset({"completed", "rejected", "cancelled"})
  1991. def _parse_day_start(s: Optional[str]) -> Optional[datetime]:
  1992. raw = (s or "").strip()
  1993. if not raw:
  1994. return None
  1995. try:
  1996. d = datetime.strptime(raw[:10], "%Y-%m-%d")
  1997. except ValueError:
  1998. return None
  1999. return d.replace(hour=0, minute=0, second=0, microsecond=0)
  2000. def _parse_day_end(s: Optional[str]) -> Optional[datetime]:
  2001. raw = (s or "").strip()
  2002. if not raw:
  2003. return None
  2004. try:
  2005. d = datetime.strptime(raw[:10], "%Y-%m-%d")
  2006. except ValueError:
  2007. return None
  2008. return d.replace(hour=23, minute=59, second=59, microsecond=999999)
  2009. def _apply_instance_list_filters(
  2010. q,
  2011. *,
  2012. app_name: Optional[str] = None,
  2013. initiator_name: Optional[str] = None,
  2014. keyword: Optional[str] = None,
  2015. apply_time_from: Optional[str] = None,
  2016. apply_time_to: Optional[str] = None,
  2017. instance_status: Optional[str] = None,
  2018. joined_app: bool = False,
  2019. joined_initiator: bool = False,
  2020. ):
  2021. """在已包含 OaInstance 的 query 上叠加列表筛选。返回 (query, joined_app, joined_initiator)。"""
  2022. combined = (keyword or "").strip()
  2023. if combined:
  2024. if not joined_app:
  2025. q = q.outerjoin(
  2026. OaApp,
  2027. (OaApp.id == OaInstance.app_id) & (OaApp.del_flag == 0),
  2028. )
  2029. joined_app = True
  2030. if not joined_initiator:
  2031. q = q.outerjoin(
  2032. SysUser,
  2033. (SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
  2034. )
  2035. joined_initiator = True
  2036. like = f"%{combined}%"
  2037. q = q.filter(
  2038. or_(
  2039. OaApp.app_name.like(like),
  2040. OaInstance.title.like(like),
  2041. SysUser.nick_name.like(like),
  2042. )
  2043. )
  2044. else:
  2045. kw = (app_name or "").strip()
  2046. if kw:
  2047. if not joined_app:
  2048. q = q.outerjoin(
  2049. OaApp,
  2050. (OaApp.id == OaInstance.app_id) & (OaApp.del_flag == 0),
  2051. )
  2052. joined_app = True
  2053. like = f"%{kw}%"
  2054. q = q.filter(or_(OaApp.app_name.like(like), OaInstance.title.like(like)))
  2055. iname = (initiator_name or "").strip()
  2056. if iname:
  2057. if not joined_initiator:
  2058. q = q.outerjoin(
  2059. SysUser,
  2060. (SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
  2061. )
  2062. joined_initiator = True
  2063. q = q.filter(SysUser.nick_name.like(f"%{iname}%"))
  2064. t0 = _parse_day_start(apply_time_from)
  2065. if t0 is not None:
  2066. q = q.filter(OaInstance.create_time >= t0)
  2067. t1 = _parse_day_end(apply_time_to)
  2068. if t1 is not None:
  2069. q = q.filter(OaInstance.create_time <= t1)
  2070. st = (instance_status or "").strip()
  2071. if st in _INSTANCE_STATUS_FILTER_VALUES:
  2072. q = q.filter(OaInstance.status == st)
  2073. return q, joined_app, joined_initiator
  2074. def _filter_by_initiator_dept(q, db: Session, *, tenant_no: str, initiator_dept_id: Optional[str]):
  2075. """按发起部门(含下级)过滤;空参数不改 query。"""
  2076. did = (initiator_dept_id or "").strip()
  2077. if not did:
  2078. return q
  2079. from app.core.data_scope import _get_child_dept_ids
  2080. dept_ids = _get_child_dept_ids(db, did, tenant_no)
  2081. if not dept_ids:
  2082. return q.filter(OaInstance.id == "")
  2083. return q.filter(OaInstance.initiator_dept_id.in_(dept_ids))
  2084. def list_instance_history(
  2085. db: Session,
  2086. *,
  2087. tenant_no: str,
  2088. user_id: str,
  2089. can_view_all: bool,
  2090. app_id: str,
  2091. instance_id: Optional[str] = None,
  2092. initiator_name: Optional[str] = None,
  2093. apply_time_from: Optional[str] = None,
  2094. apply_time_to: Optional[str] = None,
  2095. finish_time_from: Optional[str] = None,
  2096. finish_time_to: Optional[str] = None,
  2097. instance_status: Optional[str] = None,
  2098. initiator_dept_id: Optional[str] = None,
  2099. page: int = 1,
  2100. page_size: int = 20,
  2101. ) -> Dict[str, Any]:
  2102. app_id = (app_id or "").strip()
  2103. if not app_id:
  2104. raise OaEngineError("appId 不能为空")
  2105. page = max(1, int(page or 1))
  2106. page_size = min(100, max(1, int(page_size or 20)))
  2107. offset = (page - 1) * page_size
  2108. q = db.query(OaInstance).filter(
  2109. OaInstance.tenant_no == tenant_no,
  2110. OaInstance.del_flag == 0,
  2111. OaInstance.app_id == app_id,
  2112. )
  2113. if not can_view_all:
  2114. q = q.filter(OaInstance.initiator_user_id == user_id)
  2115. initiator_name = None
  2116. q = _filter_by_initiator_dept(
  2117. q, db, tenant_no=tenant_no, initiator_dept_id=initiator_dept_id
  2118. )
  2119. iid = (instance_id or "").strip()
  2120. if iid:
  2121. q = q.filter(OaInstance.id.like(f"%{iid}%"))
  2122. q, _, _ = _apply_instance_list_filters(
  2123. q,
  2124. initiator_name=initiator_name,
  2125. apply_time_from=apply_time_from,
  2126. apply_time_to=apply_time_to,
  2127. instance_status=instance_status,
  2128. )
  2129. ft0 = _parse_day_start(finish_time_from)
  2130. ft1 = _parse_day_end(finish_time_to)
  2131. if ft0 is not None or ft1 is not None:
  2132. q = q.filter(OaInstance.status.in_(tuple(_FINISH_STATUSES)))
  2133. if ft0 is not None:
  2134. q = q.filter(OaInstance.update_time >= ft0)
  2135. if ft1 is not None:
  2136. q = q.filter(OaInstance.update_time <= ft1)
  2137. total = q.count()
  2138. insts = (
  2139. q.order_by(OaInstance.create_time.desc())
  2140. .offset(offset)
  2141. .limit(page_size)
  2142. .all()
  2143. )
  2144. rows = [_instance_to_dict(i) for i in insts]
  2145. return {
  2146. "list": _enrich_list_rows(
  2147. db,
  2148. tenant_no=tenant_no,
  2149. rows=rows,
  2150. instances=insts,
  2151. preview_limit=6,
  2152. ),
  2153. "total": int(total),
  2154. "canViewAll": bool(can_view_all),
  2155. }
  2156. _HISTORY_EXPORT_MAX_ROWS = 5000
  2157. _EXPORT_RESULT_TEXT = {
  2158. "completed": "审批通过",
  2159. "approved": "审批通过",
  2160. "rejected": "审批拒绝",
  2161. "returned": "已打回",
  2162. "cancelled": "已撤销",
  2163. "running": "",
  2164. "pending": "",
  2165. }
  2166. _EXPORT_STATUS_TEXT = {
  2167. "completed": "已结束",
  2168. "approved": "已结束",
  2169. "rejected": "已结束",
  2170. "cancelled": "已结束",
  2171. "returned": "已打回",
  2172. "running": "进行中",
  2173. "pending": "待处理",
  2174. }
  2175. _EXPORT_ACTION_TEXT = {
  2176. "submitted": "提交申请",
  2177. "approved": "同意",
  2178. "rejected": "拒绝",
  2179. "returned": "打回",
  2180. "completed": "同意",
  2181. "pending": "待处理",
  2182. "cancelled": "取消",
  2183. "transferred": "转交",
  2184. "done": "抄送",
  2185. }
  2186. _FIXED_HISTORY_EXPORT_COLUMNS: List[Tuple[str, str]] = [
  2187. ("instanceNo", "审批编号"),
  2188. ("createTime", "创建时间"),
  2189. ("initiatorName", "创建人"),
  2190. ("currentAssignee", "当前负责人"),
  2191. ("approvalResult", "审批结果"),
  2192. ("approvalStatus", "审批状态"),
  2193. ("updateTime", "更新时间"),
  2194. ("finishTime", "完成时间"),
  2195. ("initiatorDept", "创建人部门"),
  2196. ("title", "审批单标题"),
  2197. ("historyApprovers", "历史审批人"),
  2198. ("duration", "耗时(时:分:秒)"),
  2199. ("approvalRecord", "审批记录"),
  2200. ]
  2201. def _format_export_duration(start: Optional[datetime], end: Optional[datetime]) -> str:
  2202. if not start or not end:
  2203. return ""
  2204. delta = end - start
  2205. if delta.total_seconds() < 0:
  2206. return ""
  2207. total = int(delta.total_seconds())
  2208. h, rem = divmod(total, 3600)
  2209. m, s = divmod(rem, 60)
  2210. return f"{h}:{m}:{s}"
  2211. def _export_upload_cell_value(field: Dict[str, Any]) -> str:
  2212. files = field.get("files") or []
  2213. if not isinstance(files, list):
  2214. return str(field.get("value") or "")
  2215. urls: List[str] = []
  2216. for f in files:
  2217. if not isinstance(f, dict):
  2218. continue
  2219. u = str(f.get("url") or "").strip()
  2220. if u:
  2221. urls.append(u)
  2222. if urls:
  2223. return ",".join(urls)
  2224. return str(field.get("value") or "")
  2225. def _export_progress_action(status: str, *, task_type: str = "") -> str:
  2226. st = (status or "").strip()
  2227. if (task_type or "").strip() == "cc" or st == "done":
  2228. return "抄送"
  2229. return _EXPORT_ACTION_TEXT.get(st, st or "")
  2230. def _format_export_progress_record(progress: List[Dict[str, Any]]) -> str:
  2231. lines: List[str] = []
  2232. for item in progress or []:
  2233. kind = (item.get("kind") or "").strip()
  2234. title = (item.get("title") or "").strip()
  2235. ts = (item.get("timestamp") or "").strip()
  2236. task_type = (item.get("taskType") or "").strip()
  2237. if kind == "submit":
  2238. name = (item.get("assigneeUserName") or "").strip()
  2239. if not name:
  2240. continue
  2241. lines.append(f"{name}|提交申请|{ts}|提交申请")
  2242. continue
  2243. people = item.get("people") or []
  2244. if isinstance(people, list) and people:
  2245. for p in people:
  2246. if not isinstance(p, dict):
  2247. continue
  2248. name = (p.get("nickName") or p.get("assigneeUserName") or "").strip()
  2249. if not name:
  2250. continue
  2251. pst = (p.get("status") or "").strip()
  2252. if pst in ("pending", "running", "cancelled") and task_type != "cc":
  2253. continue
  2254. node = title or (item.get("nodeKey") or "").strip() or "-"
  2255. pts = (p.get("timestamp") or ts or "").strip()
  2256. action = _export_progress_action(pst, task_type=task_type)
  2257. comment = (p.get("commentText") or "").strip()
  2258. seg = f"{name}|{node}|{pts}|{action}"
  2259. if comment:
  2260. seg += f"|{comment}"
  2261. lines.append(seg)
  2262. continue
  2263. name = (item.get("assigneeUserName") or "").strip()
  2264. if not name:
  2265. continue
  2266. st = (item.get("status") or "").strip()
  2267. if st in ("pending", "running", "cancelled") and task_type != "cc":
  2268. continue
  2269. node = title or (item.get("nodeKey") or "").strip() or "-"
  2270. action = _export_progress_action(st, task_type=task_type)
  2271. comment = (item.get("commentText") or "").strip()
  2272. seg = f"{name}|{node}|{ts}|{action}"
  2273. if comment:
  2274. seg += f"|{comment}"
  2275. lines.append(seg)
  2276. return ";\r\n".join(lines)
  2277. def _collect_history_approver_names(progress: List[Dict[str, Any]]) -> str:
  2278. names: List[str] = []
  2279. seen = set()
  2280. for item in progress or []:
  2281. if (item.get("kind") or "").strip() == "submit":
  2282. continue
  2283. people = item.get("people") or []
  2284. if isinstance(people, list) and people:
  2285. for p in people:
  2286. if not isinstance(p, dict):
  2287. continue
  2288. st = (p.get("status") or "").strip()
  2289. if st not in ("approved", "completed", "rejected", "returned", "transferred"):
  2290. continue
  2291. name = (p.get("nickName") or p.get("assigneeUserName") or "").strip()
  2292. if name and name not in seen:
  2293. seen.add(name)
  2294. names.append(name)
  2295. continue
  2296. st = (item.get("status") or "").strip()
  2297. if st not in ("approved", "completed", "rejected", "returned", "transferred"):
  2298. continue
  2299. name = (item.get("assigneeUserName") or "").strip()
  2300. if name and name not in seen:
  2301. seen.add(name)
  2302. names.append(name)
  2303. return ",".join(names)
  2304. def _build_history_export_business_columns(schema: Any) -> List[Dict[str, Any]]:
  2305. from app.services.oa_related_approval import LAYOUT_SKIP, _schema_rules
  2306. cols: List[Dict[str, Any]] = []
  2307. for rule in _schema_rules(schema):
  2308. typ = str(rule.get("type") or "")
  2309. if not typ or typ in LAYOUT_SKIP:
  2310. continue
  2311. field = str(rule.get("field") or "").strip()
  2312. if not field:
  2313. continue
  2314. label = str(rule.get("title") or rule.get("label") or field)
  2315. if typ == "relatedApproval":
  2316. cols.append(
  2317. {
  2318. "key": field,
  2319. "label": label,
  2320. "kind": "related",
  2321. "subLabel": "关联标题",
  2322. }
  2323. )
  2324. else:
  2325. cols.append({"key": field, "label": label, "kind": "business"})
  2326. return cols
  2327. def _merge_history_export_business_columns(col_lists: List[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
  2328. merged: List[Dict[str, Any]] = []
  2329. seen = set()
  2330. for cols in col_lists:
  2331. for c in cols:
  2332. key = str(c.get("key") or "")
  2333. if not key or key in seen:
  2334. continue
  2335. seen.add(key)
  2336. merged.append(dict(c))
  2337. return merged
  2338. def _history_export_query(
  2339. db: Session,
  2340. *,
  2341. tenant_no: str,
  2342. user_id: str,
  2343. can_view_all: bool,
  2344. app_id: str,
  2345. instance_id: Optional[str] = None,
  2346. instance_ids: Optional[List[str]] = None,
  2347. initiator_name: Optional[str] = None,
  2348. apply_time_from: Optional[str] = None,
  2349. apply_time_to: Optional[str] = None,
  2350. finish_time_from: Optional[str] = None,
  2351. finish_time_to: Optional[str] = None,
  2352. instance_status: Optional[str] = None,
  2353. initiator_dept_id: Optional[str] = None,
  2354. ):
  2355. q = db.query(OaInstance).filter(
  2356. OaInstance.tenant_no == tenant_no,
  2357. OaInstance.del_flag == 0,
  2358. OaInstance.app_id == app_id,
  2359. )
  2360. if not can_view_all:
  2361. q = q.filter(OaInstance.initiator_user_id == user_id)
  2362. initiator_name = None
  2363. q = _filter_by_initiator_dept(
  2364. q, db, tenant_no=tenant_no, initiator_dept_id=initiator_dept_id
  2365. )
  2366. clean_ids = [str(x).strip() for x in (instance_ids or []) if str(x).strip()]
  2367. if clean_ids:
  2368. q = q.filter(OaInstance.id.in_(clean_ids))
  2369. else:
  2370. iid = (instance_id or "").strip()
  2371. if iid:
  2372. q = q.filter(OaInstance.id.like(f"%{iid}%"))
  2373. q, _, _ = _apply_instance_list_filters(
  2374. q,
  2375. initiator_name=initiator_name,
  2376. apply_time_from=apply_time_from,
  2377. apply_time_to=apply_time_to,
  2378. instance_status=instance_status,
  2379. )
  2380. ft0 = _parse_day_start(finish_time_from)
  2381. ft1 = _parse_day_end(finish_time_to)
  2382. if ft0 is not None or ft1 is not None:
  2383. q = q.filter(OaInstance.status.in_(tuple(_FINISH_STATUSES)))
  2384. if ft0 is not None:
  2385. q = q.filter(OaInstance.update_time >= ft0)
  2386. if ft1 is not None:
  2387. q = q.filter(OaInstance.update_time <= ft1)
  2388. return q
  2389. def export_instance_history(
  2390. db: Session,
  2391. *,
  2392. tenant_no: str,
  2393. user_id: str,
  2394. can_view_all: bool,
  2395. app_id: str,
  2396. instance_id: Optional[str] = None,
  2397. instance_ids: Optional[List[str]] = None,
  2398. initiator_name: Optional[str] = None,
  2399. apply_time_from: Optional[str] = None,
  2400. apply_time_to: Optional[str] = None,
  2401. finish_time_from: Optional[str] = None,
  2402. finish_time_to: Optional[str] = None,
  2403. instance_status: Optional[str] = None,
  2404. initiator_dept_id: Optional[str] = None,
  2405. ) -> Dict[str, Any]:
  2406. from app.services.oa_related_approval import flatten_form_fields_for_summary
  2407. app_id = (app_id or "").strip()
  2408. if not app_id:
  2409. raise OaEngineError("appId 不能为空")
  2410. app = oa_crud.get_app(db, app_id, tenant_no)
  2411. app_name = (app.app_name or "") if app else ""
  2412. q = _history_export_query(
  2413. db,
  2414. tenant_no=tenant_no,
  2415. user_id=user_id,
  2416. can_view_all=can_view_all,
  2417. app_id=app_id,
  2418. instance_id=instance_id,
  2419. instance_ids=instance_ids,
  2420. initiator_name=initiator_name,
  2421. apply_time_from=apply_time_from,
  2422. apply_time_to=apply_time_to,
  2423. finish_time_from=finish_time_from,
  2424. finish_time_to=finish_time_to,
  2425. instance_status=instance_status,
  2426. initiator_dept_id=initiator_dept_id,
  2427. )
  2428. total = q.count()
  2429. if total > _HISTORY_EXPORT_MAX_ROWS:
  2430. raise OaEngineError(f"导出条数超过上限 {_HISTORY_EXPORT_MAX_ROWS},请缩小筛选范围")
  2431. insts = q.order_by(OaInstance.create_time.desc()).limit(_HISTORY_EXPORT_MAX_ROWS).all()
  2432. if not insts:
  2433. pub = oa_crud._find_latest_published(db, app_id, tenant_no)
  2434. schema = _loads(getattr(pub, "form_schema_json", None), {}) if pub else {}
  2435. business_columns = _build_history_export_business_columns(schema)
  2436. return {
  2437. "appName": app_name,
  2438. "businessColumns": business_columns,
  2439. "fixedColumns": [{"key": k, "label": lb} for k, lb in _FIXED_HISTORY_EXPORT_COLUMNS],
  2440. "rows": [],
  2441. "total": 0,
  2442. }
  2443. ver_by_id: Dict[str, OaAppVersion] = {}
  2444. ver_ids = list({i.app_version_id for i in insts if i.app_version_id})
  2445. if ver_ids:
  2446. for v in (
  2447. db.query(OaAppVersion)
  2448. .filter(OaAppVersion.id.in_(ver_ids), OaAppVersion.del_flag == 0)
  2449. .all()
  2450. ):
  2451. ver_by_id[v.id] = v
  2452. pub = oa_crud._find_latest_published(db, app_id, tenant_no)
  2453. pub_schema = _loads(getattr(pub, "form_schema_json", None), {}) if pub else {}
  2454. col_lists = [_build_history_export_business_columns(pub_schema)]
  2455. for inst in insts:
  2456. ver = ver_by_id.get(inst.app_version_id or "")
  2457. schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
  2458. col_lists.append(_build_history_export_business_columns(schema))
  2459. business_columns = _merge_history_export_business_columns(col_lists)
  2460. inst_ids = [i.id for i in insts]
  2461. all_tasks = (
  2462. db.query(OaTask)
  2463. .filter(OaTask.instance_id.in_(inst_ids), OaTask.del_flag == 0)
  2464. .all()
  2465. )
  2466. tasks_by_inst: Dict[str, List[OaTask]] = {}
  2467. user_ids: List[str] = []
  2468. for t in all_tasks:
  2469. tasks_by_inst.setdefault(t.instance_id, []).append(t)
  2470. if t.assignee_user_id:
  2471. user_ids.append(t.assignee_user_id)
  2472. all_events = (
  2473. db.query(OaInstanceEvent)
  2474. .filter(OaInstanceEvent.instance_id.in_(inst_ids), OaInstanceEvent.del_flag == 0)
  2475. .order_by(OaInstanceEvent.create_time.asc())
  2476. .all()
  2477. )
  2478. events_by_inst: Dict[str, List[OaInstanceEvent]] = {}
  2479. for e in all_events:
  2480. events_by_inst.setdefault(e.instance_id, []).append(e)
  2481. if e.actor_user_id:
  2482. user_ids.append(e.actor_user_id)
  2483. for inst in insts:
  2484. if inst.initiator_user_id:
  2485. user_ids.append(inst.initiator_user_id)
  2486. name_map = _user_name_map(db, user_ids)
  2487. related_ids: List[str] = []
  2488. flat_by_inst: Dict[str, List[Dict[str, Any]]] = {}
  2489. for inst in insts:
  2490. ver = ver_by_id.get(inst.app_version_id or "")
  2491. schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
  2492. form_data = _loads(inst.form_data_json, {}) or {}
  2493. flat = flatten_form_fields_for_summary(schema, form_data)
  2494. flat_by_inst[inst.id] = flat
  2495. for f in flat:
  2496. for rid in f.get("relatedIds") or []:
  2497. rid_s = str(rid or "").strip()
  2498. if rid_s:
  2499. related_ids.append(rid_s)
  2500. related_title_map: Dict[str, str] = {}
  2501. if related_ids:
  2502. rel_insts = (
  2503. db.query(OaInstance)
  2504. .filter(
  2505. OaInstance.tenant_no == tenant_no,
  2506. OaInstance.del_flag == 0,
  2507. OaInstance.id.in_(list(set(related_ids))),
  2508. )
  2509. .all()
  2510. )
  2511. rel_app_ids = list({r.app_id for r in rel_insts if r.app_id})
  2512. rel_app_map = {}
  2513. if rel_app_ids:
  2514. for a in db.query(OaApp).filter(OaApp.id.in_(rel_app_ids)).all():
  2515. rel_app_map[a.id] = a.app_name or ""
  2516. for r in rel_insts:
  2517. initiator = name_map.get(r.initiator_user_id or "", "")
  2518. related_title_map[r.id] = (
  2519. f"{initiator}提交的{rel_app_map.get(r.app_id, '')}" if initiator else (r.title or r.id)
  2520. )
  2521. pending_by_inst: Dict[str, List[OaTask]] = {}
  2522. for t in all_tasks:
  2523. if (t.task_type or "") == "approval" and (t.status or "") == "pending":
  2524. pending_by_inst.setdefault(t.instance_id, []).append(t)
  2525. rows_out: List[Dict[str, Any]] = []
  2526. for idx, inst in enumerate(insts, start=1):
  2527. ver = ver_by_id.get(inst.app_version_id or "")
  2528. flow_root = None
  2529. if ver:
  2530. try:
  2531. flow_root = _flow_root(_loads(ver.flow_tree_json, {}))
  2532. except OaEngineError:
  2533. flow_root = None
  2534. tasks = _sort_tasks_for_detail(tasks_by_inst.get(inst.id, []), flow_root=flow_root)
  2535. events = events_by_inst.get(inst.id, [])
  2536. adv = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
  2537. progress = build_progress_items(
  2538. inst=inst,
  2539. flow_root=flow_root,
  2540. tasks=tasks,
  2541. name_map=name_map,
  2542. events=events,
  2543. db=db,
  2544. advanced=adv,
  2545. )
  2546. summary = _instance_progress_summary(
  2547. inst,
  2548. app_name=app_name,
  2549. initiator_user_name=name_map.get(inst.initiator_user_id or "", ""),
  2550. pending_approval_tasks=pending_by_inst.get(inst.id, []),
  2551. name_map=name_map,
  2552. )
  2553. st = (inst.status or "").strip()
  2554. finish_time = (
  2555. inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
  2556. if st in _FINISH_STATUSES and inst.update_time
  2557. else ""
  2558. )
  2559. duration_end = inst.update_time if st in _FINISH_STATUSES else None
  2560. business: Dict[str, Any] = {}
  2561. flat_map = {str(f.get("fieldKey") or ""): f for f in flat_by_inst.get(inst.id, [])}
  2562. for col in business_columns:
  2563. key = str(col.get("key") or "")
  2564. field = flat_map.get(key) or {}
  2565. if col.get("kind") == "related":
  2566. ids = field.get("relatedIds") or []
  2567. business[key] = f"已关联 {len(ids)} 条" if ids else ""
  2568. titles = [related_title_map.get(str(i), str(i)) for i in ids]
  2569. business[f"{key}__titles"] = "、".join(t for t in titles if t)
  2570. elif field.get("fieldType") in ("upload", "fcUpload", "image", "oaAttachment"):
  2571. business[key] = _export_upload_cell_value(field)
  2572. else:
  2573. business[key] = str(field.get("value") or "")
  2574. rows_out.append(
  2575. {
  2576. "index": idx,
  2577. "id": inst.id,
  2578. "business": business,
  2579. "fixed": {
  2580. "instanceNo": inst.id,
  2581. "createTime": summary.get("applyTime") or "",
  2582. "initiatorName": summary.get("initiatorUserName") or "",
  2583. "currentAssignee": summary.get("currentApproverNames") or "-",
  2584. "approvalResult": _EXPORT_RESULT_TEXT.get(st, st),
  2585. "approvalStatus": _EXPORT_STATUS_TEXT.get(st, st),
  2586. "updateTime": inst.update_time.strftime("%Y-%m-%d %H:%M:%S")
  2587. if inst.update_time
  2588. else "",
  2589. "finishTime": finish_time,
  2590. "initiatorDept": _dept_name(
  2591. db, inst.initiator_dept_id, tenant_no=tenant_no
  2592. ),
  2593. "title": inst.title or "",
  2594. "historyApprovers": _collect_history_approver_names(progress),
  2595. "duration": _format_export_duration(inst.create_time, duration_end),
  2596. "approvalRecord": _format_export_progress_record(progress),
  2597. },
  2598. }
  2599. )
  2600. return {
  2601. "appName": app_name,
  2602. "businessColumns": business_columns,
  2603. "fixedColumns": [{"key": k, "label": lb} for k, lb in _FIXED_HISTORY_EXPORT_COLUMNS],
  2604. "rows": rows_out,
  2605. "total": len(rows_out),
  2606. }
  2607. def list_tasks(
  2608. db: Session,
  2609. *,
  2610. tenant_no: str,
  2611. user_id: str,
  2612. kind: str,
  2613. read_status: Optional[str] = None,
  2614. app_name: Optional[str] = None,
  2615. initiator_name: Optional[str] = None,
  2616. keyword: Optional[str] = None,
  2617. apply_time_from: Optional[str] = None,
  2618. apply_time_to: Optional[str] = None,
  2619. instance_status: Optional[str] = None,
  2620. page: int = 1,
  2621. page_size: int = 20,
  2622. ) -> Dict[str, Any]:
  2623. kind = (kind or "").strip()
  2624. page = max(1, int(page or 1))
  2625. page_size = min(100, max(1, int(page_size or 20)))
  2626. offset = (page - 1) * page_size
  2627. if kind == "mine":
  2628. q = db.query(OaInstance).filter(
  2629. OaInstance.tenant_no == tenant_no,
  2630. OaInstance.del_flag == 0,
  2631. OaInstance.initiator_user_id == user_id,
  2632. )
  2633. q, _, _ = _apply_instance_list_filters(
  2634. q,
  2635. app_name=app_name,
  2636. initiator_name=initiator_name,
  2637. keyword=keyword,
  2638. apply_time_from=apply_time_from,
  2639. apply_time_to=apply_time_to,
  2640. instance_status=instance_status,
  2641. )
  2642. total = q.count()
  2643. insts = (
  2644. q.order_by(OaInstance.create_time.desc())
  2645. .offset(offset)
  2646. .limit(page_size)
  2647. .all()
  2648. )
  2649. rows = [_instance_to_dict(i) for i in insts]
  2650. return {
  2651. "list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
  2652. "total": int(total),
  2653. }
  2654. if kind == "mention":
  2655. from app.services.oa_instance_comment import list_mentioned_instance_ids
  2656. ids = list_mentioned_instance_ids(db, tenant_no=tenant_no, user_id=user_id)
  2657. if not ids:
  2658. return {"list": [], "total": 0}
  2659. q = db.query(OaInstance).filter(
  2660. OaInstance.tenant_no == tenant_no,
  2661. OaInstance.del_flag == 0,
  2662. OaInstance.id.in_(ids),
  2663. )
  2664. q, _, _ = _apply_instance_list_filters(
  2665. q,
  2666. app_name=app_name,
  2667. initiator_name=initiator_name,
  2668. keyword=keyword,
  2669. apply_time_from=apply_time_from,
  2670. apply_time_to=apply_time_to,
  2671. instance_status=instance_status,
  2672. )
  2673. insts = q.all()
  2674. rank = {iid: i for i, iid in enumerate(ids)}
  2675. insts.sort(key=lambda inst: rank.get(inst.id, len(ids)))
  2676. total = len(insts)
  2677. insts = insts[offset : offset + page_size]
  2678. rows = [_instance_to_dict(i) for i in insts]
  2679. return {
  2680. "list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
  2681. "total": int(total),
  2682. }
  2683. if kind == "pending":
  2684. q = db.query(OaTask).filter(
  2685. OaTask.tenant_no == tenant_no,
  2686. OaTask.del_flag == 0,
  2687. OaTask.assignee_user_id == user_id,
  2688. OaTask.task_type == "approval",
  2689. OaTask.status == "pending",
  2690. )
  2691. elif kind == "done":
  2692. q = db.query(OaTask).filter(
  2693. OaTask.tenant_no == tenant_no,
  2694. OaTask.del_flag == 0,
  2695. OaTask.assignee_user_id == user_id,
  2696. OaTask.task_type == "approval",
  2697. OaTask.status.in_(("approved", "rejected", "returned", "transferred")),
  2698. )
  2699. elif kind == "cc":
  2700. q = db.query(OaTask).filter(
  2701. OaTask.tenant_no == tenant_no,
  2702. OaTask.del_flag == 0,
  2703. OaTask.assignee_user_id == user_id,
  2704. OaTask.task_type == "cc",
  2705. )
  2706. rs = _normalize_cc_read_status(read_status)
  2707. if rs == "unread":
  2708. q = q.filter(OaTask.is_read == 0)
  2709. elif rs == "read":
  2710. q = q.filter(OaTask.is_read == 1)
  2711. # all: 不加 is_read 条件
  2712. else:
  2713. raise OaEngineError(f"不支持的列表类型: {kind}")
  2714. need_inst_join = any(
  2715. [
  2716. (keyword or "").strip(),
  2717. (app_name or "").strip(),
  2718. (initiator_name or "").strip(),
  2719. (apply_time_from or "").strip(),
  2720. (apply_time_to or "").strip(),
  2721. (instance_status or "").strip() in _INSTANCE_STATUS_FILTER_VALUES,
  2722. ]
  2723. )
  2724. if need_inst_join:
  2725. q = q.join(
  2726. OaInstance,
  2727. (OaInstance.id == OaTask.instance_id)
  2728. & (OaInstance.tenant_no == tenant_no)
  2729. & (OaInstance.del_flag == 0),
  2730. )
  2731. q, _, _ = _apply_instance_list_filters(
  2732. q,
  2733. app_name=app_name,
  2734. initiator_name=initiator_name,
  2735. keyword=keyword,
  2736. apply_time_from=apply_time_from,
  2737. apply_time_to=apply_time_to,
  2738. instance_status=instance_status,
  2739. )
  2740. total = q.count()
  2741. rows_orm = (
  2742. q.order_by(OaTask.create_time.desc()).offset(offset).limit(page_size).all()
  2743. )
  2744. rows = [_task_to_dict(t) for t in rows_orm]
  2745. inst_ids = list({t.instance_id for t in rows_orm if t.instance_id})
  2746. insts = (
  2747. db.query(OaInstance)
  2748. .filter(
  2749. OaInstance.tenant_no == tenant_no,
  2750. OaInstance.del_flag == 0,
  2751. OaInstance.id.in_(inst_ids),
  2752. )
  2753. .all()
  2754. if inst_ids
  2755. else []
  2756. )
  2757. return {
  2758. "list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
  2759. "total": int(total),
  2760. }
  2761. def _approve_task_core(
  2762. db: Session,
  2763. *,
  2764. task: OaTask,
  2765. inst: OaInstance,
  2766. ver: OaAppVersion,
  2767. user_id: str,
  2768. comment: Optional[str],
  2769. ) -> List[str]:
  2770. """将 pending 审批任务置为 approved 并按或签/会签决定是否推进。
  2771. 返回 advance_from 新创建的任务 id 列表;会签未齐、未推进时返回空列表。
  2772. 调用方负责 commit(若需要)。
  2773. """
  2774. now = datetime.now()
  2775. task.comment_text = (comment or "").strip() or None
  2776. task.status = "approved"
  2777. task.update_by = user_id
  2778. task.update_time = now
  2779. db.add(task)
  2780. # Session 默认 autoflush=False:不先 flush 时,后续按 status=pending 的查询仍会命中本任务,
  2781. # 或签「取消同节点其余待办」会把本人已同意覆盖成 done。
  2782. db.flush()
  2783. sign_mode = (task.sign_mode or "or").strip() or "or"
  2784. pending_same = (
  2785. db.query(OaTask)
  2786. .filter(
  2787. OaTask.instance_id == inst.id,
  2788. OaTask.node_key == task.node_key,
  2789. OaTask.task_type == "approval",
  2790. OaTask.id != task.id,
  2791. OaTask.status == "pending",
  2792. OaTask.del_flag == 0,
  2793. )
  2794. .count()
  2795. )
  2796. if sign_mode == "and" and pending_same > 0:
  2797. return []
  2798. if sign_mode != "and":
  2799. for s in (
  2800. db.query(OaTask)
  2801. .filter(
  2802. OaTask.instance_id == inst.id,
  2803. OaTask.node_key == task.node_key,
  2804. OaTask.id != task.id,
  2805. OaTask.status == "pending",
  2806. OaTask.del_flag == 0,
  2807. )
  2808. .all()
  2809. ):
  2810. s.status = "done"
  2811. s.update_by = user_id
  2812. s.update_time = now
  2813. db.add(s)
  2814. root = _flow_root(_loads(ver.flow_tree_json, {}))
  2815. node = _find_node(root, task.node_key or "")
  2816. if not node:
  2817. raise OaEngineError("找不到对应流程节点")
  2818. return advance_from(
  2819. db,
  2820. inst=inst,
  2821. ver=ver,
  2822. from_node=node,
  2823. user_id=user_id,
  2824. initiator_select_by_node=_load_initiator_select_by_node(inst),
  2825. )
  2826. def _maybe_auto_pass_initiator(
  2827. db: Session,
  2828. *,
  2829. inst: OaInstance,
  2830. ver: OaAppVersion,
  2831. tasks: List[OaTask],
  2832. user_id: Optional[str],
  2833. ) -> List[str]:
  2834. _ = user_id
  2835. advanced = _advanced(ver)
  2836. if not advanced.get("autoPassWhenApproverIsInitiator"):
  2837. return []
  2838. if inst.status != "running":
  2839. return []
  2840. initiator = (inst.initiator_user_id or "").strip()
  2841. if not initiator:
  2842. return []
  2843. self_task = None
  2844. for t in tasks:
  2845. if (
  2846. t.task_type == "approval"
  2847. and t.status == "pending"
  2848. and (t.assignee_user_id or "") == initiator
  2849. ):
  2850. self_task = t
  2851. break
  2852. if not self_task:
  2853. return []
  2854. return _approve_task_core(
  2855. db,
  2856. task=self_task,
  2857. inst=inst,
  2858. ver=ver,
  2859. user_id=initiator,
  2860. comment=_AUTO_PASS_COMMENT,
  2861. )
  2862. def _prior_approved_assignees(db: Session, instance_id: str) -> set:
  2863. rows = (
  2864. db.query(OaTask.assignee_user_id)
  2865. .filter(
  2866. OaTask.instance_id == instance_id,
  2867. OaTask.task_type == "approval",
  2868. OaTask.status == "approved",
  2869. OaTask.del_flag == 0,
  2870. )
  2871. .all()
  2872. )
  2873. return {(row[0] or "").strip() for row in rows if (row[0] or "").strip()}
  2874. def _maybe_auto_pass_approver_dedup(
  2875. db: Session,
  2876. *,
  2877. inst: OaInstance,
  2878. ver: OaAppVersion,
  2879. tasks: List[OaTask],
  2880. ) -> List[str]:
  2881. """同一实例内已同意过的审批人,后续节点不再人工审批。
  2882. 或签:该人自动通过后整节点通过;会签:仅自动完成本人,其他人仍待批。
  2883. 仅统计 status=approved 的审批任务;拒绝等状态不触发。
  2884. """
  2885. advanced = _advanced(ver)
  2886. if not advanced.get("approverDedup"):
  2887. return []
  2888. if inst.status != "running":
  2889. return []
  2890. prior = _prior_approved_assignees(db, inst.id)
  2891. if not prior:
  2892. return []
  2893. created: List[str] = []
  2894. for task in tasks:
  2895. if inst.status != "running":
  2896. break
  2897. if task.task_type != "approval" or task.status != "pending":
  2898. continue
  2899. assignee = (task.assignee_user_id or "").strip()
  2900. if assignee not in prior:
  2901. continue
  2902. created.extend(
  2903. _approve_task_core(
  2904. db,
  2905. task=task,
  2906. inst=inst,
  2907. ver=ver,
  2908. user_id=assignee,
  2909. comment=_DEDUP_AUTO_PASS_COMMENT,
  2910. )
  2911. or []
  2912. )
  2913. return created
  2914. def complete_task(
  2915. db: Session,
  2916. task_id: str,
  2917. *,
  2918. tenant_no: str,
  2919. user_id: str,
  2920. action: str,
  2921. comment: Optional[str] = None,
  2922. ) -> Dict[str, Any]:
  2923. action = (action or "").strip()
  2924. if action not in ("approve", "reject"):
  2925. raise OaEngineError("不支持的操作")
  2926. task = (
  2927. db.query(OaTask)
  2928. .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0)
  2929. .first()
  2930. )
  2931. if not task:
  2932. raise OaEngineError("任务不存在")
  2933. if task.assignee_user_id != user_id:
  2934. raise OaEngineError("无权处理该任务")
  2935. if task.task_type != "approval" or task.status != "pending":
  2936. raise OaEngineError("任务状态不可处理")
  2937. inst = _get_instance(db, task.instance_id, tenant_no)
  2938. if inst.status != "running":
  2939. raise OaEngineError("实例已结束")
  2940. ver = (
  2941. db.query(OaAppVersion)
  2942. .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
  2943. .first()
  2944. )
  2945. if not ver:
  2946. raise OaEngineError("流程版本不存在")
  2947. advanced = _advanced(ver)
  2948. if advanced.get("commentRequired") and not (comment or "").strip():
  2949. raise OaEngineError("请填写审批意见")
  2950. if action == "reject":
  2951. now = datetime.now()
  2952. task.comment_text = (comment or "").strip() or None
  2953. task.status = "rejected"
  2954. task.update_by = user_id
  2955. task.update_time = now
  2956. db.add(task)
  2957. db.flush()
  2958. siblings = (
  2959. db.query(OaTask)
  2960. .filter(
  2961. OaTask.instance_id == inst.id,
  2962. OaTask.node_key == task.node_key,
  2963. OaTask.id != task.id,
  2964. OaTask.status == "pending",
  2965. OaTask.del_flag == 0,
  2966. )
  2967. .all()
  2968. )
  2969. for s in siblings:
  2970. s.status = "done"
  2971. s.update_by = user_id
  2972. s.update_time = now
  2973. db.add(s)
  2974. inst.status = "rejected"
  2975. inst.update_by = user_id
  2976. inst.update_time = now
  2977. db.add(inst)
  2978. db.commit()
  2979. from app.services.oa_wechat_notify import schedule_oa_applicant_progress
  2980. schedule_oa_applicant_progress(
  2981. instance_id=inst.id,
  2982. actor_user_id=user_id,
  2983. action="reject",
  2984. task_id=task.id,
  2985. )
  2986. return get_instance_detail(db, inst.id, tenant_no=tenant_no)
  2987. created = _approve_task_core(
  2988. db,
  2989. task=task,
  2990. inst=inst,
  2991. ver=ver,
  2992. user_id=user_id,
  2993. comment=comment,
  2994. )
  2995. db.commit()
  2996. from app.services.oa_wechat_notify import (
  2997. schedule_oa_applicant_progress,
  2998. schedule_oa_task_notifies,
  2999. )
  3000. schedule_oa_task_notifies(created or [])
  3001. schedule_oa_applicant_progress(
  3002. instance_id=inst.id,
  3003. actor_user_id=user_id,
  3004. action="approve",
  3005. task_id=task.id,
  3006. )
  3007. return get_instance_detail(db, inst.id, tenant_no=tenant_no)
  3008. def add_sign(
  3009. db: Session,
  3010. task_id: str,
  3011. *,
  3012. tenant_no: str,
  3013. user_id: str,
  3014. user_ids: List[str],
  3015. comment: Optional[str] = None,
  3016. ) -> Dict[str, Any]:
  3017. """当前待办处理人加签:为同节点新增待办审批人。"""
  3018. task = (
  3019. db.query(OaTask)
  3020. .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0)
  3021. .first()
  3022. )
  3023. if not task:
  3024. raise OaEngineError("任务不存在")
  3025. if task.assignee_user_id != user_id:
  3026. raise OaEngineError("无权加签该任务")
  3027. if task.task_type != "approval" or task.status != "pending":
  3028. raise OaEngineError("仅待处理的审批任务可加签")
  3029. inst = _get_instance(db, task.instance_id, tenant_no)
  3030. if inst.status != "running":
  3031. raise OaEngineError("实例已结束")
  3032. ver = (
  3033. db.query(OaAppVersion)
  3034. .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
  3035. .first()
  3036. )
  3037. if not ver:
  3038. raise OaEngineError("流程版本不存在")
  3039. advanced = _advanced(ver)
  3040. if not advanced.get("allowAddSign", True):
  3041. raise OaEngineError("该应用不允许加签")
  3042. ids = []
  3043. seen = set()
  3044. for uid in user_ids or []:
  3045. uid = (uid or "").strip()
  3046. if not uid or uid in seen:
  3047. continue
  3048. seen.add(uid)
  3049. ids.append(uid)
  3050. if not ids:
  3051. raise OaEngineError("请选择加签人")
  3052. existing = {
  3053. r.assignee_user_id
  3054. for r in db.query(OaTask)
  3055. .filter(
  3056. OaTask.instance_id == inst.id,
  3057. OaTask.node_key == task.node_key,
  3058. OaTask.del_flag == 0,
  3059. OaTask.status == "pending",
  3060. )
  3061. .all()
  3062. }
  3063. now = datetime.now()
  3064. created_ids: List[str] = []
  3065. next_order = _next_task_order_num(db, inst.id)
  3066. for uid in ids:
  3067. if uid in existing:
  3068. continue
  3069. # 校验用户有效
  3070. u = (
  3071. db.query(SysUser)
  3072. .filter(
  3073. SysUser.id == uid,
  3074. SysUser.tenant_no == tenant_no,
  3075. SysUser.del_flag == 0,
  3076. SysUser.status == "1",
  3077. )
  3078. .first()
  3079. )
  3080. if not u:
  3081. raise OaEngineError(f"加签用户无效:{uid}")
  3082. new_id = generate_uuid()
  3083. db.add(
  3084. OaTask(
  3085. id=new_id,
  3086. instance_id=inst.id,
  3087. node_key=task.node_key,
  3088. node_name=task.node_name,
  3089. task_type="approval",
  3090. assignee_user_id=uid,
  3091. status="pending",
  3092. comment_text=None,
  3093. resolve_source="add_sign",
  3094. sign_mode=task.sign_mode or "or",
  3095. round_no=int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1),
  3096. tenant_no=tenant_no,
  3097. order_num=next_order,
  3098. remark=(comment or "").strip() or None,
  3099. create_by=user_id,
  3100. create_dept_id=inst.initiator_dept_id or "",
  3101. create_time=now,
  3102. update_by=user_id,
  3103. update_time=now,
  3104. del_flag=0,
  3105. )
  3106. )
  3107. next_order += 1
  3108. created_ids.append(new_id)
  3109. if not created_ids:
  3110. raise OaEngineError("所选用户已在当前节点待办中")
  3111. # 加签后默认按会签语义收口更稳妥:保留原 sign_mode;若原或签则一人通过仍可结束
  3112. if comment:
  3113. task.comment_text = ((task.comment_text or "") + f"\n[加签说明] {comment}").strip()
  3114. task.update_by = user_id
  3115. task.update_time = now
  3116. db.add(task)
  3117. db.commit()
  3118. from app.services.oa_wechat_notify import (
  3119. schedule_oa_applicant_progress,
  3120. schedule_oa_task_notifies,
  3121. )
  3122. schedule_oa_task_notifies(created_ids)
  3123. schedule_oa_applicant_progress(
  3124. instance_id=inst.id,
  3125. actor_user_id=user_id,
  3126. action="add_sign",
  3127. task_id=task.id,
  3128. )
  3129. return get_instance_detail(db, inst.id, tenant_no=tenant_no)
  3130. def transfer_task(
  3131. db: Session,
  3132. task_id: str,
  3133. *,
  3134. tenant_no: str,
  3135. user_id: str,
  3136. target_user_id: str,
  3137. comment: Optional[str] = None,
  3138. ) -> Dict[str, Any]:
  3139. """当前待办处理人转交:结束本人待办并为同节点目标用户新建 pending 任务。"""
  3140. task = (
  3141. db.query(OaTask)
  3142. .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0)
  3143. .first()
  3144. )
  3145. if not task:
  3146. raise OaEngineError("任务不存在")
  3147. if task.assignee_user_id != user_id:
  3148. raise OaEngineError("无权转交该任务")
  3149. if task.task_type != "approval" or task.status != "pending":
  3150. raise OaEngineError("仅待处理的审批任务可转交")
  3151. inst = _get_instance(db, task.instance_id, tenant_no)
  3152. if inst.status != "running":
  3153. raise OaEngineError("实例已结束")
  3154. target = (target_user_id or "").strip()
  3155. if not target:
  3156. raise OaEngineError("请选择转交人")
  3157. if target == user_id:
  3158. raise OaEngineError("不能转交给自己")
  3159. u = (
  3160. db.query(SysUser)
  3161. .filter(
  3162. SysUser.id == target,
  3163. SysUser.tenant_no == tenant_no,
  3164. SysUser.del_flag == 0,
  3165. SysUser.status == "1",
  3166. )
  3167. .first()
  3168. )
  3169. if not u:
  3170. raise OaEngineError(f"转交用户无效:{target}")
  3171. existing = {
  3172. r.assignee_user_id
  3173. for r in db.query(OaTask)
  3174. .filter(
  3175. OaTask.instance_id == inst.id,
  3176. OaTask.node_key == task.node_key,
  3177. OaTask.del_flag == 0,
  3178. OaTask.status == "pending",
  3179. )
  3180. .all()
  3181. }
  3182. if target in existing:
  3183. raise OaEngineError("该用户已在当前节点待办中")
  3184. now = datetime.now()
  3185. comment_text = (comment or "").strip() or None
  3186. round_no = int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1)
  3187. task.status = "transferred"
  3188. task.comment_text = comment_text
  3189. task.update_by = user_id
  3190. task.update_time = now
  3191. db.add(task)
  3192. next_order = _next_task_order_num(db, inst.id)
  3193. new_task_id = generate_uuid()
  3194. db.add(
  3195. OaTask(
  3196. id=new_task_id,
  3197. instance_id=inst.id,
  3198. node_key=task.node_key,
  3199. node_name=task.node_name,
  3200. task_type="approval",
  3201. assignee_user_id=target,
  3202. status="pending",
  3203. comment_text=None,
  3204. resolve_source="transfer",
  3205. sign_mode=task.sign_mode or "or",
  3206. round_no=round_no,
  3207. tenant_no=tenant_no,
  3208. order_num=next_order,
  3209. remark=comment_text,
  3210. create_by=user_id,
  3211. create_dept_id=inst.initiator_dept_id or "",
  3212. create_time=now,
  3213. update_by=user_id,
  3214. update_time=now,
  3215. del_flag=0,
  3216. )
  3217. )
  3218. event_comment = comment_text or f"转交给 {target}"
  3219. _add_instance_event(
  3220. db,
  3221. inst=inst,
  3222. event_type="transfer",
  3223. actor_user_id=user_id,
  3224. round_no=round_no,
  3225. comment=event_comment,
  3226. task_id=task.id,
  3227. node_key=task.node_key,
  3228. )
  3229. db.commit()
  3230. from app.services.oa_wechat_notify import (
  3231. schedule_oa_applicant_progress,
  3232. schedule_oa_task_notifies,
  3233. )
  3234. schedule_oa_task_notifies([new_task_id])
  3235. schedule_oa_applicant_progress(
  3236. instance_id=inst.id,
  3237. actor_user_id=user_id,
  3238. action="transfer",
  3239. task_id=task.id,
  3240. )
  3241. return get_instance_detail(db, inst.id, tenant_no=tenant_no)
  3242. def return_task(
  3243. db: Session,
  3244. task_id: str,
  3245. *,
  3246. tenant_no: str,
  3247. user_id: str,
  3248. comment: Optional[str] = None,
  3249. ) -> Dict[str, Any]:
  3250. """审批人打回:本单终态 returned;再办须复制表单开新实例。"""
  3251. task = (
  3252. db.query(OaTask)
  3253. .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0)
  3254. .first()
  3255. )
  3256. if not task:
  3257. raise OaEngineError("任务不存在")
  3258. if task.assignee_user_id != user_id:
  3259. raise OaEngineError("无权打回该任务")
  3260. if task.task_type != "approval" or task.status != "pending":
  3261. raise OaEngineError("仅待处理的审批任务可打回")
  3262. inst = _get_instance(db, task.instance_id, tenant_no)
  3263. if inst.status != "running":
  3264. raise OaEngineError("仅进行中的审批可打回")
  3265. ver = (
  3266. db.query(OaAppVersion)
  3267. .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
  3268. .first()
  3269. )
  3270. if not ver:
  3271. raise OaEngineError("流程版本不存在")
  3272. advanced = _advanced(ver)
  3273. if not advanced.get("allowReturn", True):
  3274. raise OaEngineError("该应用不允许打回")
  3275. comment_text = (comment or "").strip()
  3276. if not comment_text:
  3277. raise OaEngineError("打回意见必填")
  3278. now = datetime.now()
  3279. round_no = int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1)
  3280. task.status = "returned"
  3281. task.comment_text = comment_text
  3282. task.update_by = user_id
  3283. task.update_time = now
  3284. db.add(task)
  3285. for t in (
  3286. db.query(OaTask)
  3287. .filter(
  3288. OaTask.instance_id == inst.id,
  3289. OaTask.status == "pending",
  3290. OaTask.del_flag == 0,
  3291. OaTask.id != task.id,
  3292. )
  3293. .all()
  3294. ):
  3295. t.status = "cancelled"
  3296. t.update_by = user_id
  3297. t.update_time = now
  3298. db.add(t)
  3299. inst.status = "returned"
  3300. inst.update_by = user_id
  3301. inst.update_time = now
  3302. db.add(inst)
  3303. _add_instance_event(
  3304. db,
  3305. inst=inst,
  3306. event_type="return",
  3307. actor_user_id=user_id,
  3308. round_no=round_no,
  3309. comment=comment_text,
  3310. task_id=task.id,
  3311. node_key=task.node_key,
  3312. )
  3313. db.commit()
  3314. from app.services.oa_wechat_notify import schedule_oa_applicant_progress
  3315. schedule_oa_applicant_progress(
  3316. instance_id=inst.id,
  3317. actor_user_id=user_id,
  3318. action="return",
  3319. task_id=task.id,
  3320. )
  3321. return get_instance_detail(db, inst.id, tenant_no=tenant_no)
  3322. def resubmit_instance(
  3323. db: Session,
  3324. instance_id: str,
  3325. *,
  3326. tenant_no: str,
  3327. user_id: str,
  3328. form_data: Optional[Dict[str, Any]] = None,
  3329. initiator_select_by_node: Optional[Dict[str, List[str]]] = None,
  3330. ) -> Dict[str, Any]:
  3331. """已废弃:同实例再次提交。请复制表单重新发起新实例。"""
  3332. _ = (db, instance_id, tenant_no, user_id, form_data, initiator_select_by_node)
  3333. raise OaEngineError("同实例再次提交已废弃,请复制表单重新发起")
  3334. def withdraw_instance(
  3335. db: Session, instance_id: str, *, tenant_no: str, user_id: str
  3336. ) -> Dict[str, Any]:
  3337. inst = _get_instance(db, instance_id, tenant_no)
  3338. if inst.initiator_user_id != user_id:
  3339. raise OaEngineError("仅发起人可撤销")
  3340. if inst.status != "running":
  3341. raise OaEngineError("仅运行中的实例可撤销")
  3342. ver = (
  3343. db.query(OaAppVersion)
  3344. .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
  3345. .first()
  3346. )
  3347. advanced = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
  3348. if not advanced.get("allowWithdrawRunning", True):
  3349. raise OaEngineError("该应用不允许撤销进行中的审批")
  3350. now = datetime.now()
  3351. inst.status = "cancelled"
  3352. inst.update_by = user_id
  3353. inst.update_time = now
  3354. db.add(inst)
  3355. for t in (
  3356. db.query(OaTask)
  3357. .filter(OaTask.instance_id == inst.id, OaTask.status == "pending", OaTask.del_flag == 0)
  3358. .all()
  3359. ):
  3360. t.status = "cancelled"
  3361. t.update_by = user_id
  3362. t.update_time = now
  3363. db.add(t)
  3364. _add_instance_event(
  3365. db,
  3366. inst=inst,
  3367. event_type="withdraw",
  3368. actor_user_id=user_id,
  3369. round_no=int(getattr(inst, "current_round", None) or 1),
  3370. )
  3371. db.commit()
  3372. return get_instance_detail(db, inst.id, tenant_no=tenant_no)