Skip to content

Commit

Permalink
Merge pull request #29 from vshn/contracts
Browse files Browse the repository at this point in the history
Query the workload percentage from Odoo
  • Loading branch information
ccremer authored Nov 22, 2021
2 parents c1947ec + 9c54e13 commit a4e220a
Show file tree
Hide file tree
Showing 14 changed files with 398 additions and 55 deletions.
10 changes: 5 additions & 5 deletions doc/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ In the end it doesn't matter:
- Not using a leave day, but only work half day results in undertime, but still having an additional holiday.
At the end of the year, excess holidays are transformed into overtime.

## Workload ratio

The workload ratio can be retrieved from Odoo via the "Contracts" model.
However, special read access needs to be granted so that they can be queried for their own user.

## FAQs

### Why not an SPA and offload all work into the user's browser?
Expand All @@ -27,11 +32,6 @@ CORS. Browsers won't connect to Odoo from somewhere else (which is fine).
Payslips can be queried and updated - just not with the normal VSHNeer access levels.
Easiest solution would be to enable at least read access so that odootools can accumulate the delta.

### Get FTE ratio instead of manually entering it?

FTE ratio can also be queried, but not with current VSHNeer access levels.
The contracts and the ratio are available as fields - one just needs access to it.

# Backlog

## Caching
Expand Down
67 changes: 67 additions & 0 deletions pkg/odoo/contract.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package odoo

import (
"fmt"
"time"
)

type ContractList []Contract

type Contract struct {
ID float64 `json:"id"`
Start *Date `json:"date_start"`
End *Date `json:"date_end"`
WorkingSchedule *WorkingSchedule `json:"working_hours"`
}

func (l ContractList) GetFTERatioForDay(day Date) (float64, error) {
date := day.ToTime()
for _, contract := range l {
start := contract.Start.ToTime().Add(-1 * time.Second)
if contract.End.IsZero() {
// current contract
if start.Before(date) {
return contract.WorkingSchedule.GetFTERatio()
}
continue
}
end := contract.End.ToTime().Add(1 * time.Second)
if start.Before(date) && end.After(date) {
return contract.WorkingSchedule.GetFTERatio()
}
}
return 0, fmt.Errorf("no contract found that covers date: %s", day.String())
}

func (c Client) FetchAllContracts(sid string, employeeID int) (ContractList, error) {
return c.readContracts(sid, []Filter{
[]interface{}{"employee_id", "=", employeeID},
})
}

func (c Client) readContracts(sid string, domainFilters []Filter) (ContractList, error) {
// Prepare "search contract" request
body, err := NewJsonRpcRequest(&ReadModelRequest{
Model: "hr.contract",
Domain: domainFilters,
Fields: []string{"date_start", "date_end", "working_hours"},
}).Encode()
if err != nil {
return nil, fmt.Errorf("encoding request: %w", err)
}

res, err := c.makeRequest(sid, body)
if err != nil {
return nil, err
}

type readResult struct {
Length int `json:"length,omitempty"`
Records []Contract `json:"records,omitempty"`
}
result := &readResult{}
if err := c.unmarshalResponse(res.Body, result); err != nil {
return nil, err
}
return result.Records, nil
}
75 changes: 75 additions & 0 deletions pkg/odoo/contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package odoo

import (
"strconv"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func newFTESchedule(ratioPercentage int) *WorkingSchedule {
return &WorkingSchedule{
ID: 0,
Name: strconv.Itoa(ratioPercentage) + "%",
}
}

func TestContractList_GetFTERatioForDay(t *testing.T) {
tests := map[string]struct {
givenList ContractList
givenDay Date
expectedRatio float64
expectErr bool
}{
"GivenEmptyList_WhenNil_ThenReturnErr": {
givenList: nil,
expectErr: true,
},
"GivenEmptyList_WhenNoContracts_ThenReturnErr": {
givenList: []Contract{},
expectErr: true,
},
"GivenListWith1Contract_WhenOpenEnd_ThenReturnRatio": {
givenDay: *newDate(t, "2021-12-04"),
givenList: []Contract{
{Start: newDate(t, "2021-02-01"), WorkingSchedule: newFTESchedule(100)},
},
expectedRatio: 1,
},
"GivenListWith1Contract_WhenDayBeforeStart_ThenReturnErr": {
givenDay: *newDate(t, "2021-02-01"),
givenList: []Contract{
{Start: newDate(t, "2021-02-02"), WorkingSchedule: newFTESchedule(100)},
},
expectErr: true,
},
"GivenListWith2Contract_WhenDayBetweenContract_ThenReturnRatioFromTerminatedContract": {
givenDay: *newDate(t, "2021-03-31"),
givenList: []Contract{
{Start: newDate(t, "2021-02-02"), End: newDate(t, "2021-03-31"), WorkingSchedule: newFTESchedule(90)},
{Start: newDate(t, "2021-04-01"), WorkingSchedule: newFTESchedule(80)},
},
expectedRatio: 0.9,
},
"GivenListWith2Contract_WhenDayInOpenContract_ThenReturnRatioFromOpenContract": {
givenDay: *newDate(t, "2021-04-01"),
givenList: []Contract{
{Start: newDate(t, "2021-02-02"), End: newDate(t, "2021-03-31"), WorkingSchedule: newFTESchedule(90)},
{Start: newDate(t, "2021-04-01"), WorkingSchedule: newFTESchedule(80)},
},
expectedRatio: 0.8,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
result, err := tt.givenList.GetFTERatioForDay(tt.givenDay)
if tt.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expectedRatio, result)
})
}
}
21 changes: 18 additions & 3 deletions pkg/odoo/date.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package odoo

import (
"bytes"
"encoding/json"
"fmt"
"time"
)
Expand All @@ -26,15 +27,29 @@ func (d Date) MarshalJSON() ([]byte, error) {

func (d *Date) UnmarshalJSON(b []byte) error {
ts := bytes.Trim(b, `"`)
t, err := time.Parse(DateTimeFormat, string(ts))
if err != nil {
return err
var f bool
if err := json.Unmarshal(b, &f); err == nil || string(b) == "false" {
return nil
}
// try parsing date + time
t, dateTimeErr := time.Parse(DateTimeFormat, string(ts))
if dateTimeErr != nil {
// second attempt parsing date only
t, dateTimeErr = time.Parse(DateFormat, string(ts))
if dateTimeErr != nil {
return dateTimeErr
}
}

*d = Date(t)
return nil
}

// IsZero returns true if Date is nil or Time.IsZero()
func (d *Date) IsZero() bool {
return d == nil || d.ToTime().IsZero()
}

func (d *Date) ToTime() time.Time {
return time.Time(*d)
}
Expand Down
42 changes: 41 additions & 1 deletion pkg/odoo/date_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,52 @@ import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func newDate(t *testing.T, value string) *Date {
func TestDate_UnmarshalJSON(t *testing.T) {
tests := map[string]struct {
givenInput string
expectedDate *Date
}{
"GivenFalse_ThenExpectZeroDate": {
givenInput: "false",
expectedDate: nil,
},
"GivenValidInput_WhenFormatIsDate_ThenExpectDate": {
givenInput: "2021-02-03",
expectedDate: newDate(t, "2021-02-03"),
},
"GivenValidInput_WhenFormatIsDateTime_ThenExpectDateTime": {
givenInput: "2021-02-03 15:34:00",
expectedDate: newDateTime(t, "2021-02-03 15:34"),
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
subject := &Date{}
err := subject.UnmarshalJSON([]byte(tt.givenInput))
require.NoError(t, err)
if tt.expectedDate == nil {
assert.True(t, subject.IsZero())
return
}
assert.Equal(t, tt.expectedDate, subject)
})
}
}

func newDateTime(t *testing.T, value string) *Date {
tm, err := time.Parse(DateTimeFormat, fmt.Sprintf("%s:00", value))
require.NoError(t, err)
ptr := Date(tm)
return &ptr
}

func newDate(t *testing.T, value string) *Date {
tm, err := time.Parse(DateFormat, value)
require.NoError(t, err)
ptr := Date(tm)
return &ptr
}
3 changes: 3 additions & 0 deletions pkg/odoo/json_rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ func DecodeResult(buf io.Reader, result interface{}) error {
if err := json.NewDecoder(buf).Decode(&res); err != nil {
return fmt.Errorf("decode intermediate: %w", err)
}
if res.Error != nil {
return fmt.Errorf("%s: %s", res.Error.Message, res.Error.Data["message"])
}

return json.Unmarshal(*res.Result, result)
}
10 changes: 5 additions & 5 deletions pkg/odoo/leave_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ func TestLeave_SplitByDay(t *testing.T) {
t.Run("GivenLeaveWithSingleDate_ThenExpectSameLeave", func(t *testing.T) {
givenLeave := Leave{
ID: 1,
DateFrom: newDate(t, "2021-02-03 07:00"),
DateTo: newDate(t, "2021-02-03 19:00"),
DateFrom: newDateTime(t, "2021-02-03 07:00"),
DateTo: newDateTime(t, "2021-02-03 19:00"),
Type: &LeaveType{ID: 1, Name: "SomeType"},
State: "validated",
}
Expand All @@ -27,11 +27,11 @@ func TestLeave_SplitByDay(t *testing.T) {
}{
"GivenLeave_WhenDurationGoesIntoNextDay_ThenExpectSplit": {
givenLeave: Leave{
DateFrom: newDate(t, "2021-02-03 07:00"), DateTo: newDate(t, "2021-02-04 19:00"),
DateFrom: newDateTime(t, "2021-02-03 07:00"), DateTo: newDateTime(t, "2021-02-04 19:00"),
},
expectedLeaves: []Leave{
{DateFrom: newDate(t, "2021-02-03 07:00"), DateTo: newDate(t, "2021-02-03 15:00")},
{DateFrom: newDate(t, "2021-02-04 07:00"), DateTo: newDate(t, "2021-02-04 15:00")},
{DateFrom: newDateTime(t, "2021-02-03 07:00"), DateTo: newDateTime(t, "2021-02-03 15:00")},
{DateFrom: newDateTime(t, "2021-02-04 07:00"), DateTo: newDateTime(t, "2021-02-04 15:00")},
},
},
}
Expand Down
65 changes: 65 additions & 0 deletions pkg/odoo/workingschedule.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package odoo

import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
)

var workingScheduleRegex = regexp.MustCompile("(?P<ratio>[0-9]+\\s*%)")

type WorkingSchedule struct {
ID float64
Name string
}

func (s *WorkingSchedule) String() string {
if s == nil {
return ""
}
return s.Name
}

func (s WorkingSchedule) MarshalJSON() ([]byte, error) {
if s.Name == "" {
return []byte("false"), nil
}
arr := []interface{}{s.ID, s.Name}
return json.Marshal(arr)
}
func (s *WorkingSchedule) UnmarshalJSON(b []byte) error {
var f bool
if err := json.Unmarshal(b, &f); err == nil || string(b) == "false" {
return nil
}
var arr []interface{}
if err := json.Unmarshal(b, &arr); err != nil {
return err
}
if len(arr) >= 2 {
if v, ok := arr[1].(string); ok {
*s = WorkingSchedule{
ID: arr[0].(float64),
Name: v,
}
}
}
return nil
}

// GetFTERatio tries to extract the FTE ratio from the name of the schedule.
// It returns an error if it could not find a match
func (s *WorkingSchedule) GetFTERatio() (float64, error) {
match := workingScheduleRegex.FindStringSubmatch(s.Name)
if len(match) > 0 {
v := match[0]
v = strings.TrimSpace(v)
v = strings.ReplaceAll(v, " ", "") // there might be spaces in between
v = strings.ReplaceAll(v, "%", "")
ratio, err := strconv.Atoi(v)
return float64(ratio) / 100, err
}
return 0, fmt.Errorf("could not find FTE ratio in name: '%s'", s.Name)
}
Loading

0 comments on commit a4e220a

Please sign in to comment.