Files
libredesk/internal/sla/calculator.go
Abhinav Raut 495ff02067 fix[sla]: incorrect calculated deadline when SLA minutes ends exact at a working day's closing time.
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
2025-03-26 23:56:13 +05:30

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
}