1 package Koha::Calendar;
7 use DateTime::Duration;
11 use Koha::Exceptions::Calendar;
13 # This limit avoids an infinite loop when searching for an open day in an
14 # always closed library
15 # The value is arbitrary, but it should be large enough to be able to
16 # consider there is no open days if we haven't found any with that many
17 # iterations, and small enough to allow the loop to end quickly
18 # See next_open_days and prev_open_days
19 use constant OPEN_DAYS_SEARCH_MAX_ITERATIONS => 5000;
22 my ( $classname, %options ) = @_;
24 bless $self, $classname;
25 for my $o_name ( keys %options ) {
27 $self->{$o} = $options{$o_name};
29 if ( !defined $self->{branchcode} ) {
30 croak 'No branchcode argument passed to Koha::Calendar->new';
38 my $branch = $self->{branchcode};
39 my $dbh = C4::Context->dbh();
40 my $weekly_closed_days_sth = $dbh->prepare(
41 'SELECT weekday FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NOT NULL'
43 $weekly_closed_days_sth->execute( $branch );
44 $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ];
45 while ( my $tuple = $weekly_closed_days_sth->fetchrow_hashref ) {
46 $self->{weekly_closed_days}->[ $tuple->{weekday} ] = 1;
48 my $day_month_closed_days_sth = $dbh->prepare(
49 'SELECT day, month FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NULL'
51 $day_month_closed_days_sth->execute( $branch );
52 $self->{day_month_closed_days} = {};
53 while ( my $tuple = $day_month_closed_days_sth->fetchrow_hashref ) {
54 $self->{day_month_closed_days}->{ $tuple->{month} }->{ $tuple->{day} } =
65 my $key = $self->{branchcode} . "_holidays";
66 my $cache = Koha::Caches->get_instance();
67 my $holidays = $cache->get_from_cache($key);
69 # $holidays looks like:
71 # 20131122 => 1, # single_holiday
72 # 20131123 => 0, # exception_holiday
76 # Populate the cache if necessary
78 my $dbh = C4::Context->dbh;
81 # Add holidays for each branch
82 my $holidays_sth = $dbh->prepare(
83 'SELECT day, month, year, MAX(isexception) FROM special_holidays WHERE branchcode = ? GROUP BY day, month, year'
85 $holidays_sth->execute($self->{branchcode});
87 while ( my ( $day, $month, $year, $exception ) =
88 $holidays_sth->fetchrow )
91 sprintf( "%04d", $year )
92 . sprintf( "%02d", $month )
93 . sprintf( "%02d", $day );
95 $holidays->{$datestring} = $exception ? 0 : 1;
97 $cache->set_in_cache( $key, $holidays, { expiry => 76800 } );
99 return $holidays // {};
103 my ( $self, $startdate, $add_duration, $unit ) = @_;
106 Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->addDuration: days_mode")
107 unless exists $self->{days_mode};
109 # Default to days duration (legacy support I guess)
110 if ( ref $add_duration ne 'DateTime::Duration' ) {
111 $add_duration = DateTime::Duration->new( days => $add_duration );
114 $unit ||= 'days'; # default days ?
116 if ( $unit eq 'hours' ) {
117 # Fixed for legacy support. Should be set as a branch parameter
118 my $return_by_hour = 10;
120 $dt = $self->addHours($startdate, $add_duration, $return_by_hour);
123 $dt = $self->addDays($startdate, $add_duration);
129 my ( $self, $startdate, $hours_duration, $return_by_hour ) = @_;
130 my $base_date = $startdate->clone();
132 $base_date->add_duration($hours_duration);
134 # If we are using the calendar behave for now as if Datedue
135 # was the chosen option (current intended behaviour)
137 Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->addHours: days_mode")
138 unless exists $self->{days_mode};
140 if ( $self->{days_mode} ne 'Days' &&
141 $self->is_holiday($base_date) ) {
143 if ( $hours_duration->is_negative() ) {
144 $base_date = $self->prev_open_days($base_date, 1);
146 $base_date = $self->next_open_days($base_date, 1);
149 $base_date->set_hour($return_by_hour);
157 my ( $self, $startdate, $days_duration ) = @_;
158 my $base_date = $startdate->clone();
160 Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->addDays: days_mode")
161 unless exists $self->{days_mode};
163 if ( $self->{days_mode} eq 'Calendar' ) {
164 # use the calendar to skip all days the library is closed
166 my $days = abs $days_duration->in_units('days');
168 if ( $days_duration->is_negative() ) {
170 $base_date = $self->prev_open_days($base_date, 1);
175 $base_date = $self->next_open_days($base_date, 1);
180 } else { # Days, Datedue or Dayweek
181 # use straight days, then use calendar to push
182 # the date to the next open day as appropriate
183 # if Datedue or Dayweek
184 $base_date->add_duration($days_duration);
186 if ( $self->{days_mode} eq 'Datedue' ||
187 $self->{days_mode} eq 'Dayweek') {
188 # Datedue or Dayweek, then use the calendar to push
189 # the date to the next open day if holiday
190 if ( $self->is_holiday($base_date) ) {
191 my $dow = $base_date->day_of_week;
192 my $days = $days_duration->in_units('days');
193 # Is it a period based on weeks
194 my $push_amt = $days % 7 == 0 ?
195 $self->get_push_amt($base_date) : 1;
196 if ( $days_duration->is_negative() ) {
197 $base_date = $self->prev_open_days($base_date, $push_amt);
199 $base_date = $self->next_open_days($base_date, $push_amt);
209 my ( $self, $base_date) = @_;
211 Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->get_push_amt: days_mode")
212 unless exists $self->{days_mode};
214 my $dow = $base_date->day_of_week;
216 # DateTime object dow (1-7) where Monday is 1
217 # Arrays are 0-based where 0 = Sunday, not 7.
223 # We're using Dayweek useDaysMode option
224 $self->{days_mode} eq 'Dayweek' &&
225 # It's not a permanently closed day
226 !$self->{weekly_closed_days}->[$dow]
231 my ( $self, $dt ) = @_;
233 my $localdt = $dt->clone();
234 my $day = $localdt->day;
235 my $month = $localdt->month;
236 my $ymd = $localdt->ymd('');
238 #Change timezone to "floating" before doing any calculations or comparisons
239 $localdt->set_time_zone("floating");
240 $localdt->truncate( to => 'day' );
242 return $self->_holidays->{$ymd} if defined($self->_holidays->{$ymd});
244 my $dow = $localdt->day_of_week;
246 # DateTime object dow (1-7) where Monday is 1
247 # Arrays are 0-based where 0 = Sunday, not 7.
252 if ( $self->{weekly_closed_days}->[$dow] == 1 ) {
256 if ( exists $self->{day_month_closed_days}->{$month}->{$day} ) {
260 # damn have to go to work after all
265 my ( $self, $dt, $to_add ) = @_;
267 Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->next_open_days: days_mode")
268 unless exists $self->{days_mode};
270 my $base_date = $dt->clone();
272 $base_date->add(days => $to_add);
274 while ( $self->is_holiday($base_date) && $i < OPEN_DAYS_SEARCH_MAX_ITERATIONS ) {
275 my $add_next = $self->get_push_amt($base_date);
276 $base_date->add(days => $add_next);
280 if ( $self->is_holiday($base_date) ) {
281 Koha::Exceptions::Calendar::NoOpenDays->throw(
282 sprintf( 'Unable to find an open day for library %s', $self->{branchcode} ) );
289 my ( $self, $dt, $to_sub ) = @_;
291 Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->get_open_days: days_mode")
292 unless exists $self->{days_mode};
294 my $base_date = $dt->clone();
296 # It feels logical to be passed a positive number, though we're
297 # subtracting, so do the right thing
298 $to_sub = $to_sub > 0 ? 0 - $to_sub : $to_sub;
300 $base_date->add(days => $to_sub);
303 while ( $self->is_holiday($base_date) && $i < OPEN_DAYS_SEARCH_MAX_ITERATIONS ) {
304 my $sub_next = $self->get_push_amt($base_date);
305 # Ensure we're subtracting when we need to be
306 $sub_next = $sub_next > 0 ? 0 - $sub_next : $sub_next;
307 $base_date->add(days => $sub_next);
311 if ( $self->is_holiday($base_date) ) {
312 Koha::Exceptions::Calendar::NoOpenDays->throw(
313 sprintf( 'Unable to find an open day for library %s', $self->{branchcode} ) );
321 my $start_dt = shift;
322 my $num_days = shift;
324 Koha::Exceptions::MissingParameter->throw("Missing mandatory option for Koha:Calendar->days_forward: days_mode")
325 unless exists $self->{days_mode};
327 return $start_dt unless $num_days > 0;
329 my $base_dt = $start_dt->clone();
331 while ($num_days--) {
332 $base_dt = $self->next_open_days($base_dt, 1);
340 my $start_dt = shift;
343 # Change time zone for date math and swap if needed
344 $start_dt = $start_dt->clone->set_time_zone('floating');
345 $end_dt = $end_dt->clone->set_time_zone('floating');
346 if( $start_dt->compare($end_dt) > 0 ) {
347 ( $start_dt, $end_dt ) = ( $end_dt, $start_dt );
350 # start and end should not be closed days
351 my $delta_days = $start_dt->delta_days($end_dt)->delta_days;
352 while( $start_dt->compare($end_dt) < 1 ) {
353 $delta_days-- if $self->is_holiday($start_dt);
354 $start_dt->add( days => 1 );
356 return DateTime::Duration->new( days => $delta_days );
360 my ($self, $start_date, $end_date) = @_;
361 my $start_dt = $start_date->clone()->set_time_zone('floating');
362 my $end_dt = $end_date->clone()->set_time_zone('floating');
364 my $duration = $end_dt->delta_ms($start_dt);
365 $start_dt->truncate( to => 'day' );
366 $end_dt->truncate( to => 'day' );
368 # NB this is a kludge in that it assumes all days are 24 hours
369 # However for hourly loans the logic should be expanded to
370 # take into account open/close times then it would be a duration
371 # of library open hours
372 my $skipped_days = 0;
373 while( $start_dt->compare($end_dt) < 1 ) {
374 $skipped_days++ if $self->is_holiday($start_dt);
375 $start_dt->add( days => 1 );
379 $duration->subtract_duration(DateTime::Duration->new( hours => 24 * $skipped_days));
386 my ( $self, $mode ) = @_;
388 # if not testing this is a no op
389 if ( $self->{test} ) {
390 $self->{days_mode} = $mode;
396 sub clear_weekly_closed_days {
398 $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ]; # Sunday only
407 Koha::Calendar - Object containing a branches calendar
413 my $c = Koha::Calendar->new( branchcode => 'MAIN' );
414 my $dt = dt_from_string();
417 $open = $c->is_holiday($dt);
418 # when will item be due if loan period = $dur (a DateTime::Duration object)
419 $duedate = $c->addDuration($dt,$dur,'days');
424 Implements those features of C4::Calendar needed for Staffs Rolling Loans
428 =head2 new : Create a calendar object
430 my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
432 The option branchcode is required
437 my $dt = $calendar->addDuration($date, $dur, $unit)
439 C<$date> is a DateTime object representing the starting date of the interval.
441 C<$offset> is a DateTime::Duration to add to it
443 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
445 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
446 parameter will be removed when issuingrules properly cope with that
451 my $dt = $calendar->addHours($date, $dur, $return_by_hour )
453 C<$date> is a DateTime object representing the starting date of the interval.
455 C<$offset> is a DateTime::Duration to add to it
457 C<$return_by_hour> is an integer value representing the opening hour for the branch
461 my $amt = $calendar->get_push_amt($date)
463 C<$date> is a DateTime object representing a closed return date
465 Using the days_mode syspref value and the nature of the closed return
466 date, return how many days we should jump forward to find another return date
470 my $dt = $calendar->addDays($date, $dur)
472 C<$date> is a DateTime object representing the starting date of the interval.
474 C<$offset> is a DateTime::Duration to add to it
476 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
478 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
479 parameter will be removed when issuingrules properly cope with that
483 $yesno = $calendar->is_holiday($dt);
485 passed a DateTime object returns 1 if it is a closed day
486 0 if not according to the calendar
490 $duration = $calendar->days_between($start_dt, $end_dt);
492 Passed two dates returns a DateTime::Duration object measuring the length between them
493 ignoring closed days. Always returns a positive number irrespective of the
494 relative order of the parameters.
496 Note: This routine assumes neither the passed start_dt nor end_dt can be a closed day
500 $duration = $calendar->hours_between($start_dt, $end_dt);
502 Passed two dates returns a DateTime::Duration object measuring the length between them
503 ignoring closed days. Always returns a positive number irrespective of the
504 relative order of the parameters.
506 Note: This routine assumes neither the passed start_dt nor end_dt can be a closed day
508 =head2 next_open_days
510 $datetime = $calendar->next_open_days($duedate_dt, $to_add)
512 Passed a Datetime and number of days, returns another Datetime representing
513 the next open day after adding the passed number of days. It is intended for
514 use to calculate the due date when useDaysMode syspref is set to either
515 'Datedue', 'Calendar' or 'Dayweek'.
517 =head2 prev_open_days
519 $datetime = $calendar->prev_open_days($duedate_dt, $to_sub)
521 Passed a Datetime and a number of days, returns another Datetime
522 representing the previous open day after subtracting the number of passed
523 days. It is intended for use to calculate the due date when useDaysMode
524 syspref is set to either 'Datedue', 'Calendar' or 'Dayweek'.
528 $datetime = $calendar->days_forward($start_dt, $to_add)
530 Passed a Datetime and number of days, returns another Datetime representing
531 the next open day after adding the passed number of days. It is intended for
532 use to calculate the due date when useDaysMode syspref is set to either
533 'Datedue', 'Calendar' or 'Dayweek'.
537 For testing only allows the calling script to change days mode
539 =head2 clear_weekly_closed_days
541 In test mode changes the testing set of closed days to a new set with
542 no closed days. TODO passing an array of closed days to this would
543 allow testing of more configurations
547 Passed a datetime object this will add it to the calendar's list of
548 closed days. This is for testing so that we can alter the Calenfar object's
549 list of specified dates
553 Will croak if not passed a branchcode in new
555 =head1 BUGS AND LIMITATIONS
557 This only contains a limited subset of the functionality in C4::Calendar
558 Only enough to support Staffs Rolling loans
562 Colin Campbell colin.campbell@ptfs-europe.com
564 =head1 LICENSE AND COPYRIGHT
566 Copyright (c) 2011 PTFS-Europe Ltd All rights reserved
568 Koha is free software; you can redistribute it and/or modify it
569 under the terms of the GNU General Public License as published by
570 the Free Software Foundation; either version 3 of the License, or
571 (at your option) any later version.
573 Koha is distributed in the hope that it will be useful, but
574 WITHOUT ANY WARRANTY; without even the implied warranty of
575 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
576 GNU General Public License for more details.
578 You should have received a copy of the GNU General Public License
579 along with Koha; if not, see <http://www.gnu.org/licenses>.