[freeside-commits] branch master updated. 0c72c2bf6a4f6e77fc7bea698b428a66febcae79

Mark Wells mark at 420.am
Wed Jul 23 15:53:59 PDT 2014


The branch, master has been updated
       via  0c72c2bf6a4f6e77fc7bea698b428a66febcae79 (commit)
       via  8fdc0ea36474cfb3d1389f41691c14598559cbe7 (commit)
      from  08db5f6900bb754efb597a2967adde4dbd12e731 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 0c72c2bf6a4f6e77fc7bea698b428a66febcae79
Author: Mark Wells <mark at freeside.biz>
Date:   Wed Jul 23 14:25:42 2014 -0700

    bugfixes

diff --git a/bin/convert-477-options b/bin/convert-477-options
index 48a5264..2b8970a 100755
--- a/bin/convert-477-options
+++ b/bin/convert-477-options
@@ -60,36 +60,43 @@ my @voip_option = (
   'media:Other'
 );
 
-my %num_to_formkey = map { $_->formvalue => $_->formkey }
-                      qsearch('fcc477map', {});
+my %num_to_formkey; # o2m
+foreach ( qsearch('fcc477map', {}) ) {
+  push @{ $num_to_formkey{$_->formvalue} ||= [] }, $_->formkey;
+}
 
 sub report_option_to_fcc_option {
   my $report_option_num = shift;
-  my $formkey = $num_to_formkey{$report_option_num}
+  my $formkeys = $num_to_formkey{$report_option_num}
     or return;
-  if      ($formkey =~ /^part1_column_option_(\d+)/) {
-    #download speed
-    return (broadband_downstream => $min_download_speed[$1]);
-  } elsif ($formkey =~ /^part1_row_option_(\d+)/) {
-    #upload speed
-    return (broadband_upstream   => $min_upload_speed[$1]);
-  } elsif ($formkey =~ /^part1_technology_option_(\d+)/) {
-    #broadband tech
-    return (is_broadband  => 1,
-            media         => $media_type[$1],
-            technology    => $technology[$1]);
-  } elsif ($formkey =~ /^part2a_row_option_(\d+)/) {
-    #local phone options
-    return (media => 'Copper', # sensible default
-            split(':', $phone_option[$1])
-           );
-  } elsif ($formkey =~ /^part2b_row_option_(\d+)/) {
-    #VoIP options (are all media types)
-    return (split(':', $voip_option[$1]));
-  } else {
-    warn "can't parse option with formkey '$formkey'\n";
-    return;
+  my @return;
+  foreach my $formkey (@$formkeys) {
+    if      ($formkey =~ /^part1_column_option_(\d+)/) {
+      #download speed
+      push @return, (broadband_downstream => $min_download_speed[$1]);
+    } elsif ($formkey =~ /^part1_row_option_(\d+)/) {
+      #upload speed
+      push @return, (broadband_upstream   => $min_upload_speed[$1]);
+    } elsif ($formkey =~ /^part1_technology_option_(\d+)/) {
+      #broadband tech
+      push @return, 
+             (is_broadband  => 1,
+              media         => $media_type[$1],
+              technology    => $technology[$1]);
+    } elsif ($formkey =~ /^part2a_row_option_(\d+)/) {
+      #local phone options
+      push @return,
+             (media => 'Copper', # sensible default
+              split(':', $phone_option[$1])
+             );
+    } elsif ($formkey =~ /^part2b_row_option_(\d+)/) {
+      #VoIP options (are all media types)
+      push @return, (split(':', $voip_option[$1]));
+    } else {
+      warn "can't parse option with formkey '$formkey'\n";
+    }
   }
+  @return;
 }
 
 for my $part_pkg (qsearch('part_pkg', { freq => {op => '!=', value => '0'}})) {
diff --git a/httemplate/search/old477/477partIA.html b/httemplate/search/old477/477partIA.html
index 55e901b..268b5c6 100755
--- a/httemplate/search/old477/477partIA.html
+++ b/httemplate/search/old477/477partIA.html
@@ -87,10 +87,6 @@ for ( qw(agentnum state) ) {
 $search_hash{'country'} = 'US';
 $search_hash{'classnum'} = [ $cgi->param('classnum') ];
 
-my $info = FS::part_pkg_fcc_option->info;
-
-
-
 # arrays of report_option_ numbers, running parallel to 
 # the download and upload speed arrays
 my @download_option = $cgi->param('part1_column_option');

commit 8fdc0ea36474cfb3d1389f41691c14598559cbe7
Author: Mark Wells <mark at freeside.biz>
Date:   Mon Jul 21 15:35:33 2014 -0700

    477 report rewrite, #28020

diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index 41b6d24..ea00748 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -3465,13 +3465,6 @@ and customer address. Include units.',
   },
 
   {
-    'key'         => 'cust_pkg-show_fcc_voice_grade_equivalent',
-    'section'     => 'UI',
-    'description' => "Show fields on package definitions for FCC Form 477 classification",
-    'type'        => 'checkbox',
-  },
-
-  {
     'key'         => 'cust_pkg-large_pkg_size',
     'section'     => 'UI',
     'description' => "In customer view, summarize packages with more than this many services.  Set to zero to never summarize packages.",
@@ -3486,6 +3479,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'part_pkg-show_fcc_options',
+    'section'     => 'UI',
+    'description' => "Show fields on package definitions for FCC Form 477 classification",
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'svc_acct-edit_uid',
     'section'     => 'shell',
     'description' => 'Allow UID editing.',
@@ -5773,6 +5773,13 @@ and customer address. Include units.',
                      ],
   },
 
+  {
+    'key'         => 'old_fcc_report',
+    'section'     => '',
+    'description' => 'Use the old (pre-2014) FCC Form 477 report format.',
+    'type'        => 'checkbox',
+  },
+
   { key => "apacheroot", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
   { key => "apachemachine", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
   { key => "apachemachines", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index b7aa355..1ae60ed 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -383,6 +383,8 @@ if ( -e $addl_handler_use_file ) {
   use FS::export_batch;
   use FS::export_batch_item;
   use FS::part_pkg_fcc_option;
+  use FS::state;
+  use FS::state;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Report/FCC_477.pm b/FS/FS/Report/FCC_477.pm
index 9c03842..79f00e3 100644
--- a/FS/FS/Report/FCC_477.pm
+++ b/FS/FS/Report/FCC_477.pm
@@ -81,6 +81,7 @@ Documentation.
 );
 
 #from the select at http://www.ffiec.gov/census/default.aspx
+#though this is now in the database, also
 %states = (
   '01' => 'ALABAMA (AL)',
   '02' => 'ALASKA (AK)',
@@ -204,10 +205,22 @@ sub statenum2state {
   $states{$num};
 }
 
+=head1 THE "NEW" REPORT (October 2014 and later)
+
+=head2 METHODS
+
+=over 4
+
+=cut
+
 sub join_optionnames {
   join(' ', map { join_optionname($_) } @_);
 }
 
+sub join_optionnames_int {
+  join(' ', map { join_optionname_int($_) } @_);
+}
+
 sub join_optionname {
   # Returns a FROM phrase to join a specific option into the query (via 
   # part_pkg).  The option value will appear as a field with the same name
@@ -218,6 +231,17 @@ sub join_optionname {
     " ON (part_pkg.pkgpart = t_$name.pkgpart)";
 }
 
+sub join_optionname_int {
+  # Returns a FROM phrase to join a specific option into the query (via 
+  # part_pkg) and cast it to integer..  Note this does not convert nulls
+  # to zero.
+  my $name = shift;
+  "LEFT JOIN (SELECT pkgpart, CAST(optionvalue AS int) AS $name
+   FROM part_pkg_fcc_option".
+    " WHERE fccoptionname = '$name') AS t_$name".
+    " ON (part_pkg.pkgpart = t_$name.pkgpart)";
+}
+
 sub active_on {
   # Returns a condition to limit packages to those that were setup before a 
   # certain date, and not canceled before that date.
@@ -230,7 +254,7 @@ sub active_on {
 }
 
 sub is_fixed_broadband {
-  "is_broadband = '1' AND technology::integer IN(".join(',',
+  "is_broadband::int = 1 AND technology::int IN(".join(',',
     10, 11, 12, 20, 30, 40, 41, 42, 50, 60, 70, 90, 0
   ).")";
 }
@@ -238,8 +262,19 @@ sub is_fixed_broadband {
 =item part6 OPTIONS
 
 Returns Part 6 of the 2014 FCC 477 data, as an arrayref of arrayrefs.
-OPTIONS may contain "date" => a timestamp to run the report as of that
-date.
+OPTIONS may contain:
+- date: a timestamp value to count active packages as of that date
+- agentnum: limit to customers of that agent
+
+Part 6 is the broadband subscription detail report.  Columns of the 
+report are:
+- census tract
+- technology code
+- downstream speed
+- upstream speed
+(the above columns form a key)
+- number of subscriptions
+- number of consumer-grade subscriptions
 
 =cut
 
@@ -247,6 +282,7 @@ sub part6 {
   my $class = shift;
   my %opt = shift;
   my $date = $opt{date} || time;
+  my $agentnum = $opt{agentnum};
 
   my @select = (
     'cust_location.censustract',
@@ -258,18 +294,20 @@ sub part6 {
   );
   my $from =
     'cust_pkg
-      JOIN cust_location USING (locationnum)
+      JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
+      JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
       JOIN part_pkg USING (pkgpart) '.
-      join_optionnames(qw(
+      join_optionnames_int(qw(
         is_broadband technology 
-        broadband_downstream broadband_upstream
         is_consumer
-        ))
+        )).
+      join_optionnames(qw(broadband_downstream broadband_upstream))
   ;
   my @where = (
     active_on($date),
     is_fixed_broadband()
   );
+  push @where, "cust_main.agentnum = $agentnum" if $agentnum;
   my $group_by = 'cust_location.censustract, technology, '.
                    'broadband_downstream, broadband_upstream ';
   my $order_by = $group_by;
@@ -287,7 +325,24 @@ sub part6 {
 
 =item part9 OPTIONS
 
-Returns Part 9 of the 2014 FCC 477 data, as above.
+Returns Part 9 of the 2014 FCC 477 data.  Part 9 is the Local Exchange 
+Telephone Subscription report.  Columns are:
+
+- state FIPS code (key)
+- wholesale switched voice lines
+- wholesale unswitched local loops
+- end-user total lines
+- end-user lines sold in a package with broadband
+- consumer-grade lines where you are not the long-distance carrier
+- consumer-grade lines where the carrier IS the long-distance carrier
+- business-grade lines where you are not the long-distance carrier
+- business-grade lines where the carrier IS the long-distance carrier
+- end-user lines where you own the local loop facility
+- end-user lines where you lease an unswitched local loop from a LEC
+- end-user lines resold from another carrier
+- end-user lines provided over fiber to the premises
+- end-user lines provided over coaxial
+- end-user lines provided over fixed wireless
 
 =cut
 
@@ -295,39 +350,44 @@ sub part9 {
   my $class = shift;
   my %opt = shift;
   my $date = $opt{date} || time;
+  my $agentnum = $opt{agentnum};
 
   my @select = (
-    "cust_location.state",
-    "SUM(COALESCE(phone_vges::int,0))",
-    "SUM(COALESCE(phone_circuits::int,0))",
-    "SUM(COALESCE(phone_lines::int,0))",
-    "SUM(CASE WHEN is_broadband = '1' THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN is_consumer = '1' AND is_longdistance IS NULL THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN is_consumer = '1' AND is_longdistance = '1' THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN is_consumer IS NULL AND is_longdistance IS NULL THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN is_consumer IS NULL AND is_longdistance = '1' THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN phone_localloop = 'owned' THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN phone_localloop = 'leased' THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN phone_localloop = 'resale' THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN media = 'Fiber' THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN media = 'Cable Modem' THEN phone_lines::int ELSE 0 END)",
-    "SUM(CASE WHEN media = 'Fixed Wireless' THEN phone_lines::int ELSE 0 END)",
+    "state.fips",
+    "SUM(phone_vges)",
+    "SUM(phone_circuits)",
+    "SUM(phone_lines)",
+    "SUM(CASE WHEN is_broadband = 1 THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN is_consumer = 1 AND is_longdistance IS NULL THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN is_consumer = 1 AND is_longdistance = 1 THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN is_consumer IS NULL AND is_longdistance IS NULL THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN is_consumer IS NULL AND is_longdistance = 1 THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN phone_localloop = 'owned' THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN phone_localloop = 'leased' THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN phone_localloop = 'resale' THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN media = 'Fiber' THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN media = 'Cable Modem' THEN phone_lines ELSE 0 END)",
+    "SUM(CASE WHEN media = 'Fixed Wireless' THEN phone_lines ELSE 0 END)",
   );
   my $from =
     'cust_pkg
-      JOIN cust_location USING (locationnum)
+      JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
+      JOIN state USING (country, state)
+      JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
       JOIN part_pkg USING (pkgpart) '.
-      join_optionnames(qw(
-        is_phone is_broadband media
+      join_optionnames_int(qw(
+        is_phone is_broadband
         phone_vges phone_circuits phone_lines
-        is_consumer is_longdistance phone_localloop 
-        ))
+        is_consumer is_longdistance
+        )).
+      join_optionnames('media', 'phone_localloop')
   ;
   my @where = (
     active_on($date),
-    "is_phone::int = 1",
+    "is_phone = 1",
   );
-  my $group_by = 'cust_location.state';
+  push @where, "cust_main.agentnum = $agentnum" if $agentnum;
+  my $group_by = 'state.fips';
   my $order_by = $group_by;
 
   my $statement = "SELECT ".join(', ', @select) . "
@@ -341,5 +401,106 @@ sub part9 {
   dbh->selectall_arrayref($statement);
 }
 
+sub part10 {
+  my $class = shift;
+  my %opt = shift;
+  my $date = $opt{date} || time;
+  my $agentnum = $opt{agentnum};
+
+  my @select = (
+    "state.fips",
+    # OTT, OTT + consumer
+    "SUM(CASE WHEN (voip_lastmile IS NULL) THEN 1 ELSE 0 END)",
+    "SUM(CASE WHEN (voip_lastmile IS NULL AND is_consumer = 1) THEN 1 ELSE 0 END)",
+    # non-OTT: total, consumer, broadband bundle, media types
+    "SUM(CASE WHEN (voip_lastmile = 1) THEN 1 ELSE 0 END)",
+    "SUM(CASE WHEN (voip_lastmile = 1 AND is_consumer = 1) THEN 1 ELSE 0 END)",
+    "SUM(CASE WHEN (voip_lastmile = 1 AND is_broadband = 1) THEN 1 ELSE 0 END)",
+    "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Copper') THEN 1 ELSE 0 END)",
+    "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Cable Modem') THEN 1 ELSE 0 END)",
+    "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Fiber') THEN 1 ELSE 0 END)",
+    "SUM(CASE WHEN (voip_lastmile = 1 AND media = 'Fixed Wireless') THEN 1 ELSE 0 END)",
+    "SUM(CASE WHEN (voip_lastmile = 1 AND media NOT IN('Copper', 'Fiber', 'Cable Modem', 'Fixed Wireless') ) THEN 1 ELSE 0 END)",
+  );
+
+  my $from =
+    'cust_pkg
+      JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
+      JOIN state USING (country, state)
+      JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
+      JOIN part_pkg USING (pkgpart) '.
+      join_optionnames_int(
+        qw( is_voip is_broadband is_consumer voip_lastmile)
+      ).
+      join_optionnames('media')
+  ;
+  my @where = (
+    active_on($date),
+    "is_voip = 1",
+  );
+  push @where, "cust_main.agentnum = $agentnum" if $agentnum;
+  my $group_by = 'state.fips';
+  my $order_by = $group_by;
+
+  my $statement = "SELECT ".join(', ', @select) . "
+  FROM $from
+  WHERE ".join(' AND ', @where)."
+  GROUP BY $group_by
+  ORDER BY $order_by
+  ";
+
+  warn $statement if $DEBUG;
+  dbh->selectall_arrayref($statement);
+}
+
+=item part11 OPTIONS
+
+Returns part 11 (voice subscription detail), as above.
+
+=cut
+
+sub part11 {
+  my $class = shift;
+  my %opt = shift;
+  my $date = $opt{date} || time;
+  my $agentnum = $opt{agentnum};
+
+  my @select = (
+    'cust_location.censustract',
+    # VoIP indicator (0 for non-VoIP, 1 for VoIP)
+    'COALESCE(is_voip, 0)',
+    # number of lines/subscriptions
+    'SUM(CASE WHEN is_voip = 1 THEN 1 ELSE phone_lines END)',
+    # consumer grade lines/subscriptions
+    'SUM(CASE WHEN is_consumer = 1 THEN ( CASE WHEN is_voip = 1 THEN 1 ELSE phone_lines END) ELSE 0 END)'
+  );
+
+  my $from = 'cust_pkg
+    JOIN cust_location ON (cust_pkg.locationnum = cust_location.locationnum)
+    JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
+    JOIN part_pkg USING (pkgpart) '.
+    join_optionnames_int(qw(
+      is_phone is_voip is_consumer phone_lines
+      ))
+  ;
+
+  my @where = (
+    active_on($date),
+    "(is_voip = 1 OR is_phone = 1)",
+  );
+  push @where, "cust_main.agentnum = $agentnum" if $agentnum;
+  my $group_by = 'cust_location.censustract, COALESCE(is_voip, 0)';
+  my $order_by = $group_by;
+
+  my $statement = "SELECT ".join(', ', @select) . "
+  FROM $from
+  WHERE ".join(' AND ', @where)."
+  GROUP BY $group_by
+  ORDER BY $order_by
+  ";
+
+  warn $statement if $DEBUG;
+  dbh->selectall_arrayref($statement);
+}
 
 1;
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 1c9c4a2..40248dd 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -6643,6 +6643,20 @@ sub tables_hashref {
                         ],
     },
 
+    # lookup table for states, similar to msa and lata
+    'state' => {
+      'columns' => [
+        'statenum', 'int',  '', '', '', '', 
+        'country',  'char', '',  2, '', '',
+        'state',    'char', '', $char_d, '', '', 
+        'fips',     'char', '',  3, '', '',
+      ],
+      'primary_key' => 'statenum',
+      'unique' => [ [ 'country', 'state' ], ],
+      'index' => [],
+    },
+
+
     # name type nullability length default local
 
     #'new_table' => {
diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm
index 6785a13..ce0e328 100644
--- a/FS/FS/Upgrade.pm
+++ b/FS/FS/Upgrade.pm
@@ -97,6 +97,22 @@ sub upgrade_config {
     $conf->touch('cust_main-enable_spouse');
     $conf->delete('cust_main-enable_spouse_birthdate');
   }
+
+  # renamed/repurposed
+  if ( $conf->exists('cust_pkg-show_fcc_voice_grade_equivalent') ) {
+    $conf->touch('part_pkg-show_fcc_options');
+    $conf->delete('cust_pkg-show_fcc_voice_grade_equivalent');
+    warn "
+You have FCC Form 477 package options enabled.
+
+Starting with the October 2014 filing date, the FCC has redesigned 
+Form 477 and introduced new service categories.  See bin/convert-477-options
+to update your package configuration for the new report.
+
+If you need to continue using the old Form 477 report, turn on the
+'old_fcc_report' configuration option.
+";
+  }
 }
 
 sub upgrade_overlimit_groups {
@@ -343,6 +359,9 @@ sub upgrade_data {
 
     #fix taxable line item links
     'cust_bill_pkg_tax_location' => [],
+
+    #populate state FIPS codes if not already done
+    'state' => [],
   ;
 
   \%hash;
diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm
index 2ad7859..741eb87 100644
--- a/FS/FS/part_pkg.pm
+++ b/FS/FS/part_pkg.pm
@@ -790,7 +790,7 @@ sub propagate {
 =item process_fcc_options HASHREF
 
 Sets the FCC options on this package definition to the values specified
-in HASHREF.  Names are as in L<FS::part_pkg_fcc_option/info>.
+in HASHREF.
 
 =cut
 
@@ -807,6 +807,7 @@ sub process_fcc_options {
   my %existing_num = map { $_->fccoptionname => $_->num }
                      qsearch('part_pkg_fcc_option', { pkgpart => $pkgpart });
 
+  local $FS::Record::nowarn_identical = 1;
   # set up params for process_o2m
   my $i = 0;
   my $params = {};
diff --git a/FS/FS/state.pm b/FS/FS/state.pm
new file mode 100644
index 0000000..671a93b
--- /dev/null
+++ b/FS/FS/state.pm
@@ -0,0 +1,133 @@
+package FS::state;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use Locale::SubCountry;
+
+=head1 NAME
+
+FS::state - Object methods for state/province records
+
+=head1 SYNOPSIS
+
+  use FS::state;
+
+  $record = new FS::state \%hash;
+  $record = new FS::state { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::state object represents a state, province, or other top-level 
+subdivision of a sovereign nation.  FS::state inherits from FS::Record.  
+The following fields are currently supported:
+
+=over 4
+
+=item statenum
+
+primary key
+
+=item country
+
+two-letter country code
+
+=item state
+
+state code/abbreviation/name (as used in cust_location.state)
+
+=item fips
+
+FIPS 10-4 code (not including country code)
+
+=back
+
+=head1 METHODS
+
+=cut
+
+sub table { 'state'; }
+
+# no external API; this table maintains itself
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('statenum')
+    || $self->ut_alpha('country')
+    || $self->ut_alpha('state')
+    || $self->ut_alpha('fips')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=cut
+
+sub _upgrade_data {
+  warn "Updating state and country codes...\n";
+  my %existing;
+  foreach my $state (qsearch('state')) {
+    $existing{$state->country} ||= {};
+    $existing{$state->country}{$state->state} = $state;
+  }
+  my $world = Locale::SubCountry::World->new;
+  foreach my $country_code ($world->all_codes) {
+    my $country = Locale::SubCountry->new($country_code);
+    next unless $country->has_sub_countries;
+    $existing{$country} ||= {};
+    foreach my $state_code ($country->all_codes) {
+      my $fips = $country->FIPS10_4_code($state_code);
+      # we really only need U.S. state codes at this point, so if there's
+      # no FIPS code, ignore it.
+      next if !$fips or $fips eq 'unknown' or $fips =~ /\W/;
+      my $this_state = $existing{$country_code}{$state_code};
+      if ($this_state) {
+        if ($this_state->fips ne $fips) { # this should never happen...
+          $this_state->set(fips => $fips);
+          my $error = $this_state->replace;
+          die "error updating $country_code/$state_code:\n$error\n" if $error;
+        }
+        delete $existing{$country_code}{$state_code};
+      } else {
+        $this_state = FS::state->new({
+          country => $country_code,
+          state   => $state_code,
+          fips    => $fips,
+        });
+        my $error = $this_state->insert;
+        die "error inserting $country_code/$state_code:\n$error\n" if $error;
+      }
+    }
+    # clean up states that no longer exist (does this ever happen?)
+    foreach my $state (values %{ $existing{$country_code} }) {
+      my $error = $state->delete;
+      die "error removing expired state ".$state->country.'/'.$state->state.
+          "\n$error\n" if $error;
+    }
+  } # foreach $country_code
+  '';
+}
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/MANIFEST b/FS/MANIFEST
index ed8fd9b..ef9fb44 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -806,3 +806,7 @@ FS/export_batch_item.pm
 t/export_batch_item.t
 FS/part_pkg_fcc_option.pm
 t/part_pkg_fcc_option.t
+FS/state.pm
+t/state.t
+FS/state.pm
+t/state.t
diff --git a/FS/MYMETA.json b/FS/MYMETA.json
new file mode 100644
index 0000000..42001f1
--- /dev/null
+++ b/FS/MYMETA.json
@@ -0,0 +1,39 @@
+{
+   "abstract" : "unknown",
+   "author" : [
+      "unknown"
+   ],
+   "dynamic_config" : 0,
+   "generated_by" : "ExtUtils::MakeMaker version 6.8, CPAN::Meta::Converter version 2.120351",
+   "license" : [
+      "unknown"
+   ],
+   "meta-spec" : {
+      "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec",
+      "version" : "2"
+   },
+   "name" : "FS",
+   "no_index" : {
+      "directory" : [
+         "t",
+         "inc"
+      ]
+   },
+   "prereqs" : {
+      "build" : {
+         "requires" : {
+            "ExtUtils::MakeMaker" : "0"
+         }
+      },
+      "configure" : {
+         "requires" : {
+            "ExtUtils::MakeMaker" : "0"
+         }
+      },
+      "runtime" : {
+         "requires" : {}
+      }
+   },
+   "release_status" : "stable",
+   "version" : "4.0git"
+}
diff --git a/FS/t/state.t b/FS/t/state.t
new file mode 100644
index 0000000..a21137f
--- /dev/null
+++ b/FS/t/state.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::state;
+$loaded=1;
+print "ok 1\n";
diff --git a/bin/convert-477-options b/bin/convert-477-options
index a52c56c..48a5264 100755
--- a/bin/convert-477-options
+++ b/bin/convert-477-options
@@ -6,11 +6,11 @@ use FS::Record qw(qsearch qsearchs dbh);
 use FS::part_pkg_report_option;
 use Text::CSV;
 
-adminsuidsetup($user) or die "invalid user '$user'";
-$FS::UID::AutoCommit = 1;
-$FS::Record::nowarn_classload = 1;
+if (!$user) {
+  print "
+Usage: bin/convert-477-options <username>
 
-print "This script will convert your per-package FCC 477 report options
+This script will convert your per-package FCC 477 report options
 from the classic style (part IA, IB, IIA...) to the 2014 style.
 This is an approximate conversion, and you should review the 
 resulting package settings for accuracy.  In particular:
@@ -19,11 +19,17 @@ resulting package settings for accuracy.  In particular:
   - Broadband technologies for ADSL and cable modem will be set
     to 'other ADSL' and 'other cable modem'.  You should set 
     them to the specific ADSL or cable encapsulation in use.
-  - The 'consumer grade' vs. 'business grade' classification 
-    was introduced in 2014 and will not be set.
-
-Configuring packages...
+  - All packages will be set to 'business grade'.  The 'consumer grade'
+    category did not exist in previous versions of the report.
 ";
+  exit(1);
+}
+
+adminsuidsetup($user) or die "invalid user '$user'";
+$FS::UID::AutoCommit = 1;
+$FS::Record::nowarn_classload = 1;
+
+print "Configuring packages...\n";
 
 my @min_download_speed = ( 0.2, 0.768, 1.5, 3, 6, 10, 25, 100 );
 my @min_upload_speed = ( 0.1, @min_download_speed );
@@ -74,7 +80,9 @@ sub report_option_to_fcc_option {
             technology    => $technology[$1]);
   } elsif ($formkey =~ /^part2a_row_option_(\d+)/) {
     #local phone options
-    return (split(':', $phone_option[$1]));
+    return (media => 'Copper', # sensible default
+            split(':', $phone_option[$1])
+           );
   } elsif ($formkey =~ /^part2b_row_option_(\d+)/) {
     #VoIP options (are all media types)
     return (split(':', $voip_option[$1]));
@@ -86,7 +94,7 @@ sub report_option_to_fcc_option {
 
 for my $part_pkg (qsearch('part_pkg', { freq => {op => '!=', value => '0'}})) {
   my $pkgpart = $part_pkg->pkgpart;
-  print "#$pkgpart\n";
+  #print "#$pkgpart\n";
   my %report_opts = $part_pkg->options;
   my @fcc_opts;
   foreach my $optionname (keys(%report_opts)) {
@@ -113,12 +121,12 @@ for my $part_pkg (qsearch('part_pkg', { freq => {op => '!=', value => '0'}})) {
   }
 
   my %fcc_opts = @fcc_opts;
-  print map {"\t$_\t".$fcc_opts{$_}."\n"} keys %fcc_opts;
+  #print map {"\t$_\t".$fcc_opts{$_}."\n"} keys %fcc_opts;
   my $error = $part_pkg->process_fcc_options(\%fcc_opts);
   if ( $error ) {
     die "$error\n";
   }
-  print "\n";
+  #print "\n";
 }
 
 print "Finished.\n";
diff --git a/httemplate/elements/tr-input-fcc_options.html b/httemplate/elements/tr-input-fcc_options.html
index bd50830..11cb4a9 100644
--- a/httemplate/elements/tr-input-fcc_options.html
+++ b/httemplate/elements/tr-input-fcc_options.html
@@ -1,16 +1,24 @@
+<STYLE>
+  ul.fcc_options {
+    font-weight: normal;
+    text-align: left;
+    padding: 0em 1em 0em 2em;
+  }
+</STYLE>
 <TR>
   <TH COLSPAN=2>
     <& hidden.html, 'id' => $id, @_ &>
-%#    <& input-text.html, 'id' => $id, @_ &>
-%# XXX debugging
-    <FONT SIZE="+1"><BUTTON TYPE="button" onclick="show_fcc_options()">
-      FCC Form 477 information
-    </BUTTON></FONT>
+%#    <& input-text.html, 'id' => $id, @_ &> # XXX debugging
+    <UL ID="<%$id%>_display_fcc_options" CLASS="fcc_options">
+    </UL>
+    <BUTTON TYPE="button" onclick="edit_fcc_options()">
+      Edit
+    </BUTTON>
 % # show some kind of useful summary of the FCC options here
   </TH>
 </TR>
 <SCRIPT TYPE="text/javascript">
-function show_fcc_options() {
+function edit_fcc_options() {
   <& popup_link_onclick.html,
   'action'      => $fsurl.'misc/part_pkg_fcc_options.html?id=' . $id,
   'actionlabel' => 'FCC Form 477 options',
@@ -18,6 +26,75 @@ function show_fcc_options() {
   'height'      => 600,
   &>
 }
+var technology_labels = <% encode_json(FS::part_pkg_fcc_option->technology_labels) %>;
+function show_fcc_options() {
+  var curr_values = JSON.parse(document.getElementById('<% $id %>').value);
+  // hardcoded for the same reasons as misc/part_pkg_fcc_options
+  var out = '';
+  var tech = curr_values['technology'];
+  if ( tech ) {
+    if (technology_labels[tech]) {
+      tech = technology_labels[tech];
+    } else {
+      tech = 'Technology '+tech; // unknown?
+    }
+  }
+  var media = String.toLowerCase(curr_values['media'] || 'unknown media');
+  if ( curr_values['is_consumer'] ) {
+    out += '<li><strong>Consumer-grade</strong> service</li>>';
+  } else {
+    out += '<li><strong>Business-grade</strong> service</li>';
+  }
+  if ( curr_values['is_broadband'] ) {
+    out += '<li>Broadband via <strong>' + tech + '</strong>'
+        +  '<li><strong>' + curr_values['broadband_downstream']
+        +  'Mbps </strong> down / '
+        +  '<strong>' + curr_values['broadband_upstream']
+        +  'Mbps </strong> up</li>';
+  }
+  if ( curr_values['is_phone'] ) {
+    if ( curr_values['phone_wholesale'] ) {
+      out += '<li>Wholesale telephone</li>';
+      if ( curr_values['phone_vges'] ) {
+        out += '<li><strong>' + curr_values['phone_vges'] + '</strong>'
+            +  ' switched voice-grade lines</li>';
+      }
+      if ( curr_values['phone_circuits'] ) {
+        out += '<li><strong>' + curr_values['phone_circuits'] + '</strong>'
+            +  ' unswitched circuits</li>';
+      }
+    } else {
+      // enduser service
+      out += '<li>Local telephone over <strong>' + media + '</strong></li>'
+          +  '<li><strong>' + curr_values['phone_lines']
+          +  '</strong> voice-grade lines</li>';
+      if ( curr_values['phone_localloop'] == 'resale' ) {
+        out += '<li><strong>Resold</strong> from another carrier</li>>';
+      } else if ( curr_values['phone_localloop'] == 'leased' ) {
+        out += '<li>Using <strong>leased circuits</strong> from another carrier</li>';
+      } else if ( curr_values['phone_localloop'] == 'owned' ) {
+        out += '<li>Using <strong>our own circuits</strong></li>';
+      }
+      if ( curr_values['phone_longdistance'] ) {
+        out += '<li>Includes <strong>long-distance service</strong></li>';
+      }
+    }
+  } // is_phone
+  if ( curr_values['is_voip'] ) {
+    out += '<li><strong>VoIP</strong> telephone service</li>';
+    if ( curr_values['voip_ott'] ) {
+      out += '<li>Using a <strong>separate</strong> last-mile connection</li>';
+    } else {
+      out += '<li><strong>Including</strong> last-mile connection</li>';
+    }
+  } // is_voip
+
+  var out_ul = document.getElementById('<% $id %>_display_fcc_options');
+  out_ul.innerHTML = out;
+}
+<&| onload.js &>
+  show_fcc_options();
+</&>
 </SCRIPT>
 <%init>
 my %opt = @_;
diff --git a/httemplate/misc/part_pkg_fcc_options.html b/httemplate/misc/part_pkg_fcc_options.html
index 1f5d4a8..f743284 100644
--- a/httemplate/misc/part_pkg_fcc_options.html
+++ b/httemplate/misc/part_pkg_fcc_options.html
@@ -90,8 +90,8 @@
     <& .checkbox, 'is_voip' &>
     <LABEL FOR="is_voip">This package provides VoIP telephone service</LABEL>
     <FIELDSET ID="voip">
-      <LABEL FOR="voip_ott">Do you also provide last-mile connectivity?</LABEL>
-      <& .checkbox, 'voip_ott' &>
+      <& .checkbox, 'voip_lastmile' &>
+      <LABEL FOR="voip_lastmile">Do you also provide last-mile connectivity?</LABEL>
     </FIELDSET>
   </P>
   <DIV WIDTH="100%" STYLE="text-align:center">
@@ -103,8 +103,8 @@
 // this form is invoked as a popup; the current values of the parent 
 // object are in the form field ID passed as the 'id' param
 
-var parent_id = window.parent.document.getElementById('<% $parent_id %>');
-var curr_values = JSON.parse(window.parent_id.value);
+var parent_input = window.parent.document.getElementById('<% $parent_id %>');
+var curr_values = JSON.parse(window.parent_input.value);
 var form = document.forms['fcc_option_form'];
 var media_types = <% encode_json($media_types) %>
 var technology_labels = <% encode_json($technology_labels) %>
@@ -129,6 +129,9 @@ function save_changes() {
   var form = document.forms['fcc_option_form'];
   var data = {};
   for (var i = 0; i < form.elements.length; i++) {
+    if (form.elements[i].type == 'submit')
+      continue;
+
     // quick and dirty test for whether the element is displayed
     if (form.elements[i].clientHeight > 0) {
       if (form.elements[i].type == 'checkbox') {
@@ -140,7 +143,9 @@ function save_changes() {
       }
     }
   }
-  parent_id.value = JSON.stringify(data);
+  parent_input.value = JSON.stringify(data);
+  // update the display
+  parent.show_fcc_options();
   parent.cClick(); //overlib
 }
 
diff --git a/httemplate/search/477.html b/httemplate/search/477.html
old mode 100755
new mode 100644
index ecf21cf..6849337
--- a/httemplate/search/477.html
+++ b/httemplate/search/477.html
@@ -1,135 +1,219 @@
-% if ( $type eq 'xml' ) {
-% $filename = "fcc_477_$state" . '_' . time2str('%Y%m%d', $date) . '.xml';
-% http_header('Content-Type' => 'application/XML' ); # So saith RFC 4180
-% http_header('Content-Disposition' => 'attachment;filename="'.$filename.'"');
-<?xml version="1.0" encoding="ISO-8859-1"?>
-<Form_477_submission xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://specialreports.fcc.gov/wcb/Form477/XMLSchema-instance/form_477_upload_Schema.xsd" >
-% } else { #html
-<& /elements/header.html, "FCC Form 477 Results - $state" &>
-%# XXX when we stop supporting IE8, add this to freeside.css using :nth-child
-%# selectors, and remove it from everywhere else
+<& /elements/header.html, $title &>
 <STYLE TYPE="text/css">
-.grid TH { background-color: #cccccc; padding: 0px 3px 2px; text-align: right }
-.row0 TD { background-color: #eeeeee; padding: 0px 3px 2px; text-align: right }
-.row1 TD { background-color: #ffffff; padding: 0px 3px 2px; text-align: right }
+table.fcc477part {
+  border-collapse: collapse;
+  border: 1px #777 solid;
+  margin-bottom: 20px;
+}
+table.fcc477part td {
+  padding: 0px 4px;
+  border-left: 1px #777 solid;
+  border-right: 1px #777 solid;
+}
+table.fcc477part tbody td {
+  text-align: right;
+}
+table.fcc477part thead tr.head {
+  text-align: center;
+  vertical-align: top;
+  font-weight: bold;
+  border-top: 1px #777 solid;
+  border-bottom: 1px #777 solid;
+}
+table.fcc477part thead tr.subhead {
+  text-align: center;
+  font-weight: bold;
+  font-size: small;
+  border-top: 1px #777 solid;
+  border-bottom: 1px #777 solid;
+}
+.parttitle {
+  font-weight: bold;
+  font-size: large;
+  float: left;
+}
+a.download {
+  float: right;
+}
 </STYLE>
-
-<TABLE WIDTH="100%">
-  <TR>
-    <TD></TD>
-    <TD ALIGN="right" CLASS="noprint">
-      Download full results<BR>
-%   $cgi->param('_type', 'xml');
-      as <A HREF="<% $cgi->self_url %>">XML file</A><BR>
-
-%   $cgi->param('_type', 'html-print');
-      as <A HREF="<% $cgi->self_url %>">printable copy</A>
-
-    </TD>
-%   $cgi->param('_type', $type );
-  </TR>
-</TABLE>
-% } #html
-% foreach my $part ( @parts ) {
-%   if ( $part{$part} ) {
-%
-%     if ( $part eq 'V' ) {
-%       next unless ( $part{'IIA'} || $part{'IIB'} );
-%     }
-%
-%     if ( $part eq 'VI_census' ) {
-%       next unless $part{'IA'};
-%     }
-%
-%     my @reports = ();
-%     if ( $part eq 'IA' ) {
-%       for ( my $tech = 0; $tech < scalar(@technology_option); $tech++ ) {
-%         next unless $technology_option[$tech];
-%         my $url = &{$url_mangler}($part);
-%         if ( $type eq 'xml' ) {
-<<% 'Part_IA_'. chr(65 + $tech) %>>
-%         }
-<& "477part${part}.html",
-    'tech_code' => $tech,
-    'url' => $url,
-    'type' => $type,
-    'date' => $date,
-&>
-%         if ( $type eq 'xml' ) {
-</<% 'Part_IA_'. chr(65 + $tech) %>>
-%         }
-%       }
-%     } else { # not part IA
-%       if ( $type eq 'xml' ) {
-<<% 'Part_'. $part %>>
-%       }
-%       my $url = &{$url_mangler}($part);
-<& "477part${part}.html",
-    'url' => $url,
-    'date' => $date,
-    'filename' => $filename,
-&>
-%       if ( $type eq 'xml' ) {
-</<% 'Part_'. $part %>>
-%       }
+% foreach my $partnum (@partnums) {
+%   $cgi->param('parts', $partnum);
+%   $cgi->param('type', 'csv');
+<table class="fcc477part">
+  <caption>
+    <span class="parttitle">Part <% $partnum %></span>
+    <a class="download" href="<% $cgi->self_url %>">Download</a>
+  </caption>
+%   my $header = ".header$partnum";
+%   my $data = $parts{$partnum};
+  <thead>
+    <& $header &>
+  </thead>
+%   #XXX column headings
+%   foreach my $row (@$data) {
+  <tr>
+%     foreach my $item (@$row) {
+    <td><% $item %></td>
 %     }
+  </tr>
 %   }
-% }
-%
-% if ( $type eq 'xml' ) {
-</Form_477_submission>
-% } else {
+</table>
+% } # foreach $partnum
 <& /elements/footer.html &>
-% }
 <%init>
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('List packages');
 
-my $curuser = $FS::CurrentUser::CurrentUser;
+my %parts;
+# load from cache if possible
+my $session;
+if ( $cgi->param('session') =~ /^(\d+)$/ ) {
+  $session = $1;
+  %parts = %{ $m->cache->get($session) };
+} else {
+  $session = sprintf('%010d%06d', time, int(rand(1000000)));
+  $cgi->param('session', $session);
+}
 
-die "access denied"
-  unless $curuser->access_right('List packages');
+my $agentnum;
+if ($cgi->param('agentnum') =~ /^(\d+)$/ ) {
+  $agentnum = $1;
+}
+my $date = parse_datetime($cgi->param('date')) || time;
+my @partnums = grep /^\d+$/, $cgi->param('parts');
+foreach my $partnum (@partnums) {
+  my $method = "part$partnum";
+  $parts{$partnum} ||= FS::Report::FCC_477->$method(
+    date      => $date,
+    agentnum  => $agentnum
+  );
+}
+$m->cache->set($session, \%parts, '1h');
 
-my $date = $cgi->param('date') ? parse_datetime($cgi->param('date'))
-                               : time;
+my $title = 'FCC Form 477 Data - ' . time2str('%b %o, %Y', $date);
 
-my $state = uc($cgi->param('state'));
-$state =~ /^[A-Z]{2}$/ or die "illegal state: $state";
+if ( $cgi->param('type') eq 'csv' ) {
+  my $partnum = $partnums[0]; # ignore any beyond the first
+  my $data = $parts{$partnum};
+  my $csv = Text::CSV_XS->new({ eol => "\r\n" }); # i think
 
-my %part = map { $_ => 1 } grep { /^\w+$/ } $cgi->param('part');
-my $type = $cgi->param('_type') || 'html';
-my $filename;
-my @technology_option = &FS::Report::FCC_477::parse_technology_option($cgi,1);
+  my $filename = time2str('%Y-%m-%d', $date) . '-part' . $partnum . '.csv';
+  http_header('Content-Type' => 'text/csv');
+  http_header('Content-Disposition' => qq(attachment;filename="$filename"));
 
-# save upload and download mappings
-my @download = $cgi->param('part1_column_option');
-my @upload = $cgi->param('part1_row_option');
-for(my $i=0; $i < scalar(@download); $i++) {
-    &FS::Report::FCC_477::save_fcc477map("part1_column_option_$i",$download[$i]);
-}
-for(my $i=0; $i < scalar(@upload); $i++) {
-    &FS::Report::FCC_477::save_fcc477map("part1_row_option_$i",$upload[$i]);
-}
+  $m->clear_buffer;
 
-my @part2a_row_option = $cgi->param('part2a_row_option');
-for(my $i=0; $i < scalar(@part2a_row_option); $i++) {
-    &FS::Report::FCC_477::save_fcc477map("part2a_row_option_$i",$part2a_row_option[$i]);
+  foreach my $row (@$data) {
+    $csv->combine(@$row);
+    $m->print($csv->string);
+  }
+  $m->abort;
 }
 
-my @part2b_row_option = $cgi->param('part2b_row_option');
-for(my $i=0; $i < scalar(@part2b_row_option); $i++) {
-    &FS::Report::FCC_477::save_fcc477map("part2b_row_option_$i",$part2b_row_option[$i]);
-}
+</%init>
+<%def .header6>
+  <TR CLASS="head">
+    <TD ROWSPAN=2>Census Tract</TD>
+    <TD ROWSPAN=2>Technology</TD>
+    <TD COLSPAN=2>Speed (Mbps)</TD>
+    <TD COLSPAN=2>Subscriptions</TD>
+  </TR>
+  <TR CLASS="subhead">
+    <TD>Down</TD>
+    <TD>Up</TD>
+    <TD>Total</TD>
+    <TD>Consumer</TD>
+  </TR>
+</%def>
+<%def .header7>
+  <TR CLASS="head">
+    <TD ROWSPAN=2>State</TD>
+    <TD COLSPAN=2>Speed (Mbps)</TD>
+    <TD COLSPAN=2>Subscriptions</TD>
+  </TR>
+  <TR CLASS="subhead">
+    <TD>Down</TD>
+    <TD>Up</TD>
+    <TD>Total</TD>
+    <TD>Consumer</TD>
+  </TR>
+</%def>
+<%def .header8>
+  <TR CLASS="head">
+    <TD ROWSPAN=2>State</TD>
+    <TD COLSPAN=2>Subscriptions</TD>
+  </TR>
+  <TR CLASS="subhead">
+    <TD>Total</TD>
+    <TD>Direct</TD>
+  </TR>
+</%def>
+<%def .header9>
+  <TR CLASS="head">
+    <TD ROWSPAN=3>State</TD>
+    <TD COLSPAN=2>Wholesale</TD>
+    <TD COLSPAN=12>End User Lines</TD>
+  </TR>
+  <TR CLASS="subhead">
+    <TD ROWSPAN=2>VGEs</TD>
+    <TD ROWSPAN=2>UNE-Ls</TD>
 
-my $part5_report_option = $cgi->param('part5_report_option');
-if ( $part5_report_option ) {
-  FS::Report::FCC_477::save_fcc477map('part5_report_option', $part5_report_option);
-}
+    <TD ROWSPAN=2>Total</TD>
+    <TD ROWSPAN=2>With Broadband</TD>
+    <TD COLSPAN=2>Consumer</TD>
+    <TD COLSPAN=2>Business</TD>
 
-my $url_mangler = sub {
-  my $part = shift;
-  my $url = $cgi->url('-path_info' => 1, '-full' => 1);
-  $url =~ s/477\./477part$part./;
-  $url;
-};
-my @parts = qw( IA IIA IIB IV V VI_census );
+    <TD COLSPAN=3>Local Loop</TD>
 
-</%init>
+    <TD COLSPAN=3>Special Media</TD>
+  </TR>
+
+  <TR CLASS="subhead">
+    <TD> </TD>
+    <TD>+LD</TD>
+    <TD> </TD>
+    <TD>+LD</TD>
+
+    <TD>Owned</TD>
+    <TD>UNE-L</TD>
+    <TD>Resale</TD>
+
+    <TD>Fiber</TD>
+    <TD>Coaxial</TD>
+    <TD>Wireless</TD>
+  </TR>
+</%def>
+<%def .header10>
+  <TR CLASS="head">
+    <TD ROWSPAN=2>State</TD>
+    <TD COLSPAN=2>VoIP OTT</TD>
+    <TD COLSPAN=8>VoIP Non-OTT</TD>
+  </TR>
+  <TR CLASS="subhead">
+    <TD ROWSPAN=2>Total</TD>
+    <TD ROWSPAN=2>Consumer</TD>
+
+    <TD ROWSPAN=2>Total</TD>
+    <TD ROWSPAN=2>Consumer</TD>
+    <TD ROWSPAN=2>Bundled</TD>
+    <TD COLSPAN=5>Media Type</TD>
+  </TR>
+  <TR CLASS="subhead">
+    <TD>Copper</TD>
+    <TD>Fiber</TD>
+    <TD>Coaxial</TD>
+    <TD>Wireless</TD>
+    <TD>Other</TD>
+  </TR>
+</%def>
+<%def .header11>
+  <TR CLASS="head">
+    <TD ROWSPAN=2>Census Tract</TD>
+    <TD ROWSPAN=2>VoIP?</TD>
+    <TD COLSPAN=2>Lines/Subscriptions</TD>
+  </TR>
+  <TR CLASS="subhead">
+    <TD>Total</TD>
+    <TD>Consumer</TD>
+  </TR>
+</%def>
diff --git a/httemplate/search/477.html b/httemplate/search/old477/477.html
similarity index 100%
copy from httemplate/search/477.html
copy to httemplate/search/old477/477.html
diff --git a/httemplate/search/477partIA.html b/httemplate/search/old477/477partIA.html
similarity index 100%
rename from httemplate/search/477partIA.html
rename to httemplate/search/old477/477partIA.html
diff --git a/httemplate/search/477partIIA.html b/httemplate/search/old477/477partIIA.html
similarity index 100%
rename from httemplate/search/477partIIA.html
rename to httemplate/search/old477/477partIIA.html
diff --git a/httemplate/search/477partIIB.html b/httemplate/search/old477/477partIIB.html
similarity index 100%
rename from httemplate/search/477partIIB.html
rename to httemplate/search/old477/477partIIB.html
diff --git a/httemplate/search/477partIV.html b/httemplate/search/old477/477partIV.html
similarity index 100%
rename from httemplate/search/477partIV.html
rename to httemplate/search/old477/477partIV.html
diff --git a/httemplate/search/477partV.html b/httemplate/search/old477/477partV.html
similarity index 98%
rename from httemplate/search/477partV.html
rename to httemplate/search/old477/477partV.html
index 2ffad2a..80201f9 100755
--- a/httemplate/search/477partV.html
+++ b/httemplate/search/old477/477partV.html
@@ -1,7 +1,7 @@
 % if ( $cgi->param('_type') =~ /^xml$/ ) {
 <zip_codes>
 % }
-<& elements/search.html,
+<& /search/elements/search.html,
                   'html_init'         => $html_init,
                   'name'              => 'zip code',
                   'query'             => $sql_query,
diff --git a/httemplate/search/477partVI_census.html b/httemplate/search/old477/477partVI_census.html
similarity index 99%
rename from httemplate/search/477partVI_census.html
rename to httemplate/search/old477/477partVI_census.html
index 2f3cf41..efcf4ef 100755
--- a/httemplate/search/477partVI_census.html
+++ b/httemplate/search/old477/477partVI_census.html
@@ -1,4 +1,4 @@
-<& elements/search.html,
+<& /search/elements/search.html,
                   'html_init'       => '<H2>Part VI</H2>',
                   'html_foot'       => $html_foot,
                   'name'            => 'regions',
diff --git a/httemplate/search/report_477.html b/httemplate/search/old477/report_477.html
similarity index 100%
copy from httemplate/search/report_477.html
copy to httemplate/search/old477/report_477.html
diff --git a/httemplate/search/report_477.html b/httemplate/search/report_477.html
index a5dd70b..e3ae69e 100755
--- a/httemplate/search/report_477.html
+++ b/httemplate/search/report_477.html
@@ -1,32 +1,23 @@
-<% include('/elements/header.html', 'FCC Form 477 Report' ) %>
+% if ( $conf->exists('old_fcc_report') ) {
+%   $m->clear_buffer;
+%   $m->print($cgi->redirect($fsurl . 'search/old477/report_477.html'));
+%   $m->abort;
+% }
+<& /elements/header.html, 'FCC Form 477 Report' &>
 
 <FORM ACTION="477.html" METHOD="GET">
-<INPUT TYPE="hidden" NAME="magic" VALUE="active">
 
   <TABLE BGCOLOR="#cccccc" CELLSPACING=0>
 
     <TR>
       <TH CLASS="background" COLSPAN=2 ALIGN="left">
-        <FONT SIZE="+1">Search options</FONT>
+        <FONT SIZE="+1">Report options</FONT>
       </TH>
     </TR>
 
-    <% include( '/elements/tr-select-agent.html',
-                   'curr_value'    => scalar( $cgi->param('agentnum') ),
-                   'disable_empty' => 0,
-               )
-    %>
-
-%   # not tr-select-state, we only want to choose from among those that 
-%   # have customers
-    <& /elements/tr-select-table.html,
-        'label'         => 'State',
-        'field'         => 'state',
-        'table'         => 'cust_location',
-        'name_col'      => 'state',
-        'value_col'     => 'state',
-        'disable_empty' => 1,
-        'records'       => \@states,
+    <& /elements/tr-select-agent.html,
+      'curr_value'    => scalar( $cgi->param('agentnum') ),
+      'disable_empty' => 0,
     &>
 
     <& /elements/tr-input-date-field.html, {
@@ -36,247 +27,31 @@
         'format'        => '%m/%d/%Y'
     } &>
 
-    <% include( '/elements/tr-select-pkg_class.html',
-                   'multiple'       => 1,
-                   'empty_label' => '(empty class)',
-               )
-    %>
-
-    <SCRIPT type="text/javascript">
-      function partchange(what) {
-        var id = 'part' + what.value;
-        var element = document.getElementById(id);
-        if (what.checked) {
-          element.style.display = '';
-        } else {
-          element.style.display = 'none';
-        }
-      }
-      function toggleV() {
-        document.getElementById('enableV').disabled =
-          ! (document.getElementById('enableIIA').checked ||
-             document.getElementById('enableIIB').checked);
-      }
-      function toggleVI() {
-        document.getElementById('enableVI').disabled =
-          ! document.getElementById('enableIA').checked;
-      }
-    </SCRIPT>
-
-    <% include( '/elements/tr-checkbox.html',
-                   'label' => 'Enable part IA?',
-                   'field' => 'part',
-                   'id'    => 'enableIA',
-                   'value' => 'IA',
-                   'onchange' => 'partchange(this); toggleVI();',
-               )
-    %>
-
-    <TR id='partIA' style="display:none"><TD>Part IA</TD><TD><TABLE>
-      <TR><TD>Download speeds</TD><TD>
-        <TABLE>
-%       my $i = 0;
-%       foreach my $speed ( @FS::Report::FCC_477::download ) {
-          <TR>
-            <TH><% $speed %></TH>
-            <TD>
-            <% include( '/elements/select-table.html',
-                           'table'        => 'part_pkg_report_option',
-                           'name_col'     => 'name',
-                           'hashref'      => { 'disabled' => '' },
-                           'element_name' => 'part1_column_option',
-                           'disable_empty' => 1,
-                           'curr_value'   =>
-                                FS::Report::FCC_477::restore_fcc477map("part1_column_option_$i"),
-                       )
-            %>
-            </TD>
-          </TR>
-%       $i++
-%       }
-        </TABLE></TD>
-      <TD>Upload speeds</TD><TD>
-        <TABLE>
-%       $i = 0;
-%       foreach my $speed ( @FS::Report::FCC_477::upload ) {
-          <TR>
-            <TH><% $speed %></TH>
-            <TD>
-            <% include( '/elements/select-table.html',
-                           'table'        => 'part_pkg_report_option',
-                           'name_col'     => 'name',
-                           'hashref'      => { 'disabled' => '' },
-                           'element_name' => 'part1_row_option',
-                           'disable_empty' => 1,
-                           'curr_value'   =>
-                                FS::Report::FCC_477::restore_fcc477map("part1_row_option_$i"),
-                       )
-            %>
-            </TD>
-          </TR>
-%       $i++
-%       }
-        </TABLE></TD></TR>
-      <TR><TD>Technologies</TD><TD>
-        <TABLE>
-%       $i = 0;
-%       foreach my $tech ( @FS::Report::FCC_477::technology ) {
-          <TR>
-            <TH><% $tech %></TH>
-            <TD>
-            <% include( '/elements/select-table.html',
-                           'table'        => 'part_pkg_report_option',
-                           'name_col'     => 'name',
-                           'hashref'      => { 'disabled' => '' },
-                           'element_name' => "part1_technology_option_$i",
-                           'empty_label'  => '(omit)',
-                           'curr_value'   =>
-                                FS::Report::FCC_477::restore_fcc477map("part1_technology_option_$i"),
-                       )
-            %>
-            </TD>
-          </TR>
-%       $i++
-%       }
-        </TABLE></TD></TR>
-    </TABLE></TD></TR>
-
-    <% include( '/elements/tr-checkbox.html',
-                   'label' => 'Enable part IIA?',
-                   'field' => 'part',
-                   'id'    => 'enableIIA',
-                   'value' => 'IIA',
-                   'onchange' => 'partchange(this); toggleV();',
-               )
-    %>
-
-    <TR id='partIIA' style="display:none"><TD>Part IIA</TD><TD><TABLE>
-%   $i = 0;
-%   foreach my $option ( @FS::Report::FCC_477::part2aoption ) {
-    <TR>
-      <TH><% $option %></TH>
-      <TD>
-      <% include( '/elements/select-table.html',
-                     'table'        => 'part_pkg_report_option',
-                     'name_col'     => 'name',
-                     'hashref'      => { 'disabled' => '' },
-                     'element_name' => 'part2a_row_option',
-                     'curr_value'   =>
-                           FS::Report::FCC_477::restore_fcc477map("part2a_row_option_$i"),
-                 )
-      %>
-      </TD>
-    </TR>
-%    $i++
-%   }
-  </TABLE></TD></TR>
-
-    <% include( '/elements/tr-checkbox.html',
-                   'label' => 'Enable part IIB?',
-                   'field' => 'part',
-                   'id'    => 'enableIIB',
-                   'value' => 'IIB',
-                   'onchange' => 'partchange(this); toggleV();',
-               )
-    %>
-
-    <TR id='partIIB' style="display:none"><TD>Part IIB</TD><TD><TABLE>
-%   $i = 0;
-%   foreach my $option ( @FS::Report::FCC_477::part2boption ) {
-    <TR>
-      <TH><% $option %></TH>
-      <TD>
-      <% include( '/elements/select-table.html',
-                     'table'        => 'part_pkg_report_option',
-                     'name_col'     => 'name',
-                     'hashref'      => { 'disabled' => '' },
-                     'element_name' => 'part2b_row_option',
-                      'curr_value'   =>
-                            FS::Report::FCC_477::restore_fcc477map("part2b_row_option_$i"),
-                 )
-      %>
-      </TD>
-    </TR>
-%    $i++
-%   }
-  </TABLE></TD></TR>
-
-    <% include( '/elements/tr-checkbox.html',
-                   'label' => 'Enable part IV?',
-                   'field' => 'part',
-                   'id'    => 'enableIV', #unused
-                   'value' => 'IV',
-                   'onchange' => 'partchange(this)',
-               )
-    %>
-
-    <TR id='partIV' style="display:none"><TD>Part IV</TD><TD><TABLE>
-    <% include( '/elements/tr-textarea.html',
-                   'label'        => 'Explanatory notes',
-                   'id'           => 'partIV',
-                   'field'         => 'notes',
-                   'rows'         => 15,
-                   'cols'         => 80,
-               )
-    %>
-  </TABLE></TD></TR>
-
-    <% include( '/elements/tr-checkbox.html',
-                   'label' => 'Enable part V?',
-                   'field' => 'part',
-                   'value' => 'V',
-                   'id'    => 'enableV',
-                   'onchange' => 'partchange(this)',
-                   'postfix'  => 
-                    ' <FONT SIZE="-1">(requires Part IIA or IIB)</FONT>',
-               )
-    %>
-    <TR id='partV' style="display:none">
-        <TD>Part V</TD>
-        <TD>
-            <% include( '/elements/select-table.html',
-                     'table'        => 'part_pkg_report_option',
-                     'name_col'     => 'name',
-                     'hashref'      => { 'disabled' => '' },
-                     'element_name' => 'part5_report_option',
-                     'curr_value'   =>
-                            FS::Report::FCC_477::restore_fcc477map("part5_report_option"),
-                 )
-            %>
-        </TD>
-    </TR>
-
-
-    <% include( '/elements/tr-checkbox.html',
-                   'label' => 'Enable part VI?',
-                   'field' => 'part',
-                   'id'    => 'enableVI',
-                   'value' => 'VI_census',
-                   'postfix'  =>
-                    ' <FONT SIZE="-1">(requires part IA)</FONT>',
-               )
-    %>
-  <SCRIPT TYPE="text/javascript">
-  toggleV();
-  toggleVI();
-  </SCRIPT>
+    <& /elements/tr-checkbox-multiple.html,
+      'label'   => 'Enable parts',
+      'field'   => 'parts',
+      'labels'  => {
+        6   => 'Part 6 (Fixed Broadband Subscription)',
+        #7   => 'Part 7 (Mobile Wireless Broadband Subscription),
+        #8   => 'Part 8 (Mobile Local Telephone Subscription),
+        9   => 'Part 9 (Local Exchange Telephone Subscription)',
+        10  => 'Part 10 (Interconnected VoIP Subscription)',
+        11  => 'Part 11 (Voice Telephone Subscription Detail)',
+      },
+      options => [ 6, 9, 10, 11 ],
+    &>
   </TABLE>
 
-<BR>
-<INPUT TYPE="submit" VALUE="Get Report">
+  <BR>
+  <INPUT TYPE="submit" VALUE="Get Report">
 
 </FORM>
 
-<% include('/elements/footer.html') %>
+<& /elements/footer.html &>
 <%init>
 
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('List packages');
 
-my @states = qsearch({
-  'table'   => 'cust_location',
-  'select'  => 'DISTINCT(state)',
-  'hashref' => { 'country' => 'US' }, # 477 report isn't relevant elsewhere
-});
-
+my $conf = FS::Conf->new;
 </%init>

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/Conf.pm                                      |   21 +-
 FS/FS/Mason.pm                                     |    2 +
 FS/FS/Report/FCC_477.pm                            |  221 ++++++++++++--
 FS/FS/Schema.pm                                    |   14 +
 FS/FS/Upgrade.pm                                   |   19 ++
 FS/FS/part_pkg.pm                                  |    3 +-
 FS/FS/state.pm                                     |  133 ++++++++
 FS/MANIFEST                                        |    4 +
 FS/MYMETA.json                                     |   39 +++
 FS/t/{L10N.t => state.t}                           |    2 +-
 bin/convert-477-options                            |   83 ++---
 httemplate/elements/tr-input-fcc_options.html      |   89 +++++-
 httemplate/misc/part_pkg_fcc_options.html          |   15 +-
 httemplate/search/477.html                         |  318 +++++++++++++-------
 httemplate/search/{ => old477}/477.html            |    0
 httemplate/search/{ => old477}/477partIA.html      |    4 -
 httemplate/search/{ => old477}/477partIIA.html     |    0
 httemplate/search/{ => old477}/477partIIB.html     |    0
 httemplate/search/{ => old477}/477partIV.html      |    0
 httemplate/search/{ => old477}/477partV.html       |    2 +-
 .../search/{ => old477}/477partVI_census.html      |    2 +-
 httemplate/search/{ => old477}/report_477.html     |    0
 httemplate/search/report_477.html                  |  279 ++---------------
 23 files changed, 791 insertions(+), 459 deletions(-)
 create mode 100644 FS/FS/state.pm
 create mode 100644 FS/MYMETA.json
 copy FS/t/{L10N.t => state.t} (87%)
 mode change 100755 => 100644 httemplate/search/477.html
 copy httemplate/search/{ => old477}/477.html (100%)
 rename httemplate/search/{ => old477}/477partIA.html (99%)
 rename httemplate/search/{ => old477}/477partIIA.html (100%)
 rename httemplate/search/{ => old477}/477partIIB.html (100%)
 rename httemplate/search/{ => old477}/477partIV.html (100%)
 rename httemplate/search/{ => old477}/477partV.html (98%)
 rename httemplate/search/{ => old477}/477partVI_census.html (99%)
 copy httemplate/search/{ => old477}/report_477.html (100%)




More information about the freeside-commits mailing list