mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 13:03:35 +00:00
Example test case for it.
```go
{
name: "Monday to Friday 10:00 to 18:00",
startTime: time.Date(2025, 03, 22, 18, 1, 43, 0, locIST), // Sat
// 24 hours.
slaMinutes: 1440,
businessHours: models.BusinessHours{
Hours: mustMarshalJSON(map[string]models.WorkingHours{
"Monday": {Open: "10:00", Close: "18:00"},
"Tuesday": {Open: "10:00", Close: "18:00"},
"Wednesday": {Open: "10:00", Close: "18:00"},
"Thursday": {Open: "10:00", Close: "18:00"},
"Friday": {Open: "10:00", Close: "18:00"},
"Saturday": {Open: "10:00", Close: "14:00"},
}),
},
timeZone: "Asia/Kolkata",
expectedResult: time.Date(2025, 03, 26, 18, 0, 0, 0, locIST),
},
```
fix: more validations for working hours.
chore: remove unused struct fields in working hours
147 lines
4.6 KiB
Go
147 lines
4.6 KiB
Go
package sla
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"time"
|
|
|
|
"github.com/abhinavxd/libredesk/internal/business_hours/models"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidSLADuration = fmt.Errorf("invalid SLA duration")
|
|
ErrMaxIterations = fmt.Errorf("sla: exceeded maximum iterations - check configuration")
|
|
ErrInvalidTime = fmt.Errorf("invalid time")
|
|
)
|
|
|
|
// CalculateDeadline computes the SLA deadline from a start time and SLA duration in minutes
|
|
// considering the provided holidays, working hours, and time zone.
|
|
func (m *Manager) CalculateDeadline(start time.Time, slaMinutes int, businessHours models.BusinessHours, timeZone string) (time.Time, error) {
|
|
if slaMinutes <= 0 {
|
|
return time.Time{}, ErrInvalidSLADuration
|
|
}
|
|
|
|
// If business is always open, return the deadline as the start time plus the SLA duration.
|
|
if businessHours.IsAlwaysOpen {
|
|
return start.Add(time.Duration(slaMinutes) * time.Minute), nil
|
|
}
|
|
|
|
// Load the specified time zone.
|
|
loc, err := time.LoadLocation(timeZone)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid time zone %s: %v", timeZone, err)
|
|
}
|
|
|
|
// Convert start time to the specified time zone.
|
|
currentTime := start.In(loc)
|
|
remainingMinutes := slaMinutes
|
|
maxIterations := ((slaMinutes+59)/60)*24 + 1
|
|
|
|
// Unmarshal working hours.
|
|
var workingHours map[string]models.WorkingHours
|
|
if err := json.Unmarshal(businessHours.Hours, &workingHours); err != nil {
|
|
return time.Time{}, fmt.Errorf("could not unmarshal working hours for SLA deadline calcuation: %v", err)
|
|
}
|
|
|
|
// Unmarshal holidays.
|
|
var holidays = []models.Holiday{}
|
|
if len(businessHours.Holidays) > 0 {
|
|
if err := json.Unmarshal(businessHours.Holidays, &holidays); err != nil {
|
|
return time.Time{}, fmt.Errorf("could not unmarshal holidays for SLA deadline calcuation: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create a map of holidays.
|
|
holidaysMap := make(map[string]struct{})
|
|
for _, holiday := range holidays {
|
|
holidaysMap[holiday.Date] = struct{}{}
|
|
}
|
|
|
|
iterations := 0
|
|
for remainingMinutes > 0 {
|
|
iterations++
|
|
if iterations > maxIterations {
|
|
return time.Time{}, ErrMaxIterations
|
|
}
|
|
|
|
// Move to next day if current day is a holiday.
|
|
dateStr := currentTime.Format(time.DateOnly)
|
|
if _, isHoliday := holidaysMap[dateStr]; isHoliday {
|
|
currentTime = nextDay(currentTime, loc)
|
|
continue
|
|
}
|
|
|
|
// Get working hours for the current day.
|
|
dayOfWeek := currentTime.Weekday().String()
|
|
workHours, exists := workingHours[dayOfWeek]
|
|
|
|
// Not a working day, move to next day.
|
|
if !exists {
|
|
currentTime = nextDay(currentTime, loc)
|
|
continue
|
|
}
|
|
|
|
// Parse open and close times for the current day in the specified time zone.
|
|
var startOfWork, endOfWork time.Time
|
|
var err error
|
|
startOfWork, err = parseTime(currentTime, workHours.Open, loc)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid open time %s for %s: %v", workHours.Open, dayOfWeek, err)
|
|
}
|
|
endOfWork, err = parseTime(currentTime, workHours.Close, loc)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid close time %s for %s: %v", workHours.Close, dayOfWeek, err)
|
|
}
|
|
|
|
// Adjust to start of work if current time is before it.
|
|
if currentTime.Before(startOfWork) {
|
|
currentTime = startOfWork
|
|
}
|
|
|
|
// Move to next day if current time is after end of work.
|
|
if currentTime.After(endOfWork) {
|
|
currentTime = nextDay(startOfWork, loc)
|
|
continue
|
|
}
|
|
|
|
// Deduct minutes worked today from remaining SLA time.
|
|
workMinutesLeft := int(endOfWork.Sub(currentTime).Minutes())
|
|
if workMinutesLeft >= remainingMinutes {
|
|
return currentTime.Add(time.Duration(remainingMinutes) * time.Minute), nil
|
|
}
|
|
|
|
remainingMinutes -= workMinutesLeft
|
|
if remainingMinutes == 0 {
|
|
return endOfWork, nil
|
|
}
|
|
|
|
currentTime = nextDay(startOfWork, loc)
|
|
}
|
|
|
|
return currentTime, nil
|
|
}
|
|
|
|
// nextDay advances the time to the start of the next day in the specified time zone.
|
|
func nextDay(t time.Time, loc *time.Location) time.Time {
|
|
return time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, loc)
|
|
}
|
|
|
|
// parseTime parses a time string in "HH:MM" format and returns a time.Time object for the given date and location.
|
|
func parseTime(date time.Time, timeStr string, loc *time.Location) (time.Time, error) {
|
|
// Validate timeStr is in "HH:MM" format.
|
|
matched, err := regexp.MatchString(`^(?:[01]\d|2[0-3]):[0-5]\d$`, timeStr)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("could not validate time string %s: %v", timeStr, err)
|
|
}
|
|
if !matched {
|
|
return time.Time{}, ErrInvalidTime
|
|
}
|
|
|
|
parsedTime, err := time.ParseInLocation("15:04", timeStr, loc)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return time.Date(date.Year(), date.Month(), date.Day(), parsedTime.Hour(), parsedTime.Minute(), 0, 0, loc), nil
|
|
}
|