overtime calculation optimized

This commit is contained in:
Jörg Henke
2024-01-04 20:35:26 +01:00
parent 742446e46e
commit 8be05b8afc
8 changed files with 264 additions and 30 deletions

View File

@ -23,7 +23,7 @@ repositories {
} }
dependencies { dependencies {
implementation 'de.jottyfan:timetrackjooq:20240103d' implementation 'de.jottyfan:timetrackjooq:20240104b'
implementation 'org.apache.logging.log4j:log4j-api:latest.release' implementation 'org.apache.logging.log4j:log4j-api:latest.release'
implementation 'org.apache.logging.log4j:log4j-core:latest.release' implementation 'org.apache.logging.log4j:log4j-core:latest.release'

View File

@ -8,11 +8,17 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import de.jottyfan.timetrack.component.OAuth2Provider; import de.jottyfan.timetrack.component.OAuth2Provider;
import de.jottyfan.timetrack.modules.CommonController; import de.jottyfan.timetrack.modules.CommonController;
import de.jottyfan.timetrack.modules.done.model.DoneBean; import de.jottyfan.timetrack.modules.done.model.DoneBean;
import de.jottyfan.timetrack.modules.done.model.DoneModel; import de.jottyfan.timetrack.modules.done.model.DoneModel;
import de.jottyfan.timetrack.modules.done.model.OvertimeBean;
import de.jottyfan.timetrack.modules.done.model.SummaryBean; import de.jottyfan.timetrack.modules.done.model.SummaryBean;
import de.jottyfan.timetrack.modules.profile.ProfileService; import de.jottyfan.timetrack.modules.profile.ProfileService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
@ -53,6 +59,7 @@ public class DoneController extends CommonController {
model.addAttribute("doneList", list); model.addAttribute("doneList", list);
model.addAttribute("sum", sumBean); model.addAttribute("sum", sumBean);
model.addAttribute("daysum", doneService.getDaysum(day, username)); model.addAttribute("daysum", doneService.getDaysum(day, username));
model.addAttribute("overtimeBean", doneService.getOvertimeBean(username));
model.addAttribute("schedule", weekBean.toJson()); model.addAttribute("schedule", weekBean.toJson());
model.addAttribute("recentList", doneService.getListRecent(username, 10)); model.addAttribute("recentList", doneService.getListRecent(username, 10));
model.addAttribute("projectList", doneService.getProjects(false)); model.addAttribute("projectList", doneService.getProjects(false));
@ -63,6 +70,14 @@ public class DoneController extends CommonController {
model.addAttribute("favorites", doneService.getFavorites(username)); model.addAttribute("favorites", doneService.getFavorites(username));
return "done/list"; return "done/list";
} }
@RolesAllowed("timetrack_user")
@PostMapping("/done/list")
public String getListForDate(Model model, @ModelAttribute("day") LocalDate day) {
DoneModel doneModel = new DoneModel();
doneModel.setDay(day);
return getList(model, doneModel);
}
@RolesAllowed("timetrack_user") @RolesAllowed("timetrack_user")
@GetMapping("/done/abort/{day}") @GetMapping("/done/abort/{day}")
@ -177,4 +192,12 @@ public class DoneController extends CommonController {
doneService.usefavorite(id); doneService.usefavorite(id);
return "redirect:/done/list"; return "redirect:/done/list";
} }
@RolesAllowed("timetrack_user")
@PostMapping(value = "/done/overtime/update")
public String upsertOvertime(@ModelAttribute("overtimeBean") OvertimeBean bean) {
String username = provider.getName();
doneService.upsertOvertime(bean, username);
return "redirect:/done/list";
}
} }

View File

@ -1,26 +1,35 @@
package de.jottyfan.timetrack.modules.done; package de.jottyfan.timetrack.modules.done;
import static de.jottyfan.timetrack.db.done.Tables.T_OVERTIME;
import static de.jottyfan.timetrack.db.done.Tables.T_REQUIRED_WORKTIME;
import static de.jottyfan.timetrack.db.done.Tables.V_DAY; import static de.jottyfan.timetrack.db.done.Tables.V_DAY;
import static de.jottyfan.timetrack.db.profile.Tables.T_LOGIN; import static de.jottyfan.timetrack.db.profile.Tables.T_LOGIN;
import java.sql.Time;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime; import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.jooq.DSLContext; import org.jooq.DSLContext;
import org.jooq.DatePart;
import org.jooq.Field; import org.jooq.Field;
import org.jooq.InsertOnDuplicateStep;
import org.jooq.Record1;
import org.jooq.Record3;
import org.jooq.Record5; import org.jooq.Record5;
import org.jooq.SelectConditionStep; import org.jooq.SelectConditionStep;
import org.jooq.SelectHavingStep;
import org.jooq.UpdateConditionStep;
import org.jooq.impl.DSL; import org.jooq.impl.DSL;
import org.jooq.types.YearToSecond; import org.jooq.types.YearToSecond;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import de.jottyfan.timetrack.db.done.tables.records.TOvertimeRecord;
import de.jottyfan.timetrack.modules.done.model.DaysumBean; import de.jottyfan.timetrack.modules.done.model.DaysumBean;
import de.jottyfan.timetrack.modules.done.model.OvertimeBean;
/** /**
* *
@ -62,29 +71,82 @@ public class DoneRepository {
bean.setBreaks(r.get(BREAKTIME)); bean.setBreaks(r.get(BREAKTIME));
YearToSecond dayOvertime = r.get(V_DAY.DAY_OVERTIME); YearToSecond dayOvertime = r.get(V_DAY.DAY_OVERTIME);
Duration dayOvertimeDuration = dayOvertime == null ? null : dayOvertime.toDuration(); Duration dayOvertimeDuration = dayOvertime == null ? null : dayOvertime.toDuration();
String dayOvertimeString = dayOvertimeDuration == null ? null : String.format("%3d:%2d", dayOvertimeDuration.toHours(), Math.abs(dayOvertimeDuration.toMinutes() % 60)); bean.setDayOvertime(dayOvertimeDuration == null ? null : Long.valueOf(dayOvertimeDuration.toMinutes()).intValue());
bean.setDayOvertime(dayOvertimeString == null ? "?" : dayOvertimeString);
bean.setTotalOvertime(getOvertimeOf(day, login)); bean.setTotalOvertime(getOvertimeOf(day, login));
return bean; return bean;
} }
} }
private LocalTime getOvertimeOf(LocalDate day, String login) { private Integer getOvertimeOf(LocalDate day, String login) {
// using sql string here, because the DSL.sum function does not allow LocalTime, whilst the postgreSQL sum function does allow time calculations Field<Integer> OVERTIME = DSL.field("overtime", Integer.class);
String sqlRaw = """ SelectHavingStep<Record1<Integer>> sql = jooq
select o.worktime_offset + sum(d.worktime - r.required) as overtime // @formatter:off
from done.v_day d .select(T_OVERTIME.OVERTIME_MINUTES.plus(DSL.sum(DSL.extract(V_DAY.WORKTIME, DatePart.MINUTE).minus(T_REQUIRED_WORKTIME.REQUIRED_MINUTES))).as(OVERTIME))
inner join done.t_required_worktime r on r.day = d.day and r.fk_login = d.fk_login .from(V_DAY)
inner join done.t_overtime o on o.fk_login = d.fk_login .innerJoin(T_REQUIRED_WORKTIME).on(T_REQUIRED_WORKTIME.DAY.eq(V_DAY.DAY).and(T_REQUIRED_WORKTIME.FK_LOGIN.eq(V_DAY.FK_LOGIN)))
inner join profile.t_login p on p.pk = d.fk_login .innerJoin(T_OVERTIME).on(T_OVERTIME.FK_LOGIN.eq(V_DAY.FK_LOGIN))
where o.impact < d.day .innerJoin(T_LOGIN).on(T_LOGIN.PK.eq(V_DAY.FK_LOGIN))
and d.day <= '?' .where(T_OVERTIME.IMPACT.ge(V_DAY.DAY.cast(LocalDateTime.class)))
and p.login = '?' .and(V_DAY.DAY.le(day))
group by d.fk_login, o.worktime_offset; .and(T_LOGIN.LOGIN.eq(login))
"""; .groupBy(V_DAY.FK_LOGIN, T_OVERTIME.OVERTIME_MINUTES);
String sql = sqlRaw.replaceFirst("\\?", day.format(DateTimeFormatter.ISO_DATE)).replaceFirst("\\?", login); // @formatter:on
LOGGER.trace(sql); LOGGER.trace(sql);
Time time = (Time) jooq.fetchOne(sql).get(0); return sql.fetchOne(OVERTIME);
return time.toLocalTime(); }
public OvertimeBean getOvertimeBean(String login) {
SelectConditionStep<Record3<Integer, LocalDateTime, Integer>> sql = jooq
// @formatter:off
.select(T_OVERTIME.PK_OVERTIME,
T_OVERTIME.IMPACT,
T_OVERTIME.OVERTIME_MINUTES)
.from(T_OVERTIME)
.innerJoin(T_LOGIN).on(T_LOGIN.PK.eq(T_OVERTIME.FK_LOGIN))
.where(T_LOGIN.LOGIN.eq(login));
// @formatter:on
LOGGER.trace(sql);
Record3<Integer, LocalDateTime, Integer> r = sql.fetchOne();
OvertimeBean bean = new OvertimeBean();
if (r == null) {
bean.setImpact(LocalDate.now());
bean.setOvertimeMinutes(0);
} else {
bean.setId(r.get(T_OVERTIME.PK_OVERTIME));
bean.setImpact(r.get(T_OVERTIME.IMPACT).toLocalDate());
bean.setOvertimeMinutes(r.get(T_OVERTIME.OVERTIME_MINUTES));
}
return bean;
}
public void upsertOvertime(Integer pkOvertime, String login, LocalDate impact, Integer overtimeMinutes) {
if (pkOvertime == null) {
InsertOnDuplicateStep<TOvertimeRecord> sql = jooq
// @formatter:off
.insertInto(T_OVERTIME,
T_OVERTIME.IMPACT,
T_OVERTIME.OVERTIME_MINUTES,
T_OVERTIME.FK_LOGIN)
.select(jooq
.select(DSL.val(impact == null ? null : impact.atStartOfDay()), DSL.val(overtimeMinutes), T_LOGIN.PK)
.from(T_LOGIN)
.where(T_LOGIN.LOGIN.eq(login)));
// @formatter:on
LOGGER.trace(sql);
sql.execute();
}
UpdateConditionStep<TOvertimeRecord> sql = jooq
// @formatter:off
.update(T_OVERTIME)
.set(T_OVERTIME.IMPACT, impact == null ? null : impact.atStartOfDay())
.set(T_OVERTIME.OVERTIME_MINUTES, overtimeMinutes)
.where(T_OVERTIME.PK_OVERTIME.eq(pkOvertime))
.and(T_OVERTIME.FK_LOGIN.in(jooq
.select(T_LOGIN.PK)
.from(T_LOGIN)
.where(T_LOGIN.LOGIN.eq(login))));
// @formatter:on
LOGGER.trace(sql);
sql.execute();
} }
} }

View File

@ -22,6 +22,7 @@ import de.jottyfan.timetrack.db.done.tables.records.VProjectRecord;
import de.jottyfan.timetrack.modules.done.model.DaysumBean; import de.jottyfan.timetrack.modules.done.model.DaysumBean;
import de.jottyfan.timetrack.modules.done.model.DoneBean; import de.jottyfan.timetrack.modules.done.model.DoneBean;
import de.jottyfan.timetrack.modules.done.model.FavoriteBean; import de.jottyfan.timetrack.modules.done.model.FavoriteBean;
import de.jottyfan.timetrack.modules.done.model.OvertimeBean;
import de.jottyfan.timetrack.modules.note.NoteService; import de.jottyfan.timetrack.modules.note.NoteService;
/** /**
@ -235,4 +236,12 @@ public class DoneService {
public DaysumBean getDaysum(LocalDate day, String login) { public DaysumBean getDaysum(LocalDate day, String login) {
return repository.getDaysum(day, login); return repository.getDaysum(day, login);
} }
public OvertimeBean getOvertimeBean(String login) {
return repository.getOvertimeBean(login);
}
public void upsertOvertime(OvertimeBean bean, String username) {
repository.upsertOvertime(bean.getId(), username, bean.getImpact(), bean.getOvertimeMinutes());
}
} }

View File

@ -15,8 +15,44 @@ public class DaysumBean implements Serializable {
private LocalTime daytimeUntil; private LocalTime daytimeUntil;
private LocalTime dayworktime; private LocalTime dayworktime;
private LocalTime breaks; private LocalTime breaks;
private String dayOvertime; private Integer dayOvertime;
private LocalTime totalOvertime; private Integer totalOvertime;
private String lz(Integer i) {
if (i < 10) {
return "0" + i;
} else {
return i.toString();
}
}
public String printTotalOvertime() {
StringBuilder buf = new StringBuilder();
if (totalOvertime == null) {
buf.append("?");
} else {
Boolean isNegative = totalOvertime < 0;
buf.append(isNegative ? "-" : "");
buf.append(Math.abs(totalOvertime) / 60);
buf.append(":");
buf.append(lz(Math.abs(totalOvertime) % 60));
}
return buf.toString();
}
public String printDayOvertime() {
StringBuilder buf = new StringBuilder();
if (dayOvertime == null) {
buf.append("?");
} else {
Boolean isNegative = dayOvertime < 0;
buf.append(isNegative ? "-" : "");
buf.append(Math.abs(dayOvertime) / 60);
buf.append(":");
buf.append(lz(Math.abs(dayOvertime) % 60));
}
return buf.toString();
}
/** /**
* @return the daytimeFrom * @return the daytimeFrom
@ -77,28 +113,28 @@ public class DaysumBean implements Serializable {
/** /**
* @return the dayOvertime * @return the dayOvertime
*/ */
public String getDayOvertime() { public Integer getDayOvertime() {
return dayOvertime; return dayOvertime;
} }
/** /**
* @param dayOvertime the dayOvertime to set * @param dayOvertime the dayOvertime to set
*/ */
public void setDayOvertime(String dayOvertime) { public void setDayOvertime(Integer dayOvertime) {
this.dayOvertime = dayOvertime; this.dayOvertime = dayOvertime;
} }
/** /**
* @return the totalovertime * @return the totalovertime
*/ */
public LocalTime getTotalOvertime() { public Integer getTotalOvertime() {
return totalOvertime; return totalOvertime;
} }
/** /**
* @param totalovertime the totalovertime to set * @param totalovertime the totalovertime to set
*/ */
public void setTotalOvertime(LocalTime totalOvertime) { public void setTotalOvertime(Integer totalOvertime) {
this.totalOvertime = totalOvertime; this.totalOvertime = totalOvertime;
} }

View File

@ -0,0 +1,62 @@
package de.jottyfan.timetrack.modules.done.model;
import java.io.Serializable;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
/**
*
* @author jotty
*
*/
public class OvertimeBean implements Serializable {
private static final long serialVersionUID = 1L;
private Integer id;
@DateTimeFormat(pattern="yyyy-MM-dd")
private LocalDate impact;
private Integer overtimeMinutes;
/**
* @return the id
*/
public Integer getId() {
return id;
}
/**
* @param id the id to set
*/
public void setId(Integer id) {
this.id = id;
}
/**
* @return the impact
*/
public LocalDate getImpact() {
return impact;
}
/**
* @param impact the impact to set
*/
public void setImpact(LocalDate impact) {
this.impact = impact;
}
/**
* @return the overtimeMinutes
*/
public Integer getOvertimeMinutes() {
return overtimeMinutes;
}
/**
* @param overtimeMinutes the overtimeMinutes to set
*/
public void setOvertimeMinutes(Integer overtimeMinutes) {
this.overtimeMinutes = overtimeMinutes;
}
}

View File

@ -310,6 +310,15 @@ body {
padding: 4px; padding: 4px;
} }
.emphpink {
font-weight: bolder;
color: #613583;
border: 1px solid gray;
border-radius: 8px;
background-image: linear-gradient(to left, #e6e6e6, white);
padding: 4px;
}
.tab-pane-table { .tab-pane-table {
background-color: white; background-color: white;
padding: 8px; padding: 8px;

View File

@ -52,6 +52,8 @@
<li class="nav-item"><a class="nav-link navlinkstyle" data-bs-toggle="tab" href="#div_module">Modul</a></li> <li class="nav-item"><a class="nav-link navlinkstyle" data-bs-toggle="tab" href="#div_module">Modul</a></li>
<li class="nav-item"><a class="nav-link navlinkstyle" data-bs-toggle="tab" href="#div_job">Aufgabe</a></li> <li class="nav-item"><a class="nav-link navlinkstyle" data-bs-toggle="tab" href="#div_job">Aufgabe</a></li>
<li class="nav-item"><a class="nav-link navlinkstyle" data-bs-toggle="tab" href="#div_billing">Abrechnung</a></li> <li class="nav-item"><a class="nav-link navlinkstyle" data-bs-toggle="tab" href="#div_billing">Abrechnung</a></li>
<li class="nav-item"><a class="nav-link navlinkstyle" data-bs-toggle="tab" href="#div_overtime">Überstunden</a></li>
<li class="nav-item"><a class="nav-link navlinkstyle" data-bs-toggle="tab" href="#div_slot">Slots</a></li>
</ul> </ul>
<div class="tabdivblurred tab-content"> <div class="tabdivblurred tab-content">
<div id="div_list" class="tab-pane active tab-pane-table"> <div id="div_list" class="tab-pane active tab-pane-table">
@ -104,8 +106,8 @@
<td>Ende: <span class="emphgreen" th:text="${#temporals.format(daysum.daytimeUntil, 'HH:mm')}" th:if="${daysum.daytimeUntil}"></span></td> <td>Ende: <span class="emphgreen" th:text="${#temporals.format(daysum.daytimeUntil, 'HH:mm')}" th:if="${daysum.daytimeUntil}"></span></td>
<td>Arbeitszeit total: <span class="emphblue" th:text="${#temporals.format(daysum.dayworktime, 'HH:mm')}" th:if="${daysum.dayworktime}"></span></td> <td>Arbeitszeit total: <span class="emphblue" th:text="${#temporals.format(daysum.dayworktime, 'HH:mm')}" th:if="${daysum.dayworktime}"></span></td>
<td>Pausezeit total: <span class="emphorange" th:text="${#temporals.format(daysum.breaks, 'HH:mm')}" th:if="${daysum.breaks}"></span></td> <td>Pausezeit total: <span class="emphorange" th:text="${#temporals.format(daysum.breaks, 'HH:mm')}" th:if="${daysum.breaks}"></span></td>
<td>Überstunden heute: <span class="emphred" th:text="${daysum.dayOvertime}"></span></td> <td>Überstunden heute: <span class="emphred" th:text="${daysum.printDayOvertime()}"></span></td>
<td colspan="2">Überstunden total: <span class="emphred" th:text="${#temporals.format(daysum.totalOvertime, 'HH:mm')}" th:if="${daysum.totalOvertime}"></span></td> <td colspan="2">Überstunden total: <span class="emphpink" th:text="${daysum.printTotalOvertime()}" th:if="${daysum.totalOvertime}"></span></td>
</tr> </tr>
<tr> <tr>
<td></td> <td></td>
@ -213,6 +215,37 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="div_overtime" class="tab-pane fade tab-pane-table">
<form th:action="@{/done/overtime/update}" method="post" th:object="${overtimeBean}">
<input type="hidden" th:field="*{id}" />
<div class="container">
<div class="row g-3">
<div class="col-sm-12">
<div class="alert alert-info">Hier werden die Überstunden einmalig angegeben. Dabei wird für einen bestimmten Tagesbeginn, an dem die Überstunden bekannt sind, der Wert gesetzt. Alle
nachfolgenden Zeiten werden bei der Anzeige der Überstunden während der Datenerfassung berücksichtigt und einberechnet.</div>
</div>
<div class="col-sm-3">Tagesbeginn</div>
<div class="col-sm-9">
<input type="date" th:field="*{impact}" class="form-control" />
</div>
<div class="col-sm-3">Überstunden (min)</div>
<div class="col-sm-9">
<input type="number" th:field="*{overtimeMinutes}" class="form-control" />
</div>
<div class="col-sm-3"></div>
<div class="col-sm-9">
<button type="submit" class="btn btn-outline-primary">Übernehmen</button>
</div>
</div>
</div>
</form>
</div>
<div id="div_slot" class="tab-pane fade tab-pane-table">
<div class="alert alert-info">
Zur Berechnung der täglichen Überstunden müssen Slots angelegt werden, die definieren, an welchen Tagen wieviele Stunden zu arbeiten ist.
Urlaub und Arbeitsbefreiung können durch das Entfernen des jeweiligen Slots ermöglicht werden.
</div>
</div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { $(document).ready(function() {