Repository: urlaubsverwaltung/urlaubsverwaltung
Pull request #5103 (2025-01-23 14:44:22)

Enhance the overtime reduction api for multi-day applications
see #5016

<!--

Thanks for contributing to the Urlaubsverwaltung.
Please review the following notes before submitting you pull request.

Please look for other issues or pull requests which already work on this topic. Is somebody already on it? Do you need to synchronize?

# Security Vulnerabilities

🛑 STOP! 🛑 If your contribution fixes a security vulnerability, please do not submit it.
Instead, please open a report of a security vulnerability via
[Report a security vulnerability](https://github.com/urlaubsverwaltung/urlaubsverwaltung/security/advisories/new)


# Describing Your Changes

If, having reviewed the notes above, you're ready to submit your pull request, please
provide a brief description of the proposed changes.

If they:
 🐞 fix a bug, please describe the broken behaviour and how the changes fix it.
    Please label with 'type: bug' and 'status: new'
    
 🎁 make an enhancement, please describe the new functionality and why you believe it's useful.
    Please label with 'type: enhancement' and 'status: new'
 
If your pull request relates to any existing issues,
please reference them by using the issue number prefixed with #.

-->



Patch:
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiController.java b/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiController.java
index 2bf5988c3..142739ce4 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiController.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiController.java
@@ -44,9 +44,10 @@ public class OvertimeAbsenceApiController {
     }
 
     @Operation(
-        summary = "Returns the amount of overtime used for a specific absence of category OVERTIME",
+        summary = "Returns the amount of overtime used for a specific application of category OVERTIME.",
         description = """
-            Returns the amount of overtime used for a specific absence of category OVERTIME.
+            Returns the amount of overtime used for a specific application of category OVERTIME.
+            This includes a list of date-duration tuples, which divides the full duration across the whole application date range.
 
             Needed basic authorities:
             * user
@@ -58,7 +59,7 @@ public class OvertimeAbsenceApiController {
             * boss or office         - if the requested absences of the person id is any id but not of the authenticated user
             """
     )
-    @GetMapping(value = ABSENCES + "/{absenceId}/overtime", produces = {APPLICATION_JSON_VALUE, HAL_JSON_VALUE})
+    @GetMapping(value = ABSENCES + "/{applicationId}/overtime", produces = {APPLICATION_JSON_VALUE, HAL_JSON_VALUE})
     @PreAuthorize(IS_BOSS_OR_OFFICE +
         " or @userApiMethodSecurity.isSamePersonId(authentication, #personId)" +
         " or @userApiMethodSecurity.isInDepartmentOfDepartmentHead(authentication, #personId)" +
@@ -67,21 +68,21 @@ public class OvertimeAbsenceApiController {
         @Parameter(description = "ID of the person")
         @PathVariable("personId")
         Long personId,
-        @Parameter(description = "ID of the absence")
-        @PathVariable("absenceId")
-        Long absenceId) {
-        Application application = getApplicationOfPerson(personId, absenceId);
+        @Parameter(description = "ID of the application")
+        @PathVariable("applicationId")
+        Long applicationId) {
+        Application application = getApplicationOfPerson(personId, applicationId);
 
         if (!application.getVacationType().isOfCategory(VacationCategory.OVERTIME)) {
-            throw new ResponseStatusException(BAD_REQUEST, "Absence does not have category OVERTIME.");
+            throw new ResponseStatusException(BAD_REQUEST, "Application does not have category OVERTIME.");
         }
 
         if (application.getHours() == null) {
             LOG.error("hours duration unknown for {}", application);
-            throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Absence is of category OVERTIME, but duration is not know.");
+            throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Application is of category OVERTIME, but duration is not know.");
         }
 
-        return new OvertimeAbsenceDto(absenceId, application.getHours());
+        return new OvertimeAbsenceDto(applicationId, application.getHours(), application.getOvertimeReductionShares());
     }
 
     private Application getApplicationOfPerson(Long personId, Long absenceId) {
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceDto.java b/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceDto.java
index dcc932b04..7c26aa951 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceDto.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceDto.java
@@ -5,6 +5,13 @@ import io.swagger.v3.oas.annotations.media.Schema;
 import org.springframework.hateoas.RepresentationModel;
 
 import java.time.Duration;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+import static java.time.format.DateTimeFormatter.ofPattern;
+import static org.synyx.urlaubsverwaltung.api.RestApiDateFormat.DATE_PATTERN;
+
 
 public class OvertimeAbsenceDto extends RepresentationModel<OvertimeAbsenceDto> {
 
@@ -16,9 +23,12 @@ public class OvertimeAbsenceDto extends RepresentationModel<OvertimeAbsenceDto>
         format = "duration")
     private final Duration duration;
 
-    OvertimeAbsenceDto(Long id, Duration duration) {
+    private final Map<LocalDate, Duration> durationShares;
+
+    OvertimeAbsenceDto(Long id, Duration duration, Map<LocalDate, Duration> durationShares) {
         this.id = id;
         this.duration = duration;
+        this.durationShares = durationShares;
     }
 
     public Long getId() {
@@ -28,4 +38,33 @@ public class OvertimeAbsenceDto extends RepresentationModel<OvertimeAbsenceDto>
     public Duration getDuration() {
         return duration;
     }
+
+    public List<DatedDurationShareDto> getDurationShares() {
+        return durationShares.entrySet().stream()
+            .map(entry -> new OvertimeAbsenceDto.DatedDurationShareDto(entry.getKey(), entry.getValue()))
+            .toList();
+    }
+
+    public static class DatedDurationShareDto {
+
+        @Schema(example = "2024-12-31", format = DATE_PATTERN)
+        private final String date;
+
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        @Schema(example = "PT8H", type = "string", format = "duration")
+        private final Duration duration;
+
+        private DatedDurationShareDto(LocalDate date, Duration duration) {
+            this.date = date.format(ofPattern(DATE_PATTERN));
+            this.duration = duration;
+        }
+
+        public String getDate() {
+            return date;
+        }
+
+        public Duration getDuration() {
+            return duration;
+        }
+    }
 }
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/application/application/Application.java b/src/main/java/org/synyx/urlaubsverwaltung/application/application/Application.java
index dfb735de3..e069bd419 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/application/application/Application.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/application/application/Application.java
@@ -16,12 +16,15 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.stream.Collectors;
+import java.util.Optional;
+import java.util.function.Function;
 
 import static java.math.RoundingMode.HALF_EVEN;
 import static java.time.Duration.ZERO;
 import static java.time.ZoneOffset.UTC;
+import static java.util.stream.Collectors.toMap;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.CANCELLED;
+import static org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory.OVERTIME;
 import static org.synyx.urlaubsverwaltung.util.DecimalConverter.toFormattedDecimal;
 
 /**
@@ -314,12 +317,34 @@ public class Application {
         return hours;
     }
 
-    private Duration getHoursForDateRange(DateRange dateRange) {
+    /**
+     * Partition the overtime reduction duration of this application over all involved days.
+     * The sum of these mapped durations equals the duration of the application.
+     * This partitioning weights all days evenly  and doesn't account for half days, weekends etc.
+     *
+     * @return a mapping of durations to the days involved in this application, never {@code null}
+     */
+    public Map<LocalDate, Duration> getOvertimeReductionShares() {
+        return new DateRange(startDate, endDate).stream()
+            .collect(toMap(Function.identity(), this::getOvertimeReductionShareFor));
+    }
+
+    public Duration getOvertimeReductionShareFor(LocalDate date) {
+        return getOvertimeReductionShareFor(new DateRange(date, date));
+    }
+
+    public Duration getOvertimeReductionShareFor(DateRange dateRange) {
+
+        if (vacationType == null || !OVERTIME.equals(vacationType.getCategory())) {
+            return ZERO;
+        }
+
         final DateRange overtimeDateRange = new DateRange(startDate, endDate);
         final Duration durationOfOverlap = overtimeDateRange.overlap(dateRange).map(DateRange::duration).orElse(ZERO);
 
+        final Duration overtimeReductionHours = Optional.ofNullable(hours).orElse(ZERO);
         final Duration overtimeDateRangeDuration = overtimeDateRange.duration();
-        final BigDecimal secondsProRata = toFormattedDecimal(hours)
+        final BigDecimal secondsProRata = toFormattedDecimal(overtimeReductionHours)
             .divide(toFormattedDecimal(overtimeDateRangeDuration), HALF_EVEN)
             .multiply(toFormattedDecimal(durationOfOverlap))
             .setScale(0, HALF_EVEN);
@@ -349,9 +374,11 @@ public class Application {
     }
 
     public Map<Integer, Duration> getHoursByYear() {
-        return this.splitByYear().stream().collect(Collectors.toMap(
-            dateRangeForYear -> dateRangeForYear.startDate().getYear(),
-            this::getHoursForDateRange));
+        return this.splitByYear().stream()
+            .collect(toMap(
+                dateRangeForYear -> dateRangeForYear.startDate().getYear(),
+                this::getOvertimeReductionShareFor
+            ));
     }
 
     public void setHours(Duration hours) {
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationService.java b/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationService.java
index 7f301e514..b46918a1f 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationService.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationService.java
@@ -129,6 +129,7 @@ public interface ApplicationService {
      * @return list of all matching {@link Application}s
      */
     List<Application> getForStatesAndPerson(List<ApplicationStatus> statuses, List<Person> persons, LocalDate start, LocalDate end);
+
     /**
      * Get all {@link Application}s with specific states for the given date range
      *
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationServiceImpl.java b/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationServiceImpl.java
index 0ddf315d8..1169631a0 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationServiceImpl.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationServiceImpl.java
@@ -9,7 +9,6 @@ import org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory;
 import org.synyx.urlaubsverwaltung.application.vacationtype.VacationType;
 import org.synyx.urlaubsverwaltung.person.Person;
 import org.synyx.urlaubsverwaltung.person.PersonDeletedEvent;
-import org.synyx.urlaubsverwaltung.util.DecimalConverter;
 
 import java.math.BigDecimal;
 import java.time.Duration;
@@ -21,13 +20,11 @@ import java.util.Optional;
 import java.util.function.Function;
 import java.util.stream.StreamSupport;
 
-import static java.math.RoundingMode.HALF_EVEN;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.activeStatuses;
 import static org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory.OVERTIME;
 import static org.synyx.urlaubsverwaltung.application.vacationtype.VacationTypeServiceImpl.convert;
-import static org.synyx.urlaubsverwaltung.util.DecimalConverter.toFormattedDecimal;
 
 /**
  * Implementation of interface {@link ApplicationService}.
@@ -131,21 +128,15 @@ class ApplicationServiceImpl implements ApplicationService {
         return getTotalOvertimeReductionOfPersonUntil(List.of(person), until).getOrDefault(person, Duration.ZERO);
     }
 
+    @Override
     public Map<Person, Duration> getTotalOvertimeReductionOfPersonUntil(Collection<Person> persons, LocalDate until) {
 
         final Map<Person, Duration> overtimeReductionByPerson = applicationRepository.findByPersonInAndVacationTypeCategoryAndStatusInAndStartDateIsLessThanEqual(persons, OVERTIME, activeStatuses(), until).stream()
-            .map(application -> {
+            .map(applicationEntity -> {
+                final Application application = toApplication(applicationEntity);
                 final DateRange dateRangeOfPeriod = new DateRange(application.getStartDate(), until);
-                final DateRange applicationDateRage = new DateRange(application.getStartDate(), application.getEndDate());
-                final Duration durationOfOverlap = dateRangeOfPeriod.overlap(applicationDateRage).map(DateRange::duration).orElse(Duration.ZERO);
-
-                final Duration overtimeReductionHours = Optional.ofNullable(application.getHours()).orElse(Duration.ZERO);
-
-                final BigDecimal overtimeReduction = toFormattedDecimal(overtimeReductionHours)
-                    .divide(toFormattedDecimal(applicationDateRage.duration()), HALF_EVEN)
-                    .multiply(toFormattedDecimal(durationOfOverlap))
-                    .setScale(0, HALF_EVEN);
-                return Map.entry(application.getPerson(), DecimalConverter.toDuration(overtimeReduction));
+                final Duration overtimeReduction = application.getOvertimeReductionShareFor(dateRangeOfPeriod);
+                return Map.entry(application.getPerson(), overtimeReduction);
             })
             .collect(toMap(Map.Entry::getKey, Map.Entry::getValue, Duration::plus));
 


Test patch:
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiControllerTest.java b/src/test/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiControllerTest.java
index 3f2c491ea..715301f08 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiControllerTest.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/absence/OvertimeAbsenceApiControllerTest.java
@@ -18,6 +18,7 @@ import org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory;
 import org.synyx.urlaubsverwaltung.person.Person;
 
 import java.time.Duration;
+import java.time.LocalDate;
 import java.util.Optional;
 
 import static org.mockito.Mockito.when;
@@ -42,13 +43,54 @@ class OvertimeAbsenceApiControllerTest {
 
     @Test
     void threeHoursForty() throws Exception {
+
+        final LocalDate date = LocalDate.of(2016, 1, 1);
+        final var duration = Duration.ofHours(3).plusMinutes(40);
+
+        final Person person = new Person("muster", "Muster", "Marlene", "muster@example.org");
+        person.setId(2L);
+        long applicationId = 3L;
+        final Application application = new Application();
+        application.setId(applicationId);
+        application.setPerson(person);
+        application.setStartDate(date);
+        application.setEndDate(date);
+        application.setHours(duration);
+        application.setVacationType(createVacationType(1L, OVERTIME, new StaticMessageSource()));
+        when(applicationService.getApplicationById(applicationId)).thenReturn(Optional.of(application));
+
+        perform(get("/api/persons/2/absences/3/overtime"))
+            .andExpect(status().isOk())
+            .andExpect(content().contentType("application/json"))
+            .andExpect(content().json("""
+                {
+                    "id": 3,
+                    "duration": "PT3H40M",
+                    "durationShares": [
+                        {
+                            "date": "2016-01-01",
+                            "duration": "PT3H40M"
+                        }
+                    ]
+                }
+                """));
+
+    }
+
+    @Test
+    void multipleDays() throws Exception {
+        final LocalDate startDate = LocalDate.of(2016, 1, 1);
+        final LocalDate endDate = LocalDate.of(2016, 1, 2);
+
         final Person person = new Person("muster", "Muster", "Marlene", "muster@example.org");
         person.setId(2L);
         long applicationId = 3L;
         final Application application = new Application();
         application.setId(applicationId);
         application.setPerson(person);
-        application.setHours(Duration.ofHours(3).plusMinutes(40));
+        application.setStartDate(startDate);
+        application.setEndDate(endDate);
+        application.setHours(Duration.ofHours(8));
         application.setVacationType(createVacationType(1L, OVERTIME, new StaticMessageSource()));
         when(applicationService.getApplicationById(applicationId)).thenReturn(Optional.of(application));
 
@@ -58,7 +100,17 @@ class OvertimeAbsenceApiControllerTest {
             .andExpect(content().json("""
                 {
                     "id": 3,
-                    "duration": "PT3H40M"
+                    "duration": "PT8H",
+                    "durationShares": [
+                        {
+                            "date": "2016-01-01",
+                            "duration": "PT4H"
+                        },
+                        {
+                            "date": "2016-01-02",
+                            "duration": "PT4H"
+                        }
+                    ]
                 }
                 """));
 
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationServiceImplTest.java b/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationServiceImplTest.java
index 0c2c77b29..2aa8d6df5 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationServiceImplTest.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationServiceImplTest.java
@@ -577,10 +577,9 @@ class ApplicationServiceImplTest {
         batman.setId(1L);
 
         final VacationTypeEntity vacationTypeEntity = new VacationTypeEntity();
-        vacationTypeEntity.setCategory(HOLIDAY);
+        vacationTypeEntity.setCategory(OVERTIME);
 
         final ApplicationEntity applicationEntity = new ApplicationEntity();
-        applicationEntity.setHours(Duration.ofHours(10));
         applicationEntity.setPerson(batman);
         applicationEntity.setStartDate(LocalDate.of(2022, 8, 10));
         applicationEntity.setEndDate(LocalDate.of(2022, 8, 12));
@@ -609,7 +608,7 @@ class ApplicationServiceImplTest {
         batman.setId(1L);
 
         final VacationTypeEntity vacationTypeEntity = new VacationTypeEntity();
-        vacationTypeEntity.setCategory(HOLIDAY);
+        vacationTypeEntity.setCategory(OVERTIME);
 
         final ApplicationEntity applicationEntity = new ApplicationEntity();
         applicationEntity.setPerson(batman);
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationTest.java b/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationTest.java
index c6ee218fb..a9d320ac0 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationTest.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationTest.java
@@ -16,12 +16,15 @@ import java.time.ZonedDateTime;
 import java.util.List;
 import java.util.Map;
 
+import static java.time.Duration.ofHours;
 import static java.time.ZoneOffset.UTC;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.synyx.urlaubsverwaltung.TestDataCreator.createVacationType;
+import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.WAITING;
+import static org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory.OVERTIME;
 import static org.synyx.urlaubsverwaltung.application.vacationtype.VacationTypeColor.YELLOW;
 import static org.synyx.urlaubsverwaltung.person.Role.USER;
 
-
 /**
  * Unit test for {@link Application}.
  */
@@ -164,12 +167,14 @@ class ApplicationTest {
         final Application application = new Application();
         application.setStartDate(LocalDate.of(2022, 12, 30));
         application.setEndDate(LocalDate.of(2023, 1, 2));
+        application.setVacationType(createVacationType(1L, OVERTIME, new StaticMessageSource()));
         application.setHours(Duration.ofHours(20));
 
         final Map<Integer, Duration> hoursByYear = application.getHoursByYear();
 
-        assertThat(hoursByYear).containsEntry(2022, Duration.ofHours(10));
-        assertThat(hoursByYear).containsEntry(2023, Duration.ofHours(10));
+        assertThat(hoursByYear).hasSize(2)
+            .containsEntry(2022, Duration.ofHours(10))
+            .containsEntry(2023, Duration.ofHours(10));
     }
 
     @Test
@@ -223,6 +228,44 @@ class ApplicationTest {
             "status=ALLOWED, teamInformed=true, hours=PT10H}");
     }
 
+    @Test
+    void ensureGetOvertimeReductionSharesForSingleDays() {
+        final LocalDate date = LocalDate.of(2022, 8, 10);
+
+        final Application application = new Application();
+        application.setStartDate(date);
+        application.setEndDate(date);
+        application.setStatus(WAITING);
+        application.setVacationType(createVacationType(1L, OVERTIME, new StaticMessageSource()));
+        application.setHours(Duration.ofHours(3).plusMinutes(40));
+
+        final Map<LocalDate, Duration> partitionedDurations = application.getOvertimeReductionShares();
+        assertThat(partitionedDurations).containsExactlyInAnyOrderEntriesOf(Map.of(
+            date, Duration.ofHours(3).plusMinutes(40))
+        );
+    }
+
+    @Test
+    void ensureGetOvertimeReductionSharesForMultipleDays() {
+        final LocalDate startDate = LocalDate.of(2022, 8, 10);
+        final LocalDate middleDate = LocalDate.of(2022, 8, 11);
+        final LocalDate endDate = LocalDate.of(2022, 8, 12);
+
+        final Application application = new Application();
+        application.setStartDate(startDate);
+        application.setEndDate(endDate);
+        application.setStatus(WAITING);
+        application.setVacationType(createVacationType(1L, OVERTIME, new StaticMessageSource()));
+        application.setHours(Duration.ofHours(12));
+
+        final var partitionedDurations = application.getOvertimeReductionShares();
+        assertThat(partitionedDurations).containsExactlyInAnyOrderEntriesOf(Map.of(
+            startDate, ofHours(4),
+            middleDate, ofHours(4),
+            endDate, ofHours(4))
+        );
+    }
+
     @Test
     void equals() {
         final Application applicationOne = new Application();