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