Billing Cycle Date Range - sql

Code:
DECLARE #CurrentDate DATE = '2018-01-02' ; -- Can be any date. This is just a date I used as an anchor point.
DECLARE #BillingDayOfMonth INT = 31 ; -- The day of the billing every month (can be between 1st and 31st of the month).
;WITH [TT1] ( [N] ) AS
(
SELECT 1
UNION ALL
SELECT 1
)
, [TT2] ( [N] ) AS
(
SELECT 1
UNION ALL
SELECT 1
UNION ALL
SELECT 1
UNION ALL
SELECT 1
UNION ALL
SELECT 1
UNION ALL
SELECT 1
)
, [TT] ( [N] ) AS
(
SELECT ROW_NUMBER() OVER ( ORDER BY ( SELECT NULL ) )
FROM [TT1] [T1]
CROSS JOIN [TT2] [T2]
)
SELECT [N]
FROM [TT] ; -- This gives me the count of 12 cycles (can vary).
Goal:
To return current and X number of previous billing cycle dates (12 cycles total in the above example).
If the BillingDayOfMonth doesn't exists in a month (i.e. 29th to 31st depending on the month), choose the last day of the month as the BillingDayOfMonth. So if BillingDayOfMonth = 31st, this day doesn't exist in Nov. So The Cycle will start #11/30 instead of 11/31. The above code is incomplete and need your help figuring out an efficient solution. Thank you
Desired Output:
CycleStartDate CycleEndDate
2017-12-31 2018-01-30 -- The most current cycle
2017-11-30 2017-12-30
2017-10-31 2017-11-29
2017-09-30 2017-10-30
2017-08-31 2017-09-29
2017-07-31 2017-08-30
2017-06-30 2017-07-30
2017-05-31 2017-06-29
2017-04-30 2017-05-30 -- Notice the EndDate
2017-03-31 2017-04-29 -- Notice the EndDate
2017-02-28 2017-03-30 -- Notice the StartDate
2017-01-31 2017-02-27 -- Notice the EndDate

So based on your numbers table approach and your rules, you can use a query like below.
I tested this for #BillingDayOfMonth values like 25, 28, 30 and found results reasonably true.
See live demo
DECLARE #CurrentDate DATE = '2018-01-02' ; -- Can be any date. This is just a date I used as an anchor point.
DECLARE #BillingDayOfMonth INT = 31 ; -- The day of the billing every month (can be between 1st and 31st of the month).
DECLARE #StartDate DATE
SET #StartDate= CASE WHEN
DAY(EOMONTH(#CurrentDate)) < #BillingDayOfMonth
THEN
EOMONTH(#CurrentDate)
ELSE
DATEFROMPARTS(YEAR(#CurrentDate),MONTH(#CurrentDate),#BillingDayOfMonth)
END
;WITH [TT1] ( [N] ) AS
(
SELECT 1
UNION ALL
SELECT 1
)
, [TT2] ( [N] ) AS
(
SELECT 1
UNION ALL
SELECT 1
UNION ALL
SELECT 1
UNION ALL
SELECT 1
UNION ALL
SELECT 1
UNION ALL
SELECT 1
)
, [TT] ( [N] ) AS
(
SELECT
ROW_NUMBER() OVER ( ORDER BY ( SELECT NULL ) )
FROM [TT1] [T1]
CROSS JOIN [TT2] [T2]
)
SELECT CycleStartDate =
DATEADD(M,-N,#StartDate)
,
CycleEndDate =
DATEADD(D,-1,DATEADD(M,1-N,#StartDate))
FROM [TT] ;

Related

Grouping date ranges into rolling group of 15 days

I have data with start and end datetime on each row, I'm trying to group all rows that fall within 15days of the end datetime, reset and begin new group using the end datetime when the start date doesn't fall in 15 days.
I tried different solutions like using logic to self join and try to pick min end date after looking for start date time between end datetime and end datetime + 30, but I'm not able to identify a way to continue using end datetime from the start/previous row when the start datetime is within 15days till it finds a row that doesn't.
with cte as
(
SELECT 1 id, '2018-12-25' col1, '2019-01-05' col2
UNION ALL
SELECT 1, '2019-03-01' col1, '2019-03-10' col2
UNION ALL
SELECT 1, '2019-03-15' col1, '2019-03-19' col2
UNION ALL
SELECT 1, '2019-03-22' col1, '2019-03-28' col2
UNION ALL
SELECT 1, '2019-03-30' col1, '2019-04-02' col2
UNION ALL
SELECT 1, '2019-04-10' col1, '2019-04-15' col2
UNION ALL
SELECT 1, '2019-04-18' col1, '2019-04-25' col2
), STG AS
(
SELECT A.*, MIN(B.COL2) AS GRP_COL2
FROm CTE A
LEFT OUTER JOIN CTE B ON A.col1 BETWEEN B.col2 AND DATEADD(day, 15, B.col2)
GROUP BY A.id, A.col1, A.col2
)
SELECT A.id, A.col1, A.col2, COALESCE(GRP_COL2, A.COL2) AS GRP_COL2
FROM STG A
ORDER By 1,2
--this returns wrong result for 04/10 and 04/18 dated rows in this case
For example I have following data:
ProductId ProductStartDt ProductEndDt
1 2018-12-25 2019-01-05
1 2019-03-01 2019-03-10
1 2019-03-15 2019-03-19
1 2019-03-22 2019-03-28
1 2019-03-30 2019-04-02
1 2019-04-10 2019-04-15
1 2019-04-18 2019-04-25
So what I'm trying to do is start from min end date and group all rows with start date that fall in end date + 15 days range as one group and start next row as new group and continue using the end date of that row as new start for group.
Expected Output
ProductId ProductStartDt ProductEndDt GroupNo
1 2018-12-25 2019-01-05 1
1 2019-03-01 2019-03-10 2
1 2019-03-15 2019-03-19 2
1 2019-03-22 2019-03-28 2
1 2019-03-30 2019-04-02 3
1 2019-04-10 2019-04-15 3
1 2019-04-18 2019-04-25 4
For group 2, the start date doesn't fall within 15days of the end date from Group 1 or start row and this will trigger start of new rows. We see that 03/15 and 03/22 are within 15 days of 03/10 and we group them into single group. 03/30 doesn't fall in the previous group and this will cause start of new group 3 till we find all start dates within the end datetime + 15days.
With recursive CTE we can iterate over initial table, and decide whether current row belongs an existing group or it's a new group:
;with Product as (
select * from (
VALUES
(1, '2018-12-25','2019-01-05' ),
(1, '2019-03-01','2019-03-10' ),
(1, '2019-03-15','2019-03-19' ),
(1, '2019-03-22','2019-03-28' ),
(1, '2019-03-30','2019-04-02' ),
(1, '2019-04-10','2019-04-15' ),
(1, '2019-04-18','2019-04-25' )
) as a1 (ProductId ,ProductStartDt ,ProductEndDt)
), OrderedProduct as (
select *, ROW_NUMBER() over (order by ProductStartDt) as RowNum
from Product
), DateGroupsInterim (RowNum, GroupNum, GrpStartDt) as (
select RowNum, 1, ProductEndDt
from OrderedProduct
where RowNum=1
union all
select OrderedProduct.RowNum,
CASE WHEN OrderedProduct.ProductStartDt <= dateadd(day, 15, dgi.GrpStartDt)
THEN dgi.GroupNum
ELSE dgi.GroupNum + 1
END,
CASE WHEN OrderedProduct.ProductStartDt <= dateadd(day, 15, dgi.GrpStartDt)
THEN dgi.GrpStartDt
ELSE OrderedProduct.ProductEndDt
END
from DateGroupsInterim dgi
join OrderedProduct on OrderedProduct.RowNum=dgi.RowNum+1
) select OrderedProduct.ProductId, OrderedProduct.ProductStartDt, OrderedProduct.ProductEndDt, DateGroupsInterim.GroupNum as GroupNo
from DateGroupsInterim
JOIN OrderedProduct on OrderedProduct.RowNum = DateGroupsInterim.RowNum;

Fill up date gap by month

I have table of products and their sales quantity in months.
Product Month Qty
A 2018-01-01 5
A 2018-02-01 3
A 2018-05-01 5
B 2018-08-01 10
B 2018-10-01 12
...
I'd like to first fill in the data gap between each product's min and max dates like below:
Product Month Qty
A 2018-01-01 5
A 2018-02-01 3
A 2018-03-01 0
A 2018-04-01 0
A 2018-05-01 5
B 2018-08-01 10
B 2018-09-01 0
B 2018-10-01 12
...
Then I would need to perform an accumulation of each product's sales quantity by month.
Product Month total_Qty
A 2018-01-01 5
A 2018-02-01 8
A 2018-03-01 8
A 2018-04-01 8
A 2018-05-01 13
B 2018-08-01 10
B 2018-09-01 10
B 2018-10-01 22
...
I fumbled over the "cross join" clause, however it seems to generate some unexpected results for me. Could someone help to give a hint how I can achieve this in SQL?
Thanks a lot in advance.
I think a recursive CTE is a simple way to do this. The code is just:
with cte as (
select product, min(mon) as mon, max(mon) as end_mon
from t
group by product
union all
select product, dateadd(month, 1, mon), end_mon
from cte
where mon < end_mon
)
select cte.product, cte.mon, coalesce(qty, 0) as qty
from cte left join
t
on t.product = cte.product and t.mon = cte.mon;
Here is a db<>fiddle.
Hi i think this example can help you and perform what you excepted :
CREATE TABLE #MyTable
(Product varchar(10),
ProductMonth DATETIME,
Qty int
);
GO
CREATE TABLE #MyTableTempDate
(
FullMonth DATETIME
);
GO
INSERT INTO #MyTable
SELECT 'A', '2019-01-01', 214
UNION
SELECT 'A', '2019-02-01', 4
UNION
SELECT 'A', '2019-03-01', 50
UNION
SELECT 'B', '2019-01-01', 214
UNION
SELECT 'B', '2019-02-01', 10
UNION
SELECT 'C', '2019-04-01', 150
INSERT INTO #MyTableTempDate
SELECT '2019-01-01'
UNION
SELECT '2019-02-01'
UNION
SELECT '2019-03-01'
UNION
SELECT '2019-04-01'
UNION
SELECT '2019-05-01'
UNION
SELECT '2019-06-01'
UNION
SELECT '2019-07-01';
------------- FOR NEWER SQL SERVER VERSION > 2005
WITH MyCTE AS
(
SELECT T.Product, T.ProductMonth AS 'MMonth', T.Qty
FROM #MyTable T
UNION
SELECT T.Product, TD.FullMonth AS 'MMonth', 0 AS 'Qty'
FROM #MyTable T, #MyTableTempDate TD
WHERE NOT EXISTS (SELECT 1 FROM #MyTable TT WHERE TT.Product = T.Product AND TD.FullMonth = TT.ProductMonth)
)
-- SELECT * FROM MyCTE;
SELECT Product, MMonth, Qty, SUM( Qty) OVER(PARTITION BY Product ORDER BY Product
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) as 'TotalQty'
FROM MyCTE
ORDER BY Product, MMonth ASC;
DROP TABLE #MyTable
DROP TABLE #MyTableTempDate
I have other way to perform this in lower SQL Server Version (like 2005 and lower)
It's a SELECT on SELECT if it's your case let me know and i provide some other example.
You can create the months with a recursive CTE
DECLARE #MyTable TABLE
(
ProductID CHAR(1),
Date DATE,
Amount INT
)
INSERT INTO #MyTable
VALUES
('A','2018-01-01', 5),
('A','2018-02-01', 3),
('A','2018-05-01', 5),
('B','2018-08-01', 10),
('B','2018-10-01', 12)
DECLARE #StartDate DATE
DECLARE #EndDate DATE
SELECT #StartDate = MIN(Date), #EndDate = MAX(Date) FROM #MyTable
;WITH dates AS (
SELECT #StartDate AS Date
UNION ALL
SELECT DATEADD(Month, 1, Date)
FROM dates
WHERE Date < #EndDate
)
SELECT A.ProductID, d.Date, COALESCE(Amount,0) AS Amount, COALESCE(SUM(Amount) OVER(PARTITION BY A.ProductID ORDER BY A.ProductID, d.Date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),0) AS Total
FROM
(
SELECT ProductID, MIN(date) as DateStart, MAX(date) as DateEnd
FROM #MyTable
GROUP BY ProductID -- As I read in your comments that you need different min and max dates per product
) A
JOIN dates d ON d.Date >= A.DateStart AND d.Date <= A.DateEnd
LEFT JOIN #MyTable T ON A.ProductID = T.ProductID AND T.Date = d.Date
ORDER BY A.ProductID, d.Date
Try this below
IF OBJECT_ID('tempdb..#Temp') IS NOT NULL
DROP TABLE #Temp
;WITH CTE(Product,[Month],Qty)
AS
(
SELECT 'A','2018-01-01', 5 UNION ALL
SELECT 'A','2018-02-01', 3 UNION ALL
SELECT 'A','2018-05-01', 5 UNION ALL
SELECT 'B','2018-08-01', 10 UNION ALL
SELECT 'D','2018-10-01', 12
)
SELECT ct.Product,[MonthDays],ct.Qty
INTO #Temp
FROM
(
SELECT c.Product,[Month],
ISNULL(Qty,0) AS Qty
FROM CTE c
)ct
RIGHT JOIN
(
SELECT -- This code is to get month data
CONVERT(VARCHAR(10),'2018-'+ RIGHT('00'+CAST(MONTH(DATEADD(MM, s.number, CONVERT(DATETIME, 0)))AS VARCHAR),2) +'-01',120) AS [MonthDays]
FROM master.dbo.spt_values s
WHERE [type] = 'P' AND s.number BETWEEN 0 AND 11
)DT
ON dt.[MonthDays] = ct.[Month]
SELECT
MAX(Product)OVER(ORDER BY [MonthDays])AS Product,
[MonthDays],
ISNULL(Qty,0) Qty,
SUM(ISNULL(Qty,0))OVER(ORDER BY [MonthDays]) As SumQty
FROM #Temp
Result
Product MonthDays Qty SumQty
------------------------------
A 2018-01-01 5 5
A 2018-02-01 3 8
A 2018-03-01 0 8
A 2018-04-01 0 8
A 2018-05-01 5 13
A 2018-06-01 0 13
A 2018-07-01 0 13
B 2018-08-01 10 23
B 2018-09-01 0 23
D 2018-10-01 12 35
D 2018-11-01 0 35
D 2018-12-01 0 35
First of all, i would divide month and year to get easier with statistics.
I will give you an example query, not based on your table but still helpful.
--here i create the table that will be used as calendar
Create Table MA_MonthYears (
Month int not null ,
year int not null
PRIMARY KEY ( month, year) )
--/////////////////
-- here i'm creating a procedure to fill the ma_monthyears table
declare #month as int
declare #year as int
set #month = 1
set #year = 2015
while ( #year != 2099 )
begin
insert into MA_MonthYears(Month, year)
select #month, #year
if #month < 12
set #month=#month+1
else
set #month=1
if #month = 1
set #year = #year + 1
end
--/////////////////
--here you are the possible result you are looking for
select SUM(Ma_saledocdetail.taxableamount) as Sold, MA_MonthYears.month , MA_MonthYears.year , item
from MA_MonthYears left outer join MA_SaleDocDetail on year(MA_SaleDocDetail.DocumentDate) = MA_MonthYears.year
and Month(ma_saledocdetail.documentdate) = MA_MonthYears.Month
group by MA_SaleDocDetail.Item, MA_MonthYears.year , MA_MonthYears.month
order by MA_MonthYears.year , MA_MonthYears.month

Get sum of entries over last 6 months (incomplete months)

My data looks something like this
ProductNumber | YearMonth | Number
1 201803 1
1 201804 3
1 201810 6
2 201807 -3
2 201809 5
Now what I want to have is add an additional entry "6MSum" which is the sum of the last 6 months per ProductNumber (not the last 6 entries).
Please be aware the YearMonth data is not complete, for every ProductNumber there are gaps in between so I cant just use the last 6 entries for the sum. The final result should look something like this.
ProductNumber | YearMonth | Number | 6MSum
1 201803 1 1
1 201804 3 4
1 201810 6 9
2 201807 -3 -3
2 201809 5 2
Additionally I don't want to insert the sum to the table but instead use it in a query like:
SELECT [ProductNumber],[YearMonth],[Number],
6MSum = CONVERT(INT,SUM...)
FROM ...
I found a lot off solutions that use a "sum over period" but only for the last X entries and not for the actual conditional statement of "YearMonth within last 6 months".
Any help would be much appreciated!
Its a SQL Database
EDIT/Answer
It seems to be the case that the gaps within the months have to be filled with data, afterwards something like
sum(Number) OVER (PARTITION BY category
ORDER BY year, week
ROWS 6 PRECEDING) AS 6MSum
Should work.
Reference to the solution : https://dba.stackexchange.com/questions/181773/sum-of-previous-n-number-of-columns-based-on-some-category
You could go the OUTER APPLY route. The following produces your required results exactly:
-- prep data
SELECT
ProductNumber , YearMonth , Number
into #t
FROM ( values
(1, 201803 , 1 ),
(1, 201804 , 3 ),
(1, 201810 , 6 ),
(2, 201807 , -3 ),
(2, 201809 , 5 )
) s (ProductNumber , YearMonth , Number)
-- output
SELECT
ProductNumber
,YearMonth
,Number
,[6MSum]
FROM #t t
outer apply (
SELECT
sum(number) as [6MSum]
FROM #t it
where
it.ProductNumber = t.ProductNumber
and it.yearmonth <= t.yearmonth
and t.yearmonth - it.yearmonth between 0 and 6
) tt
drop table #t
Use outer apply and convert yearmonth to a date, something like this:
with t as (
select t.*,
convert(date, convert(varchar(255), yearmonth) + '01')) as ymd
from yourtable t
)
select t.*, t2.sum_6m
from t outer apply
(select sum(t2.number) as sum_6m
from t t2
where t2.productnumber = t.productnumber and
t2.ymd <= t.ymd and
t2.ymd > dateadd(month, -6, ymd)
) t2;
Just to provide one more option. You can use DATEFROMPARTS to build valid dates from the YearMonth value and then search for values within date ranges.
Testable here: https://rextester.com/APJJ99843
SELECT
ProductNumber , YearMonth , Number
INTO #t
FROM ( values
(1, 201803 , 1 ),
(1, 201804 , 3 ),
(1, 201810 , 6 ),
(2, 201807 , -3 ),
(2, 201809 , 5 )
) s (ProductNumber , YearMonth , Number)
SELECT *
,[6MSum] = (SELECT SUM(number) FROM #t WHERE
ProductNumber = t.ProductNumber
AND DATEFROMPARTS(LEFT(YearMonth,4),RIGHT(YearMonth,2),1) --Build a valid start of month date
BETWEEN
DATEADD(MONTH,-6,DATEFROMPARTS(LEFT(t.YearMonth,4),RIGHT(t.YearMonth,2),1)) --Build a valid start of month date 6 months back
AND DATEFROMPARTS(LEFT(t.YearMonth,4),RIGHT(t.YearMonth,2),1)) --Build a valid end of month date
FROM #t t
DROP TABLE #t
So a working query (provided by a colleauge of mine) can look like this
SELECT [YearMonth]
,[Number]
,[ProductNumber]
, (Select Sum(Number) from [...] DPDS_1 where DPDS.ProductNumber =
DPDS_1.ProductNumber and DPDS_1.YearMonth <= DPDS.YearMonth and DPDS_1.YearMonth >=
convert (int, left (convert (varchar, dateadd(mm, -6, DPDS.YearMonth + '01'), 112),
6)))FROM [...] DPDS

Group time series by time intervals (e.g. days) with aggregate of duration

I have a table containing a time series with following information. Each record represents the event of "changing the mode".
Timestamp | Mode
------------------+------
2018-01-01 12:00 | 1
2018-01-01 18:00 | 2
2018-01-02 01:00 | 1
2018-01-02 02:00 | 2
2018-01-04 04:00 | 1
By using the LEAD function, I can create a query with the following result. Now each record contains the information, when and how long the "mode was active".
Please check the 2nd and the 4th record. They "belong" to multiple days.
StartDT | EndDT | Mode | Duration
------------------+------------------+------+----------
2018-01-01 12:00 | 2018-01-01 18:00 | 1 | 6:00
2018-01-01 18:00 | 2018-01-02 01:00 | 2 | 7:00
2018-01-02 01:00 | 2018-01-02 02:00 | 1 | 1:00
2018-01-02 02:00 | 2018-01-04 04:00 | 2 | 50:00
2018-01-04 04:00 | (NULL) | 1 | (NULL)
Now I would like to have a query that groups the data by day and mode and aggregates the duration.
This result table is needed:
Date | Mode | Total
------------+------+-------
2018-01-01 | 1 | 6:00
2018-01-01 | 2 | 6:00
2018-01-02 | 1 | 1:00
2018-01-02 | 2 | 23:00
2018-01-03 | 2 | 24:00
2018-01-04 | 2 | 04:00
I didn't known how to handle the records that "belongs" to multiple days. Any ideas?
create table ChangeMode ( ModeStart datetime2(7), Mode int )
insert into ChangeMode ( ModeStart, Mode ) values
( '2018-11-15T21:00:00.0000000', 1 ),
( '2018-11-16T17:18:19.1231234', 2 ),
( '2018-11-16T18:00:00.5555555', 1 ),
( '2018-11-16T18:00:01.1234567', 2 ),
( '2018-11-16T19:02:22.8888888', 1 ),
( '2018-11-16T20:00:00.9876543', 2 ),
( '2018-11-17T09:00:00.0000000', 1 ),
( '2018-11-17T23:23:23.0230450', 2 ),
( '2018-11-19T17:00:00.0172839', 1 ),
( '2018-11-20T03:07:00.7033077', 2 )
;
with
-- Determine the earliest and latest dates.
-- Cast to date to remove the time portion.
-- Cast results back to datetime because we're going to add hours later.
MinMaxDates
as
(select cast(min(cast(ModeStart as date))as datetime) as MinDate,
cast(max(cast(ModeStart as date))as datetime) as MaxDate from ChangeMode),
-- How many days have passed during that period
Dur
as
(select datediff(day,MinDate,MaxDate) as Duration from MinMaxDates),
-- Create a list of numbers.
-- These will be added to MinDate to get a list of dates.
NumList
as
( select 0 as Num
union all
select Num+1 from NumList,Dur where Num<Duration ),
-- Create a list of dates by adding those numbers to MinDate
DayList
as
( select dateadd(day,Num,MinDate)as ModeDate from NumList, MinMaxDates ),
-- Create a list of day periods
PeriodList
as
( select ModeDate as StartTime,
dateadd(day,1,ModeDate) as EndTime
from DayList ),
-- Use LEAD to get periods for each record
-- Final record would return NULL for ModeEnd
-- We replace that with end of last day
ModePeriodList
as
( select ModeStart,
coalesce( lead(ModeStart)over(order by ModeStart),
dateadd(day,1,MaxDate) ) as ModeEnd,
Mode from ChangeMode, MinMaxDates ),
ModeDayList
as
( select * from ModePeriodList, PeriodList
where ModeStart<=EndTime and ModeEnd>=StartTime
),
-- Keep the later of the mode start time, and the day start time
-- Keep the earlier of the mode end time, and the day end time
ModeDayPeriod
as
( select case when ModeStart>=StartTime then ModeStart else StartTime end as StartTime,
case when ModeEnd<=EndTime then ModeEnd else EndTime end as EndTime,
Mode from ModeDayList ),
SumDurations
as
( select cast(StartTime as date) as ModeDate,
Mode,
DateDiff_Big(nanosecond,StartTime,EndTime)
/3600000000000
as DurationHours from ModeDayPeriod )
-- List the results in order
-- Use MaxRecursion option in case there are more than 100 days
select ModeDate as [Date], Mode, sum(DurationHours) as [Total Duration Hours]
from SumDurations
group by ModeDate, Mode
order by ModeDate, Mode
option (maxrecursion 0)
Result is:
Date Mode Total Duration Hours
---------- ----------- ---------------------------------------
2018-11-15 1 3.00000000000000
2018-11-16 1 18.26605271947221
2018-11-16 2 5.73394728052777
2018-11-17 1 14.38972862361111
2018-11-17 2 9.61027137638888
2018-11-18 2 24.00000000000000
2018-11-19 1 6.99999519891666
2018-11-19 2 17.00000480108333
2018-11-20 1 3.11686202991666
2018-11-20 2 20.88313797008333
you could use a CTE to create a table of days then join the time slots to it
DECLARE #MAX as datetime2 = (SELECT MAX(CAST(Timestamp as date)) MX FROM process);
WITH StartEnd AS (select p1.Timestamp StartDT,
P2.Timestamp EndDT ,
p1.mode
from process p1
outer apply
(SELECT TOP 1 pOP.* FROM
process pOP
where pOP.Timestamp > p1.Timestamp
order by pOP.Timestamp asc) P2
),
CAL AS (SELECT (SELECT MIN(cast(StartDT as date)) MN FROM StartEnd) DT
UNION ALL
SELECT DATEADD(day,1,DT) DT FROM CAL WHERE CAL.DT < #MAX
),
TMS AS
(SELECT CASE WHEN S.StartDT > C.DT THEN S.StartDT ELSE C.DT END AS STP,
CASE WHEN S.EndDT < DATEADD(day,1,C.DT) THEN S.ENDDT ELSE DATEADD(day,1,C.DT) END AS STE
FROM StartEnd S JOIN CAL C ON NOT(S.EndDT <= C.DT OR S.StartDT>= DATEADD(day,1,C.dt))
)
SELECT *,datediff(MI ,TMS.STP, TMS.ste) as x from TMS
The following uses recursive CTE to build a list of dates (a calendar or number table works equally well). It then intersect the dates with date times so that missing dates are populated with matching data. The important bit is that for each row, if start datetime belongs to previous day then it is clamped to 00:00. Likewise for end datetime.
DECLARE #t TABLE (timestamp DATETIME, mode INT);
INSERT INTO #t VALUES
('2018-01-01 12:00', 1),
('2018-01-01 18:00', 2),
('2018-01-02 01:00', 1),
('2018-01-02 02:00', 2),
('2018-01-04 04:00', 1);
WITH cte1 AS (
-- the min and max dates in your data
SELECT
CAST(MIN(timestamp) AS DATE) AS mindate,
CAST(MAX(timestamp) AS DATE) AS maxdate
FROM #t
), cte2 AS (
-- build all dates between min and max dates using recursive cte
SELECT mindate AS day_start, DATEADD(DAY, 1, mindate) AS day_end, maxdate
FROM cte1
UNION ALL
SELECT DATEADD(DAY, 1, day_start), DATEADD(DAY, 2, day_start), maxdate
FROM cte2
WHERE day_start < maxdate
), cte3 AS (
-- pull end datetime from next row into current
SELECT
timestamp AS dt_start,
LEAD(timestamp) OVER (ORDER BY timestamp) AS dt_end,
mode
FROM #t
), cte4 AS (
-- join datetime with date using date overlap query
-- then clamp start datetime to 00:00 of the date
-- and clamp end datetime to 00:00 of next date
SELECT
IIF(dt_start < day_start, day_start, dt_start) AS dt_start_fix,
IIF(dt_end > day_end, day_end, dt_end) AS dt_end_fix,
mode
FROM cte2
INNER JOIN cte3 ON day_end > dt_start AND dt_end > day_start
)
SELECT dt_start_fix, dt_end_fix, mode, datediff(minute, dt_start_fix, dt_end_fix) / 60.0 AS total
FROM cte4
DB Fiddle
Thanks everybody!
The answer from Cato put me on the right track. Here my final solution:
DECLARE #Start AS datetime;
DECLARE #End AS datetime;
DECLARE #Interval AS int;
SET #Start = '2018-01-01';
SET #End = '2018-01-05';
SET #Interval = 24 * 60 * 60;
WITH
cteDurations AS
(SELECT [Timestamp] AS StartDT,
LEAD ([Timestamp]) OVER (ORDER BY [Timestamp]) AS EndDT,
Mode
FROM tblLog
WHERE [Timestamp] BETWEEN #Start AND #End
),
cteTimeslots AS
(SELECT #Start AS StartDT,
DATEADD(SECOND, #Interval, #Start) AS EndDT
UNION ALL
SELECT EndDT,
DATEADD(SECOND, #Interval, EndDT)
FROM cteTimeSlots WHERE StartDT < #End
),
cteDurationsPerTimesplot AS
(SELECT CASE WHEN S.StartDT > C.StartDT THEN S.StartDT ELSE C.StartDT END AS StartDT,
CASE WHEN S.EndDT < C.EndDT THEN S.EndDT ELSE C.EndDT END AS EndDT,
C.StartDT AS Slot,
S.Mode
FROM cteDurations S
JOIN cteTimeslots C ON NOT(S.EndDT <= C.StartDT OR S.StartDT >= C.EndDT)
)
SELECT Slot,
Mode,
SUM(DATEDIFF(SECOND, StartDT, EndDT)) AS Duration
FROM cteDurationsPerTimesplot
GROUP BY Slot, Mode
ORDER BY Slot, Mode;
With the variable #Interval you are able to define the size of the timeslots.
The CTE cteDurations creates a subresult with the durations of all necessary entries by using the TSQL function LEAD (available in MSSQL >= 2012). This will be a lot faster than an OUTER APPLY.
The CTE cteTimeslots generates a list of timeslots with start time and end time.
The CTE cteDurationsPerTimesplot is a subresult with a JOIN between cteDurations and cteTimeslots. This this the magic JOIN statement from Cato!
And finally the SELECT statement will do the grouping and sum calculation per Slot and Mode.
Once again: Thanks a lot to everybody! Especially to Cato! You saved my weekend!
Regards
Oliver

Group sales data by time interval for every 15 min for one day

I have a sales table and it contains sales figure by different store along with timing, let's say in one day and one of store we have done 10,000 transactions then I need to find the total sales for every 15 min for that particular business date, keeping in mind for example: if there's no sales between 12:00 PM to 12:15 PM then it should be zero as a value or null.
In a day we have 24 hours so it means 96 columns for the 15 min interval.
Sales Table:
SiteName Time Amount BusinessDate
----------------------------------------------------------
A 7:01:02 AM 20 2017-01-02
A 7:03:22 AM 25 2017-01-02
A 7:05:03 AM 33 2017-01-02
A 7:11:02 AM 55 2017-01-02
A 7:13:05 AM 46 2017-01-02
A 7:17:02 AM 21 2017-01-02
A 8:01:52 AM 18 2017-01-02
A 8:55:42 AM 7 2017-01-02
A 8:56:33 AM 7 2017-01-02
A 8:58:55 AM 31 2017-01-02
and so on
How can I accomplish this?!
Dynamic Example
Declare #SQL varchar(max) = Stuff((Select ',' + QuoteName(T)
From (Select Top 96 T=format(DateAdd(Minute,(Row_Number() Over (Order By (Select null))-1)*15,0),'HH:mm') From master..spt_values n1) A
Order by 1
For XML Path('')),1,1,'')
Select #SQL = '
Select *
From (
Select [SiteName]
,Col = format(DateAdd(MINUTE,(DatePart(HOUR,[Time])*60) + ((DatePart(MINUTE,[Time]) / 15)*15),0),''HH:mm'')
,Value = [Amount]
From Sales
) A
Pivot (sum(Value) For [Col] in (' + #SQL + ') ) p'
Exec(#SQL);
Returns 96 columns from 00:00 to 23:45
The Code Generated
Select *
From (
Select [SiteName]
,Col = format(DateAdd(MINUTE,(DatePart(HOUR,[Time])*60) + ((DatePart(MINUTE,[Time]) / 15)*15),0),'HH:mm')
,Value = [Amount]
From Sales
) A
Pivot (sum(Value) For [Col] in ([00:00],[00:15],[00:30],[00:45],[01:00],[01:15],[01:30],[01:45],[02:00],[02:15],[02:30],[02:45],[03:00],[03:15],[03:30],[03:45],[04:00],[04:15],[04:30],[04:45],[05:00],[05:15],[05:30],[05:45],[06:00],[06:15],[06:30],[06:45],[07:00],[07:15],[07:30],[07:45],[08:00],[08:15],[08:30],[08:45],[09:00],[09:15],[09:30],[09:45],[10:00],[10:15],[10:30],[10:45],[11:00],[11:15],[11:30],[11:45],[12:00],[12:15],[12:30],[12:45],[13:00],[13:15],[13:30],[13:45],[14:00],[14:15],[14:30],[14:45],[15:00],[15:15],[15:30],[15:45],[16:00],[16:15],[16:30],[16:45],[17:00],[17:15],[17:30],[17:45],[18:00],[18:15],[18:30],[18:45],[19:00],[19:15],[19:30],[19:45],[20:00],[20:15],[20:30],[20:45],[21:00],[21:15],[21:30],[21:45],[22:00],[22:15],[22:30],[22:45],[23:00],[23:15],[23:30],[23:45]) ) p
This is an option that does NOT use dynamic SQL, and instead of 96 columns wide per row, generates one row per time slot. First, I am starting with a sample table of your data.
create table #Sales
( SiteName nvarchar(1),
SaleTime time,
Amount decimal,
BusinessDate Date );
insert into #Sales ( SiteName, SaleTime, Amount, BusinessDate )
values
( 'A', '7:01:02', 20, '2017-01-02' ),
( 'A', '7:03:22', 25, '2017-01-02' ),
( 'A', '7:05:03', 33, '2017-01-02' ),
( 'A', '7:11:02', 55, '2017-01-02' ),
( 'A', '7:13:05', 46, '2017-01-02' ),
( 'A', '7:17:02', 21, '2017-01-02' ),
( 'A', '8:01:52', 18, '2017-01-02' ),
( 'A', '8:55:42', 7, '2017-01-02' ),
( 'A', '8:56:33', 7, '2017-01-02' ),
( 'A', '8:58:55', 31, '2017-01-02' );
And the query which I will explain shortly
select
allTimes.TimeStart,
allTimes.TimeEnd,
coalesce( count(S.Amount), 0 ) as NumEntries,
coalesce( sum( S.Amount), 0 ) as SumValues
from
( select
cast( DateAdd( minute, 15 * (timeSlots.Row -1), '2017-01-01' ) as time ) as TimeStart,
cast( DateAdd( minute, 15 * timeSlots.Row, '2017-01-01' ) as time ) as TimeEnd
from
( SELECT top 96
ROW_NUMBER() OVER(Order by AnyColumnInYourTable) Row
FROM
AnyTableThatHasAtLeast96Rows ) timeSlots
) allTimes
LEFT JOIN #Sales S
on allTimes.TimeStart <= S.SaleTime
AND S.SaleTime < allTimes.TimeEnd
AND ( allTimes.TimeEnd < allTimes.TimeStart
OR S.SaleTime <= allTimes.TimeEnd )
group by
allTimes.TimeStart,
allTimes.TimeEnd
Now, the explanation...
First, the inner-most query alias result "timeSlots". This can query from ANY table that has at least the 96 time slot 15 minute increments you are looking for and does nothing but returns a result set numbered sequentially from 1 to 96.
Now that we have 96 rows, we get to the next outer query alias result "allTimes". This basically does date/time math and adds the 15 minute intervals * whatever "row" number value is an create all time slots into 96 rows. I have explicitly applied a start and end time to apply >= and <. But this query does nothing but creates the explicit time slots. And since I am casting the DATEADD() component to just the TIME, it does not matter what fixed "Date" value I start with -- in this case, 2017-01-01. All I care about are the time slots themselves. The results will be like...
TimeStart TimeEnd
00:00:00 00:15:00
00:15:00 00:30:00
00:30:00 00:45:00
...
23:30:00 23:45:00
23:45:00 00:00:00 -- This one is special for the JOIN clause for time
Now, the LEFT JOIN... This is the SLIGHTLY tricky one
LEFT JOIN #Sales S
on allTimes.TimeStart <= S.SaleTime
AND S.SaleTime < allTimes.TimeEnd
AND ( allTimes.TimeEnd < allTimes.TimeStart
OR S.SaleTime <= allTimes.TimeEnd )
Here, left joining to the sales will always allow every time slot to be in the final result set. However, which slot does a given sale fit into? The Sale time must be GREATER OR EQUAL to the starting 15-minute interval...
AND..
Either... The endtime is less than the start (via the slot at 23:45 - 00:00 of the next morning) OR LESS then the beginning of the next time slot. Ex: 08:30 - 8:45 time slot is actually up to 8:44:xxxxx precision but always less than 8:45.
By doing this way with one row per time slot, I can get a count of transactions, sum of the transactions, you could even do avg, min, max for sales activity too, for finding trends.
WITH dates AS (
SELECT CAST('2009-01-01' AS datetime) 'date'
UNION ALL
SELECT DATEADD(mi, 15, t.date)
FROM dates t
WHERE DATEADD(mi, 15, t.date) < '2009-01-02')
SELECT cast([date] as time) as [date] from dates
Use the above code to get 96 columns for the 15 min interval for a day.
Join the sales table with the above CTE.
Here recursive CTE generates 15 minute intervals for 24 hour (96 rows).
Then this result LEFT JOINed to subquery. In subquery Amount is grouped by 15 minute intervals for every hour.
In result, 00:00:00 corresponds sum of amounts, which happened from 00:00:00 to 00:14:59
00:15:00 = from 00:15:00 to 00:29:59
00:30:00 = from 00:30:00 to 00:44:59
00:45:00 = from 00:45:00 to 00:59:59
and so on for every 24 hour
create table #Sales
( SiteName nvarchar(1),
SaleTime time,
Amount decimal,
BusinessDate Date );
insert into #Sales ( SiteName, SaleTime, Amount, BusinessDate )
values
( 'A', '13:22:36', 888, '2017-01-02' ),
( 'A', '00:00:00', 20, '2017-01-02' ),
( 'A', '00:00:00', 30, '2017-01-02' ),
( 'A', '00:45:00', 88, '2017-01-02' ),
( 'A', '12:46:05', 22, '2017-01-02' ),
( 'A', '12:59:59', 22, '2017-01-02' ),
( 'A', '23:59:59', 10, '2017-01-02' );
-- Below is actual query:
with rec as(
select cast('00:00:00' as time) as dt
union all
select DATEADD (mi , 15 , dt) from rec
where
dt < cast('23:45:00' as time)
)
select rec.dt, t1.summ from rec
left join
(select part, sum(Amount) as summ from (
select *, case
when DATEPART ( mi , SaleTime ) < 15 then concat(SUBSTRING (cast(SaleTime as varchar) ,1 , 2 ), ':00:00')
when DATEPART ( mi , SaleTime ) between 15 and 29 then concat(SUBSTRING (cast(SaleTime as varchar) ,1 , 2 ), ':15:00')
when DATEPART ( mi , SaleTime ) between 30 and 44 then concat(SUBSTRING (cast(SaleTime as varchar) ,1 , 2 ), ':30:00')
else concat(SUBSTRING (cast(SaleTime as varchar) ,1 , 2 ), ':45:00')
end as part
from #Sales
where BusinessDate = '2017-01-02'
) t
group by part) t1
on rec.dt = t1.part
order by rec.dt
rextester demo