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