"""Builds a JSON hierarchy tree from the Position column of an Excel Stk.-Liste. Usage: python jsonFromXlsx.py --filename Fortna --tab soll python jsonFromXlsx.py --filename Fortna --tab ist --output result.json python jsonFromXlsx.py --filename KA135776 --extract-ids python jsonFromXlsx.py --from-results --extract-ids """ import argparse import json import os import re import sys from collections import Counter from pathlib import Path import openpyxl SHEET_NAMES = { "soll": "Stk.-Liste SOLL", "ist": "Stk.-Liste IST", } # Column indices (0-based) in the header row COL_POSITION = 0 COL_TEILENUMMER = 1 COL_BEZEICHNUNG = 2 HEADER_ROW = 3 # 1-based row number of the header def find_xlsx(examples_dir: str, pattern: str) -> Path: """Find an .xlsx file in examples/ whose name contains the given pattern.""" matches = [] for f in Path(examples_dir).glob("*.xlsx"): if pattern.lower() in f.name.lower(): matches.append(f) if not matches: print(f"FEHLER: Keine .xlsx-Datei mit Muster '{pattern}' in {examples_dir} gefunden.") sys.exit(1) if len(matches) > 1: print(f"Mehrere Dateien gefunden fuer Muster '{pattern}':") for m in sorted(matches): print(f" {m.name}") print(f"Verwende: {matches[0].name}") return sorted(matches)[0] def make_key(teilenummer: str, bezeichnung: str, is_unique: bool) -> str: """Build a node key from Teilenummer, adding Bezeichnung if not unique.""" if is_unique: return teilenummer combined = f"{teilenummer}-{bezeichnung}" return combined.replace(" ", "_") def unique_key(parent: dict, key: str) -> str: """Return key with _1, _2, ... suffix if it already exists in parent.""" if key not in parent: return key suffix = 1 while f"{key}_{suffix}" in parent: suffix += 1 return f"{key}_{suffix}" def build_tree(ws, sheet_type: str) -> dict: """Read the worksheet and build a nested dict from Position paths. Keys are Teilenummer-based (with Bezeichnung if non-unique). Position paths are used only for hierarchy, not as keys. """ # First pass: collect all Teilenummern to determine uniqueness teil_counter: Counter = Counter() rows_data = [] for row in ws.iter_rows(min_row=HEADER_ROW + 1, max_row=ws.max_row, values_only=True): pos = row[COL_POSITION] teil = row[COL_TEILENUMMER] bez = row[COL_BEZEICHNUNG] if pos is None or teil is None: continue pos_str = str(pos).strip() if pos_str.lower() == "position": continue # skip header row teil_str = str(teil).strip() bez_str = str(bez).strip() if bez else "" teil_counter[teil_str] += 1 rows_data.append((pos_str, teil_str, bez_str)) unique_teile = {t for t, count in teil_counter.items() if count == 1} # Second pass: build tree with Teilenummer-based keys tree: dict = {} pos_to_node: dict[str, dict] = {} # maps position path -> dict node # Track metadata per node for leaf post-processing node_meta: dict[int, tuple[str, str]] = {} # id(child) -> (teil_str, bez_str) for pos_str, teil_str, bez_str in rows_data: key = make_key(teil_str, bez_str, teil_str in unique_teile) segments = pos_str.split("/") # Find parent dict if len(segments) == 1: parent = tree else: parent_pos = "/".join(segments[:-1]) if parent_pos not in pos_to_node: # Parent row missing in data, create placeholder pos_to_node[parent_pos] = {} parent = pos_to_node[parent_pos] # Insert with unique key final_key = unique_key(parent, key) child: dict = {} parent[final_key] = child pos_to_node[pos_str] = child node_meta[id(child)] = (teil_str, bez_str) is_sivas = re.compile(r"^\d{9}$").match # Post-process: leaves (empty dicts) -> key=Teilenummer, value=Bezeichnung def simplify_leaves(node: dict) -> dict: result: dict = {} for key, value in node.items(): if isinstance(value, dict) and len(value) == 0: # Leaf node: use only Teilenummer as key, Bezeichnung as value teil, bez = node_meta.get(id(value), ("", "")) if teil in result and is_sivas(teil): # Aggregate duplicate 9-digit Sivas numbers existing = result[teil] if isinstance(existing, str): result[teil] = {"anzahl": 2, "beschreibung": existing} else: existing["anzahl"] += 1 else: leaf_key = unique_key(result, teil) result[leaf_key] = bez elif isinstance(value, dict): result[key] = simplify_leaves(value) else: result[key] = value return result return simplify_leaves(tree) RE_SIVAS = re.compile(r"^(\d{9})") def is_leaf_value(v) -> bool: """True if v is a leaf entry (string or anzahl-dict).""" if isinstance(v, str): return True if isinstance(v, dict) and "anzahl" in v: return True return False def extract_ids_from_tree(tree: dict) -> dict[str, str]: """Collect 9-digit Sivas numbers one level above leaves. Returns dict of nummer -> bezeichnung. """ ids: dict[str, str] = {} def walk(node: dict): for key, value in node.items(): if not isinstance(value, dict) or "anzahl" in value: continue # leaf, skip # Check if all children are leaves all_leaves = all(is_leaf_value(v) for v in value.values()) m = RE_SIVAS.match(key) if all_leaves and value and m: nummer = m.group(1) # Bezeichnung from key: part after "nummer-", underscores back to spaces if "-" in key[9:]: bez = key[10:].replace("_", " ") elif len(key) > 9: bez = key[10:].replace("_", " ") else: bez = nummer ids[nummer] = bez else: walk(value) walk(tree) return ids def process_file(xlsx_path: Path, sheet_name: str, tab: str, results_dir: Path, out_path: Path | None = None) -> bool: """Process a single xlsx file. Returns True on success.""" print(f"Lade: {xlsx_path.name}") print(f"Sheet: {sheet_name}") wb = openpyxl.load_workbook(str(xlsx_path), data_only=True) if sheet_name not in wb.sheetnames: print(f" SKIP: Sheet '{sheet_name}' nicht vorhanden.") return False ws = wb[sheet_name] tree = build_tree(ws, tab) if out_path is None: stem = xlsx_path.stem.replace(" ", "_") out_path = results_dir / f"{stem}_{tab}.json" with open(out_path, "w", encoding="utf-8") as f: json.dump(tree, f, indent=2, ensure_ascii=False) print(f" Ausgabe: {out_path}") return True def main(): parser = argparse.ArgumentParser( description="Erzeugt eine JSON-Baumstruktur aus der Positionsspalte einer Excel-Stueckliste." ) parser.add_argument( "--filename", default=None, help="Suchmuster fuer die .xlsx-Datei im examples/-Ordner (z.B. 'Fortna')", ) parser.add_argument( "--tab", choices=["soll", "ist"], default="soll", help="Welches Tabellenblatt gelesen wird: 'soll' oder 'ist' (default: soll)", ) parser.add_argument( "--output", default=None, help="Ausgabedatei (default: _.json im results/-Ordner)", ) parser.add_argument( "--all", action="store_true", dest="process_all", help="Alle .xlsx-Dateien im examples/-Ordner in _ist und _soll JSON konvertieren", ) parser.add_argument( "--extract-ids", action="store_true", dest="extract_ids", help="9-stellige Sivas-Nummern eine Ebene ueber den Blaettern extrahieren", ) parser.add_argument( "--from-results", action="store_true", dest="from_results", help="JSON-Dateien aus results/ lesen statt aus .xlsx zu erzeugen", ) args = parser.parse_args() if not args.process_all and not args.filename and not args.from_results: parser.error("Entweder --filename, --all oder --from-results muss angegeben werden.") # Resolve paths relative to project root project_root = Path(os.environ.get("PROJECT", Path(__file__).resolve().parent.parent)) examples_dir = project_root / "examples" results_dir = project_root / "results" results_dir.mkdir(exist_ok=True) # Default tab to "ist" when --extract-ids without explicit --tab tab = args.tab if args.extract_ids and args.tab == "soll" and "--tab" not in sys.argv: tab = "ist" if args.from_results: # Read existing JSON files from results/ pattern = f"*_{tab}.json" json_files = sorted(results_dir.glob(pattern)) if args.filename: json_files = [f for f in json_files if args.filename.lower() in f.name.lower()] if not json_files: print(f"FEHLER: Keine JSON-Dateien mit Muster '{pattern}' in {results_dir} gefunden.") sys.exit(1) print(f"Lese {len(json_files)} JSON-Dateien aus {results_dir}\n") for json_path in json_files: with open(json_path, encoding="utf-8") as f: tree = json.load(f) # Derive project name from filename: strip _ist/_soll.json suffix project_name = json_path.stem for suffix in ("_ist", "_soll"): if project_name.endswith(suffix): project_name = project_name[: -len(suffix)] break if args.extract_ids: ids = extract_ids_from_tree(tree) ids_path = results_dir / f"{project_name}_ids_{tab}.txt" with open(ids_path, "w", encoding="utf-8") as f: for nummer, bez in sorted(ids.items()): f.write(f"{nummer}: {bez}\n") print(f" {json_path.name} -> {ids_path.name} ({len(ids)} IDs)") elif args.process_all: xlsx_files = sorted(f for f in Path(examples_dir).glob("*.xlsx") if not f.name.startswith("~$")) print(f"Verarbeite {len(xlsx_files)} Dateien aus {examples_dir}\n") ok, skip = 0, 0 for xlsx_path in xlsx_files: for t in ("soll", "ist"): sheet_name = SHEET_NAMES[t] if process_file(xlsx_path, sheet_name, t, results_dir): ok += 1 else: skip += 1 print() print(f"Fertig: {ok} JSON erzeugt, {skip} uebersprungen.") else: xlsx_path = find_xlsx(str(examples_dir), args.filename) sheet_name = SHEET_NAMES[tab] out_path = None if args.output: out_path = Path(args.output) if not out_path.is_absolute(): out_path = results_dir / out_path json_out = None if args.extract_ids: stem = xlsx_path.stem.replace(" ", "_") json_out = results_dir / f"{stem}_{tab}.json" if not process_file(xlsx_path, sheet_name, tab, results_dir, out_path): sys.exit(1) if args.extract_ids: # Read back the generated JSON read_path = out_path or json_out or results_dir / f"{xlsx_path.stem.replace(' ', '_')}_{tab}.json" with open(read_path, encoding="utf-8") as f: tree = json.load(f) ids = extract_ids_from_tree(tree) project_name = xlsx_path.stem.replace(" ", "_") ids_path = results_dir / f"{project_name}_ids_{tab}.txt" with open(ids_path, "w", encoding="utf-8") as f: for nummer, bez in sorted(ids.items()): f.write(f"{nummer}: {bez}\n") print(f" IDs: {ids_path} ({len(ids)} IDs)") if __name__ == "__main__": main()