#!/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