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