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