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