oa_publish.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. """OA 审批应用发布校验与发布落库"""
  2. from __future__ import annotations
  3. from typing import Any, Dict, List, Optional, Set, Tuple
  4. from sqlalchemy.orm import Session
  5. from app.crud import oa_approval as oa_crud
  6. from app.models.models import SysPost
  7. from app.services.oa_assignee import VALID_ASSIGNEE_TYPES, normalize_cc_assignee_types
  8. # 可填控件(FormCreate / 自建控件字典交集;布局类不计)
  9. FILLABLE_TYPES = frozenset(
  10. {
  11. "input",
  12. "textarea",
  13. "inputNumber",
  14. "number",
  15. "radio",
  16. "checkbox",
  17. "select",
  18. "switch",
  19. "datePicker",
  20. "date",
  21. "dateRange",
  22. "timePicker",
  23. "time",
  24. "phone",
  25. "image",
  26. "upload",
  27. "money",
  28. "dept",
  29. "person",
  30. "ownerDept",
  31. "ownerPerson",
  32. "post",
  33. "receiveAccount",
  34. "region",
  35. "formula",
  36. "relatedApproval",
  37. "rate",
  38. "slider",
  39. "cascader",
  40. "tripItinerary",
  41. }
  42. )
  43. LAYOUT_TYPES = frozenset(
  44. {
  45. "fcRow",
  46. "col",
  47. "column",
  48. "fcTable",
  49. "html",
  50. "divider",
  51. "card",
  52. "space",
  53. "tabs",
  54. "tabPane",
  55. "group",
  56. "object",
  57. "leaveKit",
  58. "confirmationKit",
  59. "overtimeKit",
  60. "resignationKit",
  61. "tripKit",
  62. }
  63. )
  64. VALID_NODE_TYPES = frozenset({"start", "approval", "cc", "condition", "end"})
  65. # 系统条件伪字段(非表单控件)
  66. INITIATOR_CONDITION_FIELD = "__initiator__"
  67. # 发布允许的控件 type(未知 type 拒绝)
  68. ALLOWED_CONTROL_TYPES = FILLABLE_TYPES | LAYOUT_TYPES
  69. CONDITION_ELIGIBLE_TYPES = frozenset(
  70. {
  71. "input",
  72. "inputNumber",
  73. "number",
  74. "money",
  75. "radio",
  76. "select",
  77. "datePicker",
  78. "date",
  79. "person",
  80. "ownerPerson",
  81. "post",
  82. "dept",
  83. "ownerDept",
  84. }
  85. )
  86. def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
  87. if form_schema is None:
  88. return []
  89. if isinstance(form_schema, list):
  90. return [r for r in form_schema if isinstance(r, dict)]
  91. if isinstance(form_schema, dict):
  92. rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
  93. if isinstance(rules, list):
  94. return [r for r in rules if isinstance(r, dict)]
  95. # 单字段对象
  96. if form_schema.get("type") or form_schema.get("field"):
  97. return [form_schema]
  98. return []
  99. def _walk_form_rules(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
  100. for rule in rules:
  101. out.append(rule)
  102. children = rule.get("children") or rule.get("props", {}).get("rule") or []
  103. if isinstance(children, list):
  104. _walk_form_rules([c for c in children if isinstance(c, dict)], out)
  105. def count_fillable_fields(form_schema: Any) -> int:
  106. flat: List[Dict[str, Any]] = []
  107. _walk_form_rules(_extract_rules(form_schema), flat)
  108. n = 0
  109. for rule in flat:
  110. t = (rule.get("type") or "").strip()
  111. if t in LAYOUT_TYPES:
  112. continue
  113. field = rule.get("field") or rule.get("name")
  114. if t in FILLABLE_TYPES or (field and t and t not in LAYOUT_TYPES):
  115. n += 1
  116. return n
  117. def _rule_use_in_condition(rule: Dict[str, Any]) -> bool:
  118. if rule.get("useInCondition") is True:
  119. return True
  120. props = rule.get("props") or {}
  121. return isinstance(props, dict) and props.get("useInCondition") is True
  122. def index_condition_fields(form_schema: Any) -> Dict[str, Dict[str, Any]]:
  123. """field -> rule(仅 useInCondition 且 type 合格)"""
  124. flat: List[Dict[str, Any]] = []
  125. _walk_form_rules(_extract_rules(form_schema), flat)
  126. out: Dict[str, Dict[str, Any]] = {}
  127. for rule in flat:
  128. t = (rule.get("type") or "").strip()
  129. field = (rule.get("field") or rule.get("name") or "").strip()
  130. if not field or t not in CONDITION_ELIGIBLE_TYPES:
  131. continue
  132. if _rule_use_in_condition(rule):
  133. out[field] = rule
  134. return out
  135. def normalize_use_in_condition_required(form_schema: Any) -> Any:
  136. """就地/深拷贝皆可:打标字段强制 required/$required。发布前调用。"""
  137. import copy
  138. schema = copy.deepcopy(form_schema) if form_schema is not None else {"rule": []}
  139. def walk(list_rules: List[Dict[str, Any]]) -> None:
  140. for rule in list_rules:
  141. if _rule_use_in_condition(rule):
  142. rule["useInCondition"] = True
  143. rule["$required"] = True
  144. rule["required"] = True
  145. props = rule.get("props")
  146. if isinstance(props, dict):
  147. props["useInCondition"] = True
  148. children = rule.get("children") or (rule.get("props") or {}).get("rule") or []
  149. if isinstance(children, list):
  150. walk([c for c in children if isinstance(c, dict)])
  151. if isinstance(schema, list):
  152. walk([r for r in schema if isinstance(r, dict)])
  153. elif isinstance(schema, dict):
  154. r = schema.get("rule")
  155. if isinstance(r, list):
  156. walk([x for x in r if isinstance(x, dict)])
  157. return schema
  158. def _node_type(node: Dict[str, Any]) -> str:
  159. return (node.get("type") or node.get("nodeType") or "").strip()
  160. def validate_flow_tree(flow_tree: Any) -> List[str]:
  161. errors: List[str] = []
  162. if not flow_tree or not isinstance(flow_tree, dict):
  163. return ["流程树不能为空"]
  164. root = flow_tree
  165. # 兼容 { root: node } 包装
  166. if "root" in flow_tree and isinstance(flow_tree["root"], dict):
  167. root = flow_tree["root"]
  168. if _node_type(root) != "start":
  169. errors.append("流程树根节点必须是发起人(start)")
  170. seen_end = False
  171. def walk(node: Optional[Dict[str, Any]], path: str) -> None:
  172. nonlocal seen_end
  173. if not node or not isinstance(node, dict):
  174. errors.append(f"{path}:节点缺失")
  175. return
  176. ntype = _node_type(node)
  177. if ntype not in VALID_NODE_TYPES:
  178. errors.append(f"{path}:不支持的节点类型「{ntype or '(空)'}」")
  179. return
  180. if ntype == "end":
  181. seen_end = True
  182. if node.get("child"):
  183. errors.append(f"{path}:结束节点后不应再有子节点")
  184. return
  185. if ntype == "approval":
  186. cfg = node.get("approverConfig") or node.get("approver_config") or {}
  187. if not isinstance(cfg, dict):
  188. cfg = {}
  189. # 审批只认单字段 assigneeType(忽略 assigneeTypes)
  190. at = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
  191. if not at:
  192. errors.append(f"{path}:未配置审批人/抄送人策略")
  193. elif at not in VALID_ASSIGNEE_TYPES:
  194. errors.append(f"{path}:不支持的审批人类型「{at}」")
  195. elif at == "users":
  196. ids = cfg.get("userIds") or cfg.get("user_ids") or []
  197. if not ids:
  198. errors.append(f"{path}:指定成员不能为空")
  199. elif at == "posts":
  200. pids = cfg.get("postIds") or cfg.get("post_ids") or []
  201. if not pids:
  202. errors.append(f"{path}:职位不能为空")
  203. if ntype == "cc":
  204. cfg = node.get("approverConfig") or node.get("approver_config") or {}
  205. if not isinstance(cfg, dict):
  206. cfg = {}
  207. types = normalize_cc_assignee_types(cfg)
  208. if not types:
  209. errors.append(f"{path}:未配置审批人/抄送人策略")
  210. else:
  211. for at in types:
  212. if at not in VALID_ASSIGNEE_TYPES:
  213. errors.append(f"{path}:不支持的审批人类型「{at}」")
  214. elif at == "users":
  215. ids = cfg.get("userIds") or cfg.get("user_ids") or []
  216. if not ids:
  217. errors.append(f"{path}:指定成员不能为空")
  218. elif at == "posts":
  219. pids = cfg.get("postIds") or cfg.get("post_ids") or []
  220. if not pids:
  221. errors.append(f"{path}:职位不能为空")
  222. if ntype == "condition":
  223. groups = node.get("conditionGroups") or node.get("condition_groups") or []
  224. if not isinstance(groups, list) or len(groups) < 1:
  225. errors.append(f"{path}:条件分支至少需要一组出线")
  226. else:
  227. for i, g in enumerate(groups):
  228. if not isinstance(g, dict):
  229. errors.append(f"{path}.条件组[{i}]:格式非法")
  230. continue
  231. is_default = bool(g.get("isDefault"))
  232. rules = g.get("rules") if isinstance(g.get("rules"), list) else []
  233. when = (g.get("when") or "").strip() if isinstance(g.get("when"), str) else ""
  234. valid_rules = [
  235. r
  236. for r in rules
  237. if isinstance(r, dict)
  238. and (r.get("field") or "").strip()
  239. and (r.get("op") or "").strip()
  240. and r.get("value") is not None
  241. and str(r.get("value")).strip() != ""
  242. ]
  243. if is_default:
  244. pass
  245. elif valid_rules:
  246. pass
  247. elif when and not valid_rules:
  248. errors.append(
  249. f"{path}.条件组[{i}]:请用可视化规则重新配置条件(不再支持仅公式)"
  250. )
  251. else:
  252. errors.append(f"{path}.条件组[{i}]:请完善条件分支规则")
  253. child = g.get("child")
  254. if not child:
  255. errors.append(f"{path}.条件组[{i}]:缺少下游节点")
  256. else:
  257. gname = g.get("name") or f"分支{i + 1}"
  258. walk(child if isinstance(child, dict) else None, f"{path}/{gname}")
  259. # 条件分支汇合后的主链(可选)
  260. merge = node.get("child")
  261. if merge is not None:
  262. if not isinstance(merge, dict):
  263. errors.append(f"{path}:汇合后子节点格式非法")
  264. else:
  265. walk(merge, f"{path}/汇合")
  266. return
  267. child = node.get("child")
  268. if child is None:
  269. if ntype != "end":
  270. errors.append(f"{path}:须连接到后续节点直至结束")
  271. return
  272. if not isinstance(child, dict):
  273. errors.append(f"{path}:子节点格式非法")
  274. return
  275. cname = child.get("name") or _node_type(child) or "next"
  276. walk(child, f"{path}/{cname}")
  277. walk(root, root.get("name") or "发起人")
  278. if not seen_end:
  279. errors.append("流程树未能到达结束节点")
  280. return errors
  281. def validate_condition_rules_against_form(form_schema: Any, flow_tree: Any) -> List[str]:
  282. indexed = index_condition_fields(form_schema)
  283. errors: List[str] = []
  284. seen_form: Set[str] = set()
  285. def collect_fields(node: Optional[Dict[str, Any]]) -> None:
  286. if not node or not isinstance(node, dict):
  287. return
  288. for g in node.get("conditionGroups") or node.get("condition_groups") or []:
  289. if not isinstance(g, dict) or g.get("isDefault"):
  290. continue
  291. rules = g.get("rules") if isinstance(g.get("rules"), list) else []
  292. for r in rules:
  293. if not isinstance(r, dict):
  294. continue
  295. field = (r.get("field") or "").strip()
  296. if not field:
  297. continue
  298. if field == INITIATOR_CONDITION_FIELD:
  299. op = (r.get("op") or "").strip()
  300. val = r.get("value")
  301. if op != "contains":
  302. errors.append("发起人条件仅支持「包含」")
  303. elif not isinstance(val, list) or not any(
  304. str(x).strip() for x in val
  305. ):
  306. errors.append("发起人包含条件须选择至少一名用户")
  307. continue
  308. if field in seen_form:
  309. continue
  310. seen_form.add(field)
  311. if field not in indexed:
  312. errors.append(f"条件引用了无效字段:{field}")
  313. child = g.get("child")
  314. if isinstance(child, dict):
  315. collect_fields(child)
  316. child = node.get("child")
  317. if isinstance(child, dict):
  318. collect_fields(child)
  319. root = flow_tree
  320. if isinstance(flow_tree, dict) and isinstance(flow_tree.get("root"), dict):
  321. root = flow_tree["root"]
  322. collect_fields(root if isinstance(root, dict) else None)
  323. return errors
  324. def validate_posts_referenced(
  325. db: Session, *, tenant_no: str, flow_tree: Any
  326. ) -> List[str]:
  327. post_ids: Set[str] = set()
  328. def collect(node: Optional[Dict[str, Any]]) -> None:
  329. if not node or not isinstance(node, dict):
  330. return
  331. cfg = node.get("approverConfig") or {}
  332. if isinstance(cfg, dict):
  333. types: List[str] = []
  334. ntype = _node_type(node)
  335. if ntype == "cc":
  336. types = normalize_cc_assignee_types(cfg)
  337. else:
  338. at = (cfg.get("assigneeType") or "").strip()
  339. if at:
  340. types = [at]
  341. if "posts" in types:
  342. for pid in cfg.get("postIds") or cfg.get("post_ids") or []:
  343. if pid:
  344. post_ids.add(str(pid))
  345. child = node.get("child")
  346. if isinstance(child, dict):
  347. collect(child)
  348. for g in node.get("conditionGroups") or []:
  349. if isinstance(g, dict) and isinstance(g.get("child"), dict):
  350. collect(g["child"])
  351. root = flow_tree
  352. if isinstance(flow_tree, dict) and isinstance(flow_tree.get("root"), dict):
  353. root = flow_tree["root"]
  354. collect(root if isinstance(root, dict) else None)
  355. errors: List[str] = []
  356. if post_ids:
  357. rows = (
  358. db.query(SysPost)
  359. .filter(
  360. SysPost.id.in_(list(post_ids)),
  361. SysPost.tenant_no == tenant_no,
  362. SysPost.del_flag == 0,
  363. )
  364. .all()
  365. )
  366. found = {r.id: r for r in rows}
  367. for pid in post_ids:
  368. p = found.get(pid)
  369. if not p:
  370. errors.append(f"引用的职位不存在:{pid}")
  371. elif p.status != "1":
  372. errors.append(f"职位已停用:{p.post_name or pid}")
  373. return errors
  374. def validate_control_types(form_schema: Any) -> List[str]:
  375. flat: List[Dict[str, Any]] = []
  376. _walk_form_rules(_extract_rules(form_schema), flat)
  377. errors: List[str] = []
  378. unknown: Set[str] = set()
  379. for rule in flat:
  380. t = (rule.get("type") or "").strip()
  381. if not t:
  382. continue
  383. if t not in ALLOWED_CONTROL_TYPES:
  384. unknown.add(t)
  385. for t in sorted(unknown):
  386. errors.append(f"不支持的表单控件类型:{t}")
  387. return errors
  388. def validate_draft_for_publish(
  389. db: Session,
  390. *,
  391. tenant_no: str,
  392. form_schema: Any,
  393. flow_tree: Any,
  394. basic: Optional[Dict[str, Any]] = None,
  395. ) -> List[str]:
  396. errors: List[str] = []
  397. app_name = ""
  398. if basic and isinstance(basic, dict):
  399. app_name = (basic.get("appName") or "").strip()
  400. if not app_name:
  401. # 校验侧也可能只传 schema;名称在 publish 时再查 app
  402. pass
  403. if count_fillable_fields(form_schema) < 1:
  404. errors.append("表单至少需要 1 个可填字段")
  405. form_schema = normalize_use_in_condition_required(form_schema)
  406. errors.extend(validate_control_types(form_schema))
  407. errors.extend(validate_flow_tree(flow_tree))
  408. errors.extend(validate_condition_rules_against_form(form_schema, flow_tree))
  409. errors.extend(validate_posts_referenced(db, tenant_no=tenant_no, flow_tree=flow_tree))
  410. return errors
  411. def publish_app(
  412. db: Session,
  413. app_id: str,
  414. *,
  415. tenant_no: str,
  416. user_id: Optional[str] = None,
  417. ) -> Tuple[Dict[str, Any], List[str]]:
  418. """
  419. 校验并发布当前草稿。
  420. 成功返回 (draft_dict_like_published_info, []);
  421. 失败返回 ({}, errors)。
  422. """
  423. app = oa_crud.get_app(db, app_id, tenant_no)
  424. if not app:
  425. return {}, ["应用不存在"]
  426. if not (app.app_name or "").strip():
  427. return {}, ["应用名称不能为空"]
  428. draft_payload = oa_crud.get_or_create_draft(db, app_id, tenant_no=tenant_no, user_id=user_id)
  429. form_schema = draft_payload.get("formSchemaJson")
  430. form_schema = normalize_use_in_condition_required(form_schema)
  431. flow_tree = draft_payload.get("flowTreeJson")
  432. errors = validate_draft_for_publish(
  433. db,
  434. tenant_no=tenant_no,
  435. form_schema=form_schema,
  436. flow_tree=flow_tree,
  437. basic=draft_payload.get("basic"),
  438. )
  439. if errors:
  440. return {}, errors
  441. draft = oa_crud._find_draft(db, app_id, tenant_no)
  442. if not draft:
  443. return {}, ["草稿不存在"]
  444. draft.form_schema_json = oa_crud._dumps(form_schema)
  445. from datetime import datetime
  446. from app.models.models import OaAppVersion
  447. now = datetime.now()
  448. olds = (
  449. db.query(OaAppVersion)
  450. .filter(
  451. OaAppVersion.app_id == app_id,
  452. OaAppVersion.tenant_no == tenant_no,
  453. OaAppVersion.is_latest_published == "1",
  454. OaAppVersion.del_flag == 0,
  455. )
  456. .all()
  457. )
  458. for old in olds:
  459. old.is_latest_published = "0"
  460. old.update_by = user_id
  461. old.update_time = now
  462. db.add(old)
  463. draft.status = "published"
  464. draft.is_latest_published = "1"
  465. draft.update_by = user_id
  466. draft.update_time = now
  467. db.add(draft)
  468. # 发布后不应再残留其它草稿(并发/重复创建等);否则列表 hasDraft 仍为 true
  469. leftover_drafts = (
  470. db.query(OaAppVersion)
  471. .filter(
  472. OaAppVersion.app_id == app_id,
  473. OaAppVersion.tenant_no == tenant_no,
  474. OaAppVersion.status == "draft",
  475. OaAppVersion.del_flag == 0,
  476. OaAppVersion.id != draft.id,
  477. )
  478. .all()
  479. )
  480. for leftover in leftover_drafts:
  481. leftover.del_flag = 1
  482. leftover.update_by = user_id
  483. leftover.update_time = now
  484. db.add(leftover)
  485. app.update_by = user_id
  486. app.update_time = now
  487. db.add(app)
  488. db.commit()
  489. db.refresh(draft)
  490. return {
  491. "appId": app.id,
  492. "versionId": draft.id,
  493. "versionNo": draft.version_no,
  494. "status": draft.status,
  495. "isLatestPublished": draft.is_latest_published,
  496. "appName": app.app_name,
  497. }, []