diff --git a/pkg/odoo/model/attendance.go b/pkg/odoo/model/attendance.go index 085804a..c14c549 100644 --- a/pkg/odoo/model/attendance.go +++ b/pkg/odoo/model/attendance.go @@ -24,6 +24,10 @@ type Attendance struct { // Reason describes the "action reason" from Odoo. // NOTE: This field has special meaning when calculating the overtime. Reason *ActionReason `json:"action_desc,omitempty"` + + // Timezone is the custom Time location in Odoo. + // This is an extra, custom field since Odoo saves the time in UTC only, leaving out the time zone information. + Timezone *odoo.TimeZone `json:"x_timezone,omitempty"` } type AttendanceList odoo.List[Attendance] @@ -47,7 +51,7 @@ func (o Odoo) fetchAttendances(ctx context.Context, domainFilters []odoo.Filter) err := o.querier.SearchGenericModel(ctx, odoo.SearchReadModel{ Model: "hr.attendance", Domain: domainFilters, - Fields: []string{"employee_id", "name", "action", "action_desc"}, + Fields: []string{"employee_id", "name", "action", "action_desc", "x_timezone"}, Limit: 0, Offset: 0, }, &result) diff --git a/pkg/odoo/timezone.go b/pkg/odoo/timezone.go index bddd5a0..473dcac 100644 --- a/pkg/odoo/timezone.go +++ b/pkg/odoo/timezone.go @@ -54,3 +54,18 @@ func (tz *TimeZone) IsEmpty() bool { } return false } + +// String returns the location name. +// Returns empty string if nil. +func (tz *TimeZone) String() string { + if tz == nil || tz.Location == nil { + return "" + } + return tz.Location.String() +} + +// IsEqualTo returns true if the given TimeZone is equal to other. +// If both are nil, it returns true. +func (tz *TimeZone) IsEqualTo(other *TimeZone) bool { + return tz.String() == other.String() +} diff --git a/pkg/odoo/timezone_test.go b/pkg/odoo/timezone_test.go index edf7ddc..53225c0 100644 --- a/pkg/odoo/timezone_test.go +++ b/pkg/odoo/timezone_test.go @@ -8,6 +8,24 @@ import ( "github.com/stretchr/testify/require" ) +var ( + zurichTZ *time.Location + vancouverTZ *time.Location +) + +func init() { + zue, err := time.LoadLocation("Europe/Zurich") + if err != nil { + panic(err) + } + zurichTZ = zue + van, err := time.LoadLocation("America/Vancouver") + if err != nil { + panic(err) + } + vancouverTZ = van +} + func TestTimeZone_UnmarshalJSON(t *testing.T) { tests := map[string]struct { givenInput string @@ -59,6 +77,50 @@ func TestTimeZone_MarshalJSON(t *testing.T) { } } +func TestTimeZone_IsEqualTo(t *testing.T) { + tests := map[string]struct { + givenTimeZoneA *TimeZone + givenTimeZoneB *TimeZone + expectedResult bool + }{ + "BothNil": { + givenTimeZoneA: nil, givenTimeZoneB: nil, + expectedResult: true, + }, + "BothNilNested": { + givenTimeZoneA: NewTimeZone(nil), + givenTimeZoneB: NewTimeZone(nil), + expectedResult: true, + }, + "A_IsNil": { + givenTimeZoneA: nil, + givenTimeZoneB: NewTimeZone(vancouverTZ), + expectedResult: false, + }, + "B_IsNil": { + givenTimeZoneA: NewTimeZone(vancouverTZ), + givenTimeZoneB: nil, + expectedResult: false, + }, + "BothSame": { + givenTimeZoneA: NewTimeZone(zurichTZ), + givenTimeZoneB: NewTimeZone(zurichTZ), + expectedResult: true, + }, + "A_NestedNil": { + givenTimeZoneA: NewTimeZone(nil), + givenTimeZoneB: NewTimeZone(zurichTZ), + expectedResult: false, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := tc.givenTimeZoneA.IsEqualTo(tc.givenTimeZoneB) + assert.Equal(t, tc.expectedResult, actual, "zone not equal: zone A: %s, zone B: %s", tc.givenTimeZoneA, tc.givenTimeZoneB) + }) + } +} + func mustLoadLocation(name string) *time.Location { loc, err := time.LoadLocation(name) if err != nil { diff --git a/pkg/timesheet/dailysummary.go b/pkg/timesheet/dailysummary.go index c1c1f49..9cda7f3 100644 --- a/pkg/timesheet/dailysummary.go +++ b/pkg/timesheet/dailysummary.go @@ -125,6 +125,11 @@ func (s *DailySummary) ValidateTimesheetEntries() error { return NewValidationError(s.Date, fmt.Errorf("the reasons for shift %s and %s should be equal: start %s (%s), end %s (%s)", model.ActionSignIn, model.ActionSignOut, shift.Start.DateTime.Format(odoo.TimeFormat), shift.Start.Reason, shift.End.DateTime.Format(odoo.TimeFormat), shift.End.Reason)) } + if !shift.Start.Timezone.IsEqualTo(shift.End.Timezone) { + return NewValidationError(s.Date, fmt.Errorf("if given, explicit timezones for attendances in a shift must be equal: start %s (%s), end: %s (%s)", + shift.Start.DateTime.Format(odoo.TimeFormat), shift.Start.Timezone, shift.End.DateTime.Format(odoo.TimeFormat), shift.End.Timezone, + )) + } totalDuration += shiftDuration } if totalDuration > 24*time.Hour { diff --git a/pkg/timesheet/dailysummary_test.go b/pkg/timesheet/dailysummary_test.go index 886e034..45ca4ad 100644 --- a/pkg/timesheet/dailysummary_test.go +++ b/pkg/timesheet/dailysummary_test.go @@ -350,6 +350,15 @@ func TestDailySummary_ValidateTimesheetEntries(t *testing.T) { }, expectedError: "the reasons for shift sign_in and sign_out should be equal: start 08:00:00 (), end 10:00:00 (Sick / Medical Consultation)", }, + "ExplicitTimezoneDifferent": { + givenShifts: []AttendanceShift{ + { + Start: model.Attendance{DateTime: odoo.NewDate(2021, 01, 02, 8, 0, 0, time.UTC), Action: model.ActionSignIn}, + End: model.Attendance{DateTime: odoo.NewDate(2021, 01, 02, 18, 0, 0, time.UTC), Action: model.ActionSignIn, Timezone: odoo.NewTimeZone(vancouverTZ)}, + }, + }, + expectedError: "if given, explicit timezones for attendances in a shift must be equal: start 08:00:00 (), end: 18:00:00 (America/Vancouver)", + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { diff --git a/pkg/timesheet/report.go b/pkg/timesheet/report.go index 414e1c1..aa44ae7 100644 --- a/pkg/timesheet/report.go +++ b/pkg/timesheet/report.go @@ -150,18 +150,20 @@ func (r *ReportBuilder) getTimeZone() *time.Location { } func (r *ReportBuilder) addAttendancesToDailyShifts(attendances model.AttendanceList, dailies []*DailySummary) { - tz := r.getTimeZone() + monthTz := r.getTimeZone() dailyMap := make(map[string]*DailySummary, len(dailies)) for _, dailySummary := range dailies { dailyMap[dailySummary.Date.Format(odoo.DateFormat)] = dailySummary } for _, attendance := range attendances.Items { + tz := attendance.Timezone.LocationOrDefault(monthTz) date := attendance.DateTime.In(tz) daily, exists := dailyMap[date.Format(odoo.DateFormat)] if !exists { continue // irrelevant attendance } + daily.Date = daily.Date.In(tz) // Update the timezone of the day var shift AttendanceShift shiftCount := len(daily.Shifts) newShift := false diff --git a/pkg/timesheet/report_test.go b/pkg/timesheet/report_test.go index 72ad3f0..00c8bed 100644 --- a/pkg/timesheet/report_test.go +++ b/pkg/timesheet/report_test.go @@ -442,8 +442,11 @@ func TestReportBuilder_CalculateReport(t *testing.T) { {DateTime: odoo.NewDate(2021, 1, 5, 10, 0, 0, zurichTZ), Action: model.ActionSignIn}, {DateTime: odoo.NewDate(2021, 1, 5, 17, 0, 0, zurichTZ), Action: model.ActionSignOut}, // 7h worked - {DateTime: odoo.NewDate(2021, 1, 7, 8, 0, 0, zurichTZ), Action: model.ActionSignIn}, - {DateTime: odoo.NewDate(2021, 1, 7, 17, 5, 0, zurichTZ), Action: model.ActionSignOut}, // faked signed out, still working though + {DateTime: odoo.NewDate(2021, 1, 7, 10, 0, 0, vancouverTZ), Action: model.ActionSignIn, Timezone: odoo.NewTimeZone(vancouverTZ)}, + {DateTime: odoo.NewDate(2021, 1, 7, 18, 0, 0, vancouverTZ), Action: model.ActionSignIn, Timezone: odoo.NewTimeZone(vancouverTZ)}, // 8h worked + + {DateTime: odoo.NewDate(2021, 1, 8, 8, 0, 0, zurichTZ), Action: model.ActionSignIn}, + {DateTime: odoo.NewDate(2021, 1, 8, 17, 5, 0, zurichTZ), Action: model.ActionSignOut}, // faked signed out, still working though }} givenLeaves := odoo.List[model.Leave]{Items: []model.Leave{ {DateFrom: odoo.NewDate(2021, 01, 06, 0, 0, 0, zurichTZ), DateTo: odoo.NewDate(2021, 01, 06, 23, 59, 0, zurichTZ), Type: &model.LeaveType{Name: TypeLegalLeavesPrefix}, State: StateApproved}, @@ -463,7 +466,7 @@ func TestReportBuilder_CalculateReport(t *testing.T) { report, err := b.CalculateReport(start, end) assert.NoError(t, err) assert.Equal(t, report.Employee.Name, givenEmployee.Name, "employee name") - assert.Equal(t, ((9+3+7+9)*time.Hour)+(5*time.Minute), report.Summary.TotalWorkedTime, "total worked time") + assert.Equal(t, ((9+3+7+9+8)*time.Hour)+(5*time.Minute), report.Summary.TotalWorkedTime, "total worked time") assert.Equal(t, ((1+3+1)*time.Hour)+(5*time.Minute), report.Summary.TotalOvertime, "total over time") assert.Equal(t, 1.0, report.Summary.TotalLeave, "total leave") assert.Equal(t, (8+2)*time.Hour, report.Summary.TotalExcusedTime, "total excused time") diff --git a/pkg/web/controller/view.go b/pkg/web/controller/view.go index 2a825a4..8ede033 100644 --- a/pkg/web/controller/view.go +++ b/pkg/web/controller/view.go @@ -55,7 +55,7 @@ func (v BaseView) GetPreviousMonth(year, month int) (int, int) { } // FormatDailySummary returns Values with sensible format. -func (v BaseView) FormatDailySummary(daily *timesheet.DailySummary) Values { +func (v BaseView) FormatDailySummary(report timesheet.Report, daily *timesheet.DailySummary) Values { overtimeSummary := daily.CalculateOvertimeSummary() basic := Values{ "Weekday": daily.Date.Weekday(), @@ -71,6 +71,9 @@ func (v BaseView) FormatDailySummary(daily *timesheet.DailySummary) Values { if daily.HasAbsences() { basic["LeaveType"] = daily.Absences[0].Reason } + if report.From.Location() != daily.Date.Location() { + basic["Timezone"] = daily.Date.Location().String() + } return basic } diff --git a/pkg/web/overtimereport/monthlyreport_view.go b/pkg/web/overtimereport/monthlyreport_view.go index 1a110b9..2dfecf3 100644 --- a/pkg/web/overtimereport/monthlyreport_view.go +++ b/pkg/web/overtimereport/monthlyreport_view.go @@ -21,7 +21,7 @@ func (v *monthlyReportView) GetValuesForMonthlyReport(report timesheet.BalanceRe if summary.IsWeekend() && summary.CalculateOvertimeSummary().WorkingTime() == 0 { continue } - values := v.FormatDailySummary(summary) + values := v.FormatDailySummary(report.Report, summary) if values["ValidationError"] != nil { hasInvalidAttendances = "Your timesheet contains errors." } diff --git a/pkg/web/reportconfig/config_view.go b/pkg/web/reportconfig/config_view.go index 0f1271b..1ce99f8 100644 --- a/pkg/web/reportconfig/config_view.go +++ b/pkg/web/reportconfig/config_view.go @@ -20,7 +20,7 @@ func (v *ConfigView) GetConfigurationValues(report timesheet.Report) controller. if summary.IsWeekend() && summary.CalculateOvertimeSummary().WorkingTime() == 0 { continue } - formatted = append(formatted, v.FormatDailySummary(summary)) + formatted = append(formatted, v.FormatDailySummary(report, summary)) } summary := controller.Values{ "TotalOvertime": v.FormatDurationInHours(report.Summary.TotalOvertime), diff --git a/templates/overtimereport-monthly.html b/templates/overtimereport-monthly.html index 85a4038..317df6d 100644 --- a/templates/overtimereport-monthly.html +++ b/templates/overtimereport-monthly.html @@ -42,7 +42,7 @@