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
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"
|
||||
Reference in new issue
Block a user