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