Bug 14522: (QA followup) tidy tests
[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 1;
387 __END__
388
389 =head1 NAME
390
391 Koha::Calendar - Object containing a branches calendar
392
393 =head1 VERSION
394
395 This documentation refers to Koha::Calendar version 0.0.1
396
397 =head1 SYNOPSIS
398
399   use Koha::Calendar
400
401   my $c = Koha::Calendar->new( branchcode => 'MAIN' );
402   my $dt = DateTime->now();
403
404   # are we open
405   $open = $c->is_holiday($dt);
406   # when will item be due if loan period = $dur (a DateTime::Duration object)
407   $duedate = $c->addDate($dt,$dur,'days');
408
409
410 =head1 DESCRIPTION
411
412   Implements those features of C4::Calendar needed for Staffs Rolling Loans
413
414 =head1 METHODS
415
416 =head2 new : Create a calendar object
417
418 my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
419
420 The option branchcode is required
421
422
423 =head2 addDate
424
425     my $dt = $calendar->addDate($date, $dur, $unit)
426
427 C<$date> is a DateTime object representing the starting date of the interval.
428
429 C<$offset> is a DateTime::Duration to add to it
430
431 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
432
433 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
434 parameter will be removed when issuingrules properly cope with that
435
436
437 =head2 addHours
438
439     my $dt = $calendar->addHours($date, $dur, $return_by_hour )
440
441 C<$date> is a DateTime object representing the starting date of the interval.
442
443 C<$offset> is a DateTime::Duration to add to it
444
445 C<$return_by_hour> is an integer value representing the opening hour for the branch
446
447
448 =head2 addDays
449
450     my $dt = $calendar->addDays($date, $dur)
451
452 C<$date> is a DateTime object representing the starting date of the interval.
453
454 C<$offset> is a DateTime::Duration to add to it
455
456 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
457
458 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
459 parameter will be removed when issuingrules properly cope with that
460
461
462 =head2 single_holidays
463
464 my $rc = $self->single_holidays(  $ymd  );
465
466 Passed a $date in Ymd (yyyymmdd) format -  returns 1 if date is a single_holiday, or 0 if not.
467
468
469 =head2 is_holiday
470
471 $yesno = $calendar->is_holiday($dt);
472
473 passed a DateTime object returns 1 if it is a closed day
474 0 if not according to the calendar
475
476 =head2 days_between
477
478 $duration = $calendar->days_between($start_dt, $end_dt);
479
480 Passed two dates returns a DateTime::Duration object measuring the length between them
481 ignoring closed days. Always returns a positive number irrespective of the
482 relative order of the parameters
483
484 =head2 next_open_day
485
486 $datetime = $calendar->next_open_day($duedate_dt)
487
488 Passed a Datetime returns another Datetime representing the next open day. It is
489 intended for use to calculate the due date when useDaysMode syspref is set to either
490 'Datedue' or 'Calendar'.
491
492 =head2 prev_open_day
493
494 $datetime = $calendar->prev_open_day($duedate_dt)
495
496 Passed a Datetime returns another Datetime representing the previous open day. It is
497 intended for use to calculate the due date when useDaysMode syspref is set to either
498 'Datedue' or 'Calendar'.
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/>.