Bug 32336: (QA follow-up) Use $metadata->schema
[srvgit] / Koha / Calendar.pm
index 2a2e062..5ffebb4 100644 (file)
@@ -1,14 +1,13 @@
 package Koha::Calendar;
-use strict;
-use warnings;
-use 5.010;
 
+use Modern::Perl;
+
+use Carp qw( croak );
 use DateTime;
-use DateTime::Set;
 use DateTime::Duration;
 use C4::Context;
-use Carp;
-use Readonly;
+use Koha::Caches;
+use Koha::Exceptions;
 
 sub new {
     my ( $classname, %options ) = @_;
@@ -18,10 +17,6 @@ sub new {
         my $o = lc $o_name;
         $self->{$o} = $options{$o_name};
     }
-    if ( exists $options{TEST_MODE} ) {
-        $self->_mockinit();
-        return $self;
-    }
     if ( !defined $self->{branchcode} ) {
         croak 'No branchcode argument passed to Koha::Calendar->new';
     }
@@ -33,203 +28,334 @@ sub _init {
     my $self       = shift;
     my $branch     = $self->{branchcode};
     my $dbh        = C4::Context->dbh();
-    my $repeat_sth = $dbh->prepare(
-'SELECT * from repeatable_holidays WHERE branchcode = ? AND ISNULL(weekday) = ?'
+    my $weekly_closed_days_sth = $dbh->prepare(
+'SELECT weekday FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NOT NULL'
     );
-    $repeat_sth->execute( $branch, 0 );
+    $weekly_closed_days_sth->execute( $branch );
     $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ];
-    Readonly::Scalar my $sunday => 7;
-    while ( my $tuple = $repeat_sth->fetchrow_hashref ) {
+    while ( my $tuple = $weekly_closed_days_sth->fetchrow_hashref ) {
         $self->{weekly_closed_days}->[ $tuple->{weekday} ] = 1;
     }
-    $repeat_sth->execute( $branch, 1 );
+    my $day_month_closed_days_sth = $dbh->prepare(
+'SELECT day, month FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NULL'
+    );
+    $day_month_closed_days_sth->execute( $branch );
     $self->{day_month_closed_days} = {};
-    while ( my $tuple = $repeat_sth->fetchrow_hashref ) {
+    while ( my $tuple = $day_month_closed_days_sth->fetchrow_hashref ) {
         $self->{day_month_closed_days}->{ $tuple->{month} }->{ $tuple->{day} } =
           1;
     }
 
-    my $special = $dbh->prepare(
-'SELECT day, month, year FROM special_holidays WHERE branchcode = ? AND isexception = ?'
-    );
-    $special->execute( $branch, 1 );
-    my $dates = [];
-    while ( my ( $day, $month, $year ) = $special->fetchrow ) {
-        push @{$dates},
-          DateTime->new(
-            day       => $day,
-            month     => $month,
-            year      => $year,
-            time_zone => C4::Context->tz()
-          )->truncate( to => 'day' );
-    }
-    $self->{exception_holidays} =
-      DateTime::Set->from_datetimes( dates => $dates );
-
-    $special->execute( $branch, 0 );
-    $dates = [];
-    while ( my ( $day, $month, $year ) = $special->fetchrow ) {
-        push @{$dates},
-          DateTime->new(
-            day       => $day,
-            month     => $month,
-            year      => $year,
-            time_zone => C4::Context->tz()
-          )->truncate( to => 'day' );
-    }
-    $self->{single_holidays} = DateTime::Set->from_datetimes( dates => $dates );
-    $self->{days_mode}       = C4::Context->preference('useDaysMode');
     $self->{test}            = 0;
     return;
 }
 
-sub addDate {
+sub _holidays {
+    my ($self) = @_;
+
+    my $key      = $self->{branchcode} . "_holidays";
+    my $cache    = Koha::Caches->get_instance();
+    my $holidays = $cache->get_from_cache($key);
+
+    # $holidays looks like:
+    # {
+    #    20131122 => 1, # single_holiday
+    #    20131123 => 0, # exception_holiday
+    #    ...
+    # }
+
+    # Populate the cache if necessary
+    unless ($holidays) {
+        my $dbh = C4::Context->dbh;
+        $holidays = {};
+
+        # Add holidays for each branch
+        my $holidays_sth = $dbh->prepare(
+'SELECT day, month, year, MAX(isexception) FROM special_holidays WHERE branchcode = ? GROUP BY day, month, year'
+        );
+        $holidays_sth->execute($self->{branchcode});
+
+        while ( my ( $day, $month, $year, $exception ) =
+            $holidays_sth->fetchrow )
+        {
+            my $datestring =
+                sprintf( "%04d", $year )
+              . sprintf( "%02d", $month )
+              . sprintf( "%02d", $day );
+
+            $holidays->{$datestring} = $exception ? 0 : 1;
+        }
+        $cache->set_in_cache( $key, $holidays, { expiry => 76800 } );
+    }
+    return $holidays // {};
+}
+
+sub addDuration {
     my ( $self, $startdate, $add_duration, $unit ) = @_;
-    my $base_date = $startdate->clone();
+
+
+    Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->addDuration: days_mode")
+        unless exists $self->{days_mode};
+
+    # Default to days duration (legacy support I guess)
     if ( ref $add_duration ne 'DateTime::Duration' ) {
         $add_duration = DateTime::Duration->new( days => $add_duration );
     }
-    $unit ||= q{};    # default days ?
-    my $days_mode = $self->{days_mode};
-    Readonly::Scalar my $return_by_hour => 10;
-    my $day_dur = DateTime::Duration->new( days => 1 );
-    if ( $add_duration->is_negative() ) {
-        $day_dur = DateTime::Duration->new( days => -1 );
+
+    $unit ||= 'days'; # default days ?
+    my $dt;
+    if ( $unit eq 'hours' ) {
+        # Fixed for legacy support. Should be set as a branch parameter
+        my $return_by_hour = 10;
+
+        $dt = $self->addHours($startdate, $add_duration, $return_by_hour);
+    } else {
+        # days
+        $dt = $self->addDays($startdate, $add_duration);
     }
-    if ( $days_mode eq 'Datedue' ) {
+    return $dt;
+}
+
+sub addHours {
+    my ( $self, $startdate, $hours_duration, $return_by_hour ) = @_;
+    my $base_date = $startdate->clone();
 
-        my $dt = $base_date + $add_duration;
-        while ( $self->is_holiday($dt) ) {
+    $base_date->add_duration($hours_duration);
 
-            $dt->add_duration($day_dur);
-            if ( $unit eq 'hours' ) {
-                $dt->set_hour($return_by_hour);    # Staffs specific
-            }
+    # If we are using the calendar behave for now as if Datedue
+    # was the chosen option (current intended behaviour)
+
+    Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->addHours: days_mode")
+        unless exists $self->{days_mode};
+
+    if ( $self->{days_mode} ne 'Days' &&
+          $self->is_holiday($base_date) ) {
+
+        if ( $hours_duration->is_negative() ) {
+            $base_date = $self->prev_open_days($base_date, 1);
+        } else {
+            $base_date = $self->next_open_days($base_date, 1);
         }
-        return $dt;
-    } elsif ( $days_mode eq 'Calendar' ) {
-        if ( $unit eq 'hours' ) {
-            $base_date->add_duration($add_duration);
-            while ( $self->is_holiday($base_date) ) {
-                $base_date->add_duration($day_dur);
 
-            }
+        $base_date->set_hour($return_by_hour);
+
+    }
 
+    return $base_date;
+}
+
+sub addDays {
+    my ( $self, $startdate, $days_duration ) = @_;
+    my $base_date = $startdate->clone();
+
+    Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->addDays: days_mode")
+        unless exists $self->{days_mode};
+
+    if ( $self->{days_mode} eq 'Calendar' ) {
+        # use the calendar to skip all days the library is closed
+        # when adding
+        my $days = abs $days_duration->in_units('days');
+
+        if ( $days_duration->is_negative() ) {
+            while ($days) {
+                $base_date = $self->prev_open_days($base_date, 1);
+                --$days;
+            }
         } else {
-            my $days = abs $add_duration->in_units('days');
             while ($days) {
-                $base_date->add_duration($day_dur);
-                if ( $self->is_holiday($base_date) ) {
-                    next;
-                } else {
-                    --$days;
-                }
+                $base_date = $self->next_open_days($base_date, 1);
+                --$days;
             }
         }
-        if ( $unit eq 'hours' ) {
-            my $dt = $base_date->clone()->subtract( days => 1 );
-            if ( $self->is_holiday($dt) ) {
-                $base_date->set_hour($return_by_hour);    # Staffs specific
+
+    } else { # Days, Datedue or Dayweek
+        # use straight days, then use calendar to push
+        # the date to the next open day as appropriate
+        # if Datedue or Dayweek
+        $base_date->add_duration($days_duration);
+
+        if ( $self->{days_mode} eq 'Datedue' ||
+            $self->{days_mode} eq 'Dayweek') {
+            # Datedue or Dayweek, then use the calendar to push
+            # the date to the next open day if holiday
+            if ( $self->is_holiday($base_date) ) {
+                my $dow = $base_date->day_of_week;
+                my $days = $days_duration->in_units('days');
+                # Is it a period based on weeks
+                my $push_amt = $days % 7 == 0 ?
+                    $self->get_push_amt($base_date) : 1;
+                if ( $days_duration->is_negative() ) {
+                    $base_date = $self->prev_open_days($base_date, $push_amt);
+                } else {
+                    $base_date = $self->next_open_days($base_date, $push_amt);
+                }
             }
         }
-        return $base_date;
-    } else {    # Days
-        return $base_date + $add_duration;
     }
+
+    return $base_date;
+}
+
+sub get_push_amt {
+    my ( $self, $base_date) = @_;
+
+    Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->get_push_amt: days_mode")
+        unless exists $self->{days_mode};
+
+    my $dow = $base_date->day_of_week;
+    # Representation fix
+    # DateTime object dow (1-7) where Monday is 1
+    # Arrays are 0-based where 0 = Sunday, not 7.
+    if ( $dow == 7 ) {
+        $dow = 0;
+    }
+
+    return (
+        # We're using Dayweek useDaysMode option
+        $self->{days_mode} eq 'Dayweek' &&
+        # It's not a permanently closed day
+        !$self->{weekly_closed_days}->[$dow]
+    ) ? 7 : 1;
 }
 
 sub is_holiday {
     my ( $self, $dt ) = @_;
-    my $dow = $dt->day_of_week;
+
+    my $localdt = $dt->clone();
+    my $day   = $localdt->day;
+    my $month = $localdt->month;
+    my $ymd   = $localdt->ymd('');
+
+    #Change timezone to "floating" before doing any calculations or comparisons
+    $localdt->set_time_zone("floating");
+    $localdt->truncate( to => 'day' );
+
+    return $self->_holidays->{$ymd} if defined($self->_holidays->{$ymd});
+
+    my $dow = $localdt->day_of_week;
+    # Representation fix
+    # DateTime object dow (1-7) where Monday is 1
+    # Arrays are 0-based where 0 = Sunday, not 7.
     if ( $dow == 7 ) {
         $dow = 0;
     }
+
     if ( $self->{weekly_closed_days}->[$dow] == 1 ) {
         return 1;
     }
-    $dt->truncate( to => 'day' );
-    my $day   = $dt->day;
-    my $month = $dt->month;
+
     if ( exists $self->{day_month_closed_days}->{$month}->{$day} ) {
         return 1;
     }
-    if ( $self->{exception_holidays}->contains($dt) ) {
-        return 1;
-    }
-    if ( $self->{single_holidays}->contains($dt) ) {
-        return 1;
-    }
 
     # damn have to go to work after all
     return 0;
 }
 
+sub next_open_days {
+    my ( $self, $dt, $to_add ) = @_;
+
+    Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->next_open_days: days_mode")
+        unless exists $self->{days_mode};
+
+    my $base_date = $dt->clone();
+
+    $base_date->add(days => $to_add);
+    while ($self->is_holiday($base_date)) {
+        my $add_next = $self->get_push_amt($base_date);
+        $base_date->add(days => $add_next);
+    }
+    return $base_date;
+}
+
+sub prev_open_days {
+    my ( $self, $dt, $to_sub ) = @_;
+
+    Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->get_open_days: days_mode")
+        unless exists $self->{days_mode};
+
+    my $base_date = $dt->clone();
+
+    # It feels logical to be passed a positive number, though we're
+    # subtracting, so do the right thing
+    $to_sub = $to_sub > 0 ? 0 - $to_sub : $to_sub;
+
+    $base_date->add(days => $to_sub);
+
+    while ($self->is_holiday($base_date)) {
+        my $sub_next = $self->get_push_amt($base_date);
+        # Ensure we're subtracting when we need to be
+        $sub_next = $sub_next > 0 ? 0 - $sub_next : $sub_next;
+        $base_date->add(days => $sub_next);
+    }
+
+    return $base_date;
+}
+
+sub days_forward {
+    my $self     = shift;
+    my $start_dt = shift;
+    my $num_days = shift;
+
+    Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->days_forward: days_mode")
+        unless exists $self->{days_mode};
+
+    return $start_dt unless $num_days > 0;
+
+    my $base_dt = $start_dt->clone();
+
+    while ($num_days--) {
+        $base_dt = $self->next_open_days($base_dt, 1);
+    }
+
+    return $base_dt;
+}
+
 sub days_between {
     my $self     = shift;
     my $start_dt = shift;
     my $end_dt   = shift;
 
+    # Change time zone for date math and swap if needed
+    $start_dt = $start_dt->clone->set_time_zone('floating');
+    $end_dt = $end_dt->clone->set_time_zone('floating');
+    if( $start_dt->compare($end_dt) > 0 ) {
+        ( $start_dt, $end_dt ) = ( $end_dt, $start_dt );
+    }
 
     # start and end should not be closed days
-    my $days = $start_dt->delta_days($end_dt)->delta_days;
-    for (my $dt = $start_dt->clone();
-        $dt <= $end_dt;
-        $dt->add(days => 1)
-    ) {
-        if ($self->is_holiday($dt)) {
-            $days--;
-        }
+    my $delta_days = $start_dt->delta_days($end_dt)->delta_days;
+    while( $start_dt->compare($end_dt) < 1 ) {
+        $delta_days-- if $self->is_holiday($start_dt);
+        $start_dt->add( days => 1 );
     }
-    return DateTime::Duration->new( days => $days );
-
+    return DateTime::Duration->new( days => $delta_days );
 }
 
 sub hours_between {
     my ($self, $start_date, $end_date) = @_;
-    my $start_dt = $start_date->clone();
-    my $end_dt = $end_date->clone();
+    my $start_dt = $start_date->clone()->set_time_zone('floating');
+    my $end_dt = $end_date->clone()->set_time_zone('floating');
+
     my $duration = $end_dt->delta_ms($start_dt);
     $start_dt->truncate( to => 'day' );
     $end_dt->truncate( to => 'day' );
+
     # NB this is a kludge in that it assumes all days are 24 hours
     # However for hourly loans the logic should be expanded to
     # take into account open/close times then it would be a duration
     # of library open hours
     my $skipped_days = 0;
-    for (my $dt = $start_dt->clone();
-        $dt <= $end_dt;
-        $dt->add(days => 1)
-    ) {
-        if ($self->is_holiday($dt)) {
-            ++$skipped_days;
-        }
+    while( $start_dt->compare($end_dt) < 1 ) {
+        $skipped_days++ if $self->is_holiday($start_dt);
+        $start_dt->add( days => 1 );
     }
+
     if ($skipped_days) {
         $duration->subtract_duration(DateTime::Duration->new( hours => 24 * $skipped_days));
     }
 
     return $duration;
-
-}
-
-sub _mockinit {
-    my $self = shift;
-    $self->{weekly_closed_days} = [ 1, 0, 0, 0, 0, 0, 0 ];    # Sunday only
-    $self->{day_month_closed_days} = { 6 => { 16 => 1, } };
-    my $dates = [];
-    $self->{exception_holidays} =
-      DateTime::Set->from_datetimes( dates => $dates );
-    my $special = DateTime->new(
-        year      => 2011,
-        month     => 6,
-        day       => 1,
-        time_zone => 'Europe/London',
-    );
-    push @{$dates}, $special;
-    $self->{single_holidays} = DateTime::Set->from_datetimes( dates => $dates );
-    $self->{days_mode} = 'Calendar';
-    $self->{test} = 1;
-    return;
 }
 
 sub set_daysmode {
@@ -249,17 +375,6 @@ sub clear_weekly_closed_days {
     return;
 }
 
-sub add_holiday {
-    my $self = shift;
-    my $new_dt = shift;
-    my @dt = $self->{exception_holidays}->as_list;
-    push @dt, $new_dt;
-    $self->{exception_holidays} =
-      DateTime::Set->from_datetimes( dates => \@dt );
-
-    return;
-}
-
 1;
 __END__
 
@@ -267,21 +382,17 @@ __END__
 
 Koha::Calendar - Object containing a branches calendar
 
-=head1 VERSION
-
-This documentation refers to Koha::Calendar version 0.0.1
-
 =head1 SYNOPSIS
 
-  use Koha::Calendat
+  use Koha::Calendar
 
-  my $c = Koha::Calender->new( branchcode => 'MAIN' );
-  my $dt = DateTime->now();
+  my $c = Koha::Calendar->new( branchcode => 'MAIN' );
+  my $dt = dt_from_string();
 
   # are we open
   $open = $c->is_holiday($dt);
   # when will item be due if loan period = $dur (a DateTime::Duration object)
-  $duedate = $c->addDate($dt,$dur,'days');
+  $duedate = $c->addDuration($dt,$dur,'days');
 
 
 =head1 DESCRIPTION
@@ -297,9 +408,9 @@ my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
 The option branchcode is required
 
 
-=head2 addDate
+=head2 addDuration
 
-    my $dt = $calendar->addDate($date, $dur, $unit)
+    my $dt = $calendar->addDuration($date, $dur, $unit)
 
 C<$date> is a DateTime object representing the starting date of the interval.
 
@@ -311,11 +422,43 @@ Currently unit is only used to invoke Staffs return Monday at 10 am rule this
 parameter will be removed when issuingrules properly cope with that
 
 
+=head2 addHours
+
+    my $dt = $calendar->addHours($date, $dur, $return_by_hour )
+
+C<$date> is a DateTime object representing the starting date of the interval.
+
+C<$offset> is a DateTime::Duration to add to it
+
+C<$return_by_hour> is an integer value representing the opening hour for the branch
+
+=head2 get_push_amt
+
+    my $amt = $calendar->get_push_amt($date)
+
+C<$date> is a DateTime object representing a closed return date
+
+Using the days_mode syspref value and the nature of the closed return
+date, return how many days we should jump forward to find another return date
+
+=head2 addDays
+
+    my $dt = $calendar->addDays($date, $dur)
+
+C<$date> is a DateTime object representing the starting date of the interval.
+
+C<$offset> is a DateTime::Duration to add to it
+
+C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
+
+Currently unit is only used to invoke Staffs return Monday at 10 am rule this
+parameter will be removed when issuingrules properly cope with that
+
 =head2 is_holiday
 
 $yesno = $calendar->is_holiday($dt);
 
-passed at DateTime object returns 1 if it is a closed day
+passed a DateTime object returns 1 if it is a closed day
 0 if not according to the calendar
 
 =head2 days_between
@@ -324,7 +467,46 @@ $duration = $calendar->days_between($start_dt, $end_dt);
 
 Passed two dates returns a DateTime::Duration object measuring the length between them
 ignoring closed days. Always returns a positive number irrespective of the
-relative order of the parameters
+relative order of the parameters.
+
+Note: This routine assumes neither the passed start_dt nor end_dt can be a closed day
+
+=head2 hours_between
+
+$duration = $calendar->hours_between($start_dt, $end_dt);
+
+Passed two dates returns a DateTime::Duration object measuring the length between them
+ignoring closed days. Always returns a positive number irrespective of the
+relative order of the parameters.
+
+Note: This routine assumes neither the passed start_dt nor end_dt can be a closed day
+
+=head2 next_open_days
+
+$datetime = $calendar->next_open_days($duedate_dt, $to_add)
+
+Passed a Datetime and number of days,  returns another Datetime representing
+the next open day after adding the passed number of days. It is intended for
+use to calculate the due date when useDaysMode syspref is set to either
+'Datedue', 'Calendar' or 'Dayweek'.
+
+=head2 prev_open_days
+
+$datetime = $calendar->prev_open_days($duedate_dt, $to_sub)
+
+Passed a Datetime and a number of days, returns another Datetime
+representing the previous open day after subtracting the number of passed
+days. It is intended for use to calculate the due date when useDaysMode
+syspref is set to either 'Datedue', 'Calendar' or 'Dayweek'.
+
+=head2 days_forward
+
+$datetime = $calendar->days_forward($start_dt, $to_add)
+
+Passed a Datetime and number of days, returns another Datetime representing
+the next open day after adding the passed number of days. It is intended for
+use to calculate the due date when useDaysMode syspref is set to either
+'Datedue', 'Calendar' or 'Dayweek'.
 
 =head2 set_daysmode
 
@@ -359,15 +541,15 @@ Colin Campbell colin.campbell@ptfs-europe.com
 
 Copyright (c) 2011 PTFS-Europe Ltd All rights reserved
 
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU General Public License as published by
-the Free Software Foundation, either version 2 of the License, or
+Koha is free software; you can redistribute it and/or modify it
+under the terms of the GNU General Public License as published by
+the Free Software Foundation; either version 3 of the License, or
 (at your option) any later version.
 
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+Koha is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 GNU General Public License for more details.
 
 You should have received a copy of the GNU General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
+along with Koha; if not, see <http://www.gnu.org/licenses>.