feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""报表渲染引擎"""
@@ -0,0 +1,172 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表图表数据聚合(对齐 JNPF ChartUtil"""
from __future__ import annotations
from collections import defaultdict
from decimal import Decimal
from typing import Any, Dict, List, Optional, Set, Tuple
def _parse_field(field: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
"""alias.field -> (alias, field_name)"""
if not field or not isinstance(field, str):
return None, None
parts = field.split(".", 1)
if len(parts) == 2:
return parts[0], parts[1]
return None, field
def _collect_rows(datasets: Dict[str, List[Dict[str, Any]]], dataset_names: Set[str]) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
for name in dataset_names:
rows.extend(datasets.get(name) or [])
return rows
def _aggregate(values: List[Any], summary_type: str) -> str:
if not values:
return ""
st = (summary_type or "none").lower()
nums: List[Decimal] = []
for v in values:
try:
nums.append(Decimal(str(v)))
except Exception:
pass
if st == "sum" and nums:
return str(sum(nums))
if st == "avg" and nums:
return str(sum(nums) / len(nums))
if st == "max" and nums:
return str(max(nums))
if st == "min" and nums:
return str(min(nums))
if st == "count":
return str(len(values))
return str(values[-1]) if values else ""
def _build_field(
data_list: List[Dict[str, Any]],
classify_key: Optional[str],
series_name_key: Optional[str],
series_data_key: Optional[str],
max_key: Optional[str],
summary_type: str,
) -> Dict[str, Any]:
chart_map: Dict[Any, Dict[Any, List[Any]]] = defaultdict(lambda: defaultdict(list))
max_map: Dict[Any, List[Any]] = defaultdict(list)
for row in data_list:
if not classify_key:
continue
classify = row.get(classify_key)
if classify is None:
continue
value = row.get(series_data_key) if series_data_key else None
if value is None:
continue
series = row.get(series_name_key) if series_name_key else ""
chart_map[series][classify].append(value)
if max_key:
mx = row.get(max_key)
if mx is not None:
max_map[classify].append(mx)
series_name_list: List[str] = []
classify_map: Dict[Any, List[List[str]]] = defaultdict(list)
max_counts = [0]
for series, classify_name_map in chart_map.items():
series_name_list.append(str(series))
for classify, value_list in classify_name_map.items():
agg = _aggregate(value_list, summary_type)
classify_map[classify].append([agg])
max_counts.append(len(classify_map[classify]))
classify_name_list = sorted(str(k) for k in classify_map.keys())
max_field_list: List[str] = []
for classify in classify_name_list:
objects = max_map.get(classify) or [0]
max_field_list.append(_aggregate(objects, "max"))
max_len = max(max_counts) if max_counts else 0
series_data_list: List[List[str]] = []
for i in range(max_len):
row_data: List[str] = []
for category in classify_name_list:
category_list = classify_map.get(category) or []
category_data = category_list[i] if i < len(category_list) else []
row_data.append(category_data[0] if category_data else "")
series_data_list.append(row_data)
result: Dict[str, Any] = {
"classifyNameField": classify_name_list,
"seriesDataField": series_data_list,
}
if series_name_key:
result["seriesNameField"] = series_name_list
if max_key:
result["maxField"] = max_field_list
return result
def _echart_configs_from_cells(cells: Dict[str, Any]) -> List[Dict[str, Any]]:
configs: List[Dict[str, Any]] = []
if not cells:
return configs
for key, store in (
("floatEcharts", cells.get("floatEcharts")),
("cellEcharts", cells.get("cellEcharts")),
):
if not isinstance(store, dict):
continue
for drawing_id, item in store.items():
if not isinstance(item, dict):
continue
option = item.get("option") or {}
configs.append(
{
"drawingId": item.get("drawingId") or drawing_id,
"option": option,
"source": key,
}
)
return configs
def build_chart_data(
cells: Dict[str, Any],
datasets: Dict[str, List[Dict[str, Any]]],
) -> List[Dict[str, Any]]:
"""
生成预览用 chartData 列表。
每项: { drawingId, field: { classifyNameField, seriesNameField, seriesDataField, maxField? } }
"""
result: List[Dict[str, Any]] = []
for cfg in _echart_configs_from_cells(cells):
drawing_id = cfg.get("drawingId")
option = cfg.get("option") or {}
dataset_names: Set[str] = set()
classify_alias, classify_field = _parse_field(option.get("classifyNameField"))
series_alias, series_name_field = _parse_field(option.get("seriesNameField"))
data_alias, series_data_field = _parse_field(option.get("seriesDataField"))
max_alias, max_field = _parse_field(option.get("maxField"))
for alias in (classify_alias, series_alias, data_alias, max_alias):
if alias:
dataset_names.add(alias)
if not dataset_names:
continue
rows = _collect_rows(datasets, dataset_names)
field = _build_field(
rows,
classify_field,
series_name_field,
series_data_field,
max_field,
option.get("summaryType") or "none",
)
result.append({"drawingId": drawing_id, "field": field})
return result
@@ -0,0 +1,41 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""二维码/条形码单元格:预览时解析静态值或 #{参数}"""
import copy
from typing import Any, Dict
from online_dev.report_manager.engine.preview_mvp import _replace_params_in_value
def apply_code_cells(
snapshot: Dict[str, Any],
params: Dict[str, Any],
) -> Dict[str, Any]:
"""扫描 snapshot 中 qrCode/jsbarcode 单元格,将 field 解析后写入 v"""
if not snapshot:
return snapshot or {}
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
for sheet in sheets.values():
if not isinstance(sheet, dict):
continue
cell_data = sheet.get("cellData") or {}
for row_key, row in cell_data.items():
if not isinstance(row, dict):
continue
for col_key, cell in row.items():
if not isinstance(cell, dict):
continue
custom = cell.get("custom") or {}
code_type = custom.get("type")
if code_type not in ("qrCode", "jsbarcode"):
continue
raw = custom.get("field") or cell.get("v") or ""
if raw is None:
continue
resolved = _replace_params_in_value(str(raw), params)
cell["v"] = resolved
custom["field"] = resolved
cell["custom"] = custom
result["sheets"] = sheets
return result
@@ -0,0 +1,315 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
分栏布局(MVP
- 行分栏 colcolumnType=1 超过 maxCol 行分列 / columnType=2 分 N 栏
- 列分栏 rowcolumnType=1 超过 maxRow 列分行 / columnType=2 分 N 行
- fillEmptyRows:每栏数据不足时补空行/列
"""
from __future__ import annotations
import copy
import math
import re
from typing import Any, Dict, List, Optional, Set, Tuple
def _col_letter_to_index(col: str) -> int:
col = col.upper()
n = 0
for ch in col:
n = n * 26 + (ord(ch) - ord("A") + 1)
return n - 1
def parse_cell_range(addr: str) -> Optional[Tuple[int, int, int, int]]:
"""A2:D10 -> (r0, r1, c0, c1) 0-based 闭区间"""
if not addr or not isinstance(addr, str):
return None
m = re.match(r"^([A-Za-z]+)(\d+):([A-Za-z]+)(\d+)$", addr.strip())
if not m:
return None
c0 = _col_letter_to_index(m.group(1))
r0 = int(m.group(2)) - 1
c1 = _col_letter_to_index(m.group(3))
r1 = int(m.group(4)) - 1
if r0 > r1:
r0, r1 = r1, r0
if c0 > c1:
c0, c1 = c1, c0
return r0, r1, c0, c1
def parse_index_list(spec: Optional[str]) -> Set[int]:
"""1,2-3,6 -> 0-based 索引集合"""
result: Set[int] = set()
if not spec:
return result
for part in str(spec).split(","):
part = part.strip()
if not part:
continue
if "-" in part:
a, b = part.split("-", 1)
try:
start, end = int(a) - 1, int(b) - 1
for i in range(min(start, end), max(start, end) + 1):
result.add(i)
except ValueError:
pass
else:
try:
result.add(int(part) - 1)
except ValueError:
pass
return result
def _get_column_config_for_sheet(
layout_list: List[Any],
sheet_id: str,
) -> Optional[Dict[str, Any]]:
for item in layout_list or []:
if isinstance(item, dict) and str(item.get("sheet")) == str(sheet_id):
cfg = item.get("columnList")
return cfg if isinstance(cfg, dict) else None
return None
def _extract_region_cells(
cell_data: Dict[str, Any],
r0: int,
r1: int,
c0: int,
c1: int,
) -> Dict[Tuple[int, int], Dict[str, Any]]:
region: Dict[Tuple[int, int], Dict[str, Any]] = {}
for rk in range(r0, r1 + 1):
row = cell_data.get(str(rk))
if not isinstance(row, dict):
continue
for ck in range(c0, c1 + 1):
cell = row.get(str(ck))
if isinstance(cell, dict):
region[(rk, ck)] = copy.deepcopy(cell)
return region
def _write_cell(cell_data: Dict[str, Any], row: int, col: int, cell: Dict[str, Any]) -> None:
cell_data.setdefault(str(row), {})[str(col)] = cell
def _empty_cell() -> Dict[str, Any]:
return {"v": ""}
def _resolve_col_fence(
cfg: Dict[str, Any],
data_size: int,
) -> Optional[Tuple[int, int]]:
"""返回 (fence_num, fence_data_size)"""
if data_size < 1:
return None
column_type = str(cfg.get("columnType") or "2")
if column_type == "1":
per_fence = int(cfg.get("maxCol") or 0)
if per_fence < 1:
return None
return math.ceil(data_size / per_fence), per_fence
fence_num = int(cfg.get("rowCount") or 0)
if fence_num < 2:
return None
return fence_num, math.ceil(data_size / fence_num)
def _resolve_row_fence(
cfg: Dict[str, Any],
data_size: int,
) -> Optional[Tuple[int, int]]:
if data_size < 1:
return None
column_type = str(cfg.get("columnType") or "2")
if column_type == "1":
per_fence = int(cfg.get("maxRow") or 0)
if per_fence < 1:
return None
return math.ceil(data_size / per_fence), per_fence
fence_num = int(cfg.get("colCount") or 0)
if fence_num < 2:
return None
return fence_num, math.ceil(data_size / fence_num)
def _apply_col_split(sheet: Dict[str, Any], cfg: Dict[str, Any]) -> None:
if not cfg.get("columnState") or cfg.get("columnStyle") != "col":
return
bounds = parse_cell_range(cfg.get("columnData") or "")
if not bounds:
return
r0, r1, c0, c1 = bounds
width = c1 - c0 + 1
cell_data = sheet.setdefault("cellData", {})
region = _extract_region_cells(cell_data, r0, r1, c0, c1)
data_row_indices = sorted({r for (r, _) in region.keys()}) or list(range(r0, r1 + 1))
resolved = _resolve_col_fence(cfg, len(data_row_indices))
if not resolved:
return
fence_num, fence_data_size = resolved
fill_empty = bool(cfg.get("fillEmptyRows"))
copy_rows = parse_index_list(cfg.get("copyCol"))
for r in range(r0, r1 + 1):
row_obj = cell_data.get(str(r))
if not isinstance(row_obj, dict):
continue
for c in range(c0, c1 + 1):
if (r, c) in region and str(c) in row_obj:
del row_obj[str(c)]
for block in range(fence_num):
block_rows = data_row_indices[block * fence_data_size : (block + 1) * fence_data_size]
target_c_base = c0 + block * width
out_row = r0
for copy_r in sorted(copy_rows):
if copy_r < r0 or copy_r > r1:
continue
for dc in range(width):
src = region.get((copy_r, c0 + dc))
if src:
_write_cell(cell_data, out_row, target_c_base + dc, copy.deepcopy(src))
out_row += 1
header_offset = out_row - r0
data_written = 0
for local_i, src_r in enumerate(block_rows):
if src_r in copy_rows:
continue
dst_r = r0 + header_offset + local_i
data_written += 1
for dc in range(width):
src = region.get((src_r, c0 + dc))
if src:
_write_cell(cell_data, dst_r, target_c_base + dc, copy.deepcopy(src))
if fill_empty and data_written < fence_data_size:
for pad_i in range(data_written, fence_data_size):
dst_r = r0 + header_offset + pad_i
for dc in range(width):
col_idx = target_c_base + dc
row_obj = cell_data.get(str(dst_r)) or {}
if str(col_idx) not in row_obj:
_write_cell(cell_data, dst_r, col_idx, _empty_cell())
max_r, max_c = r1, c1
for rk, row in cell_data.items():
if not str(rk).isdigit() or not isinstance(row, dict):
continue
max_r = max(max_r, int(rk))
for ck in row.keys():
if str(ck).isdigit():
max_c = max(max_c, int(ck))
sheet["rowCount"] = max(int(sheet.get("rowCount") or 0), max_r + 5)
sheet["columnCount"] = max(int(sheet.get("columnCount") or 0), max_c + 5)
def _apply_row_split(sheet: Dict[str, Any], cfg: Dict[str, Any]) -> None:
"""列分栏:将区域内列拆成多块,纵向堆叠"""
if not cfg.get("columnState") or cfg.get("columnStyle") != "row":
return
bounds = parse_cell_range(cfg.get("columnData") or "")
if not bounds:
return
r0, r1, c0, c1 = bounds
height = r1 - r0 + 1
cell_data = sheet.setdefault("cellData", {})
region = _extract_region_cells(cell_data, r0, r1, c0, c1)
data_col_indices = sorted({c for (_, c) in region.keys()}) or list(range(c0, c1 + 1))
resolved = _resolve_row_fence(cfg, len(data_col_indices))
if not resolved:
return
fence_num, fence_data_size = resolved
fill_empty = bool(cfg.get("fillEmptyRows"))
copy_cols = parse_index_list(cfg.get("copyRow"))
for r in range(r0, r1 + 1):
row_obj = cell_data.get(str(r))
if not isinstance(row_obj, dict):
continue
for c in range(c0, c1 + 1):
if (r, c) in region and str(c) in row_obj:
del row_obj[str(c)]
for block in range(fence_num):
block_cols = data_col_indices[block * fence_data_size : (block + 1) * fence_data_size]
target_r_base = r0 + block * height
for copy_c in sorted(copy_cols):
if copy_c < c0 or copy_c > c1:
continue
for dr in range(height):
src_r = r0 + dr
src = region.get((src_r, copy_c))
if src:
_write_cell(cell_data, target_r_base + dr, copy_c, copy.deepcopy(src))
written_cols = [c for c in block_cols if c not in copy_cols]
for src_c in written_cols:
for dr in range(height):
src_r = r0 + dr
dst_r = target_r_base + dr
src = region.get((src_r, src_c))
if src:
_write_cell(cell_data, dst_r, src_c, copy.deepcopy(src))
if fill_empty and len(written_cols) < fence_data_size:
pad_need = fence_data_size - len(written_cols)
pad_candidates = [
c
for c in range(c0, c1 + 1)
if c not in copy_cols and c not in written_cols
]
for pad_c in pad_candidates[:pad_need]:
for dr in range(height):
dst_r = target_r_base + dr
row_obj = cell_data.get(str(dst_r)) or {}
if str(pad_c) not in row_obj:
_write_cell(cell_data, dst_r, pad_c, _empty_cell())
max_r = r0 + fence_num * height
max_c = c1
for rk, row in cell_data.items():
if not str(rk).isdigit() or not isinstance(row, dict):
continue
max_r = max(max_r, int(rk))
for ck in row.keys():
if str(ck).isdigit():
max_c = max(max_c, int(ck))
sheet["rowCount"] = max(int(sheet.get("rowCount") or 0), max_r + 5)
sheet["columnCount"] = max(int(sheet.get("columnCount") or 0), max_c + 5)
def apply_column_layout(
snapshot: Dict[str, Any],
layout_list: List[Any],
) -> Dict[str, Any]:
if not snapshot or not layout_list:
return snapshot or {}
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
for sheet_id in result.get("sheetOrder") or list(sheets.keys()):
sheet = sheets.get(sheet_id)
if not isinstance(sheet, dict):
continue
cfg = _get_column_config_for_sheet(layout_list, sheet_id)
if not cfg or not cfg.get("columnState"):
continue
style = cfg.get("columnStyle")
if style == "col":
_apply_col_split(sheet, cfg)
elif style == "row":
_apply_row_split(sheet, cfg)
result["sheets"] = sheets
return result
@@ -0,0 +1,61 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
报表渲染引擎(Phase 3
- 参数单元格替换
- dataSource 单格填充
- dataSource 列表向下扩展(list / down
"""
from typing import Any, Dict, List, Optional
from online_dev.report_manager.engine.column_layout import apply_column_layout
from online_dev.report_manager.engine.code_cells import apply_code_cells
from online_dev.report_manager.engine.expression_eval import apply_expression_cells
from online_dev.report_manager.engine.merge_recalc import apply_merge_recalc_after_expand
from online_dev.report_manager.engine.preview_mvp import (
apply_parameter_cells,
apply_snapshot_placeholders,
)
def _apply_data_source_cells(
snapshot: Dict[str, Any],
cells_meta: Dict[str, Any],
datasets: Dict[str, List[Any]],
) -> tuple[Dict[str, Any], list[tuple[str, int, int, int]]]:
from online_dev.report_manager.engine.data_expand import apply_data_source_cells
return apply_data_source_cells(snapshot, cells_meta, datasets)
def transform(
snapshot: Dict[str, Any],
cells: Dict[str, Any],
datasets: Dict[str, List[Any]],
params: Optional[Dict[str, Any]] = None,
column_list: Optional[List[Any]] = None,
fence_list: Optional[List[Any]] = None,
) -> Dict[str, Any]:
"""
将数据集填充到 Univer snapshot。
"""
if not snapshot:
return snapshot or {}
cells_meta = cells or {}
params = params or {}
layout_list = fence_list if fence_list else column_list
original = snapshot
filled = apply_parameter_cells(snapshot, cells_meta, params)
filled = apply_snapshot_placeholders(filled, params)
filled = apply_code_cells(filled, params)
filled, pending_group_merges = _apply_data_source_cells(filled, cells_meta, datasets)
filled = apply_merge_recalc_after_expand(original, filled)
if pending_group_merges:
from online_dev.report_manager.engine.data_expand import apply_pending_group_merges
filled = apply_pending_group_merges(filled, pending_group_merges)
filled = apply_expression_cells(filled, cells_meta, datasets, params)
if layout_list:
filled = apply_column_layout(filled, layout_list)
return filled
@@ -0,0 +1,101 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""convertConfig 运行时 ID→名称查找缓存(对标 JNPF DataSetSwapUtil"""
from __future__ import annotations
from typing import Any, Dict, Optional
class ConvertLookupCache:
"""同步查找表;Golden 测试用 config.names,运行时可预填充。"""
def __init__(self) -> None:
self._users: Dict[str, str] = {}
self._depts: Dict[str, str] = {}
self._orgs: Dict[str, str] = {}
self._roles: Dict[str, str] = {}
self._groups: Dict[str, str] = {}
self._dicts: Dict[str, Dict[str, str]] = {}
def resolve(
self,
rtype: str,
value: Any,
config: Optional[Dict[str, Any]] = None,
) -> Any:
if value is None or value == "":
return value
config = config or {}
inline = config.get("names") or config.get("optionsMap") or {}
if isinstance(inline, dict):
key = str(value)
if key in inline:
return inline[key]
rtype = (rtype or "").lower()
key = str(value)
if rtype in ("user", "users"):
return self._users.get(key, value)
if rtype in ("department", "dep", "dept"):
return self._depts.get(key, value)
if rtype in ("organize", "org", "company"):
return self._orgs.get(key, value)
if rtype == "role":
return self._roles.get(key, value)
if rtype == "group":
return self._groups.get(key, value)
if rtype in ("dictionary", "dict", "select"):
dict_type = config.get("dictionaryType") or config.get("dictType") or ""
if dict_type and dict_type in self._dicts:
return self._dicts[dict_type].get(key, value)
return value
def put_dict(self, dict_type: str, mapping: Dict[str, str]) -> None:
self._dicts[dict_type] = mapping
def put_users(self, mapping: Dict[str, str]) -> None:
self._users.update(mapping)
def put_depts(self, mapping: Dict[str, str]) -> None:
self._depts.update(mapping)
def put_orgs(self, mapping: Dict[str, str]) -> None:
self._orgs.update(mapping)
async def build_lookup_cache_from_db(db) -> ConvertLookupCache:
"""从 core 模块批量加载常用 ID 映射(best-effort)。"""
cache = ConvertLookupCache()
try:
from sqlalchemy import select
from core.user.model import User
from core.dept.model import Dept
users = (await db.execute(select(User.id, User.name).where(User.is_deleted == False))).all()
cache.put_users({str(uid): name or "" for uid, name in users if uid})
depts = (await db.execute(select(Dept.id, Dept.name).where(Dept.is_deleted == False))).all()
cache.put_depts({str(did): name or "" for did, name in depts if did})
except Exception:
pass
try:
from sqlalchemy import select
from core.dict_item.model import DictItem
rows = (
await db.execute(
select(DictItem.dict_id, DictItem.value, DictItem.label).where(
DictItem.is_deleted == False
)
)
).all()
by_dict: Dict[str, Dict[str, str]] = {}
for dict_id, val, label in rows:
if not dict_id:
continue
by_dict.setdefault(str(dict_id), {})[str(val)] = label or str(val)
for dict_id, mapping in by_dict.items():
cache.put_dict(dict_id, mapping)
except Exception:
pass
return cache
@@ -0,0 +1,943 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""父格 + 聚合驱动的数据源扩展(全表拓扑,支持跨行上父格 / 横向父格树)。"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from online_dev.report_manager.engine.parent_cells import (
cell_key,
filter_rows_by_parents,
resolve_field_path,
resolve_parents,
_index_data_sources,
_get_nested_value,
_int_coord,
)
from online_dev.report_manager.engine.polymerize import BindData, build_bind_list, expand_span
CellKey = Tuple[str, int, int]
@dataclass
class ExpandedSlot:
"""单元格一次绑定扩展占用的行/列区间(左闭右开)。"""
value: Any
data_list: List[Dict[str, Any]]
start: int
end: int
def _expand_direction(custom: Dict[str, Any]) -> str:
expand = (custom.get("expand") or custom.get("expandDirection") or "").lower()
if expand in ("down", "list", "vertical"):
return "down"
if expand in ("right", "horizontal", "across"):
return "right"
fill = (custom.get("fillDirection") or "").lower()
if fill in ("portrait", "vertical", "down"):
return "down"
if fill in ("landscape", "horizontal", "right", "across"):
return "right"
return "none"
def _dataset_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
name = str(
custom.get("dataSetName") or custom.get("dataSet") or custom.get("alias") or ""
)
if name:
return name
field = str(custom.get("field") or custom.get("bindField") or "")
if "." in field:
return field.split(".", 1)[0]
return ""
def _field_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
return str(custom.get("field") or custom.get("bindField") or "")
def _poly_list(cell: Dict[str, Any]) -> bool:
return str((cell.get("custom") or {}).get("polymerizationType") or "1") == "1"
def _poly_summary(cell: Dict[str, Any]) -> bool:
return str((cell.get("custom") or {}).get("polymerizationType") or "1") == "3"
def _poly_group(cell: Dict[str, Any]) -> bool:
return str((cell.get("custom") or {}).get("polymerizationType") or "1") == "2"
def _should_merge_group(cell: Dict[str, Any]) -> bool:
"""分组列是否合并单元格(custom.mergeCell,默认开启)。"""
if not _poly_group(cell):
return False
merge = (cell.get("custom") or {}).get("mergeCell")
if merge is None:
return True
if isinstance(merge, str):
return merge.strip().lower() not in ("0", "false", "no", "")
return bool(merge)
def _add_vertical_merge_region(
sheets: Dict[str, Any],
sheet_id: str,
start_row: int,
end_row: int,
col: int,
) -> None:
"""分组列:同组多行合并为一个单元格(预览/导出)。"""
if end_row <= start_row:
return
sheet = sheets.get(sheet_id)
if not sheet:
return
region = {
"startRow": start_row,
"endRow": end_row,
"startColumn": col,
"endColumn": col,
}
merges = sheet.setdefault("mergeData", [])
if region not in merges:
merges.append(region)
def _compute_down_band_lengths(
down_by_sheet: Dict[str, List[Dict[str, Any]]],
datasets: Dict[str, List[Any]],
) -> Dict[Tuple[str, int], int]:
lengths: Dict[Tuple[str, int], int] = {}
for sheet_id, down_cells in down_by_sheet.items():
bands: Dict[int, List[Dict[str, Any]]] = {}
for cell in down_cells:
bands.setdefault(_int_coord(cell.get("row")), []).append(cell)
for start_row, band in bands.items():
max_len = max(
(len(datasets.get(_dataset_name(item)) or []) for item in band),
default=0,
)
lengths[(sheet_id, start_row)] = max_len
return lengths
def _shift_sheet_rows_down(
sheets: Dict[str, Any],
sheet_id: str,
from_row: int,
delta: int,
) -> None:
"""将 from_row 及以下的 cellData 整体下移 delta 行(列表扩展前保留汇总/static 行)。"""
if delta <= 0:
return
sheet = sheets.get(sheet_id)
if not sheet:
return
cell_data = sheet.get("cellData") or {}
shifted: Dict[str, Any] = {}
for row_key, row_obj in cell_data.items():
row = _int_coord(row_key, 0)
target_key = str(row + delta) if row >= from_row else row_key
if target_key in shifted and row >= from_row:
existing = shifted[target_key]
if isinstance(existing, dict) and isinstance(row_obj, dict):
merged = {**existing, **row_obj}
shifted[target_key] = merged
else:
shifted[target_key] = row_obj
else:
shifted[target_key] = row_obj
sheet["cellData"] = shifted
def _summary_filter_rows(
cell: Dict[str, Any],
rows: List[Dict[str, Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> List[Dict[str, Any]]:
"""汇总格:仅 custom 父格按切片统计;default/none 对整表数据集聚合(合计行)。"""
custom = cell.get("custom") or {}
left_pt = custom.get("leftParentCellType") or "default"
top_pt = custom.get("topParentCellType") or "default"
if left_pt != "custom" and top_pt != "custom":
return rows
left, top = resolve_parents(cell, by_pos, by_sheet)
left_bind = top_bind = None
if left:
lb = build_bind_list(left, rows)
left_bind = lb[0].data_list if lb else None
if top:
tb = build_bind_list(top, rows)
top_bind = tb[0].data_list if tb else None
return filter_rows_by_parents(
rows,
cell,
left_parent=left,
top_parent=top,
left_bind=left_bind,
top_bind=top_bind,
)
def _summary_output_row(
sheet_id: str,
template_row: int,
band_lengths: Dict[Tuple[str, int], int],
) -> int:
"""汇总格位于列表模板行下方时,输出到扩展后的末行(对齐 JNPF 合计行)。"""
anchor: Optional[Tuple[int, int]] = None
for (sid, start_row), max_len in band_lengths.items():
if sid != sheet_id or max_len <= 0 or template_row <= start_row:
continue
if anchor is None or start_row > anchor[0]:
anchor = (start_row, max_len)
if anchor is None:
return template_row
start_row, max_len = anchor
return template_row + (max_len - 1)
def _template_style_ref(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
) -> Optional[Any]:
sheet = sheets.get(sheet_id)
if not sheet:
return None
cell_data = sheet.get("cellData") or {}
cell = (cell_data.get(str(row)) or {}).get(str(col))
if not isinstance(cell, dict):
return None
return cell.get("s")
def _set_cell_value(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
value: Any,
*,
style_template_row: Optional[int] = None,
style_template_col: Optional[int] = None,
) -> None:
sheet = sheets.get(sheet_id)
if not sheet:
return
cell_data = sheet.setdefault("cellData", {})
row_data = cell_data.setdefault(str(row), {})
cell_obj = row_data.setdefault(str(col), {})
style_ref = None
if style_template_row is not None:
style_ref = _template_style_ref(sheets, sheet_id, style_template_row, col)
elif style_template_col is not None:
style_ref = _template_style_ref(sheets, sheet_id, row, style_template_col)
if style_ref is not None:
cell_obj["s"] = style_ref
if value is None:
cell_obj["v"] = ""
elif isinstance(value, (dict, list)):
cell_obj["v"] = str(value)
else:
cell_obj["v"] = value
def _apply_fill_empty(
sheets: Dict[str, Any],
sheet_id: str,
custom: Dict[str, Any],
*,
direction: str,
anchor_row: int,
anchor_col: int,
data_len: int,
) -> None:
if not custom.get("fillEmptyRows"):
return
try:
fill_n = int(custom.get("fillEmptyNum") or 1)
except (TypeError, ValueError):
fill_n = 1
if fill_n < 1:
return
for offset in range(fill_n):
if direction == "down":
_set_cell_value(
sheets, sheet_id, anchor_row + data_len + offset, anchor_col, ""
)
else:
_set_cell_value(
sheets, sheet_id, anchor_row, anchor_col + data_len + offset, ""
)
def _slot_at_index(slots: List[ExpandedSlot], index: int) -> Optional[ExpandedSlot]:
if 0 <= index < len(slots):
return slots[index]
return None
def _slot_covering(slots: List[ExpandedSlot], pos: int) -> Optional[ExpandedSlot]:
for s in slots:
if s.start <= pos < s.end:
return s
return None
def _children_of(
parent: Dict[str, Any],
candidates: List[Dict[str, Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> List[Dict[str, Any]]:
pk = cell_key(parent)
out: List[Dict[str, Any]] = []
for c in candidates:
left, top = resolve_parents(c, by_pos, by_sheet)
if (left and cell_key(left) == pk) or (top and cell_key(top) == pk):
out.append(c)
return sorted(out, key=lambda x: (_int_coord(x.get("row")), _int_coord(x.get("col"))))
def _is_down_root(
cell: Dict[str, Any],
down_keys: set[CellKey],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> bool:
left, top = resolve_parents(cell, by_pos, by_sheet)
if left and cell_key(left) in down_keys:
return False
if top and cell_key(top) in down_keys:
return False
return True
def _rows_for_bind(
rows: List[Dict[str, Any]],
cell: Dict[str, Any],
*,
left_parent: Optional[Dict[str, Any]],
top_parent: Optional[Dict[str, Any]],
left_slot: Optional[ExpandedSlot],
top_slot: Optional[ExpandedSlot],
) -> List[Dict[str, Any]]:
left_bind = left_slot.data_list if left_slot else None
top_bind = top_slot.data_list if top_slot else None
return filter_rows_by_parents(
rows,
cell,
left_parent=left_parent,
top_parent=top_parent,
left_bind=left_bind,
top_bind=top_bind,
)
def apply_pending_group_merges(
snapshot: Dict[str, Any],
pending: List[Tuple[str, int, int, int]],
) -> Dict[str, Any]:
sheets = snapshot.get("sheets") or {}
for sheet_id, col, start_row, end_row in pending:
_add_vertical_merge_region(sheets, sheet_id, start_row, end_row, col)
return snapshot
def _visit_down(
cell: Dict[str, Any],
rows: List[Dict[str, Any]],
row_cursor: int,
*,
sheets: Dict[str, Any],
sheet_id: str,
down_cells: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
row_registry: Dict[CellKey, List[ExpandedSlot]],
bind_index: int = 0,
left_slot: Optional[ExpandedSlot] = None,
top_slot: Optional[ExpandedSlot] = None,
parent_scoped: bool = False,
pending_group_merges: Optional[List[Tuple[str, int, int, int]]] = None,
) -> int:
custom = cell.get("custom") or {}
field = resolve_field_path(_field_name(cell), _dataset_name(cell))
col = _int_coord(cell.get("col"))
anchor_row = _int_coord(cell.get("row"))
left_p, top_p = resolve_parents(cell, by_pos, by_sheet)
if not parent_scoped:
if left_slot is None and left_p:
left_slot = _slot_at_index(
row_registry.get(cell_key(left_p), []), bind_index
)
if top_slot is None and top_p:
top_slot = _slot_at_index(
row_registry.get(cell_key(top_p), []), bind_index
)
filtered = _rows_for_bind(
rows,
cell,
left_parent=left_p,
top_parent=top_p,
left_slot=left_slot,
top_slot=top_slot,
)
else:
filtered = [r if isinstance(r, dict) else {} for r in rows]
binds = build_bind_list(cell, filtered)
children = _children_of(cell, down_cells, by_pos, by_sheet)
child_cols = {_int_coord(ch.get("col")) for ch in children}
slots: List[ExpandedSlot] = []
end_row = row_cursor
for bi, bind in enumerate(binds):
block_start = end_row
if children:
child_end = block_start
for ch in children:
child_end = _visit_down(
ch,
bind.data_list,
block_start,
sheets=sheets,
sheet_id=sheet_id,
down_cells=down_cells,
datasets=datasets,
by_pos=by_pos,
by_sheet=by_sheet,
row_registry=row_registry,
bind_index=bi,
parent_scoped=True,
pending_group_merges=pending_group_merges,
)
block_len = max(1, child_end - block_start)
else:
block_len = expand_span(bind, cell)
if col not in child_cols:
style_row = anchor_row
if _poly_list(cell):
for i in range(block_len):
r = block_start + i
val = (
_get_nested_value(bind.data_list[i], field)
if i < len(bind.data_list)
else ""
)
_set_cell_value(
sheets,
sheet_id,
r,
col,
val,
style_template_row=style_row,
)
elif _poly_group(cell):
for i in range(block_len):
r = block_start + i
_set_cell_value(
sheets,
sheet_id,
r,
col,
bind.value,
style_template_row=style_row,
)
if (
pending_group_merges is not None
and block_len > 1
and _should_merge_group(cell)
):
pending_group_merges.append(
(sheet_id, col, block_start, block_start + block_len - 1)
)
else:
for i in range(block_len):
r = block_start + i
_set_cell_value(
sheets,
sheet_id,
r,
col,
bind.value,
style_template_row=style_row,
)
slots.append(
ExpandedSlot(bind.value, bind.data_list, block_start, block_start + block_len)
)
end_row = block_start + block_len
key = cell_key(cell)
row_registry[key] = row_registry.get(key, []) + slots
if custom.get("fillEmptyRows"):
_apply_fill_empty(
sheets,
sheet_id,
custom,
direction="down",
anchor_row=anchor_row,
anchor_col=col,
data_len=end_row - row_cursor,
)
return end_row
def _expand_sheet_down(
sheets: Dict[str, Any],
sheet_id: str,
down_cells: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
pending_group_merges: Optional[List[Tuple[str, int, int, int]]] = None,
) -> None:
down_keys = {cell_key(c) for c in down_cells}
row_registry: Dict[CellKey, List[ExpandedSlot]] = {}
roots = [c for c in down_cells if _is_down_root(c, down_keys, by_pos, by_sheet)]
if not roots:
roots = down_cells
bands: Dict[int, List[Dict[str, Any]]] = {}
for c in roots:
bands.setdefault(_int_coord(c.get("row")), []).append(c)
for start_row in sorted(bands.keys()):
cursor = start_row
for root in sorted(bands[start_row], key=lambda x: _int_coord(x.get("col"))):
ds = _dataset_name(root)
raw = datasets.get(ds) or []
rows = [r if isinstance(r, dict) else {} for r in raw]
cursor = max(
cursor,
_visit_down(
root,
rows,
cursor,
sheets=sheets,
sheet_id=sheet_id,
down_cells=down_cells,
datasets=datasets,
by_pos=by_pos,
by_sheet=by_sheet,
row_registry=row_registry,
pending_group_merges=pending_group_merges,
),
)
def _legacy_down_band(
sheets: Dict[str, Any],
sheet_id: str,
start_row: int,
band: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
pending_group_merges: Optional[List[Tuple[str, int, int, int]]] = None,
) -> None:
max_len = 0
for item in band:
max_len = max(max_len, len(datasets.get(_dataset_name(item)) or []))
for item in sorted(band, key=lambda x: _int_coord(x.get("col"))):
ds = _dataset_name(item)
field = resolve_field_path(_field_name(item), ds)
col = _int_coord(item.get("col"))
data_rows = [r if isinstance(r, dict) else {} for r in datasets.get(ds) or []]
custom = item.get("custom") or {}
if _poly_group(item):
binds = build_bind_list(item, data_rows)
row_cursor = start_row
for bind in binds:
span = max(1, len(bind.data_list))
for i in range(span):
_set_cell_value(
sheets,
sheet_id,
row_cursor + i,
col,
bind.value,
style_template_row=start_row,
)
if (
span > 1
and pending_group_merges is not None
and _should_merge_group(item)
):
pending_group_merges.append(
(sheet_id, col, row_cursor, row_cursor + span - 1)
)
row_cursor += span
if custom.get("fillEmptyRows"):
_apply_fill_empty(
sheets,
sheet_id,
custom,
direction="down",
anchor_row=start_row,
anchor_col=col,
data_len=row_cursor - start_row,
)
continue
for i in range(max_len):
if i < len(data_rows):
val = _get_nested_value(data_rows[i], field)
else:
val = ""
_set_cell_value(
sheets,
sheet_id,
start_row + i,
col,
val,
style_template_row=start_row,
)
if custom.get("fillEmptyRows"):
_apply_fill_empty(
sheets,
sheet_id,
custom,
direction="down",
anchor_row=start_row,
anchor_col=col,
data_len=max_len,
)
def _has_down_parent_link(
band: List[Dict[str, Any]],
down_keys: set[CellKey],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> bool:
for c in band:
left, top = resolve_parents(c, by_pos, by_sheet)
if left and cell_key(left) in down_keys:
return True
if top and cell_key(top) in down_keys:
return True
return False
def _visit_right(
cell: Dict[str, Any],
rows: List[Dict[str, Any]],
col_cursor: int,
*,
sheets: Dict[str, Any],
sheet_id: str,
row: int,
right_cells: List[Dict[str, Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
col_registry: Dict[CellKey, List[ExpandedSlot]],
bind_index: int = 0,
left_slot: Optional[ExpandedSlot] = None,
top_slot: Optional[ExpandedSlot] = None,
parent_scoped: bool = False,
) -> int:
custom = cell.get("custom") or {}
field = resolve_field_path(_field_name(cell), _dataset_name(cell))
col = _int_coord(cell.get("col"))
anchor_col = col
left_p, top_p = resolve_parents(cell, by_pos, by_sheet)
if not parent_scoped:
if left_slot is None and left_p:
left_slot = _slot_at_index(
col_registry.get(cell_key(left_p), []), bind_index
)
if top_slot is None and top_p:
top_slot = _slot_at_index(
col_registry.get(cell_key(top_p), []), bind_index
)
filtered = _rows_for_bind(
rows,
cell,
left_parent=left_p,
top_parent=top_p,
left_slot=left_slot,
top_slot=top_slot,
)
else:
filtered = [r if isinstance(r, dict) else {} for r in rows]
binds = build_bind_list(cell, filtered)
children = _children_of(cell, right_cells, by_pos, by_sheet)
child_cols = {_int_coord(ch.get("col")) for ch in children}
slots: List[ExpandedSlot] = []
end_col = col_cursor
for bi, bind in enumerate(binds):
block_start = end_col
if children:
child_end = block_start
for ch in children:
child_end = _visit_right(
ch,
bind.data_list,
block_start,
sheets=sheets,
sheet_id=sheet_id,
row=row,
right_cells=right_cells,
by_pos=by_pos,
by_sheet=by_sheet,
col_registry=col_registry,
bind_index=bi,
parent_scoped=True,
)
block_len = max(1, child_end - block_start)
else:
block_len = expand_span(bind, cell)
if col not in child_cols:
for i in range(block_len):
c = block_start + i
if _poly_list(cell):
val = (
_get_nested_value(bind.data_list[i], field)
if i < len(bind.data_list)
else ""
)
else:
val = bind.value
_set_cell_value(
sheets,
sheet_id,
row,
c,
val,
style_template_col=anchor_col,
)
slots.append(
ExpandedSlot(bind.value, bind.data_list, block_start, block_start + block_len)
)
end_col = block_start + block_len
key = cell_key(cell)
col_registry[key] = col_registry.get(key, []) + slots
if custom.get("fillEmptyRows"):
_apply_fill_empty(
sheets,
sheet_id,
custom,
direction="right",
anchor_row=row,
anchor_col=col,
data_len=end_col - col_cursor,
)
return end_col
def _is_right_root(
cell: Dict[str, Any],
right_keys: set[CellKey],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> bool:
left, top = resolve_parents(cell, by_pos, by_sheet)
if left and cell_key(left) in right_keys:
return False
if top and cell_key(top) in right_keys:
return False
return True
def _expand_sheet_right(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
right_cells: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> None:
right_keys = {cell_key(c) for c in right_cells}
col_registry: Dict[CellKey, List[ExpandedSlot]] = {}
roots = [c for c in right_cells if _is_right_root(c, right_keys, by_pos, by_sheet)]
if not roots:
roots = right_cells
cursor = min(_int_coord(c.get("col")) for c in roots)
for root in sorted(roots, key=lambda x: _int_coord(x.get("col"))):
ds = _dataset_name(root)
raw = datasets.get(ds) or []
rows_data = [r if isinstance(r, dict) else {} for r in raw]
cursor = max(
cursor,
_visit_right(
root,
rows_data,
cursor,
sheets=sheets,
sheet_id=sheet_id,
row=row,
right_cells=right_cells,
by_pos=by_pos,
by_sheet=by_sheet,
col_registry=col_registry,
),
)
def _legacy_right_band(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
band: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
) -> None:
max_len = max((len(datasets.get(_dataset_name(c)) or []) for c in band), default=0)
for item in sorted(band, key=lambda x: _int_coord(x.get("col"))):
ds = _dataset_name(item)
field = resolve_field_path(_field_name(item), ds)
start_col = _int_coord(item.get("col"))
data_rows = datasets.get(ds) or []
for i in range(max_len):
if i < len(data_rows) and isinstance(data_rows[i], dict):
val = _get_nested_value(data_rows[i], field)
else:
val = ""
_set_cell_value(
sheets,
sheet_id,
row,
start_col + i,
val,
style_template_col=start_col,
)
def apply_data_source_cells(
snapshot: Dict[str, Any],
cells_meta: Dict[str, Any],
datasets: Dict[str, List[Any]],
) -> Tuple[Dict[str, Any], List[Tuple[str, int, int, int]]]:
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
pending_group_merges: List[Tuple[str, int, int, int]] = []
cell_list = cells_meta.get("cells") or []
by_pos, by_sheet = _index_data_sources(cell_list)
down_by_sheet: Dict[str, List[Dict[str, Any]]] = {}
right_by_sheet_row: Dict[Tuple[str, int], List[Dict[str, Any]]] = {}
singles: List[dict] = []
for cell in cell_list:
if cell.get("type") != "dataSource":
continue
custom = cell.get("custom") or {}
if not _dataset_name(cell) or not _field_name(cell):
continue
direction = _expand_direction(custom)
sheet_id = str(cell.get("sheet", "sheet1"))
if direction == "down":
down_by_sheet.setdefault(sheet_id, []).append(cell)
elif direction == "right":
row = _int_coord(cell.get("row"), 0)
right_by_sheet_row.setdefault((sheet_id, row), []).append(cell)
else:
singles.append(cell)
band_lengths = _compute_down_band_lengths(down_by_sheet, datasets)
for (sheet_id, start_row), max_len in sorted(
band_lengths.items(), key=lambda x: x[0][1], reverse=True
):
delta = max(0, max_len - 1)
if delta > 0:
_shift_sheet_rows_down(sheets, sheet_id, start_row + 1, delta)
for sheet_id, down_cells in down_by_sheet.items():
down_keys = {cell_key(c) for c in down_cells}
bands: Dict[int, List[Dict[str, Any]]] = {}
for c in down_cells:
bands.setdefault(_int_coord(c.get("row")), []).append(c)
uses_tree = False
for band in bands.values():
if _has_down_parent_link(band, down_keys, by_pos, by_sheet):
uses_tree = True
break
if uses_tree:
_expand_sheet_down(
sheets,
sheet_id,
down_cells,
datasets,
by_pos,
by_sheet,
pending_group_merges,
)
else:
for start_row, band in bands.items():
_legacy_down_band(
sheets,
sheet_id,
start_row,
band,
datasets,
pending_group_merges,
)
for (sheet_id, row), band in right_by_sheet_row.items():
right_keys = {cell_key(c) for c in band}
if _has_down_parent_link(band, right_keys, by_pos, by_sheet):
_expand_sheet_right(
sheets, sheet_id, row, band, datasets, by_pos, by_sheet
)
else:
_legacy_right_band(sheets, sheet_id, row, band, datasets)
for cell in singles:
sheet_id = str(cell.get("sheet", "sheet1"))
template_row = _int_coord(cell.get("row"), 0)
col = _int_coord(cell.get("col"), 0)
row = (
_summary_output_row(sheet_id, template_row, band_lengths)
if _poly_summary(cell)
else template_row
)
raw = datasets.get(_dataset_name(cell)) or []
rows = [r if isinstance(r, dict) else {} for r in raw]
if _poly_summary(cell):
filtered = _summary_filter_rows(cell, rows, by_pos, by_sheet)
else:
left, top = resolve_parents(cell, by_pos, by_sheet)
left_bind = top_bind = None
if left:
lb = build_bind_list(left, rows)
left_bind = lb[0].data_list if lb else None
if top:
tb = build_bind_list(top, rows)
top_bind = tb[0].data_list if tb else None
filtered = filter_rows_by_parents(
rows,
cell,
left_parent=left,
top_parent=top,
left_bind=left_bind,
top_bind=top_bind,
)
binds = build_bind_list(cell, filtered)
val = binds[0].value if binds else ""
_set_cell_value(sheets, sheet_id, row, col, val)
result["sheets"] = sheets
return result, pending_group_merges
@@ -0,0 +1,222 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据集行级变换(对齐 JNPF convertConfig + field_mapping
在 fetch_all 之后、transform 之前执行。
"""
from __future__ import annotations
import copy
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from online_dev.report_manager.engine.convert_lookup import ConvertLookupCache
def _prop_name(field: str, alias: str = "") -> str:
if not field:
return ""
if "." in field:
parts = field.split(".", 1)
if alias and parts[0] == alias:
return parts[1]
return parts[-1]
return field
def apply_field_mapping(
rows: List[Any],
mapping: Optional[Dict[str, Any]],
) -> List[Any]:
"""字段重命名:{ 源字段: 目标字段 }"""
if not mapping or not rows:
return rows
out: List[Any] = []
for row in rows:
if not isinstance(row, dict):
out.append(row)
continue
new_row = copy.deepcopy(row)
for src, dst in mapping.items():
if not src or not dst or src == dst:
continue
src_s, dst_s = str(src), str(dst)
if src_s in new_row:
new_row[dst_s] = new_row.pop(src_s)
out.append(new_row)
return out
def _jnpf_date_format_to_strftime(fmt: str) -> str:
s = (fmt or "yyyy-MM-dd").replace("YYYY", "%Y").replace("yyyy", "%Y")
s = s.replace("MM", "%m").replace("DD", "%d").replace("dd", "%d")
s = s.replace("HH", "%H").replace("mm", "%M").replace("ss", "%S")
return s
def _format_date_value(value: Any, fmt: str) -> Any:
if value is None or value == "":
return value
py_fmt = _jnpf_date_format_to_strftime(fmt)
if isinstance(value, datetime):
return value.strftime(py_fmt)
if isinstance(value, date):
return value.strftime(py_fmt)
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(value / 1000 if value > 1e12 else value).strftime(
py_fmt
)
except (OSError, ValueError, OverflowError):
return value
s = str(value).strip()
for parser in (
lambda x: datetime.fromisoformat(x.replace("Z", "+00:00")),
lambda x: datetime.strptime(x[:10], "%Y-%m-%d"),
):
try:
return parser(s).strftime(py_fmt)
except (ValueError, TypeError):
continue
return value
def _format_number_value(value: Any, config: Dict[str, Any]) -> Any:
if value is None or value == "":
return value
try:
num = float(value)
except (TypeError, ValueError):
return value
precision = config.get("precision")
prec_int: Optional[int] = None
if precision is not None:
try:
prec_int = int(precision)
num = round(num, prec_int)
except (TypeError, ValueError):
pass
if config.get("thousands"):
if prec_int is not None:
return f"{num:,.{prec_int}f}"
if isinstance(num, float) and num.is_integer():
return f"{int(num):,}"
return f"{num:,}"
if prec_int is not None and float(num).is_integer():
return int(num)
return num
def _apply_select_rule(value: Any, config: Dict[str, Any]) -> Any:
options = config.get("options") or []
if not options:
return value
for opt in options:
if not isinstance(opt, dict):
continue
oid = opt.get("id")
if oid is None:
oid = opt.get("value")
if oid == value or str(oid) == str(value):
return opt.get("fullName") or opt.get("label") or value
return value
def _rule_applies_to_alias(field: str, alias: str = "") -> bool:
if not field or "." not in field:
return True
prefix = field.split(".", 1)[0]
return not alias or prefix == alias
def _apply_rule_to_row(
row: Dict[str, Any],
rule: Dict[str, Any],
alias: str = "",
lookup: Optional[ConvertLookupCache] = None,
) -> None:
field = str(rule.get("field") or "")
if not _rule_applies_to_alias(field, alias):
return
prop = _prop_name(field, alias)
if not prop or prop not in row:
return
rtype = str(rule.get("type") or "").lower()
config = rule.get("config") or {}
val = row[prop]
if rtype == "select":
row[prop] = _apply_select_rule(val, config)
elif rtype == "date":
row[prop] = _format_date_value(val, config.get("format") or "yyyy-MM-dd")
elif rtype == "time":
row[prop] = _format_date_value(val, config.get("format") or "HH:mm:ss")
elif rtype == "number":
row[prop] = _format_number_value(val, config)
elif rtype in (
"user",
"users",
"department",
"dep",
"dept",
"organize",
"org",
"company",
"role",
"group",
"dictionary",
"dict",
):
cache = lookup or ConvertLookupCache()
row[prop] = cache.resolve(rtype, val, config)
elif lookup:
row[prop] = lookup.resolve(rtype, val, config)
def apply_convert_rules(
rows: List[Any],
rules: Any,
*,
alias: str = "",
lookup: Optional[ConvertLookupCache] = None,
) -> List[Any]:
"""
JNPF convertConfig 列表:[{ field, type, config }, ...]
也支持 { "list": [...] } 包装。
"""
rule_list: List[Dict[str, Any]] = []
if isinstance(rules, list):
rule_list = [r for r in rules if isinstance(r, dict)]
elif isinstance(rules, dict):
inner = rules.get("list") or rules.get("rules") or rules.get("items")
if isinstance(inner, list):
rule_list = [r for r in inner if isinstance(r, dict)]
if not rule_list or not rows:
return rows
out: List[Any] = []
for row in rows:
if not isinstance(row, dict):
out.append(row)
continue
new_row = copy.deepcopy(row)
for rule in rule_list:
_apply_rule_to_row(new_row, rule, alias, lookup)
out.append(new_row)
return out
def transform_dataset_rows(
rows: List[Any],
*,
field_mapping: Optional[Dict[str, Any]] = None,
dataset_convert: Any = None,
version_convert: Any = None,
alias: str = "",
lookup: Optional[ConvertLookupCache] = None,
) -> List[Any]:
"""单数据集完整变换链:mapping → dataset rules → version rules"""
data = apply_field_mapping(rows, field_mapping)
data = apply_convert_rules(data, dataset_convert, alias=alias, lookup=lookup)
data = apply_convert_rules(data, version_convert, alias=alias, lookup=lookup)
return data
@@ -0,0 +1,271 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""将 Univer snapshot 导出为 Excel(xlsx),支持合并单元格、行列尺寸与基础样式。"""
from __future__ import annotations
import io
import re
from typing import Any, Callable, Dict, List, Optional, Tuple
from openpyxl import Workbook
from online_dev.report_manager.engine.export_excel_extras import (
apply_conditional_formatting,
apply_sheet_hyperlinks,
apply_sheet_images,
)
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
# Univer CellValueType: 1 string, 2 number, 3 boolean, 4 force string
_CELL_TYPE_NUMBER = 2
_CELL_TYPE_BOOLEAN = 3
def _parse_rgb(color: Any) -> Optional[str]:
if not color:
return None
if isinstance(color, str):
s = color.strip()
if s.startswith("#") and len(s) >= 7:
return s[1:7].upper()
match = re.search(r"rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", s, re.I)
if match:
r, g, b = (int(match.group(i)) for i in range(1, 4))
return f"{r:02X}{g:02X}{b:02X}"
if isinstance(color, dict):
return _parse_rgb(color.get("rgb"))
return None
def _style_lookup(styles: Any, style_id: Any) -> Optional[Dict[str, Any]]:
if style_id is None or styles is None:
return None
if isinstance(styles, list):
try:
idx = int(style_id)
return styles[idx] if 0 <= idx < len(styles) else None
except (TypeError, ValueError):
return None
if isinstance(styles, dict):
key = str(style_id)
return styles.get(key) or styles.get(style_id)
return None
def _build_openpyxl_style(style: Dict[str, Any]) -> Tuple[Font, PatternFill, Alignment, Border]:
font_kwargs: Dict[str, Any] = {}
if style.get("fs"):
try:
font_kwargs["size"] = float(style["fs"])
except (TypeError, ValueError):
pass
if style.get("ff"):
font_kwargs["name"] = str(style["ff"])
if style.get("bl") == 1:
font_kwargs["bold"] = True
if style.get("it") == 1:
font_kwargs["italic"] = True
font_color = _parse_rgb(style.get("cl"))
if font_color:
font_kwargs["color"] = font_color
fill = PatternFill()
bg = _parse_rgb(style.get("bg"))
if bg:
fill = PatternFill(fill_type="solid", fgColor=bg)
ht_map = {1: "left", 2: "center", 3: "right"}
vt_map = {1: "top", 2: "center", 3: "bottom"}
alignment = Alignment(
horizontal=ht_map.get(style.get("ht"), "general"),
vertical=vt_map.get(style.get("vt"), "bottom"),
wrap_text=style.get("tb") == 3,
)
thin = Side(style="thin", color="000000")
border = Border()
bd = style.get("bd") or {}
if isinstance(bd, dict):
if bd.get("t"):
border.top = thin
if bd.get("b"):
border.bottom = thin
if bd.get("l"):
border.left = thin
if bd.get("r"):
border.right = thin
return Font(**font_kwargs), fill, alignment, border
def _cell_display_value(cell: Dict[str, Any]) -> Any:
if not cell:
return ""
v = cell.get("v")
if v is None:
return ""
return v
def _write_cell(
ws,
row: int,
col: int,
cell: Dict[str, Any],
styles: Any,
style_cache: Dict[str, Any],
) -> None:
excel_row = row + 1
excel_col = col + 1
target = ws.cell(row=excel_row, column=excel_col)
formula = cell.get("f")
if formula:
text = str(formula)
target.value = text[1:] if text.startswith("=") else text
target.data_type = "f"
else:
value = _cell_display_value(cell)
cell_type = cell.get("t")
if cell_type == _CELL_TYPE_NUMBER:
try:
target.value = float(value)
except (TypeError, ValueError):
target.value = value
elif cell_type == _CELL_TYPE_BOOLEAN:
target.value = bool(value) if not isinstance(value, bool) else value
else:
target.value = value
style_id = cell.get("s")
style_def = _style_lookup(styles, style_id)
if not style_def:
return
cache_key = str(style_id)
if cache_key not in style_cache:
font, fill, alignment, border = _build_openpyxl_style(style_def)
style_cache[cache_key] = (font, fill, alignment, border)
font, fill, alignment, border = style_cache[cache_key]
target.font = font
if fill.fgColor and fill.fgColor.rgb and fill.fgColor.rgb != "00000000":
target.fill = fill
target.alignment = alignment
if border.left or border.right or border.top or border.bottom:
target.border = border
def _apply_row_col_dimensions(ws, sheet: Dict[str, Any]) -> None:
default_row_h = sheet.get("defaultRowHeight") or 24
default_col_w = sheet.get("defaultColumnWidth") or 88
row_data = sheet.get("rowData") or {}
col_data = sheet.get("columnData") or {}
for row_key, meta in row_data.items():
try:
r = int(row_key)
except (TypeError, ValueError):
continue
if not isinstance(meta, dict):
continue
height = meta.get("h") or meta.get("ah") or default_row_h
try:
ws.row_dimensions[r + 1].height = float(height) * 0.75
except (TypeError, ValueError):
pass
for col_key, meta in col_data.items():
try:
c = int(col_key)
except (TypeError, ValueError):
continue
if not isinstance(meta, dict):
continue
width = meta.get("w") or default_col_w
try:
ws.column_dimensions[get_column_letter(c + 1)].width = max(8, float(width) / 7)
except (TypeError, ValueError):
pass
def _apply_merge_regions(ws, merge_data: List[Any]) -> None:
for region in merge_data or []:
if not isinstance(region, dict):
continue
try:
sr = int(region.get("startRow", region.get("start_row", 0)))
er = int(region.get("endRow", region.get("end_row", sr)))
sc = int(region.get("startColumn", region.get("start_column", 0)))
ec = int(region.get("endColumn", region.get("end_column", sc)))
except (TypeError, ValueError):
continue
if er <= sr and ec <= sc:
continue
ws.merge_cells(
start_row=sr + 1,
end_row=er + 1,
start_column=sc + 1,
end_column=ec + 1,
)
def snapshot_to_xlsx_bytes(
snapshot: Dict[str, Any],
*,
watermark_text: str = "",
fetch_url: Optional[Callable[[str], Optional[bytes]]] = None,
) -> bytes:
"""按 sheetOrder 将 cellData 写入 xlsx(含 merge / 尺寸 / 样式 / 条件格式 / 图片)。"""
wb = Workbook()
default_ws = wb.active
wb.remove(default_ws)
sheets = snapshot.get("sheets") or {}
order = snapshot.get("sheetOrder") or list(sheets.keys())
if not order:
order = list(sheets.keys())
styles = snapshot.get("styles")
if not order:
ws = wb.create_sheet("Sheet1")
ws.append([])
else:
for idx, sheet_id in enumerate(order):
sheet = sheets.get(sheet_id) or {}
name = (sheet.get("name") or sheet_id or "Sheet")[:31]
ws = wb.create_sheet(name)
style_cache: Dict[str, Any] = {}
cell_data = sheet.get("cellData") or {}
for row_key, row_obj in cell_data.items():
try:
r = int(row_key)
except (TypeError, ValueError):
continue
if not isinstance(row_obj, dict):
continue
for col_key, cell in row_obj.items():
try:
c = int(col_key)
except (TypeError, ValueError):
continue
if isinstance(cell, dict):
_write_cell(ws, r, c, cell, styles, style_cache)
_apply_row_col_dimensions(ws, sheet)
_apply_merge_regions(ws, sheet.get("mergeData") or [])
apply_conditional_formatting(ws, sheet_id, snapshot)
apply_sheet_hyperlinks(ws, sheet_id, snapshot, sheet)
apply_sheet_images(
ws,
sheet_id,
snapshot,
sheet,
fetch_url=fetch_url,
)
if watermark_text and idx == 0:
ws.oddHeader.center.text = watermark_text
ws.evenHeader.center.text = watermark_text
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
@@ -0,0 +1,913 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Excel 导出扩展:条件格式、超链接与嵌入/浮动图片。"""
from __future__ import annotations
import base64
import io
import logging
import re
from typing import Any, Callable, Dict, List, Optional, Tuple
from openpyxl.drawing.image import Image as XLImage
from openpyxl.drawing.spreadsheet_drawing import AnchorMarker, TwoCellAnchor
from openpyxl.formatting.rule import CellIsRule, ColorScaleRule, DataBarRule, FormulaRule, IconSetRule, Rule
from openpyxl.styles import Font, PatternFill
from openpyxl.styles.differential import DifferentialStyle
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.hyperlink import Hyperlink
logger = logging.getLogger(__name__)
_CF_PLUGIN = "SHEET_CONDITIONAL_FORMATTING_PLUGIN"
_DRAWING_PLUGIN = "SHEET_DRAWING_PLUGIN"
_HYPER_LINK_PLUGIN = "SHEET_HYPER_LINK_PLUGIN"
_DEFINED_NAME_PLUGIN = "SHEET_DEFINED_NAME_PLUGIN"
_BASE64_PREFIX = re.compile(r"^data:image/[\w+.-]+;base64,", re.I)
_CELL_IS_OPERATORS = {
"greaterthan": "greaterThan",
"lessthan": "lessThan",
"equal": "equal",
"notequal": "notEqual",
"greaterthanorequal": "greaterThanOrEqual",
"lessthanorequal": "lessThanOrEqual",
"between": "between",
"notbetween": "notBetween",
}
_CFVO_TYPE_MAP = {
"min": "min",
"max": "max",
"num": "num",
"number": "num",
"percent": "percent",
"percentile": "percentile",
"formula": "formula",
"expression": "formula",
"auto": "percentile",
}
_ADVANCED_CF_SUBTYPES = {
"top10",
"rank",
"aboveaverage",
"average",
"timeperiod",
"uniquevalues",
"duplicatevalues",
"containstext",
"notcontainstext",
"beginswith",
"endswith",
"containsblanks",
"notcontainsblanks",
"containserrors",
"notcontainserrors",
}
_TEXT_CF_TYPES = {
"containstext": "containsText",
"notcontainstext": "notContainsText",
"beginswith": "beginsWith",
"endswith": "endsWith",
"containsblanks": "containsBlanks",
"notcontainsblanks": "notContainsBlanks",
"containserrors": "containsErrors",
"notcontainserrors": "notContainsErrors",
}
_HYPERLINK_FONT = Font(color="0563C1", underline="single")
def _parse_rgb(color: Any) -> Optional[str]:
if not color:
return None
if isinstance(color, str):
s = color.strip()
if s.startswith("#") and len(s) >= 7:
return s[1:7].upper()
match = re.search(r"rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", s, re.I)
if match:
r, g, b = (int(match.group(i)) for i in range(1, 4))
return f"{r:02X}{g:02X}{b:02X}"
if isinstance(color, dict):
return _parse_rgb(color.get("rgb"))
return None
def _parse_resource_map(snapshot: Dict[str, Any], plugin_name: str) -> Dict[str, Any]:
resources = snapshot.get("resources") or []
for resource in resources:
if not isinstance(resource, dict) or resource.get("name") != plugin_name:
continue
raw = resource.get("data")
if not raw:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
import json
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError):
return {}
return {}
def _range_to_ref(region: Dict[str, Any]) -> Optional[str]:
if not isinstance(region, dict):
return None
try:
sr = int(region.get("startRow", region.get("start_row", 0)))
er = int(region.get("endRow", region.get("end_row", sr)))
sc = int(region.get("startColumn", region.get("start_column", 0)))
ec = int(region.get("endColumn", region.get("end_column", sc)))
except (TypeError, ValueError):
return None
start = f"{get_column_letter(sc + 1)}{sr + 1}"
end = f"{get_column_letter(ec + 1)}{er + 1}"
return start if start == end else f"{start}:{end}"
def _normalize_cfvo_type(value_type: Any) -> str:
key = str(value_type or "num").strip().lower()
return _CFVO_TYPE_MAP.get(key, key)
def _cfvo_value(univer_value: Any) -> Tuple[str, Any]:
if not isinstance(univer_value, dict):
return "num", univer_value
value_type = _normalize_cfvo_type(univer_value.get("type"))
raw = univer_value.get("value")
if value_type == "formula" and raw is not None:
text = str(raw)
if text.startswith("="):
text = text[1:]
return value_type, text
return value_type, raw
def _config_list(rule: Dict[str, Any]) -> List[Dict[str, Any]]:
config = rule.get("config")
if isinstance(config, list):
return [x for x in config if isinstance(x, dict)]
if isinstance(config, dict):
return [config]
return []
def _build_rule_style(rule: Dict[str, Any]) -> Tuple[Optional[Font], Optional[PatternFill]]:
style = rule.get("style")
if not isinstance(style, dict):
return None, None
font_kwargs: Dict[str, Any] = {}
if style.get("bl") == 1:
font_kwargs["bold"] = True
if style.get("it") == 1:
font_kwargs["italic"] = True
font_color = _parse_rgb(style.get("cl"))
if font_color:
font_kwargs["color"] = font_color
font = Font(**font_kwargs) if font_kwargs else None
fill = None
bg = _parse_rgb(style.get("bg"))
if bg:
fill = PatternFill(fill_type="solid", fgColor=bg, start_color=bg, end_color=bg)
return font, fill
def _build_rule_dxf(rule: Dict[str, Any]) -> Optional[DifferentialStyle]:
font, fill = _build_rule_style(rule)
if font is None and fill is None:
return None
return DifferentialStyle(font=font, fill=fill)
def _normalize_operator(operator: Any) -> Optional[str]:
if operator is None:
return None
key = str(operator).strip()
mapped = _CELL_IS_OPERATORS.get(key.lower())
return mapped or key
def _normalize_sub_type(rule: Dict[str, Any]) -> str:
return str(rule.get("subType") or rule.get("subtype") or "").strip().lower()
def _build_advanced_highlight_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
sub_type = _normalize_sub_type(rule)
rule_type = str(rule.get("type") or "").strip().lower()
key = sub_type or rule_type
if key not in _ADVANCED_CF_SUBTYPES and rule_type not in _ADVANCED_CF_SUBTYPES:
return None
normalized = key if key in _ADVANCED_CF_SUBTYPES else rule_type
dxf = _build_rule_dxf(rule)
operator = str(rule.get("operator") or "").strip()
value = rule.get("value")
try:
if normalized in ("top10", "rank"):
rank = 10
if value is not None:
try:
rank = int(value)
except (TypeError, ValueError):
rank = 10
cf_rule = Rule(
type="top10",
rank=rank,
percent=bool(rule.get("isPercent")),
bottom=bool(rule.get("isBottom")),
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
if normalized in ("aboveaverage", "average"):
above = operator.lower() != "lessthan"
cf_rule = Rule(
type="aboveAverage",
aboveAverage=above,
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
if normalized == "timeperiod":
period = operator or "today"
cf_rule = Rule(
type="timePeriod",
timePeriod=period,
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
if normalized in ("uniquevalues", "duplicatevalues"):
cf_type = "uniqueValues" if normalized == "uniquevalues" else "duplicateValues"
cf_rule = Rule(
type=cf_type,
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
if normalized in _TEXT_CF_TYPES:
cf_type = _TEXT_CF_TYPES[normalized]
text = str(value) if value is not None else ""
cf_rule = Rule(
type=cf_type,
operator=cf_type,
text=text,
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
except (TypeError, ValueError) as exc:
logger.debug("advanced cf rule failed, fallback to formula: %s", exc)
return _build_advanced_cf_formula_fallback(rule, stop_if_true)
def _build_advanced_cf_formula_fallback(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
"""openpyxl 不直接支持的规则,用 FormulaRule 近似兜底。"""
sub_type = _normalize_sub_type(rule)
operator = str(rule.get("operator") or "").strip().lower()
value = rule.get("value")
font, fill = _build_rule_style(rule)
formula: Optional[str] = None
if sub_type in ("top10", "rank"):
formula = "TRUE"
elif sub_type in ("aboveaverage", "average"):
ref = "INDIRECT(ADDRESS(ROW(),COLUMN()))"
if operator == "lessthan":
formula = f"{ref}<AVERAGE($A:$ZZ)"
else:
formula = f"{ref}>AVERAGE($A:$ZZ)"
elif sub_type == "timeperiod":
ref = "INDIRECT(ADDRESS(ROW(),COLUMN()))"
period_map = {
"today": f"INT({ref})=TODAY()",
"yesterday": f"INT({ref})=TODAY()-1",
"tomorrow": f"INT({ref})=TODAY()+1",
"last7days": f"AND({ref}>=TODAY()-7,{ref}<=TODAY())",
"thismonth": f"AND(MONTH({ref})=MONTH(TODAY()),YEAR({ref})=YEAR(TODAY()))",
"lastmonth": f"AND(MONTH({ref})=MONTH(EDATE(TODAY(),-1)),YEAR({ref})=YEAR(EDATE(TODAY(),-1)))",
}
formula = period_map.get(operator.lower(), f"INT({ref})=TODAY()")
elif sub_type in ("uniquevalues",):
ref = "INDIRECT(ADDRESS(ROW(),COLUMN()))"
formula = f"COUNTIF($A:$ZZ,{ref})=1"
elif sub_type in ("duplicatevalues",):
ref = "INDIRECT(ADDRESS(ROW(),COLUMN()))"
formula = f"COUNTIF($A:$ZZ,{ref})>1"
if not formula:
return None
return FormulaRule(formula=[formula], stopIfTrue=stop_if_true, font=font, fill=fill)
def _build_highlight_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
advanced = _build_advanced_highlight_rule(rule, stop_if_true)
if advanced is not None:
return advanced
sub_type = _normalize_sub_type(rule)
operator = _normalize_operator(rule.get("operator"))
value = rule.get("value")
formulas: List[str] = []
if operator in ("between", "notBetween") and isinstance(value, list):
for item in value[:2]:
if item is not None:
formulas.append(str(item))
elif sub_type == "expression" or rule.get("type") == "expression":
if value is not None:
text = str(value)
formulas.append(text[1:] if text.startswith("=") else text)
font, fill = _build_rule_style(rule)
if not formulas:
return None
return FormulaRule(formula=formulas, stopIfTrue=stop_if_true, font=font, fill=fill)
elif value is not None:
if isinstance(value, list):
for item in value[:2]:
if item is not None:
formulas.append(str(item))
else:
formulas.append(str(value))
if not operator:
if sub_type in _CELL_IS_OPERATORS:
operator = _normalize_operator(sub_type)
elif sub_type:
operator = "equal"
formulas = [str(value)] if value is not None else []
if not operator or not formulas:
return None
font, fill = _build_rule_style(rule)
return CellIsRule(
operator=operator,
formula=formulas,
stopIfTrue=stop_if_true,
font=font,
fill=fill,
)
def _build_color_scale_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
configs = _config_list(rule)
if len(configs) < 2:
return None
kwargs: Dict[str, Any] = {"stopIfTrue": stop_if_true}
slots = ("start", "mid", "end")
for idx, cfg in enumerate(configs[:3]):
slot = slots[idx] if len(configs) == 3 else ("start", "end")[idx]
value_type, value = _cfvo_value(cfg.get("value"))
kwargs[f"{slot}_type"] = value_type
if value is not None and value_type not in ("min", "max"):
kwargs[f"{slot}_value"] = value
color = _parse_rgb(cfg.get("color"))
if color:
kwargs[f"{slot}_color"] = color
try:
cf_rule = ColorScaleRule(**{k: v for k, v in kwargs.items() if k != "stopIfTrue"})
if stop_if_true is not None:
cf_rule.stopIfTrue = stop_if_true
return cf_rule
except (TypeError, ValueError):
return None
def _build_data_bar_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
configs = _config_list(rule)
if not configs:
return None
cfg = configs[0]
min_value = cfg.get("min") or {}
max_value = cfg.get("max") or {}
start_type, start_val = _cfvo_value(min_value.get("value") if isinstance(min_value, dict) else min_value)
end_type, end_val = _cfvo_value(max_value.get("value") if isinstance(max_value, dict) else max_value)
color = _parse_rgb(cfg.get("positiveColor") or cfg.get("nativeColor") or cfg.get("color"))
kwargs: Dict[str, Any] = {
"start_type": start_type or "min",
"end_type": end_type or "max",
"showValue": rule.get("isShowValue", True),
"stopIfTrue": stop_if_true,
}
if start_val is not None and start_type not in ("min", "max"):
kwargs["start_value"] = start_val
if end_val is not None and end_type not in ("min", "max"):
kwargs["end_value"] = end_val
if color:
kwargs["color"] = color
try:
stop = kwargs.pop("stopIfTrue", None)
cf_rule = DataBarRule(**kwargs)
if stop is not None:
cf_rule.stopIfTrue = stop
return cf_rule
except (TypeError, ValueError):
return None
def _build_icon_set_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
configs = _config_list(rule)
if len(configs) < 2:
return None
values: List[Any] = []
value_type = "percentile"
for cfg in configs:
value_obj = cfg.get("value") or {}
if isinstance(value_obj, dict):
value_type = _normalize_cfvo_type(value_obj.get("type") or value_type)
raw = value_obj.get("value")
if raw is not None:
values.append(raw)
elif value_obj is not None:
values.append(value_obj)
icon_style = str(configs[0].get("iconType") or rule.get("iconSet") or "3TrafficLights1")
try:
cf_rule = IconSetRule(
icon_style=icon_style,
type=value_type,
values=values,
showValue=rule.get("isShowValue", True),
)
if stop_if_true is not None:
cf_rule.stopIfTrue = stop_if_true
return cf_rule
except (TypeError, ValueError):
return None
def _build_cf_rule(entry: Dict[str, Any]) -> Optional[Any]:
rule = entry.get("rule")
if not isinstance(rule, dict):
return None
stop_if_true = entry.get("stopIfTrue")
rule_type = str(rule.get("type") or "highlight").lower()
if rule_type == "colorscale":
return _build_color_scale_rule(rule, stop_if_true)
if rule_type == "databar":
return _build_data_bar_rule(rule, stop_if_true)
if rule_type == "iconset":
return _build_icon_set_rule(rule, stop_if_true)
if rule_type in ("expression", "formula"):
return _build_highlight_rule({**rule, "subType": "expression"}, stop_if_true)
return _build_highlight_rule(rule, stop_if_true)
def build_fetch_url(base_url: str = "") -> Callable[[str], Optional[bytes]]:
"""构造相对/绝对 URL 图片拉取函数,供 Excel 导出使用。"""
def _fetch(source: str) -> Optional[bytes]:
return _default_fetch_url(source, base_url)
return _fetch
def apply_conditional_formatting(ws, sheet_id: str, snapshot: Dict[str, Any]) -> None:
cf_map = _parse_resource_map(snapshot, _CF_PLUGIN)
entries = cf_map.get(sheet_id) or []
if not isinstance(entries, list):
return
for entry in entries:
if not isinstance(entry, dict):
continue
ranges = entry.get("ranges") or []
refs = [_range_to_ref(r) for r in ranges]
refs = [r for r in refs if r]
if not refs:
continue
cf_rule = _build_cf_rule(entry)
if cf_rule is None:
continue
for ref in refs:
try:
ws.conditional_formatting.add(ref, cf_rule)
except Exception as exc:
logger.debug("skip conditional formatting %s: %s", ref, exc)
def _decode_base64_image(source: str) -> Optional[bytes]:
if not source:
return None
payload = _BASE64_PREFIX.sub("", source.strip())
try:
return base64.b64decode(payload, validate=False)
except (TypeError, ValueError):
return None
def _default_fetch_url(source: str, base_url: str = "") -> Optional[bytes]:
url = source.strip()
if not url:
return None
if url.startswith("/") and base_url:
url = f"{base_url.rstrip('/')}{url}"
if not url.lower().startswith(("http://", "https://")):
return None
try:
import httpx
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
return resp.content
except Exception as exc:
logger.debug("fetch image failed %s: %s", url, exc)
return None
def resolve_image_bytes(
source: str,
image_source_type: str = "",
*,
fetch_url: Optional[Callable[[str], Optional[bytes]]] = None,
) -> Optional[bytes]:
if not source:
return None
source_type = str(image_source_type or "").upper()
if source_type == "BASE64" or source.strip().startswith("data:image/"):
return _decode_base64_image(source)
if source_type == "URL" or source.startswith(("http://", "https://", "/")):
fetcher = fetch_url or (lambda u: _default_fetch_url(u))
return fetcher(source)
if source_type in ("", "BASE64"):
decoded = _decode_base64_image(source)
if decoded:
return decoded
return None
def _offset_value(offset: Any) -> int:
try:
return int(offset or 0)
except (TypeError, ValueError):
return 0
def _anchor_from_transform(sheet_transform: Dict[str, Any]) -> Optional[TwoCellAnchor]:
if not isinstance(sheet_transform, dict):
return None
start = sheet_transform.get("from") or {}
end = sheet_transform.get("to") or {}
try:
from_row = int(start.get("row", 0))
from_col = int(start.get("column", start.get("col", 0)))
to_row = int(end.get("row", from_row + 4))
to_col = int(end.get("column", end.get("col", from_col + 2)))
except (TypeError, ValueError):
return None
if from_col == to_col:
to_col = from_col + 2
if from_row == to_row:
to_row = from_row + 4
return TwoCellAnchor(
editAs="oneCell",
_from=AnchorMarker(
col=from_col,
colOff=_offset_value(start.get("columnOffset")),
row=from_row,
rowOff=_offset_value(start.get("rowOffset")),
),
to=AnchorMarker(
col=to_col,
colOff=_offset_value(end.get("columnOffset")),
row=to_row,
rowOff=_offset_value(end.get("rowOffset")),
),
)
def _add_image_to_sheet(ws, image_bytes: bytes, sheet_transform: Optional[Dict[str, Any]] = None) -> None:
if not image_bytes:
return
try:
img = XLImage(io.BytesIO(image_bytes))
except Exception as exc:
logger.debug("create image failed: %s", exc)
return
anchor = _anchor_from_transform(sheet_transform or {})
if anchor is not None:
img.anchor = anchor
ws.add_image(img)
return
ws.add_image(img, "A1")
def _iter_sheet_drawings(snapshot: Dict[str, Any], sheet_id: str) -> List[Dict[str, Any]]:
drawing_map = _parse_resource_map(snapshot, _DRAWING_PLUGIN)
block = drawing_map.get(sheet_id) or {}
if not isinstance(block, dict):
return []
data = block.get("data") or {}
order = block.get("order") or list(data.keys())
items: List[Dict[str, Any]] = []
if isinstance(order, list):
for key in order:
drawing = data.get(key)
if isinstance(drawing, dict):
items.append(drawing)
for key, drawing in data.items():
if isinstance(drawing, dict) and drawing not in items:
items.append(drawing)
return items
def _iter_cell_drawings(sheet: Dict[str, Any]) -> List[Tuple[Dict[str, Any], Dict[str, Any]]]:
results: List[Tuple[Dict[str, Any], Dict[str, Any]]] = []
cell_data = sheet.get("cellData") or {}
for row_key, row_obj in cell_data.items():
if not isinstance(row_obj, dict):
continue
try:
row = int(row_key)
except (TypeError, ValueError):
continue
for col_key, cell in row_obj.items():
if not isinstance(cell, dict):
continue
try:
col = int(col_key)
except (TypeError, ValueError):
continue
drawings = (cell.get("p") or {}).get("drawings") or {}
if not isinstance(drawings, dict):
continue
for drawing in drawings.values():
if not isinstance(drawing, dict):
continue
transform = drawing.get("sheetTransform") or {
"from": {"row": row, "column": col, "rowOffset": 0, "columnOffset": 0},
"to": {"row": row + 4, "column": col + 2, "rowOffset": 0, "columnOffset": 0},
}
results.append((drawing, transform))
return results
def apply_sheet_images(
ws,
sheet_id: str,
snapshot: Dict[str, Any],
sheet: Dict[str, Any],
*,
fetch_url: Optional[Callable[[str], Optional[bytes]]] = None,
) -> None:
for drawing in _iter_sheet_drawings(snapshot, sheet_id):
component_key = str(drawing.get("componentKey") or "")
if component_key and "echart" in component_key.lower():
continue
image_bytes = resolve_image_bytes(
str(drawing.get("source") or ""),
str(drawing.get("imageSourceType") or ""),
fetch_url=fetch_url,
)
if image_bytes:
_add_image_to_sheet(ws, image_bytes, drawing.get("sheetTransform"))
for drawing, transform in _iter_cell_drawings(sheet):
image_bytes = resolve_image_bytes(
str(drawing.get("source") or ""),
str(drawing.get("imageSourceType") or ""),
fetch_url=fetch_url,
)
if image_bytes:
_add_image_to_sheet(ws, image_bytes, transform)
def _parse_defined_names(snapshot: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
raw = _parse_resource_map(snapshot, _DEFINED_NAME_PLUGIN)
if not isinstance(raw, dict):
return {}
result: Dict[str, Dict[str, Any]] = {}
for key, value in raw.items():
if isinstance(value, dict):
result[str(key)] = value
return result
def _sheet_name_lookup(snapshot: Dict[str, Any]) -> Dict[str, str]:
lookup: Dict[str, str] = {}
for sheet_id, sheet in (snapshot.get("sheets") or {}).items():
if isinstance(sheet, dict):
lookup[str(sheet_id)] = str(sheet.get("name") or sheet_id)
return lookup
def _quote_sheet_name(name: str) -> str:
escaped = name.replace("'", "''")
return f"'{escaped}'"
def _resolve_hyperlink_target(
url: str,
*,
snapshot: Dict[str, Any],
defined_names: Dict[str, Dict[str, Any]],
) -> Tuple[Optional[str], Optional[str]]:
"""返回 (external_target, internal_location)。"""
if not url:
return None, None
raw = url.strip()
sheet_names = _sheet_name_lookup(snapshot)
if raw.startswith("#gid="):
payload = raw[len("#gid="):]
parts = payload.split("&range=")
sheet_id = parts[0]
cell_ref = parts[1] if len(parts) > 1 else "A1"
sheet_name = sheet_names.get(sheet_id, sheet_id)
location = f"{_quote_sheet_name(sheet_name)}!{cell_ref}"
return None, location
if raw.startswith("#rangeid="):
range_id = raw[len("#rangeid="):]
defined = defined_names.get(range_id) or {}
name = defined.get("name")
if name:
return None, str(name)
return None, None
if raw.startswith("#"):
return None, raw[1:]
return raw, None
def _extract_url_from_link_obj(obj: Dict[str, Any]) -> Optional[str]:
if not isinstance(obj, dict):
return None
for key in ("url", "address", "link", "payload", "hyperlink"):
val = obj.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
if isinstance(val, dict):
nested = _extract_url_from_link_obj(val)
if nested:
return nested
props = obj.get("properties")
if isinstance(props, dict):
return _extract_url_from_link_obj(props)
return None
def _extract_cell_hyperlink_url(cell: Dict[str, Any]) -> Optional[str]:
if not isinstance(cell, dict):
return None
direct = _extract_url_from_link_obj(cell)
if direct:
return direct
p = cell.get("p") or {}
link = p.get("link")
if isinstance(link, dict):
url = _extract_url_from_link_obj(link)
if url:
return url
body = p.get("body") or {}
if isinstance(body, dict):
for item in body.get("customRanges") or []:
if not isinstance(item, dict):
continue
url = _extract_url_from_link_obj(item.get("properties") or item)
if url:
return url
return None
def _cell_body_text(cell: Dict[str, Any]) -> Optional[str]:
body = ((cell.get("p") or {}).get("body") or {})
if isinstance(body, dict):
data_stream = body.get("dataStream")
if isinstance(data_stream, str) and data_stream.strip():
return data_stream.strip()
return None
def _iter_plugin_hyperlinks(snapshot: Dict[str, Any], sheet_id: str) -> List[Tuple[int, int, str]]:
links: List[Tuple[int, int, str]] = []
plugin_map = _parse_resource_map(snapshot, _HYPER_LINK_PLUGIN)
block = plugin_map.get(sheet_id)
if block is None:
return links
def _append(row: Any, col: Any, url: Optional[str]) -> None:
if url is None:
return
try:
links.append((int(row), int(col), url))
except (TypeError, ValueError):
return
if isinstance(block, list):
for item in block:
if not isinstance(item, dict):
continue
url = _extract_url_from_link_obj(item)
row = item.get("row", item.get("startRow", item.get("r")))
col = item.get("column", item.get("startColumn", item.get("c")))
_append(row, col, url)
elif isinstance(block, dict):
data = block.get("data") if isinstance(block.get("data"), dict) else block
if isinstance(data, dict):
for item in data.values():
if not isinstance(item, dict):
continue
url = _extract_url_from_link_obj(item)
row = item.get("row", item.get("startRow", item.get("r")))
col = item.get("column", item.get("startColumn", item.get("c")))
_append(row, col, url)
return links
def _apply_hyperlink_to_cell(
cell,
url: str,
*,
snapshot: Dict[str, Any],
defined_names: Dict[str, Dict[str, Any]],
) -> None:
target, location = _resolve_hyperlink_target(
url,
snapshot=snapshot,
defined_names=defined_names,
)
ref = cell.coordinate
if location:
cell.hyperlink = Hyperlink(ref=ref, location=location)
elif target:
cell.hyperlink = Hyperlink(ref=ref, target=target)
else:
return
cell.font = _HYPERLINK_FONT
def apply_sheet_hyperlinks(
ws,
sheet_id: str,
snapshot: Dict[str, Any],
sheet: Dict[str, Any],
) -> None:
defined_names = _parse_defined_names(snapshot)
seen: set = set()
for row_key, row_obj in (sheet.get("cellData") or {}).items():
if not isinstance(row_obj, dict):
continue
try:
row = int(row_key)
except (TypeError, ValueError):
continue
for col_key, cell in row_obj.items():
if not isinstance(cell, dict):
continue
try:
col = int(col_key)
except (TypeError, ValueError):
continue
url = _extract_cell_hyperlink_url(cell)
if not url:
continue
excel_row = row + 1
excel_col = col + 1
target_cell = ws.cell(row=excel_row, column=excel_col)
body_text = _cell_body_text(cell)
if body_text and not target_cell.value:
target_cell.value = body_text
_apply_hyperlink_to_cell(
target_cell,
url,
snapshot=snapshot,
defined_names=defined_names,
)
seen.add((row, col))
for row, col, url in _iter_plugin_hyperlinks(snapshot, sheet_id):
if (row, col) in seen:
continue
target_cell = ws.cell(row=row + 1, column=col + 1)
_apply_hyperlink_to_cell(
target_cell,
url,
snapshot=snapshot,
defined_names=defined_names,
)
@@ -0,0 +1,93 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""将 filled snapshot 导出为 PDF(对标 JNPF 打印/PDF 子集)"""
from __future__ import annotations
import io
from typing import Any, Dict, List, Optional, Tuple
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet
def _cell_text(cell: Dict[str, Any]) -> str:
if not cell:
return ""
v = cell.get("v")
if v is None:
v = cell.get("m")
return "" if v is None else str(v)
def _sheet_grid(snapshot: Dict[str, Any], sheet_id: str) -> Tuple[List[List[str]], int, int]:
sheets = snapshot.get("sheets") or {}
sheet = sheets.get(sheet_id) or {}
cell_data = sheet.get("cellData") or {}
if not cell_data:
return [[""]], 1, 1
rows = sorted(int(r) for r in cell_data.keys())
max_col = 0
for r in rows:
cols = cell_data.get(str(r)) or {}
if cols:
max_col = max(max_col, max(int(c) for c in cols.keys()))
max_col = max(max_col, 0)
grid: List[List[str]] = []
for r in rows:
row_cells = cell_data.get(str(r)) or {}
grid.append([_cell_text(row_cells.get(str(c)) or {}) for c in range(max_col + 1)])
return grid, len(rows), max_col + 1
def snapshot_to_pdf_bytes(
snapshot: Dict[str, Any],
*,
title: str = "",
watermark_text: str = "",
landscape_mode: bool = False,
) -> bytes:
buf = io.BytesIO()
page_size = landscape(A4) if landscape_mode else A4
doc = SimpleDocTemplate(
buf,
pagesize=page_size,
leftMargin=12 * mm,
rightMargin=12 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
)
styles = getSampleStyleSheet()
story: List[Any] = []
if title:
story.append(Paragraph(title, styles["Title"]))
story.append(Spacer(1, 6 * mm))
if watermark_text:
story.append(Paragraph(f"<font color='#cccccc'>{watermark_text}</font>", styles["Normal"]))
story.append(Spacer(1, 4 * mm))
sheet_order = snapshot.get("sheetOrder") or list((snapshot.get("sheets") or {}).keys())
for idx, sheet_id in enumerate(sheet_order):
grid, _, _ = _sheet_grid(snapshot, sheet_id)
if not grid:
continue
if idx > 0:
story.append(Spacer(1, 8 * mm))
table = Table(grid, repeatRows=1)
table.setStyle(
TableStyle(
[
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
("FONTSIZE", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]
)
)
story.append(table)
if not story:
story.append(Paragraph("(empty)", styles["Normal"]))
doc.build(story)
return buf.getvalue()
@@ -0,0 +1,491 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
表达式求值 MVP(对齐 JNPF expression 子集)
- #{param} 参数占位
- sum/avg/max/min/count(数据集别名.字段)
- sum/avg/max/min/count(A1:B2) 单元格区域
- A1、$B2 单元格引用
- 四则运算(仅数字)
"""
from __future__ import annotations
import ast
import copy
import operator
import re
from typing import Any, Dict, List, Optional, Tuple
from online_dev.report_manager.engine.column_layout import parse_cell_range
from online_dev.report_manager.engine.preview_mvp import _replace_params_in_value
def _get_nested_value(row: Dict[str, Any], field: str) -> Any:
if not field:
return None
if field in row:
return row[field]
parts = field.split(".")
cur: Any = row
for p in parts:
if isinstance(cur, dict) and p in cur:
cur = cur[p]
else:
return None
return cur
_AGG_FUNCS = ("sum", "avg", "max", "min", "count")
_AGG_PATTERN = re.compile(
r"(sum|avg|max|min|count)\s*\(\s*([a-zA-Z_][\w.]*)\s*\)",
re.IGNORECASE,
)
_CELL_RANGE_AGG_PATTERN = re.compile(
r"(sum|avg|max|min|count)\s*\(\s*([A-Za-z]+\d+)\s*:\s*([A-Za-z]+\d+)\s*\)",
re.IGNORECASE,
)
_CELL_REF_PATTERN = re.compile(
r"(?<![A-Za-z0-9.])(\$?)([A-Za-z]{1,3})(\d+)(?![:\w])",
re.IGNORECASE,
)
def _col_letter_to_index(col: str) -> int:
col = col.upper()
n = 0
for ch in col:
n = n * 26 + (ord(ch) - ord("A") + 1)
return n - 1
def _parse_a1(addr: str) -> Optional[Tuple[int, int]]:
"""A1 / $B2 -> (row, col) 0-based"""
if not addr:
return None
m = re.match(r"^\$?([A-Za-z]+)(\d+)$", addr.strip())
if not m:
return None
return int(m.group(2)) - 1, _col_letter_to_index(m.group(1))
def _cell_to_numeric(value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
return float(value)
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _get_cell_value(
snapshot: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
) -> Any:
sheets = snapshot.get("sheets") or {}
sheet = sheets.get(sheet_id) or {}
cell_data = sheet.get("cellData") or {}
row_obj = cell_data.get(str(row)) or {}
cell = row_obj.get(str(col)) or {}
return cell.get("v")
def _collect_cells_in_range(
snapshot: Dict[str, Any],
sheet_id: str,
start: str,
end: str,
) -> List[float]:
bounds = parse_cell_range(f"{start}:{end}")
if not bounds:
return []
r0, r1, c0, c1 = bounds
values: List[float] = []
for r in range(r0, r1 + 1):
for c in range(c0, c1 + 1):
num = _cell_to_numeric(_get_cell_value(snapshot, sheet_id, r, c))
if num is not None:
values.append(num)
return values
def _aggregate_cell_range(
func: str,
snapshot: Dict[str, Any],
sheet_id: str,
start: str,
end: str,
) -> float:
values = _collect_cells_in_range(snapshot, sheet_id, start, end)
if not values:
return 0
f = func.lower()
if f == "sum":
return sum(values)
if f == "avg":
return sum(values) / len(values)
if f == "max":
return max(values)
if f == "min":
return min(values)
if f == "count":
return float(len(values))
return 0
def _replace_cell_range_aggregates(
expr: str,
snapshot: Dict[str, Any],
sheet_id: str,
) -> str:
def repl(m: re.Match) -> str:
val = _aggregate_cell_range(
m.group(1), snapshot, sheet_id, m.group(2), m.group(3)
)
if val == int(val):
return str(int(val))
return str(round(val, 8))
return _CELL_RANGE_AGG_PATTERN.sub(repl, expr)
def _replace_cell_refs(
expr: str,
snapshot: Dict[str, Any],
sheet_id: str,
) -> str:
def repl(m: re.Match) -> str:
pos = _parse_a1(f"{m.group(2)}{m.group(3)}")
if not pos:
return m.group(0)
row, col = pos
num = _cell_to_numeric(_get_cell_value(snapshot, sheet_id, row, col))
if num is None:
return "0"
if num == int(num):
return str(int(num))
return str(num)
return _CELL_REF_PATTERN.sub(repl, expr)
_SAFE_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def _parse_dataset_field(ref: str) -> Tuple[Optional[str], str]:
if "." in ref:
parts = ref.split(".", 1)
return parts[0], parts[1]
return None, ref
def _aggregate(func: str, datasets: Dict[str, List[Any]], ref: str) -> float:
alias, field = _parse_dataset_field(ref)
if not alias or not field:
return 0
rows = datasets.get(alias) or []
values: List[float] = []
for row in rows:
if not isinstance(row, dict):
continue
v = _get_nested_value(row, field)
if v is None or v == "":
continue
try:
values.append(float(v))
except (TypeError, ValueError):
if func.lower() == "count":
values.append(1.0)
if not values:
return 0
f = func.lower()
if f == "sum":
return sum(values)
if f == "avg":
return sum(values) / len(values)
if f == "max":
return max(values)
if f == "min":
return min(values)
if f == "count":
return float(len(values))
return 0
def _replace_aggregates(expr: str, datasets: Dict[str, List[Any]]) -> str:
def repl(m: re.Match) -> str:
val = _aggregate(m.group(1), datasets, m.group(2))
if val == int(val):
return str(int(val))
return str(round(val, 8))
return _AGG_PATTERN.sub(repl, expr)
def _safe_eval_numeric(expr: str) -> Any:
expr = (expr or "").strip()
if not expr:
return ""
node = ast.parse(expr, mode="eval")
return _eval_node(node.body)
def _eval_node(node: ast.AST) -> float:
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return float(node.value)
raise ValueError("non-numeric constant")
if isinstance(node, ast.BinOp):
op = _SAFE_OPS.get(type(node.op))
if not op:
raise ValueError("unsupported operator")
return op(_eval_node(node.left), _eval_node(node.right))
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
return -_eval_node(node.operand)
raise ValueError("unsupported expression")
def evaluate_formula(
formula: str,
params: Dict[str, Any],
datasets: Dict[str, List[Any]],
snapshot: Optional[Dict[str, Any]] = None,
sheet_id: str = "sheet1",
) -> str:
"""
求值表达式,返回可写入单元格的字符串结果。
支持前缀 '=' 或裸公式。
"""
raw = (formula or "").strip()
if not raw:
return ""
if raw.startswith("="):
raw = raw[1:].strip()
text = _replace_params_in_value(raw, params)
if snapshot:
text = _replace_cell_range_aggregates(text, snapshot, sheet_id)
text = _replace_aggregates(text, datasets)
if snapshot:
text = _replace_cell_refs(text, snapshot, sheet_id)
try:
result = _safe_eval_numeric(text)
if result == int(result):
return str(int(result))
return str(result)
except Exception:
return text
def _extract_formula_cell_refs(formula: str) -> List[Tuple[int, int]]:
"""从公式中提取 A1 风格单元格引用(0-based row, col"""
raw = (formula or "").strip()
if raw.startswith("="):
raw = raw[1:].strip()
refs: List[Tuple[int, int]] = []
seen: set = set()
for m in _CELL_REF_PATTERN.finditer(raw):
pos = _parse_a1(f"{m.group(2)}{m.group(3)}")
if pos and pos not in seen:
seen.add(pos)
refs.append(pos)
return refs
def _sort_expression_targets(
targets: List[Tuple[str, int, int, str]],
) -> Tuple[List[Tuple[str, int, int, str]], bool]:
"""
按单元格引用依赖拓扑排序表达式目标。
返回 (排序后列表, 是否存在环)。
"""
if len(targets) <= 1:
return targets, False
expr_keys = {(s, r, c) for s, r, c, _ in targets}
deps: Dict[Tuple[str, int, int], Set[Tuple[str, int, int]]] = {
k: set() for k in expr_keys
}
for sheet_id, row, col, formula in targets:
key = (sheet_id, row, col)
for ref_row, ref_col in _extract_formula_cell_refs(formula):
dep_key = (sheet_id, ref_row, ref_col)
if dep_key in expr_keys and dep_key != key:
deps[key].add(dep_key)
in_degree = {k: len(deps[k]) for k in expr_keys}
children: Dict[Tuple[str, int, int], Set[Tuple[str, int, int]]] = {
k: set() for k in expr_keys
}
for key, dep_set in deps.items():
for dep in dep_set:
children[dep].add(key)
queue = sorted(k for k in expr_keys if in_degree[k] == 0)
order: List[Tuple[str, int, int]] = []
while queue:
key = queue.pop(0)
order.append(key)
for child in sorted(children[key]):
in_degree[child] -= 1
if in_degree[child] == 0:
queue.append(child)
has_cycle = len(order) != len(expr_keys)
if has_cycle:
return targets, True
key_to_target = {(s, r, c): (s, r, c, f) for s, r, c, f in targets}
return [key_to_target[k] for k in order], False
def _write_expression_cell(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
formula: str,
value: str,
) -> None:
sheet = sheets.get(sheet_id)
if not sheet:
return
cell_data = sheet.setdefault("cellData", {})
row_data = cell_data.setdefault(str(row), {})
cell_obj = row_data.setdefault(str(col), {})
cell_obj["v"] = value
display_formula = formula.strip()
if display_formula and not display_formula.startswith("="):
display_formula = f"={display_formula}"
if display_formula:
cell_obj["f"] = display_formula
cell_obj["t"] = 4
custom = cell_obj.get("custom") or {}
custom["type"] = "expression"
custom["field"] = formula
custom["formula"] = display_formula or formula
cell_obj["custom"] = custom
def _collect_expression_targets(
cells_meta: Dict[str, Any],
snapshot: Dict[str, Any],
) -> List[Tuple[str, int, int, str]]:
"""返回 (sheet_id, row, col, formula)"""
targets: List[Tuple[str, int, int, str]] = []
seen: set = set()
for cell in cells_meta.get("cells") or []:
if cell.get("type") != "expression":
continue
sheet_id = cell.get("sheet", "sheet1")
row = int(cell.get("row", 0))
col = int(cell.get("col", 0))
custom = cell.get("custom") or {}
formula = (
custom.get("field")
or custom.get("value")
or custom.get("formula")
or ""
)
key = (sheet_id, row, col)
if key not in seen:
seen.add(key)
targets.append((sheet_id, row, col, str(formula)))
sheets = snapshot.get("sheets") or {}
for sheet_id, sheet in sheets.items():
if not isinstance(sheet, dict):
continue
cell_data = sheet.get("cellData") or {}
for rk, row in cell_data.items():
if not isinstance(row, dict):
continue
try:
row_i = int(rk)
except ValueError:
continue
for ck, cell in row.items():
if not isinstance(cell, dict):
continue
custom = cell.get("custom") or {}
if custom.get("type") != "expression":
continue
try:
col_i = int(ck)
except ValueError:
continue
formula = (
custom.get("field")
or custom.get("value")
or custom.get("formula")
or cell.get("v")
or ""
)
key = (sheet_id, row_i, col_i)
if key not in seen:
seen.add(key)
targets.append((sheet_id, row_i, col_i, str(formula)))
return targets
def detect_expression_cycles(
cells_meta: Dict[str, Any],
snapshot: Optional[Dict[str, Any]] = None,
) -> List[str]:
"""
检测表达式单元格引用环。
返回警告码列表(供预览 API warnings 字段使用)。
"""
snap = snapshot if snapshot is not None else {"sheets": {}}
targets = _collect_expression_targets(cells_meta, snap)
if len(targets) <= 1:
return []
_, has_cycle = _sort_expression_targets(targets)
if has_cycle:
return ["expression_cycle"]
return []
def apply_expression_cells(
snapshot: Dict[str, Any],
cells_meta: Dict[str, Any],
datasets: Dict[str, List[Any]],
params: Dict[str, Any],
) -> Dict[str, Any]:
if not snapshot:
return snapshot or {}
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
targets = _collect_expression_targets(cells_meta, result)
if not targets:
return result
ordered, has_cycle = _sort_expression_targets(targets)
max_passes = min(len(targets) + 1, 32)
def _eval_all(batch: List[Tuple[str, int, int, str]]) -> bool:
changed = False
for sheet_id, row, col, formula in batch:
prev = _get_cell_value(result, sheet_id, row, col)
value = evaluate_formula(formula, params, datasets, result, sheet_id)
if str(prev) != str(value):
changed = True
_write_expression_cell(sheets, sheet_id, row, col, formula, value)
return changed
if not has_cycle:
_eval_all(ordered)
else:
for _ in range(max_passes):
if not _eval_all(targets):
break
result["sheets"] = sheets
return result
@@ -0,0 +1,51 @@
# JNPF Univer 报表对标差异说明
## 已对齐
| 能力 | JNPF | ZQ |
|------|------|-----|
| 列表向下扩展 | `polymerizationType=1` | `polymerize._poly_list` |
| 分组 / 相邻分组 | `2` + `groupType` | `polymerize._poly_group` |
| 汇总 | `3` + `summaryType` | `polymerize._poly_summary` |
| 左/上父格 | `leftParentType` / `topParentType` | `parent_cells.py` |
| 字段映射 | `fieldMapping` | `dataset_transform.apply_field_mapping` |
| 转换规则 | `convertConfig` select/date/number | `dataset_transform.apply_convert_rules` |
| convert 全类型 | user/dept/org/role/dict | `convert_lookup.py` |
| 分栏布局 | `f_fence_list` | `column_layout`(优先 `fence_list` |
| 服务端水印 | preview 解析 showTime | `watermark.py` + `watermark` 响应字段 |
| Excel 导出 | merge/样式/尺寸/页眉水印/条件格式/超链接/图片 | `export_excel.py` + `export_excel_extras.py` |
| 导出权限 | `allow_export` | `export-excel` 接口强制校验 |
| parameterData | 系统变量 HTTP | `parameter_resolver.py` 本地合并 |
| PDF 导出 | 部分用打印 | `export_pdf.py` |
| 扩展合并单元格 | merge 重算 | `merge_recalc.py` |
| fillDirection | portrait/landscape | `data_expand._expand_direction` |
| displayType | qrCode/jsbarcode | 设计器保存 + 引擎识别 |
## JNPF DB 真实样例 Golden2026-05-23
`jnpf-database-v6x/MySQL/jnpf_db_init.sql` 提取,脚本:
```bash
cd backend-fastapi
python -m online_dev.report_manager.engine.fixtures.extract_jnpf_fixtures
```
| Fixture | JNPF 模板 | 场景 |
|---------|-----------|------|
| `golden_jnpf_db_user_list.json` | 人员花名册(列表) | 列表 portrait 扩展 |
| `golden_jnpf_db_user_group.json` | 人员花名册(分组) | 分组 polymerizationType=2 |
| `golden_jnpf_db_user_matrix.json` | 人员花名册(行列) | landscape + portrait 混合 |
## 已知差异 / 待补
| 项 | 说明 | 优先级 |
|----|------|--------|
| report_run_log | 未实现 | P3 |
| App 菜单发布 | 范围外 | — |
| 独立报表微服务 | JNPF :32000,ZQ 单体 | 架构差异,保持 |
## Golden 样例来源
- 手写对标:`golden_jnpf_*.json`(引擎单元场景)
- 生产 DB 提取:`golden_jnpf_db_*.json`(真实 snapshot/cells 结构)
- 转换规则占位:`golden_jnpf_prod_*.json`
@@ -0,0 +1,61 @@
# Golden Test Fixtures
运行:`python -m online_dev.report_manager.engine.test_golden`
## 格式
```json
{
"name": "用例名",
"snapshot": { "sheets": { ... } },
"cells": { "cells": [ ... ] },
"datasets": { "别名": [ { ... } ] },
"params": {},
"column_list": [],
"expect": {
"sheet1": { "行,列": "期望值" }
}
}
```
## Golden 样例(23 fixtures
运行后应输出 `ok (23 fixtures)`
### JNPF DB 真实样例
从 JNPF `jnpf_db_init.sql` 提取(人员花名册 列表/分组/行列):
```bash
cd backend-fastapi
python -m online_dev.report_manager.engine.fixtures.extract_jnpf_fixtures
```
| 文件 | 场景 |
|------|------|
| `golden_jnpf_db_user_list.json` | 列表 portrait |
| `golden_jnpf_db_user_group.json` | 分组 |
| `golden_jnpf_db_user_matrix.json` | 行列 landscape |
手写对标样例仍放在 `golden_jnpf_*.json` / `golden_*.json`
`expect` 中填写本引擎 `transform()` 后应对的单元格值。
参考样例:`golden_jnpf_style.json`(参数 + 双列列表 + 占位符)。
## 阶段说明
| 阶段 | 能力 |
|------|------|
| 已完成 | transform 流水线、分栏、表达式、图表 chartData、fillEmptyRows |
| Phase 8 | 父格拓扑扩展、`polymerizationType`、JNPF golden、图表拾色器 |
| Phase 8+ | 跨行上父格、全表 `row_registry``parent_scoped` 子格数据切片 |
## JNPF 父格 Golden
| 文件 | 场景 |
|------|------|
| `golden_jnpf_parent_group.json` | 左父格 + 分组/列表 |
| `golden_jnpf_top_parent.json` | 同行上父格链(年→月→金额) |
| `golden_jnpf_cross_row.json` | 跨行上父格(同列子格优先显示) |
| `golden_jnpf_poly_summary.json` | 汇总格 |
| `golden_jnpf_export_list.json` | 字符串行列 + fillDirection |
@@ -0,0 +1,327 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
从 JNPF jnpf_db_init.sql 提取 report_version 真实样例,生成 ZQ Golden fixture。
用法(在 backend-fastapi 目录):
python -m online_dev.report_manager.engine.fixtures.extract_jnpf_fixtures
python -m online_dev.report_manager.engine.fixtures.extract_jnpf_fixtures --sql /path/to/jnpf_db_init.sql
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from online_dev.report_manager.engine.convert import transform
DEFAULT_SQL = Path(
"/Users/zcl/Project/JZKJ/lowcode6.2.x/6.2.x/jnpf-database-v6x/MySQL/jnpf_db_init.sql"
)
OUT_DIR = Path(__file__).parent
# version_id -> (slug, template_name, dataset_alias, max_rows)
TARGET_VERSIONS: List[Tuple[str, str, str, str, int]] = [
("623183857306304837", "jnpf_db_user_list", "人员花名册(列表)", "user", 8),
("623200369010278981", "jnpf_db_user_group", "人员花名册(分组)", "user", 8),
("623204233562292805", "jnpf_db_user_matrix", "人员花名册(行列)", "report_user", 6),
]
def parse_sql_values(line: str) -> List[Any]:
start = line.index("VALUES (") + len("VALUES (")
fields: List[Any] = []
i, n = start, len(line)
while i < n:
c = line[i]
if c == "'":
i += 1
buf: List[str] = []
while i < n:
if line[i] == "\\" and i + 1 < n:
nxt = line[i + 1]
if nxt == "\\":
buf.append("\\")
i += 2
elif nxt == "'":
buf.append("'")
i += 2
elif nxt == '"':
buf.append('"')
i += 2
elif nxt == "n":
buf.append("\n")
i += 2
elif nxt == "r":
buf.append("\r")
i += 2
elif nxt == "t":
buf.append("\t")
i += 2
else:
buf.append(nxt)
i += 2
elif line[i] == "'" and i + 1 < n and line[i + 1] == "'":
buf.append("'")
i += 2
elif line[i] == "'":
i += 1
break
else:
buf.append(line[i])
i += 1
fields.append("".join(buf))
elif c in " \t\n\r":
i += 1
elif c == ",":
i += 1
elif c.isdigit() or c == "-":
j = i
while j < n and line[j] not in ",)":
j += 1
fields.append(line[i:j].strip())
i = j
elif c == "N" and line[i : i + 4] == "NULL":
fields.append(None)
i += 4
elif c == ")":
break
else:
i += 1
return fields
def _load_sql_lines(sql_path: Path) -> List[str]:
return sql_path.read_text(encoding="utf-8").splitlines()
def _parse_users(lines: List[str]) -> List[dict]:
users: List[dict] = []
for line in lines:
if not line.startswith("INSERT INTO `report_user`"):
continue
f = parse_sql_values(line)
users.append(
{
"username": f[1],
"education": f[2],
"sex": f[3],
"salary": float(f[4]),
"departmentnum": f[5],
}
)
return users
def _parse_departments(lines: List[str]) -> Dict[str, dict]:
by_num: Dict[str, dict] = {}
for line in lines:
if not line.startswith("INSERT INTO `report_department`"):
continue
f = parse_sql_values(line)
by_num[str(f[2])] = {
"organizationName": f[3],
"departmentName": f[1],
"departmentNum": f[2],
}
return by_num
def _build_user_dataset(users: List[dict], depts: Dict[str, dict], limit: int) -> List[dict]:
rows: List[dict] = []
for u in users[:limit]:
d = depts.get(u["departmentnum"], {})
rows.append(
{
"orgname": d.get("organizationName", ""),
"depName": d.get("departmentName", ""),
"education": u["education"],
"sex": u["sex"],
"username": u["username"],
"salary": u["salary"],
}
)
return rows
def _build_report_user_dataset(users: List[dict], depts: Dict[str, dict], limit: int) -> List[dict]:
rows: List[dict] = []
for u in users[:limit]:
d = depts.get(u["departmentnum"], {})
rows.append(
{
**u,
"organizationName": d.get("organizationName", ""),
"departmentName": d.get("departmentName", ""),
}
)
return rows
def _find_version_line(lines: List[str], version_id: str) -> Optional[str]:
for line in lines:
if f"'{version_id}'" in line and "INSERT INTO `report_version`" in line:
return line
return None
def _trim_snapshot(snapshot: dict, cells: dict, keep_rows: int = 30) -> dict:
"""保留绑定相关行,剥离 styles/resources 等大字段,减小 fixture 体积。"""
binding_rows: set[int] = set()
for cell in cells.get("cells") or []:
try:
binding_rows.add(int(cell.get("row", 0)))
except (TypeError, ValueError):
pass
max_row = max(binding_rows) if binding_rows else 10
max_row = min(max_row + len(binding_rows) + 5, keep_rows)
sheet_order = snapshot.get("sheetOrder") or []
sheets_out: Dict[str, Any] = {}
for sid in sheet_order:
sheet = (snapshot.get("sheets") or {}).get(sid) or {}
cell_data = sheet.get("cellData") or {}
trimmed: Dict[str, Any] = {}
for rk, row in cell_data.items():
if int(rk) <= max_row:
trimmed[rk] = row
sheets_out[sid] = {
"id": sid,
"cellData": trimmed,
}
if sheet.get("mergeData"):
sheets_out[sid]["mergeData"] = sheet["mergeData"]
return {
"id": snapshot.get("id") or "_workbook",
"sheetOrder": list(sheet_order),
"sheets": sheets_out,
}
def _collect_expect(out_snapshot: dict, cells: dict, max_rows: int = 30) -> Dict[str, Dict[str, str]]:
data_cells = [c for c in (cells.get("cells") or []) if c.get("type") == "dataSource"]
if not data_cells:
return {}
sheet_ids = {str(c.get("sheet")) for c in data_cells}
cols: set[int] = set()
start_row = 9999
for c in data_cells:
cols.add(int(c.get("col", 0)))
start_row = min(start_row, int(c.get("row", 0)))
expect: Dict[str, Dict[str, str]] = {}
for sid in sheet_ids:
sheet = (out_snapshot.get("sheets") or {}).get(sid) or {}
cell_data = sheet.get("cellData") or {}
expect[sid] = {}
for rk, row in cell_data.items():
ri = int(rk)
if ri < start_row or ri > max_rows:
continue
for ck, cell in row.items():
ci = int(ck)
if ci not in cols:
continue
v = cell.get("v")
if v is None or v == "":
continue
expect[sid][f"{ri},{ci}"] = str(v)
return expect
def _parse_json_field(raw: Any) -> Any:
if raw is None or raw == "NULL":
return None
if isinstance(raw, (dict, list)):
return raw
s = str(raw).strip()
if not s:
return None
return json.loads(s)
def build_fixture(
lines: List[str],
version_id: str,
slug: str,
template_name: str,
dataset_alias: str,
row_limit: int,
) -> dict:
line = _find_version_line(lines, version_id)
if not line:
raise ValueError(f"report_version {version_id} not found in SQL")
fields = parse_sql_values(line)
cells = json.loads(fields[4])
snapshot = json.loads(fields[5])
convert_config = _parse_json_field(fields[7])
sort_list = _parse_json_field(fields[19])
fence_list = _parse_json_field(fields[21]) or _parse_json_field(fields[20])
users = _parse_users(lines)
depts = _parse_departments(lines)
if dataset_alias == "report_user":
dataset_rows = _build_report_user_dataset(users, depts, row_limit)
else:
dataset_rows = _build_user_dataset(users, depts, row_limit)
trimmed_snapshot = _trim_snapshot(snapshot, cells)
datasets = {dataset_alias: dataset_rows}
out = transform(
trimmed_snapshot,
cells,
datasets,
{},
column_list=None,
fence_list=fence_list,
)
expect = _collect_expect(out, cells)
fixture: dict = {
"name": slug,
"comment": f"JNPF DB 真实样例: {template_name} (version {version_id})",
"jnpf_version_id": version_id,
"snapshot": trimmed_snapshot,
"cells": cells,
"datasets": datasets,
"params": {},
"expect": expect,
}
if convert_config:
fixture["convert_config"] = convert_config
if fence_list:
fixture["fence_list"] = fence_list
if sort_list:
fixture["sort_list"] = sort_list
return fixture
def main() -> None:
parser = argparse.ArgumentParser(description="Extract JNPF report_version golden fixtures")
parser.add_argument("--sql", type=Path, default=DEFAULT_SQL, help="jnpf_db_init.sql path")
parser.add_argument("--out-dir", type=Path, default=OUT_DIR, help="output directory")
args = parser.parse_args()
if not args.sql.is_file():
raise SystemExit(f"SQL file not found: {args.sql}")
lines = _load_sql_lines(args.sql)
written: List[str] = []
for version_id, slug, template_name, alias, limit in TARGET_VERSIONS:
fixture = build_fixture(lines, version_id, slug, template_name, alias, limit)
out_path = args.out_dir / f"golden_{slug}.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(fixture, f, ensure_ascii=False, indent=2)
written.append(out_path.name)
print(f"wrote {out_path.name} ({len(fixture['expect'].get(list(fixture['expect'])[0], {}))} expect cells)")
print(f"done: {len(written)} fixtures")
if __name__ == "__main__":
main()
@@ -0,0 +1,42 @@
{
"name": "column_col_type1_max_col",
"snapshot": {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "1" } },
"1": { "0": { "v": "2" } },
"2": { "0": { "v": "3" } },
"3": { "0": { "v": "4" } },
"4": { "0": { "v": "5" } }
}
}
}
},
"cells": { "cells": [] },
"datasets": {},
"params": {},
"column_list": [
{
"sheet": "sheet1",
"columnList": {
"columnState": true,
"columnStyle": "col",
"columnType": "1",
"maxCol": 2,
"columnData": "A1:A5"
}
}
],
"expect": {
"sheet1": {
"0,0": "1",
"1,0": "2",
"0,1": "3",
"1,1": "4",
"0,2": "5"
}
}
}
@@ -0,0 +1,40 @@
{
"name": "column_col_type2",
"snapshot": {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "a" } },
"2": { "0": { "v": "b" } },
"3": { "0": { "v": "c" } },
"4": { "0": { "v": "d" } }
}
}
}
},
"cells": { "cells": [] },
"datasets": {},
"params": {},
"column_list": [
{
"sheet": "sheet1",
"columnList": {
"columnState": true,
"columnStyle": "col",
"columnType": "2",
"rowCount": 2,
"columnData": "A2:A5"
}
}
],
"expect": {
"sheet1": {
"1,0": "a",
"2,0": "b",
"1,1": "c",
"2,1": "d"
}
}
}
@@ -0,0 +1,39 @@
{
"name": "expression_chain_b1_c1",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "100" }, "1": { "v": "" }, "2": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": { "field": "=A1+1" }
},
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 2,
"custom": { "field": "=B1+1" }
}
]
},
"datasets": {},
"params": {},
"expect": {
"sheet1": {
"0,1": "101",
"0,2": "102"
}
}
}
@@ -0,0 +1,43 @@
{
"name": "datasource_fill_empty_rows",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "name",
"expand": "down",
"fillEmptyRows": true,
"fillEmptyNum": 2
}
}
]
},
"datasets": {
"items": [{ "name": "A" }, { "name": "B" }]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "A",
"2,0": "B",
"3,0": "",
"4,0": ""
}
}
}
@@ -0,0 +1,76 @@
{
"name": "jnpf_cross_row_top_left",
"comment": "跨行:年(0,0)分组 → 月(1,0)上父年 → 金额(1,1)左父月;同列时子格覆盖父格显示",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "" }, "1": { "v": "" } },
"1": { "0": { "v": "" }, "1": { "v": "" } },
"2": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 0,
"custom": {
"dataSetName": "sales",
"field": "year",
"polymerizationType": "2",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "sales",
"field": "month",
"polymerizationType": "1",
"expand": "down",
"topParentCellType": "default"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"dataSetName": "sales",
"field": "amount",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default"
}
}
]
},
"datasets": {
"sales": [
{ "year": 2023, "month": 1, "amount": 10 },
{ "year": 2023, "month": 2, "amount": 20 },
{ "year": 2024, "month": 1, "amount": 30 }
]
},
"params": {},
"expect": {
"sheet1": {
"0,0": "1",
"1,0": "2",
"2,0": "1",
"0,1": "10",
"1,1": "20",
"2,1": "30"
}
}
}
@@ -0,0 +1,686 @@
{
"name": "jnpf_db_user_group",
"comment": "JNPF DB 真实样例: 人员花名册(分组) (version 623200369010278981)",
"jnpf_version_id": "623200369010278981",
"snapshot": {
"id": "_cMcfw",
"sheetOrder": [
"Eh_Jx6bicu3SB2VKA8XcS"
],
"sheets": {
"Eh_Jx6bicu3SB2VKA8XcS": {
"id": "Eh_Jx6bicu3SB2VKA8XcS",
"cellData": {
"0": {
"0": {
"v": "人员花名册",
"s": "VLEizt",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
},
"t": 1
},
"1": {
"s": "KZ1C-t"
},
"2": {
"s": "KZ1C-t"
},
"3": {
"s": "KZ1C-t"
},
"4": {
"s": "KZ1C-t"
},
"5": {
"s": "lYauvg"
},
"6": {
"s": "-56Kck"
}
},
"1": {
"0": {
"v": "组织",
"t": 1,
"s": "vHs82n"
},
"1": {
"v": "部门",
"t": 1,
"s": "vHs82n"
},
"2": {
"v": "学历",
"t": 1,
"s": "vHs82n"
},
"3": {
"v": "性别",
"t": 1,
"s": "vHs82n"
},
"4": {
"v": "姓名",
"t": 1,
"s": "vHs82n"
},
"5": {
"v": "薪资",
"s": "vHs82n",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
},
"t": 1
},
"6": {
"s": "-56Kck"
}
},
"2": {
"0": {
"v": "${user.orgname}",
"t": 1,
"s": "Xk2Rw5",
"custom": {
"field": "user.orgname",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
"1": {
"v": "${user.depName}",
"t": 1,
"s": "hdQ2ih",
"custom": {
"field": "user.depName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
"2": {
"v": "${user.education}",
"t": 1,
"s": "2KKOQW",
"custom": {
"field": "user.education",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
"3": {
"v": "${user.sex}",
"t": 1,
"s": "4n6jyh",
"custom": {
"field": "user.sex",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
"4": {
"v": "${user.username}",
"t": 1,
"s": "hdQ2ih",
"custom": {
"field": "user.username",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"5": {
"v": "${user.salary}",
"t": 1,
"s": "d0FA0C",
"custom": {
"field": "user.salary",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"6": {
"s": "-56Kck"
}
},
"3": {
"0": {
"v": "合计:",
"t": 1,
"s": "-l-j-h"
},
"1": {
"s": "3sqGgY"
},
"2": {
"s": "3sqGgY"
},
"3": {
"s": "3sqGgY"
},
"4": {
"v": "${user.username}",
"t": 1,
"s": "L9T7Dl",
"custom": {
"field": "user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"5": {
"v": "${user.salary}",
"t": 1,
"s": "HPHgPE",
"custom": {
"field": "user.salary",
"polymerizationType": "3",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"6": {
"s": "-56Kck"
}
},
"4": {
"0": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"1": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"2": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"3": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"4": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"5": {
"v": "",
"t": 1,
"s": "T9ZVIu"
}
},
"5": {
"0": {
"v": "",
"t": 1,
"s": "FgxYUY"
},
"1": {
"v": "",
"t": 1,
"s": "FgxYUY"
},
"2": {
"v": "",
"t": 1,
"s": "FgxYUY"
},
"3": {
"v": "",
"t": 1,
"s": "FgxYUY"
},
"4": {
"v": "制表日期:",
"t": 1,
"s": "UWFE6A"
},
"5": {
"s": "ekE1b4",
"f": "=NOW()",
"v": 45848.65962962963,
"t": 2
}
}
},
"mergeData": [
{
"startRow": 0,
"endRow": 0,
"startColumn": 0,
"endColumn": 5
},
{
"startRow": 3,
"endRow": 3,
"startColumn": 0,
"endColumn": 3
}
]
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "0",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
},
{
"col": "5",
"row": "1",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
},
{
"col": "0",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.orgname",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "1",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.depName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "2",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.education",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "3",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.sex",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "4",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.username",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "5",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.salary",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "4",
"row": "3",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "5",
"row": "3",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.salary",
"polymerizationType": "3",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
}
],
"floatEcharts": {},
"cellEcharts": {},
"floatImages": {}
},
"datasets": {
"user": [
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "博士后",
"sex": "1",
"username": "曦晨",
"salary": 2410.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "昊明",
"salary": 3639.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "昊硕",
"salary": 2101.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "欧阳",
"salary": 5863.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "高中",
"sex": "2",
"username": "王忠亮",
"salary": 6128.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "吴忠民",
"salary": 3839.0
},
{
"orgname": "上海",
"depName": "上海-软件产品支持部",
"education": "博士",
"sex": "2",
"username": "张秀恩",
"salary": 3943.0
},
{
"orgname": "上海",
"depName": "上海-软件产品支持部",
"education": "本科",
"sex": "1",
"username": "姜磊",
"salary": 1474.0
}
]
},
"params": {},
"expect": {
"Eh_Jx6bicu3SB2VKA8XcS": {
"2,0": "广东",
"2,1": "深圳-软件技术支持部",
"2,2": "博士后",
"2,3": "1",
"2,4": "曦晨",
"2,5": "2410.0",
"3,0": "广东",
"3,1": "深圳-软件技术支持部",
"3,2": "本科",
"3,3": "1",
"3,4": "8",
"3,5": "3639.0",
"4,0": "广东",
"4,1": "深圳-软件技术支持部",
"4,2": "本科",
"4,3": "1",
"4,4": "昊硕",
"4,5": "29397.0",
"5,0": "广东",
"5,1": "深圳-软件技术支持部",
"5,2": "本科",
"5,3": "1",
"5,4": "欧阳",
"5,5": "5863.0",
"6,5": "3839.0",
"6,4": "吴忠民",
"6,3": "1",
"6,2": "本科",
"6,1": "深圳-软件技术支持部",
"6,0": "广东",
"7,5": "6128.0",
"7,4": "王忠亮",
"7,3": "2",
"7,2": "高中",
"7,1": "深圳-软件技术支持部",
"7,0": "广东",
"8,5": "3943.0",
"8,4": "张秀恩",
"8,3": "2",
"8,2": "博士",
"8,1": "上海-软件产品支持部",
"8,0": "上海",
"9,5": "1474.0",
"9,4": "姜磊",
"9,3": "1",
"9,2": "本科",
"9,1": "上海-软件产品支持部",
"9,0": "上海"
}
},
"convert_config": [
{
"field": "user.sex",
"type": "select",
"config": {
"dataType": "dictionary",
"options": [],
"dictionaryType": "963255a34ea64a2584c5d1ba269c1fe6",
"propsValue": "enCode",
"format": "yyyy-MM-dd",
"precision": 0,
"thousands": false
}
}
]
}
@@ -0,0 +1,592 @@
{
"name": "jnpf_db_user_list",
"comment": "JNPF DB 真实样例: 人员花名册(列表) (version 623183857306304837)",
"jnpf_version_id": "623183857306304837",
"snapshot": {
"id": "PlhIEz",
"sheetOrder": [
"E-ZBgdonv3JP-AKiPx-Dz"
],
"sheets": {
"E-ZBgdonv3JP-AKiPx-Dz": {
"id": "E-ZBgdonv3JP-AKiPx-Dz",
"cellData": {
"0": {
"0": {
"v": "人员花名册",
"s": "brTn0f",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
},
"t": 1
},
"1": {
"s": "O-e1uN"
},
"2": {
"s": "O-e1uN"
},
"3": {
"s": "O-e1uN"
},
"4": {
"s": "O-e1uN"
},
"5": {
"s": "quTX_s"
},
"6": {
"s": "w3oh7s"
}
},
"1": {
"0": {
"v": "组织",
"t": 1,
"s": "ZeHsQI"
},
"1": {
"v": "部门",
"t": 1,
"s": "ZeHsQI"
},
"2": {
"v": "学历",
"t": 1,
"s": "ZeHsQI"
},
"3": {
"v": "性别",
"t": 1,
"s": "ZeHsQI"
},
"4": {
"v": "姓名",
"t": 1,
"s": "ZeHsQI"
},
"5": {
"v": "薪资",
"t": 1,
"s": "ZeHsQI"
},
"6": {
"s": "w3oh7s"
}
},
"2": {
"0": {
"v": "${user.orgname}",
"t": 1,
"s": "VGNL-Q",
"custom": {
"field": "user.orgname",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"1": {
"v": "${user.depName}",
"t": 1,
"s": "l8Zma3",
"custom": {
"field": "user.depName",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"2": {
"v": "${user.education}",
"t": 1,
"s": "wtFaaw",
"custom": {
"field": "user.education",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"3": {
"v": "${user.sex}",
"t": 1,
"s": "FG5eVb",
"custom": {
"field": "user.sex",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"4": {
"v": "${user.username}",
"t": 1,
"s": "VGNL-Q",
"custom": {
"field": "user.username",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"5": {
"v": "${user.salary}",
"t": 1,
"s": "o-8LER",
"custom": {
"field": "user.salary",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"6": {
"s": "w3oh7s"
}
},
"3": {
"0": {
"v": "合计:",
"t": 1,
"s": "2so-W9"
},
"1": {
"s": "h1agbN"
},
"2": {
"s": "h1agbN"
},
"3": {
"s": "h1agbN"
},
"4": {
"v": "${user.username}",
"t": 1,
"s": "7k_VtD",
"custom": {
"field": "user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"5": {
"s": "2dGCXz",
"f": "=SUM(F3)",
"v": 0,
"t": 2
},
"6": {
"s": "w3oh7s"
}
},
"4": {
"0": {
"s": "w3oh7s"
},
"1": {
"s": "w3oh7s"
},
"2": {
"s": "w3oh7s"
},
"3": {
"s": "w3oh7s"
},
"4": {
"s": "w3oh7s"
},
"5": {
"s": "w3oh7s"
}
},
"5": {
"4": {
"v": "制表日期:",
"t": 1,
"s": "etLyRt"
},
"5": {
"f": "=NOW()",
"v": 45848.65981481481,
"t": 2,
"s": "qWukzd"
}
}
},
"mergeData": [
{
"startRow": 0,
"endRow": 0,
"startColumn": 0,
"endColumn": 5
},
{
"startRow": 3,
"endRow": 3,
"startColumn": 0,
"endColumn": 3
}
]
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "0",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
},
{
"col": "0",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.orgname",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "1",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.depName",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "2",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.education",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "3",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.sex",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "4",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.username",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "5",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.salary",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "4",
"row": "3",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
}
],
"floatEcharts": {},
"cellEcharts": {},
"floatImages": {}
},
"datasets": {
"user": [
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "博士后",
"sex": "1",
"username": "曦晨",
"salary": 2410.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "昊明",
"salary": 3639.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "昊硕",
"salary": 2101.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "欧阳",
"salary": 5863.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "高中",
"sex": "2",
"username": "王忠亮",
"salary": 6128.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "吴忠民",
"salary": 3839.0
},
{
"orgname": "上海",
"depName": "上海-软件产品支持部",
"education": "博士",
"sex": "2",
"username": "张秀恩",
"salary": 3943.0
},
{
"orgname": "上海",
"depName": "上海-软件产品支持部",
"education": "本科",
"sex": "1",
"username": "姜磊",
"salary": 1474.0
}
]
},
"params": {},
"expect": {
"E-ZBgdonv3JP-AKiPx-Dz": {
"2,0": "广东",
"2,1": "深圳-软件技术支持部",
"2,2": "博士后",
"2,3": "1",
"2,4": "曦晨",
"2,5": "2410.0",
"3,0": "广东",
"3,1": "深圳-软件技术支持部",
"3,2": "本科",
"3,3": "1",
"3,4": "8",
"3,5": "3639.0",
"4,0": "广东",
"4,1": "深圳-软件技术支持部",
"4,2": "本科",
"4,3": "1",
"4,4": "昊硕",
"4,5": "2101.0",
"5,4": "欧阳",
"5,5": "5863.0",
"5,3": "1",
"5,2": "本科",
"5,1": "深圳-软件技术支持部",
"5,0": "广东",
"6,5": "6128.0",
"6,4": "王忠亮",
"6,3": "2",
"6,2": "高中",
"6,1": "深圳-软件技术支持部",
"6,0": "广东",
"7,5": "3839.0",
"7,4": "吴忠民",
"7,3": "1",
"7,2": "本科",
"7,1": "深圳-软件技术支持部",
"7,0": "广东",
"8,5": "3943.0",
"8,4": "张秀恩",
"8,3": "2",
"8,2": "博士",
"8,1": "上海-软件产品支持部",
"8,0": "上海",
"9,5": "1474.0",
"9,4": "姜磊",
"9,3": "1",
"9,2": "本科",
"9,1": "上海-软件产品支持部",
"9,0": "上海"
}
},
"convert_config": [
{
"field": "user.sex",
"type": "select",
"config": {
"dataType": "dictionary",
"options": [],
"dictionaryType": "963255a34ea64a2584c5d1ba269c1fe6",
"propsValue": "enCode",
"format": "yyyy-MM-dd",
"precision": 0,
"thousands": false
}
}
]
}
@@ -0,0 +1,295 @@
{
"name": "jnpf_db_user_matrix",
"comment": "JNPF DB 真实样例: 人员花名册(行列) (version 623204233562292805)",
"jnpf_version_id": "623204233562292805",
"snapshot": {
"id": "GVaLEc",
"sheetOrder": [
"VkjbtPpyX8TggOO4aHSuO"
],
"sheets": {
"VkjbtPpyX8TggOO4aHSuO": {
"id": "VkjbtPpyX8TggOO4aHSuO",
"cellData": {
"0": {
"0": {
"v": "人员花名册",
"s": "7zkWyq",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
},
"t": 1
}
},
"1": {
"0": {
"v": "${report_user.organizationName}",
"t": 1,
"s": "oLh33o",
"custom": {
"field": "report_user.organizationName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "landscape",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
}
},
"2": {
"0": {
"v": "${report_user.departmentName}",
"t": 1,
"s": "pe2oi6",
"custom": {
"field": "report_user.departmentName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "landscape",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
}
},
"3": {
"0": {
"v": "${report_user.username}",
"t": 1,
"s": "LNCLmO",
"custom": {
"field": "report_user.username",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
}
},
"4": {
"0": {
"v": "${report_user.username}",
"t": 1,
"s": "17yxoW",
"custom": {
"field": "report_user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "custom",
"topParentCellCustomRowName": "A",
"topParentCellCustomColName": "3",
"type": "dataSource",
"displayType": "default"
}
}
}
}
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "0",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
},
{
"col": "0",
"row": "1",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"type": "dataSource",
"custom": {
"field": "report_user.organizationName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "landscape",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "0",
"row": "2",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"type": "dataSource",
"custom": {
"field": "report_user.departmentName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "landscape",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "0",
"row": "3",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"type": "dataSource",
"custom": {
"field": "report_user.username",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "0",
"row": "4",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"type": "dataSource",
"custom": {
"field": "report_user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "custom",
"topParentCellCustomRowName": "A",
"topParentCellCustomColName": "3",
"type": "dataSource",
"displayType": "default"
}
}
],
"floatEcharts": {},
"cellEcharts": {},
"floatImages": {}
},
"datasets": {
"report_user": [
{
"username": "曦晨",
"education": "博士后",
"sex": "1",
"salary": 2410.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "昊明",
"education": "本科",
"sex": "1",
"salary": 3639.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "昊硕",
"education": "本科",
"sex": "1",
"salary": 2101.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "欧阳",
"education": "本科",
"sex": "1",
"salary": 5863.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "王忠亮",
"education": "高中",
"sex": "2",
"salary": 6128.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "吴忠民",
"education": "本科",
"sex": "1",
"salary": 3839.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
}
]
},
"params": {},
"expect": {
"VkjbtPpyX8TggOO4aHSuO": {
"1,0": "广东",
"2,0": "深圳-软件技术支持部",
"3,0": "曦晨",
"4,0": "曦晨",
"5,0": "昊明",
"6,0": "昊硕",
"7,0": "欧阳",
"8,0": "王忠亮",
"9,0": "吴忠民"
}
}
}
@@ -0,0 +1,49 @@
{
"name": "jnpf_export_list_portrait",
"comment": "真实 JNPF 导出字段风格:字符串行列、fillDirection、polymerizationType",
"snapshot": {
"sheets": {
"E-ZBgdonv3JP-AKiPx-Dz": {
"id": "E-ZBgdonv3JP-AKiPx-Dz",
"cellData": {
"2": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.name",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
}
]
},
"datasets": {
"user": [
{ "name": "Alice" },
{ "name": "Bob" }
]
},
"params": {},
"expect": {
"E-ZBgdonv3JP-AKiPx-Dz": {
"2,0": "Alice",
"3,0": "Bob"
}
}
}
@@ -0,0 +1,60 @@
{
"name": "jnpf_header_merge_list",
"source": "jnpf",
"comment": "表头 merge + 列表扩展(merge 行在扩展区上方保持不变)",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"mergeData": [
{ "startRow": 0, "endRow": 0, "startColumn": 0, "endColumn": 1 }
],
"cellData": {
"0": { "0": { "v": "销售明细" }, "1": { "v": "" } },
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "items",
"field": "name",
"polymerizationType": "1",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"dataSetName": "items",
"field": "qty",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"datasets": {
"items": [{ "name": "A", "qty": 1 }, { "name": "B", "qty": 2 }]
},
"params": {},
"expect": {
"sheet1": {
"0,0": "销售明细",
"1,0": "A",
"2,0": "B",
"1,1": "1",
"2,1": "2"
}
}
}
@@ -0,0 +1,66 @@
{
"name": "jnpf_parent_group_down",
"comment": "左父格默认:分组列 + 列表子列按父格 dataList 扩展",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "order.category",
"dataSetName": "order",
"polymerizationType": "2",
"fillDirection": "portrait",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default"
}
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "order.product",
"dataSetName": "order",
"polymerizationType": "1",
"fillDirection": "portrait",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default"
}
}
]
},
"datasets": {
"order": [
{ "category": "A", "product": "p1" },
{ "category": "A", "product": "p2" },
{ "category": "B", "product": "p3" }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "A",
"2,0": "A",
"3,0": "B",
"1,1": "p1",
"2,1": "p2",
"3,1": "p3"
}
}
}
@@ -0,0 +1,43 @@
{
"name": "jnpf_poly_summary_sum",
"comment": "polymerizationType=3 汇总格,summaryType=sum",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": { "0": { "1": { "v": "" } } }
}
}
},
"cells": {
"cells": [
{
"col": "1",
"row": "0",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "order.amount",
"dataSetName": "order",
"polymerizationType": "3",
"summaryType": "sum",
"leftParentCellType": "none",
"topParentCellType": "none"
}
}
]
},
"datasets": {
"order": [
{ "amount": 10 },
{ "amount": 20 },
{ "amount": 5 }
]
},
"params": {},
"expect": {
"sheet1": {
"0,1": "35.0"
}
}
}
@@ -0,0 +1,51 @@
{
"name": "jnpf_prod_convert_date",
"source": "jnpf",
"comment": "convertConfig date 格式转换后列表扩展",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "order",
"field": "createdAt",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"convert_config": [
{
"field": "order.createdAt",
"type": "date",
"config": { "format": "yyyy-MM-dd" }
}
],
"datasets": {
"order": [
{ "createdAt": "2026-05-20T10:00:00" },
{ "createdAt": "2026-05-21T15:30:00" }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "2026-05-20",
"2,0": "2026-05-21"
}
}
}
@@ -0,0 +1,48 @@
{
"name": "jnpf_prod_convert_number",
"source": "jnpf",
"comment": "convertConfig number 千分位与精度",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "sales",
"field": "amount",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"convert_config": [
{
"field": "sales.amount",
"type": "number",
"config": { "precision": 2, "thousands": true }
}
],
"datasets": {
"sales": [{ "amount": 1234.5 }, { "amount": 1000000 }]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "1,234.50",
"2,0": "1,000,000.00"
}
}
}
@@ -0,0 +1,69 @@
{
"name": "jnpf_prod_convert_select",
"comment": "convertConfig select 枚举转换后再列表扩展",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "order",
"field": "status",
"polymerizationType": "1",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"dataSetName": "order",
"field": "amount",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"convert_config": [
{
"field": "order.status",
"type": "select",
"config": {
"options": [
{ "id": 1, "fullName": "待审" },
{ "id": 2, "fullName": "完成" }
]
}
}
],
"datasets": {
"order": [
{ "status": 1, "amount": 100 },
{ "status": 2, "amount": 200 }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "待审",
"2,0": "完成",
"1,1": "100",
"2,1": "200"
}
}
}
@@ -0,0 +1,74 @@
{
"name": "jnpf_prod_convert_user_inline",
"source": "jnpf",
"comment": "convertConfig user 类型(inline names 映射,对标 DataSetSwapUtil",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "task",
"field": "ownerId",
"polymerizationType": "1",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"dataSetName": "task",
"field": "deptId",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"convert_config": [
{
"field": "task.ownerId",
"type": "user",
"config": {
"names": { "u1": "张三", "u2": "李四" }
}
},
{
"field": "task.deptId",
"type": "department",
"config": {
"names": { "d1": "销售部", "d2": "研发部" }
}
}
],
"datasets": {
"task": [
{ "ownerId": "u1", "deptId": "d1" },
{ "ownerId": "u2", "deptId": "d2" }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "张三",
"2,0": "李四",
"1,1": "销售部",
"2,1": "研发部"
}
}
}
@@ -0,0 +1,48 @@
{
"name": "jnpf_prod_field_mapping",
"comment": "field_mapping 重命名后绑定字段可正确列表扩展",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "order",
"field": "product",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"field_mapping": {
"order": {
"product_code": "product"
}
},
"datasets": {
"order": [
{ "product_code": "P1" },
{ "product_code": "P2" }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "P1",
"2,0": "P2"
}
}
}
@@ -0,0 +1,80 @@
{
"name": "jnpf_export_style_mvp",
"comment": "对齐 JNPF 导出 cells 结构:parameter #{x}、dataSource expand、全表占位",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {
"0": { "v": "Report: #{title}" },
"1": { "v": "" }
},
"1": {
"0": { "v": "" },
"1": { "v": "" }
}
}
}
}
},
"cells": {
"cells": [
{
"type": "parameter",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {
"type": "parameter",
"value": "#{dept}",
"field": "dept"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"type": "dataSource",
"dataSetName": "order",
"field": "product",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"type": "dataSource",
"dataSetName": "order",
"field": "amount",
"expand": "down"
}
}
]
},
"datasets": {
"order": [
{ "product": "P1", "amount": 100 },
{ "product": "P2", "amount": 200 }
]
},
"params": {
"title": "Sales",
"dept": "East"
},
"expect": {
"sheet1": {
"0,0": "Report: Sales",
"0,1": "East",
"1,0": "P1",
"2,0": "P2",
"1,1": "100",
"2,1": "200"
}
}
}
@@ -0,0 +1,49 @@
{
"name": "jnpf_system_params",
"source": "jnpf",
"comment": "系统参数 + 查询参数合并(parameter_resolver",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "报表人: #{currentUserName}" }, "1": { "v": "" } },
"1": { "0": { "v": "日期: #{currentDate}" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "parameter",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": { "value": "#{deptName}" }
},
{
"type": "parameter",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": { "value": "#{keyword}" }
}
]
},
"datasets": {},
"params": {
"currentUserName": "Admin",
"currentDate": "2026-05-22",
"deptName": "总部",
"keyword": "测试"
},
"expect": {
"sheet1": {
"0,0": "报表人: Admin",
"0,1": "总部",
"1,0": "日期: 2026-05-22",
"1,1": "测试"
}
}
}
@@ -0,0 +1,83 @@
{
"name": "jnpf_top_parent_same_row",
"comment": "上父格:年(分组,行0) → 月(列表,行0,上父年) → 金额(列表,行0,左父月)",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "" }, "1": { "v": "" }, "2": { "v": "" } },
"1": { "0": { "v": "" }, "1": { "v": "" }, "2": { "v": "" } },
"2": { "0": { "v": "" }, "1": { "v": "" }, "2": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 0,
"custom": {
"dataSetName": "sales",
"field": "year",
"polymerizationType": "2",
"expand": "down",
"topParentCellType": "default",
"leftParentCellType": "default"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {
"dataSetName": "sales",
"field": "month",
"polymerizationType": "1",
"expand": "down",
"topParentCellType": "default",
"leftParentCellType": "default"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 2,
"custom": {
"dataSetName": "sales",
"field": "amount",
"polymerizationType": "1",
"expand": "down",
"topParentCellType": "default",
"leftParentCellType": "default"
}
}
]
},
"datasets": {
"sales": [
{ "year": 2023, "month": 1, "amount": 10 },
{ "year": 2023, "month": 2, "amount": 20 },
{ "year": 2024, "month": 1, "amount": 30 }
]
},
"params": {},
"expect": {
"sheet1": {
"0,0": "2023",
"1,0": "2023",
"2,0": "2024",
"0,1": "1",
"1,1": "2",
"2,1": "1",
"0,2": "10",
"1,2": "20",
"2,2": "30"
}
}
}
@@ -0,0 +1,63 @@
{
"name": "list_expand_down",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {
"0": { "v": "Name" },
"1": { "v": "" }
},
"1": {
"0": { "v": "" },
"1": { "v": "" }
}
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "name",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "qty",
"expand": "down"
}
}
]
},
"datasets": {
"items": [
{ "name": "Apple", "qty": 1 },
{ "name": "Banana", "qty": 2 }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "Apple",
"2,0": "Banana",
"1,1": "1",
"2,1": "2"
}
}
}
@@ -0,0 +1,60 @@
{
"name": "multi_col_down_align",
"comment": "同行多列向下扩展:列数不同,按 max_len 对齐",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "name",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "qty",
"expand": "down"
}
}
]
},
"datasets": {
"items": [
{ "name": "A", "qty": 1 },
{ "name": "B", "qty": 2 },
{ "name": "C", "qty": 3 }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "A",
"2,0": "B",
"3,0": "C",
"1,1": "1",
"2,1": "2",
"3,1": "3"
}
}
}
@@ -0,0 +1,32 @@
{
"name": "param_and_placeholder",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "Hello #{userName}" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "parameter",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": { "value": "#{dept}" }
}
]
},
"datasets": {},
"params": { "userName": "Alice", "dept": "Sales" },
"expect": {
"sheet1": {
"0,0": "Hello Alice",
"0,1": "Sales"
}
}
}
@@ -0,0 +1,45 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""解析 Excel 为 Univer 单元格网格数据"""
import io
from typing import Any, Dict, List
from openpyxl import load_workbook
def parse_excel_to_grid(file_content: bytes) -> Dict[str, Any]:
"""将 xlsx/xls 解析为设计器可写入的网格结构"""
wb = load_workbook(io.BytesIO(file_content), read_only=True, data_only=True)
ws = wb.active
if ws is None:
return {"rowsCount": 0, "colsCount": 0, "data": []}
rows_data: List[List[Dict[str, Any]]] = []
max_col = 0
for row in ws.iter_rows(values_only=True):
row_cells = []
for cell in row:
val = cell
if val is None:
row_cells.append({"v": ""})
else:
row_cells.append({"v": val})
if any(c.get("v") not in ("", None) for c in row_cells):
rows_data.append(row_cells)
max_col = max(max_col, len(row_cells))
# 去除尾部全空行已在上面处理;补齐列宽
for row in rows_data:
while len(row) < max_col:
row.append({"v": ""})
wb.close()
rows_count = len(rows_data)
cols_count = max_col
return {
"rowsCount": rows_count,
"colsCount": cols_count,
"data": rows_data,
}
@@ -0,0 +1,83 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""扩展后 mergeData 行偏移重算"""
from __future__ import annotations
import copy
from typing import Any, Dict, List, Tuple
def _int(v: Any, default: int = 0) -> int:
try:
return int(v)
except (TypeError, ValueError):
return default
def compute_row_insertions(
before: Dict[str, Any],
after: Dict[str, Any],
) -> Dict[str, List[Tuple[int, int]]]:
"""
对比扩展前后 cellData 行数,推断每个 sheet 在各行插入的额外行数。
返回 sheet_id -> [(anchor_row, rows_added), ...](按 anchor_row 升序)。
"""
insertions: Dict[str, List[Tuple[int, int]]] = {}
before_sheets = (before or {}).get("sheets") or {}
after_sheets = (after or {}).get("sheets") or {}
for sheet_id, after_sheet in after_sheets.items():
before_sheet = before_sheets.get(sheet_id) or {}
before_rows = sorted(int(k) for k in (before_sheet.get("cellData") or {}).keys())
after_rows = sorted(int(k) for k in (after_sheet.get("cellData") or {}).keys())
if len(after_rows) <= len(before_rows):
continue
added = len(after_rows) - len(before_rows)
# 默认在最后一个原数据行之后插入(legacy down band 常见模式)
anchor = before_rows[-1] if before_rows else 0
insertions.setdefault(sheet_id, []).append((anchor, added))
return insertions
def recalculate_merge_data(
snapshot: Dict[str, Any],
row_insertions: Dict[str, List[Tuple[int, int]]],
) -> Dict[str, Any]:
"""按行插入量下移 merge 区域(仅处理 startRow/endRow)。"""
if not row_insertions:
return snapshot
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
for sheet_id, inserts in row_insertions.items():
sheet = sheets.get(sheet_id)
if not sheet:
continue
merge_list = sheet.get("mergeData") or []
if not merge_list:
continue
sorted_inserts = sorted(inserts, key=lambda x: x[0])
new_merges: List[Any] = []
for region in merge_list:
if not isinstance(region, dict):
new_merges.append(region)
continue
start_row = _int(region.get("startRow"), 0)
end_row = _int(region.get("endRow"), start_row)
shift = 0
for anchor, delta in sorted_inserts:
if start_row > anchor:
shift += delta
if shift:
region = {**region, "startRow": start_row + shift, "endRow": end_row + shift}
new_merges.append(region)
sheet["mergeData"] = new_merges
return result
def apply_merge_recalc_after_expand(
original_snapshot: Dict[str, Any],
expanded_snapshot: Dict[str, Any],
) -> Dict[str, Any]:
insertions = compute_row_insertions(original_snapshot, expanded_snapshot)
return recalculate_merge_data(expanded_snapshot, insertions)
@@ -0,0 +1,55 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""预览参数解析(对标 JNPF parameterData 子集)"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, Optional
def _today_str() -> str:
return date.today().isoformat()
def _now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def build_system_params(
*,
user_id: Optional[str] = None,
user_name: Optional[str] = None,
dept_id: Optional[str] = None,
dept_name: Optional[str] = None,
tenant_id: Optional[str] = None,
) -> Dict[str, Any]:
"""系统变量,与 JNPF parameterData 常用键对齐。"""
params: Dict[str, Any] = {
"currentDate": _today_str(),
"currentTime": _now_str(),
"currentUserId": user_id or "",
"currentUserName": user_name or "",
"currentDeptId": dept_id or "",
"currentDeptName": dept_name or "",
"currentTenantId": tenant_id or "",
}
# 兼容 #{userName} / #{deptName} 简写
if user_name:
params.setdefault("userName", user_name)
if dept_name:
params.setdefault("deptName", dept_name)
return params
def merge_preview_params(
query_defaults: Optional[Dict[str, Any]] = None,
request_params: Optional[Dict[str, Any]] = None,
system_params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""合并顺序:query 默认 → 系统变量 → 请求参数(请求优先)。"""
merged: Dict[str, Any] = {}
for src in (query_defaults or {}, system_params or {}, request_params or {}):
for k, v in src.items():
if v is not None and v != "":
merged[k] = v
return merged
@@ -0,0 +1,221 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""解析 JNPF 风格左/上父格(none / default / custom)。"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
CellKey = Tuple[str, int, int]
def col_letter_to_index(letters: str) -> int:
s = (letters or "A").upper()
n = 0
for ch in s:
if not ("A" <= ch <= "Z"):
continue
n = n * 26 + (ord(ch) - 64)
return max(0, n - 1)
def col_index_to_letter(col: int) -> str:
n = col
s = ""
while n >= 0:
s = chr(65 + (n % 26)) + s
n = n // 26 - 1
return s or "A"
def _int_coord(v: Any, default: int = 0) -> int:
try:
return int(v)
except (TypeError, ValueError):
return default
def cell_key(cell: Dict[str, Any]) -> CellKey:
return (
str(cell.get("sheet") or "sheet1"),
_int_coord(cell.get("row"), 0),
_int_coord(cell.get("col"), 0),
)
def _is_data_source(cell: Dict[str, Any]) -> bool:
return cell.get("type") == "dataSource"
def _index_data_sources(cells: List[Dict[str, Any]]) -> Tuple[Dict[CellKey, Dict[str, Any]], Dict[str, List[Dict[str, Any]]]]:
by_pos: Dict[CellKey, Dict[str, Any]] = {}
by_sheet: Dict[str, List[Dict[str, Any]]] = {}
for c in cells:
if not _is_data_source(c):
continue
k = cell_key(c)
by_pos[k] = c
by_sheet.setdefault(k[0], []).append(c)
return by_pos, by_sheet
def resolve_parent(
cell: Dict[str, Any],
*,
is_left: bool,
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""返回父格 dataSource 元数据;none 为 Nonedefault 为最近左/上数据源格。"""
custom = cell.get("custom") or {}
ptype = (
custom.get("leftParentCellType") if is_left else custom.get("topParentCellType")
) or "default"
sheet, row, col = cell_key(cell)
if ptype == "none":
return None
if ptype == "custom":
if is_left:
letters = custom.get("leftParentCellCustomRowName") or "A"
row_num = custom.get("leftParentCellCustomColName")
else:
letters = custom.get("topParentCellCustomRowName") or "A"
row_num = custom.get("topParentCellCustomColName")
if row_num is None:
return None
try:
parent_row = int(row_num) - 1
except (TypeError, ValueError):
return None
parent_col = col_letter_to_index(str(letters))
return by_pos.get((sheet, parent_row, parent_col))
# default:同行向左 / 同列向上找最近 dataSource
candidates = by_sheet.get(sheet) or []
best: Optional[Dict[str, Any]] = None
if is_left:
for c in candidates:
cr, cc = _int_coord(c.get("row")), _int_coord(c.get("col"))
if cr == row and cc < col:
if best is None or _int_coord(best.get("col")) < cc:
best = c
else:
for c in candidates:
cr, cc = _int_coord(c.get("row")), _int_coord(c.get("col"))
if cc == col and cr < row:
if best is None or _int_coord(best.get("row")) < cr:
best = c
return best
def resolve_parents(
cell: Dict[str, Any],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
left = resolve_parent(cell, is_left=True, by_pos=by_pos, by_sheet=by_sheet)
top = resolve_parent(cell, is_left=False, by_pos=by_pos, by_sheet=by_sheet)
custom = cell.get("custom") or {}
ds_name = _dataset_name(cell)
# 汇总格且无扩展时,JNPF 在 none+none 时清除父格
poly = str(custom.get("polymerizationType") or "1")
if poly == "3" and custom.get("leftParentCellType") == "none" and custom.get("topParentCellType") == "none":
return None, None
if left and _dataset_name(left) != ds_name:
left = None
if top and _dataset_name(top) != ds_name:
top = None
return left, top
def _dataset_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
name = str(
custom.get("dataSetName") or custom.get("dataSet") or custom.get("alias") or ""
)
if name:
return name
field = str(custom.get("field") or custom.get("bindField") or "")
if "." in field:
return field.split(".", 1)[0]
return ""
def filter_rows_by_parents(
rows: List[Any],
cell: Dict[str, Any],
*,
left_parent: Optional[Dict[str, Any]],
top_parent: Optional[Dict[str, Any]],
left_bind: Optional[List[Dict[str, Any]]],
top_bind: Optional[List[Dict[str, Any]]],
) -> List[Dict[str, Any]]:
"""对齐 JNPF DataUtils.fetchData:按父格 bindData 切片过滤。"""
data = [r if isinstance(r, dict) else {} for r in rows]
if not left_bind and not top_bind:
return data
left_rows = left_bind
top_rows = top_bind
if left_rows is None and top_rows is not None:
return top_rows
if top_rows is None and left_rows is not None:
return left_rows
if left_rows is None or top_rows is None:
return data
left_field = _bind_field(left_parent) if left_parent else ""
top_field = _bind_field(top_parent) if top_parent else ""
left_val = _first_field_value(left_rows, left_field) if left_rows else None
top_val = _first_field_value(top_rows, top_field) if top_rows else None
from_top: List[Dict[str, Any]] = []
for row in top_rows:
if left_field and _get_nested_value(row, left_field) == left_val:
from_top.append(row)
from_left: List[Dict[str, Any]] = []
for row in left_rows:
if top_field and _get_nested_value(row, top_field) == top_val:
from_left.append(row)
return from_top if len(from_top) <= len(from_left) else from_left
def _bind_field(parent: Optional[Dict[str, Any]]) -> str:
if not parent:
return ""
custom = parent.get("custom") or {}
field = custom.get("field") or custom.get("bindField") or ""
if "." in field:
return field.split(".", 1)[1]
return field
def resolve_field_path(field: str, dataset_alias: str = "") -> str:
"""JNPF 字段常为 alias.prop,行数据一般为扁平 prop 或嵌套 prop。"""
field = str(field or "")
alias = str(dataset_alias or "")
if alias and field.startswith(f"{alias}."):
return field[len(alias) + 1 :]
return field
def _get_nested_value(row: Dict[str, Any], field: str) -> Any:
if not field:
return None
if field in row:
return row[field]
parts = field.split(".")
cur: Any = row
for p in parts:
if isinstance(cur, dict) and p in cur:
cur = cur[p]
else:
return None
return cur
def _first_field_value(rows: List[Dict[str, Any]], field: str) -> Any:
if not rows or not field:
return None
return _get_nested_value(rows[0], field)
@@ -0,0 +1,149 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据源聚合(polymerizationType
1 / select — 列表
2 / group — 分组
3 / summary — 汇总(summaryType: sum|avg|max|min|count
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Dict, List, Optional
from online_dev.report_manager.engine.parent_cells import _get_nested_value, resolve_field_path
@dataclass
class BindData:
value: Any
data_list: List[Dict[str, Any]]
def _field_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
return str(custom.get("field") or custom.get("bindField") or "")
def _poly_type(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
raw = custom.get("polymerizationType")
if raw is None:
return "1"
return str(raw)
def _summary_type(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
return str(custom.get("summaryType") or "sum").lower()
def _group_type(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
return str(custom.get("groupType") or "default")
def _dataset_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
name = str(
custom.get("dataSetName") or custom.get("dataSet") or custom.get("alias") or ""
)
if name:
return name
field = str(custom.get("field") or custom.get("bindField") or "")
if "." in field:
return field.split(".", 1)[0]
return ""
def build_bind_list(cell: Dict[str, Any], rows: List[Dict[str, Any]]) -> List[BindData]:
poly = _poly_type(cell)
field = resolve_field_path(_field_name(cell), _dataset_name(cell))
prop = field.split(".")[-1] if "." in field else field
if poly == "3":
return [_summary_bind(cell, rows, field)]
if poly == "2":
return _group_bind(rows, prop, _group_type(cell))
return _list_bind(rows, field)
def _list_bind(rows: List[Dict[str, Any]], field: str) -> List[BindData]:
out: List[BindData] = []
for row in rows:
val = _get_nested_value(row, field)
out.append(BindData(value=val, data_list=[row]))
if not out:
out.append(BindData(value="", data_list=[{}]))
return out
def _group_bind(
rows: List[Dict[str, Any]], prop: str, group_type: str
) -> List[BindData]:
if group_type == "adjacent":
return _group_adjacent(rows, prop)
ordered: Dict[Any, List[Dict[str, Any]]] = {}
for row in rows:
key = _get_nested_value(row, prop)
if key is None:
key = ""
ordered.setdefault(key, []).append(row)
return [
BindData(value=k, data_list=ordered[k]) for k in ordered.keys()
]
def _group_adjacent(rows: List[Dict[str, Any]], prop: str) -> List[BindData]:
out: List[BindData] = []
bucket: List[Dict[str, Any]] = []
last_key: Any = object()
for row in rows:
key = _get_nested_value(row, prop)
if key is None:
key = ""
if bucket and key != last_key:
out.append(BindData(value=last_key, data_list=bucket))
bucket = []
bucket.append(row)
last_key = key
if bucket:
out.append(BindData(value=last_key, data_list=bucket))
if not out:
out.append(BindData(value="", data_list=[{}]))
return out
def _summary_bind(cell: Dict[str, Any], rows: List[Dict[str, Any]], field: str) -> BindData:
st = _summary_type(cell)
nums: List[Decimal] = []
for row in rows:
v = _get_nested_value(row, field)
try:
nums.append(Decimal(str(v)))
except Exception:
pass
if st == "count":
val: Any = len(rows)
elif st == "avg":
val = float(sum(nums) / len(nums)) if nums else 0
elif st == "max":
val = float(max(nums)) if nums else 0
elif st == "min":
val = float(min(nums)) if nums else 0
else:
val = float(sum(nums)) if nums else 0
return BindData(value=val, data_list=rows)
def expand_span(bind: BindData, cell: Dict[str, Any]) -> int:
"""该 bind 在向下/向右扩展时占用的行/列数。"""
poly = _poly_type(cell)
if poly == "3":
return 1
if poly == "2":
return 1
return max(1, len(bind.data_list))
@@ -0,0 +1,43 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""预览/导出非功能性护栏(阶段 G"""
from __future__ import annotations
from typing import Any, Dict, List
# 与 dataset_bridge.fetch_all max_rows 对齐
DEFAULT_MAX_DATASET_ROWS = 50000
WARN_DATASET_ROWS = 10000
WARN_SNAPSHOT_CELL_COUNT = 200_000
def estimate_snapshot_cell_count(snapshot: Dict[str, Any]) -> int:
total = 0
for sheet in (snapshot.get("sheets") or {}).values():
if not isinstance(sheet, dict):
continue
for row in (sheet.get("cellData") or {}).values():
if isinstance(row, dict):
total += len(row)
return total
def collect_preview_warnings(
*,
datasets: Dict[str, List[Any]],
snapshot: Dict[str, Any],
max_rows: int = DEFAULT_MAX_DATASET_ROWS,
warn_rows: int = WARN_DATASET_ROWS,
) -> List[str]:
"""返回 warning 码列表,供前端 i18n 映射。"""
warnings: List[str] = []
for alias, rows in (datasets or {}).items():
count = len(rows) if isinstance(rows, list) else 0
if count >= max_rows:
warnings.append(f"dataset_row_limit:{alias}")
elif count >= warn_rows:
warnings.append(f"dataset_row_warn:{alias}")
cell_count = estimate_snapshot_cell_count(snapshot or {})
if cell_count >= WARN_SNAPSHOT_CELL_COUNT:
warnings.append("snapshot_large")
return warnings
@@ -0,0 +1,91 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
预览 MVP:参数单元格替换(Phase 2)
完整 dataSource 扩展在 Phase 3 convert 引擎实现
"""
import copy
import re
from typing import Any, Dict, List
_PARAM_PATTERN = re.compile(r"#\{([^}]+)\}")
def _replace_params_in_value(value: Any, params: Dict[str, Any]) -> Any:
if not isinstance(value, str):
return value
def repl(m):
key = m.group(1).strip()
if key in params:
return str(params[key])
return m.group(0)
return _PARAM_PATTERN.sub(repl, value)
def apply_parameter_cells(
snapshot: Dict[str, Any],
cells_meta: Dict[str, Any],
params: Dict[str, Any],
) -> Dict[str, Any]:
"""将 parameter 类型绑定写入 snapshotMVP"""
result = copy.deepcopy(snapshot)
if not result or not cells_meta:
return result
cell_list = cells_meta.get("cells") or []
sheets = result.get("sheets") or {}
for cell in cell_list:
if cell.get("type") != "parameter":
continue
sheet_id = cell.get("sheet")
row = cell.get("row", 0)
col = cell.get("col", 0)
custom = cell.get("custom") or {}
text = custom.get("value") or custom.get("text") or ""
if isinstance(text, str):
text = _replace_params_in_value(text, params)
sheet = sheets.get(sheet_id)
if not sheet:
continue
cell_data = sheet.setdefault("cellData", {})
row_data = cell_data.setdefault(str(row), {})
cell_obj = row_data.setdefault(str(col), {})
cell_obj["v"] = text
if "custom" in cell_obj:
cell_obj["custom"] = {**cell_obj.get("custom", {}), "value": text}
return result
def apply_snapshot_placeholders(
snapshot: Dict[str, Any],
params: Dict[str, Any],
) -> Dict[str, Any]:
"""扫描 snapshot 所有单元格,将 v / custom 字符串中的 #{param} 替换为查询参数"""
if not snapshot or not params:
return snapshot or {}
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
for sheet in sheets.values():
if not isinstance(sheet, dict):
continue
cell_data = sheet.get("cellData") or {}
for row in cell_data.values():
if not isinstance(row, dict):
continue
for cell in row.values():
if not isinstance(cell, dict):
continue
v = cell.get("v")
if isinstance(v, str) and "#{" in v:
cell["v"] = _replace_params_in_value(v, params)
custom = cell.get("custom")
if isinstance(custom, dict):
for key, val in list(custom.items()):
if isinstance(val, str) and "#{" in val:
custom[key] = _replace_params_in_value(val, params)
cell["custom"] = custom
result["sheets"] = sheets
return result
@@ -0,0 +1,47 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""数据集排序规则(对齐 JNPF sortList"""
from typing import Any, Dict, List, Optional
def _field_name_from_vmodel(vmodel: str) -> str:
if not vmodel:
return ""
parts = vmodel.split(".", 1)
return parts[1] if len(parts) == 2 else vmodel
def get_sort_rules_for_alias(sort_list: List[Any], alias: str) -> List[Dict[str, Any]]:
rules: List[Dict[str, Any]] = []
for sheet_cfg in sort_list or []:
if not isinstance(sheet_cfg, dict):
continue
for item in sheet_cfg.get("sortList") or []:
if not isinstance(item, dict):
continue
vmodel = item.get("vModel") or item.get("field") or ""
ds_prefix = vmodel.split(".")[0] if "." in vmodel else ""
if ds_prefix == alias or vmodel.startswith(f"{alias}."):
rules.append(item)
return rules
def apply_sort_to_rows(
rows: List[Dict[str, Any]],
sort_rules: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
if not rows or not sort_rules:
return rows
result = list(rows)
for rule in sort_rules:
field = _field_name_from_vmodel(rule.get("vModel") or rule.get("field") or "")
if not field:
continue
reverse = (rule.get("type") or "asc").lower() == "desc"
def key_fn(row: Dict[str, Any], f: str = field) -> Any:
val = row.get(f)
return (val is None, val)
result.sort(key=key_fn, reverse=reverse)
return result
@@ -0,0 +1,33 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.chart_data import build_chart_data
def test_bar_chart_from_float_echarts():
cells = {
"floatEcharts": {
"draw1": {
"drawingId": "draw1",
"echartType": "bar",
"option": {
"classifyNameField": "sales.month",
"seriesNameField": "sales.region",
"seriesDataField": "sales.amount",
"summaryType": "sum",
},
},
},
}
datasets = {
"sales": [
{"month": "Jan", "region": "A", "amount": 10},
{"month": "Jan", "region": "B", "amount": 20},
{"month": "Feb", "region": "A", "amount": 15},
],
}
chart_data = build_chart_data(cells, datasets)
assert len(chart_data) == 1
field = chart_data[0]["field"]
assert "Jan" in field["classifyNameField"]
assert field["seriesNameField"]
assert field["seriesDataField"]
@@ -0,0 +1,29 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.convert import transform
def test_qrcode_param_replace():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {
"0": {
"v": "placeholder",
"custom": {
"type": "qrCode",
"field": "#{orderNo}",
"qrCodeOption": {"type": "static"},
},
}
}
},
}
}
}
filled = transform(snapshot, {"cells": []}, {}, {"orderNo": "ORD-001"})
cell = filled["sheets"]["sheet1"]["cellData"]["0"]["0"]
assert cell["v"] == "ORD-001"
assert cell["custom"]["field"] == "ORD-001"
@@ -0,0 +1,148 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.column_layout import apply_column_layout, parse_cell_range
def test_parse_range():
assert parse_cell_range("A2:D10") == (1, 9, 0, 3)
def test_col_split_two_columns():
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": {"0": {"v": "a"}},
"2": {"0": {"v": "b"}},
"3": {"0": {"v": "c"}},
"4": {"0": {"v": "d"}},
},
}
},
}
layout = [
{
"sheet": "sheet1",
"columnList": {
"columnState": True,
"columnStyle": "col",
"columnType": "2",
"rowCount": 2,
"columnData": "A2:A5",
},
}
]
out = apply_column_layout(snapshot, layout)
cells = out["sheets"]["sheet1"]["cellData"]
assert cells["1"]["0"]["v"] == "a"
assert cells["2"]["0"]["v"] == "b"
assert cells["1"]["1"]["v"] == "c"
assert cells["2"]["1"]["v"] == "d"
def test_row_split_two_rows():
"""A2:C2 三列横向,分栏成 2 行块:上行 A,B 下行 C"""
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": {
"0": {"v": "A"},
"1": {"v": "B"},
"2": {"v": "C"},
}
},
}
},
}
layout = [
{
"sheet": "sheet1",
"columnList": {
"columnState": True,
"columnStyle": "row",
"columnType": "2",
"colCount": 2,
"columnData": "A2:C2",
},
}
]
out = apply_column_layout(snapshot, layout)
cells = out["sheets"]["sheet1"]["cellData"]
assert cells["1"]["0"]["v"] == "A"
assert cells["1"]["1"]["v"] == "B"
assert cells["2"]["2"]["v"] == "C"
def test_col_split_type1_max_col():
"""5 行数据,超过 2 行分列 -> 3 栏"""
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
str(i): {"0": {"v": str(i)}} for i in range(1, 6)
},
}
},
}
layout = [
{
"sheet": "sheet1",
"columnList": {
"columnState": True,
"columnStyle": "col",
"columnType": "1",
"maxCol": 2,
"columnData": "A1:A5",
},
}
]
out = apply_column_layout(snapshot, layout)
cells = out["sheets"]["sheet1"]["cellData"]
assert cells["1"]["0"]["v"] == "1"
assert cells["2"]["0"]["v"] == "2"
assert cells["1"]["1"]["v"] == "3"
assert cells["2"]["1"]["v"] == "4"
assert cells["1"]["2"]["v"] == "5"
def test_col_split_fill_empty_rows():
"""3 行分 2 栏,第二栏仅 1 行数据时补空行"""
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": {"0": {"v": "a"}},
"2": {"0": {"v": "b"}},
"3": {"0": {"v": "c"}},
},
}
},
}
layout = [
{
"sheet": "sheet1",
"columnList": {
"columnState": True,
"columnStyle": "col",
"columnType": "2",
"rowCount": 2,
"columnData": "A1:A3",
"fillEmptyRows": True,
},
}
]
out = apply_column_layout(snapshot, layout)
cells = out["sheets"]["sheet1"]["cellData"]
assert cells["1"]["0"]["v"] == "a"
assert cells["2"]["0"]["v"] == "b"
assert cells["1"]["1"]["v"] == "c"
assert cells["2"]["1"]["v"] == ""
@@ -0,0 +1,119 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""convert 引擎单元测试(可直接 python -m 运行)"""
from online_dev.report_manager.engine.convert import transform
def test_parameter_and_list_down():
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"0": {"0": {"v": "Title"}}},
},
},
}
cells = {
"cells": [
{
"type": "parameter",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {"value": "#{userName}"},
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "users",
"field": "name",
"expand": "down",
},
},
],
}
datasets = {
"users": [{"name": "Alice"}, {"name": "Bob"}],
}
result = transform(snapshot, cells, datasets, {"userName": "Admin"})
cd = result["sheets"]["sheet1"]["cellData"]
assert cd["0"]["1"]["v"] == "Admin"
assert cd["1"]["0"]["v"] == "Alice"
assert cd["2"]["0"]["v"] == "Bob"
print("ok")
def test_list_right_expand():
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"0": {"0": {"v": "H"}}},
},
},
}
cells = {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {
"dataSetName": "items",
"field": "name",
"expand": "right",
},
},
],
}
datasets = {"items": [{"name": "A"}, {"name": "B"}]}
result = transform(snapshot, cells, datasets, {})
cd = result["sheets"]["sheet1"]["cellData"]
assert cd["0"]["1"]["v"] == "A"
assert cd["0"]["2"]["v"] == "B"
print("right ok")
def test_fill_empty_rows_after_list_down():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"1": {"0": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "items",
"field": "name",
"expand": "down",
"fillEmptyRows": True,
"fillEmptyNum": 1,
},
}
]
}
result = transform(
snapshot, cells, {"items": [{"name": "X"}]}, {}
)
cd = result["sheets"]["sheet1"]["cellData"]
assert cd["1"]["0"]["v"] == "X"
assert cd["2"]["0"]["v"] == ""
if __name__ == "__main__":
test_parameter_and_list_down()
test_list_right_expand()
@@ -0,0 +1,84 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.dataset_transform import (
apply_convert_rules,
apply_field_mapping,
transform_dataset_rows,
)
def test_field_mapping_rename():
rows = [{"product_code": "A", "qty": 1}]
out = apply_field_mapping(rows, {"product_code": "product"})
assert out[0]["product"] == "A"
assert "product_code" not in out[0]
def test_convert_select():
rows = [{"status": 1}]
rules = [
{
"field": "order.status",
"type": "select",
"config": {
"options": [
{"id": 1, "fullName": "启用"},
{"id": 2, "fullName": "停用"},
]
},
}
]
out = apply_convert_rules(rows, rules, alias="order")
assert out[0]["status"] == "启用"
def test_transform_chain():
rows = [{"code": "X", "state": "1"}]
out = transform_dataset_rows(
rows,
field_mapping={"code": "product"},
dataset_convert=[
{
"field": "state",
"type": "select",
"config": {"options": [{"id": "1", "fullName": "OK"}]},
}
],
)
assert out[0]["product"] == "X"
assert out[0]["state"] == "OK"
def test_convert_select_respects_alias():
rows = [{"status": 1}]
rules = [
{
"field": "order.status",
"type": "select",
"config": {"options": [{"id": 1, "fullName": "启用"}]},
}
]
out = apply_convert_rules(rows, rules, alias="other")
assert out[0]["status"] == 1
def test_convert_user_inline():
from online_dev.report_manager.engine.convert_lookup import ConvertLookupCache
rows = [{"ownerId": "u1"}]
cache = ConvertLookupCache()
out = apply_convert_rules(
rows,
[{"field": "ownerId", "type": "user", "config": {"names": {"u1": "张三"}}}],
lookup=cache,
)
assert out[0]["ownerId"] == "张三"
if __name__ == "__main__":
test_field_mapping_rename()
test_convert_select()
test_convert_user_inline()
test_convert_select_respects_alias()
test_transform_chain()
print("ok")
@@ -0,0 +1,248 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import base64
import importlib.util
from online_dev.report_manager.engine.export_excel import snapshot_to_xlsx_bytes
_TINY_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
_TINY_PNG_B64 = (
"data:image/png;base64,"
+ base64.b64encode(_TINY_PNG).decode("ascii")
)
def test_export_produces_xlsx():
snapshot = {
"styles": [{"fs": 14, "bl": 1, "bg": {"rgb": "#FFFF00"}, "ht": 2}],
"sheetOrder": ["s1"],
"sheets": {
"s1": {
"name": "Report",
"cellData": {
"0": {
"0": {"v": "Title", "s": 0},
"1": {"v": "ignored"},
},
"1": {"0": {"v": 100, "t": 2}},
},
"mergeData": [
{"startRow": 0, "endRow": 0, "startColumn": 0, "endColumn": 1},
],
"rowData": {"0": {"h": 30}},
"columnData": {"0": {"w": 140}},
}
},
}
raw = snapshot_to_xlsx_bytes(snapshot, watermark_text="机密")
assert raw[:2] == b"PK"
assert len(raw) > 200
if importlib.util.find_spec("openpyxl") is None:
return
import io
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(raw))
ws = wb["Report"]
assert ws["A1"].value == "Title"
assert ws["A2"].value == 100
assert ws["A1"].font.bold is True
merged = list(ws.merged_cells.ranges)
assert len(merged) == 1
assert str(merged[0]) == "A1:B1"
assert ws.oddHeader.center.text == "机密"
def test_export_conditional_formatting_and_images():
if importlib.util.find_spec("openpyxl") is None:
return
if importlib.util.find_spec("PIL") is None:
return
import io
from openpyxl import load_workbook
snapshot = {
"sheetOrder": ["s1"],
"resources": [
{
"name": "SHEET_CONDITIONAL_FORMATTING_PLUGIN",
"data": (
'{"s1":[{"ranges":[{"startRow":0,"endRow":2,"startColumn":0,"endColumn":0}],'
'"rule":{"type":"highlight","operator":"greaterThan","value":50,'
'"style":{"bg":{"rgb":"#FFCCCC"}}},"stopIfTrue":false},'
'{"ranges":[{"startRow":0,"endRow":2,"startColumn":1,"endColumn":1}],'
'"rule":{"type":"colorScale","config":['
'{"value":{"type":"min"},"color":"#FF0000"},'
'{"value":{"type":"max"},"color":"#00FF00"}'
']},"stopIfTrue":false}]}'
),
},
{
"name": "SHEET_DRAWING_PLUGIN",
"data": (
'{"s1":{"order":["img1"],"data":{"img1":{'
'"source":"'
+ _TINY_PNG_B64
+ '","imageSourceType":"BASE64",'
'"sheetTransform":{"from":{"row":0,"column":2},'
'"to":{"row":4,"column":4}}}}}}'
),
},
],
"sheets": {
"s1": {
"name": "CF",
"cellData": {
"0": {
"0": {"v": 80, "t": 2},
"1": {"v": 20, "t": 2},
"3": {
"v": "",
"p": {
"drawings": {
"cellImg": {
"source": _TINY_PNG_B64,
"imageSourceType": "BASE64",
}
}
},
},
}
},
}
},
}
raw = snapshot_to_xlsx_bytes(snapshot)
wb = load_workbook(io.BytesIO(raw))
ws = wb["CF"]
assert len(ws.conditional_formatting._cf_rules) >= 2
assert len(ws._images) >= 2
def test_export_hyperlinks_and_advanced_conditional_formatting():
if importlib.util.find_spec("openpyxl") is None:
return
import io
import json
from openpyxl import load_workbook
cf_entries = [
{
"ranges": [{"startRow": 0, "endRow": 5, "startColumn": 0, "endColumn": 0}],
"rule": {
"type": "highlight",
"subType": "top10",
"value": 3,
"style": {"bg": {"rgb": "#FFCCCC"}},
},
"stopIfTrue": False,
},
{
"ranges": [{"startRow": 0, "endRow": 5, "startColumn": 1, "endColumn": 1}],
"rule": {
"type": "highlight",
"subType": "aboveAverage",
"operator": "greaterThan",
"style": {"bg": {"rgb": "#CCCCFF"}},
},
"stopIfTrue": False,
},
{
"ranges": [{"startRow": 0, "endRow": 5, "startColumn": 2, "endColumn": 2}],
"rule": {
"type": "highlight",
"subType": "timePeriod",
"operator": "today",
"style": {"bg": {"rgb": "#CCFFCC"}},
},
"stopIfTrue": False,
},
]
snapshot = {
"sheetOrder": ["s1", "s2"],
"resources": [
{
"name": "SHEET_DEFINED_NAME_PLUGIN",
"data": json.dumps(
{
"range1": {
"name": "MyRange",
"formulaOrRefString": "=s2!$A$1",
}
}
),
},
{
"name": "SHEET_CONDITIONAL_FORMATTING_PLUGIN",
"data": json.dumps({"s1": cf_entries}),
},
],
"sheets": {
"s1": {
"name": "Links",
"cellData": {
"0": {
"0": {
"p": {
"body": {
"dataStream": "Open Example",
"customRanges": [
{
"properties": {
"url": "https://example.com",
}
}
],
}
}
},
"1": {
"p": {
"body": {
"dataStream": "Go Sheet2",
"customRanges": [
{
"properties": {
"url": "#gid=s2&range=A1",
}
}
],
}
}
},
}
},
},
"s2": {
"name": "Target",
"cellData": {"0": {"0": {"v": "Target Cell"}}},
},
},
}
raw = snapshot_to_xlsx_bytes(snapshot)
wb = load_workbook(io.BytesIO(raw))
ws = wb["Links"]
assert ws["A1"].hyperlink is not None
assert ws["A1"].hyperlink.target == "https://example.com"
assert ws["B1"].hyperlink is not None
assert ws["B1"].hyperlink.location == "'Target'!A1"
assert len(ws.conditional_formatting._cf_rules) >= 3
if __name__ == "__main__":
test_export_produces_xlsx()
test_export_conditional_formatting_and_images()
test_export_hyperlinks_and_advanced_conditional_formatting()
print("ok")
@@ -0,0 +1,22 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.expression_eval import detect_expression_cycles
def test_no_cycle():
cells = {
"cells": [
{"type": "expression", "sheet": "s1", "row": 0, "col": 1, "custom": {"field": "=A1+1"}},
]
}
assert detect_expression_cycles(cells) == []
def test_cycle_a_b():
cells = {
"cells": [
{"type": "expression", "sheet": "s1", "row": 0, "col": 0, "custom": {"field": "=B1+1"}},
{"type": "expression", "sheet": "s1", "row": 0, "col": 1, "custom": {"field": "=A1+1"}},
]
}
assert detect_expression_cycles(cells) == ["expression_cycle"]
@@ -0,0 +1,183 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.convert import transform
def test_sum_expression():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"0": {"0": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 0,
"custom": {"field": "=sum(sales.amount)+10"},
}
]
}
datasets = {
"sales": [{"amount": 100}, {"amount": 200}],
}
filled = transform(snapshot, cells, datasets, {})
assert filled["sheets"]["sheet1"]["cellData"]["0"]["0"]["v"] == "310"
def test_expression_with_param():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"1": {"1": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {"field": "=#{bonus}+sum(items.qty)"},
}
]
}
datasets = {"items": [{"qty": 5}, {"qty": 3}]}
filled = transform(snapshot, cells, datasets, {"bonus": 2})
assert filled["sheets"]["sheet1"]["cellData"]["1"]["1"]["v"] == "10"
def test_cell_ref_addition():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": "10"}, "1": {"v": "20"}},
"2": {"0": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 2,
"col": 0,
"custom": {"field": "=A1+B2"},
}
]
}
filled = transform(snapshot, cells, {}, {})
assert filled["sheets"]["sheet1"]["cellData"]["2"]["0"]["v"] == "30"
def test_sum_cell_range():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": 5}, "1": {"v": 15}},
"1": {"0": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {"field": "=sum(A1:B1)+1"},
}
]
}
filled = transform(snapshot, cells, {}, {})
assert filled["sheets"]["sheet1"]["cellData"]["1"]["0"]["v"] == "21"
def test_expression_chain_b1_c1():
"""B1=A1+1, C1=B1+1:表达式间依赖需拓扑/多轮求值"""
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {
"0": {"v": "100"},
"1": {"v": ""},
"2": {"v": ""},
}
},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {"field": "=A1+1"},
},
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 2,
"custom": {"field": "=B1+1"},
},
]
}
filled = transform(snapshot, cells, {}, {})
assert filled["sheets"]["sheet1"]["cellData"]["0"]["1"]["v"] == "101"
assert filled["sheets"]["sheet1"]["cellData"]["0"]["2"]["v"] == "102"
def test_expression_chain_reverse_meta_order():
"""cells 元数据顺序为 C1 先于 B1 时仍应正确求值"""
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": 10}, "1": {"v": ""}, "2": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 2,
"custom": {"formula": "=B1*2"},
},
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {"formula": "=A1+5"},
},
]
}
filled = transform(snapshot, cells, {}, {})
assert filled["sheets"]["sheet1"]["cellData"]["0"]["1"]["v"] == "15"
assert filled["sheets"]["sheet1"]["cellData"]["0"]["2"]["v"] == "30"
@@ -0,0 +1,97 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Golden Test:用 JSON fixture 回归报表 transform 流水线关键输出。
后续可追加 JNPF 导出的样例 fixture 做对比。
"""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Any, Dict, List
from online_dev.report_manager.engine.convert import transform
from online_dev.report_manager.engine.dataset_transform import transform_dataset_rows
_FIXTURES_DIR = Path(__file__).parent / "fixtures"
def _load_fixture(name: str) -> dict:
path = _FIXTURES_DIR / name
with open(path, encoding="utf-8") as f:
return json.load(f)
def _cell_v(snapshot: dict, sheet_id: str, row: int, col: int) -> str:
sheet = (snapshot.get("sheets") or {}).get(sheet_id) or {}
cell = (sheet.get("cellData") or {}).get(str(row), {}).get(str(col), {})
v = cell.get("v")
return "" if v is None else str(v)
def _prepare_datasets(fixture: dict) -> Dict[str, List[Any]]:
raw = fixture.get("datasets") or {}
field_mapping_root = fixture.get("field_mapping") or {}
version_convert = fixture.get("convert_config")
out: Dict[str, List[Any]] = {}
for alias, rows in raw.items():
mapping = field_mapping_root.get(alias) if isinstance(field_mapping_root, dict) else field_mapping_root
out[alias] = transform_dataset_rows(
rows,
field_mapping=mapping,
version_convert=version_convert,
alias=alias,
)
return out
def _values_match(expected: Any, actual: str, *, tolerance: float = 1e-6) -> bool:
if expected == actual:
return True
try:
exp_f = float(expected)
act_f = float(actual)
return math.isclose(exp_f, act_f, rel_tol=tolerance, abs_tol=tolerance)
except (TypeError, ValueError):
return False
def _run_golden_fixture(fixture: dict) -> None:
datasets = _prepare_datasets(fixture)
out = transform(
fixture["snapshot"],
fixture.get("cells") or {},
datasets,
fixture.get("params") or {},
column_list=fixture.get("column_list"),
fence_list=fixture.get("fence_list"),
)
failures: List[str] = []
for sheet_id, cells in (fixture.get("expect") or {}).items():
for addr, expected in cells.items():
row_s, col_s = addr.split(",", 1)
actual = _cell_v(out, sheet_id, int(row_s), int(col_s))
exp_str = "" if expected is None else str(expected)
if not _values_match(exp_str, actual):
failures.append(
f" {sheet_id}!{addr}: expected {exp_str!r}, got {actual!r}"
)
if failures:
name = fixture.get("name") or "unnamed"
msg = f"{name} failed ({len(failures)} cell(s)):\n" + "\n".join(failures)
raise AssertionError(msg)
def test_golden_list_down():
_run_golden_fixture(_load_fixture("golden_list_down.json"))
def test_all_golden_fixtures():
for path in sorted(_FIXTURES_DIR.glob("golden_*.json")):
_run_golden_fixture(_load_fixture(path.name))
if __name__ == "__main__":
test_all_golden_fixtures()
print(f"ok ({len(list(_FIXTURES_DIR.glob('golden_*.json')))} fixtures)")
@@ -0,0 +1,32 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.parameter_resolver import (
build_system_params,
merge_preview_params,
)
def test_build_system_params():
p = build_system_params(user_name="Alice", dept_name="Sales")
assert p["currentUserName"] == "Alice"
assert p["userName"] == "Alice"
assert p["deptName"] == "Sales"
assert p["currentDate"]
def test_merge_preview_params_priority():
merged = merge_preview_params(
{"a": 1, "b": 2},
{"b": 99, "c": 3},
{"c": 0, "d": 4},
)
assert merged["a"] == 1
assert merged["b"] == 99
assert merged["c"] == 3
assert merged["d"] == 4
if __name__ == "__main__":
test_build_system_params()
test_merge_preview_params_priority()
print("ok")
@@ -0,0 +1,18 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.convert import transform
def test_snapshot_placeholder_in_text_cell():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": "Hello #{userName}"}},
},
}
}
}
filled = transform(snapshot, {"cells": []}, {}, {"userName": "Alice"})
assert filled["sheets"]["sheet1"]["cellData"]["0"]["0"]["v"] == "Hello Alice"
@@ -0,0 +1,27 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.polymerize import build_bind_list
def test_group_bind():
cell = {
"custom": {
"field": "category",
"polymerizationType": "2",
"groupType": "default",
}
}
rows = [
{"category": "A", "x": 1},
{"category": "B", "x": 2},
{"category": "A", "x": 3},
]
binds = build_bind_list(cell, rows)
assert len(binds) == 2
assert binds[0].value == "A"
assert len(binds[0].data_list) == 2
if __name__ == "__main__":
test_group_bind()
print("ok")
@@ -0,0 +1,59 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.preview_guard import (
collect_preview_warnings,
estimate_snapshot_cell_count,
WARN_SNAPSHOT_CELL_COUNT,
)
def test_estimate_snapshot_cell_count():
snapshot = {
"sheets": {
"s1": {
"cellData": {
"0": {"0": {"v": 1}, "1": {"v": 2}},
"1": {"0": {"v": 3}},
}
}
}
}
assert estimate_snapshot_cell_count(snapshot) == 3
def test_collect_dataset_row_warn():
warnings = collect_preview_warnings(
datasets={"sales": [{"id": i} for i in range(10001)]},
snapshot={},
)
assert "dataset_row_warn:sales" in warnings
assert "snapshot_large" not in warnings
def test_collect_dataset_row_limit():
warnings = collect_preview_warnings(
datasets={"sales": [{"id": i} for i in range(50000)]},
snapshot={},
)
assert "dataset_row_limit:sales" in warnings
def test_collect_snapshot_large():
cell_data = {"0": {str(c): {"v": c} for c in range(1000)}}
snapshot = {
"sheets": {
f"s{i}": {"cellData": cell_data}
for i in range(201)
}
}
assert estimate_snapshot_cell_count(snapshot) >= WARN_SNAPSHOT_CELL_COUNT
warnings = collect_preview_warnings(datasets={}, snapshot=snapshot)
assert "snapshot_large" in warnings
if __name__ == "__main__":
test_estimate_snapshot_cell_count()
test_collect_dataset_row_warn()
test_collect_dataset_row_limit()
test_collect_snapshot_large()
print("ok")
@@ -0,0 +1,10 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.sort_apply import apply_sort_to_rows
def test_apply_sort_desc():
rows = [{"n": 3}, {"n": 1}, {"n": 2}]
rules = [{"vModel": "ds.n", "type": "desc"}]
out = apply_sort_to_rows(rows, rules)
assert [r["n"] for r in out] == [3, 2, 1]
@@ -0,0 +1,305 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.convert import transform
def test_summary_moves_below_list():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": "姓名"}, "1": {"v": "年龄"}},
"1": {"0": {"v": ""}, "1": {"v": ""}},
"2": {"0": {"v": "合计"}, "1": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
},
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "age",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
},
},
{
"col": "1",
"row": "2",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "age",
"dataSetName": "test",
"polymerizationType": "3",
"summaryType": "max",
"leftParentCellType": "none",
"topParentCellType": "none",
},
},
]
}
datasets = {
"test": [
{"name": "A", "age": 20},
{"name": "B", "age": 35},
{"name": "C", "age": 28},
],
}
filled = transform(snapshot, cells, datasets, {})
cell_data = filled["sheets"]["sheet1"]["cellData"]
assert cell_data["1"]["1"]["v"] == 20
assert cell_data["2"]["1"]["v"] == 35
assert cell_data["3"]["1"]["v"] == 28
assert cell_data["4"]["1"]["v"] == 35
assert cell_data["4"]["0"]["v"] == "合计"
def test_summary_count_with_default_parents():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": "姓名"}, "1": {"v": ""}},
"1": {"0": {"v": ""}, "1": {"v": ""}},
"2": {"0": {"v": "计数"}, "1": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "gender",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
{
"col": "1",
"row": "2",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "gender",
"dataSetName": "test",
"polymerizationType": "3",
"summaryType": "count",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
]
}
datasets = {
"test": [
{"name": "A", "gender": 2},
{"name": "B", "gender": 2},
{"name": "C", "gender": 1},
{"name": "D", "gender": 2},
],
}
filled = transform(snapshot, cells, datasets, {})
cell_data = filled["sheets"]["sheet1"]["cellData"]
assert cell_data["5"]["1"]["v"] == 4
def test_group_column_merge():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"1": {"0": {"v": ""}, "1": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "gender",
"dataSetName": "test",
"polymerizationType": "2",
"groupType": "default",
"expand": "down",
"leftParentCellType": "none",
"topParentCellType": "none",
},
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
]
}
datasets = {
"test": [
{"gender": 0, "name": "A"},
{"gender": 0, "name": "B"},
{"gender": 1, "name": "C"},
{"gender": 1, "name": "D"},
],
}
filled = transform(snapshot, cells, datasets, {})
sheet = filled["sheets"]["sheet1"]
merges = sheet.get("mergeData") or []
assert {"startRow": 1, "endRow": 2, "startColumn": 0, "endColumn": 0} in merges
assert {"startRow": 3, "endRow": 4, "startColumn": 0, "endColumn": 0} in merges
assert sheet["cellData"]["1"]["0"]["v"] in (0, "0")
assert sheet["cellData"]["3"]["0"]["v"] in (1, "1")
def test_group_column_no_merge_when_disabled():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"1": {"0": {"v": ""}, "1": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "gender",
"dataSetName": "test",
"polymerizationType": "2",
"groupType": "default",
"mergeCell": False,
"expand": "down",
"leftParentCellType": "none",
"topParentCellType": "none",
},
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
]
}
datasets = {
"test": [
{"gender": 0, "name": "A"},
{"gender": 0, "name": "B"},
{"gender": 1, "name": "C"},
],
}
filled = transform(snapshot, cells, datasets, {})
merges = filled["sheets"]["sheet1"].get("mergeData") or []
group_merges = [m for m in merges if m.get("startColumn") == 0]
assert group_merges == []
def test_data_source_style_copied_to_expanded_rows():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": {"0": {"v": "", "s": "T1"}},
},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
},
}
]
}
datasets = {"test": [{"name": "A"}, {"name": "B"}, {"name": "C"}]}
filled = transform(snapshot, cells, datasets, {})
cd = filled["sheets"]["sheet1"]["cellData"]
assert cd["1"]["0"].get("s") == "T1"
assert cd["2"]["0"].get("s") == "T1"
assert cd["3"]["0"].get("s") == "T1"
if __name__ == "__main__":
test_summary_moves_below_list()
test_summary_count_with_default_parents()
test_group_column_merge()
test_group_column_no_merge_when_disabled()
test_data_source_style_copied_to_expanded_rows()
print("ok")
@@ -0,0 +1,36 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from datetime import datetime
from online_dev.report_manager.engine.watermark import (
build_watermark_payload,
resolve_watermark_config,
)
def test_resolve_show_time():
fixed = datetime(2026, 5, 22, 15, 30, 0)
cfg = resolve_watermark_config(
{"content": "机密", "showTime": True, "timeFormat": "yyyy-MM-dd"},
now=fixed,
)
assert cfg["content"] == "机密 2026-05-22"
def test_build_payload_disabled():
payload = build_watermark_payload(False, {"content": "x"})
assert payload["show"] is False
assert payload["config"] == {}
def test_build_payload_enabled():
payload = build_watermark_payload(True, {"content": "ZQ"}, template_name="报表A")
assert payload["show"] is True
assert payload["config"]["content"] == "ZQ"
if __name__ == "__main__":
test_resolve_show_time()
test_build_payload_disabled()
test_build_payload_enabled()
print("ok")
@@ -0,0 +1,79 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表水印配置解析(对齐 JNPF preview 行为)"""
from __future__ import annotations
import copy
from datetime import datetime
from typing import Any, Dict, Optional
DEFAULT_WATERMARK_CONFIG: Dict[str, Any] = {
"content": "内部使用",
"fontSize": 32,
"color": "#B8B8B8",
"bold": False,
"italic": False,
"direction": "ltr",
"x": 80,
"y": 200,
"repeat": True,
"spacingX": 200,
"spacingY": 200,
"rotate": -45,
"opacity": 0.2,
"showTime": False,
"timeFormat": "yyyy-MM-dd",
}
def _format_watermark_time(fmt: str, now: Optional[datetime] = None) -> str:
dt = now or datetime.now()
mapping = {
"yyyy": "%Y",
"yyyy-MM": "%Y-%m",
"yyyy-MM-dd": "%Y-%m-%d",
"yyyy-MM-dd HH:mm": "%Y-%m-%d %H:%M",
"yyyy-MM-dd HH:mm:ss": "%Y-%m-%d %H:%M:%S",
}
py_fmt = mapping.get(fmt or "yyyy-MM-dd", "%Y-%m-%d")
return dt.strftime(py_fmt)
def resolve_watermark_config(
raw_config: Any,
*,
template_name: str = "",
now: Optional[datetime] = None,
) -> Dict[str, Any]:
"""合并默认项、填充 content,并按 showTime 追加时间文本。"""
base = copy.deepcopy(DEFAULT_WATERMARK_CONFIG)
if isinstance(raw_config, dict):
base.update({k: v for k, v in raw_config.items() if v is not None})
if not str(base.get("content") or "").strip():
base["content"] = template_name or DEFAULT_WATERMARK_CONFIG["content"]
if base.get("showTime"):
time_text = _format_watermark_time(str(base.get("timeFormat") or "yyyy-MM-dd"), now)
content = str(base.get("content") or "").strip()
base["content"] = f"{content} {time_text}".strip()
return base
def build_watermark_payload(
allow_watermark: bool,
raw_config: Any,
*,
template_name: str = "",
now: Optional[datetime] = None,
) -> Dict[str, Any]:
"""返回前端 Univer / 打印共用的 { show, config }。"""
if not allow_watermark:
return {"show": False, "config": {}}
return {
"show": True,
"config": resolve_watermark_config(
raw_config,
template_name=template_name,
now=now,
),
}