File Coverage

blib/lib/Business/Hours.pm
Criterion Covered Total %
statement 130 135 96.3
branch 29 38 76.3
condition 14 24 58.3
subroutine 14 14 100.0
pod 8 8 100.0
total 195 219 89.0


line stmt bran cond sub pod time code
1 2     2   63050 use strict;
  2         5  
  2         68  
2 2     2   9 use warnings;
  2         4  
  2         95  
3              
4             package Business::Hours;
5              
6             require 5.006;
7 2     2   2308 use Set::IntSpan;
  2         38068  
  2         164  
8 2     2   6656 use Time::Local qw/timelocal_nocheck/;
  2         2312  
  2         4064  
9              
10             our $VERSION = '0.12';
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 6635 my $class = shift;
84              
85 6   33     59 my $self = bless( {}, ref($class) || $class );
86              
87 6         19 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         3 %{ $self->{'business_hours'} } = (@_);
  1         9  
166 1         4 $self->{'holidays'} = delete $self->{'business_hours'}{'holidays'};
167 1         5 $self->preprocess_business_hours( $self->{'business_hours'} );
168             }
169 1         160 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 8 my $self = shift;
180 3         5 my $bizdays = shift;
181              
182             my $process_start_end = sub {
183 26     26   36 my $span = shift;
184 26         39 foreach my $which (qw(Start End)) {
185 46 100 66     7227 return 0 unless $span->{ $which } && $span->{ $which } =~ /^(\d+)\D(\d+)$/;
186              
187 40         122 $span->{ $which . 'Hour' } = $1;
188 40         145 $span->{ $which . 'Minute' } = $2;
189             }
190 20 50       86 $span->{'EndHour'} += 24
191             if $span->{'EndHour'}*60+$span->{'EndMinute'}
192             <= $span->{'StartHour'}*60+$span->{'StartMinute'};
193 20         70 return 1;
194 3         20 };
195              
196             # Split the Start and End times into hour/minute specifications
197 3         19 foreach my $dow ( keys %$bizdays ) {
198 21 100 33     150 unless (
      66        
199             $bizdays->{ $dow } && ref($bizdays->{ $dow }) eq 'HASH'
200             && $process_start_end->( $bizdays->{ $dow } )
201             ) {
202 6         19 delete $bizdays->{ $dow };
203 6         15 next;
204             }
205              
206 15 100       18 foreach my $break ( splice @{ $bizdays->{ $dow }{'Breaks'} || [] } ) {
  15         99  
207 5 50 33     49 next unless $break && ref($break) eq 'HASH';
208 5 50       9 push @{ $bizdays->{ $dow }{'Breaks'} }, $break
  5         21  
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 524 my $self = shift;
230 17 100       52 if ( @_ ) {
231 1         2 @{ $self->{'holidays'} } = (@_);
  1         3  
232             }
233 17 100       21 return @{ $self->{'holidays'} || [] };
  17         123  
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 2456 my $self = shift;
258 14         79 my %args = (
259             Start => undef,
260             End => undef,
261             @_
262             );
263 14   66     82 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 14         133 my $business_period
270             = 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         6884 my @start = localtime( $args{'Start'} );
275 14         36 my $month = $start[4];
276 14         24 my $year = $start[5];
277 14         25 my $first_sunday = $start[3] - $start[6];
278              
279             # period_start is time_t at midnight local time on the first sunday
280 14         70 my $period_start
281             = timelocal_nocheck( 0, 0, 0, $first_sunday, $month, $year );
282              
283             # for each week until the end of the week in seconds since the epoch
284             # is outside the business period in question
285 14         888 my $week_start = $period_start;
286              
287             # @run_list is a run list of the period's business hours
288             # its form is (-,-)
289             # For documentation about its format, have a look at Set::IntSpan.
290             # (This is fed into Set::IntSpan to use to compute our actual run.
291 14         22 my @run_list;
292              
293             # @break_list is a run list of the period's breaks between business hours
294             # its form is (-,-)
295             # For documentation about its format, have a look at Set::IntSpan.
296             # (This is fed into Set::IntSpan to use to compute our actual run.
297             my @break_list;
298              
299             my $convert_start_end = sub {
300 95     95   176 my ($hours, @today) = @_;
301              
302             # add the business seconds in that week to the runlist we'll use to
303             # figure out business hours
304             # (Be careful to use timelocal to convert times in the week into actual
305             # seconds, so we don't lose at DST transition)
306 95         297 my $start = timelocal_nocheck(
307             0, $hours->{'StartMinute'}, $hours->{'StartHour'}, @today
308             );
309              
310             # We subtract 1 from the ending time, because the ending time
311             # really specifies what hour we end up closed at
312 95         4883 my $end = timelocal_nocheck(
313             0, $hours->{'EndMinute'}, $hours->{'EndHour'}, @today
314             ) - 1;
315              
316 95         4328 return "$start-$end";
317 14         106 };
318              
319 14         57 while ( $week_start <= $args{'End'} ) {
320              
321 17         592 my @today = (localtime($week_start))[3, 4, 5];
322 17         29 $today[0]--; # compensate next increment
323              
324             # foreach day in the week, find that day's business hours in
325             # seconds since the epoch.
326 17         61 for ( my $dow = 0; $dow <= 6; $dow++ ) {
327 119         129 $today[0]++; # next day comes
328 119 100       391 next unless my $day_hours = $bizdays->{$dow};
329              
330 85         254 push @run_list, $convert_start_end->( $day_hours, @today );
331              
332 85 100       119 foreach my $break ( @{ $bizdays->{$dow}{'Breaks'} || [] } ) {
  85         569  
333 10         171 push @break_list, $convert_start_end->( $break, @today );
334             }
335             }
336              
337             # now that we're done with this week, calculate the start of the next week
338             # the next week starts at midnight on the sunday following the previous
339             # sunday
340 17         61 $week_start = timelocal_nocheck( 0, 0, 0, $today[0]+1, $today[1], $today[2] );
341              
342             }
343              
344 14         959 my $business_hours = Set::IntSpan->new( join( ',', @run_list ) ) - Set::IntSpan->new( join( ',', @break_list ) );
345 14         3985 my $business_hours_in_period
346             = $business_hours->intersect($business_period);
347              
348             # find the intersection of the business period intspan and the business
349             # hours intspan. (Because we want to trim any business hours that fall
350             # outside the business period)
351              
352 14 100       1439 if ( my @holidays = $self->holidays ) {
353 4         5 my $start_year = $year;
354 4         70 my $end_year = (localtime $args{'End'})[5];
355 4         8 foreach my $holiday (@holidays) {
356 12         993 my ($year, $month, $date) = ($holiday =~ /^(?:(\d\d\d\d)\D)?(\d\d)\D(\d\d)$/);
357 12         20 $month--;
358 12         13 my @range;
359 12 50       24 if ( $year ) {
360 0         0 push @range, [
361             timelocal_nocheck( 0, 0, 0, $date, $month, $year ),
362             ];
363             }
364             else {
365 12         34 push @range, [
366             timelocal_nocheck( 0, 0, 0, $date, $month, $start_year ),
367             ];
368 12 100       522 push @range, [
369             timelocal_nocheck( 0, 0, 0, $date, $month, $end_year ),
370             ] if $start_year != $end_year;
371             }
372 12         364 $_->[1] = $_->[0] + 24*60*60 foreach @range;
373 12         37 $business_hours_in_period -= \@range;
374             }
375             }
376              
377             # TODO: Add any special times to the business hours
378              
379             # cache the calculated business hours in the object
380 14         437 $self->{'calculated'} = $business_hours_in_period;
381 14         62 $self->{'start'} = $args{'Start'};
382 14         36 $self->{'end'} = $args{'End'};
383              
384             # Return the intspan of business hours.
385              
386 14         243 return ($business_hours_in_period);
387              
388             }
389              
390             =head2 between START, END
391              
392             Returns the number of business seconds between START and END
393             Both START and END should be specified in seconds since the epoch.
394              
395             Returns -1 if START or END are outside the calculated business hours.
396              
397             =cut
398              
399             sub between {
400 5     5 1 714 my $self = shift;
401 5         10 my $start = shift;
402 5         9 my $end = shift;
403              
404 5 100 66     31 if ( not defined $self->{'start'} or not defined $self->{'end'} ) {
405             # We haven't calculated our sets yet, so let's do that for the
406             # user now, assuming they want to use the same start and end
407             # times
408 1         3 $self->for_timespan( Start => $start, End => $end );
409             }
410              
411 5 50       17 if ( $start < $self->{'start'} ) {
412 0         0 return (-1);
413             }
414 5 50       21 if ( $end > $self->{'end'} ) {
415 0         0 return (-1);
416             }
417              
418 5         27 my $period = Set::IntSpan->new( $start . "-" . $end );
419 5         683 my $intersection = intersect $period $self->{'calculated'};
420              
421 5         328 return cardinality $intersection;
422             }
423              
424             =head2 first_after START
425              
426             Returns START if START is within business hours.
427             Otherwise, returns the next business second after START.
428             START should be specified in seconds since the epoch.
429              
430             Returns -1 if it can't find any business hours within thirty days.
431              
432             =cut
433              
434             sub first_after {
435 3     3 1 3473 my $self = shift;
436 3         8 my $start = shift;
437              
438             # the maximum time after which we stop searching for business hours
439 3         7 my $MAXTIME = $start + ( 30 * 24 * 60 * 60 ); # 30 days
440              
441 3         5 my $period = ( 24 * 60 * 60 );
442 3         6 my $end = $start + $period;
443 3         17 my $hours = new Set::IntSpan;
444              
445 3         182 while ( $hours->empty ) {
446 5 50       50 if ( $end >= $MAXTIME ) {
447 0         0 return -1;
448             }
449 5         17 $hours = $self->for_timespan( Start => $start, End => $end );
450 5         12 $start = $end;
451 5         18 $end = $start + $period;
452             }
453              
454 3         33 return $hours->first;
455             }
456              
457             =head2 add_seconds START, SECONDS
458              
459             Returns a time SECONDS business seconds after START.
460             START should be specified in seconds since the epoch.
461              
462             Returns -1 if it can't find any business hours within thirty days.
463              
464             =cut
465              
466             sub add_seconds {
467 3     3 1 3886 my $self = shift;
468 3         7 my $start = shift;
469 3         5 my $seconds = shift;
470              
471             # the maximum time after which we stop searching for business hours
472 3         6 my $MAXTIME = ( 30 * 24 * 60 * 60 ); # 30 days
473              
474 3         7 my $last;
475              
476 3         5 my $period = ( 24 * 60 * 60 );
477 3         5 my $end = $start + $period;
478              
479 3         18 my $hours = new Set::IntSpan;
480 3   100     80 while ($hours->empty
481             or $self->between( $start, $hours->last ) <= $seconds )
482             {
483 4 50       74 if ( $end >= $start + $MAXTIME ) {
484 0         0 return -1;
485             }
486 4         14 $hours = $self->for_timespan( Start => $start, End => $end );
487              
488 4         20 $end += $period;
489             }
490              
491 3         75 my @elements = elements $hours;
492 3         41335 $last = $elements[$seconds];
493              
494 3         2415 return $last;
495             }
496              
497             =head1 BUGS
498              
499             Yes, most likely. Please report them to L.
500              
501             =head1 AUTHOR
502              
503             Jesse Vincent, L
504              
505             =head1 COPYRIGHT
506              
507             Copyright 2003-2008 Best Practical Solutions, LLC.
508              
509             This program is free software; you can redistribute
510             it and/or modify it under the same terms as Perl itself.
511              
512             The full text of the license can be found in the LICENSE
513             file included with this module.
514              
515             =cut
516              
517             1;
518