feat: restore source parity and harden agent runtime
This commit is contained in:
@@ -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)
|
||||
- 行分栏 col:columnType=1 超过 maxCol 行分列 / columnType=2 分 N 栏
|
||||
- 列分栏 row:columnType=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 真实样例 Golden(2026-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()
|
||||
+42
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+686
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+592
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+295
@@ -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": "吴忠民"
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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": "研发部"
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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 为 None,default 为最近左/上数据源格。"""
|
||||
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 类型绑定写入 snapshot(MVP)"""
|
||||
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,
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user