File Coverage

blib/lib/DateTimeX/Fiscal/Fiscal5253.pm
Criterion Covered Total %
statement 155 165 93.9
branch 59 72 81.9
condition 20 26 76.9
subroutine 20 21 95.2
pod 5 6 83.3
total 259 290 89.3


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