File Coverage

blib/lib/DateTime/BusinessHours.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             package DateTime::BusinessHours;
2              
3 4     4   168999 use strict;
  4         10  
  4         158  
4 4     4   23 use warnings;
  4         5  
  4         300  
5              
6 4     4   7389 use DateTime;
  0            
  0            
7             use Class::MethodMaker [
8             scalar => [
9             qw( datetime1 datetime2 worktiming weekends holidayfile holidays )
10             ],
11             ];
12              
13             our $VERSION = '2.03';
14              
15             sub new {
16             my ( $class, %args ) = @_;
17              
18             die 'datetime1 parameter required' if !$args{ datetime1 };
19             die 'datetime2 parameter required' if !$args{ datetime2 };
20              
21             $args{ worktiming } ||= [ [ 9, 17 ] ];
22             $args{ weekends } ||= [ 6, 7 ];
23             $args{ holidays } ||= [ ];
24              
25             if( !ref @{ $args{ worktiming } }[ 0 ] ) {
26             $args{ worktiming } = [ [ @{ $args{ worktiming } } ] ];
27             }
28              
29             my $obj = bless \%args, $class;
30              
31             # initialize holiday map on this object
32             $obj->_set_holidays();
33             my %holiday_map = map { $_ => 1 }
34             grep { $_ ge $obj->datetime1->ymd && $_ le $obj->datetime2->ymd }
35             @{$obj->holidays};
36             $obj->{_holiday_map} = \%holiday_map;
37              
38             return $obj;
39             }
40              
41             sub calculate {
42             my $self = shift;
43             $self->{ _result } = undef;
44             $self->_calculate;
45             }
46              
47             sub _calculate {
48             my $self = shift;
49              
50             return $self->{ _result } if defined $self->{ _result };
51             $self->{ _result } = { days => 0, hours => 0 };
52              
53             # number of hours in a work day
54             my $length = $self->_calculate_day_length;
55             my $d1 = $self->datetime1->clone;
56             my $d2 = $self->datetime2->clone;
57              
58             # swap if "start" is more recent than "end"
59             ( $d1, $d2 ) = ( $d2, $d1 ) if $d1 > $d2;
60              
61             my $start = $d1->clone->truncate( to => 'day' );
62             my $end = $d2->clone->truncate( to => 'day' );
63              
64             # deal with everything non-inclusive to the start/end
65             $start->add( days => 1 );
66             $end->subtract( days => 1 );
67              
68             while( $start <= $end ) {
69             if( $self->_is_business_day($start) ) {
70             $self->{ _result }->{ hours } += $length;
71             }
72             $start->add( days => 1 );
73             }
74              
75             # handle start and end days
76             for( reverse @{ $self->{ _timing_norms } } ) {
77             last if $d1 >= $d1->clone->set( %{ $_->[ 1 ] } ); #start >= end time of same day
78             last if $d2 <= $d1->clone->set( %{ $_->[ 0 ] } ); #end <= start time of same day
79             last if ! $self->_is_business_day($d1); #it's possible we start on a non-bus day
80              
81             my $r1 = $d1->clone->set( %{ $_->[ 0 ] } );
82             my $r2 = $d1->clone->set( %{ $_->[ 1 ] } );
83              
84             # full or partial range
85             $r1 = $d1 if $d1 > $r1;
86             $r2 = $d2 if $d2 < $r2; # only happens when $d1 and $d2 are on the same day
87              
88             my $dur = $r2 - $r1;
89             $self->{ _result }->{ hours } += $dur->in_units( 'minutes' ) / 60;
90             }
91              
92             # if start and end aren't on the same day
93             if( $d1->truncate( to => 'day' ) != $d2->clone->truncate( to => 'day' ) ) {
94             for( @{ $self->{ _timing_norms } } ) {
95             last if $d2 <= $d2->clone->set( %{ $_->[ 0 ] } ); #end <= start of same day
96             last if $d1 >= $d2->clone->set( %{ $_->[ 1 ] } ); #start >= end of same day
97             last if ! $self->_is_business_day($d2); #it's possible we end on a non-bus day
98              
99             my $r1 = $d2->clone->set( %{ $_->[ 0 ] } );
100             my $r2 = $d2->clone->set( %{ $_->[ 1 ] } );
101              
102             # full or partial range
103             $r2 = $d2 if $d2 < $r2;
104              
105             my $dur = $r2 - $r1;
106             $self->{ _result }->{ hours } += $dur->in_units( 'minutes' ) / 60;
107             }
108             }
109              
110             $self->{ _result }->{ days } = $self->{ _result }->{ hours } / $length;
111             return $self->{ _result };
112             }
113              
114             # determine how many hours are in a business day
115             sub _calculate_day_length {
116             my $self = shift;
117              
118             $self->{ _day_length } = 0;
119             $self->{ _timing_norms } = [];
120              
121             for my $i ( @{ $self->worktiming } ) {
122             push @{ $self->{ _timing_norms } }, [];
123             for( @$i ) {
124             # normalize input times
125             $_ = sprintf( '%02s00', $_ ) if length == 1 || length == 2;
126             $_ = sprintf( '%04s', $_ );
127              
128             my( $h, $m ) = m{(..)(..)};
129              
130             # normalize input times for use with DateTime
131             push @{ $self->{ _timing_norms }->[ -1 ] }, { hour => $h, minute => $m };
132             }
133             }
134              
135             for my $tn ( @{ $self->{ _timing_norms } } ) {
136             my $dur = DateTime->new( year => 2012, %{ $tn->[ 1 ] } )
137             - DateTime->new( year => 2012, %{ $tn->[ 0 ] } );
138             $self->{ _day_length } += $dur->in_units( 'minutes' ) / 60;
139             }
140              
141             return $self->{ _day_length };
142             }
143              
144             sub _set_holidays{
145             my $self = shift;
146              
147             my @holidays = @{ $self->holidays };
148             my $filename = $self->holidayfile;
149              
150             if( $filename && -e $filename ) {
151             open( my $fh, '<', $filename );
152             while ( <$fh> ) { chomp; push @holidays, $_ };
153             close $fh;
154             }
155              
156             $self->{holidays} = \@holidays;
157             }
158              
159             sub getdays {
160             return shift->_calculate->{ days };
161             }
162              
163             sub gethours {
164             return shift->_calculate->{ hours };
165             }
166              
167             # return 1 if day is not a weekend and it's not a holiday
168             # return 0 otherwise
169             sub _is_business_day {
170             my $self = shift;
171             my $dt = shift;
172             return 0 if ($self->_is_weekend($dt) || $self->_is_holiday($dt));
173             return 1;
174             }
175              
176             # Returns 1 if the datetime provided is a weekend day perl the weekend option
177             # Returns 0 otherwise
178             sub _is_weekend {
179             my $self = shift;
180             my $day_of_week = (shift)->day_of_week;
181             for my $defined_we (@{$self->{weekends}}) {
182             return 1 if ($defined_we == $day_of_week);
183             }
184             return 0;
185             }
186              
187             # Returns 1 if the datetime provided is in the holiday map
188             sub _is_holiday {
189             my $self = shift;
190             my $date = (shift)->ymd;
191              
192             return exists($self->{_holiday_map}->{$date});
193             }
194              
195             1;
196              
197             __END__