Merge branch 'bug_8945' 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     my $base_date = $startdate->clone();
89     if ( ref $add_duration ne 'DateTime::Duration' ) {
90         $add_duration = DateTime::Duration->new( days => $add_duration );
91     }
92     $unit ||= q{};    # default days ?
93     my $days_mode = $self->{days_mode};
94     Readonly::Scalar my $return_by_hour => 10;
95     my $day_dur = DateTime::Duration->new( days => 1 );
96     if ( $add_duration->is_negative() ) {
97         $day_dur = DateTime::Duration->new( days => -1 );
98     }
99     if ( $days_mode eq 'Datedue' ) {
100
101         my $dt = $base_date + $add_duration;
102         while ( $self->is_holiday($dt) ) {
103
104             $dt->add_duration($day_dur);
105             if ( $unit eq 'hours' ) {
106                 $dt->set_hour($return_by_hour);    # Staffs specific
107             }
108         }
109         return $dt;
110     } elsif ( $days_mode eq 'Calendar' ) {
111         if ( $unit eq 'hours' ) {
112             $base_date->add_duration($add_duration);
113             while ( $self->is_holiday($base_date) ) {
114                 $base_date->add_duration($day_dur);
115
116             }
117
118         } else {
119             my $days = abs $add_duration->in_units('days');
120             while ($days) {
121                 $base_date->add_duration($day_dur);
122                 if ( $self->is_holiday($base_date) ) {
123                     next;
124                 } else {
125                     --$days;
126                 }
127             }
128         }
129         if ( $unit eq 'hours' ) {
130             my $dt = $base_date->clone()->subtract( days => 1 );
131             if ( $self->is_holiday($dt) ) {
132                 $base_date->set_hour($return_by_hour);    # Staffs specific
133             }
134         }
135         return $base_date;
136     } else {    # Days
137         return $base_date + $add_duration;
138     }
139 }
140
141 sub is_holiday {
142     my ( $self, $dt ) = @_;
143     my $localdt = $dt->clone();
144     my $dow = $localdt->day_of_week;
145     if ( $dow == 7 ) {
146         $dow = 0;
147     }
148     if ( $self->{weekly_closed_days}->[$dow] == 1 ) {
149         return 1;
150     }
151     $localdt->truncate( to => 'day' );
152     my $day   = $localdt->day;
153     my $month = $localdt->month;
154     if ( exists $self->{day_month_closed_days}->{$month}->{$day} ) {
155         return 1;
156     }
157     if ( $self->{exception_holidays}->contains($dt) ) {
158         return 1;
159     }
160     if ( $self->{single_holidays}->contains($dt) ) {
161         return 1;
162     }
163
164     # damn have to go to work after all
165     return 0;
166 }
167
168 sub days_between {
169     my $self     = shift;
170     my $start_dt = shift;
171     my $end_dt   = shift;
172
173
174     # start and end should not be closed days
175     my $days = $start_dt->delta_days($end_dt)->delta_days;
176     for (my $dt = $start_dt->clone();
177         $dt <= $end_dt;
178         $dt->add(days => 1)
179     ) {
180         if ($self->is_holiday($dt)) {
181             $days--;
182         }
183     }
184     return DateTime::Duration->new( days => $days );
185
186 }
187
188 sub hours_between {
189     my ($self, $start_date, $end_date) = @_;
190     my $start_dt = $start_date->clone();
191     my $end_dt = $end_date->clone();
192     my $duration = $end_dt->delta_ms($start_dt);
193     $start_dt->truncate( to => 'day' );
194     $end_dt->truncate( to => 'day' );
195     # NB this is a kludge in that it assumes all days are 24 hours
196     # However for hourly loans the logic should be expanded to
197     # take into account open/close times then it would be a duration
198     # of library open hours
199     my $skipped_days = 0;
200     for (my $dt = $start_dt->clone();
201         $dt <= $end_dt;
202         $dt->add(days => 1)
203     ) {
204         if ($self->is_holiday($dt)) {
205             ++$skipped_days;
206         }
207     }
208     if ($skipped_days) {
209         $duration->subtract_duration(DateTime::Duration->new( hours => 24 * $skipped_days));
210     }
211
212     return $duration;
213
214 }
215
216 sub _mockinit {
217     my $self = shift;
218     $self->{weekly_closed_days} = [ 1, 0, 0, 0, 0, 0, 0 ];    # Sunday only
219     $self->{day_month_closed_days} = { 6 => { 16 => 1, } };
220     my $dates = [];
221     $self->{exception_holidays} =
222       DateTime::Set->from_datetimes( dates => $dates );
223     my $special = DateTime->new(
224         year      => 2011,
225         month     => 6,
226         day       => 1,
227         time_zone => 'Europe/London',
228     );
229     push @{$dates}, $special;
230     $self->{single_holidays} = DateTime::Set->from_datetimes( dates => $dates );
231     $self->{days_mode} = 'Calendar';
232     $self->{test} = 1;
233     return;
234 }
235
236 sub set_daysmode {
237     my ( $self, $mode ) = @_;
238
239     # if not testing this is a no op
240     if ( $self->{test} ) {
241         $self->{days_mode} = $mode;
242     }
243
244     return;
245 }
246
247 sub clear_weekly_closed_days {
248     my $self = shift;
249     $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ];    # Sunday only
250     return;
251 }
252
253 sub add_holiday {
254     my $self = shift;
255     my $new_dt = shift;
256     my @dt = $self->{exception_holidays}->as_list;
257     push @dt, $new_dt;
258     $self->{exception_holidays} =
259       DateTime::Set->from_datetimes( dates => \@dt );
260
261     return;
262 }
263
264 1;
265 __END__
266
267 =head1 NAME
268
269 Koha::Calendar - Object containing a branches calendar
270
271 =head1 VERSION
272
273 This documentation refers to Koha::Calendar version 0.0.1
274
275 =head1 SYNOPSIS
276
277   use Koha::Calendat
278
279   my $c = Koha::Calender->new( branchcode => 'MAIN' );
280   my $dt = DateTime->now();
281
282   # are we open
283   $open = $c->is_holiday($dt);
284   # when will item be due if loan period = $dur (a DateTime::Duration object)
285   $duedate = $c->addDate($dt,$dur,'days');
286
287
288 =head1 DESCRIPTION
289
290   Implements those features of C4::Calendar needed for Staffs Rolling Loans
291
292 =head1 METHODS
293
294 =head2 new : Create a calendar object
295
296 my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
297
298 The option branchcode is required
299
300
301 =head2 addDate
302
303     my $dt = $calendar->addDate($date, $dur, $unit)
304
305 C<$date> is a DateTime object representing the starting date of the interval.
306
307 C<$offset> is a DateTime::Duration to add to it
308
309 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
310
311 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
312 parameter will be removed when issuingrules properly cope with that
313
314
315 =head2 is_holiday
316
317 $yesno = $calendar->is_holiday($dt);
318
319 passed at DateTime object returns 1 if it is a closed day
320 0 if not according to the calendar
321
322 =head2 days_between
323
324 $duration = $calendar->days_between($start_dt, $end_dt);
325
326 Passed two dates returns a DateTime::Duration object measuring the length between them
327 ignoring closed days. Always returns a positive number irrespective of the
328 relative order of the parameters
329
330 =head2 set_daysmode
331
332 For testing only allows the calling script to change days mode
333
334 =head2 clear_weekly_closed_days
335
336 In test mode changes the testing set of closed days to a new set with
337 no closed days. TODO passing an array of closed days to this would
338 allow testing of more configurations
339
340 =head2 add_holiday
341
342 Passed a datetime object this will add it to the calendar's list of
343 closed days. This is for testing so that we can alter the Calenfar object's
344 list of specified dates
345
346 =head1 DIAGNOSTICS
347
348 Will croak if not passed a branchcode in new
349
350 =head1 BUGS AND LIMITATIONS
351
352 This only contains a limited subset of the functionality in C4::Calendar
353 Only enough to support Staffs Rolling loans
354
355 =head1 AUTHOR
356
357 Colin Campbell colin.campbell@ptfs-europe.com
358
359 =head1 LICENSE AND COPYRIGHT
360
361 Copyright (c) 2011 PTFS-Europe Ltd All rights reserved
362
363 This program is free software: you can redistribute it and/or modify
364 it under the terms of the GNU General Public License as published by
365 the Free Software Foundation, either version 2 of the License, or
366 (at your option) any later version.
367
368 This program is distributed in the hope that it will be useful,
369 but WITHOUT ANY WARRANTY; without even the implied warranty of
370 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
371 GNU General Public License for more details.
372
373 You should have received a copy of the GNU General Public License
374 along with this program.  If not, see <http://www.gnu.org/licenses/>.