September 6, 2007 at 10:08 pm
I have used a "holidays" table with great success. In it I insert a row for each non-work day (including Saturday
and Sunday, if applicable).
This approach is very flexible, as non-work days are table-driven (and not code-driven). This increases both
simplicity and flexibility quite a bit: "exceptions" really don't exist. It is reasonably fast, simple to understand, and
hard to make an error.
The main pieces in this solution are: 1) a table tblSysHolidays, 2) a UDF named udfHolidayDays
to return the number of holidays between a StartDate and EndDate (inclusive), and
3) a UDF named udfAddBusinessDays to add or subtract N business days to/from a date--like DATEADD,
but for business days.
I usually calculate elapsed working days as:
SELECT
DATEDIFF(d, @StartDate, @EndDate) –
dbo.udfHolidayDays(@StartDate, @EndDate)
Of course I could have a UDF do this calculation, but I find it handy having the actual number of
holiday days handy (for date arithmetic, etc.)
There are other benefits of using a holiday table too. For example:
a) It is easy to select rows that have a holiday included in a date range.
b) it is fairly simple to extend to support multiple definitions of work days
(for different regions or different classes of employees): simply add a Region column to tblSysHolidays, and
modify the UDF's to allow the Region to be passed in when calling the UDF's
c) It helps "future-proof" applications: what if the company changed from a 5-day
to a 6-day work week in the future, and you had to work with both historical and current data? This is trivial and <br>seamless with a holiday table.
//=======================================
// Definition of tblSysHolidays
//=======================================
CREATE TABLE dbo.tblSysHolidays(
SysHolidayID int IDENTITY(1,1) NOT NULL,
HolidayDate datetime NOT NULL,
Description varchar(40) NULL,
CONSTRAINT [pkSysHolidays] PRIMARY KEY CLUSTERED
(HolidayDate ASC) WITH
(PAD_INDEX = OFF,
STATISTICS_NORECOMPUTE = OFF,
IGNORE_DUP_KEY = OFF,
ALLOW_ROW_LOCKS = ON,
ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
//=======================================
// Definition of udfHolidayDays
//=======================================
CREATE FUNCTION [dbo].[udfHolidayDays](
@StartDate DATETIME,
@EndDate DATETIME
)
RETURNS INT
AS
BEGIN
DECLARE @Result INT
DECLARE @NegSpan BIT
IF @StartDate > @EndDate SET @NegSpan = 1
ELSE SET @NegSpan = 0
SELECT @Result = COUNT(SysHolidayID)
FROM dbo.tblSysHolidays AS a
WHERE
((@NegSpan = 0) AND ( a.HolidayDate >= @StartDate AND a.HolidayDate <= @EndDate)) OR
((@NegSpan = 1) AND ( a.HolidayDate <= @StartDate AND a.HolidayDate >= @EndDate))
IF @NegSpan = 1 SET @Result = @Result * -1
RETURN @Result
END
//=======================================
// Definition of udfAddBuisnessDays
//=======================================
CREATE FUNCTION dbo.addBusinessDays
(@OrigDate datetime,
@DaysToAdd int)
RETURNS datetime
AS
BEGIN
--Note: @DaysToAdd can be positive or negative
--Note: when @DaysToAdd = 0, the function returns @OrigDate
--if it is not a holiday. If @OrigDate is a holiday, the function returns
--the next business day.
DECLARE @Return datetime
DECLARE @OrigHolidayDays int
DECLARE @FinalHolidayDays int
SET @OrigHolidayDays = dbo.holidayDays(@OrigDate, @OrigDate + @DaysToAdd)
SET @FinalHolidayDays = dbo.holidayDays(@OrigDate, @OrigDate + @DaysToAdd + @OrigHolidayDays)
WHILE @OrigHolidayDays <> @FinalHolidayDays BEGIN
SET @OrigHolidayDays = @FinalHolidayDays
SET @FinalHolidayDays = dbo.holidayDays(@OrigDate, @OrigDate + @DaysToAdd + @FinalHolidayDays)
END
SET @Return = @OrigDate + @DaysToAdd + @FinalHolidayDays
RETURN @Return
END
David Rueter
September 7, 2007 at 1:54 am
This is an interesting column. I've always wondered why SQL Server dosn't have an optimised built-in function to handle working days.
Holidays are very important - in the UK I can't see the point of having a working day function without taking account of holidays. As for contractors - I've yet to meet a contractor who dosn't take holidays!
Also for project management / order processing etc it is often required to work backwards from a future date. I'm not sure if this has been covered. It's not quite as straightforward as simply doing a negative DATEDIFF. We wrote the following UDF to take account of this. I think we might be able to optimise it based on the UDF described in the article - because it now looks complicated in comparison!
CREATE FUNCTION [dbo].[fn_EN_GetWorkingDate] (@Start SMALLDATETIME, @Length INT, @UseWorkingDays BIT) RETURNS SMALLDATETIME AS
BEGIN
DECLARE
@Holidays INT
DECLARE @Temp INT
DECLARE @EndDate SMALLDATETIME
DECLARE @Mod INT
DECLARE @Result SMALLDATETIME
IF
(@Length = 0)
SET @Result = @Start
ELSE
BEGIN
IF (@Length < 0)
SELECT @Mod = -1
ELSE
SELECT @Mod = 1
IF @UseWorkingDays = 1
BEGIN
--Calc number of days to Friday
--Calc makes sure we start the calculated week on Monday
SET @Temp = (5 - (((DATEPART(WEEKDAY, @Start) + @@DATEFIRST)%7)-1)) * @Mod
IF @Temp < 0
SET @Length = @Length + (2*@Mod)
IF @Length > @Temp
BEGIN
SET @EndDate = DATEADD(DAY, @Temp,@Start)
SET @Temp = @Length - @Temp
SET @EndDate = DATEADD(WEEK, @Temp/5, @EndDate)
IF (@Temp%5) > 0
BEGIN
SET @EndDate = DATEADD(DAY, @Temp%5 + 2, @EndDate)
END
SET @Length = DATEDIFF(DAY, @Start, @EndDate)
END
SELECT @Holidays = ISNULL(COUNT(*),0) FROM t_EN_BankHolidays WHERE
dtHoliday > @Start AND dtHoliday <= DATEADD(Day,@Length, @Start)
IF @Holidays > 0
BEGIN
SET @EndDate = dbo.fn_EN_GetWorkingDate (DATEADD(Day,@Length, @Start), Holidays, @UseWorkingDays)
SET @Length = DATEDIFF(DAY, @Start, @EndDate)
END
SELECT @Length = @Length + (
CASE DATENAME(weekday,DATEADD(Day,@Length,@Start))
WHEN 'Saturday' THEN 2
WHEN 'Sunday' THEN 1
ELSE 0
END) * @Mod
END
SET @Result = DATEADD(DAY, @Length, @Start)
END
RETURN @Result
END
September 7, 2007 at 2:43 am
I'll admit that I haven't read all the posts or even the article itself to any degree but I just thought, when I saw the heading, what I always think when I see similar articles....
Calculating the work days is fine (without holidays its a little limited but...) but 99% of the time I want a function like this its because I want to know when to start a search from. The number or workdays isn't eoungh for that.
Maybe I'm not explaining very well so....consider an app that needs to average/sum/etc. all the closing balances in a bank account in the past n days. Weekends and bank holidays must be excluded or they will mess up the totals. I basically want to know what date to start the search on....
select...from...where TableDate between ?? and @searchDate
Know what I mean?
Anyway, keep up all the good work and the sharing of your ideas and implementations.
September 7, 2007 at 4:10 am
Hmm
check:
Alexander Chigrik
GetWorkingDays UDF - maybe less comments
September 7, 2007 at 5:22 am
Hi,
It looks great at first, but for some reason I have a strange problem when using it.
I have a start date of Wed 1st May 2002 and an end date of Fri 31st May 2002 which is 31 days less 4 weekends so I was expecting a result of 23 week days. However, it returned 21 as the wk part of the function was returning 5 weekends.
If I try ths function between dates Fri 3rd May 2002 and Fri 31st May 2002 it now correctly counts 4 weekends and gives 21 week days as the result.
I thought the problem might lie with the value of datefirst in the database but when I run the statement SELECT @@DATEFIRST AS '1st Day', DATEPART(dw, GETDATE()) AS 'Today' it returns 1st day = 7 (i.e. Sunday, the US English default) and Today = 6 (i.e. Friday). So, that must not be the problem, although irrespective of the value of datefirst I think the function should calculate properly.
Further to this, DATEPART (wk, @STARTDATE) for Wed 1st May 2002 returns a value of 18 and DATEPART(wk, @ENDDATE) for Fri 31st May 2002 returns a value of 23, hence this is where it is getting 5 weekends from. Strangely, the value of wk changes to 19 when startdate reaches Fri 3rd May 2002 and the value of wk drops to 22 when enddate comes down to Thu 30th May 2002.
Hence, once can deduce from this that the wk cutover appears to be on a Friday.
Anyone got any ideas what's going on here and how to resolve it?
September 7, 2007 at 5:28 am
Eugene, that's a good collection of function you have - very useful. I suppose, in retrospect, its always the holidays that mess things up. Now, I'm not at all suggesting it cannot be done easily enough just that I have never figured a simple or quick implementation - usually there is some kind of date-walking-loop involved....kind of like....
..calculate start date
..calculate number of holidays between that and enddate
..remove
but
..have to check again if this new start date is weekend
....if it is then remove 1 or 2 days and check if this new new date is holiday
etc.
September 7, 2007 at 8:06 am
If holidays don't exist for you, chances are you work on weekends too, so you might as well use DATEDIFF. But if you are a contractor, you most likely bill by the hour and work different hours each day, so you have no use for either function anyway.
September 7, 2007 at 8:19 am
jh72i,
Here is the way I would select rows within a range of N business days:
// Date range (5 business days prior to @searchDate through @searchDate)
SET @searchInterval = -5
SELECT xxx FROM yyy
WHERE
yyy.TableDate <= @searchDate AND
yyy.TableDate >= dbo.udfAddBusinessDays(@searchDate, @searchInterval)
(@searchInterval can be either a positive or a negative number of business days.)
Here is the way I would calculate a per-business-day average for a date range:
SELECT SUM(yyy.Sales) /
(DATEDIFF(d, @startDate, @endDate) - dbo.udfHolidayDays(@startDate, @endDate))
FROM yyy
WHERE
yyy.TableDate >= @startDate AND
yyy.TableDate <= @endDate
Note that in these examples I am assuming that these dates do not contain a time portion.
David Rueter
September 7, 2007 at 11:44 am
Accountants I work with use a business day concept that does not follow the calendar. For example, our first business day for this month was 9/4 (Tuesday). August business included 9/3 (Labor Day) and the Saturday and Sunday preceeding it.
September 7, 2007 at 2:56 pm
Arrrgh... And to think I spent half a day writing a sketchy, cumbersome client-side loop to get this exact value for an insert statment... *sigh*
September 7, 2007 at 5:10 pm
Dizzy...
I see no similar problem... it operates as expected for the dates you posted having problems with...
-- Wed 1st May 2002 and an end date of Fri 31st May 2002 (Should be 23 days)
DECLARE @StartDate DATETIME
DECLARE @EndDate DATETIME
SET @StartDate = '20020501'
SET @EndDate = '20020531'
SELECT (DATEDIFF(dd, @StartDate, @EndDate) + 1)
-(DATEDIFF(wk, @StartDate, @EndDate) * 2)
-(CASE WHEN DATENAME(dw, @StartDate) = 'Sunday' THEN 1 ELSE 0 END)
-(CASE WHEN DATENAME(dw, @EndDate) = 'Saturday' THEN 1 ELSE 0 END)
And, it doesn't matter what DateFirst is set to. Please post the code that presented the problem for you so I can see what's going on...
--Jeff Moden
Change is inevitable... Change for the better is not.
September 7, 2007 at 5:11 pm
Actually, that's quite a nice compliment... thanks
--Jeff Moden
Change is inevitable... Change for the better is not.
September 7, 2007 at 5:14 pm
Wasn't meant to resolve business days per month nor figure out what a business month consists of... it was meant to find weekdays (despite the title). If you don't need it, don't use it Use a calendar table, instead.
--Jeff Moden
Change is inevitable... Change for the better is not.
September 7, 2007 at 5:19 pm
Totally wrong approach? Heh, extraordinary claims require extraordinary proof, Joe. If you're talking about the code I submitted and if you think a calendar table would be faster, simply submit the proof in the form of code A million rows would be acceptable for the performance test...
As for the holidays, yes, a holiday table would be necessary... but you don't need a whole calendar table to use the code I presented.
--Jeff Moden
Change is inevitable... Change for the better is not.
September 7, 2007 at 5:22 pm
Don't need a "date walking loop". Just use the formula I posted to calculate the week days and hit a holiday table for the number of days to subtract
--Jeff Moden
Change is inevitable... Change for the better is not.
Viewing 15 posts - 46 through 60 (of 156 total)
You must be logged in to reply to this topic. Login to reply