Skip to content

Commit 5138af9

Browse files
committed
[WIP]
1 parent 6a509d6 commit 5138af9

24 files changed

+516
-114
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package de.focusshift.zeiterfassung.overtime;
2+
3+
import de.focusshift.zeiterfassung.timeentry.TimeEntryDuration;
4+
5+
import java.math.BigDecimal;
6+
import java.math.RoundingMode;
7+
import java.time.Duration;
8+
import java.util.Objects;
9+
10+
public final class OvertimeDuration implements TimeEntryDuration {
11+
12+
public static OvertimeDuration ZERO = new OvertimeDuration(Duration.ZERO);
13+
14+
private final Duration value;
15+
16+
public OvertimeDuration(Duration value) {
17+
this.value = value;
18+
}
19+
20+
@Override
21+
public Duration value() {
22+
return value;
23+
}
24+
25+
@Override
26+
public Duration minutes() {
27+
final long seconds = value.toSeconds();
28+
29+
return seconds % 60 == 0
30+
? value
31+
: Duration.ofMinutes(value.toMinutes() + 1);
32+
}
33+
34+
@Override
35+
public double hoursDoubleValue() {
36+
final long minutes = minutes().toMinutes();
37+
return minutesToHours(minutes);
38+
}
39+
40+
public OvertimeDuration plus(Duration duration) {
41+
return new OvertimeDuration(value.plus(duration));
42+
}
43+
44+
public OvertimeDuration plus(OvertimeDuration overtimeDuration) {
45+
return new OvertimeDuration(value.plus(overtimeDuration.value));
46+
}
47+
48+
@Override
49+
public boolean equals(Object o) {
50+
if (this == o) return true;
51+
if (o == null || getClass() != o.getClass()) return false;
52+
OvertimeDuration that = (OvertimeDuration) o;
53+
return Objects.equals(value, that.value);
54+
}
55+
56+
@Override
57+
public int hashCode() {
58+
return Objects.hash(value);
59+
}
60+
61+
@Override
62+
public String toString() {
63+
return "OvertimeDuration{" +
64+
"value=" + value +
65+
'}';
66+
}
67+
68+
private static double minutesToHours(long minutes) {
69+
return BigDecimal.valueOf(minutes).divide(BigDecimal.valueOf(60), 2, RoundingMode.CEILING).doubleValue();
70+
}
71+
}

src/main/java/de/focusshift/zeiterfassung/overtime/OvertimeRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import java.time.Duration;
88
import java.time.Instant;
9+
import java.util.Collection;
910
import java.util.List;
1011

1112
interface OvertimeRepository extends JpaRepository<TimeEntryEntity, Long> {
@@ -20,4 +21,7 @@ interface OvertimeRepository extends JpaRepository<TimeEntryEntity, Long> {
2021
*/
2122
@Query("select t.end - t.start from TimeEntryEntity t where t.isBreak = :isBreak and t.owner = :userId and t.start < :dateExclusive")
2223
List<Duration> getDurationsToDate(boolean isBreak, String userId, Instant dateExclusive);
24+
25+
@Query("select t.owner as userId, (t.end - t.start) as duration from TimeEntryEntity t where t.isBreak = :isBreak and t.owner in :userIds and t.start < :dateExclusive")
26+
List<TimeEntryBreakEntityView> getOvertimeDurationsToDate(boolean isBreak, Collection<String> userIds, Instant dateExclusive);
2327
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package de.focusshift.zeiterfassung.overtime;
2+
3+
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
4+
5+
import java.time.LocalDate;
6+
import java.util.Collection;
7+
import java.util.Map;
8+
9+
public interface OvertimeService {
10+
11+
/**
12+
* Get accumulated overtime for all given users until the given date, exclusive.
13+
*
14+
* @param date exclusive date
15+
* @param userLocalIds users
16+
* @return accumulated overtime till the given date grouped by user
17+
*/
18+
Map<UserLocalId, OvertimeDuration> accumulatedOvertimeToDate(LocalDate date, Collection<UserLocalId> userLocalIds);
19+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package de.focusshift.zeiterfassung.overtime;
2+
3+
import de.focusshift.zeiterfassung.usermanagement.User;
4+
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
5+
import de.focusshift.zeiterfassung.usermanagement.UserManagementService;
6+
import org.springframework.stereotype.Service;
7+
8+
import java.time.Duration;
9+
import java.time.Instant;
10+
import java.time.LocalDate;
11+
import java.util.Collection;
12+
import java.util.List;
13+
import java.util.Map;
14+
15+
import static java.time.ZoneOffset.UTC;
16+
import static java.util.stream.Collectors.groupingBy;
17+
import static java.util.stream.Collectors.toMap;
18+
19+
@Service
20+
class OvertimeServiceImpl implements OvertimeService {
21+
22+
private final OvertimeRepository overtimeRepository;
23+
private final UserManagementService userManagementService;
24+
25+
OvertimeServiceImpl(OvertimeRepository overtimeRepository, UserManagementService userManagementService) {
26+
this.overtimeRepository = overtimeRepository;
27+
this.userManagementService = userManagementService;
28+
}
29+
30+
@Override
31+
public Map<UserLocalId, OvertimeDuration> accumulatedOvertimeToDate(LocalDate date, Collection<UserLocalId> userLocalIds) {
32+
33+
final Instant dateExclusive = date.atStartOfDay().toInstant(UTC);
34+
final Map<String, UserLocalId> localIdById = userManagementService.findAllUsersByLocalIds(userLocalIds).stream()
35+
.collect(toMap(user -> user.id().value(), User::localId));
36+
37+
final Map<String, List<TimeEntryBreakEntityView>> byUserIdValue = overtimeRepository.getOvertimeDurationsToDate(false, localIdById.keySet(), dateExclusive)
38+
.stream()
39+
.collect(groupingBy(TimeEntryBreakEntityView::getUserId));
40+
41+
return byUserIdValue.entrySet()
42+
.stream()
43+
.map(entry -> Map.entry(localIdById.get(entry.getKey()), new OvertimeDuration(toDuration(entry.getValue()))))
44+
.collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
45+
}
46+
47+
private Duration toDuration(List<TimeEntryBreakEntityView> views) {
48+
return views.stream().map(TimeEntryBreakEntityView::getDuration).reduce(Duration.ZERO, Duration::plus);
49+
}
50+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package de.focusshift.zeiterfassung.overtime;
2+
3+
import java.time.Duration;
4+
5+
interface TimeEntryBreakEntityView {
6+
7+
String getUserId();
8+
9+
Duration getDuration();
10+
}

src/main/java/de/focusshift/zeiterfassung/report/ReportControllerHelper.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package de.focusshift.zeiterfassung.report;
22

3+
import de.focusshift.zeiterfassung.overtime.OvertimeDuration;
34
import de.focusshift.zeiterfassung.user.DateFormatter;
45
import de.focusshift.zeiterfassung.user.UserId;
56
import de.focusshift.zeiterfassung.usermanagement.User;
@@ -14,10 +15,21 @@
1415
import java.time.ZoneId;
1516
import java.time.ZonedDateTime;
1617
import java.time.temporal.ChronoField;
18+
import java.util.Comparator;
1719
import java.util.Date;
20+
import java.util.HashMap;
21+
import java.util.HashSet;
1822
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Optional;
25+
import java.util.Set;
26+
import java.util.function.Function;
27+
import java.util.stream.IntStream;
1928

29+
import static java.util.function.Function.identity;
2030
import static java.util.stream.Collectors.joining;
31+
import static java.util.stream.Collectors.toList;
32+
import static java.util.stream.Collectors.toMap;
2133

2234
@Component
2335
class ReportControllerHelper {
@@ -101,6 +113,61 @@ DetailWeekDto toDetailWeekDto(ReportWeek reportWeek, Month monthPivot) {
101113
return new DetailWeekDto(Date.from(firstOfWeek.toInstant()), Date.from(lastOfWeek.toInstant()), calendarWeek, dayReports);
102114
}
103115

116+
ReportOvertimesDto reportOvertimesDto(ReportWeek reportWeek) {
117+
// person | M | T | W | T | F | S | S
118+
// -----------------------------------
119+
// john | 1 | 2 | 2 | 3 | 4 | 4 | 4 <- `ReportOvertimeDto ( personName, overtimes )`
120+
// jane | | | 2 | 3 | 4 | 4 | 4 <- `ReportOvertimeDto ( personName, overtimes )`
121+
122+
// build up `users` peace by peace. one person could have the first working day in the middle of the week (jane).
123+
final Set<User> users = new HashSet<>();
124+
125+
// {john} -> [1, 2, 2, 3, 4, 4, 4]
126+
// {jane} -> [empty, empty, 2, 3, 4, 4, 4]
127+
final Map<User, List<Optional<OvertimeDuration>>> overtimeDurationsByUser = new HashMap<>();
128+
129+
// used to initiate the persons list of overtimes.
130+
// jane will be seen first on the third reportDay. she initially needs a list of `[null, null]`.
131+
int nrOfHandledDays = 0;
132+
133+
for (ReportDay reportDay : reportWeek.reportDays()) {
134+
135+
final Map<UserLocalId, User> userByLocalId = reportDay.reportDayEntries()
136+
.stream()
137+
.map(ReportDayEntry::user)
138+
.distinct()
139+
.collect(toMap(User::localId, identity()));
140+
141+
users.addAll(userByLocalId.values());
142+
143+
for (User user : users) {
144+
// fill `overtimeDurationsByUser` map
145+
final var durations = overtimeDurationsByUser.computeIfAbsent(user, prepareOvertimeDurationList(nrOfHandledDays));
146+
durations.add(reportDay.accumulatedOvertimeToDateEndOfBusinessByUser(user.localId()));
147+
}
148+
149+
nrOfHandledDays++;
150+
}
151+
152+
final List<ReportOvertimeDto> overtimeDtos = users.stream()
153+
.sorted(Comparator.comparing(User::fullName))
154+
.map(user -> new ReportOvertimeDto(user.fullName(), overtimeDurationToDouble(overtimeDurationsByUser, user)))
155+
.collect(toList());
156+
157+
return new ReportOvertimesDto(reportWeek.dateOfWeeks(), overtimeDtos);
158+
}
159+
160+
private static Function<User, List<Optional<OvertimeDuration>>> prepareOvertimeDurationList(int nrOfHandledDays) {
161+
return (unused) -> IntStream.range(0, nrOfHandledDays).mapToObj((unused2) -> Optional.<OvertimeDuration>empty()).collect(toList());
162+
}
163+
164+
private static List<Double> overtimeDurationToDouble(Map<User, List<Optional<OvertimeDuration>>> overtimeDurationsByUser, User user) {
165+
return overtimeDurationsByUser.get(user).stream()
166+
.map(maybe -> maybe.orElse(null))
167+
.map(overtimeDuration -> overtimeDuration == null ? null : overtimeDuration.hoursDoubleValue())
168+
.collect(toList());
169+
}
170+
104171
String createUrl(String prefix, boolean allUsersSelected, List<UserLocalId> selectedUserLocalIds) {
105172
String url = prefix;
106173

src/main/java/de/focusshift/zeiterfassung/report/ReportDay.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package de.focusshift.zeiterfassung.report;
22

3+
import de.focusshift.zeiterfassung.overtime.OvertimeDuration;
34
import de.focusshift.zeiterfassung.timeentry.PlannedWorkingHours;
45
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
56
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
@@ -13,9 +14,13 @@
1314
import java.util.function.Predicate;
1415
import java.util.stream.Stream;
1516

17+
import static java.util.function.Predicate.not;
18+
import static java.util.stream.Collectors.toMap;
19+
1620
record ReportDay(
1721
LocalDate date,
1822
Map<UserLocalId, PlannedWorkingHours> plannedWorkingHoursByUser,
23+
Map<UserLocalId, OvertimeDuration> accumulatedOvertimeToDateByUser,
1924
Map<UserLocalId, List<ReportDayEntry>> reportDayEntriesByUser
2025
) {
2126

@@ -31,6 +36,61 @@ public PlannedWorkingHours plannedWorkingHoursByUser(UserLocalId userLocalId) {
3136
return findValueByFirstKeyMatch(plannedWorkingHoursByUser, userLocalId::equals).orElse(PlannedWorkingHours.ZERO);
3237
}
3338

39+
public Optional<OvertimeDuration> accumulatedOvertimeToDateByUser(UserLocalId userLocalId) {
40+
return findValueByFirstKeyMatch(accumulatedOvertimeToDateByUser, userLocalId::equals);
41+
}
42+
43+
public Optional<OvertimeDuration> accumulatedOvertimeToDateEndOfBusinessByUser(UserLocalId userLocalId) {
44+
45+
final PlannedWorkingHours plannedWorkingHours = plannedWorkingHoursByUser.get(userLocalId);
46+
final Optional<OvertimeDuration> overtimeStartOfBusiness = accumulatedOvertimeToDateByUser(userLocalId);
47+
48+
// calculate working time duration of this day
49+
// to add it to `overtimeStartOfBusiness`
50+
51+
final List<ReportDayEntry> reportDayEntriesOfUser = reportDayEntriesByUser.getOrDefault(userLocalId, List.of());
52+
53+
final WorkDuration workDurationThisDay = reportDayEntriesOfUser
54+
.stream()
55+
.filter(not(ReportDayEntry::isBreak))
56+
.map(ReportDayEntry::workDuration)
57+
.reduce(WorkDuration.ZERO, WorkDuration::plus);
58+
59+
if (plannedWorkingHours == null) {
60+
// TODO how to handle `plannedWorkingHours=null`? it should be `plannedWorkingHours=ZERO` when everything is ok. `null` should only the case for an unknown `userLocalId` i think.
61+
return overtimeStartOfBusiness;
62+
} else {
63+
final Duration overtimeDurationThisDay = plannedWorkingHours.value().negated().plus(workDurationThisDay.value());
64+
final OvertimeDuration overtimeEndOfBusiness = overtimeStartOfBusiness.orElse(OvertimeDuration.ZERO).plus(new OvertimeDuration(overtimeDurationThisDay));
65+
return Optional.of(overtimeEndOfBusiness);
66+
}
67+
}
68+
69+
public Map<UserLocalId, OvertimeDuration> accumulatedOvertimeToDateEndOfBusinessByUser() {
70+
// `accumulatedOvertimeToDateByUser` could not contain persons with timeEntries at this day.
71+
// we need to iterate ALL persons that should have worked this day.
72+
final Map<UserLocalId, OvertimeDuration> collect = plannedWorkingHoursByUser.keySet()
73+
.stream()
74+
.map(userLocalId -> Map.entry(userLocalId, accumulatedOvertimeToDateEndOfBusinessByUser(userLocalId).orElse(OvertimeDuration.ZERO)))
75+
.collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
76+
77+
return collect;
78+
}
79+
80+
// public OvertimeDuration overtimeByUser(UserLocalId userLocalId) {
81+
//
82+
// final WorkDuration workDuration = reportDayEntriesByUser.getOrDefault(userLocalId, List.of())
83+
// .stream()
84+
// .filter(not(ReportDayEntry::isBreak))
85+
// .map(ReportDayEntry::workDuration)
86+
// .reduce(WorkDuration.ZERO, WorkDuration::plus);
87+
//
88+
// final PlannedWorkingHours plannedWorkingHours = plannedWorkingHoursByUser.get(userLocalId);
89+
// final Duration delta = workDuration.value().minus(plannedWorkingHours.value());
90+
//
91+
// return new OvertimeDuration(delta);
92+
// }
93+
3494
public WorkDuration workDuration() {
3595

3696
final Stream<ReportDayEntry> allReportDayEntries = reportDayEntriesByUser.values()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package de.focusshift.zeiterfassung.report;
2+
3+
import java.util.List;
4+
5+
record ReportOvertimeDto(String personName, List<Double> overtimes) {
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package de.focusshift.zeiterfassung.report;
2+
3+
import java.time.LocalDate;
4+
import java.util.List;
5+
6+
record ReportOvertimesDto(List<LocalDate> dayOfWeeks, List<ReportOvertimeDto> overtimes) {
7+
}

src/main/java/de/focusshift/zeiterfassung/report/ReportServicePermissionAware.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ private ReportWeek emptyReportWeek(Year year, int week) {
111111

112112
private ReportWeek emptyReportWeek(LocalDate startOfWeekDate) {
113113
final List<ReportDay> reportDays = IntStream.rangeClosed(0, 6)
114-
.mapToObj(daysToAdd -> new ReportDay(startOfWeekDate.plusDays(daysToAdd), Map.of(), Map.of()))
114+
.mapToObj(daysToAdd -> new ReportDay(startOfWeekDate.plusDays(daysToAdd), Map.of(), Map.of(), Map.of()))
115115
.toList();
116116

117117
return new ReportWeek(startOfWeekDate, reportDays);

0 commit comments

Comments
 (0)