deepseek den plan umsetzen lassen

This commit is contained in:
trnq1ll0 committed 2026-06-09 08:33:47 +02:00
1 parent 6c60a72e0f
commit 044e4df783
16 files changed
+971 -55

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Tätigkeitsnachweis-Generator package."""
+83
View File
@@ -0,0 +1,83 @@
"""Tätigkeitsnachweis-Generator – Konfigurations-Loader."""
import configparser
import json
import fnmatch
from pathlib import Path
from typing import Optional
from .models import AppConfig, FieldSchema, OutputMapping
def load_settings(config_dir: Path, project_root: Path) -> AppConfig:
"""Lädt settings.ini und gibt ein AppConfig-Objekt zurück."""
ini_path = config_dir / "settings.ini"
if not ini_path.exists():
raise FileNotFoundError(f"settings.ini nicht gefunden: {ini_path}")
parser = configparser.ConfigParser()
parser.read(ini_path, encoding="utf-8")
paths = parser["paths"] if "paths" in parser else {}
output = parser["output"] if "output" in parser else {}
parsing = parser["parsing"] if "parsing" in parser else {}
return AppConfig(
input_dir=str(project_root / paths.get("input_dir", "input")),
template_dir=str(project_root / paths.get("template_dir", "template")),
output_dir=str(project_root / paths.get("output_dir", "output")),
filename_pattern=output.get("filename_pattern", "{last_name}_{first_name}_Tätigkeitsnachweis.xlsx"),
month_sheet_pattern=parsing.get("month_sheet_pattern", "* 20??"),
skip_empty_name=parsing.getboolean("skip_empty_name", True),
skip_empty_antragsnummer=parsing.getboolean("skip_empty_antragsnummer", True),
)
def load_input_schema(config_dir: Path) -> list[FieldSchema]:
"""Lädt input-fields.json."""
schema_path = config_dir / "input-fields.json"
if not schema_path.exists():
raise FileNotFoundError(f"input-fields.json nicht gefunden: {schema_path}")
with open(schema_path, "r", encoding="utf-8") as f:
data = json.load(f)
return [FieldSchema(**item) for item in data]
def load_output_mapping(config_dir: Path) -> list[OutputMapping]:
"""Lädt output-fields.json."""
mapping_path = config_dir / "output-fields.json"
if not mapping_path.exists():
raise FileNotFoundError(f"output-fields.json nicht gefunden: {mapping_path}")
with open(mapping_path, "r", encoding="utf-8") as f:
data = json.load(f)
return [OutputMapping(**item) for item in data]
def get_available_files(directory: Path, extension: str = ".xlsx") -> list[str]:
"""Listet alle Dateien mit der angegebenen Erweiterung im Verzeichnis auf."""
if not directory.exists():
return []
files = sorted(
f.name for f in directory.iterdir()
if f.is_file() and f.suffix.lower() == extension and not f.name.startswith("~")
)
return files
def get_available_input_files(config: AppConfig) -> list[str]:
"""Listet alle .xlsx-Dateien im input-Ordner."""
return get_available_files(Path(config.input_dir))
def get_available_templates(config: AppConfig) -> list[str]:
"""Listet alle .xlsx-Dateien im template-Ordner."""
return get_available_files(Path(config.template_dir))
def is_month_sheet(sheet_name: str, pattern: str) -> bool:
"""Prüft, ob ein Sheet-Name dem Monatsmuster entspricht."""
return fnmatch.fnmatch(sheet_name.strip(), pattern)
+74
View File
@@ -0,0 +1,74 @@
"""Tätigkeitsnachweis-Generator – Datenmodelle."""
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class EmployeeRecord:
"""Eine Zeile aus einem Monats-Sheet der Input-Datei."""
name: str
antragsnummer: str
qualifikation: str
funktion: str
stellenanteil: float
monat: str # z. B. "01.2025"
taetigkeit: str
id: Optional[int] = None
kommentar: Optional[str] = None
@property
def month_index(self) -> int:
"""Extrahiere den Monatsindex (1–12) aus dem Monatsfeld."""
try:
return int(self.monat.split(".")[0])
except (ValueError, IndexError):
return 0
@property
def year(self) -> int:
"""Extrahiere das Jahr aus dem Monatsfeld."""
try:
return int(self.monat.split(".")[1])
except (ValueError, IndexError):
return 0
@dataclass
class FieldSchema:
"""Definition eines Feldes aus input-fields.json."""
column: str
field_name: str
type: str
required: bool
@dataclass
class OutputMapping:
"""Definition eines Mappings aus output-fields.json."""
source_field: str
target_cell: str
format: Optional[str] = None
@dataclass
class AppConfig:
"""Gesamte App-Konfiguration."""
input_dir: str
template_dir: str
output_dir: str
filename_pattern: str
input_schema: list = field(default_factory=list)
output_mapping: list = field(default_factory=list)
month_sheet_pattern: str = "* 20??"
skip_empty_name: bool = True
skip_empty_antragsnummer: bool = True
@dataclass
class ValidationResult:
"""Ergebnis einer Validierung."""
is_valid: bool
errors: list = field(default_factory=list)
warnings: list = field(default_factory=list)
info: dict = field(default_factory=dict)
+118
View File
@@ -0,0 +1,118 @@
"""Input-Datei-Parser: liest monatliche Sheets und erzeugt EmployeeRecords."""
import re
from pathlib import Path
from typing import Any, Optional
from openpyxl import load_workbook
from openpyxl.utils import column_index_from_string
from .config import is_month_sheet
from .models import EmployeeRecord, FieldSchema
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() == "":
return 0.0
text = str(value).strip().replace(",", ".")
try:
return float(text)
except ValueError:
return 0.0
def _to_int_or_none(value: Any) -> Optional[int]:
if value is None or str(value).strip() == "":
return None
try:
return int(value)
except (ValueError, TypeError):
return None
def _build_column_map(schema: list[FieldSchema]) -> dict[str, int]:
"""Baut ein Mapping von field_name → 0-basierter Spaltenindex."""
col_map: dict[str, int] = {}
for field in schema:
col_map[field.field_name] = column_index_from_string(field.column) - 1
return col_map
def parse_input_file(
filepath: Path,
schema: list[FieldSchema],
month_sheet_pattern: str = "* 20??",
skip_empty_name: bool = True,
skip_empty_antragsnummer: bool = True,
) -> list[EmployeeRecord]:
"""
Öffnet die Input-Datei, iteriert über alle Monats-Sheets
und gibt eine Liste von EmployeeRecords zurück.
"""
wb = load_workbook(filepath, data_only=True)
col_map = _build_column_map(schema)
records: list[EmployeeRecord] = []
for sheet_name in wb.sheetnames:
if not is_month_sheet(sheet_name, month_sheet_pattern):
continue
ws = wb[sheet_name]
# Header ist Zeile 1, Daten ab Zeile 2
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=True):
name = _to_text(row[col_map.get("Name", 0)]) if len(row) > col_map.get("Name", 0) else ""
antragsnummer = _to_text(row[col_map.get("Antragsnummer", 0)]) if len(row) > col_map.get("Antragsnummer", 0) else ""
if skip_empty_name and not name:
continue
if skip_empty_antragsnummer and not antragsnummer:
continue
qualifikation = _to_text(row[col_map.get("Qualifikation", 0)]) if len(row) > col_map.get("Qualifikation", 0) else ""
funktion = _to_text(row[col_map.get("Funktion", 0)]) if len(row) > col_map.get("Funktion", 0) else ""
stellenanteil = _to_float(row[col_map.get("Stellenanteil in %", 0)], "Stellenanteil in %") if len(row) > col_map.get("Stellenanteil in %", 0) else 0.0
monat = _to_text(row[col_map.get("Monat (MM.JJ)", 0)]) if len(row) > col_map.get("Monat (MM.JJ)", 0) else ""
taetigkeit = _to_text(row[col_map.get("Tätigkeit", 0)]) if len(row) > col_map.get("Tätigkeit", 0) else ""
record_id = _to_int_or_none(row[col_map.get("ID", 0)]) if len(row) > col_map.get("ID", 0) else None
kommentar = _to_text(row[col_map.get("Kommentar", 1)]) if "Kommentar" in col_map and len(row) > col_map["Kommentar"] else None
records.append(EmployeeRecord(
id=record_id,
name=name,
antragsnummer=antragsnummer,
qualifikation=qualifikation,
funktion=funktion,
stellenanteil=stellenanteil,
monat=monat,
taetigkeit=taetigkeit,
kommentar=kommentar,
))
wb.close()
return records
def group_by_employee(records: list[EmployeeRecord]) -> dict[str, list[EmployeeRecord]]:
"""Gruppiert Records nach Mitarbeitername."""
groups: dict[str, list[EmployeeRecord]] = {}
for record in records:
if record.name not in groups:
groups[record.name] = []
groups[record.name].append(record)
return groups
def get_unique_antragsnummern(records: list[EmployeeRecord]) -> list[str]:
"""Gibt eine sortierte Liste aller eindeutigen Antragsnummern zurück."""
return sorted(set(r.antragsnummer for r in records if r.antragsnummer))
def get_unique_employees(records: list[EmployeeRecord]) -> list[str]:
"""Gibt eine sortierte Liste aller eindeutigen Mitarbeiternamen zurück."""
return sorted(set(r.name for r in records if r.name))
+136
View File
@@ -0,0 +1,136 @@
"""Validierung von Input-Dateien und Templates."""
from pathlib import Path
from typing import Optional
from openpyxl import load_workbook
from openpyxl.utils import column_index_from_string
from .models import FieldSchema, OutputMapping, ValidationResult
def validate_input_file(
filepath: Path,
schema: list[FieldSchema],
) -> ValidationResult:
"""
Validiert eine Input-Datei:
- Datei existiert
- Erwartete Spalten sind vorhanden (Header in Zeile 1)
- Mindestens ein Daten-Sheet enthält Daten
"""
errors: list[str] = []
warnings: list[str] = []
info: dict = {}
if not filepath.exists():
errors.append(f"Input-Datei nicht gefunden: {filepath}")
return ValidationResult(is_valid=False, errors=errors)
try:
wb = load_workbook(filepath, data_only=True)
except Exception as e:
errors.append(f"Datei konnte nicht geöffnet werden: {e}")
return ValidationResult(is_valid=False, errors=errors)
sheet_count = len(wb.sheetnames)
info["sheet_count"] = sheet_count
info["sheet_names"] = wb.sheetnames
# Prüfe, ob mindestens ein Sheet die erwarteten Spalten hat
sheets_with_data = 0
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
if ws.max_row < 2:
continue # Kein Header + Daten
header_row = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
missing_cols = []
for field in schema:
col_idx = column_index_from_string(field.column) - 1
if col_idx >= len(header_row):
missing_cols.append(f"{field.column} ({field.field_name})")
continue
header_val = str(header_row[col_idx]).strip() if header_row[col_idx] is not None else ""
if header_val != field.field_name:
warnings.append(
f"Sheet '{sheet_name}', Spalte {field.column}: "
f"Erwartet '{field.field_name}', gefunden '{header_val}'"
)
if not missing_cols:
sheets_with_data += 1
# Zähle Datenzeilen
data_rows = ws.max_row - 1
info[f"rows_{sheet_name}"] = data_rows
info["sheets_with_data"] = sheets_with_data
wb.close()
if sheets_with_data == 0:
errors.append("Kein Sheet mit den erwarteten Spalten gefunden.")
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors,
warnings=warnings,
info=info,
)
def validate_template(
template_path: Path,
mapping: list[OutputMapping],
) -> ValidationResult:
"""
Validiert ein Template:
- Datei existiert
- Alle target_cells aus dem Mapping sind im Template vorhanden
"""
errors: list[str] = []
warnings: list[str] = []
info: dict = {}
if not template_path.exists():
errors.append(f"Template-Datei nicht gefunden: {template_path}")
return ValidationResult(is_valid=False, errors=errors)
try:
wb = load_workbook(template_path, data_only=True)
except Exception as e:
errors.append(f"Template konnte nicht geöffnet werden: {e}")
return ValidationResult(is_valid=False, errors=errors)
info["sheet_names"] = wb.sheetnames
# Wir validieren gegen das erste Sheet (üblicherweise das Haupt-Template)
if not wb.sheetnames:
errors.append("Template enthält keine Sheets.")
wb.close()
return ValidationResult(is_valid=False, errors=errors)
ws = wb[wb.sheetnames[0]]
found_cells = 0
missing_cells = []
for m in mapping:
try:
cell = ws[m.target_cell]
found_cells += 1
except (ValueError, KeyError):
missing_cells.append(m.target_cell)
info["found_target_cells"] = found_cells
info["total_target_cells"] = len(mapping)
if missing_cells:
errors.append(f"Zielfelder nicht im Template gefunden: {', '.join(missing_cells)}")
wb.close()
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors,
warnings=warnings,
info=info,
)
+158
View File
@@ -0,0 +1,158 @@
"""Template-basierte Activity-Report-Generierung."""
from datetime import date
from pathlib import Path
from openpyxl import load_workbook
from openpyxl.cell.cell import MergedCell
from openpyxl.utils import column_index_from_string
from .models import EmployeeRecord, OutputMapping
def _split_name_for_filename(name: str) -> tuple[str, str]:
"""Teilt 'Nachname, Vorname' oder 'Vorname Nachname' in (Vorname, Nachname)."""
cleaned = str(name or "").strip()
if "," in cleaned:
last_name, first_name = [p.strip() for p in cleaned.split(",", 1)]
return first_name or "Unknown", last_name or "Unknown"
parts = cleaned.split()
if len(parts) >= 2:
return parts[0], parts[-1]
if len(parts) == 1:
return parts[0], "Unknown"
return "Unknown", "Unknown"
def _format_value(value, fmt: str | None) -> str:
"""Formatiert einen Wert gemäß Format-String."""
if value is None:
return ""
if fmt is None:
return str(value)
# Numerische Formatierung (z.B. "%.2f")
if "%" in fmt:
try:
return fmt % float(value)
except (ValueError, TypeError):
return str(value)
return str(value)
def _set_cell_value(ws, target_cell: str, value) -> None:
"""Setzt einen Wert in einer Zelle des Worksheets.
Behandelt MergedCells, indem in die Top-Left-Zelle des Merge-Bereichs geschrieben wird."""
try:
cell = ws[target_cell]
# Wenn es eine MergedCell ist, finde die Top-Left-Zelle des Merge-Bereichs
if isinstance(cell, MergedCell):
for merged_range in ws.merged_cells.ranges:
if cell.coordinate in merged_range:
top_left = merged_range.min_row, merged_range.min_col
ws.cell(row=top_left[0], column=top_left[1]).value = value
return
else:
cell.value = value
except (ValueError, KeyError):
pass # Zelle existiert nicht
def _format_month_for_template(monat_str: str) -> str:
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ' (z.B. '01.2025' → '01.2025', '01.25' → '01.2025')."""
monat_str = monat_str.strip()
parts = monat_str.split(".")
if len(parts) == 2:
month = parts[0]
year = parts[1]
if len(year) == 2:
year = "20" + year
return f"{month}.{year}"
return monat_str
def generate_activity_reports(
employee_records: dict[str, list[EmployeeRecord]],
template_path: Path,
mapping: list[OutputMapping],
output_dir: Path,
filename_pattern: str,
) -> list[Path]:
"""
Generiert für jeden Mitarbeiter eine Excel-Datei.
Jeder Monat wird als eigenes Sheet im Template-Stil erstellt.
"""
output_dir.mkdir(parents=True, exist_ok=True)
generated_files: list[Path] = []
# Build source_field → mapping lookup
field_map: dict[str, OutputMapping] = {m.source_field: m for m in mapping}
for employee_name, records in employee_records.items():
if not records:
continue
first_name, last_name = _split_name_for_filename(employee_name)
safe_first = first_name.replace(" ", "_").replace("/", "_")
safe_last = last_name.replace(" ", "_").replace("/", "_")
filename = filename_pattern.format(
first_name=safe_first,
last_name=safe_last,
)
output_path = output_dir / filename
# Lade Template
wb = load_workbook(template_path)
template_sheet = wb.worksheets[0]
# Sortiere Records nach Monat
sorted_records = sorted(records, key=lambda r: r.month_index)
for idx, record in enumerate(sorted_records):
if idx == 0:
sheet = template_sheet
else:
sheet = wb.copy_worksheet(template_sheet)
# Sheet-Name: Monatsname
month_names = [
"", "Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember"
]
mi = record.month_index
sheet_name = f"{mi:02d}_{month_names[mi]}" if 1 <= mi <= 12 else f"Monat_{idx + 1}"
sheet.title = sheet_name[:31]
# Befülle Zielfelder
for source_field, m in field_map.items():
if source_field == "Monat (MM.JJ)":
value = _format_month_for_template(record.monat)
elif source_field == "Stellenanteil in %":
value = record.stellenanteil
elif hasattr(record, source_field.lower().replace(" ", "_")):
value = getattr(record, source_field.lower().replace(" ", "_"))
elif source_field == "Name":
value = employee_name
elif source_field == "Antragsnummer":
value = record.antragsnummer
elif source_field == "Qualifikation":
value = record.qualifikation
elif source_field == "Funktion":
value = record.funktion
elif source_field == "Tätigkeit":
value = record.taetigkeit
else:
continue
formatted = _format_value(value, m.format)
_set_cell_value(sheet, m.target_cell, formatted)
# Setze aktuelles Datum für das Erstelldatum
today = date.today().strftime("%d.%m.%Y")
_set_cell_value(sheet, "K94", today)
wb.save(output_path)
wb.close()
generated_files.append(output_path)
return generated_files