Mehr Fuktionen fuer historische Dienste

Planungszeitraum und historischer Zeitraum koennen sich jetzt
ueberlappen. So lassen sich einzelne Dienste (z.B. von einer Familie)
nachtraeglich neu planen.

historische Dienste werden bei den Constraints 1 Dienst pro Tag und 1
Dienst pro Woche korrekt beruecksichtigt

elterndienstplaner.py erzeugt jetzt ausgaben-gesamt.csv, die fuer
spaetere Aufrufe als Eingabe vorherige-dienste.csv verwendet werden kann.
This commit is contained in:
Jan Hoheisel 2026-01-28 20:30:14 +01:00
parent b524edc2ba
commit 08e5cf11bd
5 changed files with 101 additions and 25 deletions

View File

@ -54,17 +54,23 @@ Leon,2024-09-01,2024-12-31,1,2025-01-01,2025-07-31,0
### vorherige-ausgaben.csv (optional)
Historische Dienstzuteilungen für Jahres-Fairness. Format wie `ausgabe.csv`.
Historische Dienstzuteilungen für Jahres-Fairness. Format wie `ausgabe.csv` bzw. `ausgabe-gesamt.csv`.
Hier kann die `ausgabe-gesamt.csv`, die bei der letzten Planung generiert wurde eingespielt werden.
## Ausgabedatei
### ausgabe.csv
Die neu zugeteilten Dienste.
```csv
Datum,Wochentag,Frühstücksdienst,Putznotdienst,Essensausgabenotdienst,Kochen,Elternabend
2026-01-06,Montag,Sarah & Tim,Leon,Erika,,
```
### ausgabe-gesamt.csv
Wie `ausgabe.csv`, enthält aber neben den neu geplanten Diensten auch die historischen Dienste, die über `vorherige-ausgaben.csv` übergeben wurden. Die Datei `ausgabe-gesamt.csv` kann bei der nächsten Planung wieder als Eingabe `vorherige-ausgaben.csv` verwendet werden.
## Verwendung
```bash

View File

@ -6,7 +6,7 @@ Visualisierung und Export der Ergebnisse
from datetime import date
from collections import defaultdict
from typing import Dict, List, DefaultDict
from typing import Dict, List, DefaultDict, Tuple
from datenmodell import ElterndienstplanerDaten, Dienst, Eltern, Zielverteilung
from csv_io import AusgabeWriter
@ -20,6 +20,8 @@ class ElterndienstAusgabe:
# Zwischenergebnisse aus der Optimierung (über Observer-Pattern gesetzt)
self.ziel_lokal: Zielverteilung = None
self.ziel_global: Zielverteilung = None
# Historische Dienste (kann über Observer gesetzt werden)
self.historische_dienste: List[Tuple[date, Eltern, Dienst]] = None
def setze_zielverteilungen(
self,
@ -30,9 +32,17 @@ class ElterndienstAusgabe:
self.ziel_lokal = ziel_lokal
self.ziel_global = ziel_global
def setze_historische_dienste(self, historische_dienste: List[Tuple[date, Eltern, Dienst]]) -> None:
"""Observer-Callback: Setzt historische Dienste für Ausgabe/Export"""
self.historische_dienste = historische_dienste
def schreibe_ausgabe_csv(self, datei: str, lösung: Dict[date, Dict[Dienst, List[Eltern]]]) -> None:
"""Schreibt die Lösung in die ausgabe.csv"""
AusgabeWriter.schreibe_ausgabe_csv(datei, lösung, self.daten.planungszeitraum, self.daten.dienste)
AusgabeWriter.schreibe_ausgabe_csv(datei, lösung, self.daten.planungszeitraum, self.daten.dienste, False)
# Schreibe ergänzende Datei mit historischen Diensten (falls vorhanden).
hist_datei = datei.replace('.csv', '-gesamt.csv') if datei.endswith('.csv') else datei + '-gesamt.csv'
historische = self.historische_dienste if self.historische_dienste is not None else self.daten.historische_dienste
AusgabeWriter.schreibe_ausgabe_csv(hist_datei, lösung, self.daten.planungszeitraum, self.daten.dienste, True, historische)
def drucke_statistiken(self, lösung: Dict[date, Dict[Dienst, List[Eltern]]]) -> None:
"""Druckt Statistiken zur Lösung"""

View File

@ -6,7 +6,7 @@ Trennt CSV-Parsing und -Schreiben von der Business-Logik
import csv
from datetime import datetime, date, timedelta
from typing import Dict, List, Tuple, DefaultDict
from typing import Dict, List, Tuple, DefaultDict, Optional
from collections import defaultdict
@ -213,7 +213,9 @@ class AusgabeWriter:
datei: str,
lösung: Dict[date, Dict[any, List[str]]], # Dienst-Objekt als Key
tage: List[date],
dienste: List # List[Dienst]
dienste: List, # List[Dienst]
gesamt: bool = False,
historische_dienste: List[Tuple[date, str, any]] = None
) -> None:
"""
Schreibt die Lösung in die ausgabe.csv
@ -221,10 +223,50 @@ class AusgabeWriter:
Args:
datei: Pfad zur ausgabe.csv
lösung: Dictionary mit Zuteilungen {datum: {dienst: [eltern]}}
tage: Liste aller Planungstage
tage: Liste aller Planungstage (aktueller Planungszeitraum)
dienste: Liste der Dienst-Objekte
gesamt: Wenn True -> schreibe gesamte Dienste (inkl. historische_dienste).
Wenn False -> wie bisher nur die neu verplanten Dienste (lösung).
historische_dienste: Optional Liste von (datum, eltern, dienst) aus vorherige-ausgaben.csv
(wird nur ausgewertet, wenn gesamt==True)
"""
print(f"Schreibe Ergebnisse nach {datei}...")
print(f"Schreibe Ergebnisse nach {datei}... (gesamt={gesamt})")
# Bestimme alle zu schreibenden Tage
if gesamt and historische_dienste:
historische_dates = {hd[0] for hd in historische_dienste}
output_dates = sorted(set(tage) | historische_dates)
else:
output_dates = sorted(tage)
# Sicherstellen: Für alle Tage im Zeitraum (von min bis max) soll eine Zeile ausgegeben werden,
# auch wenn keine Informationen vorliegen.
if output_dates:
start_date = min(output_dates)
end_date = max(output_dates)
full_dates = []
current = start_date
while current <= end_date:
full_dates.append(current)
current += timedelta(days=1)
output_dates = full_dates
# Erstelle Mapping date -> dienst -> list[eltern]
combined: Dict[date, Dict[any, List[str]]] = {}
if gesamt and historische_dienste:
for datum, eltern_name, dienst in historische_dienste:
combined.setdefault(datum, {}).setdefault(dienst, [])
if eltern_name not in combined[datum][dienst]:
combined[datum][dienst].append(eltern_name)
# Füge neue (optimierte) Zuweisungen hinzu (überschreiben/ergänzen)
for datum, dienst_map in (lösung or {}).items():
combined.setdefault(datum, {})
for dienst, eltern_liste in dienst_map.items():
combined[datum].setdefault(dienst, [])
for eltern_name in eltern_liste:
if eltern_name not in combined[datum][dienst]:
combined[datum][dienst].append(eltern_name)
with open(datei, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
@ -234,17 +276,16 @@ class AusgabeWriter:
writer.writerow(header)
# Daten schreiben
for tag in sorted(tage):
for tag in output_dates:
wochentag = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag',
'Freitag', 'Samstag', 'Sonntag'][tag.weekday()]
row = [tag.strftime('%Y-%m-%d'), wochentag]
for dienst in dienste:
if tag in lösung and dienst in lösung[tag]:
eltern_str = ' und '.join(lösung[tag][dienst])
else:
eltern_str = ''
eltern_str = ''
if tag in combined and dienst in combined[tag]:
eltern_str = ' und '.join(combined[tag][dienst])
row.append(eltern_str)
writer.writerow(row)

View File

@ -102,11 +102,16 @@ class ElterndienstplanerDaten:
vorherige_datei: Optionaler Pfad zur vorherige-ausgaben.csv für Fairness-Constraints
"""
# Eingabe CSV: Termine, Präferenzen, Verfügbarkeit
self.eltern, self.planungszeitraum, self.benoetigte_dienste, self.verfuegbarkeit, self.praeferenzen = \
EingabeParser.parse_eingabe_csv(eingabe_datei, self.get_dienst)
# Eltern CSV: Dienstfaktoren
# Eltern CSV: Dienstfaktoren (erst einlesen, damit self.eltern daraus abgeleitet wird)
self.dienstfaktoren = EingabeParser.parse_eltern_csv(eltern_datei)
# Fülle self.eltern aus den Einträgen in eltern.csv (Vertrauensquelle für Elternnamen)
self.eltern = list(self.dienstfaktoren.keys())
# Eingabe CSV: Termine, Präferenzen, Verfügbarkeit
# Wir verwenden die Elterndefinition aus eltern.csv; die von parse_eingabe_csv
# zurückgegebene Eltern-Liste wird ignoriert, damit die Quell-of-truth konsistent bleibt.
_, self.planungszeitraum, self.benoetigte_dienste, self.verfuegbarkeit, self.praeferenzen = \
EingabeParser.parse_eingabe_csv(eingabe_datei, self.get_dienst)
# Vorherige Ausgaben CSV (optional): Historische Dienste für Fairness
if vorherige_datei:

View File

@ -179,6 +179,10 @@ class Elterndienstplaner:
woche_nr = 0
letzter_tag = self.daten.planungszeitraum[-1]
print ("\n Erster Tag im Planungszeitraum:", erster_tag)
print ("\n Letzter Tag im Planungszeitraum:", letzter_tag)
while woche_start <= letzter_tag:
woche_ende = woche_start + timedelta(days=6)
@ -188,12 +192,13 @@ class Elterndienstplaner:
# Zaehle historische Dienste in dieser Woche (VOR Planungszeitraum)
historische_dienste_in_woche = 0
if woche_start < erster_tag:
for hist_datum, hist_eltern, hist_dienst in self.daten.historische_dienste:
if (hist_eltern == eltern and
hist_dienst == dienst and
woche_start <= hist_datum < erster_tag):
historische_dienste_in_woche += 1
#if woche_start < erster_tag:
for hist_datum, hist_eltern, hist_dienst in self.daten.historische_dienste:
if (hist_eltern == eltern and
hist_dienst == dienst and
woche_start <= hist_datum and
hist_datum <= woche_ende):
historische_dienste_in_woche += 1
for tag in self.daten.planungszeitraum:
if woche_start <= tag <= woche_ende:
@ -201,8 +206,11 @@ class Elterndienstplaner:
woche_vars.append(x[eltern, tag, dienst])
if woche_vars:
prob += pulp.lpSum(woche_vars) <= 1 - historische_dienste_in_woche, \
f"C1_{eltern.replace(' ', '_')}_{dienst.kuerzel}_w{woche_nr}"
if (1 - historische_dienste_in_woche) >= 0:
prob += pulp.lpSum(woche_vars) <= 1 - historische_dienste_in_woche, \
f"C1_{eltern.replace(' ', '_')}_{dienst.kuerzel}_w{woche_nr}"
else:
print (f" Hinweis: {eltern} hat in Woche {woche_nr} bereits {historische_dienste_in_woche} mal {dienst.name}")
woche_start += timedelta(days=7)
woche_nr += 1
@ -216,12 +224,18 @@ class Elterndienstplaner:
for eltern in self.daten.eltern:
for tag in self.daten.planungszeitraum:
tag_vars = []
maximum = 1
for dienst in self.daten.dienste:
if (eltern, tag, dienst) in x:
tag_vars.append(x[eltern, tag, dienst])
for hist_datum, hist_eltern, hist_dienst in self.daten.historische_dienste:
if (hist_eltern == eltern and
hist_datum == tag):
maximum = 0
if tag_vars:
prob += pulp.lpSum(tag_vars) <= 1, f"C2_{eltern.replace(' ', '_')}_{tag}"
prob += pulp.lpSum(tag_vars) <= maximum, f"C2_{eltern.replace(' ', '_')}_{tag}"
def _add_constraint_verfuegbarkeit(
self,