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