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