Calculate business hours in SQL - sql

How can I calculate business hours between two date and times? For example we have two dates; 2022-01-21 16:35:02.470 and 2022-01-24 09:15:02.293
The working hours are 08:00 to 19:00 on weekdays
How can I calculate working hours with SQL?

If we assume that the FROM and TO dates are on the same row:
With Tbl as (
select FROM_DATE=cast('2022-01-21 16:35:02.470' as datetime),
TO_DATE=cast('2022-01-24 09:15:02.293' as datetime)
),
--Build a calendar between two dates
Cal as (
select DATE_D=cast(FROM_DATE as date), TO_DATE_D=cast(TO_DATE as date)
from Tbl
union all
select DATE_D=dateadd(day,1,Cal.DATE_D), Cal.TO_DATE_D
from Cal
where Cal.DATE_D<=Cal.TO_DATE_D
),
-- Eliminate weekends and non-working hours in the calendar
WorkDays as (
select WDCAL_FROM_DATE=dateadd(hour, 8, cast(DATE_D as datetime)),
WDCAL_TO_DATE=dateadd(hour, 19, cast(DATE_D as datetime))
from Cal
where datediff(day, '1900-01-06', DATE_D) % 7 not in (0,1) -- Saturdays, and Sundays
)
-- Find overlapping intervals, and calculate seconds in between, aggregate and convert to hours.
select
Hrs=sum(
datediff(second,
case when T.FROM_DATE>=WD.WDCAL_FROM_DATE then T.FROM_DATE else WD.WDCAL_FROM_DATE end, -- Final interval FromDT
case when T.TO_DATE<=WD.WDCAL_TO_DATE then T.TO_DATE else WD.WDCAL_TO_DATE end -- Final interval ToDT
)
) / 3600.
from Tbl T
inner join
WorkDays WD
on T.FROM_DATE<=WD.WDCAL_TO_DATE
and WD.WDCAL_FROM_DATE<=T.TO_DATE
gives number of hours, as a decimal fraction, within working hours.
If you need to take public holidays into account, I suggest that you first build a calendar table (instead of dynamically generating it).

Related

How to convert weekly data into monthly data in sql?

I have a table that has weekly effort hrs and I need to report data on a monthly basis. for most of the cases, the week start date and end date lie in the same month and those records are easy to aggregate but when the week start date and end date don't lie in the same month then I need to break that week's records into 2 rows. One with an actual start date and end of the month as the end date and another record as 1st of next month as the start date and actual end date as the end date.
For the hrs calculation, I need to calculate the number of days that fall in the first month then divide the total hrs by 5 and then multiply by the number of days. and for the second record, it will be total hrs by 5 and then multiply by (5-number of days)
SELECT [USERNAME]
,[EMPLOYEE_NAME]
,[EFFORT_HRS]
,[TS_START_DATE]
,[TS_END_DATE]
FROM [dbo].[Source]
OUTPUT:
USERNAME
EMPLOYEE_NAME
Task
EFFORT_HRS
TS_START_DATE
TS_END_DATE
mk
xyz
abcdefg
40
12/27/2021
1/2/2022
mk
xyz
defgh
33.5
1/31/2022
2/6/2022
mk
xyz
abcdefg
6
4/25/2022
5/1/2022
Expected Result:
USERNAME
EMPLOYEE_NAME
Task
EFFORT_HRS
TS_START_DATE
TS_END_DATE
mk
xyz
abcdefg
40
12/27/2021
12/31/2021
mk
xyz
abcdefg
0
1/1/2022
1/2/2022
mk
xyz
defgh
6.7
1/31/2022
1/31/2022
mk
xyz
defgh
26.8
2/1/2022
2/6/2022
mk
xyz
abcdefg
6
4/25/2022
4/30/2022
mk
xyz
abcdefg
0
5/1/2022
5/1/2022
What you are NOT providing is some sort of calendar associated with holidays such as Jan 1, 2021 (Friday), and Jan 2, 2021 (Saturday) which do not appear to be paid out. That being said, I will leave you to either provide additional information to exclude such dates, or figure out how to adjust the query.
I would also suggest a better storage solution for time be done on a daily basis for easier querying and inclusion / exclusion such as holidays. But, from what is given I have below sample to create and populate table per your sample data.
create table Source
( UserName nvarchar(5),
Employee_Name nvarchar(5),
Task nvarchar(10),
Effort_Hrs numeric( 5, 2),
TS_Start_Date datetime,
TS_End_Date datetime )
insert into Source
( UserName,
Employee_Name,
Task,
Effort_Hrs,
TS_Start_Date,
TS_End_Date)
values
( 'mk', 'xyz', 'abcdefg', 40, '2021-12-27', '2022-01-02' ),
( 'mk',' xyz', 'defgh', 33.5, '2022-01-31', '2022-02-06' ),
( 'mk',' xyz', 'abcdefg', 6, '2022-04-25', '2022-05-01' )
And now the query itself. No matter what you do based of the existing data, you will need a UNION. Basically, selecting the same columns of data in each query. First, get all records once, but chop-off the end date to the last of the month if the start and end dates are different months.
The UNION part will only consider those records where the start and end dates are DIFFERENT months. So, if you had a work week of Jan 5 - Jan 11, you would only see it in the single record with no split, but in your other examples that cross months, you get TWO records. One for the first month, one for the second.
I have rewritten the query based on your feedback of week days and ignoring that of weekends. As such, when computing the pay earned, I had to apply a case/when if PayDays = 0, to just return 0 hours, otherwise you would get a divide by zero error
I am adding extra columns (which you can remove), so you can see how / where the pieces come into play. Now, for ALL records, the start date IS the real basis of the start date in final output. In the first query, its straight-forward, it IS the start date. However, in the UNION portion query, the start date is the first of the month, but again, only in the second query the months are different. So for that, I am doing date add (net subtract) 1 less than the day of the month of the ending date.
I also changed to use EOMONTH() call to compute the end of month for a given date such as in the first part of union where the end date crosses into following month vs the dateadd() originally used.
For the pay days in the UNION part of the query, which only represents entries that cross into the following month, the PayDays IS the ending date Days. So Feb 6th would be 6 days.
NOT month( ts_start_Date ) = month( ts_end_Date )
Here is a function to compute your work days within a given time period begin/end dates that forces cycle through each individual day to determine Mon-Fri.
CREATE Function dbo.WorkDaysInWeek
( #pStartDate as datetime,
#pEndDate as datetime)
RETURNS int
AS
BEGIN
-- if bad start/end date because they passed in end date first, swap them
if( #pStartDate > #pEndDate )
begin
declare #holdDate as DateTime
set #holdDate = #pStartDate
set #pStartDate = #pEndDate
set #pEndDate = #holdDate
end
-- convert to just date to prevent false calculations on time consideration
set #pStartDate = convert( date, #pStartDate)
set #pEndDate = convert( date, #pEndDate )
declare #workDays as int
set #workDays = 0
WHILE ( #pStartDate <= #pEndDate)
BEGIN
-- is the current day being tested a week day vs weekend. Only count Mon-Fri
if( datepart( weekday, #pStartDate) in ( 2, 3, 4, 5, 6 ))
set #workDays = #workDays + 1
set #pStartDate = dateadd( day, 1, #pStartDate )
END
RETURN #workDays
end
GO
Now, instead of computing the datediff in days, you call the function with begin and end dates and the function will cycle through each day individually to determine Mon-Fri only to be counted
Here is the final query.
select
AllWork.*,
case when AllWork.PayDays = 0
then 0.0
else ( AllWork.EFFORT_HRS / ( 1.0 * dbo.WorkDaysInWeek( AllWork.TS_Start_Date, AllWork.TS_End_Date))) * AllWork.PayDays end HoursInPayPeriod
from
(SELECT
USERNAME,
EMPLOYEE_NAME,
EFFORT_HRS,
TS_START_DATE,
TS_END_DATE,
TS_START_DATE as RealStart,
case when month( ts_start_Date ) = month( ts_end_Date )
then ts_end_date
-- simplified date add to use build-in function EOMONTH (End of Month)
else EOMONTH( ts_start_Date, 0) end RealEnd,
dbo.WorkDaysInWeek( TS_START_DATE,
case when month( ts_start_Date ) = month( ts_end_Date )
then ts_end_date
-- simplified date add to use build-in function EOMONTH (End of Month)
else EOMONTH( ts_start_Date, 0) end ) PayDays
FROM
Source
UNION
-- union to get all entries where the end date is a new month from the start
SELECT
USERNAME,
EMPLOYEE_NAME,
EFFORT_HRS,
TS_START_DATE,
TS_END_DATE,
dateadd( day, 1 - datepart( day, ts_end_Date ), ts_end_Date ) RealStart,
ts_end_date RealEnd,
dbo.WorkDaysInWeek( dateadd( day, 1 - datepart( day, ts_end_Date ), ts_end_Date ),
ts_end_date ) PayDays
FROM
Source
where
-- only care about those entries where the start and end date are different months
NOT month( ts_start_Date ) = month( ts_end_Date )
) AllWork

How to get all the dates in a full calendar month

In a calendar control, we can see some dates from the previous month and next month also. Sample image below
(ie Apr-2016: Starts from Mar-28 and ends in May-08
Mar-2016: Starts from Apr Feb-29 and ends in Apr-10)
Here, i need to generate a list of all the dates in a calendar control for a particular year month. My week start is Monday.
Here is the tsql script i have tried so far.
DECLARE #V_DATE DATE = GETDATE()
;WITH CTE_DATE AS (
SELECT DATEADD(dd,-(DAY(#V_DATE)-1),#V_DATE) CDATE
UNION ALL
SELECT DATEADD(dd,1,CDATE)
FROM CTE_DATE
WHERE DATEADD(dd,1,CDATE) <= DATEADD(dd,-(DAY(DATEADD(mm,1,CDATE))),DATEADD(mm,1,CDATE))
)
SELECT * FROM CTE_DATE
Result Is:
2016-04-01
2016-04-02
.
.
2016-04-29
2016-04-30
It will list all the days from a inputted year month, but i need to include the
missing dates from the previous month as well as next month.
Expected result for Apr-2016
2016-03-28
2016-03-29
.
2016-04-15
.
2016-05-07
2016-05-08
Expected result for May-2016
2016-04-25
2016-04-26
.
2016-05-15
.
2016-06-04
2016-06-05
Note:- The calendar control is always showing 42 days.
since your week is starts on Monday,you can take referece to date 0 '1900-01-01' which is a Monday. Adding 41 days would gives you your end date
select
date_fr = dateadd(day, datediff(day, 0, '2016-05-01') / 7 * 7, 0),
date_to = dateadd(day, datediff(day, 0, '2016-05-01') / 7 * 7, 41)
the following gives you date 1900-01-01 and Monday
select convert(datetime, 0), datename(weekday, 0)
Have you considered creating a dates table in your database. You would have columns for dates and a column for week number. Linking to this table you could find the week number for your start and end dates, you could then re-link to the table to find the first date of your start week and the last date of your end week. This would probably be more efficient than calculations at each step each time, it is a simple link.
I have create done script for this. This is working as per my expectation, may be helpful for future reference. (Thanks #Squirrel for the logic).
DECLARE #V_ST_DATE DATE = GETDATE()
SET #V_ST_DATE = DATEADD(DAY,-(DAY(#V_ST_DATE)-1),#V_ST_DATE)
SET #V_ST_DATE = DATEADD(WEEK,DATEDIFF(WEEK, 0, #V_ST_DATE) ,0) +
(CASE WHEN DATEADD(WEEK,DATEDIFF(WEEK, 0, #V_ST_DATE) ,0) > #V_ST_DATE THEN -7 ELSE 0 END)
;WITH CTE_DATE AS (
SELECT #V_ST_DATE CDATE,0 TDAYS
UNION ALL
SELECT DATEADD(DAY,1,CDATE) , DATEDIFF(DAY,#V_ST_DATE, DATEADD(DAY,1,CDATE))
FROM CTE_DATE
WHERE DATEDIFF(DAY,#V_ST_DATE, DATEADD(DAY,1,CDATE)) < 42
)
SELECT * FROM CTE_DATE

How to skip overlap intervals in the following query to get the accurate per day track time

I have a query (see SQL Fiddle) which calculates the total track time per day. It worked fine until I found that my data is not clean and it has some intervals overlapping (i.e. starttime is repeated in some cases).
There are 1440 minutes in a day and therefore the maximum track time should be 1440, but due to the overlapping intervals the track time exceeds 1440 minutes per day in some cases.
At the moment the query makes it 1440 if the sum exceeds 1440. But if a value is less than 1440 it still can be wrong.
For example
One interval is from 10:00 to 14:00.
Second interval is from 13:00 to 15:00.
End result is 4 + 2 = 6 hours, where hour between 13:00 and 14:00 is counted twice.
End result is 360 minutes, which is less than 1440, but it is not a
correct answer, because data is not correct.
I want some help to fix the query so that it skips overlaps and calculates the correct track time. Thanks
;WITH
CTE_Dates
AS
(
SELECT
Email
,CAST(MIN(StartTime) AS date) AS StartDate
,CAST(MAX(EndTime) AS date) AS EndDate
FROM track
GROUP BY Email
)
SELECT
CTE_Dates.Email
,DayStart AS xDate
-- if some intervals overlap, it is possible
-- to get SUM more than 1440 per day
-- truncate such values for now
,CASE
WHEN ISNULL(SUM(DATEDIFF(second, RangeStart, RangeEnd)) / 60, 0) > 1440
THEN 1440
ELSE ISNULL(SUM(DATEDIFF(second, RangeStart, RangeEnd)) / 60, 0)
END AS TrackMinutes
FROM
Numbers
CROSS JOIN CTE_Dates
CROSS APPLY
(
SELECT
DATEADD(day, Numbers.Number-1, CTE_Dates.StartDate) AS DayStart
,DATEADD(day, Numbers.Number, CTE_Dates.StartDate) AS DayEnd
) AS A_Date
OUTER APPLY
(
SELECT
-- MAX(DayStart, StartTime)
CASE WHEN DayStart > StartTime THEN DayStart ELSE StartTime END AS RangeStart
-- MIN(DayEnd, EndTime)
,CASE WHEN DayEnd < EndTime THEN DayEnd ELSE EndTime END AS RangeEnd
FROM track AS T
WHERE
T.Email = CTE_Dates.Email
AND T.StartTime < DayEnd
AND T.EndTime > DayStart
) AS A_Track
WHERE
Numbers.Number <= DATEDIFF(day, CTE_Dates.StartDate, CTE_Dates.EndDate)+1
GROUP BY DayStart, CTE_Dates.Email
ORDER BY DayStart;
This is a "gaps and islands" problem. I faked my own test data (since you didn't provide any), but I think it works. The key intuition is that all values within the same "island" (that is, contiguous time interval) will have the same difference from a row_number() column. If you want a little insight into it, do a raw select from the IntervalsByDay cte (as opposed to the subquery I have now); this will show you the islands calculated (with start and end points).
edit: I didn't see that you had a fiddle on the first go around. My answer has been changed to reflect your data and desired output
with i as (
select datediff(minute, '2013-01-01', StartTime) as s,
datediff(minute, '2013-01-01', EndTime) as e
from #track
), brokenDown as (
select distinct n.Number
from i
join dbadmin.dbo.Numbers as n
on n.Number >= i.s
and n.Number <= i.e
), brokenDownWithID as (
select Number, Number - row_number() over(order by Number) as IslandID,
cast(dateadd(minute, number, '2013-01-01') as date) as d
from brokenDown
), IntervalsByDay as (
select
dateadd(minute, min(number), '2013-01-01') as [IntervalStart],
dateadd(minute, max(number), '2013-01-01') as [IntervalEnd],
d,
max(Number) - min(Number) + 1 as [NumMinutes]
from brokenDownWithID
group by IslandID, d
)
select d, sum(NumMinutes) as NumMinutes
from IntervalsByDay
group by d
order by d

Number of days in quarter, SQL Server

I want to calculate the number of days per-quarter if start date and finish dates are given.
for example, one table has two columns, start date and finish date.
start date = 1st september and finish is 14th november.
I want to calculate the number of days present in between these two days that are present in each quarter
Q3 - 30 days
Q4 - 45 days (for this scenario)
Regards.
declare #StartDate date='2012-09-01';
declare #EndDate date='2012-11-14';
select CEILING(month(dateadd(q,datediff(q,0,dateadd(dd,number ,#StartDate)),0))/3.0) as QuarterNo,
COUNT(*) as 'number of days'
from master..spt_values
where type='p'
and dateadd(dd,number ,#StartDate)<=#EndDate
group by dateadd(q,datediff(q,0,dateadd(dd,number ,#StartDate)),0)
SQL fiddle demo
You can use a recursive query to get this. This generates the list of dates between your start and end date and then gets the count of days per quarter:
;with cte (start, enddate) as
(
select startdate, enddate
from yourtable
union all
select dateadd(dd, 1, start), enddate
from cte
where dateadd(dd, 1, start) <= enddate
)
select datepart(q, start) Quarter, count(datepart(q, start)) NoDays
from cte
group by datepart(q, start)
See SQL Fiddle with Demo

SQL Server 2008 - date functions and formulas

I want to return results where if the date falls on 10 & 28 of each month, but if either is a weekend return the result for Friday (first working day before).
Eg. if the following lines to be returned are
10 Oct 2010 Sunday
28 Oct 2010 Thursday
In the table I have
LineId Date
1 08 Oct 2010
2 28 Oct 2010
so, because 10 October is a Sunday, therefore won't be in the table, it will return LineID 1 as the is first working day before.
Thank you.
DATEPART(WEEKDAY and DATEPART(DW are dependant on the DATEFIRST setting. To avoid incorrect results the ##DATEFIRST function can be used.
WITH T(D) AS
(
SELECT CAST('20111008' AS DATE) UNION ALL
SELECT CAST('20111009' AS DATE) UNION ALL
SELECT CAST('20111010' AS DATE) UNION ALL
SELECT CAST('20111011' AS DATE) UNION ALL
SELECT CAST('20111012' AS DATE) UNION ALL
SELECT CAST('20111013' AS DATE) UNION ALL
SELECT CAST('20111014' AS DATE)
)
SELECT CASE
WHEN ( ##DATEFIRST + DATEPART(WEEKDAY, D) ) % 7 > 1 THEN D
ELSE DATEADD(DAY, -( 1 + ( ##DATEFIRST + DATEPART(WEEKDAY, D) ) % 7 ), D)
END AS WeekDayOrPrecedingFriday
FROM T
Select Case
When DatePart(dw,SampleData.[Date]) = 1 Then DateAdd(d,-2,SampleData.[Date])
When DatePart(dw,SampleData.[Date]) = 7 Then DateAdd(d,-1,SampleData.[Date])
Else SampleData.[Date]
End
From (
Select Cast('2010-10-10' As datetime) As [Date]
Union All Select '2010-10-28' As [Date]
) As SampleData
You may find that it is easier to have a Calendar table with one row for all days you need where you indicate whether the given day is a "working" day. In this way, you can easily account for holidays and the actual day off for holidays (e.g. if July 4th, in the US, is on a Saturday, mark the preceding Friday as a day off.).
If you are really worried about dealing with DateFirst, just set it prior to running your query:
Set DateFirst 7;
The above is the US default setting which is that Sunday is the first day of the week.
I really like to use a calendar table for queries like these.
-- For convenience, I'll use a view. The view "weekdays" is a proper
-- subset of the table "calendar".
create view weekdays as
select * from calendar
where day_of_week in ('Mon', 'Tue', 'Wed', 'Thu', 'Fri');
Having done that, the query is not only dead simple, it can easily be seen to be right.
select max(cal_date)
from weekdays
where cal_date <= '2010-10-10' -- Returns 2011-10-08
Doesn't account for holidays that might fall on the 10th or 28th, but that's easy enough to remedy.