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