Bug 8800 - useDaysMode=Datedue wrong behaviour (revisited)
[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 $dow = $localdt->day_of_week;
181     if ( $dow == 7 ) {
182         $dow = 0;
183     }
184     if ( $self->{weekly_closed_days}->[$dow] == 1 ) {
185         return 1;
186     }
187     $localdt->truncate( to => 'day' );
188     my $day   = $localdt->day;
189     my $month = $localdt->month;
190     if ( exists $self->{day_month_closed_days}->{$month}->{$day} ) {
191         return 1;
192     }
193     if ( $self->{exception_holidays}->contains($localdt) ) {
194         return 1;
195     }
196     if ( $self->{single_holidays}->contains($localdt) ) {
197         return 1;
198     }
199
200     # damn have to go to work after all
201     return 0;
202 }
203
204 sub next_open_day {
205     my ( $self, $dt ) = @_;
206     my $base_date = $dt->clone();
207
208     $base_date->add(days => 1);
209
210     while ($self->is_holiday($base_date)) {
211         $base_date->add(days => 1);
212     }
213
214     return $base_date;
215 }
216
217 sub prev_open_day {
218     my ( $self, $dt ) = @_;
219     my $base_date = $dt->clone();
220
221     $base_date->add(days => -1);
222
223     while ($self->is_holiday($base_date)) {
224         $base_date->add(days => -1);
225     }
226
227     return $base_date;
228 }
229
230 sub days_between {
231     my $self     = shift;
232     my $start_dt = shift;
233     my $end_dt   = shift;
234
235
236     # start and end should not be closed days
237     my $days = $start_dt->delta_days($end_dt)->delta_days;
238     for (my $dt = $start_dt->clone();
239         $dt <= $end_dt;
240         $dt->add(days => 1)
241     ) {
242         if ($self->is_holiday($dt)) {
243             $days--;
244         }
245     }
246     return DateTime::Duration->new( days => $days );
247
248 }
249
250 sub hours_between {
251     my ($self, $start_date, $end_date) = @_;
252     my $start_dt = $start_date->clone();
253     my $end_dt = $end_date->clone();
254     my $duration = $end_dt->delta_ms($start_dt);
255     $start_dt->truncate( to => 'day' );
256     $end_dt->truncate( to => 'day' );
257     # NB this is a kludge in that it assumes all days are 24 hours
258     # However for hourly loans the logic should be expanded to
259     # take into account open/close times then it would be a duration
260     # of library open hours
261     my $skipped_days = 0;
262     for (my $dt = $start_dt->clone();
263         $dt <= $end_dt;
264         $dt->add(days => 1)
265     ) {
266         if ($self->is_holiday($dt)) {
267             ++$skipped_days;
268         }
269     }
270     if ($skipped_days) {
271         $duration->subtract_duration(DateTime::Duration->new( hours => 24 * $skipped_days));
272     }
273
274     return $duration;
275
276 }
277
278 sub _mockinit {
279     my $self = shift;
280     $self->{weekly_closed_days} = [ 1, 0, 0, 0, 0, 0, 0 ];    # Sunday only
281     $self->{day_month_closed_days} = { 6 => { 16 => 1, } };
282     my $dates = [];
283     $self->{exception_holidays} =
284       DateTime::Set->from_datetimes( dates => $dates );
285     my $special = DateTime->new(
286         year      => 2011,
287         month     => 6,
288         day       => 1,
289         time_zone => 'Europe/London',
290     );
291     push @{$dates}, $special;
292     $self->{single_holidays} = DateTime::Set->from_datetimes( dates => $dates );
293
294     # if not defined, days_mode defaults to 'Calendar'
295     if ( !defined($self->{days_mode}) ) {
296         $self->{days_mode} = 'Calendar';
297     }
298
299     $self->{test} = 1;
300     return;
301 }
302
303 sub set_daysmode {
304     my ( $self, $mode ) = @_;
305
306     # if not testing this is a no op
307     if ( $self->{test} ) {
308         $self->{days_mode} = $mode;
309     }
310
311     return;
312 }
313
314 sub clear_weekly_closed_days {
315     my $self = shift;
316     $self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ];    # Sunday only
317     return;
318 }
319
320 sub add_holiday {
321     my $self = shift;
322     my $new_dt = shift;
323     my @dt = $self->{exception_holidays}->as_list;
324     push @dt, $new_dt;
325     $self->{exception_holidays} =
326       DateTime::Set->from_datetimes( dates => \@dt );
327
328     return;
329 }
330
331 1;
332 __END__
333
334 =head1 NAME
335
336 Koha::Calendar - Object containing a branches calendar
337
338 =head1 VERSION
339
340 This documentation refers to Koha::Calendar version 0.0.1
341
342 =head1 SYNOPSIS
343
344   use Koha::Calendar
345
346   my $c = Koha::Calendar->new( branchcode => 'MAIN' );
347   my $dt = DateTime->now();
348
349   # are we open
350   $open = $c->is_holiday($dt);
351   # when will item be due if loan period = $dur (a DateTime::Duration object)
352   $duedate = $c->addDate($dt,$dur,'days');
353
354
355 =head1 DESCRIPTION
356
357   Implements those features of C4::Calendar needed for Staffs Rolling Loans
358
359 =head1 METHODS
360
361 =head2 new : Create a calendar object
362
363 my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
364
365 The option branchcode is required
366
367
368 =head2 addDate
369
370     my $dt = $calendar->addDate($date, $dur, $unit)
371
372 C<$date> is a DateTime object representing the starting date of the interval.
373
374 C<$offset> is a DateTime::Duration to add to it
375
376 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
377
378 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
379 parameter will be removed when issuingrules properly cope with that
380
381
382 =head2 addHours
383
384     my $dt = $calendar->addHours($date, $dur, $return_by_hour )
385
386 C<$date> is a DateTime object representing the starting date of the interval.
387
388 C<$offset> is a DateTime::Duration to add to it
389
390 C<$return_by_hour> is an integer value representing the opening hour for the branch
391
392
393 =head2 addDays
394
395     my $dt = $calendar->addDays($date, $dur)
396
397 C<$date> is a DateTime object representing the starting date of the interval.
398
399 C<$offset> is a DateTime::Duration to add to it
400
401 C<$unit> is a string value 'days' or 'hours' toflag granularity of duration
402
403 Currently unit is only used to invoke Staffs return Monday at 10 am rule this
404 parameter will be removed when issuingrules properly cope with that
405
406
407 =head2 is_holiday
408
409 $yesno = $calendar->is_holiday($dt);
410
411 passed a DateTime object returns 1 if it is a closed day
412 0 if not according to the calendar
413
414 =head2 days_between
415
416 $duration = $calendar->days_between($start_dt, $end_dt);
417
418 Passed two dates returns a DateTime::Duration object measuring the length between them
419 ignoring closed days. Always returns a positive number irrespective of the
420 relative order of the parameters
421
422 =head2 next_open_day
423
424 $datetime = $calendar->next_open_day($duedate_dt)
425
426 Passed a Datetime returns another Datetime representing the next open day. It is
427 intended for use to calculate the due date when useDaysMode syspref is set to either
428 'Datedue' or 'Calendar'.
429
430 =head2 prev_open_day
431
432 $datetime = $calendar->prev_open_day($duedate_dt)
433
434 Passed a Datetime returns another Datetime representing the previous open day. It is
435 intended for use to calculate the due date when useDaysMode syspref is set to either
436 'Datedue' or 'Calendar'.
437
438 =head2 set_daysmode
439
440 For testing only allows the calling script to change days mode
441
442 =head2 clear_weekly_closed_days
443
444 In test mode changes the testing set of closed days to a new set with
445 no closed days. TODO passing an array of closed days to this would
446 allow testing of more configurations
447
448 =head2 add_holiday
449
450 Passed a datetime object this will add it to the calendar's list of
451 closed days. This is for testing so that we can alter the Calenfar object's
452 list of specified dates
453
454 =head1 DIAGNOSTICS
455
456 Will croak if not passed a branchcode in new
457
458 =head1 BUGS AND LIMITATIONS
459
460 This only contains a limited subset of the functionality in C4::Calendar
461 Only enough to support Staffs Rolling loans
462
463 =head1 AUTHOR
464
465 Colin Campbell colin.campbell@ptfs-europe.com
466
467 =head1 LICENSE AND COPYRIGHT
468
469 Copyright (c) 2011 PTFS-Europe Ltd All rights reserved
470
471 This program is free software: you can redistribute it and/or modify
472 it under the terms of the GNU General Public License as published by
473 the Free Software Foundation, either version 2 of the License, or
474 (at your option) any later version.
475
476 This program is distributed in the hope that it will be useful,
477 but WITHOUT ANY WARRANTY; without even the implied warranty of
478 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
479 GNU General Public License for more details.
480
481 You should have received a copy of the GNU General Public License
482 along with this program.  If not, see <http://www.gnu.org/licenses/>.