File Coverage

blib/lib/DateTimeX/Fiscal/Fiscal5253.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package DateTimeX::Fiscal::Fiscal5253;
2              
3 1     1   40077 use strict;
  1         3  
  1         85  
4 1     1   7 use warnings FATAL => 'all';
  1         2  
  1         113  
5              
6             our $VERSION = '1.06';
7              
8 1     1   7 use Carp;
  1         8  
  1         104  
9 1     1   12273 use DateTime;
  0            
  0            
10             use POSIX qw( strftime );
11              
12             my @params = (
13             qw(
14             end_month
15             end_dow
16             end_type
17             leap_period
18             year
19             date
20             )
21             );
22              
23             my $defaults = {
24             end_month => 12,
25             end_dow => 6,
26             end_type => 'last',
27             leap_period => 'last',
28             };
29              
30             my @periodmonths = (
31             qw(
32             January
33             February
34             March
35             April
36             May
37             June
38             July
39             August
40             September
41             October
42             November
43             December
44             )
45             );
46              
47             # Automate creation of standard read-only accessors.
48             my %attributes = map { $_ => 0 } @params;
49             $attributes{start} = '_start_ymd';
50             $attributes{end} = '_end_ymd';
51             $attributes{weeks} = '_weeks';
52              
53             # This one does not get an accessor.
54             delete( $attributes{date} );
55              
56             # Define getters for each attribute.
57             # This does NOT affect POD coverage testing.
58             while ( my ( $attr, $fld ) = each(%attributes) ) {
59             my $key = $fld || $attr;
60             my $method = join( '::', __PACKAGE__, ${attr} );
61             {
62             no strict 'refs';
63             *$method = sub {
64             my $self = shift;
65             croak "Trying to set read-only accessor: $attr" if @_;
66              
67             return $self->{$key};
68             }
69             }
70             }
71              
72             # Utility function to validate values supplied as a calendar style.
73             my $_valid_cal_style = sub {
74             my $style = shift || 'fiscal';
75              
76             $style =~ tr/A-Z/a-z/;
77             croak "Invalid calendar style specified: $style"
78             unless $style =~ /^(fiscal|restated|truncated)$/;
79              
80             return $style;
81             };
82              
83             # Utility function to convert a date string to a DT object
84             my $_str2dt = sub {
85             my $date = shift;
86              
87             # convert date param to DT object
88             my ( $y, $m, $d );
89             if ( $date =~ m{^(\d{4})-(\d{1,2})-(\d{1,2})($|\D+)} ) {
90             $y = $1, $m = $2, $d = $3;
91             }
92             elsif ( $date =~ m{^(\d{1,2})/(\d{1,2})/(\d{4})($|\D+)} ) {
93             $y = $3, $m = $1, $d = $2;
94             }
95             else {
96             croak "Unable to parse date string: $date";
97             }
98             eval { $date = DateTime->new( year => $y, month => $m, day => $d ); }
99             or croak "Invalid date: $date";
100              
101             return $date;
102             };
103              
104             # Figure out if the epoch is a 32- or 64-bit value and use DT if needed
105             my $_use_dt = sub {
106             my $year = shift;
107              
108             # test for 32- or 64-bit time values. This is in an eval because there
109             # is apparently a bug in Perl 5.10.0 on a Win32 v5 build that causes
110             # gmtime to return undefs when the epoch rolls over. This of course
111             # will throw an uninitialized error with "use warnings FATAL => 'all'"
112             # in effect.
113             my $is_32 = eval {
114             my @tdata = gmtime(2147483651); # This is 4 sec past the rollover
115             return ( $tdata[5] == 138 );
116             };
117              
118             return $is_32 && ( $year < 1903 || $year > 2037 );
119             };
120              
121             # Build the week array once, then manipulate as needed.
122             # Using epoch math is more than an order of magnitude faster
123             # than DT, but the size of the epoch value must be tested.
124             my $_build_weeks = sub {
125             my $self = shift;
126              
127             my $weeks = {};
128             if ( &{$_use_dt}( $self->{year} ) ) {
129             my $wstart = $self->{_start}->clone;
130             my $wend = $self->{_start}->clone->add( days => 6 );
131              
132             for ( 1 .. $self->{_weeks} ) {
133             $weeks->{$_} = {
134             start => $wstart->ymd,
135             end => $wend->ymd,
136             };
137              
138             # skip the last step so the ending values are preserved
139             # if needed for something else in the future.
140             last if $_ == $self->{_weeks};
141              
142             $wstart->add( days => 7 );
143             $wend->add( days => 7 );
144             }
145             }
146             else {
147             my $daysecs = ( 60 * 60 * 24 );
148             my $weeksecs = $daysecs * 7;
149              
150             my $wstart = $self->{_start}->epoch + ( $daysecs / 2 );
151             my $wend = $wstart + ( $daysecs * 6 );
152              
153             for ( 1 .. $self->{_weeks} ) {
154             $weeks->{$_} = {
155             start => strftime( '%Y-%m-%d', localtime($wstart) ),
156             end => strftime( '%Y-%m-%d', localtime($wend) ),
157             };
158              
159             # skip the last step so the ending values are preserved
160             # if needed for something else in the future.
161             last if $_ == $self->{_weeks};
162              
163             $wstart += $weeksecs;
164             $wend += $weeksecs;
165             }
166             }
167              
168             $self->{_weeks_raw} = $weeks;
169              
170             return;
171             };
172              
173             # This code ref builds the basic calendar structures as needed.
174             my $_build_periods = sub {
175             my $self = shift;
176             my $style = shift || $self->{_style};
177              
178             # not strictly needed, but makes for easier to read code
179             my $restate = $style eq 'restated' ? 1 : 0;
180             my $truncate = $style eq 'truncated' ? 1 : 0;
181              
182             # Avoid re-builds when possible.
183             return if $restate && defined( $self->{_restated} );
184             return if $truncate && defined( $self->{_truncated} );
185              
186             # Disabled this for now, becomes problematic for various
187             # methods such as "contains" in normal years.
188             # return if ($restate || $truncate) && $self->{_weeks} == 52;
189              
190             my $pstart = $self->{_start}->clone;
191              
192             # This value is confusing only because it is 0-based unlike
193             # the other month values.
194             my $p1month = $self->{end_month} == 12 ? 0 : $self->{end_month};
195             my @pweeks = ( 4, 4, 5, 4, 4, 5, 4, 4, 5, 4, 4, 5 );
196             my $wkcnt = 52;
197              
198             # a truncated structure ignores the last week in a 53 week year
199             if ( $self->{_weeks} == 53 && !$truncate ) {
200             if ($restate) {
201              
202             # ignore the fist week and treat as any other 52 week year
203             $pstart->add( days => 7 );
204             }
205             elsif ( $self->{leap_period} eq 'first' ) {
206             $pweeks[$p1month] += 1;
207             $wkcnt = 53;
208             }
209             else {
210             $pweeks[ $self->{end_month} - 1 ] += 1;
211             $wkcnt = 53;
212             }
213             }
214              
215             my $pdata = {
216             summary => {
217             style => $style,
218             year => $self->{year},
219             end_month => $self->{end_month},
220             end_dow => $self->{end_dow},
221             end_type => $self->{end_type},
222             leap_period => $self->{leap_period},
223             weeks => $wkcnt,
224             start => $pstart->ymd,
225             end => undef, # this is set after the cache is built
226             }
227             };
228              
229             my $wdata = {};
230             my $wkcntr = 1;
231             for ( 0 .. 11 ) {
232             my $p_index = ( $p1month + $_ ) % 12;
233              
234             my $pinfo = {
235             period => $_ + 1,
236             weeks => $pweeks[$p_index],
237             month => $periodmonths[$p_index]
238             };
239              
240             for my $pw ( 1 .. $pweeks[$p_index] ) {
241             my $wksrc = $restate ? $wkcntr + 1 : $wkcntr;
242             my $winfo = {
243             week => $wkcntr,
244             period => $_ + 1,
245             period_week => $pw,
246             start => $self->{_weeks_raw}->{$wksrc}->{start},
247             end => $self->{_weeks_raw}->{$wksrc}->{end},
248             };
249             $pinfo->{start} = $winfo->{start} if $pw == 1;
250             $pinfo->{end} = $winfo->{end} if $pw == $pweeks[$p_index];
251             $wdata->{$wkcntr} = $winfo;
252             $wkcntr++;
253             }
254              
255             $pdata->{ $_ + 1 } = $pinfo;
256             }
257             $pdata->{summary}->{end} = $pdata->{12}->{end};
258              
259             if ( $self->{_weeks} == 52 ) {
260              
261             # Set style to 'fiscal' and assign the structure to all
262             # three calendar types in a normal year to save time and space.
263             $pdata->{summary}->{style} = 'fiscal';
264             $self->{_fiscal} = $self->{_restated} = $self->{_truncated} = $pdata;
265             $self->{_fiscal_weeks} = $wdata;
266             $self->{_restated_weeks} = $wdata;
267             $self->{_truncated_weeks} = $wdata;
268             }
269             else {
270             $self->{"_$style"} = $pdata;
271             $self->{"_${style}_weeks"} = $wdata;
272             }
273              
274             return;
275             };
276              
277             # The end day for a specified year is trivial to determine. In normal
278             # accounting use, a fiscal year is named for the calendar year it ends in,
279             # not the year it begins.
280             sub _end5253 {
281             my $self = shift;
282              
283             my $dt = DateTime->last_day_of_month(
284             year => $self->{year},
285             month => $self->{end_month},
286             time_zone => 'floating'
287             );
288              
289             my $eom_day = $dt->day;
290             my $dt_dow = $dt->dow;
291              
292             if ( $dt_dow > $self->{end_dow} ) {
293             $dt->subtract( days => $dt_dow - $self->{end_dow} );
294             }
295             elsif ( $dt_dow < $self->{end_dow} ) {
296             $dt->subtract( days => ( $dt_dow + 7 ) - $self->{end_dow} );
297             }
298             $dt->add( weeks => 1 )
299             if $self->{end_type} eq 'closest' && $eom_day - $dt->day > 3;
300              
301             return $dt;
302             }
303              
304             # Finding the starting day for a specified year is easy. Simply find
305             # the last day of the preceding year since the year is defined by
306             # the ending day and add 1 day to that. This avoids calendar year and month
307             # boundary issues.
308             sub _start5253 {
309             my $self = shift;
310              
311             # do not assume it is safe to change the year attribute
312             local $self->{year} = $self->{year} - 1;
313             my $dt = $self->_end5253->add( days => 1 );
314              
315             return $dt;
316             }
317              
318             # Determine the correct fiscal year for any given date
319             sub _find5253 {
320             my $self = shift;
321              
322             my $y1 = $self->{date}->year;
323              
324             # do not assume it is safe to change the year attribute
325             local $self->{year} = $y1;
326              
327             my $e1 = $self->_end5253;
328             return $y1 + 1 if $e1 < $self->{date};
329              
330             my $s1 = $self->_start5253;
331             return $y1 - 1 if $s1 > $self->{date};
332              
333             return $y1;
334             }
335              
336             # Duh
337             sub new {
338             my $proto = shift;
339             my %args = @_;
340              
341             # normalize end_type arg
342             $args{end_type} =~ tr/A-Z/a-z/ if $args{end_type};
343              
344             # normalize leap_period arg
345             $args{leap_period} =~ tr/A-Z/a-z/ if $args{leap_period};
346              
347             # do basic validation and set controlling params as needed
348             # the default is to end on the last Saturday of December
349             foreach ( keys( %{$defaults} ) ) {
350             $args{$_} = $defaults->{$_} if !defined( $args{$_} );
351             }
352              
353             croak "Invalid value for param end_type: $args{end_type}"
354             unless $args{end_type} =~ /^(?:last|closest)$/;
355             croak "Invalid value for param end_month: $args{end_month}"
356             unless $args{end_month} =~ /^(?:1[0-2]|[1-9])\z/;
357             croak "Invalid value for param end_dow: $args{end_dow}"
358             unless $args{end_dow} =~ /^[1-7]\z/;
359             croak "Invalid value for param leap_period: $args{leap_period}"
360             unless $args{leap_period} =~ /^(?:first|last)$/;
361              
362             # which one would be correct?
363             croak 'Mutually exclusive parameters "year" and "date" present'
364             if $args{year} && $args{date};
365              
366             croak 'Object in "date" parameter is not a member of DateTime'
367             if ref( $args{date} ) && !$args{date}->isa('DateTime');
368              
369             if ( ref( $args{date} ) ) {
370             $args{date} = $args{date}->clone;
371             }
372             elsif ( $args{date} ) {
373              
374             # _str2dt will croak on error
375             $args{date} = &{$_str2dt}( $args{date} );
376             }
377             elsif ( !$args{year} ) {
378             $args{date} = DateTime->today();
379             }
380              
381             # All parameters have been validated, make the object.
382             my $class = ref($proto) || $proto;
383             my $self = bless {
384             _style => 'fiscal',
385             _fiscal => undef,
386             _restated => undef,
387             _truncated => undef,
388             }, $class;
389             foreach (@params) {
390             $self->{$_} = delete( $args{$_} );
391             }
392              
393             # Be sure there are none left over.
394             if ( scalar( keys(%args) ) ) {
395             croak 'Unknown parameter(s): ' . join( ',', keys(%args) );
396             }
397              
398             # Set the year from the data attribute if that was given.
399             if ( $self->{date} ) {
400             $self->{date}->truncate( to => 'day' )->set_time_zone('floating');
401             $self->{year} = _find5253($self);
402             }
403              
404             $self->{_start} = $self->_start5253;
405             $self->{_start_ymd} = $self->{_start}->ymd;
406             $self->{_end} = $self->_end5253;
407             $self->{_end_ymd} = $self->{_end}->ymd;
408              
409             $self->{_weeks} =
410             $self->{_start}->clone->add( days => 367 ) > $self->{_end} ? 52 : 53;
411              
412             $self->$_build_weeks;
413             $self->$_build_periods('fiscal');
414              
415             if ( $self->{_weeks} == 53 ) {
416             $self->$_build_periods('restated');
417             $self->$_build_periods('truncated');
418             }
419              
420             return $self;
421             }
422              
423             sub has_leap_week {
424             my $self = shift;
425              
426             return ( $self->{_weeks} == 53 ? 1 : 0 );
427             }
428              
429             sub style {
430             my $self = shift;
431              
432             if (@_) {
433             croak 'Too many arguments' if @_ > 1;
434             $self->{_style} = &{$_valid_cal_style}(shift);
435             }
436              
437             return $self->{_style};
438             }
439              
440             # return summary data about a calendar.
441             sub summary {
442             my $self = shift;
443             my %args = @_ == 1 ? ( style => shift ) : @_;
444              
445             $args{style} ||= $self->{_style};
446             croak 'Unknown parameter present' if scalar( keys(%args) ) > 1;
447              
448             my $cal = &{$_valid_cal_style}( $args{style} );
449              
450             my %cdata;
451             for (qw( style year start end weeks )) {
452             $cdata{$_} = $self->{"_$cal"}->{summary}->{$_};
453             }
454              
455             return wantarray ? %cdata : \%cdata;
456             }
457              
458             sub contains {
459             my $self = shift;
460             my %args = @_ == 1 ? ( date => shift ) : @_;
461              
462             $args{date} ||= 'today';
463             $args{style} ||= $self->{_style};
464              
465             croak 'Unknown parameter present' if scalar( keys(%args) ) > 2;
466              
467             my $cal = &{$_valid_cal_style}( $args{style} );
468              
469             # Yes, a DT object set to "today" would work, but this is faster.
470             # NOTE! This will break in 2038 on 32-bit builds!
471             $args{date} = strftime( "%Y-%m-%d", localtime() )
472             if ( lc( $args{date} ) eq 'today' );
473              
474             # _str2dt will croak on error
475             my $date = &{$_str2dt}( $args{date} )->ymd;
476              
477             my $whash = $self->{"_${cal}_weeks"};
478             my $cdata = $self->{"_$cal"}->{summary};
479              
480             # it is NOT an error if the date isn't in the calendar,
481             # so return undef to differentiate this from an error condition
482             return if $date lt $cdata->{start} || $date gt $cdata->{end};
483              
484             # since the date is in the calendar, let's return it's week,
485             # and optionally, a structure with period and week number.
486              
487             my $w;
488             for ( $w = 1 ; $date gt $whash->{$w}->{end} ; $w++ ) {
489              
490             # this should NEVER fire!
491             croak 'FATAL ERROR! RAN OUT OF WEEKS' if $w > $cdata->{weeks};
492             }
493             my $p = $whash->{$w}->{period};
494              
495             return wantarray ? ( period => $p, week => $w ) : $w;
496             }
497              
498             # Utiliy routine, hidden from public use, to prevent duplicate code in
499             # the period attribute accessors.
500             my $_period_attr = sub {
501             my $self = shift;
502             my $attr = shift;
503             my %args = @_ == 1 ? ( period => shift ) : @_;
504              
505             $args{period} ||= 0;
506             $args{style} ||= $self->{_style};
507              
508             croak 'Unknown parameter present' if scalar( keys(%args) ) > 2;
509              
510             my $cal = &{$_valid_cal_style}( $args{style} );
511              
512             if ( $args{period} < 1 || $args{period} > 12 ) {
513             croak "Invalid period specified: $args{period}";
514             }
515              
516             # return a copy so the guts hopefully can't be changed
517             my %phash = %{ $self->{"_$cal"}->{ $args{period} } };
518              
519             return $attr eq 'period' ? %phash : $phash{$attr};
520             };
521              
522             # Automate creating period attribute mehtods
523             for my $p_attr (qw( month start end weeks )) {
524             my $method = join( '::', __PACKAGE__, "period_${p_attr}" );
525             {
526             no strict 'refs';
527             *$method = sub {
528             my $self = shift;
529              
530             return $self->$_period_attr( $p_attr, @_ );
531             }
532             }
533             }
534              
535             sub period {
536             my $self = shift;
537             my %args = @_ == 1 ? ( period => shift ) : @_;
538              
539             my %phash = $self->$_period_attr( 'period', %args );
540              
541             return wantarray ? %phash : \%phash;
542             }
543              
544             # Utiliy routine, hidden from public use, to prevent duplicate code in
545             # the week attribute accessors.
546             my $_week_attr = sub {
547             my $self = shift;
548             my $attr = shift;
549             my %args = @_ == 1 ? ( week => shift ) : @_;
550              
551             $args{week} ||= 0;
552             $args{style} ||= $self->{_style};
553              
554             croak 'Unknown parameter present' if scalar( keys(%args) ) > 2;
555              
556             my $cal = &{$_valid_cal_style}( $args{style} );
557              
558             if ( $args{week} < 1
559             || $args{week} > $self->{"_$cal"}->{summary}->{weeks} )
560             {
561             croak "Invalid week specified: $args{week}";
562             }
563              
564             # make a copy so the outside (hopefully) won't change the guts
565             my %whash = %{ $self->{"_${cal}_weeks"}->{ $args{week} } };
566              
567             return $attr eq 'week' ? %whash : $whash{$attr};
568             };
569              
570             sub week {
571             my $self = shift;
572             my %args = @_ == 1 ? ( week => shift ) : @_;
573              
574             my %whash = $self->$_week_attr( 'week', %args );
575              
576             return wantarray ? %whash : \%whash;
577             }
578              
579             # Automate creating week attribute mehtods
580             for my $w_attr (qw( period period_week start end )) {
581             my $method = join( '::', __PACKAGE__, "week_${w_attr}" );
582             {
583             no strict 'refs';
584             *$method = sub {
585             my $self = shift;
586              
587             return $self->$_week_attr( $w_attr, @_ );
588             }
589             }
590             }
591              
592             1;
593              
594             __END__