Bug 15260: Implement weekly rollforward
[koha.git] / Koha / Calendar.pm
1 package Koha::Calendar;
2 use strict;
3 use warnings;
4 use 5.010;
5
6 use DateTime;
7 use DateTime::Set;
8 use DateTime::Duration;
9 use C4::Context;
10 use Koha::Caches;
11 use Carp;
12
13 sub new {
14     my ( $classname, %options ) = @_;
15     my $self = {};
16     bless $self, $classname;
17     for my $o_name ( keys %options ) {
18         my $o = lc $o_name;
19         $self->{$o} = $options{$o_name};
20     }
21     if ( !defined $self->{branchcode} ) {
22         croak 'No branchcode argument passed to Koha::Calendar->new';
23     }
24     $self->_init();
25     return $self;
26 }
27
28 sub _init {
29     my $self       = shift;
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'
34     );
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;
39     }
40     my $day_month_closed_days_sth = $dbh->prepare(
41 'SELECT day, month FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NULL'
42     );
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} } =
47           1;
48     }
49
50     $self->{days_mode}       ||= C4::Context->preference('useDaysMode');
51     $self->{test}            = 0;
52     return;
53 }
54
55 sub exception_holidays {
56     my ( $self ) = @_;
57
58     my $cache  = Koha::Caches->get_instance();
59     my $cached = $cache->get_from_cache('exception_holidays');
60     return $cached if $cached;
61
62     my $dbh = C4::Context->dbh;
63     my $branch = $self->{branchcode};
64     my $exception_holidays_sth = $dbh->prepare(
65 'SELECT day, month, year FROM special_holidays WHERE branchcode = ? AND isexception = 1'
66     );
67     $exception_holidays_sth->execute( $branch );
68     my $dates = [];
69     while ( my ( $day, $month, $year ) = $exception_holidays_sth->fetchrow ) {
70         push @{$dates},
71           DateTime->new(
72             day       => $day,
73             month     => $month,
74             year      => $year,
75             time_zone => "floating",
76           )->truncate( to => 'day' );
77     }
78     $self->{exception_holidays} =
79       DateTime::Set->from_datetimes( dates => $dates );
80     $cache->set_in_cache( 'exception_holidays', $self->{exception_holidays} );
81     return $self->{exception_holidays};
82 }
83
84 sub single_holidays {
85     my ( $self, $date ) = @_;
86     my $branchcode = $self->{branchcode};
87     my $cache           = Koha::Caches->get_instance();
88     my $single_holidays = $cache->get_from_cache('single_holidays');
89
90     # $single_holidays looks like:
91     # {
92     #   CPL =>  [
93     #        [0] 20131122,
94     #         ...
95     #    ],
96     #   ...
97     # }
98
99     unless ($single_holidays) {
100         my $dbh = C4::Context->dbh;
101         $single_holidays = {};
102
103         # push holidays for each branch
104         my $branches_sth =
105           $dbh->prepare('SELECT distinct(branchcode) FROM special_holidays');
106         $branches_sth->execute();
107         while ( my $br = $branches_sth->fetchrow ) {
108             my $single_holidays_sth = $dbh->prepare(
109 'SELECT day, month, year FROM special_holidays WHERE branchcode = ? AND isexception = 0'
110             );
111             $single_holidays_sth->execute($br);
112
113             my @ymd_arr;
114             while ( my ( $day, $month, $year ) =
115                 $single_holidays_sth->fetchrow )
116             {
117                 my $dt = DateTime->new(
118                     day       => $day,
119                     month     => $month,
120                     year      => $year,
121                     time_zone => 'floating',
122                 )->truncate( to => 'day' );
123                 push @ymd_arr, $dt->ymd('');
124             }
125             $single_holidays->{$br} = \@ymd_arr;
126         }    # br
127         $cache->set_in_cache( 'single_holidays', $single_holidays,
128             { expiry => 76800 } )    #24 hrs ;
129     }
130     my $holidays  = ( $single_holidays->{$branchcode} );
131     for my $hols  (@$holidays ) {
132             return 1 if ( $date == $hols )   #match ymds;
133     }
134     return 0;
135 }
136
137 sub addDate {
138     my ( $self, $startdate, $add_duration, $unit ) = @_;
139
140     # Default to days duration (legacy support I guess)
141     if ( ref $add_duration ne 'DateTime::Duration' ) {
142         $add_duration = DateTime::Duration->new( days => $add_duration );
143     }
144
145     $unit ||= 'days'; # default days ?
146     my $dt;
147     if ( $unit eq 'hours' ) {
148         # Fixed for legacy support. Should be set as a branch parameter
149         my $return_by_hour = 10;
150
151         $dt = $self->addHours($startdate, $add_duration, $return_by_hour);
152     } else {
153         # days
154         $dt = $self->addDays($startdate, $add_duration);
155     }
156     return $dt;
157 }
158
159 sub addHours {
160     my ( $self, $startdate, $hours_duration, $return_by_hour ) = @_;
161     my $base_date = $startdate->clone();
162
163     $base_date->add_duration($hours_duration);
164
165     # If we are using the calendar behave for now as if Datedue
166     # was the chosen option (current intended behaviour)
167
168     if ( $self->{days_mode} ne 'Days' &&
169           $self->is_holiday($base_date) ) {
170
171         if ( $hours_duration->is_negative() ) {
172             $base_date = $self->prev_open_days($base_date, 1);
173         } else {
174             $base_date = $self->next_open_days($base_date, 1);
175         }
176
177         $base_date->set_hour($return_by_hour);
178
179     }
180
181     return $base_date;
182 }
183
184 sub addDays {
185     my ( $self, $startdate, $days_duration ) = @_;
186     my $base_date = $startdate->clone();
187
188     $self->{days_mode} ||= q{};
189
190     if ( $self->{days_mode} eq 'Calendar' ) {
191         # use the calendar to skip all days the library is closed
192         # when adding
193         my $days = abs $days_duration->in_units('days');
194
195         if ( $days_duration->is_negative() ) {
196             while ($days) {
197                 $base_date = $self->prev_open_days($base_date, 1);
198                 --$days;
199             }
200         } else {
201             while ($days) {
202                 $base_date = $self->next_open_days($base_date, 1);
203                 --$days;
204             }
205         }
206
207     } else { # Days, Datedue or Dayweek
208         # use straight days, then use calendar to push
209         # the date to the next open day as appropriate
210         # if Datedue or Dayweek
211         $base_date->add_duration($days_duration);
212
213         if ( $self->{days_mode} eq 'Datedue' ||
214             $self->{days_mode} eq 'Dayweek') {
215             # Datedue or Dayweek, then use the calendar to push
216             # the date to the next open day if holiday
217             if ( $self->is_holiday($base_date) ) {
218                 my $dow = $base_date->day_of_week;
219                 my $days = $days_duration->in_units('days');
220                 my $push_amt = (
221                     # We're using Dayweek useDaysMode option
222                     $self->{days_mode} eq 'Dayweek' &&
223                     # It's period based on weeks
224                     $days % 7 == 0 &&
225                     # It's not a permanently closed day
226                     !$self->{weekly_closed_days}->[$dow] == 1
227                 ) ? 7 : 1;
228                 if ( $days_duration->is_negative() ) {
229                     $base_date = $self->prev_open_days($base_date, $push_amt);
230                 } else {
231                     $base_date = $self->next_open_days($base_date, $push_amt);
232                 }
233             }
234         }
235     }
236
237     return $base_date;
238 }
239
240 sub is_holiday {
241     my ( $self, $dt ) = @_;
242
243     my $localdt = $dt->clone();
244     my $day   = $localdt->day;
245     my $month = $localdt->month;
246
247     #Change timezone to "floating" before doing any calculations or comparisons
248     $localdt->set_time_zone("floating");
249     $localdt->truncate( to => 'day' );
250
251
252     if ( $self->exception_holidays->contains($localdt) ) {
253         # exceptions are not holidays
254         return 0;
255     }
256
257     my $dow = $localdt->day_of_week;
258     # Representation fix
259     # DateTime object dow (1-7) where Monday is 1
260     # Arrays are 0-based where 0 = Sunday, not 7.
261     if ( $dow == 7 ) {
262         $dow = 0;
263     }
264
265     if ( $self->{weekly_closed_days}->[$dow] == 1 ) {
266         return 1;
267     }
268
269     if ( exists $self->{day_month_closed_days}->{$month}->{$day} ) {
270         return 1;
271     }
272
273     my $ymd   = $localdt->ymd('')  ;
274     if ($self->single_holidays(  $ymd  ) == 1 ) {
275         return 1;
276     }
277
278     # damn have to go to work after all
279     return 0;
280 }
281
282 sub next_open_days {
283     my ( $self, $dt, $to_add ) = @_;
284     my $base_date = $dt->clone();
285
286     $base_date->add(days => $to_add);
287     while ($self->is_holiday($base_date)) {
288         $base_date->add(days => $to_add);
289     }
290     return $base_date;
291 }
292
293 sub prev_open_days {
294     my ( $self, $dt, $to_sub ) = @_;
295     my $base_date = $dt->clone();
296
297     # It feels logical to be passed a positive number, though we're
298     # subtracting, so do the right thing
299     $to_sub = $to_sub > 0 ? 0 - $to_sub : $to_sub;
300
301     $base_date->add(days => $to_sub);
302
303     while ($self->is_holiday($base_date)) {
304         $base_date->add(days => $to_sub);
305     }
306
307     return $base_date;
308 }
309
310 sub days_forward {
311     my $self     = shift;
312     my $start_dt = shift;
313     my $num_days = shift;
314
315     return $start_dt unless $num_days > 0;
316
317     my $base_dt = $start_dt->clone();
318
319     while ($num_days--) {
320         $base_dt = $self->next_open_days($base_dt, 1);
321     }
322
323     return $base_dt;
324 }
325
326 sub days_between {
327     my $self     = shift;
328     my $start_dt = shift;
329     my $end_dt   = shift;
330
331     # Change time zone for date math and swap if needed
332     $start_dt = $start_dt->clone->set_time_zone('floating');
333     $end_dt = $end_dt->clone->set_time_zone('floating');
334     if( $start_dt->compare($end_dt) > 0 ) {
335         ( $start_dt, $end_dt ) = ( $end_dt, $start_dt );
336     }
337
338     # start and end should not be closed days
339     my $days = $start_dt->delta_days($end_dt)->delta_days;
340     while( $start_dt->compare($end_dt) < 1 ) {
341         $days-- if $self->is_holiday($start_dt);
342         $start_dt->add( days => 1 );
343     }
344     return DateTime::Duration->new( days => $days );
345 }
346
347 sub hours_between {
348     my ($self, $start_date, $end_date) = @_;
349     my $start_dt = $start_date->clone()->set_time_zone('floating');
350     my $end_dt = $end_date->clone()->set_time_zone('floating');
351     my $duration = $end_dt->delta_ms($start_dt);
352     $start_dt->truncate( to => 'day' );
353     $end_dt->truncate( to => 'day' );
354     # NB this is a kludge in that it assumes all days are 24 hours
355     # However for hourly loans the logic should be expanded to
356     # take into account open/close times then it would be a duration
357     # of library open hours
358     # start and end should not be closed days
359     my $skipped_days = 0;
360     while( $start_dt->compare($end_dt) < 1 ) {
361         $start_dt->add( days => 1 );
362         $skipped_days++ if $self->is_holiday($start_dt);
363     }
364     if ($skipped_days) {
365         $duration->subtract_duration(DateTime::Duration->new( hours => 24 * $skipped_days));
366     }
367
368     return $duration;
369 }
370
371 sub set_daysmode {
372     my ( $self, $mode ) = @_;
373
374     # if not testing this is a no op
375     if ( $self->{test} ) {
376         $self->{days_mode} = $mode;
377     }
378
379     return;
380 }
381
382 sub clear_weekly_closed_days {
383     my $self = shift;
384     $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ];    # Sunday only
385     return;
386 }
387
388 1;
389 __END__
390
391 =head1 NAME
392
393 Koha::Calendar - Object containing a branches calendar
394
395 =head1 SYNOPSIS
396
397   use Koha::Calendar
398
399   my $c = Koha::Calendar->new( branchcode => 'MAIN' );
400   my $dt = DateTime->now();
401
402   # are we open
403   $open = $c->is_holiday($dt);
404   # when will item be due if loan period = $dur (a DateTime::Duration object)
405   $duedate = $c->addDate($dt,$dur,'days');
406
407
408 =head1 DESCRIPTION
409
410   Implements those features of C4::Calendar needed for Staffs Rolling Loans
411
412 =head1 METHODS
413
414 =head2 new : Create a calendar object
415
416 my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
417
418 The option branchcode is required
419
420
421 =head2 addDate
422
423     my $dt = $calendar->addDate($date, $dur, $unit)
424
425 C<$date> is a DateTime object representing the starting date of the interval.
426
427 C<$offset> is a DateTime::Duration to add to it
428
429 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
430
431 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
432 parameter will be removed when issuingrules properly cope with that
433
434
435 =head2 addHours
436
437     my $dt = $calendar->addHours($date, $dur, $return_by_hour )
438
439 C<$date> is a DateTime object representing the starting date of the interval.
440
441 C<$offset> is a DateTime::Duration to add to it
442
443 C<$return_by_hour> is an integer value representing the opening hour for the branch
444
445
446 =head2 addDays
447
448     my $dt = $calendar->addDays($date, $dur)
449
450 C<$date> is a DateTime object representing the starting date of the interval.
451
452 C<$offset> is a DateTime::Duration to add to it
453
454 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
455
456 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
457 parameter will be removed when issuingrules properly cope with that
458
459
460 =head2 single_holidays
461
462 my $rc = $self->single_holidays(  $ymd  );
463
464 Passed a $date in Ymd (yyyymmdd) format -  returns 1 if date is a single_holiday, or 0 if not.
465
466
467 =head2 is_holiday
468
469 $yesno = $calendar->is_holiday($dt);
470
471 passed a DateTime object returns 1 if it is a closed day
472 0 if not according to the calendar
473
474 =head2 days_between
475
476 $duration = $calendar->days_between($start_dt, $end_dt);
477
478 Passed two dates returns a DateTime::Duration object measuring the length between them
479 ignoring closed days. Always returns a positive number irrespective of the
480 relative order of the parameters
481
482 =head2 next_open_days
483
484 $datetime = $calendar->next_open_days($duedate_dt, $to_add)
485
486 Passed a Datetime and number of days,  returns another Datetime representing
487 the next open day after adding the passed number of days. It is intended for
488 use to calculate the due date when useDaysMode syspref is set to either
489 'Datedue', 'Calendar' or 'Dayweek'.
490
491 =head2 prev_open_days
492
493 $datetime = $calendar->prev_open_days($duedate_dt, $to_sub)
494
495 Passed a Datetime and a number of days, returns another Datetime
496 representing the previous open day after subtracting the number of passed
497 days. It is intended for use to calculate the due date when useDaysMode
498 syspref is set to either 'Datedue', 'Calendar' or 'Dayweek'.
499
500 =head2 set_daysmode
501
502 For testing only allows the calling script to change days mode
503
504 =head2 clear_weekly_closed_days
505
506 In test mode changes the testing set of closed days to a new set with
507 no closed days. TODO passing an array of closed days to this would
508 allow testing of more configurations
509
510 =head2 add_holiday
511
512 Passed a datetime object this will add it to the calendar's list of
513 closed days. This is for testing so that we can alter the Calenfar object's
514 list of specified dates
515
516 =head1 DIAGNOSTICS
517
518 Will croak if not passed a branchcode in new
519
520 =head1 BUGS AND LIMITATIONS
521
522 This only contains a limited subset of the functionality in C4::Calendar
523 Only enough to support Staffs Rolling loans
524
525 =head1 AUTHOR
526
527 Colin Campbell colin.campbell@ptfs-europe.com
528
529 =head1 LICENSE AND COPYRIGHT
530
531 Copyright (c) 2011 PTFS-Europe Ltd All rights reserved
532
533 This program is free software: you can redistribute it and/or modify
534 it under the terms of the GNU General Public License as published by
535 the Free Software Foundation, either version 2 of the License, or
536 (at your option) any later version.
537
538 This program is distributed in the hope that it will be useful,
539 but WITHOUT ANY WARRANTY; without even the implied warranty of
540 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
541 GNU General Public License for more details.
542
543 You should have received a copy of the GNU General Public License
544 along with this program.  If not, see <http://www.gnu.org/licenses/>.