File Coverage

blib/lib/Business/Hours.pm
Criterion Covered Total %
statement 133 138 96.3
branch 29 38 76.3
condition 14 24 58.3
subroutine 14 14 100.0
pod 8 8 100.0
total 198 222 89.1


line stmt bran cond sub pod time code
1 2     2   142237 use strict;
  2         8  
  2         62  
2 2     2   10 use warnings;
  2         5  
  2         105  
3              
4             package Business::Hours;
5              
6             require 5.006;
7 2     2   1248 use Set::IntSpan;
  2         23691  
  2         134  
8 2     2   595 use Time::Local qw/timelocal_nocheck/;
  2         2196  
  2         3505  
9              
10             our $VERSION = '0.13';
11              
12             =head1 NAME
13              
14             Business::Hours - Calculate business hours in a time period
15              
16             =head1 SYNOPSIS
17              
18             use Business::Hours;
19             my $hours = Business::Hours->new();
20              
21             # Get a Set::IntSpan of all the business hours in the next week.
22             # use the default business hours of 9am to 6pm localtime.
23             $hours->for_timespan( Start => time(), End => time()+(86400*7) );
24              
25             =head1 DESCRIPTION
26              
27             This module is a simple tool for calculating business hours in a time period.
28             Over time, additional functionality will be added to make it easy to
29             calculate the number of business hours between arbitrary dates.
30              
31             =head1 USAGE
32              
33             =cut
34              
35             # Default business hours are weekdays from 9am to 6pm
36             our $BUSINESS_HOURS = (
37             { 0 => {
38             Name => 'Sunday',
39             Start => undef,
40             End => undef,
41             },
42             1 => {
43             Name => 'Monday',
44             Start => '9:00',
45             End => '18:00',
46             },
47             2 => {
48             Name => 'Tuesday',
49             Start => '9:00',
50             End => '18:00',
51             },
52             3 => {
53             Name => 'Wednesday',
54             Start => '9:00',
55             End => '18:00',
56             },
57             4 => {
58             Name => 'Thursday',
59             Start => '9:00',
60             End => '18:00',
61             },
62             5 => {
63             Name => 'Friday',
64             Start => '9:00',
65             End => '18:00',
66             },
67             6 => {
68             Name => 'Saturday',
69             Start => undef,
70             End => undef,
71             }
72             }
73             );
74             __PACKAGE__->preprocess_business_hours( $BUSINESS_HOURS );
75              
76             =head2 new
77              
78             Creates a new L object. Takes no arguments.
79              
80             =cut
81              
82             sub new {
83 6     6 1 5191 my $class = shift;
84              
85 6   33     37 my $self = bless( {}, ref($class) || $class );
86              
87 6         17 return ($self);
88             }
89              
90             =head2 business_hours HASH
91              
92             Gets / sets the business hours for this object.
93             Takes a hash (NOT a hash reference) of the form:
94              
95             my %hours = (
96             0 => { Name => 'Sunday',
97             Start => 'HH:MM',
98             End => 'HH:MM' },
99              
100             1 => { Name => 'Monday',
101             Start => 'HH:MM',
102             End => 'HH:MM' },
103             ....
104              
105             6 => { Name => 'Saturday',
106             Start => 'HH:MM',
107             End => 'HH:MM' },
108             );
109              
110             Start and End times are of the form HH:MM. Valid times are
111             from 00:00 to 23:59. If your hours are from 9am to 6pm, use
112             Start => '9:00', End => '18:00'. A given day MUST have a start
113             and end time OR may declare both Start and End to be undef, if
114             there are no valid hours on that day.
115              
116             You can use the array Breaks to mark interruptions between Start/End (for instance lunch hour). It's an array of periods, each with a Start and End time:
117              
118             my %hours = (
119             0 => { Name => 'Sunday',
120             Start => 'HH:MM',
121             End => 'HH:MM',
122             Breaks => [
123             { Start => 'HH:MM',
124             End => 'HH:MM' },
125             { Start => 'HH:MM',
126             End => 'HH:MM' },
127             ],
128              
129             1 => { Name => 'Monday',
130             Start => 'HH:MM',
131             End => 'HH:MM' },
132             ....
133              
134             6 => { Name => 'Saturday',
135             Start => 'HH:MM',
136             End => 'HH:MM' },
137             );
138              
139             Note that the ending time is really "what is the first minute we're closed.
140             If you specifiy an "End" of 18:00, that means that at 6pm, you are closed.
141             The last business second was 17:59:59.
142              
143             As well, you can pass information about holidays using key 'holidays' and
144             an array reference value, for example:
145              
146             $hours->business_hours(
147             0 => { Name => 'Sunday',
148             Start => 'HH:MM',
149             End => 'HH:MM' },
150             ....
151             6 => { Name => 'Saturday',
152             Start => 'HH:MM',
153             End => 'HH:MM' },
154              
155             holidays => [qw(01-01 12-25 2009-05-08)],
156             );
157              
158             Read more about holidays specification below in L.
159              
160             =cut
161              
162             sub business_hours {
163 1     1 1 7 my $self = shift;
164 1 50       5 if ( @_ ) {
165 1         4 %{ $self->{'business_hours'} } = (@_);
  1         5  
166 1         3 $self->{'holidays'} = delete $self->{'business_hours'}{'holidays'};
167 1         4 $self->preprocess_business_hours( $self->{'business_hours'} );
168             }
169 1         2 return %{ $self->{'business_hours'} };
  1         5  
170             }
171              
172             =head2 preprocess_business_hours
173              
174             Checks and transforms business hours data. No need to call it.
175              
176             =cut
177              
178             sub preprocess_business_hours {
179 3     3 1 7 my $self = shift;
180 3         5 my $bizdays = shift;
181              
182             my $process_start_end = sub {
183 26     26   39 my $span = shift;
184 26         37 foreach my $which (qw(Start End)) {
185 46 100 66     235 return 0 unless $span->{ $which } && $span->{ $which } =~ /^(\d+)\D(\d+)$/;
186              
187 40         107 $span->{ $which . 'Hour' } = $1;
188 40         127 $span->{ $which . 'Minute' } = $2;
189             }
190             $span->{'EndHour'} += 24
191             if $span->{'EndHour'}*60+$span->{'EndMinute'}
192 20 50       74 <= $span->{'StartHour'}*60+$span->{'StartMinute'};
193 20         56 return 1;
194 3         16 };
195              
196             # Split the Start and End times into hour/minute specifications
197 3         14 foreach my $dow ( keys %$bizdays ) {
198 21 100 33     101 unless (
      66        
199             $bizdays->{ $dow } && ref($bizdays->{ $dow }) eq 'HASH'
200             && $process_start_end->( $bizdays->{ $dow } )
201             ) {
202 6         15 delete $bizdays->{ $dow };
203 6         16 next;
204             }
205              
206 15 100       25 foreach my $break ( splice @{ $bizdays->{ $dow }{'Breaks'} || [] } ) {
  15         56  
207 5 50 33     20 next unless $break && ref($break) eq 'HASH';
208 5 50       8 push @{ $bizdays->{ $dow }{'Breaks'} }, $break
  5         18  
209             if $process_start_end->( $break );
210             }
211             }
212             }
213              
214             =head2 holidays ARRAY
215              
216             Gets / sets holidays for this object. Takes an array
217             where each element is ether 'MM-DD' or 'YYYY-MM-DD'.
218              
219             Specification with year defined may be required when a holiday
220             matches Sunday or Saturday. In many countries days are shifted
221             in such case.
222              
223             Holidays can be set via L method
224             as well, so you can use this feature without changing your code.
225              
226             =cut
227              
228             sub holidays {
229 17     17 1 690 my $self = shift;
230 17 100       51 if ( @_ ) {
231 1         3 @{ $self->{'holidays'} } = (@_);
  1         5  
232             }
233 17 100       27 return @{ $self->{'holidays'} || [] };
  17         93  
234             }
235              
236             =head2 for_timespan HASH
237              
238             Takes a hash with the following parameters:
239              
240             =over
241              
242             =item Start
243              
244             The start of the period in question in seconds since the epoch
245              
246             =item End
247              
248             The end of the period in question in seconds since the epoch
249              
250             =back
251              
252             Returns a L of business hours for this period of time.
253              
254             =cut
255              
256             sub for_timespan {
257 14     14 1 1335 my $self = shift;
258 14         59 my %args = (
259             Start => undef,
260             End => undef,
261             @_
262             );
263 14   66     69 my $bizdays = $self->{'business_hours'} || $BUSINESS_HOURS;
264              
265             # now that we know what the business hours are for each day in a week,
266             # we need to find all the business hours in the period in question.
267              
268             # Create an intspan of the period in total.
269             my $business_period
270 14         77 = Set::IntSpan->new( $args{'Start'} . "-" . $args{'End'} );
271              
272             # jump back to the first day (Sunday) of the last week before the period
273             # began.
274 14         1498 my @start = localtime( $args{'Start'} );
275 14         49 $start[5] += 1900; # Set 4 digit year, see perldoc localtime
276 14         24 my $month = $start[4];
277 14         23 my $year = $start[5];
278 14         41 my $first_sunday = $start[3] - $start[6];
279              
280             # period_start is time_t at midnight local time on the first sunday
281 14         55 my $period_start
282             = timelocal_nocheck( 0, 0, 0, $first_sunday, $month, $year );
283              
284             # for each week until the end of the week in seconds since the epoch
285             # is outside the business period in question
286 14         709 my $week_start = $period_start;
287              
288             # @run_list is a run list of the period's business hours
289             # its form is (-,-)
290             # For documentation about its format, have a look at Set::IntSpan.
291             # (This is fed into Set::IntSpan to use to compute our actual run.
292 14         23 my @run_list;
293              
294             # @break_list is a run list of the period's breaks between business hours
295             # its form is (-,-)
296             # For documentation about its format, have a look at Set::IntSpan.
297             # (This is fed into Set::IntSpan to use to compute our actual run.
298             my @break_list;
299              
300             my $convert_start_end = sub {
301 95     95   206 my ($hours, @today) = @_;
302              
303             # add the business seconds in that week to the runlist we'll use to
304             # figure out business hours
305             # (Be careful to use timelocal to convert times in the week into actual
306             # seconds, so we don't lose at DST transition)
307             my $start = timelocal_nocheck(
308 95         248 0, $hours->{'StartMinute'}, $hours->{'StartHour'}, @today
309             );
310              
311             # We subtract 1 from the ending time, because the ending time
312             # really specifies what hour we end up closed at
313             my $end = timelocal_nocheck(
314 95         4005 0, $hours->{'EndMinute'}, $hours->{'EndHour'}, @today
315             ) - 1;
316              
317 95         3921 return "$start-$end";
318 14         86 };
319              
320 14         46 while ( $week_start <= $args{'End'} ) {
321              
322 17         385 my @today = (localtime($week_start))[3, 4, 5];
323 17         44 $today[0]--; # compensate next increment
324 17         34 $today[2] += 1900; # Set 4 digit year
325              
326             # foreach day in the week, find that day's business hours in
327             # seconds since the epoch.
328 17         55 for ( my $dow = 0; $dow <= 6; $dow++ ) {
329 119         207 $today[0]++; # next day comes
330 119 100       297 next unless my $day_hours = $bizdays->{$dow};
331              
332 85         162 push @run_list, $convert_start_end->( $day_hours, @today );
333              
334 85 100       141 foreach my $break ( @{ $bizdays->{$dow}{'Breaks'} || [] } ) {
  85         464  
335 10         19 push @break_list, $convert_start_end->( $break, @today );
336             }
337             }
338              
339             # now that we're done with this week, calculate the start of the next week
340             # the next week starts at midnight on the sunday following the previous
341             # sunday
342 17         54 $week_start = timelocal_nocheck( 0, 0, 0, $today[0]+1, $today[1], $today[2] );
343              
344             }
345              
346 14         665 my $business_hours = Set::IntSpan->new( join( ',', @run_list ) ) - Set::IntSpan->new( join( ',', @break_list ) );
347 14         4423 my $business_hours_in_period
348             = $business_hours->intersect($business_period);
349              
350             # find the intersection of the business period intspan and the business
351             # hours intspan. (Because we want to trim any business hours that fall
352             # outside the business period)
353              
354 14 100       1772 if ( my @holidays = $self->holidays ) {
355 4         9 my $start_year = $year;
356 4         83 my $end_year = (localtime $args{'End'})[5];
357 4         14 $end_year += 1900; # Set 4 digit year
358 4         10 foreach my $holiday (@holidays) {
359 12         1345 my ($year, $month, $date) = ($holiday =~ /^(?:(\d\d\d\d)\D)?(\d\d)\D(\d\d)$/);
360 12         29 $month--;
361 12         17 my @range;
362 12 50       23 if ( $year ) {
363 0         0 push @range, [
364             timelocal_nocheck( 0, 0, 0, $date, $month, $year ),
365             ];
366             }
367             else {
368 12         34 push @range, [
369             timelocal_nocheck( 0, 0, 0, $date, $month, $start_year ),
370             ];
371 12 100       614 push @range, [
372             timelocal_nocheck( 0, 0, 0, $date, $month, $end_year ),
373             ] if $start_year != $end_year;
374             }
375 12         403 $_->[1] = $_->[0] + 24*60*60 foreach @range;
376 12         39 $business_hours_in_period -= \@range;
377             }
378             }
379              
380             # TODO: Add any special times to the business hours
381              
382             # cache the calculated business hours in the object
383 14         611 $self->{'calculated'} = $business_hours_in_period;
384 14         29 $self->{'start'} = $args{'Start'};
385 14         24 $self->{'end'} = $args{'End'};
386              
387             # Return the intspan of business hours.
388              
389 14         137 return ($business_hours_in_period);
390              
391             }
392              
393             =head2 between START, END
394              
395             Returns the number of business seconds between START and END
396             Both START and END should be specified in seconds since the epoch.
397              
398             Returns -1 if START or END are outside the calculated business hours.
399              
400             =cut
401              
402             sub between {
403 5     5 1 720 my $self = shift;
404 5         10 my $start = shift;
405 5         10 my $end = shift;
406              
407 5 100 66     32 if ( not defined $self->{'start'} or not defined $self->{'end'} ) {
408             # We haven't calculated our sets yet, so let's do that for the
409             # user now, assuming they want to use the same start and end
410             # times
411 1         4 $self->for_timespan( Start => $start, End => $end );
412             }
413              
414 5 50       17 if ( $start < $self->{'start'} ) {
415 0         0 return (-1);
416             }
417 5 50       14 if ( $end > $self->{'end'} ) {
418 0         0 return (-1);
419             }
420              
421 5         22 my $period = Set::IntSpan->new( $start . "-" . $end );
422 5         340 my $intersection = intersect $period $self->{'calculated'};
423              
424 5         394 return cardinality $intersection;
425             }
426              
427             =head2 first_after START
428              
429             Returns START if START is within business hours.
430             Otherwise, returns the next business second after START.
431             START should be specified in seconds since the epoch.
432              
433             Returns -1 if it can't find any business hours within thirty days.
434              
435             =cut
436              
437             sub first_after {
438 3     3 1 2688 my $self = shift;
439 3         7 my $start = shift;
440              
441             # the maximum time after which we stop searching for business hours
442 3         6 my $MAXTIME = $start + ( 30 * 24 * 60 * 60 ); # 30 days
443              
444 3         6 my $period = ( 24 * 60 * 60 );
445 3         5 my $end = $start + $period;
446 3         18 my $hours = new Set::IntSpan;
447              
448 3         160 while ( $hours->empty ) {
449 5 50       50 if ( $end >= $MAXTIME ) {
450 0         0 return -1;
451             }
452 5         15 $hours = $self->for_timespan( Start => $start, End => $end );
453 5         11 $start = $end;
454 5         14 $end = $start + $period;
455             }
456              
457 3         31 return $hours->first;
458             }
459              
460             =head2 add_seconds START, SECONDS
461              
462             Returns a time SECONDS business seconds after START.
463             START should be specified in seconds since the epoch.
464              
465             Returns -1 if it can't find any business hours within thirty days.
466              
467             =cut
468              
469             sub add_seconds {
470 3     3 1 1502 my $self = shift;
471 3         8 my $start = shift;
472 3         5 my $seconds = shift;
473              
474             # the maximum time after which we stop searching for business hours
475 3         7 my $MAXTIME = ( 30 * 24 * 60 * 60 ); # 30 days
476              
477 3         6 my $last;
478              
479 3         7 my $period = ( 24 * 60 * 60 );
480 3         5 my $end = $start + $period;
481              
482 3         13 my $hours = new Set::IntSpan;
483 3   100     75 while ($hours->empty
484             or $self->between( $start, $hours->last ) <= $seconds )
485             {
486 4 50       65 if ( $end >= $start + $MAXTIME ) {
487 0         0 return -1;
488             }
489 4         11 $hours = $self->for_timespan( Start => $start, End => $end );
490              
491 4         14 $end += $period;
492             }
493              
494 3         69 my @elements = elements $hours;
495 3         15716 $last = $elements[$seconds];
496              
497 3         1555 return $last;
498             }
499              
500             =head1 BUGS
501              
502             Yes, most likely. Please report them to L.
503              
504             =head1 AUTHOR
505              
506             Jesse Vincent, L
507              
508             =head1 COPYRIGHT
509              
510             Copyright 2003-2008 Best Practical Solutions, LLC.
511              
512             This program is free software; you can redistribute
513             it and/or modify it under the same terms as Perl itself.
514              
515             The full text of the license can be found in the LICENSE
516             file included with this module.
517              
518             =cut
519              
520             1;
521