""" collect_partnumbers.py - Sammelt 9-stellige Teilenummern aus Produktprogramm-RSTs. Erkennt auch Bereiche wie 834372007-011 und expandiert diese zu 834372007, 834372008, 834372009, 834372010, 834372011. Gleicht die Nummern gegen eine Excel-Stueckliste ab und schreibt eine CSV mit Teilenummer, Quelldatei und Bezeichnung. Verwendung: python collect_partnumbers.py [--data-dir ] """ from __future__ import annotations import argparse import csv import logging import os import re import sys from pathlib import Path log = logging.getLogger(__name__) def expand_ranges(text: str) -> list[str]: """Findet alle 9-stelligen Nummern und Bereiche in einem Text. Unterstuetzte Formate: - Einzeln: 834372007 - Bereich: 834372007-011 -> 834372007..834372011 - OCR-verklebt: "834372001-005834372 007-011" bedeutet eigentlich "834372001-005 834372007-011" -> zwei Bereiche mit gleicher Stammnummer - Kurzbereich mit Stammnummer: "834372 007-011" -> 834372007..834372011 Returns: Liste der gefundenen/expandierten 9-stelligen Nummern. """ results: list[str] = [] # Phase 1: OCR-verklebte Nummern trennen. # "005834372" -> "005 834372" (3 Suffix-Ziffern gefolgt von 6-stelliger Stammnr) # Wiederhole bis stabil. cleaned = text for _ in range(10): prev = cleaned # Nach einem Bereichs-Ende (-NNN) klebt eine Stammnummer (6 Ziffern) dran cleaned = re.sub(r'-(\d{3})(\d{6})(?=\s|\d{3}-|\d{3}\s|$)', r'-\1 \2', cleaned) if cleaned == prev: break # Phase 2: Tokenisieren und mit Kontext parsen. # Tokens: 9-stellige Nummern, 6-stellige Stammnummern, NNN-NNN Bereiche, # einzelne NNN Suffixe. # Die Stammnummer wird aus dem letzten vollstaendigen 9-Steller oder # expliziten 6-Steller uebernommen. current_prefix = "" # 6-stellige Stammnummer # Tokenize: Zifferngruppen und Bindestriche extrahieren # Wir verarbeiten die Zeile sequentiell token_pat = re.compile(r'\d+') dash_pat = re.compile(r'(\d+)-(\d+)') # Strategie: alle Zifferngruppen und ihre Kontexte finden # Zuerst alle "Nummer-Nummer" Paare und einzelne Nummern identifizieren segments: list[str] = [] # Aufteilen an Nicht-Ziffern-nicht-Bindestrich Grenzen # aber Bindestrich zwischen Ziffern beibehalten seg_pat = re.compile(r'[\d]+(?:-[\d]+)*') segments = seg_pat.findall(cleaned) for seg in segments: # Fall 1: "834372007-011" (9 Stellen - 3 Stellen) m = re.match(r'^(\d{9})-(\d{3})$', seg) if m: base = m.group(1) current_prefix = base[:6] start = int(base[6:]) end = int(m.group(2)) if end >= start: for i in range(start, end + 1): results.append(f"{current_prefix}{i:03d}") else: results.append(base) continue # Fall 2: "834372007" (einzelne 9-stellige Nummer) m = re.match(r'^(\d{9})$', seg) if m: num = m.group(1) current_prefix = num[:6] if num not in results: results.append(num) continue # Fall 3: "834372" (6-stellige Stammnummer, setzt Prefix) m = re.match(r'^(\d{6})$', seg) if m: current_prefix = m.group(1) continue # Fall 4: "007-011" (3-3 Bereich mit bekanntem Prefix) m = re.match(r'^(\d{3})-(\d{3})$', seg) if m and current_prefix: start = int(m.group(1)) end = int(m.group(2)) if end >= start: for i in range(start, end + 1): results.append(f"{current_prefix}{i:03d}") continue # Fall 5: "420" (einzelnes 3-stelliges Suffix mit bekanntem Prefix) m = re.match(r'^(\d{3})$', seg) if m and current_prefix: num = f"{current_prefix}{m.group(1)}" if num not in results: results.append(num) continue # Fall 6: "200-202834372" -> nach Phase 1 Trennung sollte das # nicht mehr vorkommen, aber als Fallback: # Mehrere Bereiche hintereinander sub_segs = re.findall(r'(\d{9})-(\d{3})|(\d{9})|(\d{3})-(\d{3})', seg) for ss in sub_segs: if ss[0] and ss[1]: # 9-3 Bereich base = ss[0] current_prefix = base[:6] start = int(base[6:]) end = int(ss[1]) if end >= start: for i in range(start, end + 1): results.append(f"{current_prefix}{i:03d}") elif ss[2]: # Einzelne 9-stellige current_prefix = ss[2][:6] if ss[2] not in results: results.append(ss[2]) elif ss[3] and ss[4] and current_prefix: # 3-3 Bereich start = int(ss[3]) end = int(ss[4]) if end >= start: for i in range(start, end + 1): results.append(f"{current_prefix}{i:03d}") return results RE_UNDERLINE = re.compile(r"^([=\-~\^])\1{2,}$") def collect_from_rst(rst_path: Path) -> dict[str, str]: """Sammelt alle 9-stelligen Teilenummern aus einer RST-Datei. Returns: Dict nummer -> kapitelueberschrift in der die Nummer gefunden wurde. """ lines = rst_path.read_text(encoding="utf-8").splitlines() num_to_chapter: dict[str, str] = {} current_heading = "" i = 0 while i < len(lines): line = lines[i].rstrip() # RST-Ueberschriften erkennen: # Variante 1: overline + title + underline (Level 1) # Variante 2: title + underline (Level 2+) if RE_UNDERLINE.match(line): # Pruefen ob dies eine Underline nach einem Titel ist if i > 0 and lines[i - 1].strip(): title = lines[i - 1].strip() # Nur nummerierte Ueberschriften als Kapitel verwenden if re.match(r"^\d+(\.\d+)*\s+", title): current_heading = title # Oder eine Overline vor einem Titel elif i + 1 < len(lines) and lines[i + 1].strip(): title = lines[i + 1].strip() if re.match(r"^\d+(\.\d+)*\s+", title): current_heading = title i += 1 continue # Teilenummern aus der Zeile extrahieren nums = expand_ranges(line) for n in nums: if n[0] in ("8", "9") and n not in num_to_chapter: num_to_chapter[n] = current_heading i += 1 return num_to_chapter def load_excel_descriptions(xlsx_path: Path) -> dict[str, str]: """Laedt Teilenummer -> Bezeichnung aus Excel-Stueckliste.""" import openpyxl wb = openpyxl.load_workbook(str(xlsx_path), read_only=True) ws = wb["Stk.-Liste SOLL"] desc_map: dict[str, str] = {} for row in ws.iter_rows(min_row=5, values_only=True): tn = str(row[1]).strip() if row[1] else "" if re.match(r"^\d{9}$", tn) and tn not in desc_map: bez = str(row[2]).strip() if row[2] else "" if bez: desc_map[tn] = bez wb.close() return desc_map def process(data_dir: Path) -> None: pp_dir = data_dir / "Produktprogramm" rst_files = sorted(pp_dir.glob("*.de.rst")) if not rst_files: log.error("Keine RST-Dateien in %s gefunden", pp_dir) sys.exit(1) # Teilenummern sammeln: nummer -> list[(quelldatei, kapitel)] numbers: dict[str, list[tuple[str, str]]] = {} for rst_path in rst_files: basename = rst_path.name num_chapters = collect_from_rst(rst_path) log.info("%s: %d Teilenummern gefunden", basename, len(num_chapters)) for num, chapter in num_chapters.items(): numbers.setdefault(num, []).append((basename, chapter)) log.info("Gesamt: %d eindeutige Teilenummern", len(numbers)) # Excel-Bezeichnungen laden xlsx_files = list((data_dir).glob("KA*.xlsx")) desc_map: dict[str, str] = {} if xlsx_files: xlsx_path = xlsx_files[0] log.info("Lade Excel: %s", xlsx_path.name) desc_map = load_excel_descriptions(xlsx_path) log.info(" %d Bezeichnungen geladen", len(desc_map)) else: log.warning("Keine KA*.xlsx Datei gefunden in %s", data_dir) # CSV schreiben outfile = pp_dir / "teilenummern.csv" matched = 0 with open(outfile, "w", encoding="utf-8-sig", newline="") as fh: writer = csv.writer(fh, delimiter=";") writer.writerow(["Teilenummer", "Quelldatei", "Kapitel", "Bezeichnung"]) for num in sorted(numbers.keys()): entries = sorted(numbers[num], key=lambda e: e[0]) sources = ", ".join(e[0] for e in entries) chapters = ", ".join(e[1] for e in entries if e[1]) bez = desc_map.get(num, "") if bez: matched += 1 writer.writerow([num, sources, chapters, bez]) log.info("CSV geschrieben: %s", outfile) log.info(" %d Nummern, %d mit Bezeichnung, %d ohne", len(numbers), matched, len(numbers) - matched) def main() -> None: parser = argparse.ArgumentParser( description="Sammelt 9-stellige Teilenummern aus Produktprogramm-RSTs" ) parser.add_argument("--data-dir", type=Path, default=None, help="data/ Verzeichnis (default: aus PV_DATA oder ./data)") parser.add_argument("--verbose", "-v", action="store_true") args = parser.parse_args() logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(levelname)-7s %(message)s", ) if args.data_dir: data_dir = args.data_dir.resolve() elif os.environ.get("PV_DATA"): data_dir = Path(os.environ["PV_DATA"]).resolve() else: data_dir = Path("data").resolve() log.info("Data-Verzeichnis: %s", data_dir) process(data_dir) if __name__ == "__main__": main()