erster versuch der KI mit Plan
This commit is contained in:
1 parent
c697c64f3a
commit
2faa1689dd
20 files changed
+1749
No files matched your search
@@ -0,0 +1,60 @@
|
||||
# Copilot Plan: Attendance PDF Generator
|
||||
|
||||
## Ziel
|
||||
Kombiniertes Python-CLI-Tool, das Anwesenheitsdaten aus einer Excel-Datei liest
|
||||
und für jeden Teilnehmer eine ausgefüllte PDF-Datei erzeugt.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
generate.py # Click CLI entry point
|
||||
config.json # Konfiguration (antragsnummer, ort, month_mapping, ...)
|
||||
schema.json # JSON-Schema der Konfiguration (Dokumentation)
|
||||
requirements.txt # openpyxl, pypdf, click
|
||||
requirements-dev.txt # + pytest
|
||||
src/
|
||||
__init__.py
|
||||
exceptions.py # Custom Exception-Hierarchie
|
||||
models.py # Datenklassen: Person, AttendanceDate, MonthlyAttendance
|
||||
config_loader.py # config.json laden & validieren
|
||||
excel_reader.py # Excel → MonthlyAttendance-Objekte
|
||||
pdf_filler.py # MonthlyAttendance → ausgefüllte PDF
|
||||
validators.py # Pre-Generation Validierung + Konsolenvorschau
|
||||
template/ # PDF-Template (sab.pdf, wird separat bereitgestellt)
|
||||
output/ # Ausgabeverzeichnis für generierte PDFs
|
||||
tests/
|
||||
__init__.py
|
||||
test_excel_reader.py
|
||||
test_pdf_filler.py
|
||||
test_validators.py
|
||||
```
|
||||
|
||||
## Datenfluss
|
||||
Excel-Datei → excel_reader → MonthlyAttendance → pdf_filler → PDF-Datei
|
||||
|
||||
## Entwurfsentscheidungen
|
||||
- `plan.N` = immer `"6"` (mind. 6h), unabhängig von tatsächlichen Stunden
|
||||
- Sheet-Auswahl: manuell via `--sheet <name>` (z.B. `--sheet Feb`)
|
||||
- Output-Benennung: `(n) Nachname, Vorname.pdf` (n = lfd. Nummer aus Spalte A)
|
||||
- `pauschale.N` = `"8,60"` wenn anwesend (X), sonst leer
|
||||
- PDF-Template liegt in `./template/sab.pdf`
|
||||
|
||||
## Excel-Struktur
|
||||
- Spalte A (Index 0): lfd. Nummer
|
||||
- Spalte B (Index 1): Name (Format: "Nachname, Vorname")
|
||||
- Spalten J–AN (Index 9–39): Tageswerte (Tag 1–31)
|
||||
- Numerischer Wert → anwesend (X), Wert = Ist-Stunden
|
||||
- "K" → Krank
|
||||
- "KK" → Kindkrank
|
||||
- "F" → unentschuldigtes Fehlen
|
||||
- "E" → entschuldigtes Fehlen
|
||||
- "P" → Praktikum
|
||||
- "U" → Urlaub
|
||||
- Leer → Zeile überspringen
|
||||
|
||||
## PDF-Feld-Mapping (pro Zeile N, max 23 Zeilen)
|
||||
| Excel-Zelle | datum.N | anwesend.N | ist.N | plan.N | pauschale.N |
|
||||
|---|---|---|---|---|---|
|
||||
| Numerisch | DD.MM.YYYY | X | Wert (dt. Format) | "6" | "8,60" |
|
||||
| "K"/"KK"/etc| DD.MM.YYYY | Code | leer | leer | leer |
|
||||
| Leer | (überspringen) | — | — | — | — |
|
||||
@@ -0,0 +1,40 @@
|
||||
# Copilot Tasks: Attendance PDF Generator
|
||||
|
||||
## Phase 1: Projekt-Setup
|
||||
- [x] Task 1.1: .github/copilot-plan.md erstellen
|
||||
- [ ] Task 1.2: requirements.txt + requirements-dev.txt erstellen
|
||||
- [ ] Task 1.3: config.json erstellen (mit Platzhaltern für antragsnummer, ort)
|
||||
- [ ] Task 1.4: schema.json erstellen
|
||||
- [ ] Task 1.5: Verzeichnisse anlegen (src/, output/, template/, tests/)
|
||||
|
||||
## Phase 2: Datenmodell & Konfiguration
|
||||
- [ ] Task 2.1: src/exceptions.py implementieren
|
||||
- [ ] Task 2.2: src/models.py implementieren (Person, AttendanceDate, MonthlyAttendance)
|
||||
- [ ] Task 2.3: src/config_loader.py implementieren
|
||||
|
||||
## Phase 3: Excel Reader
|
||||
- [ ] Task 3.1: src/excel_reader.py implementieren
|
||||
- extract_year_from_filename()
|
||||
- get_sheet_names()
|
||||
- extract_persons()
|
||||
- extract_attendance()
|
||||
|
||||
## Phase 4: PDF Filler
|
||||
- [ ] Task 4.1: src/pdf_filler.py implementieren
|
||||
- build_field_dict()
|
||||
- fill_pdf()
|
||||
|
||||
## Phase 5: Validierung & Vorschau
|
||||
- [ ] Task 5.1: src/validators.py implementieren (9 Checks)
|
||||
- [ ] Task 5.2: Konsolenvorschau + j/N-Prompt
|
||||
|
||||
## Phase 6: CLI
|
||||
- [ ] Task 6.1: generate.py mit Click implementieren
|
||||
- generate --file --sheet [--out]
|
||||
- validate --file --sheet
|
||||
|
||||
## Phase 7: Tests
|
||||
- [ ] Task 7.1: tests/test_excel_reader.py
|
||||
- [ ] Task 7.2: tests/test_pdf_filler.py
|
||||
- [ ] Task 7.3: tests/test_validators.py
|
||||
- [ ] Task 7.4: pytest ausführen, Fehler beheben
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"antragsnummer": "BITTE_EINTRAGEN",
|
||||
"ort": "BITTE_EINTRAGEN",
|
||||
"template_pdf": "template/sab.pdf",
|
||||
"output_dir": "output",
|
||||
"excel_year_pattern": "(19\\d{2}|20\\d{2})",
|
||||
"excel_name_header": "Name, Vorname",
|
||||
"excel_name_column": 1,
|
||||
"excel_seq_column": 0,
|
||||
"excel_day_start_col": 9,
|
||||
"excel_day_end_col": 39,
|
||||
"month_mapping": {
|
||||
"Jan": "01",
|
||||
"Feb": "02",
|
||||
"Mär": "03",
|
||||
"Mrz": "03",
|
||||
"Apr": "04",
|
||||
"Mai": "05",
|
||||
"Jun": "06",
|
||||
"Jul": "07",
|
||||
"Aug": "08",
|
||||
"Sep": "09",
|
||||
"Okt": "10",
|
||||
"Nov": "11",
|
||||
"Dez": "12"
|
||||
},
|
||||
"valid_status_codes": ["K", "KK", "E", "F", "P", "U"],
|
||||
"plan_hours_value": "6",
|
||||
"pauschale_value": "8,60"
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""CLI entry point for the Attendance PDF Generator.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python generate.py generate --file Anwesenheit_2026.xlsx --sheet Feb
|
||||
python generate.py validate --file Anwesenheit_2026.xlsx --sheet Feb
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
# Add project root to sys.path so 'src' is importable
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from src.config_loader import load_config
|
||||
from src.excel_reader import extract_attendance, extract_persons, extract_year_from_filename
|
||||
from src.pdf_filler import fill_pdf
|
||||
from src.validators import show_preview_and_confirm, validate
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli() -> None:
|
||||
"""Anwesenheit PDF Generator — Excel → PDF"""
|
||||
|
||||
|
||||
@cli.command("validate")
|
||||
@click.option(
|
||||
"--file",
|
||||
"excel_file",
|
||||
required=True,
|
||||
type=click.Path(exists=False, file_okay=True, dir_okay=False),
|
||||
help="Pfad zur Excel-Datei (z. B. Anwesenheit_2026.xlsx)",
|
||||
)
|
||||
@click.option(
|
||||
"--sheet",
|
||||
required=True,
|
||||
help="Name des Monats-Sheets (z. B. Feb, Mär, Jan)",
|
||||
)
|
||||
def validate_cmd(excel_file: str, sheet: str) -> None:
|
||||
"""Nur Validierung und Vorschau – keine PDFs werden erzeugt."""
|
||||
config = load_config()
|
||||
errors = validate(excel_file, sheet)
|
||||
|
||||
try:
|
||||
year = extract_year_from_filename(excel_file, config)
|
||||
except Exception:
|
||||
year = 0
|
||||
|
||||
try:
|
||||
persons = extract_persons(excel_file, sheet, config)
|
||||
person_count = len(persons)
|
||||
except Exception:
|
||||
person_count = 0
|
||||
|
||||
show_preview_and_confirm(excel_file, sheet, year, person_count, config, errors)
|
||||
|
||||
if errors:
|
||||
click.echo("\nValidierung fehlgeschlagen. Keine PDFs erzeugt.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo("\nValidierung erfolgreich.")
|
||||
|
||||
|
||||
@cli.command("generate")
|
||||
@click.option(
|
||||
"--file",
|
||||
"excel_file",
|
||||
required=True,
|
||||
type=click.Path(exists=False, file_okay=True, dir_okay=False),
|
||||
help="Pfad zur Excel-Datei (z. B. Anwesenheit_2026.xlsx)",
|
||||
)
|
||||
@click.option(
|
||||
"--sheet",
|
||||
required=True,
|
||||
help="Name des Monats-Sheets (z. B. Feb, Mär, Jan)",
|
||||
)
|
||||
@click.option(
|
||||
"--out",
|
||||
"output_dir",
|
||||
required=False,
|
||||
default=None,
|
||||
type=click.Path(file_okay=False, dir_okay=True),
|
||||
help="Ausgabeverzeichnis (Standard: output/ aus config.json)",
|
||||
)
|
||||
def generate_cmd(excel_file: str, sheet: str, output_dir: str | None) -> None:
|
||||
"""Liest Excel-Daten und erzeugt eine PDF-Datei pro Teilnehmer."""
|
||||
config = load_config()
|
||||
|
||||
# --- Validation ----------------------------------------------------------
|
||||
errors = validate(excel_file, sheet)
|
||||
|
||||
try:
|
||||
year = extract_year_from_filename(excel_file, config)
|
||||
except Exception as exc:
|
||||
click.echo(f"Fehler bei Jahresextraktion: {exc}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
persons = extract_persons(excel_file, sheet, config)
|
||||
except Exception as exc:
|
||||
click.echo(f"Fehler beim Lesen der Teilnehmer: {exc}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Preview + confirmation ----------------------------------------------
|
||||
confirmed = show_preview_and_confirm(
|
||||
excel_file, sheet, year, len(persons), config, errors
|
||||
)
|
||||
if not confirmed:
|
||||
click.echo("Abgebrochen. Keine PDFs erzeugt.")
|
||||
sys.exit(0)
|
||||
|
||||
if errors:
|
||||
click.echo("Generierung abgebrochen – es gibt Validierungsfehler.")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Resolve paths -------------------------------------------------------
|
||||
project_root = Path(__file__).parent
|
||||
template_path = (project_root / config["template_pdf"]).resolve()
|
||||
out_dir = Path(output_dir).resolve() if output_dir else (project_root / config["output_dir"]).resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# --- Generate PDFs -------------------------------------------------------
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for person in persons:
|
||||
try:
|
||||
attendance = extract_attendance(excel_file, sheet, person, year, config)
|
||||
output_filename = f"{person.filename_safe_name}.pdf"
|
||||
output_path = out_dir / output_filename
|
||||
fill_pdf(attendance, template_path, output_path, config)
|
||||
click.echo(f" [OK] {output_filename}")
|
||||
success_count += 1
|
||||
except Exception as exc:
|
||||
click.echo(f" [FEHLER] {person.filename_safe_name}: {exc}", err=True)
|
||||
fail_count += 1
|
||||
|
||||
click.echo()
|
||||
click.echo(f"Fertig: {success_count} PDF(s) erzeugt, {fail_count} Fehler.")
|
||||
click.echo(f"Ausgabeverzeichnis: {out_dir}")
|
||||
|
||||
if fail_count > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
Whitespace-only changes.
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=7.0.0
|
||||
@@ -0,0 +1,3 @@
|
||||
openpyxl>=3.1.2
|
||||
pypdf>=4.0.0
|
||||
click>=8.1.0
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Attendance PDF Generator – Konfiguration",
|
||||
"description": "Konfigurationsschema für config.json",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"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"
|
||||
],
|
||||
"properties": {
|
||||
"antragsnummer": {
|
||||
"type": "string",
|
||||
"description": "SAB-Antragsnummer (statisch, aus config)"
|
||||
},
|
||||
"ort": {
|
||||
"type": "string",
|
||||
"description": "Ort für die Unterschrift (statisch, aus config)"
|
||||
},
|
||||
"template_pdf": {
|
||||
"type": "string",
|
||||
"description": "Relativer Pfad zum PDF-Template"
|
||||
},
|
||||
"output_dir": {
|
||||
"type": "string",
|
||||
"description": "Ausgabeverzeichnis für generierte PDFs"
|
||||
},
|
||||
"excel_year_pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex-Muster zur Jahresextraktion aus dem Dateinamen"
|
||||
},
|
||||
"excel_name_header": {
|
||||
"type": "string",
|
||||
"description": "Text in der Headerzeile der Namensspalte"
|
||||
},
|
||||
"excel_name_column": {
|
||||
"type": "integer",
|
||||
"description": "0-basierter Spaltenindex der Namensspalte (Spalte B = 1)"
|
||||
},
|
||||
"excel_seq_column": {
|
||||
"type": "integer",
|
||||
"description": "0-basierter Spaltenindex der lfd. Nummer (Spalte A = 0)"
|
||||
},
|
||||
"excel_day_start_col": {
|
||||
"type": "integer",
|
||||
"description": "0-basierter Spaltenindex des ersten Tages (Spalte J = 9)"
|
||||
},
|
||||
"excel_day_end_col": {
|
||||
"type": "integer",
|
||||
"description": "0-basierter Spaltenindex des letzten Tages (Spalte AN = 39)"
|
||||
},
|
||||
"month_mapping": {
|
||||
"type": "object",
|
||||
"description": "Mapping der deutschen Monatsabkürzungen auf zweistellige Monatsnummern",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"pattern": "^(0[1-9]|1[0-2])$"
|
||||
}
|
||||
},
|
||||
"valid_status_codes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Gültige String-Statuscodes in Excel-Zellen (außer numerisch)"
|
||||
},
|
||||
"plan_hours_value": {
|
||||
"type": "string",
|
||||
"description": "Wert für das plan.N Dropdown (immer '6')"
|
||||
},
|
||||
"pauschale_value": {
|
||||
"type": "string",
|
||||
"description": "Wert für das pauschale.N Feld wenn anwesend"
|
||||
}
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,278 @@
|
||||
"""Tests for src/excel_reader.py — file-independent business logic."""
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.exceptions import ExcelDataError, ExcelSheetError, YearExtractionError
|
||||
from src.excel_reader import (
|
||||
_parse_cell_value,
|
||||
extract_year_from_filename,
|
||||
extract_persons,
|
||||
extract_attendance,
|
||||
)
|
||||
from src.models import Person
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal config fixture (no real file needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def cfg():
|
||||
return {
|
||||
"excel_year_pattern": "(19\\d{2}|20\\d{2})",
|
||||
"excel_name_header": "Name, Vorname",
|
||||
"excel_name_column": 1,
|
||||
"excel_seq_column": 0,
|
||||
"excel_day_start_col": 9,
|
||||
"excel_day_end_col": 39,
|
||||
"month_mapping": {
|
||||
"Jan": "01", "Feb": "02", "Mär": "03", "Mrz": "03",
|
||||
"Apr": "04", "Mai": "05", "Jun": "06", "Jul": "07",
|
||||
"Aug": "08", "Sep": "09", "Okt": "10", "Nov": "11", "Dez": "12",
|
||||
},
|
||||
"valid_status_codes": ["K", "KK", "E", "F", "P", "U"],
|
||||
"plan_hours_value": "6",
|
||||
"pauschale_value": "8,60",
|
||||
"antragsnummer": "12345",
|
||||
"ort": "Teststadt",
|
||||
"template_pdf": "template/sab.pdf",
|
||||
"output_dir": "output",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_year_from_filename
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractYearFromFilename:
|
||||
def test_standard_filename(self, cfg):
|
||||
assert extract_year_from_filename("Anwesenheit_2026.xlsx", cfg) == 2026
|
||||
|
||||
def test_year_embedded_in_path(self, cfg):
|
||||
assert extract_year_from_filename("/data/exports/Anwesenheit_2024.xlsx", cfg) == 2024
|
||||
|
||||
def test_no_year_raises(self, cfg):
|
||||
with pytest.raises(YearExtractionError):
|
||||
extract_year_from_filename("Anwesenheit.xlsx", cfg)
|
||||
|
||||
def test_out_of_range_raises(self, cfg):
|
||||
# Overwrite pattern to force out-of-range year
|
||||
bad_cfg = {**cfg, "excel_year_pattern": "(1800)"}
|
||||
with pytest.raises(YearExtractionError):
|
||||
extract_year_from_filename("data_1800.xlsx", bad_cfg)
|
||||
|
||||
def test_first_year_match_wins(self, cfg):
|
||||
# Two years in name — first one is used
|
||||
assert extract_year_from_filename("2023_to_2025_export.xlsx", cfg) == 2023
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_cell_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseCellValue:
|
||||
VALID_CODES = ["K", "KK", "E", "F", "P", "U"]
|
||||
|
||||
def test_numeric_int(self):
|
||||
result = _parse_cell_value(6, self.VALID_CODES)
|
||||
assert result == ("X", 6.0)
|
||||
|
||||
def test_numeric_float(self):
|
||||
result = _parse_cell_value(6.5, self.VALID_CODES)
|
||||
assert result == ("X", 6.5)
|
||||
|
||||
def test_zero_skipped(self):
|
||||
assert _parse_cell_value(0, self.VALID_CODES) is None
|
||||
|
||||
def test_negative_skipped(self):
|
||||
assert _parse_cell_value(-1, self.VALID_CODES) is None
|
||||
|
||||
def test_none_skipped(self):
|
||||
assert _parse_cell_value(None, self.VALID_CODES) is None
|
||||
|
||||
def test_empty_string_skipped(self):
|
||||
assert _parse_cell_value("", self.VALID_CODES) is None
|
||||
assert _parse_cell_value(" ", self.VALID_CODES) is None
|
||||
|
||||
def test_status_codes(self):
|
||||
for code in self.VALID_CODES:
|
||||
result = _parse_cell_value(code, self.VALID_CODES)
|
||||
assert result is not None
|
||||
assert result[0] == code
|
||||
assert result[1] is None
|
||||
|
||||
def test_lowercase_status_code(self):
|
||||
result = _parse_cell_value("k", self.VALID_CODES)
|
||||
assert result is not None
|
||||
assert result[0] == "K"
|
||||
|
||||
def test_explicit_x_no_hours(self):
|
||||
result = _parse_cell_value("X", self.VALID_CODES)
|
||||
assert result == ("X", None)
|
||||
|
||||
def test_unknown_string_skipped(self):
|
||||
assert _parse_cell_value("UNBEKANNT", self.VALID_CODES) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_persons (mocked workbook)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_row(*values):
|
||||
"""Create a list of values as a mock row."""
|
||||
return list(values)
|
||||
|
||||
|
||||
def _sheet_data_persons():
|
||||
"""Simulated sheet data: header row + 3 persons."""
|
||||
return [
|
||||
[None, "Einrichtung XY", None], # row 0 – ignored
|
||||
[None, "Name, Vorname", None], # row 1 – header
|
||||
[1, "Mustermann, Max", None], # row 2 – person 1
|
||||
[2, "Beispiel, Anna", None], # row 3 – person 2
|
||||
[3, "Test, Peter", None], # row 4 – person 3
|
||||
[None, None, None], # row 5 – empty row
|
||||
]
|
||||
|
||||
|
||||
class TestExtractPersons:
|
||||
def test_returns_correct_persons(self, cfg):
|
||||
with patch("src.excel_reader._open_workbook") as mock_open, \
|
||||
patch("src.excel_reader._sheet_data", return_value=_sheet_data_persons()):
|
||||
mock_wb = MagicMock()
|
||||
mock_wb.sheetnames = ["Feb"]
|
||||
mock_open.return_value = mock_wb
|
||||
|
||||
persons = extract_persons("dummy.xlsx", "Feb", cfg)
|
||||
|
||||
assert len(persons) == 3
|
||||
assert persons[0].seq_num == 1
|
||||
assert persons[1].name == "Beispiel, Anna"
|
||||
assert persons[2].seq_num == 3
|
||||
|
||||
def test_empty_rows_ignored(self, cfg):
|
||||
with patch("src.excel_reader._open_workbook") as mock_open, \
|
||||
patch("src.excel_reader._sheet_data", return_value=_sheet_data_persons()):
|
||||
mock_open.return_value = MagicMock(sheetnames=["Feb"])
|
||||
persons = extract_persons("dummy.xlsx", "Feb", cfg)
|
||||
|
||||
assert all(p.name for p in persons)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_attendance (mocked workbook)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _sheet_data_attendance():
|
||||
"""
|
||||
Simulated month sheet with one person row.
|
||||
Columns 0-8: seq, name, and spare cols.
|
||||
Columns 9-39: days 1-31.
|
||||
Day 1 (col 9): 6.0 → present 6h
|
||||
Day 2 (col 10): "K" → sick
|
||||
Day 3 (col 11): "KK" → child sick
|
||||
Day 4 (col 12): None → skip
|
||||
Day 5 (col 13): "F" → unexcused absence
|
||||
"""
|
||||
person_row = [None] * 40
|
||||
person_row[0] = 1
|
||||
person_row[1] = "Mustermann, Max"
|
||||
person_row[9] = 6.0
|
||||
person_row[10] = "K"
|
||||
person_row[11] = "KK"
|
||||
person_row[12] = None
|
||||
person_row[13] = "F"
|
||||
|
||||
return [
|
||||
[None, "Name, Vorname"] + [None] * 38, # header row
|
||||
person_row,
|
||||
]
|
||||
|
||||
|
||||
class TestExtractAttendance:
|
||||
def test_present_day(self, cfg):
|
||||
with patch("src.excel_reader._open_workbook") as mock_open, \
|
||||
patch("src.excel_reader._sheet_data", return_value=_sheet_data_attendance()):
|
||||
mock_open.return_value = MagicMock(sheetnames=["Feb"])
|
||||
person = Person(seq_num=1, name="Mustermann, Max")
|
||||
attendance = extract_attendance("dummy.xlsx", "Feb", person, 2026, cfg)
|
||||
|
||||
entry = attendance.entries[0]
|
||||
assert entry.status == "X"
|
||||
assert entry.hours == 6.0
|
||||
assert entry.date == date(2026, 2, 1)
|
||||
|
||||
def test_sick_day(self, cfg):
|
||||
with patch("src.excel_reader._open_workbook") as mock_open, \
|
||||
patch("src.excel_reader._sheet_data", return_value=_sheet_data_attendance()):
|
||||
mock_open.return_value = MagicMock(sheetnames=["Feb"])
|
||||
person = Person(seq_num=1, name="Mustermann, Max")
|
||||
attendance = extract_attendance("dummy.xlsx", "Feb", person, 2026, cfg)
|
||||
|
||||
entry = attendance.entries[1]
|
||||
assert entry.status == "K"
|
||||
assert entry.hours is None
|
||||
|
||||
def test_empty_day_skipped(self, cfg):
|
||||
with patch("src.excel_reader._open_workbook") as mock_open, \
|
||||
patch("src.excel_reader._sheet_data", return_value=_sheet_data_attendance()):
|
||||
mock_open.return_value = MagicMock(sheetnames=["Feb"])
|
||||
person = Person(seq_num=1, name="Mustermann, Max")
|
||||
attendance = extract_attendance("dummy.xlsx", "Feb", person, 2026, cfg)
|
||||
|
||||
# Day 4 (None) is skipped, so entry index 2 is Day 3 (KK) and index 3 is Day 5 (F)
|
||||
assert len(attendance.entries) == 4
|
||||
assert attendance.entries[2].status == "KK"
|
||||
assert attendance.entries[3].status == "F"
|
||||
|
||||
def test_max_23_entries(self, cfg):
|
||||
"""More than 23 valid entries must be truncated to 23."""
|
||||
person_row = [None] * 40
|
||||
person_row[0] = 1
|
||||
person_row[1] = "Viel, Anwesend"
|
||||
for col in range(9, 40): # 31 days all present
|
||||
person_row[col] = 6.0
|
||||
|
||||
sheet_data = [
|
||||
[None, "Name, Vorname"] + [None] * 38,
|
||||
person_row,
|
||||
]
|
||||
with patch("src.excel_reader._open_workbook") as mock_open, \
|
||||
patch("src.excel_reader._sheet_data", return_value=sheet_data):
|
||||
mock_open.return_value = MagicMock(sheetnames=["Jan"])
|
||||
person = Person(seq_num=1, name="Viel, Anwesend")
|
||||
attendance = extract_attendance("dummy.xlsx", "Jan", person, 2026, cfg)
|
||||
|
||||
assert len(attendance.entries) == 23
|
||||
|
||||
def test_invalid_month_raises(self, cfg):
|
||||
with pytest.raises(ExcelSheetError):
|
||||
extract_attendance("dummy.xlsx", "UNGÜLTIG", Person(1, "X, Y"), 2026, cfg)
|
||||
|
||||
def test_february_day_29_invalid_year(self, cfg):
|
||||
"""Day 29 in Feb of a non-leap year must be skipped."""
|
||||
person_row = [None] * 40
|
||||
person_row[0] = 1
|
||||
person_row[1] = "Schaltjahr, Test"
|
||||
person_row[9 + 27] = 6.0 # day 28 = col 36 → valid
|
||||
person_row[9 + 28] = 6.0 # day 29 = col 37 → invalid (2025 not leap year)
|
||||
person_row[9 + 29] = 6.0 # day 30 = col 38 → invalid
|
||||
|
||||
sheet_data = [
|
||||
[None, "Name, Vorname"] + [None] * 38,
|
||||
person_row,
|
||||
]
|
||||
with patch("src.excel_reader._open_workbook") as mock_open, \
|
||||
patch("src.excel_reader._sheet_data", return_value=sheet_data):
|
||||
mock_open.return_value = MagicMock(sheetnames=["Feb"])
|
||||
person = Person(seq_num=1, name="Schaltjahr, Test")
|
||||
attendance = extract_attendance("dummy.xlsx", "Feb", person, 2025, cfg)
|
||||
|
||||
# Only day 28 is valid in Feb 2025
|
||||
assert len(attendance.entries) == 1
|
||||
assert attendance.entries[0].date == date(2025, 2, 28)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests for src/pdf_filler.py — field dict building and pauschale/plan logic."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models import AttendanceDate, MonthlyAttendance, Person
|
||||
from src.pdf_filler import build_field_dict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def cfg():
|
||||
return {
|
||||
"antragsnummer": "SAB-12345",
|
||||
"ort": "Leipzig",
|
||||
"template_pdf": "template/sab.pdf",
|
||||
"output_dir": "output",
|
||||
"plan_hours_value": "6",
|
||||
"pauschale_value": "8,60",
|
||||
"excel_year_pattern": "(20\\d{2})",
|
||||
"excel_name_header": "Name, Vorname",
|
||||
"excel_name_column": 1,
|
||||
"excel_seq_column": 0,
|
||||
"excel_day_start_col": 9,
|
||||
"excel_day_end_col": 39,
|
||||
"month_mapping": {"Feb": "02"},
|
||||
"valid_status_codes": ["K", "KK", "E", "F", "P", "U"],
|
||||
}
|
||||
|
||||
|
||||
def _make_attendance(*entries) -> MonthlyAttendance:
|
||||
person = Person(seq_num=1, name="Teilnehmer, Test")
|
||||
return MonthlyAttendance(
|
||||
person=person,
|
||||
year=2026,
|
||||
month=2,
|
||||
month_name="Feb",
|
||||
entries=list(entries),
|
||||
)
|
||||
|
||||
|
||||
def _present(day: int, hours: float) -> AttendanceDate:
|
||||
return AttendanceDate(date=date(2026, 2, day), status="X", hours=hours)
|
||||
|
||||
|
||||
def _absent(day: int, status: str) -> AttendanceDate:
|
||||
return AttendanceDate(date=date(2026, 2, day), status=status, hours=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHeaderFields:
|
||||
def test_person_name(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1))
|
||||
assert fields["person.name"] == "Teilnehmer, Test"
|
||||
|
||||
def test_monat_jahr(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1))
|
||||
assert fields["monat_jahr"] == "02/2026"
|
||||
|
||||
def test_antragsnummer(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1))
|
||||
assert fields["antragsnummer"] == "SAB-12345"
|
||||
|
||||
def test_ort(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(), cfg, date(2026, 2, 1))
|
||||
assert fields["ort"] == "Leipzig"
|
||||
|
||||
def test_erstellungsdatum(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(), cfg, date(2026, 3, 27))
|
||||
assert fields["erstellungsdatum"] == "27.03.2026"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Present day (status X)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPresentDay:
|
||||
def test_datum_field(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1))
|
||||
assert fields["datum.0"] == "03.02.2026"
|
||||
|
||||
def test_anwesend_field(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1))
|
||||
assert fields["anwesend.0"] == "X"
|
||||
|
||||
def test_ist_field_whole_hours(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1))
|
||||
assert fields["ist.0"] == "6"
|
||||
|
||||
def test_ist_field_decimal_hours(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(_present(3, 6.5)), cfg, date(2026, 2, 1))
|
||||
assert fields["ist.0"] == "6,5"
|
||||
|
||||
def test_plan_always_6(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(_present(3, 3.0)), cfg, date(2026, 2, 1))
|
||||
assert fields["plan.0"] == "6"
|
||||
|
||||
def test_pauschale_set(self, cfg):
|
||||
fields = build_field_dict(_make_attendance(_present(3, 6.0)), cfg, date(2026, 2, 1))
|
||||
assert fields["pauschale.0"] == "8,60"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Absent days (K, KK, E, F, P, U)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAbsentDay:
|
||||
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
|
||||
def test_anwesend_field(self, cfg, status):
|
||||
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
||||
assert fields["anwesend.0"] == status
|
||||
|
||||
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
|
||||
def test_ist_empty(self, cfg, status):
|
||||
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
||||
assert fields["ist.0"] == ""
|
||||
|
||||
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
|
||||
def test_plan_empty(self, cfg, status):
|
||||
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
||||
assert fields["plan.0"] == ""
|
||||
|
||||
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
|
||||
def test_pauschale_empty(self, cfg, status):
|
||||
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
||||
assert fields["pauschale.0"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-row indexing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiRow:
|
||||
def test_second_entry_indexed_as_1(self, cfg):
|
||||
entries = [_present(1, 6.0), _absent(2, "K")]
|
||||
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
|
||||
assert fields["datum.1"] == "02.02.2026"
|
||||
assert fields["anwesend.1"] == "K"
|
||||
|
||||
def test_23_entries_produce_index_0_to_22(self, cfg):
|
||||
entries = [_present(i, 6.0) for i in range(1, 24)] # 23 entries max
|
||||
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
|
||||
# Index 22 should exist
|
||||
assert "datum.22" in fields
|
||||
assert "anwesend.22" in fields
|
||||
# Index 23 must NOT exist
|
||||
assert "datum.23" not in fields
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hours formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHoursFormatting:
|
||||
def test_whole_number_no_decimal(self):
|
||||
entry = AttendanceDate(date=date(2026, 2, 1), status="X", hours=6.0)
|
||||
assert entry.hours_german() == "6"
|
||||
|
||||
def test_decimal_comma_separator(self):
|
||||
entry = AttendanceDate(date=date(2026, 2, 1), status="X", hours=6.5)
|
||||
assert entry.hours_german() == "6,5"
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
entry = AttendanceDate(date=date(2026, 2, 1), status="K", hours=None)
|
||||
assert entry.hours_german() == ""
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for src/validators.py — pre-generation validation logic."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.validators import validate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _write_config(tmp_path: Path, overrides: dict = None) -> Path:
|
||||
"""Write a valid config.json into tmp_path, optionally applying overrides."""
|
||||
cfg = {
|
||||
"antragsnummer": "12345",
|
||||
"ort": "Teststadt",
|
||||
"template_pdf": "template/sab.pdf",
|
||||
"output_dir": "output",
|
||||
"excel_year_pattern": "(19\\d{2}|20\\d{2})",
|
||||
"excel_name_header": "Name, Vorname",
|
||||
"excel_name_column": 1,
|
||||
"excel_seq_column": 0,
|
||||
"excel_day_start_col": 9,
|
||||
"excel_day_end_col": 39,
|
||||
"month_mapping": {"Feb": "02", "Jan": "01"},
|
||||
"valid_status_codes": ["K", "KK", "E", "F", "P", "U"],
|
||||
"plan_hours_value": "6",
|
||||
"pauschale_value": "8,60",
|
||||
}
|
||||
if overrides:
|
||||
cfg.update(overrides)
|
||||
cfg_path = tmp_path / "config.json"
|
||||
cfg_path.write_text(json.dumps(cfg), encoding="utf-8")
|
||||
return cfg_path
|
||||
|
||||
|
||||
def _write_dummy_xlsx(tmp_path: Path, name: str = "Anwesenheit_2026.xlsx") -> Path:
|
||||
"""Create a minimal valid xlsx file using openpyxl."""
|
||||
import openpyxl
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Feb"
|
||||
# Header row
|
||||
ws.cell(row=1, column=2, value="Name, Vorname")
|
||||
# Day headers (columns J–AN = col 10–40 in 1-based)
|
||||
for col in range(10, 41):
|
||||
ws.cell(row=1, column=col, value=col - 9) # day number as header
|
||||
# Person row
|
||||
ws.cell(row=2, column=1, value=1)
|
||||
ws.cell(row=2, column=2, value="Mustermann, Max")
|
||||
ws.cell(row=2, column=10, value=6.0) # day 1 → present 6h
|
||||
xlsx_path = tmp_path / name
|
||||
wb.save(str(xlsx_path))
|
||||
return xlsx_path
|
||||
|
||||
|
||||
def _write_dummy_pdf(tmp_path: Path) -> Path:
|
||||
"""Create a 1-byte placeholder file that passes existence checks."""
|
||||
pdf_path = tmp_path / "template" / "sab.pdf"
|
||||
pdf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pdf_path.write_bytes(b"%PDF-1.4 placeholder")
|
||||
return pdf_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidate:
|
||||
def test_all_checks_pass(self, tmp_path):
|
||||
cfg_path = _write_config(tmp_path)
|
||||
xlsx_path = _write_dummy_xlsx(tmp_path)
|
||||
_write_dummy_pdf(tmp_path)
|
||||
|
||||
# Reset config cache so the test config is used
|
||||
from src.config_loader import reset_config_cache
|
||||
reset_config_cache()
|
||||
|
||||
errors = validate(xlsx_path, "Feb", cfg_path)
|
||||
assert errors == []
|
||||
|
||||
def test_excel_file_missing(self, tmp_path):
|
||||
cfg_path = _write_config(tmp_path)
|
||||
_write_dummy_pdf(tmp_path)
|
||||
|
||||
from src.config_loader import reset_config_cache
|
||||
reset_config_cache()
|
||||
|
||||
errors = validate(tmp_path / "nonexistent.xlsx", "Feb", cfg_path)
|
||||
assert any("nicht gefunden" in e for e in errors)
|
||||
|
||||
def test_year_not_in_filename(self, tmp_path):
|
||||
cfg_path = _write_config(tmp_path)
|
||||
xlsx_path = _write_dummy_xlsx(tmp_path, name="Anwesenheit_kein_jahr.xlsx")
|
||||
_write_dummy_pdf(tmp_path)
|
||||
|
||||
from src.config_loader import reset_config_cache
|
||||
reset_config_cache()
|
||||
|
||||
errors = validate(xlsx_path, "Feb", cfg_path)
|
||||
assert any("Jahr" in e for e in errors)
|
||||
|
||||
def test_unknown_sheet_name(self, tmp_path):
|
||||
cfg_path = _write_config(tmp_path)
|
||||
xlsx_path = _write_dummy_xlsx(tmp_path)
|
||||
_write_dummy_pdf(tmp_path)
|
||||
|
||||
from src.config_loader import reset_config_cache
|
||||
reset_config_cache()
|
||||
|
||||
errors = validate(xlsx_path, "Xyz", cfg_path)
|
||||
assert any("month_mapping" in e or "Sheet" in e for e in errors)
|
||||
|
||||
def test_pdf_template_missing(self, tmp_path):
|
||||
# No _write_dummy_pdf call → template missing
|
||||
cfg_path = _write_config(tmp_path)
|
||||
xlsx_path = _write_dummy_xlsx(tmp_path)
|
||||
|
||||
from src.config_loader import reset_config_cache
|
||||
reset_config_cache()
|
||||
|
||||
errors = validate(xlsx_path, "Feb", cfg_path)
|
||||
assert any("Template" in e or "template" in e.lower() for e in errors)
|
||||
|
||||
def test_config_missing_key(self, tmp_path):
|
||||
cfg = {
|
||||
"antragsnummer": "123",
|
||||
# omit 'ort' and other required keys
|
||||
}
|
||||
cfg_path = tmp_path / "config.json"
|
||||
cfg_path.write_text(json.dumps(cfg), encoding="utf-8")
|
||||
|
||||
from src.config_loader import reset_config_cache
|
||||
reset_config_cache()
|
||||
|
||||
errors = validate(tmp_path / "file.xlsx", "Feb", cfg_path)
|
||||
assert any("Konfigurationsfehler" in e or "fehlen" in e.lower() for e in errors)
|
||||
|
||||
def test_non_xlsx_extension_flagged(self, tmp_path):
|
||||
cfg_path = _write_config(tmp_path)
|
||||
_write_dummy_pdf(tmp_path)
|
||||
|
||||
csv_path = tmp_path / "Anwesenheit_2026.csv"
|
||||
csv_path.write_text("dummy")
|
||||
|
||||
from src.config_loader import reset_config_cache
|
||||
reset_config_cache()
|
||||
|
||||
errors = validate(csv_path, "Feb", cfg_path)
|
||||
assert any(".xlsx" in e or "Excel" in e for e in errors)
|
||||
Reference in new issue
Block a user