erster versuch der KI mit Plan

This commit is contained in:
Rob committed 2026-03-27 13:18:07 +01:00
1 parent c697c64f3a
commit 2faa1689dd
20 files changed
+1749

No files matched your search

View File
Whitespace-only changes.
+73
View File
@@ -0,0 +1,73 @@
"""Loads and validates config.json for the Attendance PDF Generator."""
import json
from pathlib import Path
from typing import Any, Dict
from .exceptions import ConfigurationError
_REQUIRED_KEYS = [
"antragsnummer",
"ort",
"template_pdf",
"output_dir",
"excel_year_pattern",
"excel_name_header",
"excel_name_column",
"excel_seq_column",
"excel_day_start_col",
"excel_day_end_col",
"month_mapping",
"valid_status_codes",
"plan_hours_value",
"pauschale_value",
]
# Singleton cache so the file is only read once per process
_config_cache: Dict[str, Any] | None = None
_config_path_cache: Path | None = None
def load_config(config_path: Path | None = None) -> Dict[str, Any]:
"""Load config.json from *config_path* (default: project root config.json).
Validates that all required keys are present and returns the config dict.
Raises :class:`ConfigurationError` on any problem.
"""
global _config_cache, _config_path_cache
if config_path is None:
# Locate config.json relative to this file: two levels up = project root
config_path = Path(__file__).parent.parent / "config.json"
config_path = Path(config_path).resolve()
# Return cached result if the same file was already loaded
if _config_cache is not None and _config_path_cache == config_path:
return _config_cache
if not config_path.is_file():
raise ConfigurationError(f"config.json nicht gefunden: {config_path}")
try:
raw = config_path.read_text(encoding="utf-8")
data: Dict[str, Any] = json.loads(raw)
except (OSError, json.JSONDecodeError) as exc:
raise ConfigurationError(f"config.json konnte nicht gelesen werden: {exc}") from exc
missing = [k for k in _REQUIRED_KEYS if k not in data]
if missing:
raise ConfigurationError(
f"config.json fehlen Pflichtfelder: {', '.join(missing)}"
)
_config_cache = data
_config_path_cache = config_path
return data
def reset_config_cache() -> None:
"""Clear the in-process config cache (used in tests)."""
global _config_cache, _config_path_cache
_config_cache = None
_config_path_cache = None
+280
View File
@@ -0,0 +1,280 @@
"""Reads attendance data from Excel (.xlsx) files.
Public API
----------
extract_year_from_filename(file_path, config) -> int
get_sheet_names(file_path) -> list[str]
extract_persons(file_path, sheet_name, config) -> list[Person]
extract_attendance(file_path, sheet_name, person, year, config) -> MonthlyAttendance
"""
import re
from datetime import date
from pathlib import Path
from typing import Any, Dict, List, Tuple
import openpyxl
from .config_loader import load_config
from .exceptions import (
ExcelDataError,
ExcelFileError,
ExcelSheetError,
YearExtractionError,
)
from .models import AttendanceDate, MonthlyAttendance, Person
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _open_workbook(file_path: Path):
"""Open an xlsx workbook in read-only, data-only mode."""
try:
return openpyxl.load_workbook(str(file_path), read_only=True, data_only=True)
except Exception as exc:
raise ExcelFileError(f"Excel-Datei konnte nicht geöffnet werden: {exc}") from exc
def _sheet_data(workbook, sheet_name: str) -> List[List[Any]]:
"""Return all rows of *sheet_name* as a list-of-lists of raw cell values."""
if sheet_name not in workbook.sheetnames:
raise ExcelSheetError(
f"Sheet '{sheet_name}' nicht in der Arbeitsmappe gefunden. "
f"Verfügbar: {workbook.sheetnames}"
)
ws = workbook[sheet_name]
return [[cell.value for cell in row] for row in ws.iter_rows()]
def _find_header_row(
data: List[List[Any]], name_col: int, name_header: str
) -> int:
"""Return the 0-based row index of the header row.
The header row is identified by its value in *name_col* containing
*name_header* as a substring.
"""
for idx, row in enumerate(data):
if len(row) > name_col and row[name_col] is not None:
if name_header in str(row[name_col]):
return idx
raise ExcelDataError(
f"Headerzeile mit '{name_header}' in Spaltenindex {name_col} nicht gefunden."
)
def _parse_cell_value(
cell_value: Any, valid_status_codes: List[str]
) -> Tuple[str, float | None] | None:
"""Interpret one Excel day cell.
Returns (status, hours) tuple or None if the cell should be skipped.
- Numeric value → ("X", hours_as_float)
- Known letter code → (code, None)
- Empty/unknown → None
"""
if cell_value is None:
return None
# Numeric: present with hours
if isinstance(cell_value, (int, float)):
hours = float(cell_value)
if hours <= 0:
return None
return ("X", hours)
# String codes (K, KK, F, E, P, U – and optionally "X" as explicit present)
if isinstance(cell_value, str):
stripped = cell_value.strip().upper()
if not stripped:
return None
if stripped == "X":
# Explicit "X" without hours – treat as present with unknown hours
return ("X", None)
if stripped in [c.upper() for c in valid_status_codes]:
# Normalise to the configured casing (config stores uppercase already)
matched = next(
c for c in valid_status_codes if c.upper() == stripped
)
return (matched, None)
# Unknown string – skip silently
return None
return None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def extract_year_from_filename(
file_path: str | Path, config: Dict[str, Any] | None = None
) -> int:
"""Extract the 4-digit year from *file_path* using the regex in config.
Raises :class:`YearExtractionError` when no year can be found.
"""
if config is None:
config = load_config()
pattern = config["excel_year_pattern"]
filename = Path(file_path).name
match = re.search(pattern, filename)
if not match:
raise YearExtractionError(
f"Kein Jahr in Dateiname '{filename}' gefunden (Muster: '{pattern}')."
)
year = int(match.group(1))
if not (1900 <= year <= 2099):
raise YearExtractionError(f"Extrahiertes Jahr {year} liegt außerhalb 1900–2099.")
return year
def get_sheet_names(file_path: str | Path) -> List[str]:
"""Return all sheet names in the workbook."""
wb = _open_workbook(Path(file_path))
names = wb.sheetnames
wb.close()
return names
def extract_persons(
file_path: str | Path,
sheet_name: str,
config: Dict[str, Any] | None = None,
) -> List[Person]:
"""Extract all participants (seq_num, name) from *sheet_name*.
Persons are rows after the header row where column A (seq_column) holds
an integer and column B (name_column) holds a non-empty string.
"""
if config is None:
config = load_config()
name_col: int = config["excel_name_column"]
seq_col: int = config["excel_seq_column"]
name_header: str = config["excel_name_header"]
wb = _open_workbook(Path(file_path))
try:
data = _sheet_data(wb, sheet_name)
finally:
wb.close()
header_idx = _find_header_row(data, name_col, name_header)
persons: List[Person] = []
for row in data[header_idx + 1 :]:
if len(row) <= max(seq_col, name_col):
continue
seq_val = row[seq_col]
name_val = row[name_col]
if seq_val is None or name_val is None:
continue
name_str = str(name_val).strip()
if not name_str:
continue
try:
seq_num = int(seq_val)
except (ValueError, TypeError):
continue
persons.append(Person(seq_num=seq_num, name=name_str))
return persons
def extract_attendance(
file_path: str | Path,
sheet_name: str,
person: Person,
year: int,
config: Dict[str, Any] | None = None,
) -> MonthlyAttendance:
"""Extract all attendance entries for *person* on *sheet_name*.
Returns a :class:`MonthlyAttendance` instance. The entries list contains
only days where a valid (non-empty) cell value is present.
Rows exceeding 23 entries are silently truncated (PDF limit).
"""
if config is None:
config = load_config()
name_col: int = config["excel_name_column"]
seq_col: int = config["excel_seq_column"]
name_header: str = config["excel_name_header"]
day_start: int = config["excel_day_start_col"]
day_end: int = config["excel_day_end_col"]
month_mapping: Dict[str, str] = config["month_mapping"]
valid_codes: List[str] = config["valid_status_codes"]
if sheet_name not in month_mapping:
raise ExcelSheetError(
f"Sheet-Name '{sheet_name}' nicht in month_mapping. "
f"Bekannte Namen: {list(month_mapping.keys())}"
)
month_num = int(month_mapping[sheet_name])
wb = _open_workbook(Path(file_path))
try:
data = _sheet_data(wb, sheet_name)
finally:
wb.close()
header_idx = _find_header_row(data, name_col, name_header)
# Locate the person's row by matching seq_num (primary) or name (fallback)
person_row: List[Any] | None = None
for row in data[header_idx + 1 :]:
if len(row) <= max(seq_col, name_col):
continue
try:
row_seq = int(row[seq_col])
except (TypeError, ValueError):
continue
if row_seq == person.seq_num:
person_row = row
break
if person_row is None:
raise ExcelDataError(
f"Person '{person.name}' (Nr. {person.seq_num}) nicht in Sheet '{sheet_name}' gefunden."
)
entries: List[AttendanceDate] = []
max_days = day_end - day_start + 1 # typically 31
for day_index in range(max_days):
col_index = day_start + day_index
if col_index >= len(person_row):
break
cell_value = person_row[col_index]
parsed = _parse_cell_value(cell_value, valid_codes)
if parsed is None:
continue
status, hours = parsed
day_num = day_index + 1
try:
entry_date = date(year, month_num, day_num)
except ValueError:
# Day does not exist in this month (e.g. Feb 30)
continue
entries.append(
AttendanceDate(date=entry_date, status=status, hours=hours)
)
if len(entries) == 23:
# PDF supports at most 23 rows
break
return MonthlyAttendance(
person=person,
year=year,
month=month_num,
month_name=sheet_name,
entries=entries,
)
+33
View File
@@ -0,0 +1,33 @@
"""Custom exception hierarchy for the Attendance PDF Generator."""
class AttendanceAppError(Exception):
"""Base exception for all application errors."""
class ConfigurationError(AttendanceAppError):
"""Raised when config.json is missing, incomplete, or invalid."""
class ExcelFileError(AttendanceAppError):
"""Raised for general Excel file access or format problems."""
class ExcelSheetError(AttendanceAppError):
"""Raised when a required sheet is not found in the workbook."""
class ExcelDataError(AttendanceAppError):
"""Raised when Excel data contains unexpected or invalid values."""
class YearExtractionError(AttendanceAppError):
"""Raised when the year cannot be extracted from the filename."""
class PdfTemplateError(AttendanceAppError):
"""Raised when the PDF template is missing or unreadable."""
class ValidationError(AttendanceAppError):
"""Raised when pre-generation validation fails."""
+72
View File
@@ -0,0 +1,72 @@
"""Data models for the Attendance PDF Generator."""
from dataclasses import dataclass, field
from datetime import date
from typing import List, Optional
@dataclass
class Person:
"""Represents a participant read from column A and B of the Excel sheet."""
seq_num: int
name: str # Format: "Nachname, Vorname"
def __post_init__(self) -> None:
if not self.name or not self.name.strip():
raise ValueError("Person name must not be empty.")
@property
def filename_safe_name(self) -> str:
"""Returns a filename-safe representation: '(n) Nachname, Vorname'."""
safe = self.name.replace("/", "-").replace("\\", "-").replace(":", "-")
return f"({self.seq_num}) {safe}"
@dataclass
class AttendanceDate:
"""Represents one day's attendance entry for a person."""
date: date
status: str # "X", "K", "KK", "E", "F", "P", "U"
hours: Optional[float] = None # Only set when status == "X"
def date_german(self) -> str:
"""Returns the date in DD.MM.YYYY format."""
return self.date.strftime("%d.%m.%Y")
@property
def is_present(self) -> bool:
return self.status == "X"
def hours_german(self) -> str:
"""Returns hours as a German-formatted string (comma decimal separator)."""
if self.hours is None:
return ""
if self.hours == int(self.hours):
return str(int(self.hours))
return str(self.hours).replace(".", ",")
@dataclass
class MonthlyAttendance:
"""Aggregates all attendance entries for one person in one month."""
person: Person
year: int
month: int
month_name: str
entries: List[AttendanceDate] = field(default_factory=list)
@property
def monat_jahr(self) -> str:
"""Returns 'MM/YYYY' as expected by the PDF field monat_jahr."""
return f"{self.month:02d}/{self.year}"
@property
def present_count(self) -> int:
return sum(1 for e in self.entries if e.is_present)
@property
def total_entries(self) -> int:
return len(self.entries)
+115
View File
@@ -0,0 +1,115 @@
"""Fills the PDF template with attendance data.
Public API
----------
build_field_dict(attendance, config, creation_date) -> dict[str, str]
fill_pdf(attendance, template_path, output_path, config, creation_date)
"""
from datetime import date
from pathlib import Path
from typing import Any, Dict, Optional
from pypdf import PdfReader, PdfWriter
from .config_loader import load_config
from .exceptions import PdfTemplateError
from .models import MonthlyAttendance
def build_field_dict(
attendance: MonthlyAttendance,
config: Dict[str, Any] | None = None,
creation_date: Optional[date] = None,
) -> Dict[str, str]:
"""Build a flat ``{field_name: value}`` dict for the PDF AcroForm.
Header fields:
- ``person.name``
- ``monat_jahr``
- ``antragsnummer``
- ``ort``
- ``erstellungsdatum``
Per-row fields (index 0–22, max 23 rows):
- ``datum.N``
- ``anwesend.N``
- ``ist.N``
- ``plan.N``
- ``pauschale.N``
"""
if config is None:
config = load_config()
if creation_date is None:
creation_date = date.today()
plan_value: str = config["plan_hours_value"]
pauschale_value: str = config["pauschale_value"]
fields: Dict[str, str] = {
"person.name": attendance.person.name,
"monat_jahr": attendance.monat_jahr,
"antragsnummer": config["antragsnummer"],
"ort": config["ort"],
"erstellungsdatum": creation_date.strftime("%d.%m.%Y"),
}
for idx, entry in enumerate(attendance.entries):
fields[f"datum.{idx}"] = entry.date_german()
fields[f"anwesend.{idx}"] = entry.status
if entry.is_present:
fields[f"ist.{idx}"] = entry.hours_german()
fields[f"plan.{idx}"] = plan_value
fields[f"pauschale.{idx}"] = pauschale_value
else:
fields[f"ist.{idx}"] = ""
fields[f"plan.{idx}"] = ""
fields[f"pauschale.{idx}"] = ""
return fields
def fill_pdf(
attendance: MonthlyAttendance,
template_path: str | Path,
output_path: str | Path,
config: Dict[str, Any] | None = None,
creation_date: Optional[date] = None,
) -> None:
"""Write a filled PDF for *attendance* to *output_path*.
Uses pypdf with ``auto_regenerate=False`` to bypass PDF JavaScript and
write field values directly into the PDF stream.
Raises :class:`PdfTemplateError` when the template is unreadable.
"""
if config is None:
config = load_config()
template_path = Path(template_path).resolve()
output_path = Path(output_path).resolve()
if not template_path.is_file():
raise PdfTemplateError(
f"PDF-Template nicht gefunden: {template_path}"
)
fields = build_field_dict(attendance, config, creation_date)
try:
reader = PdfReader(str(template_path))
except Exception as exc:
raise PdfTemplateError(
f"PDF-Template konnte nicht gelesen werden: {exc}"
) from exc
writer = PdfWriter()
writer.clone_document_from_reader(reader)
for page in writer.pages:
writer.update_page_form_field_values(page, fields, auto_regenerate=False)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("wb") as fh:
writer.write(fh)
+202
View File
@@ -0,0 +1,202 @@
"""Pre-generation validation and console preview for the Attendance PDF Generator.
Public API
----------
validate(file_path, sheet_name, config_path) -> list[str]
Returns a list of error messages (empty = all checks passed).
show_preview_and_confirm(file_path, sheet_name, year, persons, config) -> bool
Prints the structured preview and returns True only when the user types 'j'.
"""
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
from .config_loader import load_config
from .exceptions import ConfigurationError
def validate(
file_path: str | Path,
sheet_name: str,
config_path: str | Path | None = None,
) -> List[str]:
"""Run all pre-generation checks.
Returns a list of German error messages. An empty list means all checks
passed. This function never raises exceptions for business-rule failures —
it only collects them.
"""
errors: List[str] = []
file_path = Path(file_path)
# --- Check 1: config.json present and complete ---------------------------
try:
config = load_config(config_path)
except ConfigurationError as exc:
errors.append(f"Konfigurationsfehler: {exc}")
# Cannot continue without config
return errors
# --- Check 2: Excel file exists ------------------------------------------
if not file_path.exists():
errors.append(f"Excel-Datei nicht gefunden: {file_path}")
elif not file_path.suffix.lower() in (".xlsx", ".xlsm"):
errors.append(
f"Datei '{file_path.name}' ist keine Excel-Datei (.xlsx/.xlsm)."
)
else:
# --- Check 3: Year extractable from filename -------------------------
pattern = config["excel_year_pattern"]
match = re.search(pattern, file_path.name)
if not match:
errors.append(
f"Kein Jahr im Dateinamen '{file_path.name}' gefunden "
f"(Muster: '{pattern}')."
)
else:
year = int(match.group(1))
if not (1900 <= year <= 2099):
errors.append(
f"Extrahiertes Jahr {year} liegt außerhalb 1900–2099."
)
# --- Check 4–6: Sheet and column structure ---------------------------
try:
import openpyxl
wb = openpyxl.load_workbook(
str(file_path), read_only=True, data_only=True
)
sheet_names = wb.sheetnames
# Check 4: sheet name valid
if sheet_name not in config["month_mapping"]:
errors.append(
f"Sheet-Name '{sheet_name}' nicht in month_mapping der config.json."
)
if sheet_name not in sheet_names:
errors.append(
f"Sheet '{sheet_name}' existiert nicht in der Arbeitsmappe. "
f"Verfügbar: {sheet_names}"
)
else:
ws = wb[sheet_name]
rows = list(ws.iter_rows(values_only=True))
name_col: int = config["excel_name_column"]
seq_col: int = config["excel_seq_column"]
name_header: str = config["excel_name_header"]
day_start: int = config["excel_day_start_col"]
# Check 5: Column A (seq) present
any_seq = any(
len(r) > seq_col and r[seq_col] is not None for r in rows
)
if not any_seq:
errors.append("Spalte A (lfd. Nummer) enthält keine Daten.")
# Check 5b: Column B (name) present with header
header_found = any(
len(r) > name_col
and r[name_col] is not None
and name_header in str(r[name_col])
for r in rows
)
if not header_found:
errors.append(
f"Headerzeile mit '{name_header}' in Spaltenindex {name_col} "
"nicht gefunden."
)
# Check 6: Day columns present
any_days = any(
len(r) > day_start for r in rows
)
if not any_days:
errors.append(
f"Keine Tagesspalten ab Spaltenindex {day_start} gefunden."
)
# Check 7: Attendance values valid
valid_codes = [c.upper() for c in config["valid_status_codes"]]
invalid_vals: List[str] = []
for r in rows:
day_end: int = config["excel_day_end_col"]
for col_idx in range(day_start, min(day_end + 1, len(r))):
val = r[col_idx]
if val is None:
continue
if isinstance(val, (int, float)):
continue # numeric hours – valid
if isinstance(val, str):
stripped = val.strip().upper()
if stripped in valid_codes or stripped == "X":
continue
if stripped:
invalid_vals.append(repr(val))
if invalid_vals:
unique = list(dict.fromkeys(invalid_vals))[:5]
errors.append(
f"Ungültige Anwesenheitswerte in Tagesspalten: "
f"{', '.join(unique)}"
+ (" …" if len(invalid_vals) > 5 else "")
)
wb.close()
except Exception as exc:
errors.append(f"Excel-Datei konnte nicht geöffnet werden: {exc}")
# --- Check 8: PDF template accessible ------------------------------------
config_dir = Path(config_path).parent.resolve() if config_path else Path(__file__).parent.parent.resolve()
template_path = (config_dir / config["template_pdf"]).resolve()
if not template_path.is_file():
errors.append(f"PDF-Template nicht gefunden: {template_path}")
return errors
def show_preview_and_confirm(
file_path: str | Path,
sheet_name: str,
year: int,
person_count: int,
config: Dict[str, Any],
errors: Optional[List[str]] = None,
) -> bool:
"""Print the structured preview to stdout and return True only on 'j'.
If *errors* is provided and non-empty, each error is listed before the
confirmation prompt and the function still asks the user to confirm.
"""
month_mapping: Dict[str, str] = config["month_mapping"]
month_num = month_mapping.get(sheet_name, "??")
month_display = f"{sheet_name} ({month_num})"
from datetime import date
erstelldatum = date.today().strftime("%d.%m.%Y")
print()
print("=== Vorschau der ermittelten Werte ===")
print(f"Excel-Datei : {Path(file_path).name}")
print(f"Jahr : {year}")
print(f"Monat : {month_display}")
print(f"Antragsnummer : {config['antragsnummer']}")
print(f"Ort : {config['ort']}")
print(f"Erstelldatum : {erstelldatum}")
print(f"Teilnehmer : {person_count}")
print(f"Fehler : {len(errors) if errors else 0}")
if errors:
print()
print("--- Validierungsfehler ---")
for err in errors:
print(f" [!] {err}")
print()
answer = input("Möchten Sie die PDF-Generierung starten? [j/N] ").strip().lower()
return answer == "j"