detail links on new 477 report, #30360
[freeside.git] / FS / FS / Report / FCC_477.pm
1 package FS::Report::FCC_477;
2 use base qw( FS::Report );
3
4 use strict;
5 use vars qw( @upload @download @technology @part2aoption @part2boption
6              %states
7              $DEBUG
8            );
9 use FS::Record qw( dbh );
10
11 use Tie::IxHash;
12 use Storable;
13
14 $DEBUG = 0;
15
16 =head1 NAME
17
18 FS::Report::FCC_477 - Routines for FCC Form 477 reports
19
20 =head1 SYNOPSIS
21
22 =head1 BUGS
23
24 Documentation.
25
26 =head1 SEE ALSO
27
28 =cut
29
30 @upload = qw(
31  <200kbps
32  200-768kbps
33  768kbps-1.5mbps
34  1.5-3mpbs
35  3-6mbps
36  6-10mbps
37  10-25mbps
38  25-100mbps
39  >100mbps
40 );
41
42 @download = qw(
43  200-768kbps
44  768kbps-1.5mbps
45  1.5-3mbps
46  3-6mbps
47  6-10mbps
48  10-25mbps
49  25-100mbps
50  >100mbps
51 );
52
53 @technology = (
54   'Asymmetric xDSL',
55   'Symmetric xDSL',
56   'Other Wireline',
57   'Cable Modem',
58   'Optical Carrier',
59   'Satellite',
60   'Terrestrial Fixed Wireless',
61   'Terrestrial Mobile Wireless',
62   'Electric Power Line',
63   'Other Technology',
64 );
65
66 @part2aoption = (
67  'LD carrier',
68  'owned loops',
69  'unswitched UNE loops',
70  'UNE-P',
71  'UNE-P replacement',
72  'FTTP',
73  'coax',
74  'wireless',
75 );
76
77 @part2boption = (
78  'nomadic',
79  'copper',
80  'FTTP',
81  'coax',
82  'wireless',
83  'other broadband',
84 );
85
86 #from the select at http://www.ffiec.gov/census/default.aspx
87 #though this is now in the database, also
88 %states = (
89   '01' => 'ALABAMA (AL)',
90   '02' => 'ALASKA (AK)',
91   '04' => 'ARIZONA (AZ)',
92   '05' => 'ARKANSAS (AR)',
93   '06' => 'CALIFORNIA (CA)',
94   '08' => 'COLORADO (CO)',
95
96   '09' => 'CONNECTICUT (CT)',
97   '10' => 'DELAWARE (DE)',
98   '11' => 'DISTRICT OF COLUMBIA (DC)',
99   '12' => 'FLORIDA (FL)',
100   '13' => 'GEORGIA (GA)',
101   '15' => 'HAWAII (HI)',
102
103   '16' => 'IDAHO (ID)',
104   '17' => 'ILLINOIS (IL)',
105   '18' => 'INDIANA (IN)',
106   '19' => 'IOWA (IA)',
107   '20' => 'KANSAS (KS)',
108   '21' => 'KENTUCKY (KY)',
109
110   '22' => 'LOUISIANA (LA)',
111   '23' => 'MAINE (ME)',
112   '24' => 'MARYLAND (MD)',
113   '25' => 'MASSACHUSETTS (MA)',
114   '26' => 'MICHIGAN (MI)',
115   '27' => 'MINNESOTA (MN)',
116
117   '28' => 'MISSISSIPPI (MS)',
118   '29' => 'MISSOURI (MO)',
119   '30' => 'MONTANA (MT)',
120   '31' => 'NEBRASKA (NE)',
121   '32' => 'NEVADA (NV)',
122   '33' => 'NEW HAMPSHIRE (NH)',
123
124   '34' => 'NEW JERSEY (NJ)',
125   '35' => 'NEW MEXICO (NM)',
126   '36' => 'NEW YORK (NY)',
127   '37' => 'NORTH CAROLINA (NC)',
128   '38' => 'NORTH DAKOTA (ND)',
129   '39' => 'OHIO (OH)',
130
131   '40' => 'OKLAHOMA (OK)',
132   '41' => 'OREGON (OR)',
133   '42' => 'PENNSYLVANIA (PA)',
134   '44' => 'RHODE ISLAND (RI)',
135   '45' => 'SOUTH CAROLINA (SC)',
136   '46' => 'SOUTH DAKOTA (SD)',
137
138   '47' => 'TENNESSEE (TN)',
139   '48' => 'TEXAS (TX)',
140   '49' => 'UTAH (UT)',
141   '50' => 'VERMONT (VT)',
142   '51' => 'VIRGINIA (VA)',
143   '53' => 'WASHINGTON (WA)',
144
145   '54' => 'WEST VIRGINIA (WV)',
146   '55' => 'WISCONSIN (WI)',
147   '56' => 'WYOMING (WY)',
148   '72' => 'PUERTO RICO (PR)',
149 );
150
151 sub restore_fcc477map {
152   my $key = shift;
153   FS::Record::scalar_sql('',"select formvalue from fcc477map where formkey = ?",$key);
154 }
155
156 sub save_fcc477map {
157   my $key = shift;
158   my $value = shift;
159
160   local $SIG{HUP} = 'IGNORE';
161   local $SIG{INT} = 'IGNORE';
162   local $SIG{QUIT} = 'IGNORE';
163   local $SIG{TERM} = 'IGNORE';
164   local $SIG{TSTP} = 'IGNORE';
165   local $SIG{PIPE} = 'IGNORE';
166
167   my $oldAutoCommit = $FS::UID::AutoCommit;
168   local $FS::UID::AutoCommit = 0;
169   my $dbh = dbh;
170
171   my $sql = "delete from fcc477map where formkey = ?";
172   my $sth = dbh->prepare($sql) or die dbh->errstr;
173   $sth->execute($key) or do {
174     warn "WARNING: Error removing FCC 477 form defaults: " . $sth->errstr;
175     $dbh->rollback if $oldAutoCommit;
176   };
177
178   $sql = "insert into fcc477map (formkey,formvalue) values (?,?)";
179   $sth = dbh->prepare($sql) or die dbh->errstr;
180   $sth->execute($key,$value) or do {
181     warn "WARNING: Error setting FCC 477 form defaults: " . $sth->errstr;
182     $dbh->rollback if $oldAutoCommit;
183   };
184
185   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
186
187   '';
188 }
189
190 sub parse_technology_option {
191   my $cgi = shift;
192   my $save = shift;
193   my @result = ();
194   my $i = 0;
195   for (my $i = 0; $i < scalar(@technology); $i++) {
196     my $value = $cgi->param("part1_technology_option_$i"); #lame
197     save_fcc477map("part1_technology_option_$i",$value) 
198         if $save && $value =~ /^\d+$/;
199     push @result, $value =~ /^\d+$/ ? $value : 0;
200   }
201   return (@result);
202 }
203
204 sub statenum2state {
205   my $num = shift;
206   $states{$num};
207 }
208 ### everything above this point is unmaintained ###
209
210
211 =head1 THE "NEW" REPORT (October 2014 and later)
212
213 =head2 METHODS
214
215 =over 4
216
217 =cut
218
219 # functions for internal use
220
221 sub join_optionnames {
222   join(' ', map { join_optionname($_) } @_);
223 }
224
225 sub join_optionnames_int {
226   join(' ', map { join_optionname_int($_) } @_);
227 }
228
229 sub join_optionname {
230   # Returns a FROM phrase to join a specific option into the query (via 
231   # part_pkg).  The option value will appear as a field with the same name
232   # as the option.
233   my $name = shift;
234   "LEFT JOIN (SELECT pkgpart, optionvalue AS $name FROM part_pkg_fcc_option".
235     " WHERE fccoptionname = '$name') AS t_$name".
236     " ON (part_pkg.pkgpart = t_$name.pkgpart)";
237 }
238
239 sub join_optionname_int {
240   # Returns a FROM phrase to join a specific option into the query (via 
241   # part_pkg) and cast it to integer..  Note this does not convert nulls
242   # to zero.
243   my $name = shift;
244   "LEFT JOIN (SELECT pkgpart, CAST(optionvalue AS int) AS $name
245    FROM part_pkg_fcc_option".
246     " WHERE fccoptionname = '$name') AS t_$name".
247     " ON (part_pkg.pkgpart = t_$name.pkgpart)";
248 }
249
250 sub dbaname {
251   # Returns an sql expression for the DBA name
252   "COALESCE( deploy_zone.dbaname,
253      (SELECT value FROM conf WHERE conf.name = 'company_name'
254                              AND (conf.agentnum = deploy_zone.agentnum
255                                   OR conf.agentnum IS NULL)
256                              ORDER BY conf.agentnum IS NOT NULL DESC
257                              LIMIT 1)
258      ) AS dbaname"
259 }
260
261 sub active_on {
262   # Returns a condition to limit packages to those that were setup before a 
263   # certain date, and not canceled before that date.
264   #
265   # (Strictly speaking this should also exclude suspended packages but 
266   # "suspended as of some past date" is a complicated query.)
267   my $date = shift;
268   "cust_pkg.setup <= $date AND ".
269   "(cust_pkg.cancel IS NULL OR cust_pkg.cancel > $date)";
270 }
271
272 sub is_fixed_broadband {
273   "is_broadband::int = 1 AND technology::int IN( 10, 11, 12, 20, 30, 40, 41, 42, 50, 60, 70, 90, 0 )"
274 }
275
276 sub is_mobile_broadband {
277   "is_broadband::int = 1 AND technology::int IN( 80, 81, 82, 83, 84, 85, 86, 87, 88)"
278 }
279
280 =item report SECTION, OPTIONS
281
282 Returns the report section SECTION (see the C<parts> method for section 
283 name strings) as an arrayref of arrayrefs.  OPTIONS may contain "date"
284 (a timestamp value to run the report as of this date) and "agentnum"
285 (to limit to a single agent).
286
287 OPTIONS may also contain "detail", a flag that tells the report to return
288 a comma-separated list of the detail records included in each row count.
289
290 =cut
291
292 sub report {
293   my $class = shift;
294   my $section = shift;
295   my %opt = @_;
296
297   my $method = $section.'_sql';
298   die "Report section '$section' is not implemented\n"
299     unless $class->can($method);
300   my $statement = $class->$method(%opt);
301
302   my $sth = dbh->prepare($statement);
303   $sth->execute or die $sth->errstr;
304   $sth->fetchall_arrayref;
305 }
306
307 sub fbd_sql {
308   my $class = shift;
309   my %opt = @_;
310   my $date = $opt{date} || time;
311   warn $date;
312   my $agentnum = $opt{agentnum};
313
314   my @select = (
315     'censusblock',
316     dbaname(),
317     'technology',
318     'CASE WHEN is_consumer IS NOT NULL THEN 1 ELSE 0 END',
319     'adv_speed_down',
320     'adv_speed_up',
321     'CASE WHEN is_business IS NOT NULL THEN 1 ELSE 0 END',
322     'cir_speed_down',
323     'cir_speed_up',
324   );
325   push @select, 'blocknum' if $opt{detail};
326
327   my $from = 'deploy_zone_block
328     JOIN deploy_zone USING (zonenum)
329     JOIN agent USING (agentnum)';
330   my @where = (
331     "zonetype = 'B'",
332     "active_date  < $date",
333     "(expire_date > $date OR expire_date IS NULL)",
334   );
335   push @where, "agentnum = $agentnum" if $agentnum;
336
337   my $order_by = 'censusblock, agentnum, technology, is_consumer, is_business';
338
339   "SELECT ".join(', ', @select) . "
340   FROM $from
341   WHERE ".join(' AND ', @where)."
342   ORDER BY $order_by
343   ";
344 }
345
346 sub fbs_sql {
347   my $class = shift;
348   my %opt = @_;
349   my $date = $opt{date} || time;
350   my $agentnum = $opt{agentnum};
351
352   my @select = (
353     'cust_location.censustract',
354     'technology',
355     'broadband_downstream',
356     'broadband_upstream',
357     'COUNT(*)',
358     'COUNT(is_consumer)',
359   );
360   push @select, "array_to_string(array_agg(pkgnum), ',')" if $opt{detail};
361
362   my $from =
363     'cust_pkg
364       JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
365       JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
366       JOIN part_pkg USING (pkgpart) '.
367       join_optionnames_int(qw(
368         is_broadband technology 
369         is_consumer
370         )).
371       join_optionnames(qw(broadband_downstream broadband_upstream))
372   ;
373   my @where = (
374     active_on($date),
375     is_fixed_broadband()
376   );
377   push @where, "cust_main.agentnum = $agentnum" if $agentnum;
378   my $group_by = 'cust_location.censustract, technology, '.
379                    'broadband_downstream, broadband_upstream ';
380   my $order_by = $group_by;
381
382   "SELECT ".join(', ', @select) . "
383   FROM $from
384   WHERE ".join(' AND ', @where)."
385   GROUP BY $group_by
386   ORDER BY $order_by
387   ";
388
389 }
390
391 sub fvs_sql {
392   my $class = shift;
393   my %opt = @_;
394   my $date = $opt{date} || time;
395   my $agentnum = $opt{agentnum};
396
397   my @select = (
398     'cust_location.censustract',
399     # VoIP indicator (0 for non-VoIP, 1 for VoIP)
400     'COALESCE(is_voip, 0)',
401     # number of lines/subscriptions
402     'SUM(CASE WHEN is_voip = 1 THEN 1 ELSE phone_lines END)',
403     # consumer grade lines/subscriptions
404     'SUM(CASE WHEN is_consumer = 1 THEN ( CASE WHEN is_voip = 1 THEN voip_sessions ELSE phone_lines END) ELSE 0 END)',
405   );
406   push @select, "array_to_string(array_agg(pkgnum), ',')" if $opt{detail};
407
408   my $from = 'cust_pkg
409     JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
410     JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
411     JOIN part_pkg USING (pkgpart) '.
412     join_optionnames_int(qw(
413       is_phone is_voip is_consumer phone_lines voip_sessions
414       ))
415   ;
416
417   my @where = (
418     active_on($date),
419     "(is_voip = 1 OR is_phone = 1)",
420   );
421   push @where, "cust_main.agentnum = $agentnum" if $agentnum;
422   my $group_by = 'cust_location.censustract, COALESCE(is_voip, 0)';
423   my $order_by = $group_by;
424
425   "SELECT ".join(', ', @select) . "
426   FROM $from
427   WHERE ".join(' AND ', @where)."
428   GROUP BY $group_by
429   ORDER BY $order_by
430   ";
431
432 }
433
434 sub lts_sql {
435   my $class = shift;
436   my %opt = @_;
437   my $date = $opt{date} || time;
438   my $agentnum = $opt{agentnum};
439
440   my @select = (
441     "state.fips",
442     "SUM(phone_vges)",
443     "SUM(phone_circuits)",
444     "SUM(phone_lines)",
445     "SUM(CASE WHEN is_broadband = 1 THEN phone_lines ELSE 0 END)",
446     "SUM(CASE WHEN is_consumer = 1 AND phone_longdistance IS NULL THEN phone_lines ELSE 0 END)",
447     "SUM(CASE WHEN is_consumer = 1 AND phone_longdistance = 1 THEN phone_lines ELSE 0 END)",
448     "SUM(CASE WHEN is_consumer IS NULL AND phone_longdistance IS NULL THEN phone_lines ELSE 0 END)",
449     "SUM(CASE WHEN is_consumer IS NULL AND phone_longdistance = 1 THEN phone_lines ELSE 0 END)",
450     "SUM(CASE WHEN phone_localloop = 'owned' THEN phone_lines ELSE 0 END)",
451     "SUM(CASE WHEN phone_localloop = 'leased' THEN phone_lines ELSE 0 END)",
452     "SUM(CASE WHEN phone_localloop = 'resale' THEN phone_lines ELSE 0 END)",
453     "SUM(CASE WHEN media = 'Fiber' THEN phone_lines ELSE 0 END)",
454     "SUM(CASE WHEN media = 'Cable Modem' THEN phone_lines ELSE 0 END)",
455     "SUM(CASE WHEN media = 'Fixed Wireless' THEN phone_lines ELSE 0 END)",
456   );
457   push @select, "array_to_string(array_agg(pkgnum),',')" if $opt{detail};
458
459   my $from =
460     'cust_pkg
461       JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
462       JOIN state USING (country, state)
463       JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
464       JOIN part_pkg USING (pkgpart) '.
465       join_optionnames_int(qw(
466         is_phone is_broadband
467         phone_vges phone_circuits phone_lines
468         is_consumer phone_longdistance
469         )).
470       join_optionnames('media', 'phone_localloop')
471   ;
472   my @where = (
473     active_on($date),
474     "is_phone = 1",
475   );
476   push @where, "cust_main.agentnum = $agentnum" if $agentnum;
477   my $group_by = 'state.fips';
478   my $order_by = $group_by;
479
480   "SELECT ".join(', ', @select) . "
481   FROM $from
482   WHERE ".join(' AND ', @where)."
483   GROUP BY $group_by
484   ORDER BY $order_by
485   ";
486 }
487
488 sub voip_sql {
489   my $class = shift;
490   my %opt = @_;
491   my $date = $opt{date} || time;
492   my $agentnum = $opt{agentnum};
493
494   my @select = (
495     "state.fips",
496     # OTT, OTT + consumer
497     "SUM(CASE WHEN (voip_lastmile IS NULL) THEN 1 ELSE 0 END)",
498     "SUM(CASE WHEN (voip_lastmile IS NULL AND is_consumer = 1) THEN 1 ELSE 0 END)",
499     # non-OTT: total, consumer, broadband bundle, media types
500     "SUM(CASE WHEN (voip_lastmile = 1) THEN 1 ELSE 0 END)",
501     "SUM(CASE WHEN (voip_lastmile = 1 AND is_consumer = 1) THEN 1 ELSE 0 END)",
502     "SUM(CASE WHEN (voip_lastmile = 1 AND is_broadband = 1) THEN 1 ELSE 0 END)",
503     "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Copper') THEN 1 ELSE 0 END)",
504     "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Cable Modem') THEN 1 ELSE 0 END)",
505     "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Fiber') THEN 1 ELSE 0 END)",
506     "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Fixed Wireless') THEN 1 ELSE 0 END)",
507     "SUM(CASE WHEN (voip_lastmile = 1 AND media NOT IN('Copper', 'Fiber', 'Cable Modem', 'Fixed Wireless') ) THEN 1 ELSE 0 END)",
508   );
509   push @select, "array_to_string(array_agg(pkgnum),',')" if $opt{detail};
510
511   my $from =
512     'cust_pkg
513       JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
514       JOIN state USING (country, state)
515       JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
516       JOIN part_pkg USING (pkgpart) '.
517       join_optionnames_int(
518         qw( is_voip is_broadband is_consumer voip_lastmile)
519       ).
520       join_optionnames('media')
521   ;
522   my @where = (
523     active_on($date),
524     "is_voip = 1",
525   );
526   push @where, "cust_main.agentnum = $agentnum" if $agentnum;
527   my $group_by = 'state.fips';
528   my $order_by = $group_by;
529
530   "SELECT ".join(', ', @select) . "
531   FROM $from
532   WHERE ".join(' AND ', @where)."
533   GROUP BY $group_by
534   ORDER BY $order_by
535   ";
536 }
537
538 sub mbs_sql {
539   my $class = shift;
540   my %opt = @_;
541   my $date = $opt{date} || time;
542   my $agentnum = $opt{agentnum};
543
544   my @select = (
545     'state.fips',
546     'broadband_downstream',
547     'broadband_upstream',
548     'COUNT(*)',
549     'COUNT(is_consumer)',
550   );
551   push @select, "array_to_string(array_agg(pkgnum),',')" if $opt{detail};
552
553   my $from =
554     'cust_pkg
555       JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
556       JOIN state USING (country, state)
557       JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
558       JOIN part_pkg USING (pkgpart) '.
559       join_optionnames_int(qw(
560         is_broadband technology
561         is_consumer
562         )).
563       join_optionnames(qw(broadband_downstream broadband_upstream))
564   ;
565   my @where = (
566     active_on($date),
567     is_mobile_broadband()
568   );
569   push @where, "cust_main.agentnum = $agentnum" if $agentnum;
570   my $group_by = 'state.fips, broadband_downstream, broadband_upstream ';
571   my $order_by = $group_by;
572
573   "SELECT ".join(', ', @select) . "
574   FROM $from
575   WHERE ".join(' AND ', @where)."
576   GROUP BY $group_by
577   ORDER BY $order_by
578   ";
579 }
580
581 sub mvs_sql {
582   my $class = shift;
583   my %opt = @_;
584   my $date = $opt{date} || time;
585   my $agentnum = $opt{agentnum};
586
587   my @select = (
588     'state.fips',
589     'COUNT(*)',
590     'COUNT(mobile_direct)',
591   );
592   push @select, "array_to_string(array_agg(pkgnum),',')" if $opt{detail};
593
594   my $from =
595     'cust_pkg
596       JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
597       JOIN state USING (country, state)
598       JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
599       JOIN part_pkg USING (pkgpart) '.
600       join_optionnames_int(qw( is_mobile mobile_direct) )
601   ;
602   my @where = (
603     active_on($date),
604     'is_mobile = 1'
605   );
606   push @where, "cust_main.agentnum = $agentnum" if $agentnum;
607   my $group_by = 'state.fips';
608   my $order_by = $group_by;
609
610   "SELECT ".join(', ', @select) . "
611   FROM $from
612   WHERE ".join(' AND ', @where)."
613   GROUP BY $group_by
614   ORDER BY $order_by
615   ";
616 }
617
618 =item parts
619
620 Returns a Tie::IxHash reference of the internal short names used for the 
621 report sections ('fbd', 'mbs', etc.) to the full names.
622
623 =cut
624
625 tie our %parts, 'Tie::IxHash', (
626   fbd   => 'Fixed Broadband Deployment',
627   fbs   => 'Fixed Broadband Subscription',
628   fvs   => 'Fixed Voice Subscription',
629   lts   => 'Local Exchange Telephone Subscription',
630   voip  => 'Interconnected VoIP Subscription',
631   mbd   => 'Mobile Broadband Deployment',
632   mbsa  => 'Mobile Broadband Service Availability',
633   mbs   => 'Mobile Broadband Subscription',
634   mvd   => 'Mobile Voice Deployment',
635   mvs   => 'Mobile Voice Subscription',
636 );
637
638 sub parts {
639   Storable::dclone(\%parts);
640 }
641
642 =item part_table SECTION
643
644 Returns the name of the primary table that's aggregated in the report section 
645 SECTION. The last column of the report returned by the L</report> method is 
646 a comma-separated list of record numbers, in this table, that are included in
647 the report line item.
648
649 =cut
650
651 sub part_table {
652   my ($class, $part) = @_;
653   if ($part eq 'fbd') {
654     return 'deploy_zone_block';
655   } else {
656     return 'cust_pkg';
657   } # add other cases as we add more of the deployment/availability reports
658 }
659
660 1;