""" 宜搭报表 schema 构造 — 多 chart 装配进 Page > RootContent 栅格布局。 主入口: build_report_schema_with_filters(report_title, charts, filters, report_id, corp_id) apply_chart_changes_to_schema(schema, changes, cube_tenant_id) """ from __future__ import annotations import copy from typing import Any, Optional from yida_schema_common import ( build_report_components_map, DATA_SOURCE_FIT_COMPILED, DATA_SOURCE_FIT_SOURCE, i18n, next_node_id, generate_field_id, ) from yida_report_charts import ( build_chart_component, CHART_TYPE_MAP, normalize_cube_code, normalize_field_code, ) # --------------------------------------------------------------------------- # 报表骨架常量 # --------------------------------------------------------------------------- _PAGE_STYLE = ":root {\n background-color: #f2f3f5;\n}\n" _PAGE_PROPS = { "pageStyle": _PAGE_STYLE, "containerStyle": {}, "userVariables": [ {"text": "工号", "id": "varWorkNo"}, {"text": "部门名称", "id": "varDeptName"}, {"text": "所属公司编号", "id": "varCorpNo"}, {"text": "部门编码", "id": "varDeptNo"}, ], "templateVersion": "1.0.0", "className": "page_m9o7d9ml", "params": [], } _INIT_METHODS = { "__initMethods__": { "type": "js", "source": "function (exports, module) { /*set actions code here*/ }", "compiled": "function (exports, module) { /*set actions code here*/ }", } } _CONSTRUCTOR = { "type": "js", "compiled": ( "function constructor() {\n" "var module = { exports: {} };\n" "var _this = this;\n" "this.__initMethods__(module.exports, module);\n" "Object.keys(module.exports).forEach(function(item) {\n" " if(typeof module.exports[item] === 'function'){\n" " _this[item] = module.exports[item];\n" " }\n" "});\n" "}" ), "source": ( "function constructor() {\n" "var module = { exports: {} };\n" "var _this = this;\n" "this.__initMethods__(module.exports, module);\n" "Object.keys(module.exports).forEach(function(item) {\n" " if(typeof module.exports[item] === 'function'){\n" " _this[item] = module.exports[item];\n" " }\n" "});\n" "}" ), } # --------------------------------------------------------------------------- # 布局计算 # --------------------------------------------------------------------------- def _next_layout_position(layout: list[dict[str, Any]], w: int) -> tuple[int, int]: """Calculate next (x, y) for a new item in the 6-column react-grid-layout grid. Finds the last row, checks if ``w`` fits to the right of existing items; if yes returns (right_edge, row_y), otherwise wraps to a new row. """ if not layout: return 0, 0 max_y = max(li.get("y", 0) for li in layout) last_row = [li for li in layout if li.get("y", 0) == max_y] last_row_right = max(li.get("x", 0) + li.get("w", 0) for li in last_row) last_row_h = max(li.get("h", 0) for li in last_row) if last_row_right + w <= 6: return last_row_right, max_y return 0, max_y + last_row_h # --------------------------------------------------------------------------- # 主入口 # --------------------------------------------------------------------------- def build_report_schema_with_filters( report_title: str, charts: list[dict[str, Any]], filters: Optional[list[dict[str, Any]]] = None, report_id: str = "", corp_id: str = "", ) -> dict[str, Any]: """构造完整报表 schema(含图表 + 筛选器)。""" if filters is None: filters = [] # 构造所有图表节点 chart_nodes: list[dict[str, Any]] = [] chart_field_ids: list[str] = [] layout_items: list[dict[str, Any]] = [] component_names: set[str] = {"Page", "RootHeader", "RootContent", "RootFooter"} x = 0 y = 0 row_max_h = 0 for chart in charts: node, field_id, default_layout = build_chart_component(chart, cube_tenant_id=corp_id) chart_nodes.append(node) chart_field_ids.append(field_id) component_names.add(node["componentName"]) w = default_layout["w"] h = default_layout["h"] if x + w > 6: x = 0 y += row_max_h row_max_h = 0 item: dict[str, Any] = {"i": field_id, "x": x, "y": y, "w": w, "h": h, "moved": False, "static": False} for k in ("minH", "maxH", "resizeHandles"): if k in default_layout: item[k] = default_layout[k] layout_items.append(item) row_max_h = max(row_max_h, h) x += w if x >= 6: x = 0 y += row_max_h row_max_h = 0 # 筛选器组件 filter_nodes: list[dict[str, Any]] = [] for flt in filters: f_node = _build_select_filter(flt, corp_id, chart_nodes) if f_node: filter_nodes.append(f_node) component_names.add("YoushuSelect") # 筛选器 layout(顶部一行) if filter_nodes: filter_layout: list[dict[str, Any]] = [] fx = 0 for fn in filter_nodes: fn_field_id = fn.get("props", {}).get("fieldId", fn["id"]) filter_layout.append({"i": fn_field_id, "x": fx, "y": 0, "w": 2, "h": 2}) fx += 2 # 把图表 layout 的 y 下移 offset_y = 2 for li in layout_items: li["y"] += offset_y layout_items = filter_layout + layout_items all_children = filter_nodes + chart_nodes components_map = build_report_components_map(sorted(component_names)) schema: dict[str, Any] = { "schemaType": "superform", "schemaVersion": "5.0", "pages": [ { "utils": [], "componentsMap": components_map, "componentsTree": [ { "componentName": "Page", "id": next_node_id(), "props": copy.deepcopy(_PAGE_PROPS), "css": "body {\n background-color: #f2f3f5;\n}\n", "dataSource": { "offline": [], "globalConfig": { "fit": { "compiled": DATA_SOURCE_FIT_COMPILED, "source": DATA_SOURCE_FIT_SOURCE, "type": "js", "error": {}, }, }, "online": [], "list": [], "sync": True, }, "methods": copy.deepcopy(_INIT_METHODS), "lifeCycles": { "constructor": copy.deepcopy(_CONSTRUCTOR), }, "children": [ { "componentName": "RootHeader", "id": next_node_id(), "props": {}, }, { "componentName": "RootContent", "id": next_node_id(), "props": { "layout": layout_items, "rglSwitch": True, "contentBgColor": "transparent", }, "children": all_children, }, { "componentName": "RootFooter", "id": next_node_id(), "props": {}, }, ], }, ], "id": report_id or next_node_id(), "connectComponent": [], }, ], "actions": { "module": {"source": "", "compiled": ""}, "list": [], }, } return schema # --------------------------------------------------------------------------- # 增量修改 # --------------------------------------------------------------------------- def apply_chart_changes_to_schema( schema: dict[str, Any], changes: list[dict[str, Any]], cube_tenant_id: str = "", ) -> dict[str, Any]: """增量修改报表 schema(add/remove/replace/update-props)。""" root_content = _find_root_content(schema) if root_content is None: raise ValueError("schema 中找不到 RootContent") children: list[dict[str, Any]] = root_content.get("children", []) layout: list[dict[str, Any]] = root_content.get("props", {}).get("layout", []) for change in changes: action = change.get("action", "") if action == "add": chart_def = change.get("chart", {}) node, field_id, default_layout = build_chart_component(chart_def, cube_tenant_id=cube_tenant_id) after_title = change.get("after") insert_idx = len(children) if after_title: idx = _find_chart_index_by_title(children, after_title) if idx is not None: insert_idx = idx + 1 children.insert(insert_idx, node) nx, ny = _next_layout_position(layout, default_layout["w"]) item: dict[str, Any] = {"i": field_id, "x": nx, "y": ny, "w": default_layout["w"], "h": default_layout["h"], "moved": False, "static": False} for lk in ("minH", "maxH", "resizeHandles"): if lk in default_layout: item[lk] = default_layout[lk] layout.append(item) _ensure_component_in_map(schema, node["componentName"]) elif action == "remove": title = change.get("title", "") idx = _find_chart_index_by_title(children, title) if idx is not None: removed = children.pop(idx) remove_keys = {removed.get("id"), removed.get("props", {}).get("fieldId")} layout[:] = [li for li in layout if li.get("i") not in remove_keys] elif action == "replace": title = change.get("title", "") chart_def = change.get("chart", {}) idx = _find_chart_index_by_title(children, title) if idx is not None: old_node = children[idx] old_keys = {old_node.get("id"), old_node.get("props", {}).get("fieldId")} node, field_id, default_layout = build_chart_component(chart_def, cube_tenant_id=cube_tenant_id) for li in layout: if li.get("i") in old_keys: li["i"] = field_id break children[idx] = node _ensure_component_in_map(schema, node["componentName"]) elif action == "update-props": title = change.get("title", "") props_patch = change.get("props", {}) idx = _find_chart_index_by_title(children, title) if idx is not None: children[idx].setdefault("props", {}).update(props_patch) root_content["children"] = children root_content.setdefault("props", {})["layout"] = layout return schema # --------------------------------------------------------------------------- # 筛选器 # --------------------------------------------------------------------------- def auto_generate_filters(charts: list[dict[str, Any]]) -> list[dict[str, Any]]: """从图表字段中自动提取 select-like 字段作为筛选器。""" seen: set[str] = set() filters: list[dict[str, Any]] = [] prefixes = ("selectField_", "radioField_", "checkboxField_", "multiSelectField_") _SCAN_KEYS = ("xField", "yField", "columnFields", "kpi", "kpiField", "leftYFields", "rightYFields", "columnList", "valueField") for chart in charts: for key in _SCAN_KEYS: val = chart.get(key, "") if not val: continue fields = val if isinstance(val, list) else [val] for f in fields: fc = f.get("fieldCode", "") if isinstance(f, dict) else str(f) if not fc: continue base = fc.replace("_value", "") if fc.endswith("_value") else fc if any(base.startswith(p) for p in prefixes) and base not in seen: seen.add(base) filters.append({ "type": "select", "cubeCode": chart.get("cubeCode", ""), "fieldCode": fc if fc.endswith("_value") else normalize_field_code(fc), "label": base, }) return filters def _build_select_filter( flt: dict[str, Any], cube_tenant_id: str, chart_nodes: list[dict[str, Any]], ) -> Optional[dict[str, Any]]: if flt.get("type") != "select": return None node_id = next_node_id() field_code = normalize_field_code(flt.get("fieldCode", "")) cube_code = normalize_cube_code(flt.get("cubeCode", "")) label = flt.get("label", field_code) node: dict[str, Any] = { "componentName": "YoushuSelect", "id": node_id, "props": { "fieldId": generate_field_id("YoushuSelect"), "cid": node_id, "componentTitle": i18n(label), "dataSetModelMap": { "selectFilter": { "dataViewQueryModel": { "cubeCode": cube_code, "fieldDefinitionList": [{ "classifiedCode": cube_code, "cubeCode": cube_code, "fieldCode": field_code, "dataType": "VARCHAR", "isDim": False, "aggregateType": "NONE", "alias": "filter_dim", "aliasName": {"type": "i18n", "zh_CN": label, "en_US": label}, "timeGranularityType": None, }], "fieldList": ["filter_dim"], "filterList": [], "orderByList": [], "cubeTenantId": cube_tenant_id, }, "fieldList": [], "youshuDataType": "real", "cubeCodes": [cube_code] if cube_code else "", "filterList": [], "limit": "", } }, "filterLinkage": _build_filter_linkage(flt, chart_nodes, field_code), }, } return node def _build_filter_linkage( flt: dict[str, Any], chart_nodes: list[dict[str, Any]], field_code: str, ) -> list[dict[str, Any]]: link_to = flt.get("linkTo") linkage: list[dict[str, Any]] = [] targets = chart_nodes if link_to is None else [chart_nodes[i] for i in link_to if i < len(chart_nodes)] for target in targets: linkage.append({ "targetComponentId": target.get("id", ""), "targetFieldCode": field_code, }) return linkage # --------------------------------------------------------------------------- # 辅助 # --------------------------------------------------------------------------- def _find_root_content(schema: dict[str, Any]) -> Optional[dict[str, Any]]: pages = schema.get("pages", []) if not pages: return None tree = pages[0].get("componentsTree", []) if not tree: return None for child in tree[0].get("children", []): if child.get("componentName") == "RootContent": return child return None def _find_chart_index_by_title(nodes: list[dict[str, Any]], title: str) -> Optional[int]: for idx, node in enumerate(nodes): ct = node.get("props", {}).get("componentTitle", {}) zh = ct.get("zh_CN", "") if isinstance(ct, dict) else str(ct) if zh == title: return idx return None def _ensure_component_in_map(schema: dict[str, Any], component_name: str) -> None: pages = schema.get("pages", []) if not pages: return cm = pages[0].get("componentsMap", []) from yida_schema_common import REPORT_COMPONENT_PACKAGE, REPORT_COMPONENT_VERSION if not any(c.get("componentName") == component_name for c in cm): cm.append({ "package": REPORT_COMPONENT_PACKAGE, "version": REPORT_COMPONENT_VERSION, "componentName": component_name, }) def parse_report_config(raw: Any) -> tuple[list[dict[str, Any]], Optional[list[dict[str, Any]]]]: """解析入参:支持 [charts] 数组 或 {charts, filters} 对象。""" if isinstance(raw, list): return raw, None if isinstance(raw, dict): return raw.get("charts", []), raw.get("filters") raise ValueError("charts 入参必须是数组或 {charts, filters} 对象") def count_report_children(schema: dict[str, Any]) -> int: """Return children count in RootContent, or -1 if RootContent not found.""" rc = _find_root_content(schema) if rc is None: return -1 return len(rc.get("children", [])) def is_empty_report_skeleton(schema: dict[str, Any]) -> bool: return count_report_children(schema) == 0