Bug 16693: Remove reserve/renewscript.pl
[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 VERSION
377
378 This documentation refers to Koha::Calendar version 0.0.1
379
380 =head1 SYNOPSIS
381
382   use Koha::Calendar
383
384   my $c = Koha::Calendar->new( branchcode => 'MAIN' );
385   my $dt = DateTime->now();
386
387   # are we open
388   $open = $c->is_holiday($dt);
389   # when will item be due if loan period = $dur (a DateTime::Duration object)
390   $duedate = $c->addDate($dt,$dur,'days');
391
392
393 =head1 DESCRIPTION
394
395   Implements those features of C4::Calendar needed for Staffs Rolling Loans
396
397 =head1 METHODS
398
399 =head2 new : Create a calendar object
400
401 my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
402
403 The option branchcode is required
404
405
406 =head2 addDate
407
408     my $dt = $calendar->addDate($date, $dur, $unit)
409
410 C<$date> is a DateTime object representing the starting date of the interval.
411
412 C<$offset> is a DateTime::Duration to add to it
413
414 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
415
416 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
417 parameter will be removed when issuingrules properly cope with that
418
419
420 =head2 addHours
421
422     my $dt = $calendar->addHours($date, $dur, $return_by_hour )
423
424 C<$date> is a DateTime object representing the starting date of the interval.
425
426 C<$offset> is a DateTime::Duration to add to it
427
428 C<$return_by_hour> is an integer value representing the opening hour for the branch
429
430
431 =head2 addDays
432
433     my $dt = $calendar->addDays($date, $dur)
434
435 C<$date> is a DateTime object representing the starting date of the interval.
436
437 C<$offset> is a DateTime::Duration to add to it
438
439 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
440
441 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
442 parameter will be removed when issuingrules properly cope with that
443
444
445 =head2 single_holidays
446
447 my $rc = $self->single_holidays(  $ymd  );
448
449 Passed a $date in Ymd (yyyymmdd) format -  returns 1 if date is a single_holiday, or 0 if not.
450
451
452 =head2 is_holiday
453
454 $yesno = $calendar->is_holiday($dt);
455
456 passed a DateTime object returns 1 if it is a closed day
457 0 if not according to the calendar
458
459 =head2 days_between
460
461 $duration = $calendar->days_between($start_dt, $end_dt);
462
463 Passed two dates returns a DateTime::Duration object measuring the length between them
464 ignoring closed days. Always returns a positive number irrespective of the
465 relative order of the parameters
466
467 =head2 next_open_day
468
469 $datetime = $calendar->next_open_day($duedate_dt)
470
471 Passed a Datetime returns another Datetime representing the next open day. It is
472 intended for use to calculate the due date when useDaysMode syspref is set to either
473 'Datedue' or 'Calendar'.
474
475 =head2 prev_open_day
476
477 $datetime = $calendar->prev_open_day($duedate_dt)
478
479 Passed a Datetime returns another Datetime representing the previous open day. It is
480 intended for use to calculate the due date when useDaysMode syspref is set to either
481 'Datedue' or 'Calendar'.
482
483 =head2 set_daysmode
484
485 For testing only allows the calling script to change days mode
486
487 =head2 clear_weekly_closed_days
488
489 In test mode changes the testing set of closed days to a new set with
490 no closed days. TODO passing an array of closed days to this would
491 allow testing of more configurations
492
493 =head2 add_holiday
494
495 Passed a datetime object this will add it to the calendar's list of
496 closed days. This is for testing so that we can alter the Calenfar object's
497 list of specified dates
498
499 =head1 DIAGNOSTICS
500
501 Will croak if not passed a branchcode in new
502
503 =head1 BUGS AND LIMITATIONS
504
505 This only contains a limited subset of the functionality in C4::Calendar
506 Only enough to support Staffs Rolling loans
507
508 =head1 AUTHOR
509
510 Colin Campbell colin.campbell@ptfs-europe.com
511
512 =head1 LICENSE AND COPYRIGHT
513
514 Copyright (c) 2011 PTFS-Europe Ltd All rights reserved
515
516 This program is free software: you can redistribute it and/or modify
517 it under the terms of the GNU General Public License as published by
518 the Free Software Foundation, either version 2 of the License, or
519 (at your option) any later version.
520
521 This program is distributed in the hope that it will be useful,
522 but WITHOUT ANY WARRANTY; without even the implied warranty of
523 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
524 GNU General Public License for more details.
525
526 You should have received a copy of the GNU General Public License
527 along with this program.  If not, see <http://www.gnu.org/licenses/>.