Mengenlehre auf Kalenderblättern

„Data dominates. If you’ve chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming.“

Rob Pike

Kaum waren die Beiträge über Feiertage veröffentlicht, da kommt eine Anfrage zur Arbeitszeitberechnung von meiner Gattin.

Das Problem, das meine Ehefrau mir schilderte, ist den Eltern schulpflichtiger Kinder wohlbekannt. Arbeitnehmer haben viel weniger Urlaubsanspruch als ihre Kinder Ferientage. Für einen Arbeitgeber wird dies zu einem Problem, wenn die Mitarbeiter Schulkinder betreuen und keine Schulkinder zur Verfügung stehen.

Die Lösung des Problems ist augenscheinlich einfach, die Kollegen leisten Mehrarbeit in der Schulzeit und nehmen in den Schulferien frei. Dafür muss der Arbeitgeber die Arbeitsverträge so gestalten, dass die gemittelte wöchentliche Arbeitszeit zur tatsächlichen zu leistenen Arbeitszeit in der Schulzeit passt.

Häufig reicht eine kleine Gabe von Anforderungen und eine Prise Randbedingungen um einen mehr oder minder komplizierten Algorithmus niederzuschreiben. Der menschliche Instinkt zieht Handeln dem Planen vor und daher ergießen sich allzu oft kilometerlange prozedurale Gedichte in den Source Code.

Da ich während der Implementierung meiner Auftraggeberin gerne zeigen wollte, was ich da mache, zog ich den Einsatz von nützlichen Datenstrukturen und moderner APIs vor.

Wie sollte nun ein Programm entwickelt werden, dass für eine Vertragsdauer bis zu einem Jahr, die bestmögliche Wochenarbeitszeit bei vorgegebener Betreuungszeit berechnet?

Zuallererst benötigt der Algorithmus die Anzahl der Werktage während der Vertragsdauer, die Ferien- und die Feiertage. Die Werktage ohne Ferien- und Feiertage sind die Betreuungstage.

Bei der Berechnung der Werktage hilft uns die Java Time API mit der datesUntil Methode. Sie liefert einen Stream von Tagen, aus denen wir Samstage und Sonntage herausfiltern und den Rest in einem Set speichern.

Set<DayOfWeek> WEEKEND = Set.of(DayOfWeek.SUNDAY, DayOfWeek.SATURDAY);
...
private Set<LocalDate> getAllWorkDays(LocalDate from, LocalDate until) {
  return from.datesUntil(until.plus(1, DAYS))
      .filter(x -> !WEEKEND.contains(x.getDayOfWeek())).collect(toSet());
}

Da der Parameter von datesUntil als Datum exklusive ist, addieren wir mit plus noch einen Tag auf das Enddatum.

Die Feiertage erhalten wir über das Holidays Framework aus dem Beitrag Kalenderspielereien mit Java.

Set<LocalDate> officialHolidays = new HashSet<>();  
officialHolidays.addAll(Holidays.in(NW).getHolidays(from.getYear()).keySet());   
officialHolidays.addAll(Holidays.in(NW).getHolidays(until.getYear()).keySet());
officialHolidays.retainAll(allworkDays);

Die getHolidays Methode wird zweimal aufgerufen, da wir nicht so genau wissen, ob Anfang und Ende des Berechnungszeitraums im selben Jahr liegen. Da wir die Tage in einem Set speichern, müssen wir uns keine Sorge um Doubletten machen, falls wir die Methode beide Male mit dem gleichen Jahr aufrufen.

Die retainAll Methode am Ende sorgt dafür, dass wir uns nur mit Feiertagen beschäftigen, die auf einen unserer Werktage fallen. Sie berechnet die Schnittmenge der beiden beteiligten Sets und belässt ihre Elemente im aufrufenden Set.

Ein weiterer wichtiger Aspekt bei der Berechnung ist der Urlaubsanspruch des Mitarbeiters. Bei einer Vertragslänge von einem Jahr ergeben sich keine Probleme, weil der gesamte Urlaub in den Ferien genommen werden kann. Bei Mitarbeitern, die kurzfristig dabei sind, kann der Urlaubsanspruch aber großer sein, als die Anzahl der Ferientage. Die Berechnung des Urlaubsanspruch ist in der folgenden Methode formuliert.

private double calculateVacation(LocalDate from, LocalDate until) {
  LocalDate lastDay = until.with(lastDayOfMonth());
  LocalDate firstDay = from.with(firstDayOfMonth());
  Period period = firstDay.until(lastDay.plus(1, DAYS));
  double vacation = (period.getYears() * 12 + period.getMonths()) * VACATION_PER_MONTH;
  vacation -= from.getDayOfMonth() > 1 ? VACATION_PER_MONTH : 0;
  vacation -= until.getDayOfMonth() < lastDay.getDayOfMonth() ? VACATION_PER_MONTH : 0;
  return vacation;
}

Mit dem Ausdruck from.with(firstDayOfMonth()) wird der erste Tag des Anfangmonats und mit until.with(lastDayOfMonth()) der letzte Tag des Endmonats berechnet. Die damit berechnete Period ergibt die Anzahl der Monate, die der Mitarbeiter längstens beschäftigt ist und damit den maximalen Urlaubsanspruch. Falls der Mitarbeiter nicht den gesamten ersten oder letzten Monat angestellt ist, reduziert sich der Urlaubsanspruch anteilig.

Am Ende werden noch die Ferientage benötigt, die es auszugleichen gilt. Da diese durch die Kultusministerkonferenz festgelegt werden, fügen wir sie per Konfiguration hinzu. Da es eine kleine Spring Boot Applikation werden soll, ergibt sich folgender YAML-Eintrag.

holidays:
  schoolHolidays:
    - name: Weihnachtsferien 2019
      from: 2020-01-01
      until: 2020-01-06
    - name: Osterferien 2020
      from: 2020-04-06
      until: 2020-04-18
    - name: Pfingstferien 2020
      from: 2020-06-02
      until: 2020-06-02
    - name: Sommerferien 2020
      from: 2020-06-29
      until: 2020-08-11
    - name: Herbstferien 2020
      from: 2020-10-12
      until: 2020-10-24
    - name: Weihnachtsferien 2020
      from: 2020-12-23
      until: 2020-12-31

Die Konfiguration wird über eine @ConfigurationProperties annotierte Klasse eingelesen und steht als Liste von DatePeriod Instanzen bereit. Der Name der Einträge wir im Programm nicht verwendet, macht aber die Konfiguration lesbarer.

@ConfigurationProperties("holidays")
public class HolidayConfiguration {

  private List<DatePeriod> schoolHolidays = new ArrayList<>();

  public List<DatePeriod> getSchoolHolidays() {
    return schoolHolidays;
  }
}

Beim Programmstart wird die Konfiguration genutzt, um für jedes konfigurierte Jahr alle Schulferientage einzusammeln. Da die Weihnachtsferien in das Folgejahr hineinreichen, erfolgt auch hier ein Aufruf mit dem Anfangsdatum der Ferien und einer mit dem Enddatum.

private final Map<Integer, Set<LocalDate>> schoolHolidays = new HashMap<>();

public FreeDays(HolidayConfiguration configuration) {
  configuration.getSchoolHolidays().forEach(period -> {
    schoolHolidays.computeIfAbsent(period.getFrom().getYear(), HashSet::new).addAll(period.getDays());
    schoolHolidays.computeIfAbsent(period.getUntil().getYear(), HashSet::new).addAll(period.getDays());
  });
}

Zusätzlich gibt es noch drei bis vier bewegliche Ferientage in NRW. Da diese von den einzelnen Schulkonferenzen festgelegt werden, muss der Anwender die genaue Anzahl im Berechnungszeitraum angeben.

Diese Beispiel zeigt, dass nicht immer komplexe Berechnungen notwendig sind um ein Problem zu lösen. Hier reicht es aus, die richtigen Mengen von Tagen zu erstellen und ihre Schnittmengen ergeben die gesuchte Lösung.