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