diff --git a/pkg/timesheet/dailysummary.go b/pkg/timesheet/dailysummary.go index ac3a35c..addb01c 100644 --- a/pkg/timesheet/dailysummary.go +++ b/pkg/timesheet/dailysummary.go @@ -12,6 +12,15 @@ type DailySummary struct { FTERatio float64 } +type OvertimeSummary struct { + RegularWorkingTime time.Duration + SickLeaveTime time.Duration + AuthoritiesTime time.Duration + OutOfOfficeTime time.Duration + DailyMax time.Duration + PublicServiceTime time.Duration +} + // NewDailySummary creates a new instance. // The fteRatio is the percentage (input a value between 0..1) of the employee and is used to calculate the daily maximum hours an employee should work. // Date is expected to be in a localized timezone. @@ -40,7 +49,7 @@ func (s *DailySummary) addAbsenceBlock(block AbsenceBlock) { s.Absences = append(s.Absences, block) } -// CalculateOvertime returns the duration of overtime. +// CalculateOvertimeSummary returns the duration of overtime. // If returned duration is positive, then the employee did overtime and undertime if duration is negative. // // The overtime is then calculated according to these business rules: @@ -49,28 +58,45 @@ func (s *DailySummary) addAbsenceBlock(block AbsenceBlock) { // However, there's no overtime possible using excused hours // * If the working hours exceed the theoretical daily maximum, then the excused hours are basically ignored. // Example: it's not possible to work 9 hours, have 1 hour sick leave and expect 2 hours overtime for an 8 hours daily maximum, the overtime here is 1 hour. -func (s *DailySummary) CalculateOvertime() time.Duration { - workingTime := s.CalculateWorkingTime() - excusedTime := s.CalculateExcusedTime() +func (s *DailySummary) CalculateOvertimeSummary() OvertimeSummary { + os := OvertimeSummary{} + dailyMax := s.calculateDailyMax() - s.CalculateAbsenceTime() + os.DailyMax = dailyMax + s.calculateWorkingTime(&os) + s.calculateExcusedTime(&os) + return os +} - dailyMax := s.CalculateDailyMax() - s.CalculateAbsenceTime() - if workingTime >= dailyMax { - // Can't be on sick leave etc. if working overtime. +// Overtime returns the total overtime with all business rules respected. +func (s OvertimeSummary) Overtime() time.Duration { + excusedTime := s.ExcusedTime() + workingTime := s.WorkingTime() + if workingTime >= s.DailyMax { + // Can't be on excused hours. if working overtime. excusedTime = 0 - } else if workingTime+excusedTime > dailyMax { + } else if workingTime+excusedTime > s.DailyMax { // There is overlap: Not enough workHours, but having excused hours = Cap at daily max, no overtime - excusedTime = dailyMax - workingTime + excusedTime = s.DailyMax - workingTime } - overtime := workingTime + excusedTime - dailyMax + return workingTime + excusedTime - s.DailyMax +} - return overtime +// WorkingTime is the sum of OutOfOfficeTime (multiplied with 1.5) and RegularWorkingTime. +func (s OvertimeSummary) WorkingTime() time.Duration { + overtime := 1.5 * float64(s.OutOfOfficeTime) + return s.RegularWorkingTime + time.Duration(overtime) } -// CalculateDailyMax returns the theoretical amount of hours that an employee should work on this day. +// ExcusedTime returns the sum of AuthoritiesTime, PublicServiceTime and SickLeaveTime. +func (s OvertimeSummary) ExcusedTime() time.Duration { + return s.AuthoritiesTime + s.PublicServiceTime + s.SickLeaveTime +} + +// calculateDailyMax returns the theoretical amount of hours that an employee should work on this day. // * It returns 0 for weekend days. // * It returns 8.5 hours multiplied by FTE ratio for days in 2020 and earlier. // * It returns 8.0 hours multiplied by FTE ratio for days in 2021 and later. -func (s *DailySummary) CalculateDailyMax() time.Duration { +func (s *DailySummary) calculateDailyMax() time.Duration { if s.IsWeekend() { return 0 } @@ -81,34 +107,32 @@ func (s *DailySummary) CalculateDailyMax() time.Duration { return time.Duration(8 * s.FTERatio * float64(time.Hour)) } -// CalculateWorkingTime accumulates all working hours from that day. -// The outside office hours are multiplied with 1.5. -func (s *DailySummary) CalculateWorkingTime() time.Duration { - workTime := time.Duration(0) +// calculateWorkingTime accumulates all working hours from that day. +func (s *DailySummary) calculateWorkingTime(o *OvertimeSummary) { for _, shift := range s.Shifts { + diff := shift.End.Sub(shift.Start) switch shift.Reason { case "": - diff := shift.End.Sub(shift.Start) - workTime += diff + o.RegularWorkingTime += diff case ReasonOutsideOfficeHours: - diff := 1.5 * float64(shift.End.Sub(shift.Start)) - workTime += time.Duration(diff) + o.OutOfOfficeTime += diff } } - return workTime } -// CalculateExcusedTime accumulates all hours that are excused in some way (sick leave etc) from that day. -func (s *DailySummary) CalculateExcusedTime() time.Duration { - total := time.Duration(0) +// calculateExcusedTime accumulates all hours that are excused in some way (sick leave etc) from that day. +func (s *DailySummary) calculateExcusedTime(o *OvertimeSummary) { for _, shift := range s.Shifts { + diff := shift.End.Sub(shift.Start) switch shift.Reason { - case ReasonSickLeave, ReasonAuthorities, ReasonPublicService: - diff := shift.End.Sub(shift.Start) - total += diff + case ReasonSickLeave: + o.SickLeaveTime += diff + case ReasonAuthorities: + o.AuthoritiesTime += diff + case ReasonPublicService: + o.PublicServiceTime += diff } } - return total } // CalculateAbsenceTime accumulates all absence hours from that day. @@ -119,7 +143,7 @@ func (s *DailySummary) CalculateAbsenceTime() time.Duration { // VSHN specific: Odoo treats "Unpaid" as normal leave, but for VSHN it's informational-only, meaning one still has to work. // For every other type of absence, we add the daily max equivalent. - total += s.CalculateDailyMax() + total += s.calculateDailyMax() } } return total diff --git a/pkg/timesheet/dailysummary_test.go b/pkg/timesheet/dailysummary_test.go index 05b3bb4..6cd5f63 100644 --- a/pkg/timesheet/dailysummary_test.go +++ b/pkg/timesheet/dailysummary_test.go @@ -107,7 +107,7 @@ func TestDailySummary_CalculateOvertime(t *testing.T) { Shifts: tt.givenShifts, FTERatio: 1, } - result := s.CalculateOvertime() + result := s.CalculateOvertimeSummary().Overtime() assert.Equal(t, tt.expectedOvertime, result) }) } @@ -168,7 +168,7 @@ func TestDailySummary_CalculateDailyMaxHours(t *testing.T) { FTERatio: tt.givenFteRatio, Absences: tt.givenAbsences, } - result := s.CalculateDailyMax() + result := s.calculateDailyMax() assert.Equal(t, time.Duration(tt.expectedHours*float64(time.Hour)), result) }) } diff --git a/pkg/timesheet/report.go b/pkg/timesheet/report.go index 86a1339..84cb4fa 100644 --- a/pkg/timesheet/report.go +++ b/pkg/timesheet/report.go @@ -46,9 +46,10 @@ type AbsenceBlock struct { } type Summary struct { - TotalOvertime time.Duration - TotalExcusedTime time.Duration - TotalWorkedTime time.Duration + TotalOvertime time.Duration + TotalExcusedTime time.Duration + TotalWorkedTime time.Duration + TotalOutOfOfficeTime time.Duration // TotalLeave is the amount of paid leave days. // This value respects FTE ratio, e.g. in a 50% ratio a public holiday is still counted as '1d'. TotalLeave float64 @@ -129,9 +130,11 @@ func (r *ReportBuilder) CalculateReport() (Report, error) { summary := Summary{} for _, dailySummary := range dailySummaries { - summary.TotalOvertime += dailySummary.CalculateOvertime() - summary.TotalExcusedTime += dailySummary.CalculateExcusedTime() - summary.TotalWorkedTime += dailySummary.CalculateWorkingTime() + overtimeSummary := dailySummary.CalculateOvertimeSummary() + summary.TotalOvertime += overtimeSummary.Overtime() + summary.TotalExcusedTime += overtimeSummary.ExcusedTime() + summary.TotalWorkedTime += overtimeSummary.WorkingTime() + summary.TotalOutOfOfficeTime += overtimeSummary.OutOfOfficeTime if dailySummary.IsHoliday() { summary.TotalLeave += 1 } diff --git a/pkg/web/controller/view.go b/pkg/web/controller/view.go index be025f7..bfea07e 100644 --- a/pkg/web/controller/view.go +++ b/pkg/web/controller/view.go @@ -56,13 +56,14 @@ func (v BaseView) GetPreviousMonth(year, month int) (int, int) { // FormatDailySummary returns Values with sensible format. func (v BaseView) FormatDailySummary(daily *timesheet.DailySummary) Values { + overtimeSummary := daily.CalculateOvertimeSummary() basic := Values{ "Weekday": daily.Date.Weekday(), "Date": daily.Date.Format(odoo.DateFormat), "Workload": daily.FTERatio * 100, - "ExcusedHours": v.FormatDurationInHours(daily.CalculateExcusedTime()), - "WorkedHours": v.FormatDurationInHours(daily.CalculateWorkingTime()), - "OvertimeHours": v.FormatDurationInHours(daily.CalculateOvertime()), + "ExcusedHours": v.FormatDurationInHours(overtimeSummary.ExcusedTime()), + "WorkedHours": v.FormatDurationInHours(overtimeSummary.WorkingTime()), + "OvertimeHours": v.FormatDurationInHours(overtimeSummary.Overtime()), "LeaveType": "", } if daily.HasAbsences() { diff --git a/pkg/web/employeereport/employeereport_view.go b/pkg/web/employeereport/employeereport_view.go index defa519..2864ced 100644 --- a/pkg/web/employeereport/employeereport_view.go +++ b/pkg/web/employeereport/employeereport_view.go @@ -57,6 +57,7 @@ func (v *reportView) getValuesForReport(report timesheet.Report, previousPayslip "Leaves": report.Summary.TotalLeave, "ExcusedHours": v.FormatDurationInHours(report.Summary.TotalExcusedTime), "WorkedHours": v.FormatDurationInHours(report.Summary.TotalWorkedTime), + "OutOfOfficeHours": v.FormatDurationInHours(report.Summary.TotalOutOfOfficeTime), "OvertimeHours": v.FormatDurationInHours(report.Summary.TotalOvertime), "PreviousBalance": previousBalanceCellText, "NextBalance": nextBalanceCellText, diff --git a/pkg/web/overtimereport/monthlyreport_view.go b/pkg/web/overtimereport/monthlyreport_view.go index 6a70e09..53c56cb 100644 --- a/pkg/web/overtimereport/monthlyreport_view.go +++ b/pkg/web/overtimereport/monthlyreport_view.go @@ -39,7 +39,7 @@ func (v *reportView) formatMonthlySummary(s timesheet.Summary, payslip *model.Pa func (v *reportView) GetValuesForMonthlyReport(report timesheet.Report, payslip *model.Payslip) controller.Values { formatted := make([]controller.Values, 0) for _, summary := range report.DailySummaries { - if summary.IsWeekend() && summary.CalculateWorkingTime() == 0 { + if summary.IsWeekend() && summary.CalculateOvertimeSummary().WorkingTime() == 0 { continue } formatted = append(formatted, v.FormatDailySummary(summary)) diff --git a/pkg/web/reportconfig/config_view.go b/pkg/web/reportconfig/config_view.go index d9c411c..0f1271b 100644 --- a/pkg/web/reportconfig/config_view.go +++ b/pkg/web/reportconfig/config_view.go @@ -17,7 +17,7 @@ type ConfigView struct { func (v *ConfigView) GetConfigurationValues(report timesheet.Report) controller.Values { formatted := make([]controller.Values, 0) for _, summary := range report.DailySummaries { - if summary.IsWeekend() && summary.CalculateWorkingTime() == 0 { + if summary.IsWeekend() && summary.CalculateOvertimeSummary().WorkingTime() == 0 { continue } formatted = append(formatted, v.FormatDailySummary(summary)) diff --git a/templates/employeereport.html b/templates/employeereport.html index 1eccb6d..9be860f 100644 --- a/templates/employeereport.html +++ b/templates/employeereport.html @@ -75,6 +75,7 @@