Initial commit: NRW Dienstplan Generator (Variante 2)

This commit is contained in:
Kenearos 2025-11-14 14:43:34 +01:00
commit 99480bb7ff
10 changed files with 960 additions and 0 deletions

301
src/build_template.py Normal file
View file

@ -0,0 +1,301 @@
"""Builds an empty Excel template for the NRW duty roster rules (Variante 2 - streng)."""
from pathlib import Path
from datetime import date
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.worksheet.table import Table, TableStyleInfo
from openpyxl.utils import get_column_letter
TEMPLATE_PATH = Path("templates/Dienstplan_Vorlage_V2_NRW.xlsx")
MAX_PLAN_ROWS = 400
MAX_HOLIDAY_ROWS = 50
NRW_HOLIDAYS_2025 = [
("2025-01-01", "Neujahr", "NRW"),
("2025-04-18", "Karfreitag", "NRW"),
("2025-04-21", "Ostermontag", "NRW"),
("2025-05-01", "Tag der Arbeit", "NRW"),
("2025-05-29", "Christi Himmelfahrt", "NRW"),
("2025-06-09", "Pfingstmontag", "NRW"),
("2025-06-19", "Fronleichnam", "NRW"),
("2025-10-03", "Tag der Deutschen Einheit", "NRW"),
("2025-11-01", "Allerheiligen", "NRW"),
("2025-12-25", "1. Weihnachtstag", "NRW"),
("2025-12-26", "2. Weihnachtstag", "NRW"),
]
NRW_HOLIDAYS_2026 = [
("2026-01-01", "Neujahr", "NRW"),
("2026-04-03", "Karfreitag", "NRW"),
("2026-04-06", "Ostermontag", "NRW"),
("2026-05-01", "Tag der Arbeit", "NRW"),
("2026-05-14", "Christi Himmelfahrt", "NRW"),
("2026-05-25", "Pfingstmontag", "NRW"),
("2026-06-04", "Fronleichnam", "NRW"),
("2026-10-03", "Tag der Deutschen Einheit", "NRW"),
("2026-11-01", "Allerheiligen", "NRW"),
("2026-12-25", "1. Weihnachtstag", "NRW"),
("2026-12-26", "2. Weihnachtstag", "NRW"),
]
def _style_header(ws, row=1):
"""Apply header styling."""
fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
font = Font(bold=True, color="FFFFFF", size=11)
for cell in ws[row]:
if cell.value:
cell.font = font
cell.fill = fill
cell.alignment = Alignment(horizontal="center", vertical="center")
def _populate_readme(ws):
ws["A1"] = "NRW-Dienstplan (Variante 2 streng)"
ws["A1"].font = Font(bold=True, size=14)
ws["A3"] = "Kurzregeln"
ws["A3"].font = Font(bold=True)
rules = [
"WE-Tag = Fr/Sa/So/Feiertag/Vortag (BL-abhängig).",
"Variante 2 (streng): WE werden nur vergütet, wenn im Monat ≥ 2,0 WE-Einheiten erreicht werden;",
"dann 450 €/WE und Abzug 1,0 (Freitag zuerst). WT werden immer mit 250 € vergütet.",
"Splits anteilig. Monat und Bundesland in 'Regeln' wählen.",
"",
"Schritte:",
"1. In 'Regeln': Monat_Auswahl (erster Tag) + BL_Auswahl setzen.",
"2. 'Feiertage' kontrollieren bzw. erweitern.",
"3. Im Blatt 'Plan' pro Tag Datum, Mitarbeiter und Anteil (01) eintragen.",
"4. Auswertung erfolgt automatisch im Blatt 'Auswertung'.",
"5. 'Checks' zeigt Unstimmigkeiten (Summe Anteil ≠ 1, etc.).",
]
for idx, text in enumerate(rules, start=4):
ws[f"A{idx}"] = text
ws.column_dimensions["A"].width = 100
def _populate_rules(ws):
headers = ["Parameter", "Wert", "Beschreibung"]
ws.append(headers)
rows = [
("Satz_WT", 250, "Euro für jeden Werktagsdienst (MoDo, sofern kein WE-Tag)"),
("Satz_WE", 450, "Euro für jeden WE-Tag (FrSo, Feiertag, Vortag Feiertag)"),
("WE_Schwelle", 2.0, "Ab dieser WE-Anzahl wird vergütet (sonst 0 €)"),
("Abzug_nach_WE_Schwelle", 1.0, "Einheiten, die nach Erreichen der Schwelle abgezogen werden"),
("BL_Auswahl", "NRW", "Bundesland (steuert Feiertage)"),
("Monat_Auswahl", date(2025, 11, 1), "Erster Tag des Zielmonats"),
("Variante", 2, "Fix: 2 = streng (WE nur bei Schwelle ≥ 2,0)"),
]
for param, value, desc in rows:
ws.append([param, value, desc])
ws.column_dimensions["A"].width = 26
ws.column_dimensions["B"].width = 18
ws.column_dimensions["C"].width = 80
_style_header(ws)
def _populate_holidays(ws):
headers = ["Datum", "Name", "BL"]
ws.append(headers)
all_holidays = NRW_HOLIDAYS_2025 + NRW_HOLIDAYS_2026
for iso_date, name, bl in all_holidays:
ws.append([iso_date, name, bl])
# Create table
tab = Table(displayName="tblFeiertage", ref=f"A1:C{len(all_holidays)+1}")
style = TableStyleInfo(name="TableStyleMedium9", showFirstColumn=False,
showLastColumn=False, showRowStripes=True, showColumnStripes=False)
tab.tableStyleInfo = style
ws.add_table(tab)
ws.column_dimensions["A"].width = 14
ws.column_dimensions["B"].width = 32
ws.column_dimensions["C"].width = 8
_style_header(ws)
def _plan_formulas(row: int) -> dict:
"""Return helper-column formulas for Plan sheet (Variante 2)."""
date_cell = f"A{row}"
anteil_cell = f"C{row}"
# Holiday range filtered by BL (Non-365 fallback with SUMPRODUCT)
holiday_check = f'SUMMENPRODUKT((tblFeiertage[Datum]={date_cell})*(tblFeiertage[BL]=Regeln!$B$6))>0'
vortag_check = f'SUMMENPRODUKT((tblFeiertage[Datum]={date_cell}+1)*(tblFeiertage[BL]=Regeln!$B$6))>0'
return {
"D": f"=WENNFEHLER({holiday_check};FALSCH)", # Ist_FEIERTAG
"E": f"=WENNFEHLER({vortag_check};FALSCH)", # Ist_VORTAG
"F": f"=WENNFEHLER(WOCHENTAG({date_cell};2)=5;FALSCH)", # Ist_Freitag
"G": f"=ODER($F{row};WOCHENTAG({date_cell};2)=6;WOCHENTAG({date_cell};2)=7;$D{row};$E{row})", # Ist_WE_Tag
"H": f"=NICHT($G{row})", # Ist_WT_Tag
"I": f"=WENN($H{row};{anteil_cell};0)", # WT_Einheit
"J": f"=WENN(UND($G{row};$F{row});{anteil_cell};0)", # WE_Freitag_Einheit
"K": f"=WENN(UND($G{row};NICHT($F{row}));{anteil_cell};0)", # WE_Andere_Einheit
}
def _populate_plan(ws):
headers = [
"Datum", "Mitarbeiter", "Anteil",
"Ist_FEIERTAG", "Ist_VORTAG", "Ist_Freitag", "Ist_WE_Tag", "Ist_WT_Tag",
"WT_Einheit", "WE_Freitag_Einheit", "WE_Andere_Einheit"
]
ws.append(headers)
_style_header(ws)
for row in range(2, MAX_PLAN_ROWS + 2):
formulas = _plan_formulas(row)
for col_letter, formula in formulas.items():
ws[f"{col_letter}{row}"] = formula
# Table
tab = Table(displayName="tblPlan", ref=f"A1:K{MAX_PLAN_ROWS+1}")
style = TableStyleInfo(name="TableStyleMedium2", showFirstColumn=False,
showLastColumn=False, showRowStripes=True, showColumnStripes=False)
tab.tableStyleInfo = style
ws.add_table(tab)
# Data validation for Anteil
dv = DataValidation(type="decimal", operator="between", formula1="0", formula2="1", allow_blank=True)
ws.add_data_validation(dv)
dv.add(f"C2:C{MAX_PLAN_ROWS + 1}")
ws.column_dimensions["A"].width = 12
ws.column_dimensions["B"].width = 22
ws.column_dimensions["C"].width = 10
for col in "DEFGHIJK":
ws.column_dimensions[col].width = 13
def _populate_auswertung(ws):
headers = [
"Mitarbeiter", "WT_Einheiten", "WE_Freitag", "WE_Andere", "WE_Gesamt",
"Schwelle_erreicht", "Abzug_gesamt", "Abzug_Freitag", "Abzug_Andere",
"WE_bezahlt", "Auszahlung_WT", "Auszahlung_WE", "Auszahlung_Gesamt"
]
ws.append(headers)
_style_header(ws)
# Manual employee list (user fills column A)
# Row 2 onwards: formulas reference column A
monat_start = "Regeln!$B$7"
monat_end = f"MONATSENDE({monat_start};0)"
# Create formulas for 50 rows
for row in range(2, 52):
name_ref = f"$A{row}"
# Skip if no name
guard = f'WENN({name_ref}="";""'
# WT_Einheiten - using SUMMENPRODUKT for compatibility
wt_formula = (
f'={guard};SUMMENPRODUKT((tblPlan[Mitarbeiter]={name_ref})*'
f'(tblPlan[Datum]>={monat_start})*(tblPlan[Datum]<={monat_end})*'
f'(tblPlan[WT_Einheit])))'
)
ws[f"B{row}"] = wt_formula
# WE_Freitag
we_fri_formula = (
f'={guard};SUMMENPRODUKT((tblPlan[Mitarbeiter]={name_ref})*'
f'(tblPlan[Datum]>={monat_start})*(tblPlan[Datum]<={monat_end})*'
f'(tblPlan[WE_Freitag_Einheit])))'
)
ws[f"C{row}"] = we_fri_formula
# WE_Andere
we_other_formula = (
f'={guard};SUMMENPRODUKT((tblPlan[Mitarbeiter]={name_ref})*'
f'(tblPlan[Datum]>={monat_start})*(tblPlan[Datum]<={monat_end})*'
f'(tblPlan[WE_Andere_Einheit])))'
)
ws[f"D{row}"] = we_other_formula
# WE_Gesamt
ws[f"E{row}"] = f'={guard};C{row}+D{row})'
# Schwelle_erreicht
ws[f"F{row}"] = f'={guard};WENN(E{row}>=Regeln!$B$4-0,0001;"JA";"NEIN"))'
# Abzug_gesamt
ws[f"G{row}"] = f'={guard};WENN(E{row}>=Regeln!$B$4-0,0001;Regeln!$B$5;0))'
# Abzug_Freitag
ws[f"H{row}"] = f'={guard};MIN(G{row};C{row}))'
# Abzug_Andere
ws[f"I{row}"] = f'={guard};MAX(0;G{row}-H{row}))'
# WE_bezahlt (Variante 2: only if threshold reached)
ws[f"J{row}"] = f'={guard};WENN(E{row}<Regeln!$B$4-0,0001;0;(C{row}-H{row})+(D{row}-I{row})))'
# Auszahlung_WT
ws[f"K{row}"] = f'={guard};B{row}*Regeln!$B$2)'
# Auszahlung_WE
ws[f"L{row}"] = f'={guard};J{row}*Regeln!$B$3)'
# Auszahlung_Gesamt
ws[f"M{row}"] = f'={guard};K{row}+L{row})'
widths = [22, 14, 14, 14, 14, 16, 14, 14, 14, 14, 14, 14, 16]
for idx, width in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(idx)].width = width
def _populate_checks(ws):
ws["A1"] = "Datum"
ws["B1"] = "Summe_Anteile"
ws["C1"] = "Status"
_style_header(ws)
# Manual check list - user can add dates to check
# Formula checks sum of Anteil for each date
for row in range(2, 52):
date_ref = f"A{row}"
ws[f"B{row}"] = f'=WENN({date_ref}="";"";SUMMENPRODUKT((tblPlan[Datum]={date_ref})*(tblPlan[Anteil])))'
ws[f"C{row}"] = f'=WENN({date_ref}="";""WENN(ABS(B{row}-1)<=0,0001;"OK";"FEHLER"))'
ws.column_dimensions["A"].width = 14
ws.column_dimensions["B"].width = 16
ws.column_dimensions["C"].width = 12
def build_template():
TEMPLATE_PATH.parent.mkdir(parents=True, exist_ok=True)
wb = Workbook()
readme_ws = wb.active
readme_ws.title = "README"
_populate_readme(readme_ws)
rules_ws = wb.create_sheet("Regeln")
_populate_rules(rules_ws)
holiday_ws = wb.create_sheet("Feiertage")
_populate_holidays(holiday_ws)
plan_ws = wb.create_sheet("Plan")
_populate_plan(plan_ws)
auswertung_ws = wb.create_sheet("Auswertung")
_populate_auswertung(auswertung_ws)
checks_ws = wb.create_sheet("Checks")
_populate_checks(checks_ws)
wb.save(TEMPLATE_PATH)
return TEMPLATE_PATH
if __name__ == "__main__":
path = build_template()
print(f"✅ Vorlage (Variante 2 streng) erstellt: {path}")

76
src/fill_plan_dates.py Normal file
View file

@ -0,0 +1,76 @@
"""
Füllt das Plan-Blatt automatisch mit allen Datumszeilen eines Monats vor.
Nutzer muss nur noch Namen + Anteile eintragen.
"""
from pathlib import Path
from datetime import date, timedelta
from openpyxl import load_workbook
import sys
def fill_plan_with_dates(template_path, output_path, year, month):
"""
Lädt die Vorlage und füllt Spalte A (Datum) im Plan-Blatt
mit allen Tagen des angegebenen Monats.
"""
wb = load_workbook(template_path)
# Regeln-Blatt: Monat_Auswahl setzen
if "Regeln" in wb.sheetnames:
regeln_ws = wb["Regeln"]
# Zeile 7, Spalte B = Monat_Auswahl
regeln_ws["B7"] = date(year, month, 1)
# Plan-Blatt füllen
if "Plan" not in wb.sheetnames:
print("❌ Blatt 'Plan' nicht gefunden!")
return
plan_ws = wb["Plan"]
# Startdatum
start_date = date(year, month, 1)
# Letzter Tag des Monats
if month == 12:
end_date = date(year + 1, 1, 1) - timedelta(days=1)
else:
end_date = date(year, month + 1, 1) - timedelta(days=1)
# Alle Tage durchgehen
current_date = start_date
row = 2 # Zeile 2 = erste Datenzeile nach Header
while current_date <= end_date:
plan_ws[f"A{row}"] = current_date
# Spalten B (Mitarbeiter) und C (Anteil) bleiben leer zum Ausfüllen
current_date += timedelta(days=1)
row += 1
wb.save(output_path)
print(f"✅ Plan-Blatt vorbefüllt für {month:02d}/{year}")
print(f" Ausgabe: {output_path}")
print(f" Trage jetzt nur noch in Spalte B (Mitarbeiter) und C (Anteil) die Namen ein!")
if __name__ == "__main__":
template = Path("templates/Dienstplan_Vorlage_V2_NRW.xlsx")
if len(sys.argv) >= 3:
year = int(sys.argv[1])
month = int(sys.argv[2])
else:
# Standard: November 2025
year = 2025
month = 11
output = Path(f"output/Dienstplan_{year}_{month:02d}_NRW.xlsx")
output.parent.mkdir(exist_ok=True)
if not template.exists():
print(f"❌ Vorlage nicht gefunden: {template}")
print(" Führe erst 'python src/build_template.py' aus!")
sys.exit(1)
fill_plan_with_dates(template, output, year, month)

63
src/main.py Normal file
View file

@ -0,0 +1,63 @@
"""
Excel XLSX Generator
Erstellt Excel-Dateien mit openpyxl
"""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from pathlib import Path
from datetime import datetime
def create_example_excel():
"""Erstellt eine Beispiel-Excel-Datei mit formatierten Daten."""
# Neues Workbook erstellen
wb = Workbook()
ws = wb.active
ws.title = "Beispiel"
# Überschriften hinzufügen
headers = ["Name", "Alter", "Stadt", "Beruf"]
ws.append(headers)
# Überschriften formatieren
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF", size=12)
for cell in ws[1]:
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
# Beispieldaten hinzufügen
data = [
["Max Mustermann", 30, "Berlin", "Entwickler"],
["Erika Musterfrau", 28, "München", "Designerin"],
["Hans Schmidt", 35, "Hamburg", "Manager"],
["Anna Weber", 27, "Köln", "Analyst"],
]
for row in data:
ws.append(row)
# Spaltenbreiten anpassen
ws.column_dimensions['A'].width = 20
ws.column_dimensions['B'].width = 10
ws.column_dimensions['C'].width = 15
ws.column_dimensions['D'].width = 15
# Ausgabeverzeichnis erstellen
output_dir = Path("output")
output_dir.mkdir(exist_ok=True)
# Datei speichern
output_file = output_dir / f"example_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
wb.save(output_file)
print(f"Excel-Datei erfolgreich erstellt: {output_file}")
return output_file
if __name__ == "__main__":
create_example_excel()

81
src/read_excel.py Normal file
View file

@ -0,0 +1,81 @@
"""
Excel-Datei einlesen und Inhalt anzeigen
"""
from openpyxl import load_workbook
import json
from pathlib import Path
def read_excel_to_dict(filepath):
"""Liest eine Excel-Datei und gibt die Daten als Dictionary zurück."""
wb = load_workbook(filepath, data_only=True)
result = {}
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
# Daten aus dem Sheet lesen
data = []
for row in ws.iter_rows(values_only=True):
# Nur Zeilen mit Inhalt
if any(cell is not None for cell in row):
data.append(list(row))
result[sheet_name] = data
return result
def print_excel_content(filepath):
"""Gibt den Inhalt einer Excel-Datei formatiert aus."""
print(f"\n{'='*60}")
print(f"Excel-Datei: {filepath}")
print(f"{'='*60}\n")
data = read_excel_to_dict(filepath)
for sheet_name, rows in data.items():
print(f"\n📊 Sheet: {sheet_name}")
print(f"{'-'*60}")
if not rows:
print(" (leer)")
continue
# Tabelle ausgeben
for i, row in enumerate(rows, 1):
row_str = " | ".join(str(cell) if cell is not None else "" for cell in row)
print(f" {i:3d}: {row_str}")
print(f"\n{'='*60}\n")
# Als JSON ausgeben
print("📄 JSON-Format:")
print(json.dumps(data, indent=2, ensure_ascii=False))
return data
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
# Datei als Argument übergeben
filepath = sys.argv[1]
else:
# Nach neuester Datei im output-Ordner suchen
output_dir = Path("output")
excel_files = list(output_dir.glob("*.xlsx"))
if not excel_files:
print("❌ Keine Excel-Dateien im output-Ordner gefunden!")
print("Verwendung: python src/read_excel.py <pfad-zur-datei>")
sys.exit(1)
# Neueste Datei verwenden
filepath = max(excel_files, key=lambda p: p.stat().st_mtime)
print_excel_content(filepath)