Business days between two dates with calendar table [duplicate] - sql

I am trying to calculate business days between two dates in Oracle select. I got to the point when my calculation gives most results correct for given dates (I compare it with NETWORKDAYS in excel) but sometimes it varies from 2 days to -2 days - and I don't know why...
Here's my code:
SELECT
((to_char(CompleteDate,'J') - to_char(InstallDate,'J'))+1) - (((to_char(CompleteDate,'WW')+ (52 * ((to_char(CompleteDate,'YYYY') - to_char(InstallDate,'YYYY'))))) - to_char(InstallDate,'WW'))*2) as BusinessDays
FROM TABLE
Thanks!

The solution, finally:
SELECT OrderNumber, InstallDate, CompleteDate,
(TRUNC(CompleteDate) - TRUNC(InstallDate) ) +1 -
((((TRUNC(CompleteDate,'D'))-(TRUNC(InstallDate,'D')))/7)*2) -
(CASE WHEN TO_CHAR(InstallDate,'DY','nls_date_language=english')='SUN' THEN 1 ELSE 0 END) -
(CASE WHEN TO_CHAR(CompleteDate,'DY','nls_date_language=english')='SAT' THEN 1 ELSE 0 END) as BusinessDays
FROM Orders
ORDER BY OrderNumber;
Thanks for all your responses !

I took into account all the different approaches discussed above and came up with a simple query that gives us the number of working days in each month of the year between two dates:
WITH test_data AS
(
SELECT TO_DATE('01-JAN-14') AS start_date,
TO_DATE('31-DEC-14') AS end_date
FROM dual
),
all_dates AS
(
SELECT td.start_date, td.end_date, td.start_date + LEVEL-1 as week_day
FROM test_data td
CONNECT BY td.start_date + LEVEL-1 <= td.end_date)
SELECT TO_CHAR(week_day, 'MON'), COUNT(*)
FROM all_dates
WHERE to_char(week_day, 'dy', 'nls_date_language=AMERICAN') NOT IN ('sun' , 'sat')
GROUP BY TO_CHAR(week_day, 'MON');
Please feel free to modify the query as needed.

Try this:
with holidays as
(
select d from (
select minDate + level -1 d
from (select min(submitDate) minDate, max (completeDate) maxDate
from t)
connect by level <= maxDate - mindate + 1)
where to_char(d, 'dy', 'nls_date_language=AMERICAN') not in ('sun' , 'sat')
)
select t.OrderNo, t.submitDate, t.completeDate, count(*) businessDays
from t join holidays h on h.d between t.submitDate and t.completeDate
group by t.OrderNo, t.submitDate, t.completeDate
order by orderno
Here is a sqlfiddle demo

I changed my example to more readable and to return count of bus. days between. I do not know why you need 'J'- Julian format. All it takes is start/Install and end/Complete dates. You will get correct number of days between 2 dates using this. Replace my dates with yours, add NLS if needed...:
SELECT Count(*) BusDaysBtwn
FROM
(
SELECT TO_DATE('2013-02-18', 'YYYY-MM-DD') + LEVEL-1 InstallDate -- MON or any other day
, TO_DATE('2013-02-25', 'YYYY-MM-DD') CompleteDate -- MON or any other day
, TO_CHAR(TO_DATE('2013-02-18', 'YYYY-MM-DD') + LEVEL-1, 'DY') InstallDay -- day of week
FROM dual
CONNECT BY LEVEL <= (TO_DATE('2013-02-25', 'YYYY-MM-DD') - TO_DATE('2013-02-18', 'YYYY-MM-DD')) -- end_date - start_date
)
WHERE InstallDay NOT IN ('SAT', 'SUN')
/
SQL> 5

I see that marked final solution is not correct always. Suppose, InstallDate is 1st of the month (if falls on Saturday) and CompleteDate is 16th of the month (if falls on Sunday)
In that case, actual Business Days is 10 but the marked query result will give the answer as 12. So, we have to treat this type of cases too, which I used
(CASE WHEN TO_CHAR(InstallDate,'DY','nls_date_language=english')='SAT' AND TO_CHAR(CompleteDate,'DY','nls_date_language=english')='SUN' THEN 2 ELSE 0 END
line to handle it.
SELECT OrderNumber, InstallDate, CompleteDate,
(TRUNC(CompleteDate) - TRUNC(InstallDate) ) +1 -
((((TRUNC(CompleteDate,'D'))-(TRUNC(InstallDate,'D')))/7)*2) -
(CASE WHEN TO_CHAR(InstallDate,'DY','nls_date_language=english')='SUN' THEN 1 ELSE 0 END) -
(CASE WHEN TO_CHAR(CompleteDate,'DY','nls_date_language=english')='SAT' THEN 1 ELSE 0 END) -
(CASE WHEN TO_CHAR(InstallDate,'DY','nls_date_language=english')='SAT' AND TO_CHAR(CompleteDate,'DY','nls_date_language=english')='SUN' THEN 2 ELSE 0 END)as BusinessDays
FROM Orders
ORDER BY OrderNumber;

The accepted solution is quite close but seems wrong in some cases (e.g., 2/1/2015 through 2-28/2015 or 5/1/2015 through 5/31/2015). Here's a refined version...
end_date-begin_date+1 /* total days */
- TRUNC(2*(end_date-begin_date+1)/7) /* weekend days in whole weeks */
- (CASE
WHEN TO_CHAR(begin_date,'D') = 1 AND REMAINDER(end_date-begin_date+1,7) > 0 THEN 1
WHEN TO_CHAR(begin_date,'D') = 8 - REMAINDER(end_date-begin_date+1,7) THEN 1
WHEN TO_CHAR(begin_date,'D') > 8 - REMAINDER(end_date-begin_date+1,7) THEN 2
ELSE 0
END) /* weekend days in partial week */
AS business_days
The part that handles the multiples of 7 (whole weeks) is good. But, when considering the partial week portion, it depends on both the day-of-week offset and the number of days in the partial portion, according to the following matrix...
654321
1N 111111
2M 100000
3T 210000
4W 221000
5R 222100
6F 222210
7S 222221

To just remove sundays and saturdays you can use this
SELECT Base_DateDiff
- (floor((Base_DateDiff + 0 + Start_WeekDay) / 7))
- (floor((Base_DateDiff + 1 + Start_WeekDay) / 7))
FROM (SELECT 1 + TRUNC(InstallDate) - TRUNC(InstallDate, 'IW') Start_WeekDay
, CompleteDate - InstallDate + 1 Base_DateDiff
FROM TABLE) a
Base_DateDiff counts the number of days between the two dates
(floor((Base_DateDiff + 0 + Start_WeekDay) / 7)) counts the number of sundays
(floor((Base_DateDiff + 1 + Start_WeekDay) / 7)) counts the number of saturdays
1 + TRUNC(InstallDate) - TRUNC(InstallDate, 'IW') get 1 for mondays to 7 for sunday

This query can be used to go backward N days from the given date (business days only)
For example, go backward 15 days from 2017-05-17:
select date_point, closest_saturday - (15 - offset + floor((15 - offset) / 6) * 2) from(
select date_point,
closest_saturday,
(case
when weekday_num > 1 then
weekday_num - 2
else
0
end) offset
from (
select to_date('2017-05-17', 'yyyy-mm-dd') date_point,
to_date('2017-05-17', 'yyyy-mm-dd') - to_char(to_date('2017-05-17', 'yyyy-mm-dd'), 'D') closest_saturday,
to_char(to_date('2017-05-17', 'yyyy-mm-dd'), 'D') weekday_num
from dual
))
Some brief explanation: suppose we want to go backward N days from a given date
- Find the closest Saturday that is less than or equal to the given date.
- From the closest Saturday, go back ward (N - offset) days. offset is the number of business days between the closest Saturday and the given date (excluding the given date).
*To go back M days from a Saturday (business days only), use this formula DateOfMonthOfTheSaturday - [M + Floor(M / 6) * 2]

Here is a function that is fast and flexible. You can count any weekday in a date range.
CREATE OR REPLACE FUNCTION wfportal.cx_count_specific_weekdays( p_week_days VARCHAR2 DEFAULT 'MON,TUE,WED,THU,FRI'
, p_start_date DATE
, p_end_date DATE)
RETURN NUMBER
IS
/***************************************************************************************************************
*
* FUNCTION DESCRIPTION:
*
* This function calculates the total required week days in a date range.
*
* PARAMETERS:
*
* p_week_days VARCHAR2 The week days that need to be counted, comma seperated e.g. MON,TUE,WED,THU,FRU,SAT,SUN
* p_start_date DATE The start date
* p_end_date DATE The end date
*
* CHANGE history
*
* No. Date Changed by Change Description
* ---- ----------- ------------- -------------------------------------------------------------------------
* 0 07-May-2013 yourname Created
*
***************************************************************************************************************/
v_date_end_first_date_range DATE;
v_date_start_last_date_range DATE;
v_total_days_in_the_weeks NUMBER;
v_total_days_first_date_range NUMBER;
v_total_days_last_date_range NUMBER;
v_output NUMBER;
v_error_text CX_ERROR_CODES.ERROR_MESSAGE%TYPE;
--Count the required days in a specific date ranges by using a list of all the weekdays in that range.
CURSOR c_total_days ( v_start_date DATE
, v_end_date DATE ) IS
SELECT COUNT(*) total_days
FROM ( SELECT ( v_start_date + level - 1) days
FROM dual
CONNECT BY LEVEL <= ( v_end_date - v_start_date ) + 1
)
WHERE INSTR( ',' || p_week_days || ',', ',' || TO_CHAR( days, 'DY', 'NLS_DATE_LANGUAGE=english') || ',', 1 ) > 0
;
--Calculate the first and last date range by retrieving the first Sunday after the start date and the last Monday before the end date.
--Calculate the total amount of weeks in between and multiply that with the total required days.
CURSOR c_calculate_new_dates ( v_start_date DATE
, v_end_date DATE ) IS
SELECT date_end_first_date_range
, date_start_last_date_range
, (
(
( date_start_last_date_range - ( date_end_first_date_range + 1 ) )
) / 7
) * total_required_days total_days_in_the_weeks --The total amount of required days
FROM ( SELECT v_start_date + DECODE( TO_CHAR( v_start_date, 'DY', 'NLS_DATE_LANGUAGE=english')
, 'MON', 6
, 'TUE', 5
, 'WED', 4
, 'THU', 3
, 'FRI', 2
, 'SAT', 1
, 'SUN', 0
, 0 ) date_end_first_date_range
, v_end_date - DECODE( TO_CHAR( v_end_date, 'DY', 'NLS_DATE_LANGUAGE=english')
, 'MON', 0
, 'TUE', 1
, 'WED', 2
, 'THU', 3
, 'FRI', 4
, 'SAT', 5
, 'SUN', 6
, 0 ) date_start_last_date_range
, REGEXP_COUNT( p_week_days, ',' ) + 1 total_required_days --Count the commas + 1 to get the total required weekdays
FROM dual
)
;
BEGIN
--Verify that the start date is before the end date
IF p_start_date < p_end_date THEN
--Get the new calculated days.
OPEN c_calculate_new_dates( p_start_date, p_end_date );
FETCH c_calculate_new_dates INTO v_date_end_first_date_range
, v_date_start_last_date_range
, v_total_days_in_the_weeks;
CLOSE c_calculate_new_dates;
--Calculate the days in the first date range
OPEN c_total_days( p_start_date, v_date_end_first_date_range );
FETCH c_total_days INTO v_total_days_first_date_range;
CLOSE c_total_days;
--Calculate the days in the last date range
OPEN c_total_days( v_date_start_last_date_range, p_end_date );
FETCH c_total_days INTO v_total_days_last_date_range;
CLOSE c_total_days;
--Sum the total required days
v_output := v_total_days_first_date_range + v_total_days_last_date_range + v_total_days_in_the_weeks;
ELSE
v_output := 0;
END IF;
RETURN v_output;
EXCEPTION
WHEN OTHERS
THEN
RETURN NULL;
END cx_count_specific_weekdays;
/

Here you go...
First check how many days you got in the holiday table, excluding weekend days.
Get business days (MON to FRI) between the 2 dates and after that subtract the holiday days.
create or replace
FUNCTION calculate_business_days (p_start_date IN DATE, p_end_date IN DATE)
RETURN NUMBER IS
v_holidays NUMBER;
v_start_date DATE := TRUNC (p_start_date);
v_end_date DATE := TRUNC (p_end_date);
BEGIN
IF v_end_date >= v_start_date
THEN
SELECT COUNT (*)
INTO v_holidays
FROM holidays
WHERE day BETWEEN v_start_date AND v_end_date
AND day NOT IN (
SELECT hol.day
FROM holidays hol
WHERE MOD(TO_CHAR(hol.day, 'J'), 7) + 1 IN (6, 7)
);
RETURN GREATEST (NEXT_DAY (v_start_date, 'MON') - v_start_date - 2, 0)
+ ( ( NEXT_DAY (v_end_date, 'MON')
- NEXT_DAY (v_start_date, 'MON')
)
/ 7
)
* 5
- GREATEST (NEXT_DAY (v_end_date, 'MON') - v_end_date - 3, 0)
- v_holidays;
ELSE
RETURN NULL;
END IF;
END calculate_business_days;
After that you can test it out, like:
select
calculate_business_days('21-AUG-2013','28-AUG-2013') as business_days
from dual;

There is another easier way, using connect by and dual...
with t as (select to_date('30-sep-2013') end_date, trunc(sysdate) start_date from dual)select count(1) from dual, t where to_char(t.start_date + level, 'D') not in (1,7) connect by t.start_date + level <= t.end_date;
with connect by you get all the dates from start_date till the end_date. Then you can exclude the dates you don't need and count only the needed.

This would return business days:
(CompleteDate-InstallDate)-2*FLOOR((CompleteDate-InstallDate)/7)-
DECODE(SIGN(TO_CHAR(CompleteDate,'D')-
TO_CHAR(InstallDate,'D')),-1,2,0)+DECODE(TO_CHAR(CompleteDate,'D'),7,1,0)-
DECODE(TO_CHAR(InstallDate,'D'),7,1,0) as BusinessDays,

Related

Calculating Hours Between 2 dates Excluding Holidays in oracle [duplicate]

I need to calculate the number of days between two dates as decimal, excluding the weekends and holidays by using a custom function in Oracle SQL.
There are similar questions on the site; however as I could see, none of them asks for an output as decimal using a custom function. The reason why I need a decimal is to be able to use/extract time component afterwards. If there is already a question like this, please just share the link.
Tried to write a function as below with the help of the additional content I found on the internet thanks to the author. The inner subqueries works fine seperately, but it doesn't work as a whole function.
In brief, the idea is:
(calculate the day difference between startdate and enddate) -> (exclude the number of weekend days between startdate and enddate) -> (exclude the number of weekends between startdate and enddate)
When I try to save the function, it gives the error PLS-00103: Encountered the symbol "end-of-file". Since I am already new in functions maybe missing something basic.
Lastly, also please let me know if you have suggestions on how to make the code more efficient.
Thanks in advance!
CREATE OR REPLACE FUNCTION NET_WORKING_DAYS (startdate IN DATE, enddate IN DATE)
RETURN NUMBER IS
WORKINGDAYS_BETWEEN NUMBER;
BEGIN
SELECT
-- number of days between startdate and enddate
(
SELECT (TO_DATE('20160831150000','YYYYMMDDHH24MISS') - TO_DATE('20160801000000','YYYYMMDDHH24MISS') ) DAYS_BETWEEN
FROM DUAL
)
-
-- number of weekend days (after a given date)
(
SELECT COUNT(1) WEEKEND_DAYS_BETWEEN
FROM
(
SELECT
TO_DATE('20160701000000','YYYYMMDDHH24MISS') + SEQ as day_date, --2016/07/01 is a constant/given date for this formula
TO_CHAR(TO_DATE('20160701000000','YYYYMMDDHH24MISS') + SEQ , 'D') day_of_week
FROM
(
SELECT ROWNUM-1 SEQ
FROM ( SELECT 1 FROM DUAL CONNECT BY LEVEL<= 365 * 5) --5 years
)
ORDER BY 1
)
WHERE day_of_week IN (6,7)
AND day_date > TO_DATE('20160801000000','YYYYMMDDHH24MISS') --this should be replaced with startdate parameter
AND day_date < TO_DATE('20160831000000','YYYYMMDDHH24MISS') --this should be replaced with enddate parameter
)
-
-- number of holidays (after a given date)
(
SELECT COUNT(1)
FROM HOLIDAYS
WHERE HOLIDAY_DATE > TO_DATE('20160801000000','YYYYMMDDHH24MISS') --this should be replaced with startdate parameter
AND HOLIDAY_DATE < TO_DATE('20160831000000','YYYYMMDDHH24MISS') --this should be replaced with enddate parameter
)
INTO WORKINGDAYS_BETWEEN
FROM DUAL;
RETURN WORKINGDAYS_BETWEEN;
END NET_WORKING_DAYS;
**EDIT-1: Holidays are already defined in HOLIDAYS table in the database and for this date range from 20160801000000 to 20160831000000 , 30.06.2016 is the holiday date.
You do not need to use a row generator to enumerate every day to get the number of week days - it can be done using a simple calculation:
From my answer here:
CREATE FUNCTION getWorkingDays (
in_start_date IN DATE,
in_end_date IN DATE
) RETURN NUMBER DETERMINISTIC
IS
p_start_date DATE;
p_end_date DATE;
p_working_days NUMBER;
p_holiday_days NUMBER;
BEGIN
IF in_start_date IS NULL OR in_end_date IS NULL THEN
RETURN NUll;
END IF;
p_start_date := LEAST( in_start_date, in_end_date );
p_end_date := GREATEST( in_start_date, in_end_date );
-- 5/7 * ( Number of days between monday of the week containing the start date
-- and monday of the week containing the end date )
-- + LEAST( day of week for end date, 5 )
-- - LEAST( day of week for start date, 5 )
p_working_days := ( TRUNC( p_end_date, 'IW' ) - TRUNC( p_start_date, 'IW' ) ) * 5 / 7
+ LEAST( p_end_date - TRUNC( p_end_date, 'IW' ), 5 )
- LEAST( p_start_date - TRUNC( p_start_date, 'IW' ), 5 );
SELECT COALESCE(
SUM(
LEAST( p_end_date, holiday_date + INTERVAL '1' DAY )
- GREATEST( p_start_date, holiday_date )
),
0
)
INTO p_holiday_days
FROM Holidays
WHERE HOLIDAY_DATE BETWEEN TRUNC( p_start_date )
AND TRUNC( p_end_date )
AND HOLIDAY_DATE - TRUNC( HOLIDAY_DATE, 'IW' ) < 5;
RETURN GREATEST( p_working_days - p_holiday_days, 0 );
END;
/

Validation of first 6 digits in a SQL query in Oracle SQL developer

There is a requirement where I need to validate the identity card number with the first 6 digits as DOB. I need to find out the users not maintaining correct format.
If the dob is 02/10/1983 - 83021023456 && if its 02/10/2083 ->83221023456 (DOB is in MM/DD/YYYY and if year of birth >2000 then the +20 is done to the dob month). The query I tried with is given below:-
SELECT f_account_name,F_SSN ,F_DOB from table where
CASE WHEN SUBSTR(to_char(F_DOB, 'YYYY-MM-DD'),0,4)>2000
THEN
SUBSTR(f_ssn,0,6) <>
SUBSTR(to_char(F_DOB, 'YY-MM-DD'),0,2)
||SUBSTR(to_char(F_DOB, 'YY-MM-DD'),4,2)
||SUBSTR(to_char(F_DOB, 'YY-MM-DD'),7,2)
ELSE
SUBSTR(f_ssn,0,6) <>
SUBSTR(to_char(F_DOB, 'YY-MM-DD'),0,2)
||(SUBSTR(to_char(F_DOB, 'YY-MM-DD'),4,2)+20)
||SUBSTR(to_char(F_DOB, 'YY-MM-DD'),7,2)
END;
Its not working .
You cannot have the comparison inside the CASE expression; since the left-hand side of the expression is identical then it is simple to move it out and then you can simplify the rest:
SELECT f_account_name,
F_SSN,
F_DOB
FROM table_name
WHERE SUBSTR(f_ssn,0,6) !=
CASE
WHEN EXTRACT( YEAR FROM F_DOB ) > 2000
THEN TO_CHAR( F_DOB, 'YYMMDD')
ELSE TO_CHAR( F_DOB, 'YY' )
|| TO_CHAR( EXTRACT( MONTH FROM F_DOB )+20, 'FM00' )
|| TO_CHAR( F_DOB, 'DD')
END;
or, if the rule is to add 20 to the month for each century past 1900 (i.e. 20XX add 20 and 21XX add 40, etc.) then:
SELECT f_account_name,
F_SSN,
F_DOB
FROM table_name
WHERE SUBSTR(f_ssn,0,6) !=
TO_CHAR( F_DOB, 'YY' )
|| TO_CHAR(
EXTRACT( MONTH FROM F_DOB )
+ 20 * GREATEST( TRUNC( EXTRACT( YEAR FROM F_DOB ) / 100 ) - 19, 0 ),
'FM00'
)
|| TO_CHAR( F_DOB, 'DD');
I tried some date arithmetics and worked with numbers rather than strings ...
WITH
-- your input
indata(f_account_name,f_ssn,f_dob) AS (
--string -- number -- string
SELECT 'Arthur',83021023456,'02/10/1983' FROM dual
UNION ALL SELECT 'Tricia',83221023456,'02/10/2083' FROM dual
)
SELECT
f_account_name
, f_ssn
, f_dob
FROM indata
WHERE CAST(TRUNC(f_ssn/100000) AS NUMBER(6))
-- ^ integer division by 100000 to get the first 6 digits ...
= MOD(EXTRACT(YEAR FROM TO_DATE(f_dob,'MM/DD/YYYY')),100) * 10000
-- ^ modulo year of date of 100 gives 3rd and 4th digit of year
+ (
EXTRACT(MONTH FROM TO_DATE(f_dob,'MM/DD/YYYY'))
+CASE
WHEN EXTRACT(YEAR FROM TO_DATE(f_dob,'MM/DD/YYYY')) >= 2000 THEN 20
ELSE 0
END
) * 100
+ EXTRACT(DAY FROM TO_DATE(f_dob,'MM/DD/YYYY'))
;

Incorrect day of the week in Oracle Function

I have this common function which counts business days between two dates.
BUS_DAY := TRUNC(TO_DATE(P_START_DATE, D_FORMAT));
DATES_DIFF := TRUNC(TO_DATE(P_END_DATE, D_FORMAT)) - BUS_DAY;
SELECT MAX(RNUM) INTO T_DAYS
FROM (
SELECT ROWNUM RNUM
FROM ALL_OBJECTS
)
WHERE ROWNUM <= DATES_DIFF
AND TO_CHAR(BUS_DAY + RNUM, 'DY') NOT IN ('SAT', 'SUN');
My problem is it gives an incorrect day of the week for dates.
For example, today is Oct 7 2020 WEDNESDAY, but the function reads this date as a MONDAY, so it gives incorrect number of business days T_T
Anyone have the same issue or have any idea why oracle is reading dates incorrectly?
You can calculate the value without having to use a row generator and independent of the NLS_DATE_LANGUAGE.
Adapted from my answer here (which is the same problem but also ignoring holidays):
Get the number of days between the Mondays of both weeks (using TRUNC( datevalue, 'IW' ) as an NLS_LANGUAGE independent method of finding the Monday of the week) and multiply by 5/7 to give the week days of the full weeks; then
Add the day of the week (Monday = 1, Tuesday = 2, etc., to a maximum of 5 to ignore weekends) to count the part week for the end date; and
Subtract the day of the week of the start date to remove the counted values beforehand.
Like this:
SELECT ( TRUNC( end_date, 'IW' ) - TRUNC( start_date, 'IW' ) ) * 5 / 7
+ LEAST( end_date - TRUNC( end_date, 'IW' ) + 1, 5 )
- LEAST( start_date - TRUNC( start_date, 'IW' ) + 1, 5 )
AS WeekDaysDifference
FROM your_table
If you are just calculating for a single value in a function then you can avoid a context-switch to SQL and do it all in PL/SQL:
CREATE FUNCTION count_weekdays_between(
p_start_date IN DATE,
p_end_date IN DATE
) RETURN NUMBER DETERMINISTIC
IS
BEGIN
RETURN ( TRUNC( p_end_date, 'IW' ) - TRUNC( p_start_date, 'IW' ) ) * 5 / 7
+ LEAST( p_end_date - TRUNC( p_end_date, 'IW' ) + 1, 5 )
- LEAST( p_start_date - TRUNC( p_start_date, 'IW' ) + 1, 5 );
END;
/
and:
SELECT count_weekdays_between( DATE '2020-09-29', DATE '2020-10-07' )
AS num_week_days
FROM DUAL;
Outputs: 6
db<>fiddle here
Using SELECT ... FROM ALL_OBJECTS is a really ugly workaround.
What about this proposal?
DECLARE
BUS_DAY DATE := TRUNC(SYSDATE - 10);
DATES_DIFF INTEGER := TRUNC(SYSDATE - BUS_DAY);
T_DAYS INTEGER;
BEGIN
SELECT SUM(1)
INTO T_DAYS
FROM dual
WHERE TO_CHAR(BUS_DAY + LEVEL, 'DY', 'NLS_DATE_LANGUAGE = American') NOT IN ('SAT', 'SUN')
CONNECT BY LEVEL <= DATES_DIFF;
DBMS_OUTPUT.PUT_LINE ( 'T_DAYS = ' || T_DAYS );
END;
Looking just at why you see Monday, and ignoring whether this is a good approach - which #MTO has covered - then if p_start_date and p_end_date are dates, using to_date() on them is a bug, as #a_horse_with_no_name said in a comment.
If your NLS settings have YY or RR and d_format is using YYYY then today's date would end up as 0020-10-07, which was a Monday.
As a demo:
declare
P_START_DATE date := date '2020-10-07';
D_FORMAT varchar2(11) := 'DD-MON-YYYY';
BUS_DAY date;
begin
dbms_output.put_line(P_START_DATE || ' => ' || to_char(P_START_DATE, 'SYYYY-MM-DD Day'));
BUS_DAY := TRUNC(TO_DATE(P_START_DATE, D_FORMAT));
dbms_output.put_line(BUS_DAY || ' => ' || to_char(BUS_DAY, 'SYYYY-MM-DD Day'));
end;
/
07-OCT-20 => 2020-10-07 Wednesday
07-OCT-20 => 0020-10-07 Monday
When you do:
BUS_DAY := TRUNC(TO_DATE(P_START_DATE, D_FORMAT));
it's really:
BUS_DAY := TRUNC(TO_DATE(TO_CHAR(P_START_DATE), D_FORMAT));
and that implicit TO_CHAR(P_START_DATE) is using your NLS settings, so it's something like:
BUS_DAY := TRUNC(TO_DATE(TO_CHAR(P_START_DATE, 'DD-MON-RR'), D_FORMAT));
You end up with that intermediate string value as '07-OCT-20'. If you convert that back to a date with a YYYY year component in the format mask then the year is seen as 0020, not 2020:
select to_char(to_date('07-OCT-20', 'DD-MON-YYYY'), 'DD-MON-YYYY') from dual;
07-OCT-0020
You don't need to convert to and from a string, and you're already truncating to set any time part to midnight, so you only need that part:
BUS_DAY := TRUNC(P_START_DATE);
db<>fiddle
Some clients use their own display preferences rather than NLS settings, so you may be seeing the date as 07-Oct-2020 when you query, while the NLS setting has YY or RR. You can query nls_session_parameters to check.

create custom function for date difference excluding weekends and holidays in oracle sql

I need to calculate the number of days between two dates as decimal, excluding the weekends and holidays by using a custom function in Oracle SQL.
There are similar questions on the site; however as I could see, none of them asks for an output as decimal using a custom function. The reason why I need a decimal is to be able to use/extract time component afterwards. If there is already a question like this, please just share the link.
Tried to write a function as below with the help of the additional content I found on the internet thanks to the author. The inner subqueries works fine seperately, but it doesn't work as a whole function.
In brief, the idea is:
(calculate the day difference between startdate and enddate) -> (exclude the number of weekend days between startdate and enddate) -> (exclude the number of weekends between startdate and enddate)
When I try to save the function, it gives the error PLS-00103: Encountered the symbol "end-of-file". Since I am already new in functions maybe missing something basic.
Lastly, also please let me know if you have suggestions on how to make the code more efficient.
Thanks in advance!
CREATE OR REPLACE FUNCTION NET_WORKING_DAYS (startdate IN DATE, enddate IN DATE)
RETURN NUMBER IS
WORKINGDAYS_BETWEEN NUMBER;
BEGIN
SELECT
-- number of days between startdate and enddate
(
SELECT (TO_DATE('20160831150000','YYYYMMDDHH24MISS') - TO_DATE('20160801000000','YYYYMMDDHH24MISS') ) DAYS_BETWEEN
FROM DUAL
)
-
-- number of weekend days (after a given date)
(
SELECT COUNT(1) WEEKEND_DAYS_BETWEEN
FROM
(
SELECT
TO_DATE('20160701000000','YYYYMMDDHH24MISS') + SEQ as day_date, --2016/07/01 is a constant/given date for this formula
TO_CHAR(TO_DATE('20160701000000','YYYYMMDDHH24MISS') + SEQ , 'D') day_of_week
FROM
(
SELECT ROWNUM-1 SEQ
FROM ( SELECT 1 FROM DUAL CONNECT BY LEVEL<= 365 * 5) --5 years
)
ORDER BY 1
)
WHERE day_of_week IN (6,7)
AND day_date > TO_DATE('20160801000000','YYYYMMDDHH24MISS') --this should be replaced with startdate parameter
AND day_date < TO_DATE('20160831000000','YYYYMMDDHH24MISS') --this should be replaced with enddate parameter
)
-
-- number of holidays (after a given date)
(
SELECT COUNT(1)
FROM HOLIDAYS
WHERE HOLIDAY_DATE > TO_DATE('20160801000000','YYYYMMDDHH24MISS') --this should be replaced with startdate parameter
AND HOLIDAY_DATE < TO_DATE('20160831000000','YYYYMMDDHH24MISS') --this should be replaced with enddate parameter
)
INTO WORKINGDAYS_BETWEEN
FROM DUAL;
RETURN WORKINGDAYS_BETWEEN;
END NET_WORKING_DAYS;
**EDIT-1: Holidays are already defined in HOLIDAYS table in the database and for this date range from 20160801000000 to 20160831000000 , 30.06.2016 is the holiday date.
You do not need to use a row generator to enumerate every day to get the number of week days - it can be done using a simple calculation:
From my answer here:
CREATE FUNCTION getWorkingDays (
in_start_date IN DATE,
in_end_date IN DATE
) RETURN NUMBER DETERMINISTIC
IS
p_start_date DATE;
p_end_date DATE;
p_working_days NUMBER;
p_holiday_days NUMBER;
BEGIN
IF in_start_date IS NULL OR in_end_date IS NULL THEN
RETURN NUll;
END IF;
p_start_date := LEAST( in_start_date, in_end_date );
p_end_date := GREATEST( in_start_date, in_end_date );
-- 5/7 * ( Number of days between monday of the week containing the start date
-- and monday of the week containing the end date )
-- + LEAST( day of week for end date, 5 )
-- - LEAST( day of week for start date, 5 )
p_working_days := ( TRUNC( p_end_date, 'IW' ) - TRUNC( p_start_date, 'IW' ) ) * 5 / 7
+ LEAST( p_end_date - TRUNC( p_end_date, 'IW' ), 5 )
- LEAST( p_start_date - TRUNC( p_start_date, 'IW' ), 5 );
SELECT COALESCE(
SUM(
LEAST( p_end_date, holiday_date + INTERVAL '1' DAY )
- GREATEST( p_start_date, holiday_date )
),
0
)
INTO p_holiday_days
FROM Holidays
WHERE HOLIDAY_DATE BETWEEN TRUNC( p_start_date )
AND TRUNC( p_end_date )
AND HOLIDAY_DATE - TRUNC( HOLIDAY_DATE, 'IW' ) < 5;
RETURN GREATEST( p_working_days - p_holiday_days, 0 );
END;
/

how to find the difference between two dates using Oracle sql by excluding weekends [duplicate]

I am trying to calculate business days between two dates in Oracle select. I got to the point when my calculation gives most results correct for given dates (I compare it with NETWORKDAYS in excel) but sometimes it varies from 2 days to -2 days - and I don't know why...
Here's my code:
SELECT
((to_char(CompleteDate,'J') - to_char(InstallDate,'J'))+1) - (((to_char(CompleteDate,'WW')+ (52 * ((to_char(CompleteDate,'YYYY') - to_char(InstallDate,'YYYY'))))) - to_char(InstallDate,'WW'))*2) as BusinessDays
FROM TABLE
Thanks!
The solution, finally:
SELECT OrderNumber, InstallDate, CompleteDate,
(TRUNC(CompleteDate) - TRUNC(InstallDate) ) +1 -
((((TRUNC(CompleteDate,'D'))-(TRUNC(InstallDate,'D')))/7)*2) -
(CASE WHEN TO_CHAR(InstallDate,'DY','nls_date_language=english')='SUN' THEN 1 ELSE 0 END) -
(CASE WHEN TO_CHAR(CompleteDate,'DY','nls_date_language=english')='SAT' THEN 1 ELSE 0 END) as BusinessDays
FROM Orders
ORDER BY OrderNumber;
Thanks for all your responses !
I took into account all the different approaches discussed above and came up with a simple query that gives us the number of working days in each month of the year between two dates:
WITH test_data AS
(
SELECT TO_DATE('01-JAN-14') AS start_date,
TO_DATE('31-DEC-14') AS end_date
FROM dual
),
all_dates AS
(
SELECT td.start_date, td.end_date, td.start_date + LEVEL-1 as week_day
FROM test_data td
CONNECT BY td.start_date + LEVEL-1 <= td.end_date)
SELECT TO_CHAR(week_day, 'MON'), COUNT(*)
FROM all_dates
WHERE to_char(week_day, 'dy', 'nls_date_language=AMERICAN') NOT IN ('sun' , 'sat')
GROUP BY TO_CHAR(week_day, 'MON');
Please feel free to modify the query as needed.
Try this:
with holidays as
(
select d from (
select minDate + level -1 d
from (select min(submitDate) minDate, max (completeDate) maxDate
from t)
connect by level <= maxDate - mindate + 1)
where to_char(d, 'dy', 'nls_date_language=AMERICAN') not in ('sun' , 'sat')
)
select t.OrderNo, t.submitDate, t.completeDate, count(*) businessDays
from t join holidays h on h.d between t.submitDate and t.completeDate
group by t.OrderNo, t.submitDate, t.completeDate
order by orderno
Here is a sqlfiddle demo
I changed my example to more readable and to return count of bus. days between. I do not know why you need 'J'- Julian format. All it takes is start/Install and end/Complete dates. You will get correct number of days between 2 dates using this. Replace my dates with yours, add NLS if needed...:
SELECT Count(*) BusDaysBtwn
FROM
(
SELECT TO_DATE('2013-02-18', 'YYYY-MM-DD') + LEVEL-1 InstallDate -- MON or any other day
, TO_DATE('2013-02-25', 'YYYY-MM-DD') CompleteDate -- MON or any other day
, TO_CHAR(TO_DATE('2013-02-18', 'YYYY-MM-DD') + LEVEL-1, 'DY') InstallDay -- day of week
FROM dual
CONNECT BY LEVEL <= (TO_DATE('2013-02-25', 'YYYY-MM-DD') - TO_DATE('2013-02-18', 'YYYY-MM-DD')) -- end_date - start_date
)
WHERE InstallDay NOT IN ('SAT', 'SUN')
/
SQL> 5
I see that marked final solution is not correct always. Suppose, InstallDate is 1st of the month (if falls on Saturday) and CompleteDate is 16th of the month (if falls on Sunday)
In that case, actual Business Days is 10 but the marked query result will give the answer as 12. So, we have to treat this type of cases too, which I used
(CASE WHEN TO_CHAR(InstallDate,'DY','nls_date_language=english')='SAT' AND TO_CHAR(CompleteDate,'DY','nls_date_language=english')='SUN' THEN 2 ELSE 0 END
line to handle it.
SELECT OrderNumber, InstallDate, CompleteDate,
(TRUNC(CompleteDate) - TRUNC(InstallDate) ) +1 -
((((TRUNC(CompleteDate,'D'))-(TRUNC(InstallDate,'D')))/7)*2) -
(CASE WHEN TO_CHAR(InstallDate,'DY','nls_date_language=english')='SUN' THEN 1 ELSE 0 END) -
(CASE WHEN TO_CHAR(CompleteDate,'DY','nls_date_language=english')='SAT' THEN 1 ELSE 0 END) -
(CASE WHEN TO_CHAR(InstallDate,'DY','nls_date_language=english')='SAT' AND TO_CHAR(CompleteDate,'DY','nls_date_language=english')='SUN' THEN 2 ELSE 0 END)as BusinessDays
FROM Orders
ORDER BY OrderNumber;
The accepted solution is quite close but seems wrong in some cases (e.g., 2/1/2015 through 2-28/2015 or 5/1/2015 through 5/31/2015). Here's a refined version...
end_date-begin_date+1 /* total days */
- TRUNC(2*(end_date-begin_date+1)/7) /* weekend days in whole weeks */
- (CASE
WHEN TO_CHAR(begin_date,'D') = 1 AND REMAINDER(end_date-begin_date+1,7) > 0 THEN 1
WHEN TO_CHAR(begin_date,'D') = 8 - REMAINDER(end_date-begin_date+1,7) THEN 1
WHEN TO_CHAR(begin_date,'D') > 8 - REMAINDER(end_date-begin_date+1,7) THEN 2
ELSE 0
END) /* weekend days in partial week */
AS business_days
The part that handles the multiples of 7 (whole weeks) is good. But, when considering the partial week portion, it depends on both the day-of-week offset and the number of days in the partial portion, according to the following matrix...
654321
1N 111111
2M 100000
3T 210000
4W 221000
5R 222100
6F 222210
7S 222221
To just remove sundays and saturdays you can use this
SELECT Base_DateDiff
- (floor((Base_DateDiff + 0 + Start_WeekDay) / 7))
- (floor((Base_DateDiff + 1 + Start_WeekDay) / 7))
FROM (SELECT 1 + TRUNC(InstallDate) - TRUNC(InstallDate, 'IW') Start_WeekDay
, CompleteDate - InstallDate + 1 Base_DateDiff
FROM TABLE) a
Base_DateDiff counts the number of days between the two dates
(floor((Base_DateDiff + 0 + Start_WeekDay) / 7)) counts the number of sundays
(floor((Base_DateDiff + 1 + Start_WeekDay) / 7)) counts the number of saturdays
1 + TRUNC(InstallDate) - TRUNC(InstallDate, 'IW') get 1 for mondays to 7 for sunday
This query can be used to go backward N days from the given date (business days only)
For example, go backward 15 days from 2017-05-17:
select date_point, closest_saturday - (15 - offset + floor((15 - offset) / 6) * 2) from(
select date_point,
closest_saturday,
(case
when weekday_num > 1 then
weekday_num - 2
else
0
end) offset
from (
select to_date('2017-05-17', 'yyyy-mm-dd') date_point,
to_date('2017-05-17', 'yyyy-mm-dd') - to_char(to_date('2017-05-17', 'yyyy-mm-dd'), 'D') closest_saturday,
to_char(to_date('2017-05-17', 'yyyy-mm-dd'), 'D') weekday_num
from dual
))
Some brief explanation: suppose we want to go backward N days from a given date
- Find the closest Saturday that is less than or equal to the given date.
- From the closest Saturday, go back ward (N - offset) days. offset is the number of business days between the closest Saturday and the given date (excluding the given date).
*To go back M days from a Saturday (business days only), use this formula DateOfMonthOfTheSaturday - [M + Floor(M / 6) * 2]
Here is a function that is fast and flexible. You can count any weekday in a date range.
CREATE OR REPLACE FUNCTION wfportal.cx_count_specific_weekdays( p_week_days VARCHAR2 DEFAULT 'MON,TUE,WED,THU,FRI'
, p_start_date DATE
, p_end_date DATE)
RETURN NUMBER
IS
/***************************************************************************************************************
*
* FUNCTION DESCRIPTION:
*
* This function calculates the total required week days in a date range.
*
* PARAMETERS:
*
* p_week_days VARCHAR2 The week days that need to be counted, comma seperated e.g. MON,TUE,WED,THU,FRU,SAT,SUN
* p_start_date DATE The start date
* p_end_date DATE The end date
*
* CHANGE history
*
* No. Date Changed by Change Description
* ---- ----------- ------------- -------------------------------------------------------------------------
* 0 07-May-2013 yourname Created
*
***************************************************************************************************************/
v_date_end_first_date_range DATE;
v_date_start_last_date_range DATE;
v_total_days_in_the_weeks NUMBER;
v_total_days_first_date_range NUMBER;
v_total_days_last_date_range NUMBER;
v_output NUMBER;
v_error_text CX_ERROR_CODES.ERROR_MESSAGE%TYPE;
--Count the required days in a specific date ranges by using a list of all the weekdays in that range.
CURSOR c_total_days ( v_start_date DATE
, v_end_date DATE ) IS
SELECT COUNT(*) total_days
FROM ( SELECT ( v_start_date + level - 1) days
FROM dual
CONNECT BY LEVEL <= ( v_end_date - v_start_date ) + 1
)
WHERE INSTR( ',' || p_week_days || ',', ',' || TO_CHAR( days, 'DY', 'NLS_DATE_LANGUAGE=english') || ',', 1 ) > 0
;
--Calculate the first and last date range by retrieving the first Sunday after the start date and the last Monday before the end date.
--Calculate the total amount of weeks in between and multiply that with the total required days.
CURSOR c_calculate_new_dates ( v_start_date DATE
, v_end_date DATE ) IS
SELECT date_end_first_date_range
, date_start_last_date_range
, (
(
( date_start_last_date_range - ( date_end_first_date_range + 1 ) )
) / 7
) * total_required_days total_days_in_the_weeks --The total amount of required days
FROM ( SELECT v_start_date + DECODE( TO_CHAR( v_start_date, 'DY', 'NLS_DATE_LANGUAGE=english')
, 'MON', 6
, 'TUE', 5
, 'WED', 4
, 'THU', 3
, 'FRI', 2
, 'SAT', 1
, 'SUN', 0
, 0 ) date_end_first_date_range
, v_end_date - DECODE( TO_CHAR( v_end_date, 'DY', 'NLS_DATE_LANGUAGE=english')
, 'MON', 0
, 'TUE', 1
, 'WED', 2
, 'THU', 3
, 'FRI', 4
, 'SAT', 5
, 'SUN', 6
, 0 ) date_start_last_date_range
, REGEXP_COUNT( p_week_days, ',' ) + 1 total_required_days --Count the commas + 1 to get the total required weekdays
FROM dual
)
;
BEGIN
--Verify that the start date is before the end date
IF p_start_date < p_end_date THEN
--Get the new calculated days.
OPEN c_calculate_new_dates( p_start_date, p_end_date );
FETCH c_calculate_new_dates INTO v_date_end_first_date_range
, v_date_start_last_date_range
, v_total_days_in_the_weeks;
CLOSE c_calculate_new_dates;
--Calculate the days in the first date range
OPEN c_total_days( p_start_date, v_date_end_first_date_range );
FETCH c_total_days INTO v_total_days_first_date_range;
CLOSE c_total_days;
--Calculate the days in the last date range
OPEN c_total_days( v_date_start_last_date_range, p_end_date );
FETCH c_total_days INTO v_total_days_last_date_range;
CLOSE c_total_days;
--Sum the total required days
v_output := v_total_days_first_date_range + v_total_days_last_date_range + v_total_days_in_the_weeks;
ELSE
v_output := 0;
END IF;
RETURN v_output;
EXCEPTION
WHEN OTHERS
THEN
RETURN NULL;
END cx_count_specific_weekdays;
/
Here you go...
First check how many days you got in the holiday table, excluding weekend days.
Get business days (MON to FRI) between the 2 dates and after that subtract the holiday days.
create or replace
FUNCTION calculate_business_days (p_start_date IN DATE, p_end_date IN DATE)
RETURN NUMBER IS
v_holidays NUMBER;
v_start_date DATE := TRUNC (p_start_date);
v_end_date DATE := TRUNC (p_end_date);
BEGIN
IF v_end_date >= v_start_date
THEN
SELECT COUNT (*)
INTO v_holidays
FROM holidays
WHERE day BETWEEN v_start_date AND v_end_date
AND day NOT IN (
SELECT hol.day
FROM holidays hol
WHERE MOD(TO_CHAR(hol.day, 'J'), 7) + 1 IN (6, 7)
);
RETURN GREATEST (NEXT_DAY (v_start_date, 'MON') - v_start_date - 2, 0)
+ ( ( NEXT_DAY (v_end_date, 'MON')
- NEXT_DAY (v_start_date, 'MON')
)
/ 7
)
* 5
- GREATEST (NEXT_DAY (v_end_date, 'MON') - v_end_date - 3, 0)
- v_holidays;
ELSE
RETURN NULL;
END IF;
END calculate_business_days;
After that you can test it out, like:
select
calculate_business_days('21-AUG-2013','28-AUG-2013') as business_days
from dual;
There is another easier way, using connect by and dual...
with t as (select to_date('30-sep-2013') end_date, trunc(sysdate) start_date from dual)select count(1) from dual, t where to_char(t.start_date + level, 'D') not in (1,7) connect by t.start_date + level <= t.end_date;
with connect by you get all the dates from start_date till the end_date. Then you can exclude the dates you don't need and count only the needed.
This would return business days:
(CompleteDate-InstallDate)-2*FLOOR((CompleteDate-InstallDate)/7)-
DECODE(SIGN(TO_CHAR(CompleteDate,'D')-
TO_CHAR(InstallDate,'D')),-1,2,0)+DECODE(TO_CHAR(CompleteDate,'D'),7,1,0)-
DECODE(TO_CHAR(InstallDate,'D'),7,1,0) as BusinessDays,