App-Dochazka-REST

 view release on metacpan or  search on metacpan

lib/App/Dochazka/REST/Holiday.pm  view on Meta::CPAN

#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of SUSE LLC nor the names of its contributors may be
# used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# *************************************************************************

package App::Dochazka::REST::Holiday;

use 5.012;
use strict;
use warnings;

use App::CELL qw( $CELL $log );
use Date::Calc qw( 
    Add_Delta_Days 
    Date_to_Days
    Day_of_Week 
);
use Date::Holidays::CZ qw( holidays );
use Params::Validate qw( :all );




=head1 NAME

App::Dochazka::REST::Holiday - non-database holiday and date routines




=head1 SYNOPSIS

    use App::Dochazka::REST::Holiday qw( holidays_in_daterange );

    my $holidays1 = holidays_in_daterange( 
        begin => '2001-01-02',
        end => '2001-12-24',
    );
    my $holidays2 = holidays_in_daterange( 
        begin => '2001-01-02',
        end => '2002-12-24',
    );

*WARNING*: C<holidays_in_daterange()> makes no attempt to validate the date
range. It assumes this validation has already taken place, and that the dates
are in YYYY-MM-DD format!




=head1 EXPORTS

=cut 

use Exporter qw( import );
our @EXPORT_OK = qw( 
    calculate_hours
    canon_date_diff
    canon_to_ymd
    get_tomorrow 
    holidays_and_weekends
    holidays_in_daterange 
    is_weekend 
    tsrange_to_dates_and_times
    ymd_to_canon
);



=head1 FUNCTIONS


=head2 calculate_hours

Given a canonicalized tsrange, return the number of hours. For example, if
the range is [ 2016-01-06 08:00, 2016-01-06 09:00 ), the return value will
be 1. If the range is [ 2016-01-06 08:00, 2016-01-07 09:00 ), the return
value will 25.

Returns 0 if there's a problem with the tsrange argument.

=cut

sub calculate_hours {
    my $tsr = shift;
    $log->debug( "Entering " . __PACKAGE__ . "::calculate_hours with tsr $tsr" );

    my ( $begin_date, $begin_time, $end_date, $end_time ) = 
        $tsr =~ m/(\d{4}-\d{2}-\d{2}).+(\d{2}:\d{2}):\d{2}.+(\d{4}-\d{2}-\d{2}).+(\d{2}:\d{2}):\d{2}/;

    return 0 unless $begin_date and $begin_time and $end_date and $end_time;

    my $days = canon_date_diff( $begin_date, $end_date );

    if ( $days == 0 ) {
        return _single_day_hours( $begin_time, $end_time )
    }
    
    return _single_day_hours( $begin_time, '24:00' ) +
           ( ( $days - 1 ) * 24 ) +
           _single_day_hours( '00:00', $end_time );
}


=head2 canon_date_diff

Compute difference (in days) between two canonical dates

=cut

sub canon_date_diff {
    my ( $date, $date1 ) = @_;
    my ( $date_days, $date1_days ) = (
        Date_to_Days( canon_to_ymd( $date ) ),
        Date_to_Days( canon_to_ymd( $date1 ) ),
    );
    return abs( $date_days - $date1_days );
}


=head2 canon_to_ymd

Takes canonical date YYYY-MM-DD and returns $y, $m, $d

=cut

sub canon_to_ymd {
    my ( $date ) = @_;
    return unless $date;

    return ( $date =~ m/(\d+)-(\d+)-(\d+)/ );
}


=head2 holidays_in_daterange

Given a PARAMHASH containing two properties, C<begin> and C<end>, the values of
which are canonicalized dates (possibly produced by the C<split_tsrange()>
function), determine the holidays that fall within this range. The function will
always return a status object. Upon success, the payload will contain a hashref
with the following structure:

{
    '2015-01-01' => '',
    '2015-05-01' => '',
}

The idea is that this hash can be used to quickly look up if a given date is a
holiday.

=cut

sub holidays_in_daterange {
    my ( %ARGS ) = validate( @_, {
        begin => { type => SCALAR },
        end => { type => SCALAR },
    } );

    my $begin_year = _extract_year( $ARGS{begin} );
    my $end_year = _extract_year( $ARGS{end} );

    # transform daterange into an array of hashes containing "begin", "end"
    # in other words: 
    # INPUT: { begin => '1901-06-30', end => '1903-03-15' } 
    # becomes
    # OUTPUT: [
    #     { begin => '1901-06-30', end => '1901-12-31' },
    #     { begin => '1902-01-01', end => '1902-12-31' },
    #     { begin => '1903-01-01', end => '1903-03-15' },
    # ]
    my $daterange_by_year = _daterange_by_year(
        begin_year => $begin_year,
        end_year => $end_year,
        begin_date => $ARGS{begin},
        end_date => $ARGS{end},
    );
    
    my %retval;

    foreach my $year ( sort( keys %{ $daterange_by_year } ) ) {
        my $holidays = holidays( YEAR => $year, FORMAT => '%Y-%m-%d', WEEKENDS => 1 );
        if ( $year eq $begin_year and $year eq $end_year ) {
            my $tmp_holidays = _eliminate_dates( $holidays, $ARGS{begin}, "before" );
            $holidays = _eliminate_dates( $tmp_holidays, $ARGS{end}, "after" );
            map { $retval{$_} = ''; } @$holidays;
        } elsif ( $year eq $begin_year ) {
            map { $retval{$_} = ''; } @{ _eliminate_dates( $holidays, $ARGS{begin}, "before" ) };
        } elsif ( $year eq $end_year ) {
            map { $retval{$_} = ''; } @{ _eliminate_dates( $holidays, $ARGS{end}, "after" ) };
        } else {
            map { $retval{$_} = ''; } @$holidays;
        }
    }

    return \%retval;
}


=head2 is_weekend

Simple function that takes a canonicalized date string in 
the format YYYY-MM-DD and returns a true or false value 
indicating whether or not the date falls on a weekend.

=cut

sub is_weekend {
    my $cdate = shift;  # cdate == Canonicalized Date String YYYY-MM-DD
    my ( $year, $month, $day ) = $cdate =~ m/(\d{4})-(\d{2})-(\d{2})/;
    my $dow = Day_of_Week( $year, $month, $day );
    return ( $dow == 6 or $dow == 7 )
        ? 1
        : 0;
}


=head2 get_tomorrow

Given a canonicalized date string in the format YYYY-MM-DD, return 
the next date (i.e. "tomorrow" from the perspective of the given date).

=cut

sub get_tomorrow {
    my $cdate = shift;  # cdate == Canonicalized Date String YYYY-MM-DD
    my ( $year, $month, $day ) = $cdate =~ m/(\d{4})-(\d{2})-(\d{2})/;
    my ( $tyear, $tmonth, $tday ) = Add_Delta_Days( $year, $month, $day, 1 );
    return "$tyear-" . sprintf( "%02d", $tmonth ) . "-" . sprintf( "%02d", $tday );
}


=head2 holidays_and_weekends

Given a date range (same as in C<holidays_in_daterange>, above), return
a reference to a hash of hashes that looks like this (for sample dates):

    {
        '2015-01-01' => { holiday => 1 },
        '2015-01-02' => {},
        '2015-01-03' => { weekend => 1 },
        '2015-01-04' => { weekend => 1 },
        '2015-01-05' => {},
        '2015-01-06' => {},
    }

Note that the range is always considered inclusive -- i.e. the bounding
dates of the range will be included in the hash.

=cut

sub holidays_and_weekends {
    my ( %ARGS ) = validate( @_, {
        begin => { type => SCALAR },
        end => { type => SCALAR },
    } );
    my $holidays = holidays_in_daterange( %ARGS );
    my $res = {};
    my $d = $ARGS{begin};
    $log->debug( "holidays_and_weekends \$d == $d" );
    while ( $d ne get_tomorrow( $ARGS{end} ) ) {
        $res->{ $d } = {};
        if ( is_weekend( $d ) ) {
            $res->{ $d }->{ 'weekend' } = 1;
        }
        if ( exists( $holidays->{ $d } ) ) {
            $res->{ $d }->{ 'holiday' } = 1;
        }
        $d = get_tomorrow( $d );
    }
    return $res;
}


=head2 tsrange_to_dates_and_times

Takes a string that might be a canonicalized tsrange. Attempts to extract
beginning and ending dates (YYYY-MM-DD) from it. If this succeeds, an OK status
object is returned, the payload of which is a hash suitable for passing to
holidays_and_weekends().

=cut

sub tsrange_to_dates_and_times {
    my ( $tsrange ) = @_;

    my ( $begin_date, $begin_time, $end_date, $end_time ) = 
        $tsrange =~ m/(\d{4}-\d{2}-\d{2}).+(\d{2}:\d{2}):\d{2}.+(\d{4}-\d{2}-\d{2}).+(\d{2}:\d{2}):\d{2}/;

    # if begin_time is 24:00 convert it to 00:00
    if ( $begin_time eq '24:00' ) {
        my ( $y, $m, $d ) = canon_to_ymd( $begin_date );
        $log->debug( "Before Add_Delta_Days $y $m $d" );
        ( $y, $m, $d ) = Add_Delta_Days( $y, $m, $d, 1 );
        $begin_date = ymd_to_canon( $y, $m, $d );
    }
    # if end_time is 00:00 convert it to 24:00
    if ( $end_time eq '00:00' ) {
        my ( $y, $m, $d ) = canon_to_ymd( $end_date );
        $log->debug( "Before Add_Delta_Days $y-$m-$d" );
        ( $y, $m, $d ) = Add_Delta_Days( $y, $m, $d, -1 );
        $end_date = ymd_to_canon( $y, $m, $d );
    }

    return $CELL->status_ok( 'DOCHAZKA_NORMAL_COMPLETION',
        payload => { begin => [ $begin_date, $begin_time ], 
                     end => [ $end_date, $end_time ] } );
}


=head2 ymd_to_canon

Takes $y, $m, $d and returns canonical date YYYY-MM-DD

=cut

sub ymd_to_canon {
    my ( $y, $m, $d ) = @_;

    if ( $y < 1 or $y > 9999 or $m < 1 or $m > 99 or $d < 1 or $d > 99 ) {
        die "AUCKLANDERS! ymd out of range!!";
    }

    return sprintf( "%04d-%02d-%02d", $y, $m, $d );
}


# HELPER FUNCTIONS

sub _daterange_by_year {
    my ( %ARGS ) = validate( @_, {
        begin_year => { type => SCALAR },
        end_year => { type => SCALAR },
        begin_date => { type => SCALAR },
        end_date => { type => SCALAR },
    } );
    my $year_delta = $ARGS{end_year} - $ARGS{begin_year};
    if ( $year_delta == 0 ) {
        return { $ARGS{begin_year} => { begin => $ARGS{begin}, end => $ARGS{end} } };
    }
    if ( $year_delta == 1 ) {
        return {
            $ARGS{begin_year} => { begin => $ARGS{begin}, end => "$ARGS{begin_year}-12-31" },
            $ARGS{end_year} => { begin => "$ARGS{end_year}-01-01", end => $ARGS{end} },
        };
    }
    my @intervening_years = ( ($ARGS{begin_year}+1)..($ARGS{end_year}-1) );
    my %retval = ( 
        $ARGS{begin_year} => { begin => $ARGS{begin}, end => "$ARGS{begin_year}-12-31" },
        $ARGS{end_year} => { begin => "$ARGS{end_year}-01-01", end => $ARGS{end} },
    );
    foreach my $year ( @intervening_years ) {
        $retval{ $year } = { begin => "$year-01-01", end => "$year-12-31" };
    }
    return \%retval;
}

# $inequality can be "before" or "after"
sub _eliminate_dates {
    my ( $holidays, $date, $inequality ) = @_;
    my @retval;
    foreach my $holiday ( @$holidays ) {
        if ( $inequality eq 'before' ) {
            push @retval, $holiday if $holiday ge $date; 
        } elsif ( $inequality eq 'after' ) {
            push @retval, $holiday if $holiday le $date;
        } else {
            die 'AG@D##KDW####!!!';
        }
    }
    return \@retval;
}

sub _extract_year {
    my $date = shift;
    my ( $year ) = $date =~ m/(\d+)-\d+-\d+/;
    return $year;
}

# Given two strings in the format HH:MM representing a starting and an ending
# time, calculate and return the number of hours.
sub _single_day_hours {
    my ( $begin, $end ) = @_;
    my ( $bh, $begin_minutes ) = $begin =~ m/(\d+):(\d+)/;
    my $begin_hours = $bh + $begin_minutes / 60;
    my ( $eh, $end_minutes ) = $end =~ m/(\d+):(\d+)/;
    my $end_hours = $eh + $end_minutes / 60;
    return $end_hours - $begin_hours;
}

1;



( run in 0.901 second using v1.01-cache-2.11-cpan-df04353d9ac )