1 package Koha::Calendar;
8 use DateTime::Duration;
14 my ( $classname, %options ) = @_;
16 bless $self, $classname;
17 for my $o_name ( keys %options ) {
19 $self->{$o} = $options{$o_name};
21 if ( !defined $self->{branchcode} ) {
22 croak 'No branchcode argument passed to Koha::Calendar->new';
30 my $branch = $self->{branchcode};
31 my $dbh = C4::Context->dbh();
32 my $weekly_closed_days_sth = $dbh->prepare(
33 'SELECT weekday FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NOT NULL'
35 $weekly_closed_days_sth->execute( $branch );
36 $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ];
37 while ( my $tuple = $weekly_closed_days_sth->fetchrow_hashref ) {
38 $self->{weekly_closed_days}->[ $tuple->{weekday} ] = 1;
40 my $day_month_closed_days_sth = $dbh->prepare(
41 'SELECT day, month FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NULL'
43 $day_month_closed_days_sth->execute( $branch );
44 $self->{day_month_closed_days} = {};
45 while ( my $tuple = $day_month_closed_days_sth->fetchrow_hashref ) {
46 $self->{day_month_closed_days}->{ $tuple->{month} }->{ $tuple->{day} } =
50 $self->{days_mode} ||= C4::Context->preference('useDaysMode');
55 sub exception_holidays {
58 my $branch = $self->{branchcode};
59 my $cache = Koha::Caches->get_instance();
60 my $key = 'exception_holidays_'.$branch;
61 my $cached = $cache->get_from_cache($key);
62 return $cached if $cached;
64 my $dbh = C4::Context->dbh;
65 my $exception_holidays_sth = $dbh->prepare(
66 'SELECT day, month, year FROM special_holidays WHERE branchcode = ? AND isexception = 1'
68 $exception_holidays_sth->execute( $branch );
70 while ( my ( $day, $month, $year ) = $exception_holidays_sth->fetchrow ) {
76 time_zone => "floating",
77 )->truncate( to => 'day' );
79 $self->{exception_holidays} =
80 DateTime::Set->from_datetimes( dates => $dates );
81 $cache->set_in_cache( $key, $self->{exception_holidays} );
82 return $self->{exception_holidays};
86 my ( $self, $date ) = @_;
87 my $branchcode = $self->{branchcode};
88 my $cache = Koha::Caches->get_instance();
89 my $single_holidays = $cache->get_from_cache('single_holidays');
91 # $single_holidays looks like:
100 unless ($single_holidays) {
101 my $dbh = C4::Context->dbh;
102 $single_holidays = {};
104 # push holidays for each branch
106 $dbh->prepare('SELECT distinct(branchcode) FROM special_holidays');
107 $branches_sth->execute();
108 while ( my $br = $branches_sth->fetchrow ) {
109 my $single_holidays_sth = $dbh->prepare(
110 'SELECT day, month, year FROM special_holidays WHERE branchcode = ? AND isexception = 0'
112 $single_holidays_sth->execute($br);
115 while ( my ( $day, $month, $year ) =
116 $single_holidays_sth->fetchrow )
118 my $dt = DateTime->new(
122 time_zone => 'floating',
123 )->truncate( to => 'day' );
124 push @ymd_arr, $dt->ymd('');
126 $single_holidays->{$br} = \@ymd_arr;
128 $cache->set_in_cache( 'single_holidays', $single_holidays,
129 { expiry => 76800 } ) #24 hrs ;
131 my $holidays = ( $single_holidays->{$branchcode} );
132 for my $hols (@$holidays ) {
133 return 1 if ( $date == $hols ) #match ymds;
139 my ( $self, $startdate, $add_duration, $unit ) = @_;
141 # Default to days duration (legacy support I guess)
142 if ( ref $add_duration ne 'DateTime::Duration' ) {
143 $add_duration = DateTime::Duration->new( days => $add_duration );
146 $unit ||= 'days'; # default days ?
148 if ( $unit eq 'hours' ) {
149 # Fixed for legacy support. Should be set as a branch parameter
150 my $return_by_hour = 10;
152 $dt = $self->addHours($startdate, $add_duration, $return_by_hour);
155 $dt = $self->addDays($startdate, $add_duration);
161 my ( $self, $startdate, $hours_duration, $return_by_hour ) = @_;
162 my $base_date = $startdate->clone();
164 $base_date->add_duration($hours_duration);
166 # If we are using the calendar behave for now as if Datedue
167 # was the chosen option (current intended behaviour)
169 if ( $self->{days_mode} ne 'Days' &&
170 $self->is_holiday($base_date) ) {
172 if ( $hours_duration->is_negative() ) {
173 $base_date = $self->prev_open_days($base_date, 1);
175 $base_date = $self->next_open_days($base_date, 1);
178 $base_date->set_hour($return_by_hour);
186 my ( $self, $startdate, $days_duration ) = @_;
187 my $base_date = $startdate->clone();
189 $self->{days_mode} ||= q{};
191 if ( $self->{days_mode} eq 'Calendar' ) {
192 # use the calendar to skip all days the library is closed
194 my $days = abs $days_duration->in_units('days');
196 if ( $days_duration->is_negative() ) {
198 $base_date = $self->prev_open_days($base_date, 1);
203 $base_date = $self->next_open_days($base_date, 1);
208 } else { # Days, Datedue or Dayweek
209 # use straight days, then use calendar to push
210 # the date to the next open day as appropriate
211 # if Datedue or Dayweek
212 $base_date->add_duration($days_duration);
214 if ( $self->{days_mode} eq 'Datedue' ||
215 $self->{days_mode} eq 'Dayweek') {
216 # Datedue or Dayweek, then use the calendar to push
217 # the date to the next open day if holiday
218 if ( $self->is_holiday($base_date) ) {
219 my $dow = $base_date->day_of_week;
220 my $days = $days_duration->in_units('days');
221 # Is it a period based on weeks
222 my $push_amt = $days % 7 == 0 ?
223 $self->get_push_amt($base_date) : 1;
224 if ( $days_duration->is_negative() ) {
225 $base_date = $self->prev_open_days($base_date, $push_amt);
227 $base_date = $self->next_open_days($base_date, $push_amt);
237 my ( $self, $base_date) = @_;
239 my $dow = $base_date->day_of_week;
241 # DateTime object dow (1-7) where Monday is 1
242 # Arrays are 0-based where 0 = Sunday, not 7.
248 # We're using Dayweek useDaysMode option
249 $self->{days_mode} eq 'Dayweek' &&
250 # It's not a permanently closed day
251 !$self->{weekly_closed_days}->[$dow]
256 my ( $self, $dt ) = @_;
258 my $localdt = $dt->clone();
259 my $day = $localdt->day;
260 my $month = $localdt->month;
262 #Change timezone to "floating" before doing any calculations or comparisons
263 $localdt->set_time_zone("floating");
264 $localdt->truncate( to => 'day' );
267 if ( $self->exception_holidays->contains($localdt) ) {
268 # exceptions are not holidays
272 my $dow = $localdt->day_of_week;
274 # DateTime object dow (1-7) where Monday is 1
275 # Arrays are 0-based where 0 = Sunday, not 7.
280 if ( $self->{weekly_closed_days}->[$dow] == 1 ) {
284 if ( exists $self->{day_month_closed_days}->{$month}->{$day} ) {
288 my $ymd = $localdt->ymd('') ;
289 if ($self->single_holidays( $ymd ) == 1 ) {
293 # damn have to go to work after all
298 my ( $self, $dt, $to_add ) = @_;
299 my $base_date = $dt->clone();
301 $base_date->add(days => $to_add);
302 while ($self->is_holiday($base_date)) {
303 my $add_next = $self->get_push_amt($base_date);
304 $base_date->add(days => $add_next);
310 my ( $self, $dt, $to_sub ) = @_;
311 my $base_date = $dt->clone();
313 # It feels logical to be passed a positive number, though we're
314 # subtracting, so do the right thing
315 $to_sub = $to_sub > 0 ? 0 - $to_sub : $to_sub;
317 $base_date->add(days => $to_sub);
319 while ($self->is_holiday($base_date)) {
320 my $sub_next = $self->get_push_amt($base_date);
321 # Ensure we're subtracting when we need to be
322 $sub_next = $sub_next > 0 ? 0 - $sub_next : $sub_next;
323 $base_date->add(days => $sub_next);
331 my $start_dt = shift;
332 my $num_days = shift;
334 return $start_dt unless $num_days > 0;
336 my $base_dt = $start_dt->clone();
338 while ($num_days--) {
339 $base_dt = $self->next_open_days($base_dt, 1);
347 my $start_dt = shift;
350 # Change time zone for date math and swap if needed
351 $start_dt = $start_dt->clone->set_time_zone('floating');
352 $end_dt = $end_dt->clone->set_time_zone('floating');
353 if( $start_dt->compare($end_dt) > 0 ) {
354 ( $start_dt, $end_dt ) = ( $end_dt, $start_dt );
357 # start and end should not be closed days
358 my $delta_days = $start_dt->delta_days($end_dt)->delta_days;
359 while( $start_dt->compare($end_dt) < 1 ) {
360 $delta_days-- if $self->is_holiday($start_dt);
361 $start_dt->add( days => 1 );
363 return DateTime::Duration->new( days => $delta_days );
367 my ($self, $start_date, $end_date) = @_;
368 my $start_dt = $start_date->clone()->set_time_zone('floating');
369 my $end_dt = $end_date->clone()->set_time_zone('floating');
371 my $duration = $end_dt->delta_ms($start_dt);
372 $start_dt->truncate( to => 'day' );
373 $end_dt->truncate( to => 'day' );
375 # NB this is a kludge in that it assumes all days are 24 hours
376 # However for hourly loans the logic should be expanded to
377 # take into account open/close times then it would be a duration
378 # of library open hours
379 my $skipped_days = 0;
380 while( $start_dt->compare($end_dt) < 1 ) {
381 $skipped_days++ if $self->is_holiday($start_dt);
382 $start_dt->add( days => 1 );
386 $duration->subtract_duration(DateTime::Duration->new( hours => 24 * $skipped_days));
393 my ( $self, $mode ) = @_;
395 # if not testing this is a no op
396 if ( $self->{test} ) {
397 $self->{days_mode} = $mode;
403 sub clear_weekly_closed_days {
405 $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ]; # Sunday only
414 Koha::Calendar - Object containing a branches calendar
420 my $c = Koha::Calendar->new( branchcode => 'MAIN' );
421 my $dt = dt_from_string();
424 $open = $c->is_holiday($dt);
425 # when will item be due if loan period = $dur (a DateTime::Duration object)
426 $duedate = $c->addDate($dt,$dur,'days');
431 Implements those features of C4::Calendar needed for Staffs Rolling Loans
435 =head2 new : Create a calendar object
437 my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
439 The option branchcode is required
444 my $dt = $calendar->addDate($date, $dur, $unit)
446 C<$date> is a DateTime object representing the starting date of the interval.
448 C<$offset> is a DateTime::Duration to add to it
450 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
452 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
453 parameter will be removed when issuingrules properly cope with that
458 my $dt = $calendar->addHours($date, $dur, $return_by_hour )
460 C<$date> is a DateTime object representing the starting date of the interval.
462 C<$offset> is a DateTime::Duration to add to it
464 C<$return_by_hour> is an integer value representing the opening hour for the branch
468 my $amt = $calendar->get_push_amt($date)
470 C<$date> is a DateTime object representing a closed return date
472 Using the days_mode syspref value and the nature of the closed return
473 date, return how many days we should jump forward to find another return date
477 my $dt = $calendar->addDays($date, $dur)
479 C<$date> is a DateTime object representing the starting date of the interval.
481 C<$offset> is a DateTime::Duration to add to it
483 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
485 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
486 parameter will be removed when issuingrules properly cope with that
489 =head2 single_holidays
491 my $rc = $self->single_holidays( $ymd );
493 Passed a $date in Ymd (yyyymmdd) format - returns 1 if date is a single_holiday, or 0 if not.
498 $yesno = $calendar->is_holiday($dt);
500 passed a DateTime object returns 1 if it is a closed day
501 0 if not according to the calendar
505 $duration = $calendar->days_between($start_dt, $end_dt);
507 Passed two dates returns a DateTime::Duration object measuring the length between them
508 ignoring closed days. Always returns a positive number irrespective of the
509 relative order of the parameters.
511 Note: This routine assumes neither the passed start_dt nor end_dt can be a closed day
515 $duration = $calendar->hours_between($start_dt, $end_dt);
517 Passed two dates returns a DateTime::Duration object measuring the length between them
518 ignoring closed days. Always returns a positive number irrespective of the
519 relative order of the parameters.
521 Note: This routine assumes neither the passed start_dt nor end_dt can be a closed day
523 =head2 next_open_days
525 $datetime = $calendar->next_open_days($duedate_dt, $to_add)
527 Passed a Datetime and number of days, returns another Datetime representing
528 the next open day after adding the passed number of days. It is intended for
529 use to calculate the due date when useDaysMode syspref is set to either
530 'Datedue', 'Calendar' or 'Dayweek'.
532 =head2 prev_open_days
534 $datetime = $calendar->prev_open_days($duedate_dt, $to_sub)
536 Passed a Datetime and a number of days, returns another Datetime
537 representing the previous open day after subtracting the number of passed
538 days. It is intended for use to calculate the due date when useDaysMode
539 syspref is set to either 'Datedue', 'Calendar' or 'Dayweek'.
543 For testing only allows the calling script to change days mode
545 =head2 clear_weekly_closed_days
547 In test mode changes the testing set of closed days to a new set with
548 no closed days. TODO passing an array of closed days to this would
549 allow testing of more configurations
553 Passed a datetime object this will add it to the calendar's list of
554 closed days. This is for testing so that we can alter the Calenfar object's
555 list of specified dates
559 Will croak if not passed a branchcode in new
561 =head1 BUGS AND LIMITATIONS
563 This only contains a limited subset of the functionality in C4::Calendar
564 Only enough to support Staffs Rolling loans
568 Colin Campbell colin.campbell@ptfs-europe.com
570 =head1 LICENSE AND COPYRIGHT
572 Copyright (c) 2011 PTFS-Europe Ltd All rights reserved
574 This program is free software: you can redistribute it and/or modify
575 it under the terms of the GNU General Public License as published by
576 the Free Software Foundation, either version 2 of the License, or
577 (at your option) any later version.
579 This program is distributed in the hope that it will be useful,
580 but WITHOUT ANY WARRANTY; without even the implied warranty of
581 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
582 GNU General Public License for more details.
584 You should have received a copy of the GNU General Public License
585 along with this program. If not, see <http://www.gnu.org/licenses/>.