535 lines
18 KiB
Python
535 lines
18 KiB
Python
"""OA 审批应用发布校验与发布落库"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.crud import oa_approval as oa_crud
|
||
from app.models.models import OaRole
|
||
|
||
# 可填控件(FormCreate / 自建控件字典交集;布局类不计)
|
||
FILLABLE_TYPES = frozenset(
|
||
{
|
||
"input",
|
||
"textarea",
|
||
"inputNumber",
|
||
"number",
|
||
"radio",
|
||
"checkbox",
|
||
"select",
|
||
"switch",
|
||
"datePicker",
|
||
"date",
|
||
"dateRange",
|
||
"timePicker",
|
||
"time",
|
||
"phone",
|
||
"image",
|
||
"upload",
|
||
"money",
|
||
"dept",
|
||
"region",
|
||
"formula",
|
||
"relatedApproval",
|
||
"rate",
|
||
"slider",
|
||
"cascader",
|
||
}
|
||
)
|
||
|
||
LAYOUT_TYPES = frozenset(
|
||
{
|
||
"fcRow",
|
||
"col",
|
||
"column",
|
||
"fcTable",
|
||
"html",
|
||
"divider",
|
||
"card",
|
||
"space",
|
||
"tabs",
|
||
"tabPane",
|
||
"group",
|
||
"object",
|
||
}
|
||
)
|
||
|
||
VALID_ASSIGNEE_TYPES = frozenset(
|
||
{
|
||
"users",
|
||
"oa_roles",
|
||
"dept_leader",
|
||
"direct_supervisor",
|
||
"initiator_select",
|
||
}
|
||
)
|
||
|
||
VALID_NODE_TYPES = frozenset({"start", "approval", "cc", "condition", "end"})
|
||
|
||
# 发布允许的控件 type(未知 type 拒绝)
|
||
ALLOWED_CONTROL_TYPES = FILLABLE_TYPES | LAYOUT_TYPES
|
||
|
||
CONDITION_ELIGIBLE_TYPES = frozenset(
|
||
{
|
||
"input",
|
||
"inputNumber",
|
||
"number",
|
||
"money",
|
||
"radio",
|
||
"select",
|
||
"datePicker",
|
||
"date",
|
||
}
|
||
)
|
||
|
||
|
||
def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
|
||
if form_schema is None:
|
||
return []
|
||
if isinstance(form_schema, list):
|
||
return [r for r in form_schema if isinstance(r, dict)]
|
||
if isinstance(form_schema, dict):
|
||
rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
|
||
if isinstance(rules, list):
|
||
return [r for r in rules if isinstance(r, dict)]
|
||
# 单字段对象
|
||
if form_schema.get("type") or form_schema.get("field"):
|
||
return [form_schema]
|
||
return []
|
||
|
||
|
||
def _walk_form_rules(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
|
||
for rule in rules:
|
||
out.append(rule)
|
||
children = rule.get("children") or rule.get("props", {}).get("rule") or []
|
||
if isinstance(children, list):
|
||
_walk_form_rules([c for c in children if isinstance(c, dict)], out)
|
||
|
||
|
||
def count_fillable_fields(form_schema: Any) -> int:
|
||
flat: List[Dict[str, Any]] = []
|
||
_walk_form_rules(_extract_rules(form_schema), flat)
|
||
n = 0
|
||
for rule in flat:
|
||
t = (rule.get("type") or "").strip()
|
||
if t in LAYOUT_TYPES:
|
||
continue
|
||
field = rule.get("field") or rule.get("name")
|
||
if t in FILLABLE_TYPES or (field and t and t not in LAYOUT_TYPES):
|
||
n += 1
|
||
return n
|
||
|
||
|
||
def _rule_use_in_condition(rule: Dict[str, Any]) -> bool:
|
||
if rule.get("useInCondition") is True:
|
||
return True
|
||
props = rule.get("props") or {}
|
||
return isinstance(props, dict) and props.get("useInCondition") is True
|
||
|
||
|
||
def index_condition_fields(form_schema: Any) -> Dict[str, Dict[str, Any]]:
|
||
"""field -> rule(仅 useInCondition 且 type 合格)"""
|
||
flat: List[Dict[str, Any]] = []
|
||
_walk_form_rules(_extract_rules(form_schema), flat)
|
||
out: Dict[str, Dict[str, Any]] = {}
|
||
for rule in flat:
|
||
t = (rule.get("type") or "").strip()
|
||
field = (rule.get("field") or rule.get("name") or "").strip()
|
||
if not field or t not in CONDITION_ELIGIBLE_TYPES:
|
||
continue
|
||
if _rule_use_in_condition(rule):
|
||
out[field] = rule
|
||
return out
|
||
|
||
|
||
def normalize_use_in_condition_required(form_schema: Any) -> Any:
|
||
"""就地/深拷贝皆可:打标字段强制 required/$required。发布前调用。"""
|
||
import copy
|
||
|
||
schema = copy.deepcopy(form_schema) if form_schema is not None else {"rule": []}
|
||
|
||
def walk(list_rules: List[Dict[str, Any]]) -> None:
|
||
for rule in list_rules:
|
||
if _rule_use_in_condition(rule):
|
||
rule["useInCondition"] = True
|
||
rule["$required"] = True
|
||
rule["required"] = True
|
||
props = rule.get("props")
|
||
if isinstance(props, dict):
|
||
props["useInCondition"] = True
|
||
children = rule.get("children") or (rule.get("props") or {}).get("rule") or []
|
||
if isinstance(children, list):
|
||
walk([c for c in children if isinstance(c, dict)])
|
||
|
||
if isinstance(schema, list):
|
||
walk([r for r in schema if isinstance(r, dict)])
|
||
elif isinstance(schema, dict):
|
||
r = schema.get("rule")
|
||
if isinstance(r, list):
|
||
walk([x for x in r if isinstance(x, dict)])
|
||
return schema
|
||
|
||
|
||
def _node_type(node: Dict[str, Any]) -> str:
|
||
return (node.get("type") or node.get("nodeType") or "").strip()
|
||
|
||
|
||
def validate_flow_tree(flow_tree: Any) -> List[str]:
|
||
errors: List[str] = []
|
||
if not flow_tree or not isinstance(flow_tree, dict):
|
||
return ["流程树不能为空"]
|
||
|
||
root = flow_tree
|
||
# 兼容 { root: node } 包装
|
||
if "root" in flow_tree and isinstance(flow_tree["root"], dict):
|
||
root = flow_tree["root"]
|
||
|
||
if _node_type(root) != "start":
|
||
errors.append("流程树根节点必须是发起人(start)")
|
||
|
||
seen_end = False
|
||
|
||
def walk(node: Optional[Dict[str, Any]], path: str) -> None:
|
||
nonlocal seen_end
|
||
if not node or not isinstance(node, dict):
|
||
errors.append(f"{path}:节点缺失")
|
||
return
|
||
ntype = _node_type(node)
|
||
if ntype not in VALID_NODE_TYPES:
|
||
errors.append(f"{path}:不支持的节点类型「{ntype or '(空)'}」")
|
||
return
|
||
|
||
if ntype == "end":
|
||
seen_end = True
|
||
if node.get("child"):
|
||
errors.append(f"{path}:结束节点后不应再有子节点")
|
||
return
|
||
|
||
if ntype in ("approval", "cc"):
|
||
cfg = node.get("approverConfig") or node.get("approver_config") or {}
|
||
if not isinstance(cfg, dict):
|
||
cfg = {}
|
||
# 抄送也需要明确接收人策略
|
||
at = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
|
||
if not at:
|
||
errors.append(f"{path}:未配置审批人/抄送人策略")
|
||
elif at not in VALID_ASSIGNEE_TYPES:
|
||
errors.append(f"{path}:不支持的审批人类型「{at}」")
|
||
elif at == "users":
|
||
ids = cfg.get("userIds") or cfg.get("user_ids") or []
|
||
if not ids:
|
||
errors.append(f"{path}:指定成员不能为空")
|
||
elif at == "oa_roles":
|
||
rids = cfg.get("oaRoleIds") or cfg.get("oa_role_ids") or []
|
||
codes = cfg.get("oaRoleCodes") or cfg.get("oa_role_codes") or []
|
||
if not rids and not codes:
|
||
errors.append(f"{path}:工作流角色不能为空")
|
||
# dept_leader / direct_supervisor / initiator_select:类型本身即完备
|
||
|
||
if ntype == "condition":
|
||
groups = node.get("conditionGroups") or node.get("condition_groups") or []
|
||
if not isinstance(groups, list) or len(groups) < 1:
|
||
errors.append(f"{path}:条件分支至少需要一组出线")
|
||
else:
|
||
for i, g in enumerate(groups):
|
||
if not isinstance(g, dict):
|
||
errors.append(f"{path}.条件组[{i}]:格式非法")
|
||
continue
|
||
is_default = bool(g.get("isDefault"))
|
||
rules = g.get("rules") if isinstance(g.get("rules"), list) else []
|
||
when = (g.get("when") or "").strip() if isinstance(g.get("when"), str) else ""
|
||
valid_rules = [
|
||
r
|
||
for r in rules
|
||
if isinstance(r, dict)
|
||
and (r.get("field") or "").strip()
|
||
and (r.get("op") or "").strip()
|
||
and r.get("value") is not None
|
||
and str(r.get("value")).strip() != ""
|
||
]
|
||
if is_default:
|
||
pass
|
||
elif valid_rules:
|
||
pass
|
||
elif when and not valid_rules:
|
||
errors.append(
|
||
f"{path}.条件组[{i}]:请用可视化规则重新配置条件(不再支持仅公式)"
|
||
)
|
||
else:
|
||
errors.append(f"{path}.条件组[{i}]:请完善条件分支规则")
|
||
child = g.get("child")
|
||
if not child:
|
||
errors.append(f"{path}.条件组[{i}]:缺少下游节点")
|
||
else:
|
||
gname = g.get("name") or f"分支{i + 1}"
|
||
walk(child if isinstance(child, dict) else None, f"{path}/{gname}")
|
||
# 条件分支汇合后的主链(可选)
|
||
merge = node.get("child")
|
||
if merge is not None:
|
||
if not isinstance(merge, dict):
|
||
errors.append(f"{path}:汇合后子节点格式非法")
|
||
else:
|
||
walk(merge, f"{path}/汇合")
|
||
return
|
||
|
||
child = node.get("child")
|
||
if child is None:
|
||
if ntype != "end":
|
||
errors.append(f"{path}:须连接到后续节点直至结束")
|
||
return
|
||
if not isinstance(child, dict):
|
||
errors.append(f"{path}:子节点格式非法")
|
||
return
|
||
cname = child.get("name") or _node_type(child) or "next"
|
||
walk(child, f"{path}/{cname}")
|
||
|
||
walk(root, root.get("name") or "发起人")
|
||
if not seen_end:
|
||
errors.append("流程树未能到达结束节点")
|
||
return errors
|
||
|
||
|
||
def validate_condition_rules_against_form(form_schema: Any, flow_tree: Any) -> List[str]:
|
||
indexed = index_condition_fields(form_schema)
|
||
errors: List[str] = []
|
||
seen: Set[str] = set()
|
||
|
||
def collect_fields(node: Optional[Dict[str, Any]]) -> None:
|
||
if not node or not isinstance(node, dict):
|
||
return
|
||
for g in node.get("conditionGroups") or node.get("condition_groups") or []:
|
||
if not isinstance(g, dict) or g.get("isDefault"):
|
||
continue
|
||
rules = g.get("rules") if isinstance(g.get("rules"), list) else []
|
||
for r in rules:
|
||
if not isinstance(r, dict):
|
||
continue
|
||
field = (r.get("field") or "").strip()
|
||
if not field or field in seen:
|
||
continue
|
||
seen.add(field)
|
||
if field not in indexed:
|
||
errors.append(f"条件引用了无效字段:{field}")
|
||
child = g.get("child")
|
||
if isinstance(child, dict):
|
||
collect_fields(child)
|
||
child = node.get("child")
|
||
if isinstance(child, dict):
|
||
collect_fields(child)
|
||
|
||
root = flow_tree
|
||
if isinstance(flow_tree, dict) and isinstance(flow_tree.get("root"), dict):
|
||
root = flow_tree["root"]
|
||
collect_fields(root if isinstance(root, dict) else None)
|
||
return errors
|
||
|
||
|
||
def validate_oa_roles_referenced(
|
||
db: Session, *, tenant_no: str, flow_tree: Any
|
||
) -> List[str]:
|
||
role_ids: Set[str] = set()
|
||
role_codes: Set[str] = set()
|
||
|
||
def collect(node: Optional[Dict[str, Any]]) -> None:
|
||
if not node or not isinstance(node, dict):
|
||
return
|
||
cfg = node.get("approverConfig") or {}
|
||
if isinstance(cfg, dict):
|
||
at = (cfg.get("assigneeType") or "").strip()
|
||
if at == "oa_roles":
|
||
for rid in cfg.get("oaRoleIds") or cfg.get("oa_role_ids") or []:
|
||
if rid:
|
||
role_ids.add(str(rid))
|
||
for code in cfg.get("oaRoleCodes") or cfg.get("oa_role_codes") or []:
|
||
if code:
|
||
role_codes.add(str(code).upper())
|
||
child = node.get("child")
|
||
if isinstance(child, dict):
|
||
collect(child)
|
||
for g in node.get("conditionGroups") or []:
|
||
if isinstance(g, dict) and isinstance(g.get("child"), dict):
|
||
collect(g["child"])
|
||
|
||
root = flow_tree
|
||
if isinstance(flow_tree, dict) and isinstance(flow_tree.get("root"), dict):
|
||
root = flow_tree["root"]
|
||
collect(root if isinstance(root, dict) else None)
|
||
|
||
errors: List[str] = []
|
||
if role_ids:
|
||
rows = (
|
||
db.query(OaRole)
|
||
.filter(
|
||
OaRole.id.in_(list(role_ids)),
|
||
OaRole.tenant_no == tenant_no,
|
||
OaRole.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
found = {r.id: r for r in rows}
|
||
for rid in role_ids:
|
||
r = found.get(rid)
|
||
if not r:
|
||
errors.append(f"引用的工作流角色不存在:{rid}")
|
||
elif r.status != "1":
|
||
errors.append(f"工作流角色已停用:{r.role_name or rid}")
|
||
if role_codes:
|
||
rows = (
|
||
db.query(OaRole)
|
||
.filter(
|
||
OaRole.role_code.in_(list(role_codes)),
|
||
OaRole.tenant_no == tenant_no,
|
||
OaRole.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
found_codes = {r.role_code.upper(): r for r in rows}
|
||
for code in role_codes:
|
||
r = found_codes.get(code)
|
||
if not r:
|
||
errors.append(f"引用的工作流角色编码不存在:{code}")
|
||
elif r.status != "1":
|
||
errors.append(f"工作流角色已停用:{r.role_name or code}")
|
||
return errors
|
||
|
||
|
||
def validate_control_types(form_schema: Any) -> List[str]:
|
||
flat: List[Dict[str, Any]] = []
|
||
_walk_form_rules(_extract_rules(form_schema), flat)
|
||
errors: List[str] = []
|
||
unknown: Set[str] = set()
|
||
for rule in flat:
|
||
t = (rule.get("type") or "").strip()
|
||
if not t:
|
||
continue
|
||
if t not in ALLOWED_CONTROL_TYPES:
|
||
unknown.add(t)
|
||
for t in sorted(unknown):
|
||
errors.append(f"不支持的表单控件类型:{t}")
|
||
return errors
|
||
|
||
|
||
def validate_draft_for_publish(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
form_schema: Any,
|
||
flow_tree: Any,
|
||
basic: Optional[Dict[str, Any]] = None,
|
||
) -> List[str]:
|
||
errors: List[str] = []
|
||
app_name = ""
|
||
if basic and isinstance(basic, dict):
|
||
app_name = (basic.get("appName") or "").strip()
|
||
if not app_name:
|
||
# 校验侧也可能只传 schema;名称在 publish 时再查 app
|
||
pass
|
||
if count_fillable_fields(form_schema) < 1:
|
||
errors.append("表单至少需要 1 个可填字段")
|
||
form_schema = normalize_use_in_condition_required(form_schema)
|
||
errors.extend(validate_control_types(form_schema))
|
||
errors.extend(validate_flow_tree(flow_tree))
|
||
errors.extend(validate_condition_rules_against_form(form_schema, flow_tree))
|
||
errors.extend(validate_oa_roles_referenced(db, tenant_no=tenant_no, flow_tree=flow_tree))
|
||
return errors
|
||
|
||
|
||
def publish_app(
|
||
db: Session,
|
||
app_id: str,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
) -> Tuple[Dict[str, Any], List[str]]:
|
||
"""
|
||
校验并发布当前草稿。
|
||
成功返回 (draft_dict_like_published_info, []);
|
||
失败返回 ({}, errors)。
|
||
"""
|
||
app = oa_crud.get_app(db, app_id, tenant_no)
|
||
if not app:
|
||
return {}, ["应用不存在"]
|
||
if not (app.app_name or "").strip():
|
||
return {}, ["应用名称不能为空"]
|
||
|
||
draft_payload = oa_crud.get_or_create_draft(db, app_id, tenant_no=tenant_no, user_id=user_id)
|
||
form_schema = draft_payload.get("formSchemaJson")
|
||
form_schema = normalize_use_in_condition_required(form_schema)
|
||
flow_tree = draft_payload.get("flowTreeJson")
|
||
errors = validate_draft_for_publish(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
form_schema=form_schema,
|
||
flow_tree=flow_tree,
|
||
basic=draft_payload.get("basic"),
|
||
)
|
||
if errors:
|
||
return {}, errors
|
||
|
||
draft = oa_crud._find_draft(db, app_id, tenant_no)
|
||
if not draft:
|
||
return {}, ["草稿不存在"]
|
||
|
||
draft.form_schema_json = oa_crud._dumps(form_schema)
|
||
|
||
from datetime import datetime
|
||
|
||
from app.models.models import OaAppVersion
|
||
|
||
now = datetime.now()
|
||
olds = (
|
||
db.query(OaAppVersion)
|
||
.filter(
|
||
OaAppVersion.app_id == app_id,
|
||
OaAppVersion.tenant_no == tenant_no,
|
||
OaAppVersion.is_latest_published == "1",
|
||
OaAppVersion.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
for old in olds:
|
||
old.is_latest_published = "0"
|
||
old.update_by = user_id
|
||
old.update_time = now
|
||
db.add(old)
|
||
|
||
draft.status = "published"
|
||
draft.is_latest_published = "1"
|
||
draft.update_by = user_id
|
||
draft.update_time = now
|
||
db.add(draft)
|
||
|
||
# 发布后不应再残留其它草稿(并发/重复创建等);否则列表 hasDraft 仍为 true
|
||
leftover_drafts = (
|
||
db.query(OaAppVersion)
|
||
.filter(
|
||
OaAppVersion.app_id == app_id,
|
||
OaAppVersion.tenant_no == tenant_no,
|
||
OaAppVersion.status == "draft",
|
||
OaAppVersion.del_flag == 0,
|
||
OaAppVersion.id != draft.id,
|
||
)
|
||
.all()
|
||
)
|
||
for leftover in leftover_drafts:
|
||
leftover.del_flag = 1
|
||
leftover.update_by = user_id
|
||
leftover.update_time = now
|
||
db.add(leftover)
|
||
|
||
app.update_by = user_id
|
||
app.update_time = now
|
||
db.add(app)
|
||
db.commit()
|
||
db.refresh(draft)
|
||
|
||
return {
|
||
"appId": app.id,
|
||
"versionId": draft.id,
|
||
"versionNo": draft.version_no,
|
||
"status": draft.status,
|
||
"isLatestPublished": draft.is_latest_published,
|
||
"appName": app.app_name,
|
||
}, []
|