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