#!/usr/bin/env python3 """Shared Yida process-flow schema builder. The schema consumed by integration automation and design process update is the same shape: {"props": {"processCode": "TPROC-xxx", ...}, "nodes": [...]} This module accepts either that ready-to-save shape or the flatter node objects described in references/yida-process-node.md, then normalizes them into the save payload used by `dws yida design process update --content`. """ from __future__ import annotations import copy from typing import Any FLOW_PROP_KEYS = ("allowWithdraw", "allowCollaboration", "allowTemporaryStorage") FLOW_TOP_LEVEL_KEYS = ("flowConfig", "formulaRules", "approvalSummary", "nodeI18nKeyMap") NODE_CORE_KEYS = { "type", "componentName", "nodeType", "tool", "toolName", "kind", "nodeId", "name", "description", "title", "approvalType", "nextId", "prevId", "props", "childNodes", } TYPE_ALIASES = { "create_trigger_node": "trigger", "trigger_node": "trigger", "trigger": "trigger", "create_timer_trigger_node": "trigger", "timer_trigger_node": "trigger", "timerTrigger": "trigger", "create_data_create_node": "dataCreate", "data_create_node": "dataCreate", "AddDataNode": "dataCreate", "addData": "dataCreate", "dataCreate": "dataCreate", "create_data_update_node": "dataUpdate", "data_update_node": "dataUpdate", "UpdateDataNode": "dataUpdate", "updateData": "dataUpdate", "dataUpdate": "dataUpdate", "create_data_retrieve_node": "dataRetrieve", "data_retrieve_node": "dataRetrieve", "GetSingleDataNode": "dataRetrieve", "GetBatchDataNode": "dataRetrieve", "getSingleData": "dataRetrieve", "getBatchData": "dataRetrieve", "dataRetrieve": "dataRetrieve", "DeleteDataNode": "dataDelete", "deleteData": "dataDelete", "dataDelete": "dataDelete", "create_connector_node": "connector", "connector_node": "connector", "ConnectorNode": "connector", "connector": "connector", "create_send_message_node": "sendMessage", "send_message_node": "sendMessage", "SendMessageNode": "sendMessage", "sendMessage": "sendMessage", "create_send_email_node": "sendEmail", "send_email_node": "sendEmail", "SendEmailNode": "sendEmail", "sendEmail": "sendEmail", "create_send_card_node": "sendCard", "send_card_node": "sendCard", "SendCardNode": "sendCard", "CardNode": "sendCard", "sendCard": "sendCard", "create_update_card_node": "updateCard", "update_card_node": "updateCard", "UpdateCardNode": "updateCard", "CardUpdateNode": "updateCard", "updateCard": "updateCard", "create_route_node": "route", "route_node": "route", "ConditionContainer": "route", "route": "route", "ConditionNode": "condition", "ParallelNode": "condition", "condition": "condition", "create_foreach_node": "forEach", "foreach_node": "forEach", "CycleContainer": "forEach", "forEach": "forEach", "foreach": "forEach", "create_initiate_approval_node": "initiateApproval", "initiate_approval_node": "initiateApproval", "InitiateApprovalNode": "initiateApproval", "subProcess": "initiateApproval", "initiateApproval": "initiateApproval", "create_approver_node": "approver", "approver_node": "approver", "ApprovalNode": "approval", "MultiApprovalNode": "approval", "approver": "approver", "create_executor_node": "executor", "executor_node": "executor", "OperatorNode": "operator", "executor": "executor", "create_carbon_copy_node": "carbonCopy", "carbon_copy_node": "carbonCopy", "CarbonNode": "carbon", "carbonCopy": "carbonCopy", "GroovyNode": "CodeExecutor", "groovy": "CodeExecutor", "JavaScriptNode": "CodeExecutor", "javascript": "CodeExecutor", "AINode": "AIExecutor", "ai": "AIExecutor", "create_finish_node": "finish", "finish_node": "finish", "EndNode": "finish", "finish": "finish", } DEFAULT_NODE_NAMES = { "apply": "发起", "approval": "审批人", "operator": "执行人", "carbon": "抄送人", "trigger": "表单事件触发", "dataCreate": "新增数据", "dataUpdate": "更新数据", "dataRetrieve": "获取数据", "dataDelete": "删除数据", "connector": "连接器", "sendMessage": "消息通知", "sendEmail": "发送邮件", "sendCard": "发送卡片", "updateCard": "更新卡片", "route": "ConditionContainer", "condition": "条件", "forEach": "循环容器", "foreach": "循环容器", "initiateApproval": "发起审批", "CodeExecutor": "脚本", "AIExecutor": "AI 节点", "approver": "审批人", "executor": "执行人", "carbonCopy": "抄送人", "finish": "结束", } VIEW_COMPONENT_BY_TYPE = { "apply": "ApplyNode", "approval": "ApprovalNode", "operator": "OperatorNode", "carbon": "CarbonNode", "connector": "ConnectorNode", "dataCreate": "AddDataNode", "dataUpdate": "UpdateDataNode", "dataRetrieve": "GetSingleDataNode", "dataDelete": "DeleteDataNode", "sendMessage": "SendMessageNode", "sendEmail": "SendEmailNode", "sendCard": "CardNode", "updateCard": "CardUpdateNode", "route": "ConditionContainer", "condition": "ConditionNode", "initiateApproval": "InitiateApprovalNode", "approver": "ApprovalNode", "executor": "OperatorNode", "carbonCopy": "CarbonNode", "forEach": "CycleContainer", "foreach": "CycleContainer", "AIExecutor": "AINode", "CodeExecutor": "JavaScriptNode", "finish": "EndNode", } VIEW_COMPONENT_RULE_KEY = { "ConnectorNode": "connectorRules", "AddDataNode": "addDataRules", "UpdateDataNode": "updateDataRules", "DeleteDataNode": "deleteData", "GetSingleDataNode": "getData", "GetBatchDataNode": "getData", "SendMessageNode": "sendMessageRules", "SendEmailNode": "sendEmailRules", "CardNode": "cardRules", "CardUpdateNode": "cardUpdateRules", "InitiateApprovalNode": "initiateApprovalRules", "CycleContainer": "cycleContainerRules", "AINode": "workFlowRules", "GroovyNode": "groovy", "JavaScriptNode": "JavaScript", } TRIGGER_INPUT_KEYS = { "formUuid", "formEventType", "formEventField", "triggerFormEventRecursively", "conditions", "startDate", "repeat", "end", } UNRESOLVED_CARD_BIZ_ID_VALUES = { "__masterdata_form_inst_id", } class FlowSchemaError(ValueError): """Raised when a flow spec cannot be normalized safely.""" def i18n_name(value: Any, default: str) -> dict[str, str]: if isinstance(value, dict): result = copy.deepcopy(value) result.setdefault("zh_CN", result.get("name") or default) result.setdefault("en_US", "") result.setdefault("type", "i18n") return result text = str(value or default) return {"zh_CN": text, "en_US": "", "type": "i18n"} def normalize_next_id(value: Any) -> list[str]: if value is None: return [] if isinstance(value, str): return [value] if value else [] if isinstance(value, list): return [str(item) for item in value if item not in (None, "")] raise FlowSchemaError(f"nextId must be string or list, got {type(value).__name__}") def normalize_node_type(node: dict[str, Any]) -> str: raw = ( node.get("componentName") or node.get("nodeType") or node.get("tool") or node.get("toolName") or node.get("kind") or node.get("type") ) if not raw: raise FlowSchemaError(f"node {node.get('nodeId') or ''} missing type") raw = str(raw) return TYPE_ALIASES.get(raw, raw) def _normalize_trigger_props(props: dict[str, Any]) -> dict[str, Any]: result = copy.deepcopy(props) inputs = result.get("inputs") if inputs is None: inputs = {} if not isinstance(inputs, dict): raise FlowSchemaError("trigger props.inputs must be object") for key in list(TRIGGER_INPUT_KEYS): if key in result: inputs[key] = result.pop(key) if "triggerType" not in result: result["triggerType"] = "TimerEvent" if ("repeat" in inputs or "startDate" in inputs) else "FormEvent" result["inputs"] = inputs return result def _script_outputs_schema(outputs: Any) -> list[dict[str, Any]]: if not isinstance(outputs, list): return [] result = [] for item in outputs: if not isinstance(item, dict): continue result.append({ "description": item.get("desc") or item.get("description") or "", "name": item.get("name"), "type": item.get("type"), "valueType": item.get("valueType"), }) return result def _normalize_process_payload(node_type: str, props: dict[str, Any]) -> tuple[str, dict[str, Any]]: result = copy.deepcopy(props) if node_type in {"approval", "approver", "operator", "executor"} and isinstance(result.get("approver"), dict): approver = result["approver"] if approver.get("approvalType") and not result.get("approvalType"): result["approvalType"] = copy.deepcopy(approver["approvalType"]) if approver.get("approvals") is not None and "approvals" not in result: result["approvals"] = copy.deepcopy(approver.get("approvals")) if isinstance(approver.get("approverRules"), dict) and "approverRules" not in result: result["approverRules"] = copy.deepcopy(approver["approverRules"]) if node_type == "dataRetrieve" and isinstance(result.get("getData"), dict): return node_type, copy.deepcopy(result["getData"]) if node_type == "dataUpdate" and isinstance(result.get("updateDataRules"), dict): return node_type, copy.deepcopy(result["updateDataRules"]) if node_type == "dataDelete" and isinstance(result.get("deleteData"), dict): return node_type, copy.deepcopy(result["deleteData"]) if node_type == "sendMessage" and isinstance(result.get("sendMessageRules"), dict): payload = copy.deepcopy(result["sendMessageRules"]) payload.pop("description", None) return node_type, payload if node_type == "sendEmail" and isinstance(result.get("sendEmailRules"), dict): return node_type, copy.deepcopy(result["sendEmailRules"]) if node_type == "sendCard": if isinstance(result.get("sendCardRules"), dict): return node_type, copy.deepcopy(result["sendCardRules"]) if isinstance(result.get("cardRules"), dict): return node_type, copy.deepcopy(result["cardRules"]) if node_type == "updateCard": if isinstance(result.get("updateCardRules"), dict): return node_type, copy.deepcopy(result["updateCardRules"]) if isinstance(result.get("cardUpdateRules"), dict): return node_type, copy.deepcopy(result["cardUpdateRules"]) if node_type == "initiateApproval" and isinstance(result.get("initiateApprovalRules"), dict): return node_type, copy.deepcopy(result["initiateApprovalRules"]) if node_type == "forEach": if isinstance(result.get("cycleContainerRules"), dict): return "foreach", copy.deepcopy(result["cycleContainerRules"]) if isinstance(result.get("cycleRules"), dict): return "foreach", copy.deepcopy(result["cycleRules"]) if isinstance(result.get("foreachRules"), dict): return "foreach", copy.deepcopy(result["foreachRules"]) if node_type == "AIExecutor" and isinstance(result.get("workFlowRules"), dict): workflow = result["workFlowRules"] return node_type, { "type": "aiFlow", "action": {"flowId": workflow.get("flowId")}, "outputs": copy.deepcopy(workflow.get("outputs") or []), "yidaFieldIdList": copy.deepcopy(workflow.get("yidaFieldIdList") or []), } if node_type == "CodeExecutor": script = result.get("JavaScript") if isinstance(result.get("JavaScript"), dict) else None script_type = "JavaScript" if script is None and isinstance(result.get("groovy"), dict): script = result.get("groovy") script_type = "groovy" if script is not None: return node_type, { "inputs": copy.deepcopy(script.get("inputs")), "action": copy.deepcopy(script.get("action")), "scriptType": script.get("scriptType") or script_type, "outputsSchema": _script_outputs_schema(script.get("outputs")), } return node_type, result def _contains_originator_keyword(value: Any) -> bool: text = _i18n_text(value, "").lower() return any(keyword in text for keyword in ("申请人", "发起人", "applicant", "originator")) def _actioner_targets_originator(value: Any) -> bool: if isinstance(value, str): return value.lower() in {"originator", "$originator", "applicant", "$applicant"} or value in {"申请人", "发起人"} if not isinstance(value, dict): return False rule_type = str(value.get("type") or value.get("approvalType") or "").lower() if rule_type in {"originator", "ext_target_approval_originator"}: return True for key in ("value", "id", "userId", "fieldId", "fieldName", "label", "name", "description"): if _actioner_targets_originator(value.get(key)): return True return False def _is_originator_operator_node(node_type: str, node: dict[str, Any], props: dict[str, Any]) -> bool: if node_type not in {"operator", "executor"}: return False if _actioner_targets_originator(props.get("actionerRule")): return True if _actioner_targets_originator(props.get("executor")) or _actioner_targets_originator(props.get("approver")): return True if _contains_originator_keyword(node.get("name")) or _contains_originator_keyword(node.get("description")): return True if _contains_originator_keyword(props.get("name")) or _contains_originator_keyword(props.get("description")): return True return False def _operator_action_to_approval_action(action: Any) -> dict[str, Any] | None: if isinstance(action, dict): normalized = copy.deepcopy(action) raw_action = str(normalized.get("action") or "").lower() if raw_action in {"submit", "confirm"}: normalized["action"] = "agree" normalized.setdefault("hidden", False) normalized.setdefault("name", i18n_name(normalized.get("name") or normalized.get("text"), "确认")) normalized.setdefault("text", copy.deepcopy(normalized["name"])) normalized.setdefault("alias", copy.deepcopy(normalized["name"])) return normalized raw = str(action or "").lower() if raw in {"submit", "confirm", "agree"}: return { "hidden": False, "name": {"en_US": "Confirm", "zh_CN": "确认", "type": "i18n"}, "text": {"en_US": "Confirm", "zh_CN": "确认", "type": "i18n"}, "action": "agree", "alias": {"en_US": "Confirm", "zh_CN": "确认", "type": "i18n"}, } if raw in {"transfer", "forward"}: return { "hidden": True, "name": {"en_US": "Forward", "zh_CN": "转交", "type": "i18n"}, "text": {"en_US": "Forward", "zh_CN": "转交", "type": "i18n"}, "action": "forward", "alias": {"en_US": "Forward", "zh_CN": "转交", "type": "i18n"}, } return None def _originator_approval_props_from_operator(props: dict[str, Any]) -> dict[str, Any]: result = copy.deepcopy(props) result.pop("actionerRule", None) result.pop("finishRule", None) result.pop("executor", None) result.pop("approver", None) result["approvals"] = [["originator"]] approver_rules = copy.deepcopy(result.get("approverRules") if isinstance(result.get("approverRules"), dict) else {}) approver_rules.update({ "type": "ext_target_approval_originator", "mode": "ApprovalNode_rules_only", "approverList": [{"type": "ext_target_approval"}], "multiApproverType": approver_rules.get("multiApproverType", "all"), "conditionalMode": approver_rules.get("conditionalMode", "conditional"), "approvals": [["originator"]], "description": approver_rules.get("description") or "发起人本人", }) result["approverRules"] = approver_rules raw_actions = result.get("actions") converted_actions = [ converted for converted in (_operator_action_to_approval_action(item) for item in raw_actions or []) if converted is not None ] if not converted_actions: converted_actions = [ _operator_action_to_approval_action("submit"), _operator_action_to_approval_action("transfer"), ] result["actions"] = [item for item in converted_actions if item is not None] result.setdefault("appendActions", []) result.setdefault("openDigitalSign", False) result.setdefault("noActionersType", "stopProcess") return result def normalize_node(node: dict[str, Any]) -> dict[str, Any]: if not isinstance(node, dict): raise FlowSchemaError(f"node must be object, got {type(node).__name__}") node_type = normalize_node_type(node) node_id = node.get("nodeId") if not isinstance(node_id, str) or not node_id: raise FlowSchemaError(f"{node_type} node missing nodeId") props = copy.deepcopy(node.get("props") or {}) if not isinstance(props, dict): raise FlowSchemaError(f"node {node_id} props must be object") for key, value in node.items(): if key == "type" and ( node.get("componentName") or node.get("nodeType") or node.get("tool") or node.get("toolName") or node.get("kind") ): props[key] = copy.deepcopy(value) continue if key not in NODE_CORE_KEYS: props[key] = copy.deepcopy(value) if node_type == "trigger": props = _normalize_trigger_props(props) node_type, props = _normalize_process_payload(node_type, props) approval_type = copy.deepcopy(node.get("approvalType")) if "approvalType" in node else copy.deepcopy(props.get("approvalType")) if _is_originator_operator_node(node_type, node, props): node_type = "approval" approval_type = "ext_target_approval_originator" props = _originator_approval_props_from_operator(props) child_nodes = node.get("childNodes") or [] if not isinstance(child_nodes, list): raise FlowSchemaError(f"node {node_id} childNodes must be list") normalized = { "name": i18n_name(node.get("name"), DEFAULT_NODE_NAMES.get(node_type, node_type)), "type": node_type, "nodeId": node_id, "nextId": normalize_next_id(node.get("nextId")), "props": props, "childNodes": [normalize_node(child) for child in child_nodes], } if "prevId" in node: normalized["prevId"] = node["prevId"] if "description" in node: normalized["description"] = copy.deepcopy(node.get("description")) if "title" in node: normalized["title"] = copy.deepcopy(node.get("title")) if approval_type is not None: normalized["approvalType"] = approval_type return normalized def _walk_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] for node in nodes: result.append(node) result.extend(_walk_nodes(node.get("childNodes") or [])) return result def _node_label(node: dict[str, Any]) -> str: return f"{node.get('nodeId') or ''}({ _i18n_text(node.get('name'), str(node.get('type') or 'node')) })" def _non_empty_assignments(value: Any) -> bool: return isinstance(value, list) and any(isinstance(item, dict) for item in value) def _rule_list_has_entries(value: Any) -> bool: if not isinstance(value, dict): return False rules = value.get("rules") if isinstance(rules, list): return any(isinstance(item, dict) for item in rules) if isinstance(rules, dict): return _rule_list_has_entries(rules) return False def _validate_assignment_columns(node: dict[str, Any], assignments: Any) -> None: if not isinstance(assignments, list): return for index, assignment in enumerate(assignments, start=1): if not isinstance(assignment, dict): raise FlowSchemaError(f"{_node_label(node)} assignment #{index} must be object") if not assignment.get("column"): raise FlowSchemaError(f"{_node_label(node)} assignment #{index} missing column") if not assignment.get("valueType"): raise FlowSchemaError(f"{_node_label(node)} assignment #{index} missing valueType") def _validate_designer_runtime_node(node: dict[str, Any]) -> None: node_type = str(node.get("type") or "") props = node.get("props") if not isinstance(props, dict): return if node_type == "dataCreate": if not props.get("formUuid"): raise FlowSchemaError(f"{_node_label(node)} AddDataNode missing target formUuid") assignments = props.get("assignments") has_rules = _rule_list_has_entries(props.get("rules")) add_data_rules = props.get("addDataRules") if isinstance(props.get("addDataRules"), dict) else {} has_add_rules = _rule_list_has_entries(add_data_rules.get("rules")) if not (_non_empty_assignments(assignments) or has_rules or has_add_rules): raise FlowSchemaError(f"{_node_label(node)} AddDataNode missing field assignment rules") _validate_assignment_columns(node, assignments) if node_type == "dataUpdate": if not props.get("sourceId"): raise FlowSchemaError(f"{_node_label(node)} UpdateDataNode missing sourceId") assignments = props.get("assignments") update_rules = props.get("updateDataRules") if isinstance(props.get("updateDataRules"), dict) else {} has_update_rules = _rule_list_has_entries(update_rules.get("rules")) if not (_non_empty_assignments(assignments) or has_update_rules): raise FlowSchemaError(f"{_node_label(node)} UpdateDataNode missing field assignment rules") _validate_assignment_columns(node, assignments) if node_type == "dataRetrieve": if not props.get("sourceId"): raise FlowSchemaError(f"{_node_label(node)} GetDataNode missing sourceId") if node_type == "sendCard": if not props.get("cardAppId"): raise FlowSchemaError(f"{_node_label(node)} CardNode missing cardAppId") if not props.get("cardPageCode"): raise FlowSchemaError(f"{_node_label(node)} CardNode missing cardPageCode") if props.get("sendType") == "update": biz_id = props.get("bizId") if isinstance(props.get("bizId"), dict) else {} value = str(biz_id.get("value") or "") if not value: raise FlowSchemaError(f"{_node_label(node)} CardNode sendType=update missing bizId.value") if value in UNRESOLVED_CARD_BIZ_ID_VALUES: raise FlowSchemaError( f"{_node_label(node)} CardNode bizId.value={value!r} is not designer-visible; " "choose a real variable from the designer dropdown, such as a current form fieldId" ) def validate_flow_schema(flow: dict[str, Any]) -> None: if not isinstance(flow, dict): raise FlowSchemaError("flow schema must be object") props = flow.get("props") nodes = flow.get("nodes") if not isinstance(props, dict): raise FlowSchemaError("flow schema missing props object") if not props.get("processCode"): raise FlowSchemaError("flow schema missing props.processCode") if not isinstance(nodes, list) or not nodes: raise FlowSchemaError("flow schema nodes must be non-empty array") seen: set[str] = set() for node in _walk_nodes(nodes): node_id = node.get("nodeId") if node_id in seen: raise FlowSchemaError(f"duplicate nodeId: {node_id}") seen.add(node_id) _validate_designer_runtime_node(node) def build_automation_flow( process_code: str, nodes: list[dict[str, Any]], *, allow_withdraw: bool = True, allow_collaboration: bool = True, allow_temporary_storage: bool = True, ) -> dict[str, Any]: if not process_code: raise FlowSchemaError("processCode is required") if not isinstance(nodes, list) or not nodes: raise FlowSchemaError("nodes must be non-empty array") flow = { "props": { "processCode": process_code, "allowWithdraw": allow_withdraw, "allowCollaboration": allow_collaboration, "allowTemporaryStorage": allow_temporary_storage, }, "nodes": [normalize_node(node) for node in nodes], } validate_flow_schema(flow) return flow def build_flow_schema(spec: Any, *, process_code: str | None = None) -> dict[str, Any]: if isinstance(spec, list): if not process_code: raise FlowSchemaError("processCode is required when spec is a node array") return build_automation_flow(process_code, spec) if not isinstance(spec, dict): raise FlowSchemaError("flow spec must be object or node array") if "props" in spec and "nodes" in spec: props = copy.deepcopy(spec["props"]) if not isinstance(props, dict): raise FlowSchemaError("props must be object") if process_code: props["processCode"] = process_code if "bindingForm" in spec and not props.get("bindingForm"): props["bindingForm"] = copy.deepcopy(spec["bindingForm"]) nodes = spec["nodes"] if not isinstance(nodes, list): raise FlowSchemaError("nodes must be array") flow = {"props": props, "nodes": [normalize_node(node) for node in nodes]} for key in FLOW_PROP_KEYS: flow["props"].setdefault(key, True) for key in FLOW_TOP_LEVEL_KEYS: if key in spec: flow[key] = copy.deepcopy(spec[key]) validate_flow_schema(flow) return flow nodes = spec.get("nodes") if not isinstance(nodes, list): raise FlowSchemaError("flow spec missing nodes array") code = process_code or spec.get("processCode") flow = build_automation_flow( str(code or ""), nodes, allow_withdraw=bool(spec.get("allowWithdraw", True)), allow_collaboration=bool(spec.get("allowCollaboration", True)), allow_temporary_storage=bool(spec.get("allowTemporaryStorage", True)), ) if "bindingForm" in spec: flow["props"]["bindingForm"] = copy.deepcopy(spec["bindingForm"]) return flow def _i18n_text(value: Any, default: str = "") -> str: if isinstance(value, dict): return str(value.get("zh_CN") or value.get("en_US") or value.get("value") or default) return str(value or default) def _default_component_props() -> dict[str, Any]: return { "defaultDataSource": {}, "relateAppType": "", "relateOrderEnable": False, "relateOrderConfig": [], } def _rule_for_assignment(assignment: dict[str, Any], fields_by_id: dict[str, dict[str, Any]]) -> dict[str, Any]: column = str(assignment.get("column") or "") field = copy.deepcopy(fields_by_id.get(column) or {}) rule = { "ruleId": f"rule-{column}" if column else "rule-unknown", "name": column, "label": field.get("label") or column, "componentName": field.get("componentName"), "componentOption": field.get("componentOption", "[]"), "componentProps": copy.deepcopy(field.get("componentProps") or _default_component_props()), "required": bool(field.get("required", False)), "valueType": assignment.get("valueType", "literal"), "value": assignment.get("value"), } for key in ("__display", "__source"): if key in assignment: rule[key] = copy.deepcopy(assignment[key]) return rule def _component_option_map(child_list: list[dict[str, Any]]) -> dict[str, Any]: return { str(field.get("fieldId") or field.get("name") or ""): copy.deepcopy(field.get("componentOption", "[]")) for field in child_list if field.get("fieldId") or field.get("name") } def _rules_for_assignments( assignments: list[dict[str, Any]], child_list: list[dict[str, Any]], existing_rules: Any = None, ) -> dict[str, Any]: rules = copy.deepcopy(existing_rules or {}) component_option_map = _component_option_map(child_list) if assignments and not rules.get("rules"): fields_by_id = {str(field.get("fieldId") or field.get("name")): field for field in child_list} rules = { "ruleId": "rule-root", "childList": copy.deepcopy(child_list), "componentOptionMap": copy.deepcopy(component_option_map), "rules": [_rule_for_assignment(item, fields_by_id) for item in assignments], } else: rules.setdefault("ruleId", "rule-root") rules.setdefault("childList", copy.deepcopy(child_list)) rules.setdefault("componentOptionMap", copy.deepcopy(component_option_map)) rules.setdefault("rules", []) return rules def _approval_option_lookup(props: dict[str, Any]) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for key in ("approvalUsers", "approvalOptions", "approverOptions"): options = props.get(key) if not isinstance(options, list): continue for item in options: if not isinstance(item, dict): continue user_id = item.get("id") or item.get("userId") or item.get("value") if isinstance(user_id, str) and user_id: normalized = copy.deepcopy(item) normalized.setdefault("id", user_id) normalized.setdefault("label", item.get("name") or item.get("userName") or user_id) normalized.setdefault("type", "employee") normalized.setdefault("roleType", "DINGTALK") result[user_id] = normalized return result def _approval_value_for_view(value: Any, options_by_id: dict[str, dict[str, Any]]) -> Any: if isinstance(value, dict): item = copy.deepcopy(value) user_id = item.get("id") or item.get("userId") or item.get("value") if isinstance(user_id, str) and user_id: item.setdefault("id", user_id) item.setdefault("label", item.get("name") or item.get("userName") or item.get("id") or "") item.setdefault("type", "employee") item.setdefault("roleType", "DINGTALK") return item if isinstance(value, str): if value in options_by_id: return copy.deepcopy(options_by_id[value]) return { "id": value, "label": value, "type": "employee", "roleType": "DINGTALK", } return value def _approver_rules_for_view(node: dict[str, Any], props: dict[str, Any]) -> dict[str, Any]: raw_rules = props.get("approverRules") if isinstance(props.get("approverRules"), dict) else {} result = copy.deepcopy(raw_rules) result.setdefault("type", node.get("approvalType") or props.get("approvalType") or "ext_target_approval") result.setdefault("mode", "ApprovalNode_rules_only") if result.get("type") == "ext_target_approval_originator": approver_list = result.get("approverList") if isinstance(approver_list, list) and approver_list: normalized_list = [] for item in approver_list: if isinstance(item, dict): normalized = copy.deepcopy(item) if normalized.get("type") == "ext_target_approval_originator": normalized["type"] = "ext_target_approval" normalized_list.append(normalized) else: normalized_list.append(item) result["approverList"] = normalized_list else: result["approverList"] = [{"type": "ext_target_approval"}] result.setdefault("description", "发起人本人") else: result.setdefault("approverList", [{"type": result["type"]}]) result.setdefault("multiApproverType", "all") result.setdefault("conditionalMode", props.get("conditionalMode", "conditional")) approvals = result.get("approvals", props.get("approvals", [])) if isinstance(approvals, list): options_by_id = _approval_option_lookup(props) result["approvals"] = [_approval_value_for_view(item, options_by_id) for item in approvals] labels = [ str(item.get("label") or item.get("id") or "") for item in result["approvals"] if isinstance(item, dict) ] if labels and not result.get("description"): result["description"] = "、".join(label for label in labels if label) return result def _operator_actions_for_view(props: dict[str, Any]) -> dict[str, Any]: normal_actions = [] for action in props.get("actions") or []: if isinstance(action, dict): normalized = copy.deepcopy(action) normalized.setdefault("hidden", False) normalized.setdefault("name", i18n_name(normalized.get("name") or normalized.get("text"), "提交")) normalized.setdefault("text", copy.deepcopy(normalized["name"])) normalized.setdefault("alias", copy.deepcopy(normalized["name"])) normal_actions.append(normalized) continue raw = str(action or "").lower() if raw in {"submit", "agree", "confirm"}: normal_actions.append({ "hidden": False, "name": {"en_US": "Submit", "zh_CN": "提交", "type": "i18n"}, "text": {"en_US": "Submit", "zh_CN": "提交", "type": "i18n"}, "action": "submit", "alias": {"en_US": "Submit", "zh_CN": "提交", "type": "i18n"}, }) elif raw in {"transfer", "forward"}: normal_actions.append({ "hidden": True, "name": {"en_US": "Forward", "zh_CN": "转交", "type": "i18n"}, "text": {"en_US": "Forward", "zh_CN": "转交", "type": "i18n"}, "action": "forward", "alias": {"en_US": "Forward", "zh_CN": "转交", "type": "i18n"}, }) if not normal_actions: normal_actions.append({ "hidden": False, "name": {"en_US": "Submit", "zh_CN": "提交", "type": "i18n"}, "text": {"en_US": "Submit", "zh_CN": "提交", "type": "i18n"}, "action": "submit", "alias": {"en_US": "Submit", "zh_CN": "提交", "type": "i18n"}, }) return { "normalActions": normal_actions, "appendActions": copy.deepcopy(props.get("appendActions") or []), } def _operator_rules_for_view(node: dict[str, Any], props: dict[str, Any]) -> dict[str, Any]: actioner_rule = props.get("actionerRule") if isinstance(props.get("actionerRule"), dict) else {} if actioner_rule and not props.get("approvals") and not props.get("approverRules"): rule_type = str(actioner_rule.get("type") or "") value = str(actioner_rule.get("value") or "") if rule_type == "static" and value: props = copy.deepcopy(props) props["approvals"] = [item.strip() for item in value.split(",") if item.strip()] props.setdefault("approvalType", "ext_target_approval") return _approver_rules_for_view(node, props) def _message_recipient_description(props: dict[str, Any]) -> str: parts: list[str] = [] for item in props.get("toUsers") or []: if not isinstance(item, dict): continue label = item.get("userName") or item.get("name") or item.get("label") or item.get("userId") if label: parts.append(str(label)) for item in props.get("toRoles") or []: if not isinstance(item, dict): continue label = item.get("roleName") or item.get("name") or item.get("label") or item.get("roleCode") if label: parts.append(str(label)) field_labels = { "PROCESS_PARTICIPANTS": "流程参与人", "PROCESS_APPROVERS": "流程审批人", "PROCESS_CARBONS": "流程抄送人", "FORM_INST_MODIFIER": "表单修改人", } for item in props.get("userFields") or []: label = field_labels.get(str(item), str(item)) if label: parts.append(label) for key, label in ( ("toVars", "变量接收人"), ("toGroups", "群组"), ("toGroupVars", "变量群组"), ("robotUrls", "机器人"), ): values = props.get(key) or [] if values: parts.append(label) seen: set[str] = set() result: list[str] = [] for part in parts: if part not in seen: seen.add(part) result.append(part) return "、".join(result) def _send_message_rules_for_view(node: dict[str, Any], props: dict[str, Any]) -> tuple[dict[str, Any], str]: result = copy.deepcopy(props) description = result.get("description") if isinstance(description, dict): description = _i18n_text(description) if not description and node.get("description") not in (None, ""): description = _i18n_text(node.get("description")) if not description: description = _message_recipient_description(result) if description: result["description"] = description return result, str(description or "") def _condition_description(condition_group: Any) -> str: if not isinstance(condition_group, dict): return "" rules = condition_group.get("rules") if not isinstance(rules, list): return "" parts = [] for rule in rules: if not isinstance(rule, dict): continue name = str(rule.get("name") or rule.get("id") or "") op = str(rule.get("op") or rule.get("opCode") or "") value = rule.get("ruleValue", rule.get("value", "")) if isinstance(value, list): value_text = "、".join(str(item.get("label") or item.get("name") or item.get("id") or item) if isinstance(item, dict) else str(item) for item in value) else: value_text = str(value) if name and op: parts.append(f"{name}{op}{value_text}") return ",".join(parts) def _condition_rules_for_view(props: dict[str, Any]) -> dict[str, Any]: raw_conditions = props.get("conditions") if isinstance(props.get("conditions"), dict) else {} result = copy.deepcopy(raw_conditions) result.setdefault("condition", "AND") result.setdefault("conditionCode", "&&") result.setdefault("rules", []) result["calculate"] = props.get("calculate") or raw_conditions.get("calculate") or "condition" result["conditions"] = copy.deepcopy(raw_conditions) result["isDefault"] = bool(props.get("isDefault", raw_conditions.get("isDefault", False))) if "priority" in props: result["priority"] = props["priority"] elif "priority" in raw_conditions: result["priority"] = raw_conditions["priority"] description = props.get("description") or raw_conditions.get("description") or _condition_description(raw_conditions) if not description and result.get("calculate") == "all" and not result.get("rules"): description = "全部数据" if description: result["description"] = description return result def _build_add_data_rules( node_props: dict[str, Any], *, fields: list[dict[str, Any]] | None = None, form_title: str = "", ) -> dict[str, Any]: raw_add_rules = node_props.get("addDataRules") if isinstance(node_props.get("addDataRules"), dict) else {} child_list = copy.deepcopy(fields or raw_add_rules.get("inputs", {}).get("childList") or []) component_option_map = _component_option_map(child_list) assignments = copy.deepcopy(node_props.get("assignments") or raw_add_rules.get("assignments") or []) rules = _rules_for_assignments(assignments, child_list, raw_add_rules.get("rules") or node_props.get("rules")) inputs = copy.deepcopy(raw_add_rules.get("inputs") or node_props.get("inputs") or {}) inputs.setdefault("childList", copy.deepcopy(child_list)) inputs.setdefault("componentOptionMap", component_option_map) description = raw_add_rules.get("description") if isinstance(description, dict): description = _i18n_text(description) if not description and form_title: description = f"在 [{form_title}] 中新增数据" if not description: description = node_props.get("description") if isinstance(description, dict): description = _i18n_text(description) return { "formUuid": node_props.get("formUuid", raw_add_rules.get("formUuid", "")), "appType": node_props.get("appType", raw_add_rules.get("appType", "")), "subFormUuid": node_props.get("subFormUuid", raw_add_rules.get("subFormUuid", "")), "insertType": ( node_props.get("insertType") or raw_add_rules.get("insertType") or node_props.get("originalType") or raw_add_rules.get("originalType") or "form" ), "originalType": node_props.get("originalType", raw_add_rules.get("originalType", "form")), "type": node_props.get("type", raw_add_rules.get("type", "single")), "sourceId": node_props.get("sourceId", raw_add_rules.get("sourceId", "")), "assignments": copy.deepcopy(raw_add_rules.get("assignments") or []), "inputs": inputs, "rules": rules, "description": description or None, } def _normalize_none_operation(value: Any) -> str: raw = str(value or "").strip() lowered = raw.lower() if lowered in {"add", "新增一条数据"} or raw == "ADD": return "add" if lowered in {"ignored", "ignore", "skip", "跳过当前节点"} or raw == "IGNORED": return "ignored" return lowered or "ignored" def _condition_rule_text(rule: Any) -> str: if not isinstance(rule, dict): return "" name = str(rule.get("name") or rule.get("label") or rule.get("id") or rule.get("column") or "") op = str(rule.get("op") or rule.get("opCode") or "") value = rule.get("ruleValue") if rule.get("ruleValue") not in (None, "") else rule.get("value") value_text = str(value or "") if name and op and value_text: return f"{name}{op}{value_text}" if name and op: return f"{name}{op}" return name or value_text def _condition_description_for_get_data(condition: Any) -> str: if not isinstance(condition, dict): return "" rules = condition.get("rules") if not isinstance(rules, list): return "" parts = [_condition_rule_text(rule) for rule in rules] return ",".join(part for part in parts if part) def _build_get_data_rules( node_props: dict[str, Any], *, form_title: str = "", ) -> tuple[dict[str, Any], str]: raw_get_data = node_props.get("getData") if isinstance(node_props.get("getData"), dict) else {} get_data = copy.deepcopy(raw_get_data or node_props) get_data.setdefault("type", node_props.get("type") or "single") get_data.setdefault("sourceId", node_props.get("sourceId") or "") get_data.setdefault("appType", node_props.get("appType") or "") get_data.setdefault("originalType", node_props.get("originalType") or "form") get_data.setdefault("subSourceId", node_props.get("subSourceId") or "") get_data.setdefault("filterType", node_props.get("filterType") or "condition") get_data.setdefault("quantity", str(node_props.get("quantity") or "1")) get_data.setdefault("assignments", copy.deepcopy(node_props.get("assignments") or [])) get_data.setdefault("sort", copy.deepcopy(node_props.get("sort") or {"type": "none", "column": ""})) description = get_data.get("description") or node_props.get("description") if isinstance(description, dict): description = _i18n_text(description) if not description: condition_text = _condition_description_for_get_data(get_data.get("condition")) if condition_text: description = condition_text elif form_title: quantity = get_data.get("quantity") or "1" description = f"在 [{form_title}] 中查询 {quantity} 条数据" if description: get_data["description"] = description condition = get_data.get("condition") if isinstance(condition, dict): condition.setdefault("description", description) condition.setdefault("conditions", copy.deepcopy(condition)) return get_data, str(description or "") def _find_data_source_form( node_props: dict[str, Any], *, nodes_by_id: dict[str, dict[str, Any]] | None = None, ) -> tuple[str, str]: app_type = str(node_props.get("appType") or "") direct_form = node_props.get("formUuid") or node_props.get("sourceFormUuid") if isinstance(direct_form, str) and direct_form: return app_type, direct_form source_id = str(node_props.get("sourceId") or "") if source_id.startswith("FORM-"): return app_type, source_id source_node = (nodes_by_id or {}).get(source_id) if isinstance(source_node, dict): props = source_node.get("props") if isinstance(source_node.get("props"), dict) else {} source_app = str(props.get("appType") or app_type) source_form = props.get("formUuid") or props.get("sourceId") if isinstance(source_form, str) and source_form.startswith("FORM-"): return source_app, source_form return app_type, "" def _build_update_data_rules( node_props: dict[str, Any], *, fields: list[dict[str, Any]] | None = None, form_title: str = "", ) -> tuple[dict[str, Any], str]: raw_update_rules = node_props.get("updateDataRules") if isinstance(node_props.get("updateDataRules"), dict) else {} rules = copy.deepcopy(raw_update_rules or node_props) child_list = copy.deepcopy(fields or raw_update_rules.get("inputs", {}).get("childList") or []) component_option_map = _component_option_map(child_list) assignments = copy.deepcopy(node_props.get("assignments") or raw_update_rules.get("assignments") or []) rules.setdefault("type", node_props.get("type") or "node") rules.setdefault("sourceId", node_props.get("sourceId") or "") rules.setdefault("subSourceId", node_props.get("subSourceId") or "") rules.setdefault("condition", copy.deepcopy(node_props.get("condition") or {})) rules.setdefault("subCondition", copy.deepcopy(node_props.get("subCondition") or {})) rules["assignments"] = assignments display_rules = _rules_for_assignments(assignments, child_list, raw_update_rules.get("rules") or node_props.get("rules")) rules["rules"] = display_rules rules["dataRules"] = copy.deepcopy( raw_update_rules.get("dataRules") or node_props.get("dataRules") or display_rules ) inputs = copy.deepcopy(raw_update_rules.get("inputs") or node_props.get("inputs") or {}) inputs.setdefault("childList", copy.deepcopy(child_list)) inputs.setdefault("componentOptionMap", component_option_map) rules["inputs"] = inputs rules.setdefault("rulesFilter", copy.deepcopy(node_props.get("rulesFilter") or [])) rules.setdefault("tableRulesFilter", copy.deepcopy(node_props.get("tableRulesFilter") or [])) rules["noneOperation"] = _normalize_none_operation( raw_update_rules.get("noneOperation") or node_props.get("noneOperation") ) description = rules.get("description") or node_props.get("description") if isinstance(description, dict): description = _i18n_text(description) if not description and form_title: description = f"更新 [{form_title}] 中的数据" if description: rules["description"] = description return rules, str(description or "") def _view_component_name_for_node(node: dict[str, Any]) -> str: node_type = str(node.get("type") or "") props = node.get("props") if isinstance(node.get("props"), dict) else {} if node_type == "dataRetrieve": get_data = props.get("getData") if isinstance(props.get("getData"), dict) else props retrieve_type = str(get_data.get("type") or "").lower() return "GetBatchDataNode" if retrieve_type in {"batch", "multiple", "multi"} else "GetSingleDataNode" if node_type == "CodeExecutor": if "groovy" in props or str(props.get("scriptType") or "").lower() == "groovy": return "GroovyNode" return "JavaScriptNode" return VIEW_COMPONENT_BY_TYPE.get(node_type, str(node.get("componentName") or node_type)) def _view_props_for_node( node: dict[str, Any], *, data_create_metadata: dict[tuple[str, str], dict[str, Any]] | None = None, nodes_by_id: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: node_type = str(node.get("type") or "") component_name = _view_component_name_for_node(node) props = copy.deepcopy(node.get("props") or {}) name = copy.deepcopy(node.get("name") or i18n_name(None, DEFAULT_NODE_NAMES.get(node_type, node_type))) if node.get("description") not in (None, ""): props.setdefault("description", copy.deepcopy(node["description"])) view_props: dict[str, Any] = { "nodeName": component_name, "name": name, } if node.get("description") not in (None, ""): view_props["description"] = copy.deepcopy(node["description"]) if component_name == "AddDataNode": target_key = (str(props.get("appType") or ""), str(props.get("formUuid") or "")) metadata = (data_create_metadata or {}).get(target_key) or {} add_data_rules = _build_add_data_rules( props, fields=metadata.get("fields") or [], form_title=str(metadata.get("title") or ""), ) view_props["addDataRules"] = add_data_rules elif component_name in {"GetSingleDataNode", "GetBatchDataNode"}: target_app, target_form = _find_data_source_form(props, nodes_by_id=nodes_by_id) metadata = (data_create_metadata or {}).get((target_app, target_form)) or {} get_data, description = _build_get_data_rules( props, form_title=str(metadata.get("title") or ""), ) view_props["getData"] = get_data if description: view_props.setdefault("description", description) elif component_name == "UpdateDataNode": target_app, target_form = _find_data_source_form(props, nodes_by_id=nodes_by_id) metadata = (data_create_metadata or {}).get((target_app, target_form)) or {} update_data_rules, description = _build_update_data_rules( props, fields=metadata.get("fields") or [], form_title=str(metadata.get("title") or ""), ) view_props["updateDataRules"] = update_data_rules if description: view_props.setdefault("description", description) elif component_name == "ApprovalNode": view_props["approverRules"] = _approver_rules_for_view(node, props) if props.get("actions") or props.get("appendActions"): view_props["actions"] = { "normalActions": copy.deepcopy(props.get("actions") or []), "appendActions": copy.deepcopy(props.get("appendActions") or []), } elif component_name == "OperatorNode": view_props["approverRules"] = _operator_rules_for_view(node, props) view_props["actions"] = _operator_actions_for_view(props) elif component_name == "SendMessageNode": send_message_rules, description = _send_message_rules_for_view(node, props) view_props["sendMessageRules"] = send_message_rules if description: view_props.setdefault("description", description) elif node_type == "condition": for key, value in props.items(): view_props.setdefault(key, copy.deepcopy(value)) view_props["conditions"] = _condition_rules_for_view(props) elif node_type == "route": for key, value in props.items(): view_props.setdefault(key, copy.deepcopy(value)) else: rule_key = VIEW_COMPONENT_RULE_KEY.get(component_name) if rule_key: view_props[rule_key] = copy.deepcopy(props.get(rule_key) or props) view_props["title"] = copy.deepcopy(node.get("title") or name) return view_props def _view_child_for_node( node: dict[str, Any], *, data_create_metadata: dict[tuple[str, str], dict[str, Any]] | None = None, nodes_by_id: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: child = { "componentName": _view_component_name_for_node(node), "id": node.get("nodeId"), "props": _view_props_for_node(node, data_create_metadata=data_create_metadata, nodes_by_id=nodes_by_id), } nested = node.get("childNodes") or [] if isinstance(nested, list) and nested: child["children"] = [ _view_child_for_node(item, data_create_metadata=data_create_metadata, nodes_by_id=nodes_by_id) for item in nested ] return child def build_process_view_schema( flow: dict[str, Any], *, data_create_metadata: dict[tuple[str, str], dict[str, Any]] | None = None, ) -> dict[str, Any]: """Build the designer viewJson that matches a normalized process schema. The backend processJson can execute dataCreate nodes with plain ``props.assignments``. The Yida designer, however, renders and re-saves AddDataNode from ``props.addDataRules.rules.rules`` plus target field metadata. This helper keeps both surfaces aligned. """ validate_flow_schema(flow) nodes_by_id = {str(node.get("nodeId")): node for node in _walk_nodes(flow.get("nodes") or [])} children = [] for node in flow.get("nodes") or []: children.append(_view_child_for_node(node, data_create_metadata=data_create_metadata, nodes_by_id=nodes_by_id)) return { "schema": { "componentName": "CanvasEngine", "id": "canvas", "props": {}, "children": children, }, "bindingForm": flow.get("props", {}).get("bindingForm"), "formulaRules": flow.get("formulaRules", []), "globalSetting": { "enableSignature": False, "stopAssociationRulesIfFailed": flow.get("props", {}).get("stopAssociationRulesIfFailed", False), "nodeMerge": False, "originatorMerge": False, "allNodeMerge": False, "behaviorList": [], "needOpenDigitalSignNodes": [], "approvalSummary": flow.get("approvalSummary", []), "noRecordRecall": flow.get("props", {}).get("noRecordRecall", False), "untimedRule": flow.get("props", {}).get("untimedRule", []), }, } def _sync_view_top_level_defaults(view: dict[str, Any], flow: dict[str, Any]) -> None: props = flow.get("props") if isinstance(flow.get("props"), dict) else {} binding_form = props.get("bindingForm") if binding_form: view["bindingForm"] = binding_form view.setdefault("formulaRules", copy.deepcopy(flow.get("formulaRules") or [])) view.setdefault("flowConfig", copy.deepcopy(flow.get("flowConfig") or {})) global_setting = view.setdefault("globalSetting", {}) if not isinstance(global_setting, dict): global_setting = {} view["globalSetting"] = global_setting global_setting.setdefault("enableSignature", False) global_setting.setdefault("stopAssociationRulesIfFailed", props.get("stopAssociationRulesIfFailed", False)) global_setting.setdefault("nodeMerge", False) global_setting.setdefault("originatorMerge", False) global_setting.setdefault("allNodeMerge", False) global_setting.setdefault("behaviorList", []) global_setting.setdefault("needOpenDigitalSignNodes", []) global_setting.setdefault("approvalSummary", flow.get("approvalSummary", [])) global_setting.setdefault("noRecordRecall", props.get("noRecordRecall", False)) global_setting.setdefault("untimedRule", props.get("untimedRule", [])) def _iter_view_children(children: list[dict[str, Any]]): for child in children: yield child nested = child.get("children") or [] if isinstance(nested, list): yield from _iter_view_children(nested) def merge_process_view_schema( base_view: dict[str, Any], flow: dict[str, Any], *, data_create_metadata: dict[tuple[str, str], dict[str, Any]] | None = None, ) -> dict[str, Any]: """Patch a designer viewJson with normalized flow-node configuration. Prefer this over building a view from scratch when a source process version already has a valid designer skeleton. It preserves designer-specific node ids and props, while refreshing advanced component settings such as AddDataNode's UI-visible addDataRules. """ validate_flow_schema(flow) view = copy.deepcopy(base_view) schema = view.setdefault("schema", {}) children = schema.setdefault("children", []) nodes_by_id = {str(node.get("nodeId")): node for node in _walk_nodes(flow.get("nodes") or [])} seen: set[str] = set() def sync_children(items: list[dict[str, Any]]) -> list[dict[str, Any]]: synced: list[dict[str, Any]] = [] for child in items: node_id = str(child.get("id") or "") node = nodes_by_id.get(node_id) if node: fresh_child = _view_child_for_node( node, data_create_metadata=data_create_metadata, nodes_by_id=nodes_by_id, ) component_name = fresh_child["componentName"] if ( child.get("componentName") == component_name and str(node.get("type") or "") in {"apply", "finish"} ): seen.add(node_id) synced.append(child) continue child["componentName"] = component_name child["props"] = fresh_child["props"] if "children" in fresh_child: child["children"] = fresh_child["children"] else: child.pop("children", None) seen.add(node_id) synced.append(child) continue nested = child.get("children") if isinstance(nested, list): child["children"] = sync_children(nested) synced.append(child) return synced schema["children"] = sync_children(children) children = schema.setdefault("children", []) for child in _iter_view_children(children): node_id = str(child.get("id") or "") node = nodes_by_id.get(node_id) if not node: continue seen.add(node_id) existing_top_level = {str(child.get("id") or ""): child for child in children if child.get("id")} ordered_children: list[dict[str, Any]] = [] for child in children: node_id = str(child.get("id") or "") if child.get("componentName") == "ApplyNode" and node_id not in nodes_by_id: ordered_children.append(child) for node in flow.get("nodes") or []: node_id = str(node.get("nodeId") or "") if str(node.get("type") or "") == "apply" and not existing_top_level.get(node_id): continue child = existing_top_level.get(node_id) if not child: child = _view_child_for_node( node, data_create_metadata=data_create_metadata, nodes_by_id=nodes_by_id, ) ordered_children.append(child) if ordered_children: schema["children"] = ordered_children _sync_view_top_level_defaults(view, flow) return view