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