diff --git a/README.md b/README.md index 60da198..3af155d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ausgabe.py b/ausgabe.py index 6bfae53..35833db 100644 --- a/ausgabe.py +++ b/ausgabe.py @@ -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""" diff --git a/csv_io.py b/csv_io.py index 8052e6b..7656c1b 100644 --- a/csv_io.py +++ b/csv_io.py @@ -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) diff --git a/datenmodell.py b/datenmodell.py index 75b5f6e..a01003e 100644 --- a/datenmodell.py +++ b/datenmodell.py @@ -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: diff --git a/elterndienstplaner.py b/elterndienstplaner.py index ba09626..5539d59 100755 --- a/elterndienstplaner.py +++ b/elterndienstplaner.py @@ -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,