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}")