Files
EP-Hub-Skill/.agents/skills/dingtalk-misc/scripts/yida_report_builder.py
T
2026-09-02 11:44:52 +08:00

495 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
宜搭报表 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]:
"""增量修改报表 schemaadd/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