简体   繁体   中英

C# - calculate sla in days, hours and minutes excluding non working hours, weekends and public holidays

I was searching stackoverflow to find the exact match question and response on similar problem to solve in C#.

Though I found couple of similarities on the questions available, I would not find any particular question and response on how to calculate the sla in days, hours and minutes in c# excluding the public holidays, weekends and non working hours.

For example, I've the ticket raised datetime as 21/02/2019 10:00:00 pm and if I would like to add only n (say 21 in this example) number of working hours excluding non working hours, weekends and public holidays to find the sla datetime of that ticket in C#.

Though I've some logics implemented on calculating only working hours, weekends, but finding hard to exclude the public holidays. Also appreciate the better, simple and understandable way of doing (using linq probably) than long lines of functions. Appreciate any sample code from the community.

I've got a working solution refined from other stackoverflow link as below, but this needs more refinement towards simplifying and resolving any possibilities of bugs like this scenario didn't handle if we get 2 days of holiday continuously, then calculate sla from the 3rd day, etc.

The solution I've got so far is:

public virtual DateTime AddWithinWorkingHours(DateTime start, TimeSpan offset)
    {
        //Get publicholidaysList from holiday table to not to include in working hour calculation
        var holidaysList = _holidayManager.GetHolidays().Result;

        // Don't start counting hours until start time is during working hours
        if (start.TimeOfDay.TotalHours > StartHour + HoursPerDay)
            start = start.Date.AddDays(1).AddHours(StartHour);
        if (start.TimeOfDay.TotalHours < StartHour)
            start = start.Date.AddHours(StartHour);
        if (start.DayOfWeek == DayOfWeek.Saturday)
            start.AddDays(2);
        //if it is a Sunday or holiday date, skip that date in workinghour calc
        else if (start.DayOfWeek == DayOfWeek.Sunday || holidaysList.Exists(hd=>hd.Date == start.Date))
            start.AddDays(1);
        // Calculate how much working time already passed on the first day
        TimeSpan firstDayOffset = start.TimeOfDay.Subtract(TimeSpan.FromHours(StartHour));
        // Calculate number of whole days to add
        int wholeDays = (int)(offset.Add(firstDayOffset).TotalHours / HoursPerDay);
        // How many hours off the specified offset does this many whole days consume?
        TimeSpan wholeDaysHours = TimeSpan.FromHours(wholeDays * HoursPerDay);
        // Calculate the final time of day based on the number of whole days spanned and the specified offset
        TimeSpan remainder = offset - wholeDaysHours;
        // How far into the week is the starting date?
        int weekOffset = ((int)(start.DayOfWeek + 7) - (int)DayOfWeek.Monday) % 7;
        // How many weekends are spanned?
        int weekends = (int)((wholeDays + weekOffset) / 5);
        // Calculate the final result using all the above calculated values
        return start.AddDays(wholeDays + weekends * 2).Add(remainder);
    } 

I have actually spent the last hour implementing this solution which combines an aswner from another stackoverflow question ( Add hours to datetime but exclude weekends and should be between working hours ) that calculates to a date the working hours + a nugget that validates if a date its an holiday depending on which country specified. First install the nugget

PM> install-package Nager.Date

Then I created 3 methods to make your functionality but its simple and you can optimize it to take in CountryCode and how many hours are in a working day and when does it start , but I made it hard coded just for example purposes:

        private static DateTime AddWithinWorkingHours(DateTime start, TimeSpan offset)
        {
            const int hoursPerDay = 8;
            const int startHour = 9;

            // Don't start counting hours until start time is during working hours
            if (start.TimeOfDay.TotalHours > startHour + hoursPerDay)
                start = start.Date.AddDays(1).AddHours(startHour);
            if (start.TimeOfDay.TotalHours < startHour)
                start = start.Date.AddHours(startHour);

            start = CheckTillNoLongerHoliday(start);

            if (start.DayOfWeek == DayOfWeek.Saturday)
                start = start.AddDays(2);
            else if (start.DayOfWeek == DayOfWeek.Sunday)
                start = start.AddDays(1);

            //Saving this proccessed date to check later if there are more holidays
            var dateAfterArranges = start;

            // Calculate how much working time already passed on the first day
            TimeSpan firstDayOffset = start.TimeOfDay.Subtract(TimeSpan.FromHours(startHour));

            // Calculate number of whole days to add
            int wholeDays = (int)(offset.Add(firstDayOffset).TotalHours / hoursPerDay);

            // How many hours off the specified offset does this many whole days consume?
            TimeSpan wholeDaysHours = TimeSpan.FromHours(wholeDays * hoursPerDay);

            // Calculate the final time of day based on the number of whole days spanned and the specified offset
            TimeSpan remainder = offset - wholeDaysHours;

            // How far into the week is the starting date?
            int weekOffset = ((int)(start.DayOfWeek + 7) - (int)DayOfWeek.Monday) % 7;

            // How many weekends are spanned?
            int weekends = (int)((wholeDays + weekOffset) / 5);

            //Get the final date without the holidays
            start = start.AddDays(wholeDays + weekends * 2).Add(remainder);

            //Check again if in this timeSpan there were any more holidays
            return InPeriodCheckHolidaysOnWorkingDays(dateAfterArranges, start);
        }


        private static DateTime CheckTillNoLongerHoliday(DateTime date)
        {
            if (DateSystem.IsPublicHoliday(date, CountryCode.PT) && !DateSystem.IsWeekend(date, CountryCode.PT))
            {
                date = date.AddDays(1);
                date = CheckTillNoLongerHoliday(date);
            }

            return date;
        }


        private static DateTime InPeriodCheckHolidaysOnWorkingDays(DateTime start, DateTime end)
        {
            var publicHolidays = DateSystem.GetPublicHoliday(2019, CountryCode.PT);

            var holidaysSpent = publicHolidays.Where(x => x.Date.Date >= start.Date && x.Date.Date < end.Date);
            foreach (var holiday in holidaysSpent)
            {
                if (!DateSystem.IsWeekend(holiday.Date, CountryCode.PT))
                {
                    end = end.AddDays(1);
                    if (DateSystem.IsWeekend(end, CountryCode.PT))
                    {
                        end = end.AddDays(2);
                    }
                }
            }

            return end;
        }

What I have implemented are 3 methods: AddWithinWorkingHours it's the main method that as all the base functionalities and was made by the user on that link I mentioned (Go give him credit as well) basicly it takes in a DateTime of the start date ( in your example its the ticket raised time) and a TimeSpan which you can pass number of working hours. Then the next 2 methods are the ones taking in the account for the Country Holidays, as you can notice it in that example i used Portuguese holidays but you can use any other country code supported by the Nager.Date nuget package.

I hope it really helps you! This was a fun challenge to me but useful for future implementations :)

This is a really error prone task in my experience. If you were working in whole hours or days I would suggest just enumerating through each and keeping a total of qualifying ones.

However if you need minute precision, better to use a library.

The library referenced in one of the answers that Tiago links to seems to do exactly what you want:

https://www.codeproject.com/Articles/168662/Time-Period-Library-for-NET

public void CalendarDateAddSample()
{
  CalendarDateAdd calendarDateAdd = new CalendarDateAdd();
  // weekdays
  calendarDateAdd.AddWorkingWeekDays();
  // holidays
  calendarDateAdd.ExcludePeriods.Add( new Day( 2011, 4, 5, calendarDateAdd.Calendar ) );
  // working hours
  calendarDateAdd.WorkingHours.Add( new HourRange( new Time( 08, 30 ), new Time( 12 ) ) );
  calendarDateAdd.WorkingHours.Add( new HourRange( new Time( 13, 30 ), new Time( 18 ) ) );

  DateTime start = new DateTime( 2011, 4, 1, 9, 0, 0 );
  TimeSpan offset = new TimeSpan( 22, 0, 0 ); // 22 hours

  DateTime? end = calendarDateAdd.Add( start, offset );

  Console.WriteLine( "start: {0}", start );
  // > start: 01.04.2011 09:00:00
  Console.WriteLine( "offset: {0}", offset );
  // > offset: 22:00:00
  Console.WriteLine( "end: {0}", end );
  // > end: 06.04.2011 16:30:00
}

Here is my solution for calculating SLAs. Easier to read than some over-complicated ones I've seen. Code should always be easily maintainable by someone else.

It only counts time within business hours (workday start and end values are stored in db and hence configurable). It takes account of Saturday and Sunday and any public holidays (from an in-memory cached list).

    public DateTime? CalculateSLADueDate(DateTime slaStartDateUTC, double slaDays, TimeSpan workdayStartUTC, TimeSpan workdayEndUTC)
    {
        if ((slaDays < 0)
        || (workdayStartUTC > workdayEndUTC))
        {
            return null;
        }

        var dueDate = slaStartDateUTC;
        var tsWorkdayHours = (workdayEndUTC - workdayStartUTC);
        var tsSlaCount = TimeSpan.FromHours(slaDays * ((workdayEndUTC - workdayStartUTC)).TotalHours);

        //get list of public holidays from in-memory cache
        var blPublicHoliday = new PublicHoliday();
        IList<BusObj.PublicHoliday> publicHolidays = blPublicHoliday.SelectAll();

        do
        {
            if ((dueDate.DayOfWeek == DayOfWeek.Saturday)
            || (dueDate.DayOfWeek == DayOfWeek.Sunday)
            || publicHolidays.Any(x => x.HolidayDate == dueDate.Date)
            || ((dueDate.TimeOfDay >= workdayEndUTC) && (dueDate.TimeOfDay < workdayStartUTC)))
            {
                //jump to start of next day
                dueDate = dueDate.AddDays(1);
                dueDate = new DateTime(dueDate.Year, dueDate.Month, dueDate.Day, workdayStartUTC.Hours, workdayStartUTC.Minutes, workdayStartUTC.Seconds);
            }
            else if ((dueDate.TimeOfDay == workdayStartUTC) && (tsSlaCount >= tsWorkdayHours))
            {
                //add a whole working day
                dueDate = dueDate.AddDays(1);
                tsSlaCount = tsSlaCount.Subtract(tsWorkdayHours);
            }
            else if (dueDate.TimeOfDay == workdayStartUTC)
            {
                //end day - add remainder of time for final work day
                dueDate = dueDate.Add(tsSlaCount);
                tsSlaCount = tsSlaCount.Subtract(tsSlaCount);
            }
            else
            {
                if(workdayEndUTC > dueDate.TimeOfDay)
                {
                    //start day and still in business hours - add rest of today
                    tsSlaCount = tsSlaCount.Subtract(workdayEndUTC - dueDate.TimeOfDay);
                    dueDate = dueDate.Add(workdayEndUTC - dueDate.TimeOfDay);
                }

                if (tsSlaCount.Ticks > 0)
                {
                    //if theres more to process - jump to start of next day
                    dueDate = dueDate.AddDays(1);
                    dueDate = new DateTime(dueDate.Year, dueDate.Month, dueDate.Day, workdayStartUTC.Hours, workdayStartUTC.Minutes, workdayStartUTC.Seconds);
                }
            }
        }
        while (tsSlaCount.Ticks > 0);

        return dueDate;
    }

I always store dates in db as UTC, so be wary you need to convert TimeSpan parameters to UTC. Thanks to PeterJ for his TimeSpan extensions (I added the 'this' to them):

public static class DatetimeExtensionMethod
{
    public static TimeSpan LocalTimeSpanToUTC(this TimeSpan ts)
    {
        DateTime dt = DateTime.Now.Date.Add(ts);
        DateTime dtUtc = dt.ToUniversalTime();
        TimeSpan tsUtc = dtUtc.TimeOfDay;
        return tsUtc;
    }

    public static TimeSpan UTCTimeSpanToLocal(this TimeSpan tsUtc)
    {
        DateTime dtUtc = DateTime.UtcNow.Date.Add(tsUtc);
        DateTime dt = dtUtc.ToLocalTime();
        TimeSpan ts = dt.TimeOfDay;
        return ts;
    }
}

Luckily for you, I needed the same functionality and I have built it yesterday and shared it as a NuGet on NuGet.org

https://www.nuget.org/packages/WorkTimeCalculator

You can define a weekly working schedule with multiple shifts, holidays. The result will be in the form of time span, you can even extract seconds of SLA in work time.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM