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