From 10a27ad4b60b480a281798294353bb9ae1c9a6df Mon Sep 17 00:00:00 2001 From: Rob Date: Tue, 21 Apr 2026 15:26:53 +0200 Subject: [PATCH] Implement PS_PW allocation parser with month resolution --- .github/copilot-tasks.md | 14 +-- src/taetigkeitsnachweis/parser.py | 183 ++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 7 deletions(-) diff --git a/.github/copilot-tasks.md b/.github/copilot-tasks.md index a262a10..cf19780 100644 --- a/.github/copilot-tasks.md +++ b/.github/copilot-tasks.md @@ -30,12 +30,12 @@ Projekt vollstaendig umsetzen: aus PS_PW.xlsx und Stellenbeschreibungen.xlsx mon - [x] Startkommando fuer lokale Ausfuehrung dokumentieren ## Phase 3 - Einlesen der Quelldaten -- [ ] T5: Parser fuer PS_PW.xlsx implementieren - - [ ] Monatsmarker in Zeile 2 erkennen und parsen (`ab 01.MM.YYYY`) - - [ ] Zielspalte fuer requested month/year eindeutig bestimmen - - [ ] Mitarbeiterkontext ueber Mehrzeilen-Eintraege vererben - - [ ] Werte normalisieren (`-`, `0`, leer, numerisch) - - [ ] Normalisierte Records erzeugen +- [x] T5: Parser fuer PS_PW.xlsx implementieren + - [x] Monatsmarker in Zeile 2 erkennen und parsen (`ab 01.MM.YYYY`) + - [x] Zielspalte fuer requested month/year eindeutig bestimmen + - [x] Mitarbeiterkontext ueber Mehrzeilen-Eintraege vererben + - [x] Werte normalisieren (`-`, `0`, leer, numerisch) + - [x] Normalisierte Records erzeugen - [ ] T6: Parser fuer Stellenbeschreibungen.xlsx implementieren - [ ] Felder ID, Antragsnummer, Name, Qualifikation, Stellenanteil, Funktion, Taetigkeit einlesen - [ ] Prozentwerte (deutsches Dezimalformat) robust in Zahlen umwandeln @@ -111,4 +111,4 @@ Projekt vollstaendig umsetzen: aus PS_PW.xlsx und Stellenbeschreibungen.xlsx mon 8. T15 -> T16 -> T17 ## Aktueller Startpunkt -- [ ] Naechste aktive Aufgabe: T5 (Parser fuer PS_PW.xlsx implementieren) +- [ ] Naechste aktive Aufgabe: T6 (Parser fuer Stellenbeschreibungen.xlsx implementieren) diff --git a/src/taetigkeitsnachweis/parser.py b/src/taetigkeitsnachweis/parser.py index a3314d3..dfd8b32 100644 --- a/src/taetigkeitsnachweis/parser.py +++ b/src/taetigkeitsnachweis/parser.py @@ -1 +1,184 @@ """Parsing and normalization for source workbooks.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook +from openpyxl.utils.cell import column_index_from_string + +from .errors import ValidationError +from .models import AllocationRecord + + +def _to_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _to_float(value: Any, field_name: str) -> float: + if value is None or str(value).strip() == "": + raise ValidationError(f"Missing numeric value for {field_name}.") + + text = str(value).strip().replace(",", ".") + try: + return float(text) + except ValueError as exc: + raise ValidationError(f"Invalid numeric value for {field_name}: {value}") from exc + + +def _parse_month_marker(value: Any, prefix: str, date_format: str) -> datetime | None: + marker = _to_text(value) + if not marker: + return None + + if not marker.lower().startswith(prefix.lower()): + return None + + date_text = marker[len(prefix) :].strip() + return datetime.strptime(date_text, date_format) + + +def _resolve_month_column_index( + worksheet: Any, + header_row: int, + first_month_col: str, + target_month: int, + target_year: int, + month_prefix: str, + month_date_format: str, +) -> int: + start_index = column_index_from_string(first_month_col) + matched_columns: list[int] = [] + + for column_index in range(start_index, worksheet.max_column + 1): + value = worksheet.cell(row=header_row, column=column_index).value + parsed = _parse_month_marker(value, month_prefix, month_date_format) + if parsed is None: + continue + if parsed.month == target_month and parsed.year == target_year: + matched_columns.append(column_index) + + if len(matched_columns) == 0: + raise ValidationError( + f"No month column found for {target_month:02d}/{target_year} in allocation workbook." + ) + + if len(matched_columns) > 1: + raise ValidationError( + f"Multiple month columns found for {target_month:02d}/{target_year} in allocation workbook." + ) + + return matched_columns[0] + + +def _is_inactive_value(value: Any, inactive_values: list[Any]) -> bool: + normalized = _to_text(value) + if normalized in {"", "-"}: + return True + + for inactive in inactive_values: + if str(inactive).strip() == normalized: + return True + + return False + + +def parse_allocation_records( + config: dict[str, Any], + report_month: int, + report_year: int, + project_root: Path, +) -> list[AllocationRecord]: + allocation_cfg = config["allocation"] + columns_cfg = allocation_cfg["columns"] + files_cfg = config["files"] + sheets_cfg = config["sheets"] + paths_cfg = config["paths"] + + workbook_path = ( + project_root + / paths_cfg["input_dir"] + / files_cfg["allocation_workbook"] + ) + + if not workbook_path.exists(): + raise ValidationError(f"Allocation workbook not found: {workbook_path}") + + workbook = load_workbook(workbook_path, data_only=True) + sheet_name = sheets_cfg["allocation"] + if sheet_name not in workbook.sheetnames: + raise ValidationError(f"Sheet '{sheet_name}' not found in {workbook_path.name}") + worksheet = workbook[sheet_name] + + header_row = allocation_cfg["header_month_marker_row"] + month_marker_cfg = allocation_cfg["month_marker"] + month_column = _resolve_month_column_index( + worksheet=worksheet, + header_row=header_row, + first_month_col=columns_cfg["first_month_column"], + target_month=report_month, + target_year=report_year, + month_prefix=month_marker_cfg["prefix"], + month_date_format=month_marker_cfg["date_format"], + ) + + employee_col = column_index_from_string(columns_cfg["employee_name"]) + contract_col = column_index_from_string(columns_cfg["contract_hours_weekly"]) + project_col = column_index_from_string(columns_cfg["project_key"]) + remarks_col = column_index_from_string(columns_cfg["remarks"]) + inactive_values = allocation_cfg.get("inactive_values", ["", "-", "0", 0]) + + current_employee = "" + current_contract_hours: float | None = None + normalized_records: list[AllocationRecord] = [] + + for row_index in range(header_row + 1, worksheet.max_row + 1): + row_employee = _to_text(worksheet.cell(row=row_index, column=employee_col).value) + row_contract = worksheet.cell(row=row_index, column=contract_col).value + row_project = _to_text(worksheet.cell(row=row_index, column=project_col).value) + + if row_employee: + current_employee = row_employee + current_contract_hours = _to_float( + row_contract, + f"contract_hours_weekly row {row_index}", + ) + + if not row_project: + continue + + if not current_employee or current_contract_hours is None: + raise ValidationError( + f"Project row {row_index} has no inherited employee context." + ) + + month_value = worksheet.cell(row=row_index, column=month_column).value + if _is_inactive_value(month_value, inactive_values): + continue + + funded_hours = _to_float(month_value, f"funded_hours row {row_index}") + if funded_hours <= 0: + continue + + if current_contract_hours <= 0: + raise ValidationError( + f"Contract hours must be > 0 for employee '{current_employee}'" + ) + + notes = _to_text(worksheet.cell(row=row_index, column=remarks_col).value) + normalized_records.append( + AllocationRecord( + employee_name=current_employee, + contract_hours_weekly=current_contract_hours, + project_key=row_project, + funded_hours_weekly=funded_hours, + notes=notes, + ) + ) + + workbook.close() + return normalized_records